@danypops/papyrus 0.54.2 → 0.54.4

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,107 @@
1
+ import type { Artifact } from "../artifact/artifact.ts";
2
+ import { TASK_PROJECT_LIST_MAX_RESULTS } from "../constants.ts";
3
+ import { assertRegisterProjectInputBounds } from "../domain/project-registry.ts";
4
+ import type { AppendTaskEvent, TaskEventContext } from "../domain/task-event.ts";
5
+ import {
6
+ normalizeProjectRoot,
7
+ type RegisterTaskProjectInput,
8
+ type TaskProject,
9
+ type TaskViewMode,
10
+ type TaskViewSelection,
11
+ taskScopeLabel,
12
+ } from "../domain/task-scope.ts";
13
+ import type { TaskEventStore } from "../stores/task-event-store.ts";
14
+ import type { TaskScopeStore } from "../stores/task-scope-store.ts";
15
+
16
+ export class TaskProjectNotFoundError extends Error {}
17
+ export class TaskProjectAmbiguousError extends Error {}
18
+
19
+ /**
20
+ * Task project-scope management (scopeSelection/setView/assignProject/projects/resolveProject/
21
+ * registerProject), split out of the Tasks god class as part of a SOLID-audit-driven
22
+ * decomposition (see task b51419a0 and the "TaskProjectScope" child of "Epic: Modularize
23
+ * papyrus/pi-papyrus god-files into building-block modules"), mirroring the existing
24
+ * TaskLeaseCoordinator/TaskMutationCoordinator/TaskFocusCoordinator precedent in this directory.
25
+ *
26
+ * list()/graph() (which stay on Tasks -- they're core query/graph-construction, not project-scope
27
+ * itself) still call scopeSelection() on this collaborator via Tasks' own thin delegation.
28
+ */
29
+ export class TaskProjectScope {
30
+ constructor(
31
+ private readonly scopes: TaskScopeStore,
32
+ private readonly events: TaskEventStore,
33
+ /** Delegates to Tasks.require() so project-scope methods get the identical not-found/wrong-kind checks every other Tasks method already enforces, without duplicating that logic here. */
34
+ private readonly requireTask: (id: string) => Artifact,
35
+ /** Delegates to Tasks' own actor/source/sessionId/reason defaulting so every event this collaborator appends looks identical to one Tasks itself would have appended. */
36
+ private readonly appendEvent: (event: Omit<AppendTaskEvent, "actor" | "source">, context: TaskEventContext) => void,
37
+ ) {}
38
+
39
+ scopeSelection(projectRoot?: string, mode?: TaskViewMode, rootTaskId?: string): TaskViewSelection {
40
+ if (mode !== undefined && mode !== "project" && mode !== "graph" && mode !== "all")
41
+ throw new Error("task scope must be project, graph, or all");
42
+ if (projectRoot === undefined) return { mode: "all", label: taskScopeLabel("all") };
43
+ const normalized = normalizeProjectRoot(projectRoot);
44
+ const persisted = this.scopes.view(normalized);
45
+ const selectedMode = mode ?? persisted.mode;
46
+ const selectedRoot = rootTaskId ?? (selectedMode === "graph" ? persisted.rootTaskId : undefined);
47
+ if (selectedMode === "graph" && !selectedRoot) throw new Error("graph scope requires root_task_id");
48
+ const root = selectedRoot ? this.requireTask(selectedRoot) : undefined;
49
+ if (root && this.scopes.get(root.id)?.projectRoot !== normalized) throw new Error(`task "${root.id}" is outside project scope`);
50
+ return {
51
+ mode: selectedMode,
52
+ label: taskScopeLabel(selectedMode, normalized, root?.title),
53
+ projectRoot: normalized,
54
+ ...(selectedRoot === undefined ? {} : { rootTaskId: selectedRoot }),
55
+ };
56
+ }
57
+
58
+ setView(projectRoot: string, mode: TaskViewMode, rootTaskId?: string): TaskViewSelection {
59
+ const selection = this.scopeSelection(projectRoot, mode, rootTaskId);
60
+ this.scopes.setView(selection.projectRoot!, selection.mode, selection.rootTaskId);
61
+ return selection;
62
+ }
63
+
64
+ assignProject(id: string, projectRoot: string, context: TaskEventContext = {}): Artifact {
65
+ return this.events.atomic(() => {
66
+ const task = this.requireTask(id);
67
+ this.scopes.assign(id, normalizeProjectRoot(projectRoot), "explicit");
68
+ this.appendEvent({ taskId: id, type: "project_assigned", reason: context.reason }, context);
69
+ return task;
70
+ });
71
+ }
72
+
73
+ projects(query?: string, limit = 20): TaskProject[] {
74
+ if (!Number.isInteger(limit) || limit < 1 || limit > TASK_PROJECT_LIST_MAX_RESULTS) {
75
+ throw new Error(`project list limit must be between 1 and ${TASK_PROJECT_LIST_MAX_RESULTS}`);
76
+ }
77
+ return this.scopes.projects(query, limit);
78
+ }
79
+
80
+ resolveProject(reference: string): TaskProject {
81
+ const matches = this.scopes.matchingProjects(reference);
82
+ if (matches.length === 0) {
83
+ const candidates = this.scopes.projects(reference, 10);
84
+ const fallback = candidates.length === 0 ? this.scopes.projects(undefined, 10) : candidates;
85
+ const suffix =
86
+ fallback.length === 0 ? "" : ` Candidates: ${fallback.map((project) => `${project.name} (${project.projectRoot})`).join(", ")}`;
87
+ throw new TaskProjectNotFoundError(`no task project named or aliased "${reference}" is registered.${suffix}`);
88
+ }
89
+ if (matches.length > 1) {
90
+ throw new TaskProjectAmbiguousError(
91
+ `task project reference "${reference}" is ambiguous: ${matches
92
+ .slice(0, 10)
93
+ .map((project) => `${project.name} (${project.projectRoot})`)
94
+ .join(", ")}`,
95
+ );
96
+ }
97
+ return matches[0]!;
98
+ }
99
+
100
+ registerProject(input: RegisterTaskProjectInput, existingReference?: string): TaskProject {
101
+ const projectRoot = normalizeProjectRoot(input.projectRoot);
102
+ const name = input.name?.trim();
103
+ assertRegisterProjectInputBounds(name, input.aliases);
104
+ const existingId = existingReference ? this.resolveProject(existingReference).id : input.existingId;
105
+ return this.scopes.registerProject({ projectRoot, ...(name ? { name } : {}), aliases: input.aliases, existingId });
106
+ }
107
+ }
@@ -9,17 +9,14 @@ import {
9
9
  TASK_EXECUTION_MAX_DEGREE,
10
10
  TASK_EXECUTION_MAX_EDGES,
11
11
  TASK_EXECUTION_MAX_NODES,
12
- TASK_FOCUS_STALE_AFTER_MS,
13
12
  TASK_LABEL_MAX_COUNT,
14
13
  TASK_LABEL_MAX_LENGTH,
15
- TASK_PROJECT_LIST_MAX_RESULTS,
16
14
  TASK_SCOPE_MAX_TASKS,
17
15
  TASK_TITLE_MAX_LENGTH,
18
16
  } from "../constants.ts";
19
17
  import { type Checklist, checklistEntries, type ProofReference, validateChecklist } from "../domain/checklist.ts";
20
18
  import { DISCUSSION_SUBTYPE, isDiscussionArtifact, readDiscussionExtra } from "../domain/discussion.ts";
21
19
  import { type Gate, type GateResult, validateGates } from "../domain/gate.ts";
22
- import { assertRegisterProjectInputBounds } from "../domain/project-registry.ts";
23
20
  import type {
24
21
  AppendTaskEvent,
25
22
  TaskEventContext,
@@ -57,16 +54,29 @@ import {
57
54
  type TaskMutationRequestStore,
58
55
  } from "../stores/task-mutation-request-store.ts";
59
56
  import { InMemoryTaskScopeStore, type TaskScopeStore } from "../stores/task-scope-store.ts";
60
- import { assertDependencyEdgeAllowed, TaskExecutionBoundExceededError } from "./task-execution.ts";
57
+ import { TaskEdges } from "./task-edges.ts";
58
+ import { TaskExecutionBoundExceededError } from "./task-execution.ts";
59
+ import { type TaskFocus, TaskFocusCoordinator, type TaskFocusMutationResult } from "./task-focus-coordinator.ts";
61
60
  import { TaskLeaseCoordinator } from "./task-lease-coordinator.ts";
61
+ import { TaskInvalidTransitionError } from "./task-lifecycle-errors.ts";
62
62
  import {
63
63
  TaskMutationCoordinator,
64
64
  TaskMutationReceiptNotFoundError,
65
65
  type TaskMutationReceiptView,
66
66
  type TaskMutationRequestContext,
67
67
  } from "./task-mutation-coordinator.ts";
68
+ import { TaskProjectAmbiguousError, TaskProjectNotFoundError, TaskProjectScope } from "./task-project-scope.ts";
68
69
 
69
- export { TaskMutationReceiptNotFoundError, type TaskMutationReceiptView, type TaskMutationRequestContext };
70
+ export {
71
+ type TaskFocus,
72
+ type TaskFocusMutationResult,
73
+ TaskInvalidTransitionError,
74
+ TaskMutationReceiptNotFoundError,
75
+ type TaskMutationReceiptView,
76
+ type TaskMutationRequestContext,
77
+ TaskProjectAmbiguousError,
78
+ TaskProjectNotFoundError,
79
+ };
70
80
 
71
81
  export interface UpdateTaskInput {
72
82
  title?: string;
@@ -90,21 +100,6 @@ export interface TaskFilter {
90
100
 
91
101
  export type TaskStatus = TaskLifecycleStatus;
92
102
 
93
- export class TaskProjectNotFoundError extends Error {}
94
- export class TaskProjectAmbiguousError extends Error {}
95
-
96
- export class TaskInvalidTransitionError extends Error {
97
- constructor(
98
- readonly operation: string,
99
- readonly currentStatus: string,
100
- readonly intendedStatus: string,
101
- readonly allowedActions: readonly string[],
102
- readonly recovery: string,
103
- ) {
104
- super(`cannot ${operation} task from ${currentStatus}; intended status is ${intendedStatus}`);
105
- }
106
- }
107
-
108
103
  export interface TaskMutationMetadata {
109
104
  changed: boolean;
110
105
  operation: string;
@@ -152,15 +147,6 @@ export interface ChecklistReview {
152
147
  reason?: string;
153
148
  }
154
149
 
155
- export interface TaskFocus {
156
- artifact: Artifact;
157
- status: TaskFocusStatus;
158
- updatedAt: string;
159
- pauseReason?: string;
160
- }
161
-
162
- export type TaskFocusMutationResult = TaskFocus & TaskMutationMetadata;
163
-
164
150
  export interface TaskCompletionOptions {
165
151
  focusSuccessor?: boolean;
166
152
  gateDeadlineMs?: number;
@@ -234,11 +220,39 @@ export class Tasks {
234
220
  ) {
235
221
  this.leaseCoordinator = new TaskLeaseCoordinator(this.leases, (id) => this.require(id));
236
222
  this.mutationCoordinator = new TaskMutationCoordinator(this.mutationRequests, this.artifacts);
223
+ this.focusCoordinator = new TaskFocusCoordinator(
224
+ this.artifacts,
225
+ this.focusStore,
226
+ this.events,
227
+ this.mutationCoordinator,
228
+ (id) => this.require(id),
229
+ (filter) => this.list(filter),
230
+ (event, context) => this.appendEvent(event, context),
231
+ );
232
+ this.projectScope = new TaskProjectScope(
233
+ this.scopes,
234
+ this.events,
235
+ (id) => this.require(id),
236
+ (event, context) => this.appendEvent(event, context),
237
+ );
238
+ this.taskEdges = new TaskEdges(
239
+ this.artifacts,
240
+ this.events,
241
+ (id) => this.require(id),
242
+ (id) => this.show(id),
243
+ (event, context) => this.appendEvent(event, context),
244
+ (id, dependencyId) => this.dependencyCheckGraph(id, dependencyId),
245
+ (id) => this.dependencyIds(id),
246
+ (id) => this.relationships(id),
247
+ );
237
248
  }
238
249
 
239
250
  private readonly completionFlights = new Map<string, Promise<TaskCompletion>>();
240
251
  private readonly leaseCoordinator: TaskLeaseCoordinator;
241
252
  private readonly mutationCoordinator: TaskMutationCoordinator;
253
+ private readonly focusCoordinator: TaskFocusCoordinator;
254
+ private readonly projectScope: TaskProjectScope;
255
+ private readonly taskEdges: TaskEdges;
242
256
 
243
257
  private require(id: string): Artifact {
244
258
  const artifact = this.artifacts.get(id);
@@ -375,7 +389,7 @@ export class Tasks {
375
389
  }
376
390
 
377
391
  list(filter: TaskFilter = {}): Artifact[] {
378
- const selection = this.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
392
+ const selection = this.projectScope.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
379
393
  const limit = filter.limit ?? TASK_SCOPE_MAX_TASKS;
380
394
  if (!Number.isInteger(limit) || limit < 1 || limit > TASK_SCOPE_MAX_TASKS + 1) {
381
395
  throw new Error(`task list limit must be between 1 and ${TASK_SCOPE_MAX_TASKS + 1}`);
@@ -415,41 +429,19 @@ export class Tasks {
415
429
  }
416
430
 
417
431
  scopeSelection(projectRoot?: string, mode?: TaskViewMode, rootTaskId?: string): TaskViewSelection {
418
- if (mode !== undefined && mode !== "project" && mode !== "graph" && mode !== "all")
419
- throw new Error("task scope must be project, graph, or all");
420
- if (projectRoot === undefined) return { mode: "all", label: taskScopeLabel("all") };
421
- const normalized = normalizeProjectRoot(projectRoot);
422
- const persisted = this.scopes.view(normalized);
423
- const selectedMode = mode ?? persisted.mode;
424
- const selectedRoot = rootTaskId ?? (selectedMode === "graph" ? persisted.rootTaskId : undefined);
425
- if (selectedMode === "graph" && !selectedRoot) throw new Error("graph scope requires root_task_id");
426
- const root = selectedRoot ? this.require(selectedRoot) : undefined;
427
- if (root && this.scopes.get(root.id)?.projectRoot !== normalized) throw new Error(`task "${root.id}" is outside project scope`);
428
- return {
429
- mode: selectedMode,
430
- label: taskScopeLabel(selectedMode, normalized, root?.title),
431
- projectRoot: normalized,
432
- ...(selectedRoot === undefined ? {} : { rootTaskId: selectedRoot }),
433
- };
432
+ return this.projectScope.scopeSelection(projectRoot, mode, rootTaskId);
434
433
  }
435
434
 
436
435
  setView(projectRoot: string, mode: TaskViewMode, rootTaskId?: string): TaskViewSelection {
437
- const selection = this.scopeSelection(projectRoot, mode, rootTaskId);
438
- this.scopes.setView(selection.projectRoot!, selection.mode, selection.rootTaskId);
439
- return selection;
436
+ return this.projectScope.setView(projectRoot, mode, rootTaskId);
440
437
  }
441
438
 
442
439
  assignProject(id: string, projectRoot: string, context: TaskEventContext = {}): Artifact {
443
- return this.events.atomic(() => {
444
- const task = this.require(id);
445
- this.scopes.assign(id, normalizeProjectRoot(projectRoot), "explicit");
446
- this.appendEvent({ taskId: id, type: "project_assigned", reason: context.reason }, context);
447
- return task;
448
- });
440
+ return this.projectScope.assignProject(id, projectRoot, context);
449
441
  }
450
442
 
451
443
  graph(filter: TaskFilter = {}): TaskGraph {
452
- const scope = this.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
444
+ const scope = this.projectScope.scopeSelection(filter.projectRoot, filter.scope, filter.rootTaskId);
453
445
  const requestedLimit = filter.limit ?? TASK_EXECUTION_MAX_NODES + 1;
454
446
  if (!Number.isInteger(requestedLimit) || requestedLimit < 1 || requestedLimit > TASK_EXECUTION_MAX_NODES + 1) {
455
447
  throw new Error(`task graph limit must be between 1 and ${TASK_EXECUTION_MAX_NODES + 1}`);
@@ -512,38 +504,15 @@ export class Tasks {
512
504
  }
513
505
 
514
506
  projects(query?: string, limit = 20): TaskProject[] {
515
- if (!Number.isInteger(limit) || limit < 1 || limit > TASK_PROJECT_LIST_MAX_RESULTS) {
516
- throw new Error(`project list limit must be between 1 and ${TASK_PROJECT_LIST_MAX_RESULTS}`);
517
- }
518
- return this.scopes.projects(query, limit);
507
+ return this.projectScope.projects(query, limit);
519
508
  }
520
509
 
521
510
  resolveProject(reference: string): TaskProject {
522
- const matches = this.scopes.matchingProjects(reference);
523
- if (matches.length === 0) {
524
- const candidates = this.scopes.projects(reference, 10);
525
- const fallback = candidates.length === 0 ? this.scopes.projects(undefined, 10) : candidates;
526
- const suffix =
527
- fallback.length === 0 ? "" : ` Candidates: ${fallback.map((project) => `${project.name} (${project.projectRoot})`).join(", ")}`;
528
- throw new TaskProjectNotFoundError(`no task project named or aliased "${reference}" is registered.${suffix}`);
529
- }
530
- if (matches.length > 1) {
531
- throw new TaskProjectAmbiguousError(
532
- `task project reference "${reference}" is ambiguous: ${matches
533
- .slice(0, 10)
534
- .map((project) => `${project.name} (${project.projectRoot})`)
535
- .join(", ")}`,
536
- );
537
- }
538
- return matches[0]!;
511
+ return this.projectScope.resolveProject(reference);
539
512
  }
540
513
 
541
514
  registerProject(input: RegisterTaskProjectInput, existingReference?: string): TaskProject {
542
- const projectRoot = normalizeProjectRoot(input.projectRoot);
543
- const name = input.name?.trim();
544
- assertRegisterProjectInputBounds(name, input.aliases);
545
- const existingId = existingReference ? this.resolveProject(existingReference).id : input.existingId;
546
- return this.scopes.registerProject({ projectRoot, ...(name ? { name } : {}), aliases: input.aliases, existingId });
515
+ return this.projectScope.registerProject(input, existingReference);
547
516
  }
548
517
 
549
518
  show(id: string): Artifact {
@@ -552,142 +521,32 @@ export class Tasks {
552
521
  }
553
522
 
554
523
  focused(filter?: TaskFilter): TaskFocus | null {
555
- const focus = this.focusStore.get(filter?.sessionId);
556
- if (!focus) return null;
557
- const task = this.artifacts.get(focus.taskId);
558
- if (task?.kind !== "task" || task.status === "done" || task.status === "canceled") {
559
- this.focusStore.clear(focus.taskId, filter?.sessionId);
560
- return null;
561
- }
562
- if (filter?.projectRoot && !this.list(filter).some((candidate) => candidate.id === task.id)) return null;
563
- return {
564
- artifact: task,
565
- status: focus.status,
566
- updatedAt: focus.updatedAt,
567
- ...(focus.pauseReason ? { pauseReason: focus.pauseReason } : {}),
568
- };
524
+ return this.focusCoordinator.focused(filter);
569
525
  }
570
526
 
571
527
  active(filter?: TaskFilter): Artifact | null {
572
- const focus = this.focused(filter);
573
- return focus?.status === "active" ? focus.artifact : null;
528
+ return this.focusCoordinator.active(filter);
574
529
  }
575
530
 
576
531
  focus(id: string, context: TaskEventContext = {}): Artifact {
577
- return this.events.atomic(() => {
578
- const task = this.require(id);
579
- if (task.status === "done" || task.status === "canceled") throw new Error(`cannot focus task from ${task.status}`);
580
- this.focusStore.set(id, context.sessionId);
581
- this.appendEvent({ taskId: id, type: "focus_set" }, context);
582
- return task;
583
- });
532
+ return this.focusCoordinator.focus(id, context);
584
533
  }
585
534
 
586
535
  pauseFocus(context: TaskEventContext = {}, request: TaskMutationRequestContext = {}): TaskFocusMutationResult {
587
- const inspection = this.prepareMutation<TaskFocusMutationResult>("pause", undefined, context, request, false);
588
- if (inspection.replay) return inspection.replay;
589
- const focus = this.focused({ sessionId: context.sessionId });
590
- if (!focus) {
591
- throw new TaskInvalidTransitionError(
592
- "pause",
593
- "none",
594
- "paused",
595
- ["focus"],
596
- "Focus a non-terminal task before pausing; do not blindly retry pause.",
597
- );
598
- }
599
- const prepared = inspection.pending
600
- ? inspection
601
- : this.prepareMutation<TaskFocusMutationResult>("pause", undefined, context, request, true, () => validateEventContext(context));
602
- if (focus.status === "paused") {
603
- return this.completeMutation(prepared.record, {
604
- ...focus,
605
- changed: false,
606
- operation: "pause",
607
- currentStatus: "paused",
608
- intendedStatus: "paused",
609
- ...(prepared.record ? { receiptId: prepared.record.receiptId } : {}),
610
- });
611
- }
612
- return this.events.atomic(() => {
613
- const state = this.focusStore.pause(focus.artifact.id, context.reason, context.sessionId);
614
- this.appendEvent({ taskId: focus.artifact.id, type: "focus_paused" }, context);
615
- return this.completeMutation(prepared.record, {
616
- artifact: focus.artifact,
617
- status: state.status,
618
- updatedAt: state.updatedAt,
619
- ...(state.pauseReason ? { pauseReason: state.pauseReason } : {}),
620
- changed: true,
621
- operation: "pause",
622
- currentStatus: "paused",
623
- intendedStatus: "paused",
624
- ...(prepared.record ? { receiptId: prepared.record.receiptId } : {}),
625
- });
626
- });
536
+ return this.focusCoordinator.pauseFocus(context, request);
627
537
  }
628
538
 
629
539
  unpauseFocus(context: TaskEventContext = {}, request: TaskMutationRequestContext = {}): TaskFocusMutationResult {
630
- const inspection = this.prepareMutation<TaskFocusMutationResult>("unpause", undefined, context, request, false);
631
- if (inspection.replay) return inspection.replay;
632
- const focus = this.focused({ sessionId: context.sessionId });
633
- if (!focus) {
634
- throw new TaskInvalidTransitionError(
635
- "unpause",
636
- "none",
637
- "active",
638
- ["focus"],
639
- "Focus a non-terminal task before resuming; do not blindly retry unpause.",
640
- );
641
- }
642
- const prepared = inspection.pending
643
- ? inspection
644
- : this.prepareMutation<TaskFocusMutationResult>("unpause", undefined, context, request, true, () => validateEventContext(context));
645
- if (focus.status === "active") {
646
- return this.completeMutation(prepared.record, {
647
- ...focus,
648
- changed: false,
649
- operation: "unpause",
650
- currentStatus: "active",
651
- intendedStatus: "active",
652
- ...(prepared.record ? { receiptId: prepared.record.receiptId } : {}),
653
- });
654
- }
655
- return this.events.atomic(() => {
656
- const state = this.focusStore.unpause(focus.artifact.id, context.sessionId);
657
- this.appendEvent({ taskId: focus.artifact.id, type: "focus_unpaused" }, context);
658
- return this.completeMutation(prepared.record, {
659
- artifact: focus.artifact,
660
- status: state.status,
661
- updatedAt: state.updatedAt,
662
- changed: true,
663
- operation: "unpause",
664
- currentStatus: "active",
665
- intendedStatus: "active",
666
- ...(prepared.record ? { receiptId: prepared.record.receiptId } : {}),
667
- });
668
- });
540
+ return this.focusCoordinator.unpauseFocus(context, request);
669
541
  }
670
542
 
671
543
  clearFocus(context: TaskEventContext = {}): { cleared: boolean } {
672
- return this.events.atomic(() => {
673
- const focus = this.focusStore.get(context.sessionId);
674
- if (focus) this.appendEvent({ taskId: focus.taskId, type: "focus_cleared" }, context);
675
- this.focusStore.clear(undefined, context.sessionId);
676
- return { cleared: focus !== undefined };
677
- });
544
+ return this.focusCoordinator.clearFocus(context);
678
545
  }
679
546
 
680
- /**
681
- * Time-based reclamation of Focus scopes nobody has touched in TASK_FOCUS_STALE_AFTER_MS,
682
- * independent of and in addition to the TASK_FOCUS_MAX_SCOPES hard cap -- see
683
- * clean-up-stale-per-session-task-focus-rows-on-real-session-l-9i7s and constants.ts's
684
- * comment on why this is deliberately not driven by session_start/session_shutdown.
685
- * No task-lifecycle event is appended: this is daemon housekeeping, not a caller-driven
686
- * mutation, and there is no longer a specific session/actor to attribute it to.
687
- */
688
- reapStaleFocus(now: () => string = () => new Date().toISOString()): number {
689
- const cutoff = new Date(new Date(now()).getTime() - TASK_FOCUS_STALE_AFTER_MS).toISOString();
690
- return this.focusStore.reapStale(cutoff);
547
+ /** Delegates to TaskFocusCoordinator -- see its own doc comment on TASK_FOCUS_STALE_AFTER_MS/TASK_FOCUS_MAX_SCOPES. */
548
+ reapStaleFocus(now?: () => string): number {
549
+ return this.focusCoordinator.reapStaleFocus(now);
691
550
  }
692
551
 
693
552
  /** A lease is orthogonal to lifecycle and Focus: claiming a task does not start it, and does not require it to be Focused. */
@@ -974,24 +833,7 @@ export class Tasks {
974
833
  }
975
834
 
976
835
  depend(id: string, dependencyId: string, context: TaskEventContext = {}): Artifact {
977
- return this.events.atomic(() => {
978
- this.require(id);
979
- this.require(dependencyId);
980
- const graph = this.dependencyCheckGraph(id, dependencyId);
981
- assertDependencyEdgeAllowed(graph, id, dependencyId);
982
- const node = graph.nodes.find((entry) => entry.task.id === id)!;
983
- if (node.dependencyIds.includes(dependencyId)) return this.show(id);
984
- if (node.dependencyIds.length >= TASK_EXECUTION_MAX_DEGREE) {
985
- throw new TaskExecutionBoundExceededError(`task "${id}" cannot exceed ${TASK_EXECUTION_MAX_DEGREE} prerequisites`);
986
- }
987
- const successorCount = graph.nodes.filter((entry) => entry.dependencyIds.includes(dependencyId)).length;
988
- if (successorCount >= TASK_EXECUTION_MAX_DEGREE) {
989
- throw new TaskExecutionBoundExceededError(`task "${dependencyId}" cannot exceed ${TASK_EXECUTION_MAX_DEGREE} successors`);
990
- }
991
- this.artifacts.link({ from: id, relation: "depends_on", to: dependencyId }, context);
992
- this.appendEvent({ taskId: id, type: "dependency_added", reason: context.reason }, context);
993
- return this.show(id);
994
- });
836
+ return this.taskEdges.depend(id, dependencyId, context);
995
837
  }
996
838
 
997
839
  /**
@@ -1025,45 +867,16 @@ export class Tasks {
1025
867
 
1026
868
  /** Idempotent: undepending an already-absent dependency is a no-op. Never starts, completes, or focuses work — only removes the edge. */
1027
869
  undepend(id: string, dependencyId: string, context: TaskEventContext = {}): Artifact {
1028
- return this.events.atomic(() => {
1029
- const task = this.require(id);
1030
- const dependency = this.require(dependencyId);
1031
- const removed = this.artifacts.unlink({ from: id, relation: "depends_on", to: dependencyId }, context);
1032
- if (removed) this.appendEvent({ taskId: id, type: "dependency_removed", reason: context.reason }, context);
1033
- // Only meaningful if the removed edge was itself unmet -- removing an already-satisfied
1034
- // dependency, or removing one from a task that was already unblocked, changes nothing.
1035
- if (removed && task.status === "todo" && dependency.status !== "done") {
1036
- const stillBlocking = this.dependencyIds(id).filter((remainingId) => this.require(remainingId).status !== "done");
1037
- if (stillBlocking.length === 0) this.appendEvent({ taskId: id, type: "became_ready" }, context);
1038
- }
1039
- return this.show(id);
1040
- });
870
+ return this.taskEdges.undepend(id, dependencyId, context);
1041
871
  }
1042
872
 
1043
873
  contain(parentId: string, childId: string, context: TaskEventContext = {}): Artifact {
1044
- return this.events.atomic(() => {
1045
- this.require(parentId);
1046
- this.require(childId);
1047
- const alreadyContained = this.relationships(parentId).some(
1048
- (edge) => edge.relation === "contains" && edge.from === parentId && edge.to === childId,
1049
- );
1050
- this.artifacts.link({ from: parentId, relation: "contains", to: childId }, context);
1051
- this.artifacts.link({ from: childId, relation: "part_of", to: parentId }, context);
1052
- if (!alreadyContained) this.appendEvent({ taskId: parentId, type: "containment_added", reason: context.reason }, context);
1053
- return this.show(parentId);
1054
- });
874
+ return this.taskEdges.contain(parentId, childId, context);
1055
875
  }
1056
876
 
1057
877
  /** Idempotent: removing an already-absent containment is a no-op. Both contains/part_of edges are removed atomically. */
1058
878
  uncontain(parentId: string, childId: string, context: TaskEventContext = {}): Artifact {
1059
- return this.events.atomic(() => {
1060
- this.require(parentId);
1061
- this.require(childId);
1062
- const removedContains = this.artifacts.unlink({ from: parentId, relation: "contains", to: childId }, context);
1063
- this.artifacts.unlink({ from: childId, relation: "part_of", to: parentId }, context);
1064
- if (removedContains) this.appendEvent({ taskId: parentId, type: "containment_removed", reason: context.reason }, context);
1065
- return this.show(parentId);
1066
- });
879
+ return this.taskEdges.uncontain(parentId, childId, context);
1067
880
  }
1068
881
 
1069
882
  private descendantIds(rootTaskId: string, projectTaskIds: string[]): Set<string> {