@danypops/papyrus 0.55.0 → 0.57.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.
Files changed (110) hide show
  1. package/package.json +14 -1
  2. package/src/artifact/artifact-relationship-view.ts +1 -1
  3. package/src/artifact/artifact-scope-store.ts +32 -26
  4. package/src/artifact/in-memory-artifact-scope-store.ts +82 -43
  5. package/src/artifact/index.ts +3 -0
  6. package/src/artifact/sqlite-artifact-scope-store.ts +105 -56
  7. package/src/cli/docs-command.ts +71 -0
  8. package/src/cli/gates-command.ts +1 -1
  9. package/src/cli/note-command.ts +1 -1
  10. package/src/cli/playbooks-command.ts +71 -0
  11. package/src/cli/rules-command.ts +71 -0
  12. package/src/cli/scope-groups-command.ts +197 -0
  13. package/src/cli/task-command.ts +1 -1
  14. package/src/cli.ts +7 -0
  15. package/src/constants.ts +10 -2
  16. package/src/db.ts +117 -7
  17. package/src/{stores → discussion}/discussion-round-store.ts +1 -1
  18. package/src/discussion/discussion-service.ts +2 -2
  19. package/src/discussion/index.ts +1 -0
  20. package/src/{stores → discussion}/sqlite-discussion-round-store.ts +2 -2
  21. package/src/display-graph/graph-renderer.ts +5 -0
  22. package/src/docs/docs-service.ts +55 -20
  23. package/src/domain-service-shared.ts +90 -2
  24. package/src/{domain → gate}/gate-execution.ts +1 -1
  25. package/src/{stores → gate}/gate-runner.ts +1 -1
  26. package/src/{stores → gate}/sqlite-gate-runner.ts +3 -3
  27. package/src/graph-projection/graph-projection-service.ts +2 -2
  28. package/src/{stores → graph-projection}/graph-projection-store.ts +1 -1
  29. package/src/{stores → graph-projection}/sqlite-graph-projection-store.ts +2 -2
  30. package/src/handlers/discuss.ts +1 -1
  31. package/src/handlers/docs.ts +57 -2
  32. package/src/handlers/playbooks.ts +49 -8
  33. package/src/handlers/projects.ts +1 -1
  34. package/src/handlers/registry.ts +10 -5
  35. package/src/handlers/rules.ts +66 -2
  36. package/src/handlers/scope-groups.ts +100 -0
  37. package/src/handlers/task-classifiers.ts +1 -1
  38. package/src/handlers/tasks.ts +4 -4
  39. package/src/index.ts +9 -9
  40. package/src/log/log-service.ts +2 -2
  41. package/src/{stores → log}/log-store.ts +1 -1
  42. package/src/{stores → log}/sqlite-log-store.ts +2 -2
  43. package/src/modules/docs.ts +21 -1
  44. package/src/modules/graph-projection.ts +2 -2
  45. package/src/modules/logs.ts +1 -1
  46. package/src/modules/notes.ts +1 -1
  47. package/src/modules/playbooks.ts +24 -3
  48. package/src/modules/projects.ts +3 -3
  49. package/src/modules/rules.ts +21 -1
  50. package/src/modules/scope-groups.ts +92 -0
  51. package/src/modules/tasks.ts +3 -3
  52. package/src/note/index.ts +1 -0
  53. package/src/{stores → note}/note-event-store.ts +1 -1
  54. package/src/note/note-service.ts +2 -2
  55. package/src/{stores → note}/sqlite-note-event-store.ts +2 -2
  56. package/src/ops.ts +1 -1
  57. package/src/playbook/index.ts +2 -0
  58. package/src/playbook/playbook-definition.ts +2 -2
  59. package/src/playbook/playbook-execution.ts +1 -1
  60. package/src/playbook/playbook-service.ts +61 -26
  61. package/src/playbook/workflow-execution.ts +8 -8
  62. package/src/{stores → project-registry}/in-memory-project-registry-store.ts +2 -2
  63. package/src/{ports → project-registry}/project-registry-store.ts +1 -1
  64. package/src/{stores → project-registry}/sqlite-project-registry-store.ts +3 -3
  65. package/src/rules/rules-service.ts +56 -24
  66. package/src/scope-group/delete-scope-group.ts +17 -0
  67. package/src/scope-group/in-memory-scope-group-store.ts +173 -0
  68. package/src/scope-group/scope-group-store.ts +28 -0
  69. package/src/scope-group/scope-group.ts +79 -0
  70. package/src/scope-group/sqlite-scope-group-store.ts +223 -0
  71. package/src/service.ts +46 -21
  72. package/src/session-identity/session-identity-service.ts +2 -2
  73. package/src/{stores → session-identity}/sqlite-session-identity-store.ts +1 -1
  74. package/src/task/index.ts +13 -0
  75. package/src/task/task-context.ts +1 -1
  76. package/src/task/task-edges.ts +2 -2
  77. package/src/task/task-focus-coordinator.ts +3 -3
  78. package/src/task/task-graph-view.ts +1 -1
  79. package/src/task/task-lease-coordinator.ts +2 -2
  80. package/src/task/task-mutation-coordinator.ts +1 -1
  81. package/src/task/task-project-scope.ts +5 -5
  82. package/src/task/task-relationship-view.ts +1 -1
  83. package/src/task/task-service.ts +24 -24
  84. package/src/{stores → task-create-request}/sqlite-task-create-request-store.ts +1 -1
  85. package/src/{stores → task-event}/sqlite-task-event-store.ts +2 -2
  86. package/src/{stores → task-event}/task-event-store.ts +1 -1
  87. package/src/{stores → task-focus}/sqlite-task-focus-store.ts +1 -1
  88. package/src/{stores → task-lease}/sqlite-task-lease-store.ts +2 -2
  89. package/src/{stores → task-lease}/task-lease-store.ts +1 -1
  90. package/src/{stores → task-mutation-request}/sqlite-task-mutation-request-store.ts +5 -1
  91. package/src/{stores → task-scope}/sqlite-task-scope-store.ts +3 -3
  92. package/src/{stores → task-scope}/task-scope-store.ts +3 -3
  93. package/src/{domain → task-scope}/task-scope.ts +1 -1
  94. package/src/stores/graph-renderer.ts +0 -5
  95. /package/src/{domain → discussion}/discussion.ts +0 -0
  96. /package/src/{domain → display-graph}/display-graph.ts +0 -0
  97. /package/src/{domain → gate}/gate.ts +0 -0
  98. /package/src/{domain → graph-projection}/graph-projection.ts +0 -0
  99. /package/src/{domain → log}/log-entry.ts +0 -0
  100. /package/src/{domain → note}/note-event.ts +0 -0
  101. /package/src/{domain → playbook}/blueprint-definition.ts +0 -0
  102. /package/src/{domain → project-registry}/project-registry.ts +0 -0
  103. /package/src/{stores → session-identity}/session-identity-store.ts +0 -0
  104. /package/src/{domain → session-identity}/session-identity.ts +0 -0
  105. /package/src/{domain → task}/checklist.ts +0 -0
  106. /package/src/{stores → task-create-request}/task-create-request-store.ts +0 -0
  107. /package/src/{domain → task-event}/task-event.ts +0 -0
  108. /package/src/{stores → task-focus}/task-focus-store.ts +0 -0
  109. /package/src/{domain → task-lease}/task-lease.ts +0 -0
  110. /package/src/{stores → task-mutation-request}/task-mutation-request-store.ts +0 -0
package/package.json CHANGED
@@ -1,10 +1,23 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.55.0",
3
+ "version": "0.57.0",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./artifact": "./src/artifact/index.ts",
11
+ "./client": "./src/client.ts",
12
+ "./discussion": "./src/discussion/index.ts",
13
+ "./domain": "./src/domain/index.ts",
14
+ "./note": "./src/note/index.ts",
15
+ "./playbook": "./src/playbook/index.ts",
16
+ "./stores": "./src/stores/index.ts",
17
+ "./task": "./src/task/index.ts",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "sideEffects": false,
8
21
  "bin": {
9
22
  "papyrus": "src/cli.ts"
10
23
  },
@@ -1,4 +1,4 @@
1
- import type { DisplayGraph, DisplayGraphEdge, DisplayGraphNode } from "../domain/display-graph.ts";
1
+ import type { DisplayGraph, DisplayGraphEdge, DisplayGraphNode } from "../display-graph/display-graph.ts";
2
2
  import { fallbackLabel } from "../task/task-relationship-view.ts";
3
3
  import type { Artifact } from "./artifact.ts";
4
4
 
@@ -1,22 +1,24 @@
1
- import type { TaskScopeSource } from "../domain/task-scope.ts";
1
+ import type { ScopeMemberRef } from "../scope-group/scope-group.ts";
2
+ import type { TaskScopeSource } from "../task-scope/task-scope.ts";
2
3
 
3
4
  /**
4
- * Project scoping for Docs/Rules/Playbooks: an artifact is either explicitly global (applies
5
- * everywhere) or bound to a bounded, non-empty set of registered project ids -- never inferred
6
- * from an accidentally empty join table, which is why `mode` is its own explicit field rather
7
- * than "projectIds.length === 0 means global". Membership is by project id (from the shared
8
- * ProjectRegistryStore) internally, so a registered project's root can move without a
9
- * best-effort string rewrite across every artifact that references it -- assign/get/ids keep
10
- * taking/returning a root for compatibility with every existing caller, resolved to/from a
11
- * project id under the hood.
5
+ * Project scoping for Docs/Rules/Playbooks: an artifact is explicitly "none" (hidden -- never
6
+ * applicable, never context-injected, regardless of project), explicitly "all" (applies
7
+ * everywhere), or bound to a bounded, non-empty set of explicit members -- never inferred from
8
+ * an accidentally empty join table, which is why `mode` is its own explicit field rather than
9
+ * "members.length === 0 means none/all". A member is either a registered project (from the
10
+ * shared ProjectRegistryStore) or a scope group (from ScopeGroupStore, itself a possibly-nested
11
+ * collection of projects/groups) -- "explicit scope can include nested scopes". Membership is by
12
+ * id, never by root/name, so a registered project's root (or a group's name) can move without a
13
+ * best-effort string rewrite across every artifact that references it.
12
14
  */
13
- export type ArtifactScopeMode = "global" | "projects";
15
+ export type ArtifactScopeMode = "none" | "all" | "explicit";
14
16
 
15
17
  export interface ArtifactScope {
16
18
  artifactId: string;
17
19
  mode: ArtifactScopeMode;
18
- /** Registered project ids this artifact applies to. Always empty when mode is "global"; always non-empty when mode is "projects". */
19
- projectIds: string[];
20
+ /** Direct membership only (not expanded through nested groups) -- always empty when mode is "none"/"all", always non-empty when mode is "explicit". */
21
+ members: ScopeMemberRef[];
20
22
  source: TaskScopeSource;
21
23
  }
22
24
 
@@ -27,24 +29,28 @@ export interface LegacyArtifactScope {
27
29
  }
28
30
 
29
31
  export interface ArtifactScopeStore {
30
- /** The real, non-lossy multi-membership view. Defaults to global/unscoped for an artifact with no scope row yet. */
32
+ /** The real, non-lossy multi-membership view. Defaults to "all"/unscoped for an artifact with no scope row yet. */
31
33
  scope(artifactId: string): ArtifactScope;
32
- /** Single-root compatibility view over scope(): a global or unscoped artifact omits projectRoot; a "projects" mode artifact with exactly one membership resolves it back to that project's current root; more than one membership (only reachable through the new multi-project primitives below) omits projectRoot, since this shape cannot represent more than one. */
34
+ /** Single-root compatibility view over scope(): an "all"/"none" or unscoped artifact omits projectRoot; an "explicit" mode artifact with exactly one project-type membership (and no group members) resolves it back to that project's current root; anything else (zero, more than one, or any group membership) omits projectRoot, since this shape cannot represent it. */
33
35
  get(artifactId: string): LegacyArtifactScope | undefined;
34
- /** Single-root compatibility shim: registers/resolves projectRoot and replaces the artifact's scope with exactly that one membership, or setGlobal() when projectRoot is undefined. Every existing caller (rules/docs/playbooks assign_project) keeps working unchanged. */
36
+ /** Single-root compatibility shim: registers/resolves projectRoot and replaces the artifact's scope with exactly that one project membership, or setAll() when projectRoot is undefined. Every existing caller (rules/docs/playbooks assign_project) keeps working unchanged. */
35
37
  assign(artifactId: string, projectRoot: string | undefined, source: TaskScopeSource): LegacyArtifactScope;
36
- /** Sets an artifact to explicitly global, clearing any project membership. */
37
- setGlobal(artifactId: string, source: TaskScopeSource): ArtifactScope;
38
- /** Replaces an artifact's entire project membership set with exactly these (registered) project ids -- must be non-empty; use setGlobal to clear scoping entirely. */
39
- replaceProjects(artifactId: string, projectIds: readonly string[], source: TaskScopeSource): ArtifactScope;
40
- /** Adds one project to an artifact's membership (idempotent -- adding an already-present id is a no-op), switching mode to "projects" if it was global. Enforces the bounded maximum membership count. */
41
- addProject(artifactId: string, projectId: string, source: TaskScopeSource): ArtifactScope;
42
- /** Removes one project from an artifact's membership (idempotent -- removing an absent id is a no-op). Rejects removing the last membership while mode is "projects": a caller must explicitly call setGlobal instead of accidentally broadening scope by emptying the set. */
43
- removeProject(artifactId: string, projectId: string): ArtifactScope;
44
- /** Bounded id listing for one project root (or the global/unscoped bucket when projectRoot is undefined) -- an unregistered root always yields an empty list, since nothing can be scoped to a project that was never registered. */
38
+ /** Sets an artifact to explicitly "all" (applies everywhere), clearing any membership. */
39
+ setAll(artifactId: string, source: TaskScopeSource): ArtifactScope;
40
+ /** Sets an artifact to explicitly "none" (fully hidden -- never applicable, never context-injected, regardless of project), clearing any membership. */
41
+ setNone(artifactId: string, source: TaskScopeSource): ArtifactScope;
42
+ /** Replaces an artifact's entire explicit membership set with exactly these (project and/or group) members -- must be non-empty; use setAll/setNone to clear scoping entirely. */
43
+ replaceMembers(artifactId: string, members: readonly ScopeMemberRef[], source: TaskScopeSource): ArtifactScope;
44
+ /** Adds one member to an artifact's explicit membership (idempotent), switching mode to "explicit" if it was "all"/"none". Enforces the bounded maximum membership count. */
45
+ addMember(artifactId: string, member: ScopeMemberRef, source: TaskScopeSource): ArtifactScope;
46
+ /** Removes one member from an artifact's explicit membership (idempotent). Rejects removing the last membership while mode is "explicit": a caller must explicitly call setAll/setNone instead of accidentally broadening/narrowing scope by emptying the set. */
47
+ removeMember(artifactId: string, member: ScopeMemberRef): ArtifactScope;
48
+ /** Bounded id listing for one project root's EXACT direct membership (or the "all" bucket when projectRoot is undefined) -- an unregistered root always yields an empty list. Deliberately does not expand nested groups (audit semantics, matching this method's own pre-existing "exact membership" contract) -- use appliesToProjectRoot for the applicable/injection query instead. */
45
49
  ids(projectRoot: string | undefined, limit: number): string[];
46
- /** True when artifactId's scope includes projectId (mode "projects" and a member), or is "global" (applies everywhere). False for an unscoped artifact with no row -- matching a Rule/Doc/Playbook's default of "applies everywhere" being represented by the same "global" default scope() already returns, but injection call sites decide their own applicability policy; this is the raw membership fact only. */
50
+ /** True when artifactId's scope is "all", or "explicit" with projectId reachable from its member set (a direct project member, or transitively through a nested group member) -- false for "none". False for an unscoped artifact with no row is never returned (defaults to "all" behavior); injection call sites decide their own applicability policy beyond this raw membership fact. */
47
51
  appliesToProject(artifactId: string, projectId: string): boolean;
48
- /** Root-based convenience over appliesToProject, for a caller (e.g. rules.injectable) that only has a project root, not a resolved id -- resolves the same way ids() does. An unregistered root (no project was ever registered for it) means only a global-mode artifact applies; projectRoot === undefined means "no project context at all", so only global-mode artifacts apply either way. */
52
+ /** Root-based convenience over appliesToProject, for a caller (e.g. rules.injectable) that only has a project root, not a resolved id. An unregistered root means only an "all"-mode artifact applies; projectRoot === undefined means "no project context at all", so only "all"-mode artifacts apply either way. */
49
53
  appliesToProjectRoot(artifactId: string, projectRoot: string | undefined): boolean;
54
+ /** True when any artifact's own explicit scope references this scope group as a direct member -- checked by deleteScopeGroup before allowing a group to be deleted. */
55
+ referencesGroup(groupId: string): boolean;
50
56
  }
@@ -1,30 +1,35 @@
1
- import { ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT } from "../constants.ts";
2
- import type { TaskScopeSource } from "../domain/task-scope.ts";
3
- import type { ProjectRegistryStore } from "../ports/project-registry-store.ts";
4
- import { InMemoryProjectRegistryStore } from "../stores/in-memory-project-registry-store.ts";
5
- import type { ArtifactScope, ArtifactScopeStore, LegacyArtifactScope } from "./artifact-scope-store.ts";
1
+ import { ARTIFACT_SCOPE_MAX_MEMBERS_PER_ARTIFACT } from "../constants.ts";
2
+ import { InMemoryProjectRegistryStore } from "../project-registry/in-memory-project-registry-store.ts";
3
+ import type { ProjectRegistryStore } from "../project-registry/project-registry-store.ts";
4
+ import { InMemoryScopeGroupStore } from "../scope-group/in-memory-scope-group-store.ts";
5
+ import { type ScopeMemberRef, sameScopeMember } from "../scope-group/scope-group.ts";
6
+ import type { ScopeGroupStore } from "../scope-group/scope-group-store.ts";
7
+ import type { TaskScopeSource } from "../task-scope/task-scope.ts";
8
+ import type { ArtifactScope, ArtifactScopeMode, ArtifactScopeStore, LegacyArtifactScope } from "./artifact-scope-store.ts";
6
9
 
7
10
  interface Row {
8
- mode: "global" | "projects";
9
- projectIds: Set<string>;
11
+ mode: ArtifactScopeMode;
12
+ members: ScopeMemberRef[];
10
13
  source: TaskScopeSource;
11
14
  }
12
15
 
13
16
  export class InMemoryArtifactScopeStore implements ArtifactScopeStore {
14
17
  private readonly rows = new Map<string, Row>();
15
18
  private readonly registry: InMemoryProjectRegistryStore;
19
+ private readonly scopeGroups: ScopeGroupStore;
16
20
 
17
- // Membership is stored by project id, never by root, so a registry root move (see
18
- // ProjectRegistryStore.registerProject) needs no rewrite here at all -- unlike
19
- // InMemoryTaskScopeStore, this store never subscribes to root-move notifications.
20
- constructor(registry?: ProjectRegistryStore) {
21
+ // Membership is stored by project/group id, never by root/name, so a registry root move (see
22
+ // ProjectRegistryStore.registerProject) or a scope group rename needs no rewrite here at all --
23
+ // unlike InMemoryTaskScopeStore, this store never subscribes to root-move notifications.
24
+ constructor(registry?: ProjectRegistryStore, scopeGroups?: ScopeGroupStore) {
21
25
  this.registry = registry instanceof InMemoryProjectRegistryStore ? registry : new InMemoryProjectRegistryStore();
26
+ this.scopeGroups = scopeGroups ?? new InMemoryScopeGroupStore();
22
27
  }
23
28
 
24
29
  private toScope(artifactId: string, row: Row | undefined): ArtifactScope {
25
30
  return row
26
- ? { artifactId, mode: row.mode, projectIds: [...row.projectIds], source: row.source }
27
- : { artifactId, mode: "global", projectIds: [], source: "unscoped" };
31
+ ? { artifactId, mode: row.mode, members: [...row.members], source: row.source }
32
+ : { artifactId, mode: "all", members: [], source: "unscoped" };
28
33
  }
29
34
 
30
35
  scope(artifactId: string): ArtifactScope {
@@ -34,58 +39,67 @@ export class InMemoryArtifactScopeStore implements ArtifactScopeStore {
34
39
  get(artifactId: string): LegacyArtifactScope | undefined {
35
40
  const row = this.rows.get(artifactId);
36
41
  if (!row) return undefined;
37
- const onlyProjectId = row.mode === "projects" && row.projectIds.size === 1 ? [...row.projectIds][0] : undefined;
38
- const projectRoot = onlyProjectId === undefined ? undefined : this.registry.byId(onlyProjectId)?.projectRoot;
42
+ const onlyProjectMember =
43
+ row.mode === "explicit" && row.members.length === 1 && row.members[0]!.type === "project" ? row.members[0] : undefined;
44
+ const projectRoot = onlyProjectMember === undefined ? undefined : this.registry.byId(onlyProjectMember.id)?.projectRoot;
39
45
  return { artifactId, ...(projectRoot === undefined ? {} : { projectRoot }), source: row.source };
40
46
  }
41
47
 
42
48
  assign(artifactId: string, projectRoot: string | undefined, source: TaskScopeSource): LegacyArtifactScope {
43
49
  if (projectRoot === undefined) {
44
- this.setGlobal(artifactId, source);
50
+ this.setAll(artifactId, source);
45
51
  return { artifactId, source };
46
52
  }
47
53
  const project = this.registry.registerProject({ projectRoot });
48
- this.replaceProjects(artifactId, [project.id], source);
54
+ this.replaceMembers(artifactId, [{ type: "project", id: project.id }], source);
49
55
  return { artifactId, projectRoot: project.projectRoot, source };
50
56
  }
51
57
 
52
- setGlobal(artifactId: string, source: TaskScopeSource): ArtifactScope {
53
- const row: Row = { mode: "global", projectIds: new Set(), source };
58
+ setAll(artifactId: string, source: TaskScopeSource): ArtifactScope {
59
+ const row: Row = { mode: "all", members: [], source };
54
60
  this.rows.set(artifactId, row);
55
61
  return this.toScope(artifactId, row);
56
62
  }
57
63
 
58
- replaceProjects(artifactId: string, projectIds: readonly string[], source: TaskScopeSource): ArtifactScope {
59
- if (projectIds.length === 0) throw new Error("replaceProjects requires at least one project id; use setGlobal to clear scoping");
60
- if (projectIds.length > ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
61
- throw new Error(`an artifact cannot belong to more than ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} projects`);
64
+ setNone(artifactId: string, source: TaskScopeSource): ArtifactScope {
65
+ const row: Row = { mode: "none", members: [], source };
66
+ this.rows.set(artifactId, row);
67
+ return this.toScope(artifactId, row);
68
+ }
69
+
70
+ replaceMembers(artifactId: string, members: readonly ScopeMemberRef[], source: TaskScopeSource): ArtifactScope {
71
+ if (members.length === 0) throw new Error("replaceMembers requires at least one member; use setAll/setNone to clear scoping");
72
+ if (members.length > ARTIFACT_SCOPE_MAX_MEMBERS_PER_ARTIFACT) {
73
+ throw new Error(`an artifact cannot have more than ${ARTIFACT_SCOPE_MAX_MEMBERS_PER_ARTIFACT} scope members`);
62
74
  }
63
- const row: Row = { mode: "projects", projectIds: new Set(projectIds), source };
75
+ const unique = dedupeMembers(members);
76
+ const row: Row = { mode: "explicit", members: unique, source };
64
77
  this.rows.set(artifactId, row);
65
78
  return this.toScope(artifactId, row);
66
79
  }
67
80
 
68
- addProject(artifactId: string, projectId: string, source: TaskScopeSource): ArtifactScope {
81
+ addMember(artifactId: string, member: ScopeMemberRef, source: TaskScopeSource): ArtifactScope {
69
82
  const existing = this.rows.get(artifactId);
70
- const projectIds = new Set(existing?.mode === "projects" ? existing.projectIds : []);
71
- if (!projectIds.has(projectId) && projectIds.size >= ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
72
- throw new Error(`an artifact cannot belong to more than ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} projects`);
83
+ const members = dedupeMembers(existing?.mode === "explicit" ? existing.members : []);
84
+ if (!members.some((candidate) => sameScopeMember(candidate, member)) && members.length >= ARTIFACT_SCOPE_MAX_MEMBERS_PER_ARTIFACT) {
85
+ throw new Error(`an artifact cannot have more than ${ARTIFACT_SCOPE_MAX_MEMBERS_PER_ARTIFACT} scope members`);
73
86
  }
74
- projectIds.add(projectId);
75
- const row: Row = { mode: "projects", projectIds, source };
87
+ if (!members.some((candidate) => sameScopeMember(candidate, member))) members.push(member);
88
+ const row: Row = { mode: "explicit", members, source };
76
89
  this.rows.set(artifactId, row);
77
90
  return this.toScope(artifactId, row);
78
91
  }
79
92
 
80
- removeProject(artifactId: string, projectId: string): ArtifactScope {
93
+ removeMember(artifactId: string, member: ScopeMemberRef): ArtifactScope {
81
94
  const existing = this.rows.get(artifactId);
82
- if (existing?.mode !== "projects" || !existing.projectIds.has(projectId)) return this.toScope(artifactId, existing);
83
- if (existing.projectIds.size === 1) {
84
- throw new Error("cannot remove the last project membership; call setGlobal to make this artifact apply everywhere instead");
95
+ if (existing?.mode !== "explicit" || !existing.members.some((candidate) => sameScopeMember(candidate, member))) {
96
+ return this.toScope(artifactId, existing);
85
97
  }
86
- const projectIds = new Set(existing.projectIds);
87
- projectIds.delete(projectId);
88
- const row: Row = { mode: "projects", projectIds, source: existing.source };
98
+ if (existing.members.length === 1) {
99
+ throw new Error("cannot remove the last scope member; call setAll or setNone instead of accidentally widening/hiding scope");
100
+ }
101
+ const members = existing.members.filter((candidate) => !sameScopeMember(candidate, member));
102
+ const row: Row = { mode: "explicit", members, source: existing.source };
89
103
  this.rows.set(artifactId, row);
90
104
  return this.toScope(artifactId, row);
91
105
  }
@@ -93,7 +107,7 @@ export class InMemoryArtifactScopeStore implements ArtifactScopeStore {
93
107
  ids(projectRoot: string | undefined, limit: number): string[] {
94
108
  if (projectRoot === undefined) {
95
109
  return [...this.rows.entries()]
96
- .filter(([, row]) => row.mode === "global")
110
+ .filter(([, row]) => row.mode === "all")
97
111
  .map(([artifactId]) => artifactId)
98
112
  .sort()
99
113
  .slice(0, limit);
@@ -101,23 +115,48 @@ export class InMemoryArtifactScopeStore implements ArtifactScopeStore {
101
115
  const project = this.registry.byRoot(projectRoot);
102
116
  if (!project) return [];
103
117
  return [...this.rows.entries()]
104
- .filter(([, row]) => row.mode === "projects" && row.projectIds.has(project.id))
118
+ .filter(([, row]) => row.mode === "explicit" && row.members.some((member) => member.type === "project" && member.id === project.id))
105
119
  .map(([artifactId]) => artifactId)
106
120
  .sort()
107
121
  .slice(0, limit);
108
122
  }
109
123
 
124
+ private expandedProjectIds(row: Row): Set<string> {
125
+ const result = new Set<string>();
126
+ for (const member of row.members) {
127
+ if (member.type === "project") result.add(member.id);
128
+ else for (const projectId of this.scopeGroups.expandToProjectIds(member.id)) result.add(projectId);
129
+ }
130
+ return result;
131
+ }
132
+
110
133
  appliesToProject(artifactId: string, projectId: string): boolean {
111
134
  const row = this.rows.get(artifactId);
112
- if (!row || row.mode === "global") return true;
113
- return row.projectIds.has(projectId);
135
+ if (!row || row.mode === "all") return true;
136
+ if (row.mode === "none") return false;
137
+ return this.expandedProjectIds(row).has(projectId);
114
138
  }
115
139
 
116
140
  appliesToProjectRoot(artifactId: string, projectRoot: string | undefined): boolean {
117
141
  const row = this.rows.get(artifactId);
118
- if (!row || row.mode === "global") return true;
142
+ if (!row || row.mode === "all") return true;
143
+ if (row.mode === "none") return false;
119
144
  if (projectRoot === undefined) return false;
120
145
  const project = this.registry.byRoot(projectRoot);
121
- return project !== undefined && row.projectIds.has(project.id);
146
+ return project !== undefined && this.expandedProjectIds(row).has(project.id);
147
+ }
148
+
149
+ referencesGroup(groupId: string): boolean {
150
+ return [...this.rows.values()].some(
151
+ (row) => row.mode === "explicit" && row.members.some((member) => member.type === "group" && member.id === groupId),
152
+ );
153
+ }
154
+ }
155
+
156
+ function dedupeMembers(members: readonly ScopeMemberRef[]): ScopeMemberRef[] {
157
+ const result: ScopeMemberRef[] = [];
158
+ for (const member of members) {
159
+ if (!result.some((candidate) => sameScopeMember(candidate, member))) result.push(member);
122
160
  }
161
+ return result;
123
162
  }
@@ -0,0 +1,3 @@
1
+ export type { Artifact, ArtifactEdge } from "./artifact.ts";
2
+ export { projectArtifactRelationships } from "./artifact-relationship-view.ts";
3
+ export type { ArtifactStore } from "./artifact-store.ts";
@@ -1,8 +1,10 @@
1
- import { ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT } from "../constants.ts";
1
+ import { ARTIFACT_SCOPE_MAX_MEMBERS_PER_ARTIFACT } from "../constants.ts";
2
2
  import type { Db } from "../db.ts";
3
3
  import { inTransaction } from "../db.ts";
4
- import type { TaskScopeSource } from "../domain/task-scope.ts";
5
- import { SQLiteProjectRegistryStore } from "../stores/sqlite-project-registry-store.ts";
4
+ import { SQLiteProjectRegistryStore } from "../project-registry/sqlite-project-registry-store.ts";
5
+ import type { ScopeMemberRef } from "../scope-group/scope-group.ts";
6
+ import { SQLiteScopeGroupStore } from "../scope-group/sqlite-scope-group-store.ts";
7
+ import type { TaskScopeSource } from "../task-scope/task-scope.ts";
6
8
  import type { ArtifactScope, ArtifactScopeMode, ArtifactScopeStore, LegacyArtifactScope } from "./artifact-scope-store.ts";
7
9
 
8
10
  interface ScopeRow {
@@ -11,22 +13,33 @@ interface ScopeRow {
11
13
  source: TaskScopeSource;
12
14
  }
13
15
 
16
+ interface MemberRow {
17
+ member_type: "project" | "group";
18
+ member_id: string;
19
+ }
20
+
21
+ function memberFromRow(row: MemberRow): ScopeMemberRef {
22
+ return row.member_type === "project" ? { type: "project", id: row.member_id } : { type: "group", id: row.member_id };
23
+ }
24
+
14
25
  export class SQLiteArtifactScopeStore implements ArtifactScopeStore {
15
26
  private readonly registry: SQLiteProjectRegistryStore;
27
+ private readonly scopeGroups: SQLiteScopeGroupStore;
16
28
 
17
- // Membership is by project id, not root -- a registry root move (see
18
- // SQLiteProjectRegistryStore.registerProject) needs no rewrite of artifact_scope_projects at
19
- // all, unlike SQLiteTaskScopeStore's own task_scopes/task_views rewrite.
29
+ // Membership is by project/group id, not root/name -- a registry root move or scope group
30
+ // rename needs no rewrite of artifact_scope_members at all, unlike SQLiteTaskScopeStore's own
31
+ // task_scopes/task_views rewrite.
20
32
  constructor(private readonly db: Db) {
21
33
  this.registry = new SQLiteProjectRegistryStore(db);
34
+ this.scopeGroups = new SQLiteScopeGroupStore(db);
22
35
  }
23
36
 
24
- private membershipIds(artifactId: string): string[] {
37
+ private members(artifactId: string): ScopeMemberRef[] {
25
38
  return (
26
- this.db.prepare("SELECT project_id FROM artifact_scope_projects WHERE artifact_id = ? ORDER BY project_id").all(artifactId) as Array<{
27
- project_id: string;
28
- }>
29
- ).map((row) => row.project_id);
39
+ this.db
40
+ .prepare("SELECT member_type, member_id FROM artifact_scope_members WHERE artifact_id = ? ORDER BY member_type, member_id")
41
+ .all(artifactId) as MemberRow[]
42
+ ).map(memberFromRow);
30
43
  }
31
44
 
32
45
  private readRow(artifactId: string): ScopeRow | undefined {
@@ -38,28 +51,28 @@ export class SQLiteArtifactScopeStore implements ArtifactScopeStore {
38
51
 
39
52
  scope(artifactId: string): ArtifactScope {
40
53
  const row = this.readRow(artifactId);
41
- if (!row) return { artifactId, mode: "global", projectIds: [], source: "unscoped" };
42
- return { artifactId, mode: row.mode, projectIds: row.mode === "projects" ? this.membershipIds(artifactId) : [], source: row.source };
54
+ if (!row) return { artifactId, mode: "all", members: [], source: "unscoped" };
55
+ return { artifactId, mode: row.mode, members: row.mode === "explicit" ? this.members(artifactId) : [], source: row.source };
43
56
  }
44
57
 
45
58
  get(artifactId: string): LegacyArtifactScope | undefined {
46
59
  const row = this.readRow(artifactId);
47
60
  if (!row) return undefined;
48
- if (row.mode !== "projects") return { artifactId, source: row.source };
49
- const ids = this.membershipIds(artifactId);
50
- if (ids.length !== 1) return { artifactId, source: row.source };
51
- const project = this.registry.matchingProjects(ids[0]!).find((candidate) => candidate.id === ids[0]);
61
+ if (row.mode !== "explicit") return { artifactId, source: row.source };
62
+ const members = this.members(artifactId);
63
+ if (members.length !== 1 || members[0]!.type !== "project") return { artifactId, source: row.source };
64
+ const project = this.registry.matchingProjects(members[0]!.id).find((candidate) => candidate.id === members[0]!.id);
52
65
  return { artifactId, ...(project ? { projectRoot: project.projectRoot } : {}), source: row.source };
53
66
  }
54
67
 
55
68
  assign(artifactId: string, projectRoot: string | undefined, source: TaskScopeSource): LegacyArtifactScope {
56
69
  if (projectRoot === undefined) {
57
- this.setGlobal(artifactId, source);
70
+ this.setAll(artifactId, source);
58
71
  return { artifactId, source };
59
72
  }
60
73
  return inTransaction(this.db, () => {
61
74
  const project = this.registry.registerProject({ projectRoot });
62
- this.replaceProjects(artifactId, [project.id], source);
75
+ this.replaceMembers(artifactId, [{ type: "project", id: project.id }], source);
63
76
  return { artifactId, projectRoot: project.projectRoot, source };
64
77
  });
65
78
  }
@@ -78,58 +91,77 @@ export class SQLiteArtifactScopeStore implements ArtifactScopeStore {
78
91
  .run(artifactId, mode, source, new Date().toISOString());
79
92
  }
80
93
 
81
- setGlobal(artifactId: string, source: TaskScopeSource): ArtifactScope {
94
+ setAll(artifactId: string, source: TaskScopeSource): ArtifactScope {
95
+ return inTransaction(this.db, () => {
96
+ this.upsertScopeRow(artifactId, "all", source);
97
+ this.db.prepare("DELETE FROM artifact_scope_members WHERE artifact_id = ?").run(artifactId);
98
+ return { artifactId, mode: "all", members: [], source };
99
+ });
100
+ }
101
+
102
+ setNone(artifactId: string, source: TaskScopeSource): ArtifactScope {
82
103
  return inTransaction(this.db, () => {
83
- this.upsertScopeRow(artifactId, "global", source);
84
- this.db.prepare("DELETE FROM artifact_scope_projects WHERE artifact_id = ?").run(artifactId);
85
- return { artifactId, mode: "global", projectIds: [], source };
104
+ this.upsertScopeRow(artifactId, "none", source);
105
+ this.db.prepare("DELETE FROM artifact_scope_members WHERE artifact_id = ?").run(artifactId);
106
+ return { artifactId, mode: "none", members: [], source };
86
107
  });
87
108
  }
88
109
 
89
- replaceProjects(artifactId: string, projectIds: readonly string[], source: TaskScopeSource): ArtifactScope {
90
- if (projectIds.length === 0) throw new Error("replaceProjects requires at least one project id; use setGlobal to clear scoping");
91
- if (projectIds.length > ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
92
- throw new Error(`an artifact cannot belong to more than ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} projects`);
110
+ replaceMembers(artifactId: string, members: readonly ScopeMemberRef[], source: TaskScopeSource): ArtifactScope {
111
+ if (members.length === 0) throw new Error("replaceMembers requires at least one member; use setAll/setNone to clear scoping");
112
+ if (members.length > ARTIFACT_SCOPE_MAX_MEMBERS_PER_ARTIFACT) {
113
+ throw new Error(`an artifact cannot have more than ${ARTIFACT_SCOPE_MAX_MEMBERS_PER_ARTIFACT} scope members`);
93
114
  }
94
115
  return inTransaction(this.db, () => {
95
- this.upsertScopeRow(artifactId, "projects", source);
96
- this.db.prepare("DELETE FROM artifact_scope_projects WHERE artifact_id = ?").run(artifactId);
97
- const insert = this.db.prepare("INSERT OR IGNORE INTO artifact_scope_projects (artifact_id, project_id) VALUES (?, ?)");
98
- const unique = [...new Set(projectIds)];
99
- for (const projectId of unique) insert.run(artifactId, projectId);
100
- return { artifactId, mode: "projects", projectIds: unique, source };
116
+ this.upsertScopeRow(artifactId, "explicit", source);
117
+ this.db.prepare("DELETE FROM artifact_scope_members WHERE artifact_id = ?").run(artifactId);
118
+ const insert = this.db.prepare("INSERT OR IGNORE INTO artifact_scope_members (artifact_id, member_type, member_id) VALUES (?, ?, ?)");
119
+ const seen = new Set<string>();
120
+ for (const member of members) {
121
+ const key = `${member.type}:${member.id}`;
122
+ if (seen.has(key)) continue;
123
+ seen.add(key);
124
+ insert.run(artifactId, member.type, member.id);
125
+ }
126
+ return { artifactId, mode: "explicit", members: this.members(artifactId), source };
101
127
  });
102
128
  }
103
129
 
104
- addProject(artifactId: string, projectId: string, source: TaskScopeSource): ArtifactScope {
130
+ addMember(artifactId: string, member: ScopeMemberRef, source: TaskScopeSource): ArtifactScope {
105
131
  return inTransaction(this.db, () => {
106
- const current = this.membershipIds(artifactId);
107
- if (!current.includes(projectId) && current.length >= ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT) {
108
- throw new Error(`an artifact cannot belong to more than ${ARTIFACT_SCOPE_MAX_PROJECTS_PER_ARTIFACT} projects`);
132
+ const current = this.members(artifactId);
133
+ const already = current.some((candidate) => candidate.type === member.type && candidate.id === member.id);
134
+ if (!already && current.length >= ARTIFACT_SCOPE_MAX_MEMBERS_PER_ARTIFACT) {
135
+ throw new Error(`an artifact cannot have more than ${ARTIFACT_SCOPE_MAX_MEMBERS_PER_ARTIFACT} scope members`);
109
136
  }
110
- this.upsertScopeRow(artifactId, "projects", source);
111
- this.db.prepare("INSERT OR IGNORE INTO artifact_scope_projects (artifact_id, project_id) VALUES (?, ?)").run(artifactId, projectId);
112
- return { artifactId, mode: "projects", projectIds: this.membershipIds(artifactId), source };
137
+ this.upsertScopeRow(artifactId, "explicit", source);
138
+ this.db
139
+ .prepare("INSERT OR IGNORE INTO artifact_scope_members (artifact_id, member_type, member_id) VALUES (?, ?, ?)")
140
+ .run(artifactId, member.type, member.id);
141
+ return { artifactId, mode: "explicit", members: this.members(artifactId), source };
113
142
  });
114
143
  }
115
144
 
116
- removeProject(artifactId: string, projectId: string): ArtifactScope {
145
+ removeMember(artifactId: string, member: ScopeMemberRef): ArtifactScope {
117
146
  return inTransaction(this.db, () => {
118
147
  const row = this.readRow(artifactId);
119
- const current = row?.mode === "projects" ? this.membershipIds(artifactId) : [];
120
- if (row?.mode !== "projects" || !current.includes(projectId)) return this.scope(artifactId);
148
+ const current = row?.mode === "explicit" ? this.members(artifactId) : [];
149
+ const already = current.some((candidate) => candidate.type === member.type && candidate.id === member.id);
150
+ if (row?.mode !== "explicit" || !already) return this.scope(artifactId);
121
151
  if (current.length === 1) {
122
- throw new Error("cannot remove the last project membership; call setGlobal to make this artifact apply everywhere instead");
152
+ throw new Error("cannot remove the last scope member; call setAll or setNone instead of accidentally widening/hiding scope");
123
153
  }
124
- this.db.prepare("DELETE FROM artifact_scope_projects WHERE artifact_id = ? AND project_id = ?").run(artifactId, projectId);
125
- return { artifactId, mode: "projects", projectIds: this.membershipIds(artifactId), source: row.source };
154
+ this.db
155
+ .prepare("DELETE FROM artifact_scope_members WHERE artifact_id = ? AND member_type = ? AND member_id = ?")
156
+ .run(artifactId, member.type, member.id);
157
+ return { artifactId, mode: "explicit", members: this.members(artifactId), source: row.source };
126
158
  });
127
159
  }
128
160
 
129
161
  ids(projectRoot: string | undefined, limit: number): string[] {
130
162
  if (projectRoot === undefined) {
131
163
  return (
132
- this.db.prepare("SELECT artifact_id FROM artifact_scopes WHERE mode = 'global' ORDER BY artifact_id LIMIT ?").all(limit) as Array<{
164
+ this.db.prepare("SELECT artifact_id FROM artifact_scopes WHERE mode = 'all' ORDER BY artifact_id LIMIT ?").all(limit) as Array<{
133
165
  artifact_id: string;
134
166
  }>
135
167
  ).map((row) => row.artifact_id);
@@ -139,28 +171,45 @@ export class SQLiteArtifactScopeStore implements ArtifactScopeStore {
139
171
  return (
140
172
  this.db
141
173
  .prepare(`
142
- SELECT asp.artifact_id AS artifact_id
143
- FROM artifact_scope_projects asp
144
- JOIN artifact_scopes s ON s.artifact_id = asp.artifact_id AND s.mode = 'projects'
145
- WHERE asp.project_id = ?
146
- ORDER BY asp.artifact_id
174
+ SELECT asm.artifact_id AS artifact_id
175
+ FROM artifact_scope_members asm
176
+ JOIN artifact_scopes s ON s.artifact_id = asm.artifact_id AND s.mode = 'explicit'
177
+ WHERE asm.member_type = 'project' AND asm.member_id = ?
178
+ ORDER BY asm.artifact_id
147
179
  LIMIT ?
148
180
  `)
149
181
  .all(project.id, limit) as Array<{ artifact_id: string }>
150
182
  ).map((row) => row.artifact_id);
151
183
  }
152
184
 
185
+ private expandedProjectIds(artifactId: string): Set<string> {
186
+ const result = new Set<string>();
187
+ for (const member of this.members(artifactId)) {
188
+ if (member.type === "project") result.add(member.id);
189
+ else for (const projectId of this.scopeGroups.expandToProjectIds(member.id)) result.add(projectId);
190
+ }
191
+ return result;
192
+ }
193
+
153
194
  appliesToProject(artifactId: string, projectId: string): boolean {
154
195
  const row = this.readRow(artifactId);
155
- if (!row || row.mode === "global") return true;
156
- return this.membershipIds(artifactId).includes(projectId);
196
+ if (!row || row.mode === "all") return true;
197
+ if (row.mode === "none") return false;
198
+ return this.expandedProjectIds(artifactId).has(projectId);
157
199
  }
158
200
 
159
201
  appliesToProjectRoot(artifactId: string, projectRoot: string | undefined): boolean {
160
202
  const row = this.readRow(artifactId);
161
- if (!row || row.mode === "global") return true;
203
+ if (!row || row.mode === "all") return true;
204
+ if (row.mode === "none") return false;
162
205
  if (projectRoot === undefined) return false;
163
206
  const project = this.db.prepare("SELECT id FROM task_projects WHERE project_root = ?").get(projectRoot) as { id: string } | null;
164
- return project !== null && this.membershipIds(artifactId).includes(project.id);
207
+ return project !== null && this.expandedProjectIds(artifactId).has(project.id);
208
+ }
209
+
210
+ referencesGroup(groupId: string): boolean {
211
+ return (
212
+ this.db.prepare("SELECT 1 FROM artifact_scope_members WHERE member_type = 'group' AND member_id = ? LIMIT 1").get(groupId) !== null
213
+ );
165
214
  }
166
215
  }