@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
@@ -0,0 +1,173 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { SCOPE_GROUP_MAX_MEMBERS, SCOPE_GROUP_MAX_NESTING_DEPTH } from "../constants.ts";
3
+ import {
4
+ assertRegisterScopeGroupInputBounds,
5
+ type RegisterScopeGroupInput,
6
+ type ScopeGroup,
7
+ ScopeGroupInUseError,
8
+ type ScopeMemberRef,
9
+ sameScopeMember,
10
+ } from "./scope-group.ts";
11
+ import type { ScopeGroupStore } from "./scope-group-store.ts";
12
+
13
+ interface Row {
14
+ group: ScopeGroup;
15
+ members: ScopeMemberRef[];
16
+ }
17
+
18
+ export class InMemoryScopeGroupStore implements ScopeGroupStore {
19
+ private readonly rows = new Map<string, Row>();
20
+
21
+ groups(query: string | undefined, limit: number): ScopeGroup[] {
22
+ const needle = query?.trim().toLowerCase();
23
+ return [...this.rows.values()]
24
+ .map((row) => row.group)
25
+ .filter(
26
+ (group) =>
27
+ !needle || group.name.toLowerCase().includes(needle) || group.aliases.some((alias) => alias.toLowerCase().includes(needle)),
28
+ )
29
+ .sort((left, right) => left.name.localeCompare(right.name))
30
+ .slice(0, limit);
31
+ }
32
+
33
+ matchingGroups(reference: string): ScopeGroup[] {
34
+ const needle = reference.trim().toLowerCase();
35
+ return [...this.rows.values()]
36
+ .map((row) => row.group)
37
+ .filter(
38
+ (group) =>
39
+ group.id.toLowerCase() === needle ||
40
+ group.name.toLowerCase() === needle ||
41
+ group.aliases.some((alias) => alias.toLowerCase() === needle),
42
+ )
43
+ .slice(0, 11);
44
+ }
45
+
46
+ get(id: string): ScopeGroup | undefined {
47
+ return this.rows.get(id)?.group;
48
+ }
49
+
50
+ registerGroup(input: RegisterScopeGroupInput): ScopeGroup {
51
+ assertRegisterScopeGroupInputBounds(input.name, input.aliases);
52
+ const existing = input.existingId ? this.rows.get(input.existingId) : undefined;
53
+ const now = new Date().toISOString();
54
+ const name = input.name?.trim() || existing?.group.name || "unnamed-scope-group";
55
+ const seen = new Set([name.toLowerCase()]);
56
+ const aliases = [
57
+ ...(existing?.group.aliases ?? []),
58
+ ...(existing && existing.group.name !== name ? [existing.group.name] : []),
59
+ ...(input.aliases ?? []),
60
+ ].flatMap((value) => {
61
+ const trimmed = value.trim();
62
+ const key = trimmed.toLowerCase();
63
+ if (!trimmed || seen.has(key)) return [];
64
+ seen.add(key);
65
+ return [trimmed];
66
+ });
67
+ const group: ScopeGroup = {
68
+ id: existing?.group.id ?? randomUUID(),
69
+ name,
70
+ aliases,
71
+ createdAt: existing?.group.createdAt ?? now,
72
+ updatedAt: now,
73
+ };
74
+ this.rows.set(group.id, { group, members: existing?.members ?? [] });
75
+ return group;
76
+ }
77
+
78
+ members(groupId: string): ScopeMemberRef[] {
79
+ return [...(this.rows.get(groupId)?.members ?? [])];
80
+ }
81
+
82
+ private requireRow(groupId: string): Row {
83
+ const row = this.rows.get(groupId);
84
+ if (!row) throw new Error(`scope group "${groupId}" not found`);
85
+ return row;
86
+ }
87
+
88
+ wouldCreateCycle(groupId: string, candidateMemberGroupId: string): boolean {
89
+ if (groupId === candidateMemberGroupId) return true;
90
+ const visited = new Set<string>();
91
+ const stack = [candidateMemberGroupId];
92
+ while (stack.length > 0) {
93
+ const current = stack.pop()!;
94
+ if (current === groupId) return true;
95
+ if (visited.has(current)) continue;
96
+ visited.add(current);
97
+ for (const member of this.rows.get(current)?.members ?? []) {
98
+ if (member.type === "group") stack.push(member.id);
99
+ }
100
+ }
101
+ return false;
102
+ }
103
+
104
+ private depthOf(groupId: string, seen: Set<string> = new Set()): number {
105
+ if (seen.has(groupId)) return 0;
106
+ seen.add(groupId);
107
+ const members = this.rows.get(groupId)?.members ?? [];
108
+ let max = 0;
109
+ for (const member of members) {
110
+ if (member.type === "group") max = Math.max(max, 1 + this.depthOf(member.id, seen));
111
+ }
112
+ return max;
113
+ }
114
+
115
+ addMember(groupId: string, member: ScopeMemberRef): ScopeGroup {
116
+ const row = this.requireRow(groupId);
117
+ if (row.members.some((existing) => sameScopeMember(existing, member))) return row.group;
118
+ if (row.members.length >= SCOPE_GROUP_MAX_MEMBERS)
119
+ throw new Error(`a scope group cannot have more than ${SCOPE_GROUP_MAX_MEMBERS} members`);
120
+ if (member.type === "group") {
121
+ if (this.wouldCreateCycle(groupId, member.id))
122
+ throw new Error(`adding "${member.id}" to "${groupId}" would create a scope group cycle`);
123
+ if (1 + this.depthOf(member.id) > SCOPE_GROUP_MAX_NESTING_DEPTH) {
124
+ throw new Error(`scope group nesting cannot exceed ${SCOPE_GROUP_MAX_NESTING_DEPTH} levels`);
125
+ }
126
+ }
127
+ row.members.push(member);
128
+ row.group = { ...row.group, updatedAt: new Date().toISOString() };
129
+ return row.group;
130
+ }
131
+
132
+ removeMember(groupId: string, member: ScopeMemberRef): ScopeGroup {
133
+ const row = this.requireRow(groupId);
134
+ const next = row.members.filter((existing) => !sameScopeMember(existing, member));
135
+ if (next.length !== row.members.length) {
136
+ row.members = next;
137
+ row.group = { ...row.group, updatedAt: new Date().toISOString() };
138
+ }
139
+ return row.group;
140
+ }
141
+
142
+ expandToProjectIds(groupId: string): Set<string> {
143
+ const result = new Set<string>();
144
+ const visited = new Set<string>();
145
+ const stack = [groupId];
146
+ while (stack.length > 0) {
147
+ const current = stack.pop()!;
148
+ if (visited.has(current)) continue;
149
+ visited.add(current);
150
+ for (const member of this.rows.get(current)?.members ?? []) {
151
+ if (member.type === "project") result.add(member.id);
152
+ else stack.push(member.id);
153
+ }
154
+ }
155
+ return result;
156
+ }
157
+
158
+ /** Exposed for a caller (e.g. an ArtifactScopeStore checking whether a group it references still exists) needing raw existence, without going through a bounded search. */
159
+ exists(groupId: string): boolean {
160
+ return this.rows.has(groupId);
161
+ }
162
+
163
+ /** Only checks self-consistency (another group's own membership) -- whether any Doc/Rule/Playbook's own explicit scope still references this group is a cross-store concern the caller (deleteScopeGroup, which holds both stores) checks first. */
164
+ delete(groupId: string): void {
165
+ for (const [otherId, row] of this.rows) {
166
+ if (otherId === groupId) continue;
167
+ if (row.members.some((member) => member.type === "group" && member.id === groupId)) {
168
+ throw new ScopeGroupInUseError(`scope group "${groupId}" is still a member of scope group "${otherId}"`);
169
+ }
170
+ }
171
+ this.rows.delete(groupId);
172
+ }
173
+ }
@@ -0,0 +1,28 @@
1
+ import type { RegisterScopeGroupInput, ScopeGroup, ScopeMemberRef } from "./scope-group.ts";
2
+
3
+ /**
4
+ * Kind-neutral registry for named, reusable scope member collections -- the "nesting" primitive
5
+ * behind an artifact's explicit scope. Mirrors ProjectRegistryStore's own shape (create/list/
6
+ * matching/register-in-place) plus real membership management and a real delete, learning from
7
+ * the confirmed-live gap that ProjectRegistryStore itself has no delete path at all (see task
8
+ * 1716b34b) -- a scope group's own delete refuses while still referenced, rather than never
9
+ * being possible.
10
+ */
11
+ export interface ScopeGroupStore {
12
+ groups(query: string | undefined, limit: number): ScopeGroup[];
13
+ matchingGroups(reference: string): ScopeGroup[];
14
+ get(id: string): ScopeGroup | undefined;
15
+ /** Registers a new group, or renames/updates an existing one in place (via existingId) -- same upsert shape as ProjectRegistryStore.registerProject, minus root-move semantics (a group has no root to move). */
16
+ registerGroup(input: RegisterScopeGroupInput): ScopeGroup;
17
+ members(groupId: string): ScopeMemberRef[];
18
+ /** Adds one member (idempotent). Refuses a group-type member that would create a cycle (directly or transitively) or exceed SCOPE_GROUP_MAX_NESTING_DEPTH, and refuses exceeding SCOPE_GROUP_MAX_MEMBERS. */
19
+ addMember(groupId: string, member: ScopeMemberRef): ScopeGroup;
20
+ /** Removes one member (idempotent -- removing an absent member is a no-op). */
21
+ removeMember(groupId: string, member: ScopeMemberRef): ScopeGroup;
22
+ /** All projectIds reachable from this group by expanding every nested group member recursively (cycle-safe, depth-bounded), deduplicated. Does not include the group itself. */
23
+ expandToProjectIds(groupId: string): Set<string>;
24
+ /** True when adding `candidateMemberGroupId` as a member of `groupId` would create a cycle (groupId is reachable from candidateMemberGroupId) -- checked before addMember persists a group-type member. */
25
+ wouldCreateCycle(groupId: string, candidateMemberGroupId: string): boolean;
26
+ /** Deletes a group outright. Refuses (ScopeGroupInUseError) if any artifact's explicit scope or any other group's own membership still references it. */
27
+ delete(groupId: string): void;
28
+ }
@@ -0,0 +1,79 @@
1
+ import { SCOPE_GROUP_ALIAS_MAX_COUNT, SCOPE_GROUP_NAME_MAX_LENGTH } from "../constants.ts";
2
+
3
+ /**
4
+ * A named, reusable collection of scope members (registered projects and/or other scope groups)
5
+ * -- the "explicit scope can include nested scopes" primitive. Lives alongside the project
6
+ * registry (same shape: id/name/aliases/createdAt/updatedAt) rather than as an Artifact, since
7
+ * it is bookkeeping infrastructure for scoping, not itself a piece of knowledge or work.
8
+ *
9
+ * Deliberately explicit/opt-in membership, not filesystem-path-derived nesting: a registered
10
+ * project whose root happens to be a subdirectory of another registered project's root is NOT
11
+ * automatically its child (a real false-positive confirmed live: alignment-lector is registered
12
+ * under lector/packages/alignment-lector, a subdirectory of lector's own root, but is Lector's
13
+ * adapter for a *different* project, not part of Lector's own scope). A group's membership is
14
+ * only ever what was deliberately added to it.
15
+ */
16
+ export interface ScopeGroup {
17
+ id: string;
18
+ name: string;
19
+ aliases: string[];
20
+ createdAt: string;
21
+ updatedAt: string;
22
+ }
23
+
24
+ /** One member of a ScopeGroup or of an artifact's own explicit scope -- a project, or another group (nesting). */
25
+ export type ScopeMemberRef = { readonly type: "project"; readonly id: string } | { readonly type: "group"; readonly id: string };
26
+
27
+ export function sameScopeMember(a: ScopeMemberRef, b: ScopeMemberRef): boolean {
28
+ return a.type === b.type && a.id === b.id;
29
+ }
30
+
31
+ export interface RegisterScopeGroupInput {
32
+ name?: string;
33
+ aliases?: string[];
34
+ existingId?: string;
35
+ }
36
+
37
+ export function assertRegisterScopeGroupInputBounds(name: string | undefined, aliases: string[] | undefined): void {
38
+ if (name !== undefined && (name.trim().length === 0 || name.length > SCOPE_GROUP_NAME_MAX_LENGTH)) {
39
+ throw new Error(`scope group name must be between 1 and ${SCOPE_GROUP_NAME_MAX_LENGTH} characters`);
40
+ }
41
+ if ((aliases?.length ?? 0) > SCOPE_GROUP_ALIAS_MAX_COUNT) {
42
+ throw new Error(`scope group aliases cannot exceed ${SCOPE_GROUP_ALIAS_MAX_COUNT} entries`);
43
+ }
44
+ for (const alias of aliases ?? []) {
45
+ if (alias.trim().length === 0 || alias.length > SCOPE_GROUP_NAME_MAX_LENGTH) {
46
+ throw new Error(`each scope group alias must be between 1 and ${SCOPE_GROUP_NAME_MAX_LENGTH} characters`);
47
+ }
48
+ }
49
+ }
50
+
51
+ export class ScopeGroupNotFoundError extends Error {}
52
+ export class ScopeGroupAmbiguousError extends Error {}
53
+ export class ScopeGroupCycleError extends Error {}
54
+ export class ScopeGroupInUseError extends Error {}
55
+
56
+ export interface ScopeGroupReferenceLookup {
57
+ matchingGroups(reference: string): ScopeGroup[];
58
+ groups(query: string | undefined, limit: number): ScopeGroup[];
59
+ }
60
+
61
+ /** Same bounded, fail-closed exact-reference resolution resolveProjectReference already established -- identical contract, applied to scope groups. */
62
+ export function resolveScopeGroupReference(registry: ScopeGroupReferenceLookup, reference: string): ScopeGroup {
63
+ const matches = registry.matchingGroups(reference);
64
+ if (matches.length === 0) {
65
+ const candidates = registry.groups(reference, 10);
66
+ const fallback = candidates.length === 0 ? registry.groups(undefined, 10) : candidates;
67
+ const suffix = fallback.length === 0 ? "" : ` Candidates: ${fallback.map((group) => group.name).join(", ")}`;
68
+ throw new ScopeGroupNotFoundError(`no scope group named or aliased "${reference}" is registered.${suffix}`);
69
+ }
70
+ if (matches.length > 1) {
71
+ throw new ScopeGroupAmbiguousError(
72
+ `scope group reference "${reference}" is ambiguous: ${matches
73
+ .slice(0, 10)
74
+ .map((group) => group.name)
75
+ .join(", ")}`,
76
+ );
77
+ }
78
+ return matches[0]!;
79
+ }
@@ -0,0 +1,223 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { SCOPE_GROUP_MAX_MEMBERS, SCOPE_GROUP_MAX_NESTING_DEPTH } from "../constants.ts";
3
+ import type { Db } from "../db.ts";
4
+ import { inTransaction } from "../db.ts";
5
+ import {
6
+ assertRegisterScopeGroupInputBounds,
7
+ type RegisterScopeGroupInput,
8
+ type ScopeGroup,
9
+ ScopeGroupInUseError,
10
+ type ScopeMemberRef,
11
+ } from "./scope-group.ts";
12
+ import type { ScopeGroupStore } from "./scope-group-store.ts";
13
+
14
+ interface GroupRow {
15
+ id: string;
16
+ name: string;
17
+ aliases_json: string;
18
+ created_at: string;
19
+ updated_at: string;
20
+ }
21
+
22
+ function groupFromRow(row: GroupRow): ScopeGroup {
23
+ return {
24
+ id: row.id,
25
+ name: row.name,
26
+ aliases: JSON.parse(row.aliases_json) as string[],
27
+ createdAt: row.created_at,
28
+ updatedAt: row.updated_at,
29
+ };
30
+ }
31
+
32
+ interface MemberRow {
33
+ member_type: "project" | "group";
34
+ member_id: string;
35
+ }
36
+
37
+ function memberFromRow(row: MemberRow): ScopeMemberRef {
38
+ return row.member_type === "project" ? { type: "project", id: row.member_id } : { type: "group", id: row.member_id };
39
+ }
40
+
41
+ export class SQLiteScopeGroupStore implements ScopeGroupStore {
42
+ constructor(private readonly db: Db) {}
43
+
44
+ groups(query: string | undefined, limit: number): ScopeGroup[] {
45
+ const needle = query?.trim().toLowerCase();
46
+ if (!needle) {
47
+ return (
48
+ this.db
49
+ .prepare("SELECT id, name, aliases_json, created_at, updated_at FROM scope_groups ORDER BY name LIMIT ?")
50
+ .all(limit) as GroupRow[]
51
+ ).map(groupFromRow);
52
+ }
53
+ return (
54
+ this.db
55
+ .prepare(`
56
+ SELECT id, name, aliases_json, created_at, updated_at FROM scope_groups
57
+ WHERE instr(lower(name), ?) > 0
58
+ OR EXISTS (SELECT 1 FROM json_each(scope_groups.aliases_json) WHERE instr(lower(CAST(json_each.value AS TEXT)), ?) > 0)
59
+ ORDER BY name LIMIT ?
60
+ `)
61
+ .all(needle, needle, limit) as GroupRow[]
62
+ ).map(groupFromRow);
63
+ }
64
+
65
+ matchingGroups(reference: string): ScopeGroup[] {
66
+ const needle = reference.trim().toLowerCase();
67
+ return (
68
+ this.db
69
+ .prepare(`
70
+ SELECT id, name, aliases_json, created_at, updated_at FROM scope_groups
71
+ WHERE lower(id) = ? OR lower(name) = ?
72
+ OR EXISTS (SELECT 1 FROM json_each(scope_groups.aliases_json) WHERE lower(CAST(json_each.value AS TEXT)) = ?)
73
+ LIMIT 11
74
+ `)
75
+ .all(needle, needle, needle) as GroupRow[]
76
+ ).map(groupFromRow);
77
+ }
78
+
79
+ get(id: string): ScopeGroup | undefined {
80
+ const row = this.db
81
+ .prepare("SELECT id, name, aliases_json, created_at, updated_at FROM scope_groups WHERE id = ?")
82
+ .get(id) as GroupRow | null;
83
+ return row ? groupFromRow(row) : undefined;
84
+ }
85
+
86
+ registerGroup(input: RegisterScopeGroupInput): ScopeGroup {
87
+ assertRegisterScopeGroupInputBounds(input.name, input.aliases);
88
+ return inTransaction(this.db, () => {
89
+ const existingRow = input.existingId
90
+ ? (this.db
91
+ .prepare("SELECT id, name, aliases_json, created_at, updated_at FROM scope_groups WHERE id = ?")
92
+ .get(input.existingId) as GroupRow | null)
93
+ : null;
94
+ const existing = existingRow ? groupFromRow(existingRow) : undefined;
95
+ const now = new Date().toISOString();
96
+ const name = input.name?.trim() || existing?.name || "unnamed-scope-group";
97
+ const seen = new Set([name.toLowerCase()]);
98
+ const aliases = [
99
+ ...(existing?.aliases ?? []),
100
+ ...(existing && existing.name !== name ? [existing.name] : []),
101
+ ...(input.aliases ?? []),
102
+ ].flatMap((value) => {
103
+ const trimmed = value.trim();
104
+ const key = trimmed.toLowerCase();
105
+ if (!trimmed || seen.has(key)) return [];
106
+ seen.add(key);
107
+ return [trimmed];
108
+ });
109
+ const group: ScopeGroup = { id: existing?.id ?? randomUUID(), name, aliases, createdAt: existing?.createdAt ?? now, updatedAt: now };
110
+ this.db
111
+ .prepare(`
112
+ INSERT INTO scope_groups (id, name, aliases_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?)
113
+ ON CONFLICT(id) DO UPDATE SET name = excluded.name, aliases_json = excluded.aliases_json, updated_at = excluded.updated_at
114
+ `)
115
+ .run(group.id, group.name, JSON.stringify(group.aliases), group.createdAt, group.updatedAt);
116
+ return group;
117
+ });
118
+ }
119
+
120
+ members(groupId: string): ScopeMemberRef[] {
121
+ return (
122
+ this.db
123
+ .prepare("SELECT member_type, member_id FROM scope_group_members WHERE group_id = ? ORDER BY member_type, member_id")
124
+ .all(groupId) as MemberRow[]
125
+ ).map(memberFromRow);
126
+ }
127
+
128
+ private childGroupIds(groupId: string): string[] {
129
+ return (
130
+ this.db.prepare("SELECT member_id FROM scope_group_members WHERE group_id = ? AND member_type = 'group'").all(groupId) as Array<{
131
+ member_id: string;
132
+ }>
133
+ ).map((row) => row.member_id);
134
+ }
135
+
136
+ wouldCreateCycle(groupId: string, candidateMemberGroupId: string): boolean {
137
+ if (groupId === candidateMemberGroupId) return true;
138
+ const visited = new Set<string>();
139
+ const stack = [candidateMemberGroupId];
140
+ while (stack.length > 0) {
141
+ const current = stack.pop()!;
142
+ if (current === groupId) return true;
143
+ if (visited.has(current)) continue;
144
+ visited.add(current);
145
+ for (const childId of this.childGroupIds(current)) stack.push(childId);
146
+ }
147
+ return false;
148
+ }
149
+
150
+ private depthOf(groupId: string, seen: Set<string> = new Set()): number {
151
+ if (seen.has(groupId)) return 0;
152
+ seen.add(groupId);
153
+ let max = 0;
154
+ for (const childId of this.childGroupIds(groupId)) max = Math.max(max, 1 + this.depthOf(childId, seen));
155
+ return max;
156
+ }
157
+
158
+ addMember(groupId: string, member: ScopeMemberRef): ScopeGroup {
159
+ return inTransaction(this.db, () => {
160
+ const group = this.get(groupId);
161
+ if (!group) throw new Error(`scope group "${groupId}" not found`);
162
+ const already = this.db
163
+ .prepare("SELECT 1 FROM scope_group_members WHERE group_id = ? AND member_type = ? AND member_id = ?")
164
+ .get(groupId, member.type, member.id);
165
+ if (already) return group;
166
+ const count = (this.db.prepare("SELECT COUNT(*) AS n FROM scope_group_members WHERE group_id = ?").get(groupId) as { n: number }).n;
167
+ if (count >= SCOPE_GROUP_MAX_MEMBERS) throw new Error(`a scope group cannot have more than ${SCOPE_GROUP_MAX_MEMBERS} members`);
168
+ if (member.type === "group") {
169
+ if (this.wouldCreateCycle(groupId, member.id))
170
+ throw new Error(`adding "${member.id}" to "${groupId}" would create a scope group cycle`);
171
+ if (1 + this.depthOf(member.id) > SCOPE_GROUP_MAX_NESTING_DEPTH) {
172
+ throw new Error(`scope group nesting cannot exceed ${SCOPE_GROUP_MAX_NESTING_DEPTH} levels`);
173
+ }
174
+ }
175
+ this.db
176
+ .prepare("INSERT INTO scope_group_members (group_id, member_type, member_id) VALUES (?, ?, ?)")
177
+ .run(groupId, member.type, member.id);
178
+ this.db.prepare("UPDATE scope_groups SET updated_at = ? WHERE id = ?").run(new Date().toISOString(), groupId);
179
+ return this.get(groupId)!;
180
+ });
181
+ }
182
+
183
+ removeMember(groupId: string, member: ScopeMemberRef): ScopeGroup {
184
+ return inTransaction(this.db, () => {
185
+ const group = this.get(groupId);
186
+ if (!group) throw new Error(`scope group "${groupId}" not found`);
187
+ const result = this.db
188
+ .prepare("DELETE FROM scope_group_members WHERE group_id = ? AND member_type = ? AND member_id = ?")
189
+ .run(groupId, member.type, member.id);
190
+ if (result.changes > 0) this.db.prepare("UPDATE scope_groups SET updated_at = ? WHERE id = ?").run(new Date().toISOString(), groupId);
191
+ return this.get(groupId)!;
192
+ });
193
+ }
194
+
195
+ expandToProjectIds(groupId: string): Set<string> {
196
+ const result = new Set<string>();
197
+ const visited = new Set<string>();
198
+ const stack = [groupId];
199
+ while (stack.length > 0) {
200
+ const current = stack.pop()!;
201
+ if (visited.has(current)) continue;
202
+ visited.add(current);
203
+ for (const member of this.members(current)) {
204
+ if (member.type === "project") result.add(member.id);
205
+ else stack.push(member.id);
206
+ }
207
+ }
208
+ return result;
209
+ }
210
+
211
+ /** Only checks self-consistency (another group's own membership) -- whether any Doc/Rule/Playbook's own explicit scope still references this group is a cross-store concern the caller (deleteScopeGroup, which holds both stores) checks first. */
212
+ delete(groupId: string): void {
213
+ inTransaction(this.db, () => {
214
+ const referencingGroup = this.db
215
+ .prepare("SELECT group_id FROM scope_group_members WHERE member_type = 'group' AND member_id = ? LIMIT 1")
216
+ .get(groupId) as { group_id: string } | null;
217
+ if (referencingGroup)
218
+ throw new ScopeGroupInUseError(`scope group "${groupId}" is still a member of scope group "${referencingGroup.group_id}"`);
219
+ this.db.prepare("DELETE FROM scope_group_members WHERE group_id = ?").run(groupId);
220
+ this.db.prepare("DELETE FROM scope_groups WHERE id = ?").run(groupId);
221
+ });
222
+ }
223
+ }
package/src/service.ts CHANGED
@@ -15,11 +15,14 @@ import { type AuthorityClaim, AuthorityRegistry, AuthorizedArtifactWriter } from
15
15
  import { SERVICE_MAX_BODY_BYTES, SQLITE_SCHEMA_VERSION } from "./constants.ts";
16
16
  import { migrateDb, openDb, schemaVersion } from "./db.ts";
17
17
  import { Discussions } from "./discussion/discussion-service.ts";
18
- import type { TaskEventContext } from "./domain/task-event.ts";
19
- import type { TaskViewMode } from "./domain/task-scope.ts";
18
+ import { SQLiteDiscussionRoundStore } from "./discussion/sqlite-discussion-round-store.ts";
19
+ import type { GateRunner } from "./gate/gate-runner.ts";
20
+ import { SQLiteGateRunner } from "./gate/sqlite-gate-runner.ts";
21
+ import { SQLiteGraphProjectionStore } from "./graph-projection/sqlite-graph-projection-store.ts";
20
22
  import { createPapyrusVehicleRegistry } from "./handlers/registry.ts";
21
23
  import { logEvent } from "./log/log.ts";
22
24
  import { Logs } from "./log/log-service.ts";
25
+ import { SQLiteLogStore } from "./log/sqlite-log-store.ts";
23
26
  import { OperationRegistry } from "./module-registry.ts";
24
27
  import { DISCUSS_OPERATION_NAMES, discussOperations } from "./modules/discuss.ts";
25
28
  import { DOCS_OPERATION_NAMES, docsOperations } from "./modules/docs.ts";
@@ -30,28 +33,27 @@ import { type OperationInput, optionalNumber, optionalString, string } from "./m
30
33
  import { PLAYBOOKS_OPERATION_NAMES, playbooksOperations } from "./modules/playbooks.ts";
31
34
  import { PROJECTS_OPERATION_NAMES, projectsOperations } from "./modules/projects.ts";
32
35
  import { RULES_OPERATION_NAMES, rulesOperations } from "./modules/rules.ts";
36
+ import { SCOPE_GROUPS_OPERATION_NAMES, scopeGroupsOperations } from "./modules/scope-groups.ts";
33
37
  import { SESSION_IDENTITY_OPERATION_NAMES, sessionIdentityOperations } from "./modules/session-identity.ts";
34
38
  import { TASKS_OPERATION_NAMES, tasksOperations } from "./modules/tasks.ts";
35
39
  import { NOTE_SUBTYPE, Notes } from "./note/note-service.ts";
40
+ import { SQLiteNoteEventStore } from "./note/sqlite-note-event-store.ts";
41
+ import { SQLiteProjectRegistryStore } from "./project-registry/sqlite-project-registry-store.ts";
36
42
  import { listInjectableRules } from "./rules/rules-service.ts";
43
+ import { SQLiteScopeGroupStore } from "./scope-group/sqlite-scope-group-store.ts";
37
44
  import { InvalidSessionSecretError, SessionIdentity } from "./session-identity/session-identity-service.ts";
38
- import type { GateRunner } from "./stores/gate-runner.ts";
39
- import { SQLiteDiscussionRoundStore } from "./stores/sqlite-discussion-round-store.ts";
40
- import { SQLiteGateRunner } from "./stores/sqlite-gate-runner.ts";
41
- import { SQLiteGraphProjectionStore } from "./stores/sqlite-graph-projection-store.ts";
42
- import { SQLiteLogStore } from "./stores/sqlite-log-store.ts";
43
- import { SQLiteNoteEventStore } from "./stores/sqlite-note-event-store.ts";
44
- import { SQLiteProjectRegistryStore } from "./stores/sqlite-project-registry-store.ts";
45
- import { SQLiteSessionIdentityStore } from "./stores/sqlite-session-identity-store.ts";
46
- import { SQLiteTaskCreateRequestStore } from "./stores/sqlite-task-create-request-store.ts";
47
- import { SQLiteTaskEventStore } from "./stores/sqlite-task-event-store.ts";
48
- import { SQLiteTaskFocusStore } from "./stores/sqlite-task-focus-store.ts";
49
- import { SQLiteTaskLeaseStore } from "./stores/sqlite-task-lease-store.ts";
50
- import { SQLiteTaskMutationRequestStore } from "./stores/sqlite-task-mutation-request-store.ts";
51
- import { SQLiteTaskScopeStore } from "./stores/sqlite-task-scope-store.ts";
52
- import type { TaskEventStore } from "./stores/task-event-store.ts";
53
- import type { TaskScopeStore } from "./stores/task-scope-store.ts";
45
+ import { SQLiteSessionIdentityStore } from "./session-identity/sqlite-session-identity-store.ts";
54
46
  import { type TaskStatus, Tasks } from "./task/task-service.ts";
47
+ import { SQLiteTaskCreateRequestStore } from "./task-create-request/sqlite-task-create-request-store.ts";
48
+ import { SQLiteTaskEventStore } from "./task-event/sqlite-task-event-store.ts";
49
+ import type { TaskEventContext } from "./task-event/task-event.ts";
50
+ import type { TaskEventStore } from "./task-event/task-event-store.ts";
51
+ import { SQLiteTaskFocusStore } from "./task-focus/sqlite-task-focus-store.ts";
52
+ import { SQLiteTaskLeaseStore } from "./task-lease/sqlite-task-lease-store.ts";
53
+ import { SQLiteTaskMutationRequestStore } from "./task-mutation-request/sqlite-task-mutation-request-store.ts";
54
+ import { SQLiteTaskScopeStore } from "./task-scope/sqlite-task-scope-store.ts";
55
+ import type { TaskViewMode } from "./task-scope/task-scope.ts";
56
+ import type { TaskScopeStore } from "./task-scope/task-scope-store.ts";
55
57
  import { VERSION } from "./version.ts";
56
58
 
57
59
  /**
@@ -100,6 +102,7 @@ export const EXPECTED_OPERATION_NAMES = [
100
102
  ...RULES_OPERATION_NAMES,
101
103
  ...PLAYBOOKS_OPERATION_NAMES,
102
104
  ...PROJECTS_OPERATION_NAMES,
105
+ ...SCOPE_GROUPS_OPERATION_NAMES,
103
106
  ...GRAPH_PROJECTION_OPERATION_NAMES,
104
107
  ...LOGS_OPERATION_NAMES,
105
108
  ...SESSION_IDENTITY_OPERATION_NAMES,
@@ -410,9 +413,13 @@ function handlers(
410
413
  "docs.assign_project": forwardToModule("docs.assign_project"),
411
414
  "docs.scope": forwardToModule("docs.scope"),
412
415
  "docs.set_global": forwardToModule("docs.set_global"),
416
+ "docs.set_none": forwardToModule("docs.set_none"),
413
417
  "docs.add_project": forwardToModule("docs.add_project"),
414
418
  "docs.remove_project": forwardToModule("docs.remove_project"),
415
419
  "docs.replace_projects": forwardToModule("docs.replace_projects"),
420
+ "docs.add_group": forwardToModule("docs.add_group"),
421
+ "docs.remove_group": forwardToModule("docs.remove_group"),
422
+ "docs.replace_groups": forwardToModule("docs.replace_groups"),
416
423
  "docs.update": forwardToModule("docs.update"),
417
424
  "notes.capture": forwardToModule("notes.capture"),
418
425
  "notes.list": forwardToModule("notes.list"),
@@ -431,9 +438,13 @@ function handlers(
431
438
  "rules.assign_project": forwardToModule("rules.assign_project"),
432
439
  "rules.scope": forwardToModule("rules.scope"),
433
440
  "rules.set_global": forwardToModule("rules.set_global"),
441
+ "rules.set_none": forwardToModule("rules.set_none"),
434
442
  "rules.add_project": forwardToModule("rules.add_project"),
435
443
  "rules.remove_project": forwardToModule("rules.remove_project"),
436
444
  "rules.replace_projects": forwardToModule("rules.replace_projects"),
445
+ "rules.add_group": forwardToModule("rules.add_group"),
446
+ "rules.remove_group": forwardToModule("rules.remove_group"),
447
+ "rules.replace_groups": forwardToModule("rules.replace_groups"),
437
448
  "rules.update": forwardToModule("rules.update"),
438
449
  "playbooks.create": forwardToModule("playbooks.create"),
439
450
  "playbooks.list": forwardToModule("playbooks.list"),
@@ -445,9 +456,13 @@ function handlers(
445
456
  "playbooks.assign_project": forwardToModule("playbooks.assign_project"),
446
457
  "playbooks.scope": forwardToModule("playbooks.scope"),
447
458
  "playbooks.set_global": forwardToModule("playbooks.set_global"),
459
+ "playbooks.set_none": forwardToModule("playbooks.set_none"),
448
460
  "playbooks.add_project": forwardToModule("playbooks.add_project"),
449
461
  "playbooks.remove_project": forwardToModule("playbooks.remove_project"),
450
462
  "playbooks.replace_projects": forwardToModule("playbooks.replace_projects"),
463
+ "playbooks.add_group": forwardToModule("playbooks.add_group"),
464
+ "playbooks.remove_group": forwardToModule("playbooks.remove_group"),
465
+ "playbooks.replace_groups": forwardToModule("playbooks.replace_groups"),
451
466
  "playbooks.update": forwardToModule("playbooks.update"),
452
467
  "playbooks.contain": forwardToModule("playbooks.contain"),
453
468
  "playbooks.uncontain": forwardToModule("playbooks.uncontain"),
@@ -456,6 +471,13 @@ function handlers(
456
471
  "projects.list": forwardToModule("projects.list"),
457
472
  "projects.resolve": forwardToModule("projects.resolve"),
458
473
  "projects.register": forwardToModule("projects.register"),
474
+ "scope_groups.list": forwardToModule("scope_groups.list"),
475
+ "scope_groups.resolve": forwardToModule("scope_groups.resolve"),
476
+ "scope_groups.register": forwardToModule("scope_groups.register"),
477
+ "scope_groups.show": forwardToModule("scope_groups.show"),
478
+ "scope_groups.add_member": forwardToModule("scope_groups.add_member"),
479
+ "scope_groups.remove_member": forwardToModule("scope_groups.remove_member"),
480
+ "scope_groups.delete": forwardToModule("scope_groups.delete"),
459
481
  "graph_projection.apply": forwardToModule("graph_projection.apply"),
460
482
  "graph_projection.checkpoint": forwardToModule("graph_projection.checkpoint"),
461
483
  "logs.append": forwardToModule("logs.append"),
@@ -491,6 +513,7 @@ export function createPapyrusService(path: string): PapyrusService {
491
513
  const projections = new SQLiteGraphProjectionStore(db);
492
514
  const artifactScopes = new SQLiteArtifactScopeStore(db);
493
515
  const projectRegistry = new SQLiteProjectRegistryStore(db);
516
+ const scopeGroups = new SQLiteScopeGroupStore(db);
494
517
  const logs = new Logs(new SQLiteLogStore(db));
495
518
  const sessionIdentity = new SessionIdentity(new SQLiteSessionIdentityStore(db));
496
519
  const discussions = new Discussions(artifacts, new SQLiteDiscussionRoundStore(db));
@@ -506,6 +529,7 @@ export function createPapyrusService(path: string): PapyrusService {
506
529
  discussions,
507
530
  sessionIdentity,
508
531
  projectRegistry,
532
+ scopeGroups,
509
533
  });
510
534
  const moduleRegistry = new OperationRegistry();
511
535
  moduleRegistry.registerAll(notesOperations(notes));
@@ -513,12 +537,13 @@ export function createPapyrusService(path: string): PapyrusService {
513
537
  moduleRegistry.registerAll(sessionIdentityOperations(sessionIdentity));
514
538
  moduleRegistry.registerAll(discussOperations(discussions));
515
539
  moduleRegistry.registerAll(tasksOperations(tasks, artifacts, sessionIdentity));
516
- moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority, projectRegistry));
517
- moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes, projectRegistry));
540
+ moduleRegistry.registerAll(docsOperations(artifacts, artifactScopes, authority, projectRegistry, scopeGroups));
541
+ moduleRegistry.registerAll(rulesOperations(artifacts, artifactScopes, projectRegistry, scopeGroups));
518
542
  moduleRegistry.registerAll(
519
- playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity, registry: projectRegistry }),
543
+ playbooksOperations({ artifacts, events, scopes, artifactScopes, tasks, sessionIdentity, registry: projectRegistry, scopeGroups }),
520
544
  );
521
545
  moduleRegistry.registerAll(projectsOperations(projectRegistry));
546
+ moduleRegistry.registerAll(scopeGroupsOperations(scopeGroups, projectRegistry, artifactScopes));
522
547
  moduleRegistry.registerAll(graphProjectionOperations(artifacts, projections, authority));
523
548
  const registry = handlers(artifacts, gates, tasks, notes, events, scopes, artifactScopes, () => migrateDb(db), moduleRegistry, authority);
524
549
  const state = (): SchemaState => {
@@ -4,8 +4,8 @@ import {
4
4
  releaseSessionIdentity,
5
5
  verifySessionSecret,
6
6
  } from "@danypops/vehicle-server/session-identity";
7
- import { assertValidSessionId } from "../domain/session-identity.ts";
8
- import type { SessionIdentityStore } from "../stores/session-identity-store.ts";
7
+ import { assertValidSessionId } from "./session-identity.ts";
8
+ import type { SessionIdentityStore } from "./session-identity-store.ts";
9
9
 
10
10
  export interface RegisterSessionIdentityResult {
11
11
  sessionId: string;