agent-relay-server 0.57.2 → 0.59.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 (148) hide show
  1. package/docs/openapi.json +151 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-CHKzD4RY.js → MessageBubble-BrEKymoV.js} +2 -2
  4. package/public/assets/{MessageBubble-CHKzD4RY.js.map → MessageBubble-BrEKymoV.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BFVTJa0q.js → PlanGraphPanel-CXix1C-H.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BFVTJa0q.js.map → PlanGraphPanel-CXix1C-H.js.map} +1 -1
  7. package/public/assets/{activity-M-dFLU_O.js → activity-CRafQ1Je.js} +2 -2
  8. package/public/assets/{activity-M-dFLU_O.js.map → activity-CRafQ1Je.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CvHSoyCX.js → agent-profiles-DW4o5-ND.js} +2 -2
  10. package/public/assets/{agent-profiles-CvHSoyCX.js.map → agent-profiles-DW4o5-ND.js.map} +1 -1
  11. package/public/assets/agents-Cbegv0Hm.js +2 -0
  12. package/public/assets/{agents-DZBM5dp_.js.map → agents-Cbegv0Hm.js.map} +1 -1
  13. package/public/assets/analytics-DX6T7JX3.js +910 -0
  14. package/public/assets/{analytics-bvjb3Urs.js.map → analytics-DX6T7JX3.js.map} +1 -1
  15. package/public/assets/{automation-CDSn9Uq-.js → automation-CzSBv4gg.js} +2 -2
  16. package/public/assets/{automation-CDSn9Uq-.js.map → automation-CzSBv4gg.js.map} +1 -1
  17. package/public/assets/{badge-Dj6PREuI.js → badge-Cy6qO_Jz.js} +2 -2
  18. package/public/assets/{badge-Dj6PREuI.js.map → badge-Cy6qO_Jz.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-CdYg2HYc.js → branch-state-badge-CRA50r81.js} +2 -2
  20. package/public/assets/{branch-state-badge-CdYg2HYc.js.map → branch-state-badge-CRA50r81.js.map} +1 -1
  21. package/public/assets/{button-Cqpufqam.js → button-73wpRw65.js} +2 -2
  22. package/public/assets/{button-Cqpufqam.js.map → button-73wpRw65.js.map} +1 -1
  23. package/public/assets/{card-OmDF73CE.js → card-BU6PvVHv.js} +2 -2
  24. package/public/assets/{card-OmDF73CE.js.map → card-BU6PvVHv.js.map} +1 -1
  25. package/public/assets/{channels-BkxrdOFZ.js → channels-B1MQCPCT.js} +2 -2
  26. package/public/assets/{channels-BkxrdOFZ.js.map → channels-B1MQCPCT.js.map} +1 -1
  27. package/public/assets/chat-BqIaFYGJ.js +2 -0
  28. package/public/assets/{chat-YzX1z76l.js.map → chat-BqIaFYGJ.js.map} +1 -1
  29. package/public/assets/{connectors-Bg3rHgl_.js → connectors-C9aFJIrh.js} +2 -2
  30. package/public/assets/{connectors-Bg3rHgl_.js.map → connectors-C9aFJIrh.js.map} +1 -1
  31. package/public/assets/{copy-button-DI7b8A7_.js → copy-button-HRopUwui.js} +2 -2
  32. package/public/assets/{copy-button-DI7b8A7_.js.map → copy-button-HRopUwui.js.map} +1 -1
  33. package/public/assets/display-CketawEF.js.map +1 -1
  34. package/public/assets/{dist-CNjnTZEI.js → dist-CC5f7oYw.js} +2 -2
  35. package/public/assets/{dist-CNjnTZEI.js.map → dist-CC5f7oYw.js.map} +1 -1
  36. package/public/assets/{dist-DkeTrbTA.js → dist-CLHMeXYi.js} +2 -2
  37. package/public/assets/{dist-DkeTrbTA.js.map → dist-CLHMeXYi.js.map} +1 -1
  38. package/public/assets/{dist-C5t_OxI0.js → dist-Ci1MM51P.js} +2 -2
  39. package/public/assets/{dist-C5t_OxI0.js.map → dist-Ci1MM51P.js.map} +1 -1
  40. package/public/assets/{dist-CV3sgFiv.js → dist-Cko2KPW8.js} +2 -2
  41. package/public/assets/{dist-CV3sgFiv.js.map → dist-Cko2KPW8.js.map} +1 -1
  42. package/public/assets/dist-D41tzJYg.js +2 -0
  43. package/public/assets/{dist-yYauqYKR.js.map → dist-D41tzJYg.js.map} +1 -1
  44. package/public/assets/{dist-CoUvwLTy.js → dist-DFLciw0h.js} +2 -2
  45. package/public/assets/{dist-CoUvwLTy.js.map → dist-DFLciw0h.js.map} +1 -1
  46. package/public/assets/dist-DUoZnlB2.js +2 -0
  47. package/public/assets/{dist-DmhyU0Na.js.map → dist-DUoZnlB2.js.map} +1 -1
  48. package/public/assets/dist-De2oguBm.js +2 -0
  49. package/public/assets/{dist-DxkMwkrr.js.map → dist-De2oguBm.js.map} +1 -1
  50. package/public/assets/{dist-1x-LE9Uk.js → dist-DmN94rc8.js} +2 -2
  51. package/public/assets/{dist-1x-LE9Uk.js.map → dist-DmN94rc8.js.map} +1 -1
  52. package/public/assets/{dist-DV2tZ2fq.js → dist-Kkpnjx_M.js} +2 -2
  53. package/public/assets/{dist-DV2tZ2fq.js.map → dist-Kkpnjx_M.js.map} +1 -1
  54. package/public/assets/{dist-Ca3ujqG_.js → dist-T7u7IZGJ.js} +2 -2
  55. package/public/assets/{dist-Ca3ujqG_.js.map → dist-T7u7IZGJ.js.map} +1 -1
  56. package/public/assets/{es2015-DcavLcQ0.js → es2015-Df08Opjx.js} +2 -2
  57. package/public/assets/{es2015-DcavLcQ0.js.map → es2015-Df08Opjx.js.map} +1 -1
  58. package/public/assets/{formatted-body-impl-D8i9qqL1.js → formatted-body-impl-DOXH7oDF.js} +2 -2
  59. package/public/assets/{formatted-body-impl-D8i9qqL1.js.map → formatted-body-impl-DOXH7oDF.js.map} +1 -1
  60. package/public/assets/index-DaNOQJci.css +2 -0
  61. package/public/assets/index-br_Y6Cvt.js +21 -0
  62. package/public/assets/{index-Ba2RDJaA.js.map → index-br_Y6Cvt.js.map} +1 -1
  63. package/public/assets/{input-C_dl_9fG.js → input-B2EDz-BE.js} +2 -2
  64. package/public/assets/{input-C_dl_9fG.js.map → input-B2EDz-BE.js.map} +1 -1
  65. package/public/assets/{insights-D1aANwZ2.js → insights-B1edq5lk.js} +2 -2
  66. package/public/assets/{insights-D1aANwZ2.js.map → insights-B1edq5lk.js.map} +1 -1
  67. package/public/assets/{integrations-BrAr9bap.js → integrations-Cm4fjp7_.js} +2 -2
  68. package/public/assets/{integrations-BrAr9bap.js.map → integrations-Cm4fjp7_.js.map} +1 -1
  69. package/public/assets/{lib-B3iMVsf-.js → lib-BZrjIp-o.js} +3 -3
  70. package/public/assets/{lib-B3iMVsf-.js.map → lib-BZrjIp-o.js.map} +1 -1
  71. package/public/assets/lucide-react-H_-8G79A.js +9 -0
  72. package/public/assets/{lucide-react-CMhBract.js.map → lucide-react-H_-8G79A.js.map} +1 -1
  73. package/public/assets/{maintenance-C-xspsMG.js → maintenance-D6zvw0fU.js} +2 -2
  74. package/public/assets/{maintenance-C-xspsMG.js.map → maintenance-D6zvw0fU.js.map} +1 -1
  75. package/public/assets/{managed-agents-Bf3ni0vo.js → managed-agents-CzfEznM1.js} +2 -2
  76. package/public/assets/{managed-agents-Bf3ni0vo.js.map → managed-agents-CzfEznM1.js.map} +1 -1
  77. package/public/assets/{markdown-preview-impl-DKbUBTf_.js → markdown-preview-impl-DZlCaTHQ.js} +2 -2
  78. package/public/assets/{markdown-preview-impl-DKbUBTf_.js.map → markdown-preview-impl-DZlCaTHQ.js.map} +1 -1
  79. package/public/assets/memory-CUUAzJDc.js +2 -0
  80. package/public/assets/{memory-DeLwJFwH.js.map → memory-CUUAzJDc.js.map} +1 -1
  81. package/public/assets/messages-6QZH46LG.js +2 -0
  82. package/public/assets/{messages-Cww6dZnM.js.map → messages-6QZH46LG.js.map} +1 -1
  83. package/public/assets/{orchestrators-DRjwROXw.js → orchestrators-CrcqkPuc.js} +2 -2
  84. package/public/assets/{orchestrators-DRjwROXw.js.map → orchestrators-CrcqkPuc.js.map} +1 -1
  85. package/public/assets/overview-418xXZO_.js +2 -0
  86. package/public/assets/{overview-DjcRomU_.js.map → overview-418xXZO_.js.map} +1 -1
  87. package/public/assets/pairs-CnedATgT.js +2 -0
  88. package/public/assets/{pairs-DT0u2fnW.js.map → pairs-CnedATgT.js.map} +1 -1
  89. package/public/assets/{react-dom-Dtl9-x_1.js → react-dom-WI6TjVe7.js} +2 -2
  90. package/public/assets/{react-dom-Dtl9-x_1.js.map → react-dom-WI6TjVe7.js.map} +1 -1
  91. package/public/assets/{security-Bwg3oFI8.js → security-DS13pbag.js} +2 -2
  92. package/public/assets/{security-Bwg3oFI8.js.map → security-DS13pbag.js.map} +1 -1
  93. package/public/assets/{select-Cariwv69.js → select-BOprGZDS.js} +2 -2
  94. package/public/assets/{select-Cariwv69.js.map → select-BOprGZDS.js.map} +1 -1
  95. package/public/assets/{settings-Bd6Ug3DO.js → settings-BgInn_f3.js} +4 -4
  96. package/public/assets/{settings-Bd6Ug3DO.js.map → settings-BgInn_f3.js.map} +1 -1
  97. package/public/assets/{store-BgJq_lN9.js → store-DHO_a8BD.js} +5 -5
  98. package/public/assets/{store-BgJq_lN9.js.map → store-DHO_a8BD.js.map} +1 -1
  99. package/public/assets/{tasks-DVz1zhOU.js → tasks-B-4oCPuv.js} +2 -2
  100. package/public/assets/{tasks-DVz1zhOU.js.map → tasks-B-4oCPuv.js.map} +1 -1
  101. package/public/assets/teams-sCQAs_QA.js +2 -0
  102. package/public/assets/{teams-TqFntFB_.js.map → teams-sCQAs_QA.js.map} +1 -1
  103. package/public/assets/{terminal-viewer-impl-OsJ5crlx.js → terminal-viewer-impl-BhqS2XpS.js} +3 -3
  104. package/public/assets/{terminal-viewer-impl-OsJ5crlx.js.map → terminal-viewer-impl-BhqS2XpS.js.map} +1 -1
  105. package/public/assets/{use-keyboard-viewport-BYHcL7sP.js → use-keyboard-viewport-fOL_3RHB.js} +2 -2
  106. package/public/assets/{use-keyboard-viewport-BYHcL7sP.js.map → use-keyboard-viewport-fOL_3RHB.js.map} +1 -1
  107. package/public/assets/{work-queue-B4b2jPLR.js → work-queue-BDhjlwUH.js} +2 -2
  108. package/public/assets/{work-queue-B4b2jPLR.js.map → work-queue-BDhjlwUH.js.map} +1 -1
  109. package/public/assets/workspaces-6I-C69nf.js +3 -0
  110. package/public/assets/{workspaces-m97LP4Eu.js.map → workspaces-6I-C69nf.js.map} +1 -1
  111. package/public/index.html +21 -21
  112. package/src/db/index.ts +2 -0
  113. package/src/db/project-entries.ts +270 -0
  114. package/src/db/projects.ts +304 -0
  115. package/src/db/schema.ts +50 -0
  116. package/src/db/stats.ts +209 -38
  117. package/src/db/teams.ts +13 -2
  118. package/src/maintenance.ts +9 -2
  119. package/src/mcp-project-tools.ts +352 -0
  120. package/src/mcp.ts +6 -0
  121. package/src/project-ingest.ts +186 -0
  122. package/src/project-instructions.ts +176 -0
  123. package/src/routes/agents-spawn.ts +2 -0
  124. package/src/routes/commands.ts +11 -5
  125. package/src/routes/index.ts +5 -0
  126. package/src/routes/projects.ts +55 -0
  127. package/src/runtime-tokens.ts +6 -0
  128. package/src/security.ts +4 -0
  129. package/src/services/spawn-agent.ts +33 -2
  130. package/src/services/team.ts +5 -1
  131. package/src/token-db.ts +3 -3
  132. package/src/workspace-merge.ts +11 -0
  133. package/src/workspace-pr-completion.ts +57 -1
  134. package/public/assets/agents-DZBM5dp_.js +0 -2
  135. package/public/assets/analytics-bvjb3Urs.js +0 -910
  136. package/public/assets/chat-YzX1z76l.js +0 -2
  137. package/public/assets/dist-DmhyU0Na.js +0 -2
  138. package/public/assets/dist-DxkMwkrr.js +0 -2
  139. package/public/assets/dist-yYauqYKR.js +0 -2
  140. package/public/assets/index-Ba2RDJaA.js +0 -21
  141. package/public/assets/index-CtFXWxBJ.css +0 -2
  142. package/public/assets/lucide-react-CMhBract.js +0 -9
  143. package/public/assets/memory-DeLwJFwH.js +0 -2
  144. package/public/assets/messages-Cww6dZnM.js +0 -2
  145. package/public/assets/overview-DjcRomU_.js +0 -2
  146. package/public/assets/pairs-DT0u2fnW.js +0 -2
  147. package/public/assets/teams-TqFntFB_.js +0 -2
  148. package/public/assets/workspaces-m97LP4Eu.js +0 -3
@@ -0,0 +1,270 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDb, ValidationError } from "./connection.ts";
3
+ import { getEntry as getBlackboardEntry, listEntries as listBlackboardEntries } from "./blackboard.ts";
4
+ import { parseJson } from "../utils";
5
+ import { PROJECT_ENTRY_KINDS, PROJECT_ENTRY_SOURCES, type BlackboardEntryKind, type ProjectEntry, type ProjectEntryKind, type ProjectEntrySource, type ProjectEntryStatus } from "../types";
6
+
7
+ // Project-entry CRUD (#405) — a near-exact copy of src/db/blackboard.ts re-scoped
8
+ // from team_id → project_id, plus the source/origin_ref provenance columns. Kept as
9
+ // a parallel copy (not folded with blackboard) because the two scopes have different
10
+ // lifetimes and column sets; the duplication is intentional and ratchet-noted.
11
+
12
+ const PROJECT_ENTRY_STATUSES = ["active", "superseded", "retracted"] as const;
13
+
14
+ interface ProjectEntryRow {
15
+ id: string;
16
+ project_id: string;
17
+ kind: ProjectEntryKind;
18
+ title: string;
19
+ body: string | null;
20
+ status: ProjectEntryStatus;
21
+ supersedes: string | null;
22
+ tags: string | null;
23
+ author_id: string;
24
+ source: ProjectEntrySource;
25
+ origin_ref: string | null;
26
+ created_at: number;
27
+ updated_at: number;
28
+ }
29
+
30
+ interface WriteProjectEntryInput {
31
+ projectId: string;
32
+ kind: ProjectEntryKind;
33
+ title: string;
34
+ body?: string;
35
+ tags?: string[];
36
+ authorId: string;
37
+ supersedes?: string;
38
+ source?: ProjectEntrySource;
39
+ originRef?: string;
40
+ }
41
+
42
+ function projectEntryId(): string {
43
+ return `pe_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
44
+ }
45
+
46
+ function rowToEntry(row: ProjectEntryRow): ProjectEntry {
47
+ const tags = parseJson<string[]>(row.tags, []).filter((tag) => typeof tag === "string");
48
+ return {
49
+ id: row.id,
50
+ projectId: row.project_id,
51
+ kind: row.kind,
52
+ title: row.title,
53
+ ...(row.body ? { body: row.body } : {}),
54
+ status: row.status,
55
+ ...(row.supersedes ? { supersedes: row.supersedes } : {}),
56
+ tags,
57
+ authorId: row.author_id,
58
+ source: row.source,
59
+ ...(row.origin_ref ? { originRef: row.origin_ref } : {}),
60
+ createdAt: row.created_at,
61
+ updatedAt: row.updated_at,
62
+ };
63
+ }
64
+
65
+ function assertKind(kind: ProjectEntryKind): void {
66
+ if (!PROJECT_ENTRY_KINDS.includes(kind)) {
67
+ throw new ValidationError(`kind must be one of: ${PROJECT_ENTRY_KINDS.join(", ")}`);
68
+ }
69
+ }
70
+
71
+ function assertSource(source: ProjectEntrySource): void {
72
+ if (!PROJECT_ENTRY_SOURCES.includes(source)) {
73
+ throw new ValidationError(`source must be one of: ${PROJECT_ENTRY_SOURCES.join(", ")}`);
74
+ }
75
+ }
76
+
77
+ function assertStatus(status: ProjectEntryStatus): void {
78
+ if (!PROJECT_ENTRY_STATUSES.includes(status)) {
79
+ throw new ValidationError(`status must be one of: ${PROJECT_ENTRY_STATUSES.join(", ")}`);
80
+ }
81
+ }
82
+
83
+ function escapeLike(value: string): string {
84
+ return value.replace(/[\\%_]/g, (char) => `\\${char}`);
85
+ }
86
+
87
+ export function writeProjectEntry(input: WriteProjectEntryInput): ProjectEntry {
88
+ assertKind(input.kind);
89
+ const source = input.source ?? "manual";
90
+ assertSource(source);
91
+ const id = projectEntryId();
92
+ const now = Date.now();
93
+ getDb().transaction(() => {
94
+ if (input.supersedes) {
95
+ const previous = getProjectEntry(input.supersedes);
96
+ if (!previous) throw new ValidationError(`project entry ${input.supersedes} not found`);
97
+ if (previous.projectId !== input.projectId) throw new ValidationError("superseded entry belongs to another project");
98
+ if (previous.status === "retracted") throw new ValidationError("cannot supersede a retracted entry");
99
+ getDb().query("UPDATE project_entries SET status = 'superseded', updated_at = ? WHERE id = ?")
100
+ .run(now, input.supersedes);
101
+ }
102
+ getDb().query(`
103
+ INSERT INTO project_entries (id, project_id, kind, title, body, status, supersedes, tags, author_id, source, origin_ref, created_at, updated_at)
104
+ VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)
105
+ `).run(
106
+ id,
107
+ input.projectId,
108
+ input.kind,
109
+ input.title,
110
+ input.body ?? null,
111
+ input.supersedes ?? null,
112
+ input.tags?.length ? JSON.stringify([...new Set(input.tags)]) : null,
113
+ input.authorId,
114
+ source,
115
+ input.originRef ?? null,
116
+ now,
117
+ now,
118
+ );
119
+ })();
120
+ return getProjectEntry(id)!;
121
+ }
122
+
123
+ export function getProjectEntry(id: string): ProjectEntry | null {
124
+ const row = getDb().query("SELECT * FROM project_entries WHERE id = ?").get(id) as ProjectEntryRow | undefined;
125
+ return row ? rowToEntry(row) : null;
126
+ }
127
+
128
+ export function getProjectEntryChain(id: string): ProjectEntry[] {
129
+ const chain: ProjectEntry[] = [];
130
+ const seen = new Set<string>();
131
+ let current = getProjectEntry(id);
132
+ while (current && !seen.has(current.id)) {
133
+ chain.push(current);
134
+ seen.add(current.id);
135
+ current = current.supersedes ? getProjectEntry(current.supersedes) : null;
136
+ }
137
+ return chain;
138
+ }
139
+
140
+ export function listProjectEntries(
141
+ projectId: string,
142
+ opts: { kind?: ProjectEntryKind; includeInactive?: boolean } = {},
143
+ ): ProjectEntry[] {
144
+ if (opts.kind) assertKind(opts.kind);
145
+ const conditions = ["project_id = ?"];
146
+ const params: string[] = [projectId];
147
+ if (opts.kind) {
148
+ conditions.push("kind = ?");
149
+ params.push(opts.kind);
150
+ }
151
+ if (!opts.includeInactive) conditions.push("status = 'active'");
152
+ const rows = getDb().query(`
153
+ SELECT * FROM project_entries
154
+ WHERE ${conditions.join(" AND ")}
155
+ ORDER BY updated_at DESC, created_at DESC, id DESC
156
+ `).all(...params) as ProjectEntryRow[];
157
+ return rows.map(rowToEntry);
158
+ }
159
+
160
+ export function searchProjectEntries(
161
+ projectId: string,
162
+ query: string,
163
+ opts: { kind?: ProjectEntryKind } = {},
164
+ ): ProjectEntry[] {
165
+ if (opts.kind) assertKind(opts.kind);
166
+ const like = `%${escapeLike(query)}%`;
167
+ const conditions = [
168
+ "project_id = ?",
169
+ "status = 'active'",
170
+ "(title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\' OR tags LIKE ? ESCAPE '\\')",
171
+ ];
172
+ const params: string[] = [projectId, like, like, like];
173
+ if (opts.kind) {
174
+ conditions.push("kind = ?");
175
+ params.push(opts.kind);
176
+ }
177
+ const rows = getDb().query(`
178
+ SELECT * FROM project_entries
179
+ WHERE ${conditions.join(" AND ")}
180
+ ORDER BY updated_at DESC, created_at DESC, id DESC
181
+ `).all(...params) as ProjectEntryRow[];
182
+ return rows.map(rowToEntry);
183
+ }
184
+
185
+ export function retractProjectEntry(id: string): ProjectEntry | null {
186
+ const existing = getProjectEntry(id);
187
+ if (!existing) return null;
188
+ assertStatus("retracted");
189
+ getDb().query("UPDATE project_entries SET status = 'retracted', updated_at = ? WHERE id = ?")
190
+ .run(Date.now(), id);
191
+ return getProjectEntry(id);
192
+ }
193
+
194
+ // --- blackboard → project promotion (#404): the lossy→durable lift ---------------
195
+ // The ONLY bridge between the ephemeral team blackboard and the durable project ledger.
196
+ // Promotion is ALWAYS explicit — nothing here is wired into blackboard writes or team
197
+ // dissolve; an agent/operator decides what graduates. Blackboard kinds map to project
198
+ // kinds 1:1 except `ruled_out`, which becomes a durable `rule` (a "don't do X" lands as
199
+ // a standing rule). `memory` has no blackboard source — it is project-native only.
200
+ const BLACKBOARD_TO_PROJECT_KIND: Record<BlackboardEntryKind, ProjectEntryKind> = {
201
+ decision: "decision",
202
+ fact: "fact",
203
+ ruled_out: "rule",
204
+ };
205
+
206
+ export interface PromoteBlackboardEntryInput {
207
+ blackboardEntryId: string;
208
+ projectId: string;
209
+ }
210
+
211
+ /** Find the active project entry already promoted from `originRef` into `projectId`,
212
+ * if any — the anchor for idempotent re-promotion. */
213
+ function activePromotedEntry(projectId: string, originRef: string): ProjectEntry | null {
214
+ const row = getDb().query(`
215
+ SELECT * FROM project_entries
216
+ WHERE project_id = ? AND origin_ref = ? AND source = 'promoted' AND status = 'active'
217
+ ORDER BY created_at DESC, id DESC
218
+ LIMIT 1
219
+ `).get(projectId, originRef) as ProjectEntryRow | undefined;
220
+ return row ? rowToEntry(row) : null;
221
+ }
222
+
223
+ /** Copy an active blackboard finding into the durable project ledger as a `promoted`
224
+ * entry whose `origin_ref` back-references the `bb_…` id. Preserves title/body/tags/author.
225
+ * Validates the source is `active` (superseded/retracted findings cannot be promoted).
226
+ *
227
+ * Double-promote is IDEMPOTENT-BY-SUPERSEDE: re-promoting the same `bb_…` into the same
228
+ * project supersedes the prior promoted copy rather than duplicating it, so the durable
229
+ * ledger holds at most one active entry per promoted finding while keeping the full
230
+ * supersede chain for audit. */
231
+ export function promoteBlackboardEntry(input: PromoteBlackboardEntryInput): ProjectEntry {
232
+ const source = getBlackboardEntry(input.blackboardEntryId);
233
+ if (!source) throw new ValidationError(`blackboard entry ${input.blackboardEntryId} not found`);
234
+ if (source.status !== "active") {
235
+ throw new ValidationError(`blackboard entry ${source.id} is ${source.status}; only active entries can be promoted`);
236
+ }
237
+ const existing = activePromotedEntry(input.projectId, source.id);
238
+ return writeProjectEntry({
239
+ projectId: input.projectId,
240
+ kind: BLACKBOARD_TO_PROJECT_KIND[source.kind],
241
+ title: source.title,
242
+ ...(source.body ? { body: source.body } : {}),
243
+ ...(source.tags.length ? { tags: source.tags } : {}),
244
+ authorId: source.authorId,
245
+ source: "promoted",
246
+ originRef: source.id,
247
+ ...(existing ? { supersedes: existing.id } : {}),
248
+ });
249
+ }
250
+
251
+ export interface UnpromotedBlackboardSummary {
252
+ count: number;
253
+ entries: Array<{ id: string; kind: BlackboardEntryKind; title: string }>;
254
+ }
255
+
256
+ /** Active blackboard findings for a team that have NOT been promoted into any project
257
+ * (no active `promoted` project entry back-references them). Surfaced at team dissolve so
258
+ * the operator/agent can choose to lift them rather than silently losing them. */
259
+ export function unpromotedBlackboardEntries(teamId: string): UnpromotedBlackboardSummary {
260
+ const active = listBlackboardEntries(teamId);
261
+ const promotedRefs = new Set(
262
+ (getDb().query(
263
+ "SELECT DISTINCT origin_ref FROM project_entries WHERE source = 'promoted' AND status = 'active' AND origin_ref IS NOT NULL",
264
+ ).all() as Array<{ origin_ref: string }>).map((row) => row.origin_ref),
265
+ );
266
+ const entries = active
267
+ .filter((entry) => !promotedRefs.has(entry.id))
268
+ .map((entry) => ({ id: entry.id, kind: entry.kind, title: entry.title }));
269
+ return { count: entries.length, entries };
270
+ }
@@ -0,0 +1,304 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getDb, ValidationError } from "./connection.ts";
3
+ import { listProjectEntries } from "./project-entries.ts";
4
+ import { isPathWithinBase } from "../utils";
5
+ import { PROJECT_EDGE_KINDS, type Project, type ProjectEdge, type ProjectEdgeKind, type ProjectEntryKind, type ProjectRoot, type ResolvedProjectEntry } from "../types";
6
+
7
+ // Projects entity CRUD + roots + cwd→project resolution (#405). Templated on
8
+ // src/db/teams.ts; the durable analog of a team. project_entries CRUD lives in
9
+ // the sibling src/db/project-entries.ts (templated on blackboard.ts).
10
+
11
+ interface ProjectRow {
12
+ id: string;
13
+ slug: string;
14
+ title: string | null;
15
+ description: string | null;
16
+ tenant_id: string | null;
17
+ status: Project["status"];
18
+ created_at: number;
19
+ updated_at: number;
20
+ }
21
+
22
+ interface ProjectRootRow {
23
+ project_id: string;
24
+ root_path: string;
25
+ created_at: number;
26
+ }
27
+
28
+ export function projectId(): string {
29
+ return `project_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
30
+ }
31
+
32
+ function rowToProject(row: ProjectRow): Project {
33
+ return {
34
+ id: row.id,
35
+ slug: row.slug,
36
+ ...(row.title ? { title: row.title } : {}),
37
+ ...(row.description ? { description: row.description } : {}),
38
+ ...(row.tenant_id ? { tenantId: row.tenant_id } : {}),
39
+ status: row.status,
40
+ createdAt: row.created_at,
41
+ updatedAt: row.updated_at,
42
+ };
43
+ }
44
+
45
+ function rowToRoot(row: ProjectRootRow): ProjectRoot {
46
+ return { projectId: row.project_id, rootPath: row.root_path, createdAt: row.created_at };
47
+ }
48
+
49
+ export function getProject(id: string): Project | null {
50
+ const row = getDb().query("SELECT * FROM projects WHERE id = ?").get(id) as ProjectRow | undefined;
51
+ return row ? rowToProject(row) : null;
52
+ }
53
+
54
+ export function getProjectBySlug(slug: string): Project | null {
55
+ const row = getDb().query("SELECT * FROM projects WHERE slug = ?").get(slug) as ProjectRow | undefined;
56
+ return row ? rowToProject(row) : null;
57
+ }
58
+
59
+ /** Resolve a project by id first, then by slug — the form MCP/route callers pass. */
60
+ export function resolveProject(ref: string): Project | null {
61
+ return getProject(ref) ?? getProjectBySlug(ref);
62
+ }
63
+
64
+ export function listProjects(opts: { includeArchived?: boolean } = {}): Project[] {
65
+ const where = opts.includeArchived ? "" : "WHERE status = 'active' ";
66
+ const rows = getDb()
67
+ .query(`SELECT * FROM projects ${where}ORDER BY created_at ASC, id ASC`)
68
+ .all() as ProjectRow[];
69
+ return rows.map(rowToProject);
70
+ }
71
+
72
+ export function rootsForProject(id: string): ProjectRoot[] {
73
+ const rows = getDb()
74
+ .query("SELECT * FROM project_roots WHERE project_id = ? ORDER BY root_path ASC")
75
+ .all(id) as ProjectRootRow[];
76
+ return rows.map(rowToRoot);
77
+ }
78
+
79
+ export interface EnsureProjectInput {
80
+ slug: string;
81
+ title?: string;
82
+ description?: string;
83
+ tenantId?: string;
84
+ }
85
+
86
+ /** Get-or-create a project by slug. The create-on-first-use analog of ensureTeam;
87
+ * updates title/description/tenant on an existing project only when provided. */
88
+ export function ensureProject(input: EnsureProjectInput): Project {
89
+ const slug = input.slug.trim();
90
+ if (!slug) throw new ValidationError("slug required");
91
+ const existing = getProjectBySlug(slug);
92
+ const now = Date.now();
93
+ if (existing) {
94
+ if (input.title !== undefined || input.description !== undefined || input.tenantId !== undefined) {
95
+ getDb().query(`
96
+ UPDATE projects
97
+ SET title = ?, description = ?, tenant_id = ?, updated_at = ?
98
+ WHERE id = ?
99
+ `).run(
100
+ input.title ?? existing.title ?? null,
101
+ input.description ?? existing.description ?? null,
102
+ input.tenantId ?? existing.tenantId ?? null,
103
+ now,
104
+ existing.id,
105
+ );
106
+ return getProject(existing.id)!;
107
+ }
108
+ return existing;
109
+ }
110
+ const id = projectId();
111
+ getDb().query(`
112
+ INSERT INTO projects (id, slug, title, description, tenant_id, status, created_at, updated_at)
113
+ VALUES (?, ?, ?, ?, ?, 'active', ?, ?)
114
+ `).run(id, slug, input.title ?? null, input.description ?? null, input.tenantId ?? null, now, now);
115
+ return getProject(id)!;
116
+ }
117
+
118
+ /** Register a root path for a project (idempotent — UNIQUE(project_id, root_path)). */
119
+ export function addProjectRoot(id: string, rootPath: string): ProjectRoot[] {
120
+ const project = getProject(id);
121
+ if (!project) throw new ValidationError(`project ${id} not found`);
122
+ const path = rootPath.trim();
123
+ if (!path) throw new ValidationError("rootPath required");
124
+ getDb().query(`
125
+ INSERT INTO project_roots (project_id, root_path, created_at)
126
+ VALUES (?, ?, ?)
127
+ ON CONFLICT(project_id, root_path) DO NOTHING
128
+ `).run(id, path, Date.now());
129
+ return rootsForProject(id);
130
+ }
131
+
132
+ /** cwd→project binding: the project whose any root_path contains `cwd`. Uses the
133
+ * shared containment helper (resolve+relative, never startsWith). When more than one
134
+ * root matches, the most specific (longest root_path) wins. Returns null gracefully
135
+ * when no root contains the cwd. */
136
+ export function resolveProjectForCwd(cwd: string): Project | null {
137
+ if (!cwd) return null;
138
+ const rows = getDb()
139
+ .query("SELECT project_id, root_path, created_at FROM project_roots")
140
+ .all() as ProjectRootRow[];
141
+ let best: { projectId: string; len: number } | null = null;
142
+ for (const row of rows) {
143
+ if (isPathWithinBase(cwd, row.root_path) && (!best || row.root_path.length > best.len)) {
144
+ best = { projectId: row.project_id, len: row.root_path.length };
145
+ }
146
+ }
147
+ return best ? getProject(best.projectId) : null;
148
+ }
149
+
150
+ // ── Composition graph (#406) ────────────────────────────────────────────────
151
+ // project_edges models two relations, asymmetric by design:
152
+ // • contains — umbrella → sub-project hierarchy. INHERITS: resolving a
153
+ // project's entries pulls in its transitive contains-ANCESTORS.
154
+ // • depends_on — reference-only. A project's deps are exposed for explicit
155
+ // lookup but NEVER auto-inherited (a dependency's internal
156
+ // rules must not flood every consuming agent).
157
+ // Cycles are rejected per-kind on insert (addEdge); the traversal helpers also
158
+ // carry a visited-set so a manually-corrupted graph can't loop them.
159
+
160
+ interface ProjectEdgeRow {
161
+ from_project: string;
162
+ to_project: string;
163
+ kind: ProjectEdgeKind;
164
+ created_at: number;
165
+ }
166
+
167
+ function rowToEdge(row: ProjectEdgeRow): ProjectEdge {
168
+ return { fromProject: row.from_project, toProject: row.to_project, kind: row.kind, createdAt: row.created_at };
169
+ }
170
+
171
+ function assertEdgeKind(kind: ProjectEdgeKind): void {
172
+ if (!PROJECT_EDGE_KINDS.includes(kind)) {
173
+ throw new ValidationError(`kind must be one of: ${PROJECT_EDGE_KINDS.join(", ")}`);
174
+ }
175
+ }
176
+
177
+ /** Can `start` reach `target` following same-kind edges in the from→to direction?
178
+ * Used by addEdge to reject an edge that would close a cycle. Visited-set bounded. */
179
+ function reachableSameKind(start: string, target: string, kind: ProjectEdgeKind): boolean {
180
+ const stack = [start];
181
+ const seen = new Set<string>();
182
+ while (stack.length) {
183
+ const node = stack.pop()!;
184
+ if (node === target) return true;
185
+ if (seen.has(node)) continue;
186
+ seen.add(node);
187
+ const rows = getDb()
188
+ .query("SELECT to_project FROM project_edges WHERE from_project = ? AND kind = ?")
189
+ .all(node, kind) as Array<{ to_project: string }>;
190
+ for (const row of rows) stack.push(row.to_project);
191
+ }
192
+ return false;
193
+ }
194
+
195
+ /** Add a graph edge `from`→`to` of `kind`. Both projects must exist. Rejects a
196
+ * self-loop and any edge that would create a cycle within the same kind (so the
197
+ * contains hierarchy stays a DAG). Idempotent — UNIQUE(from, to, kind). */
198
+ export function addEdge(fromProject: string, toProject: string, kind: ProjectEdgeKind): ProjectEdge {
199
+ assertEdgeKind(kind);
200
+ if (fromProject === toProject) throw new ValidationError("an edge cannot point a project at itself");
201
+ if (!getProject(fromProject)) throw new ValidationError(`project ${fromProject} not found`);
202
+ if (!getProject(toProject)) throw new ValidationError(`project ${toProject} not found`);
203
+ // A cycle forms iff `to` can already reach `from` along same-kind edges; adding
204
+ // from→to would then close the loop.
205
+ if (reachableSameKind(toProject, fromProject, kind)) {
206
+ throw new ValidationError(`edge ${fromProject} -> ${toProject} (${kind}) would create a cycle`);
207
+ }
208
+ getDb().query(`
209
+ INSERT INTO project_edges (from_project, to_project, kind, created_at)
210
+ VALUES (?, ?, ?, ?)
211
+ ON CONFLICT(from_project, to_project, kind) DO NOTHING
212
+ `).run(fromProject, toProject, kind, Date.now());
213
+ return { fromProject, toProject, kind, createdAt: Date.now() };
214
+ }
215
+
216
+ /** Remove a graph edge. Returns true when a row was deleted, false when absent. */
217
+ export function removeEdge(fromProject: string, toProject: string, kind: ProjectEdgeKind): boolean {
218
+ assertEdgeKind(kind);
219
+ const result = getDb()
220
+ .query("DELETE FROM project_edges WHERE from_project = ? AND to_project = ? AND kind = ?")
221
+ .run(fromProject, toProject, kind);
222
+ return result.changes > 0;
223
+ }
224
+
225
+ /** All edges incident to a project — both outgoing (project is `from`) and
226
+ * incoming (project is `to`), across both kinds. The visibility surface that lets
227
+ * a caller see its umbrella(s) and its dependencies. */
228
+ export function edgesFor(projectId: string): ProjectEdge[] {
229
+ const rows = getDb()
230
+ .query("SELECT * FROM project_edges WHERE from_project = ? OR to_project = ? ORDER BY kind ASC, created_at ASC")
231
+ .all(projectId, projectId) as ProjectEdgeRow[];
232
+ return rows.map(rowToEdge);
233
+ }
234
+
235
+ /** Transitive `contains` ancestors of a project (the umbrellas that contain it,
236
+ * directly or via intermediate umbrellas), nearest-first (BFS). Self is excluded. */
237
+ export function ancestors(projectId: string): string[] {
238
+ const order: string[] = [];
239
+ const seen = new Set<string>([projectId]);
240
+ let frontier = [projectId];
241
+ while (frontier.length) {
242
+ const next: string[] = [];
243
+ for (const node of frontier) {
244
+ const rows = getDb()
245
+ .query("SELECT from_project FROM project_edges WHERE to_project = ? AND kind = 'contains'")
246
+ .all(node) as Array<{ from_project: string }>;
247
+ for (const row of rows) {
248
+ if (seen.has(row.from_project)) continue;
249
+ seen.add(row.from_project);
250
+ order.push(row.from_project);
251
+ next.push(row.from_project);
252
+ }
253
+ }
254
+ frontier = next;
255
+ }
256
+ return order;
257
+ }
258
+
259
+ /** Direct `depends_on` targets of a project. NOT transitive — dependencies are a
260
+ * reference edge, surfaced for explicit opt-in lookup, never an inheritance chain. */
261
+ export function dependencies(projectId: string): string[] {
262
+ const rows = getDb()
263
+ .query("SELECT to_project FROM project_edges WHERE from_project = ? AND kind = 'depends_on' ORDER BY created_at ASC")
264
+ .all(projectId) as Array<{ to_project: string }>;
265
+ return rows.map((row) => row.to_project);
266
+ }
267
+
268
+ export interface ResolveProjectEntriesOptions {
269
+ kind?: ProjectEntryKind;
270
+ includeInactive?: boolean;
271
+ /** When true (default), merge in entries from transitive `contains` ancestors,
272
+ * each tagged `inherited: true`. When false, only the project's own entries. */
273
+ includeInherited?: boolean;
274
+ }
275
+
276
+ /** Cascade resolution — THE behavioral core of the composition graph.
277
+ *
278
+ * Returns a project's own entries PLUS, when `includeInherited` (the default), the
279
+ * entries of its transitive `contains` ancestors, each tagged with `inherited`.
280
+ *
281
+ * MERGE SEMANTICS: own ⊕ ancestors is a UNION, not an override — a project and its
282
+ * umbrella can both hold a `rule` and both are returned (same-kind = merge). Order
283
+ * is most-specific-first: the project's own entries lead, then ancestors
284
+ * nearest-first; if a consumer ever needs precedence, more-specific/nearer (and,
285
+ * within a project, more-recent) wins by appearing first.
286
+ *
287
+ * ASYMMETRY: only `contains` ancestors inherit. `depends_on` targets are NEVER
288
+ * folded in here — pull a dependency's entries explicitly via its own project ref. */
289
+ export function resolveProjectEntries(
290
+ projectId: string,
291
+ opts: ResolveProjectEntriesOptions = {},
292
+ ): ResolvedProjectEntry[] {
293
+ const listOpts = { kind: opts.kind, includeInactive: opts.includeInactive };
294
+ const resolved: ResolvedProjectEntry[] = listProjectEntries(projectId, listOpts).map(
295
+ (entry) => ({ ...entry, inherited: false }),
296
+ );
297
+ if (opts.includeInherited === false) return resolved;
298
+ for (const ancestorId of ancestors(projectId)) {
299
+ for (const entry of listProjectEntries(ancestorId, listOpts)) {
300
+ resolved.push({ ...entry, inherited: true });
301
+ }
302
+ }
303
+ return resolved;
304
+ }
package/src/db/schema.ts CHANGED
@@ -187,6 +187,56 @@ export function initDb(path: string = "agent-relay.db"): Database {
187
187
  );
188
188
  CREATE INDEX IF NOT EXISTS idx_blackboard_team ON blackboard_entries(team_id);
189
189
  CREATE INDEX IF NOT EXISTS idx_blackboard_team_kind ON blackboard_entries(team_id, kind);
190
+ -- Projects (#403/#405) — the durable knowledge scope. projects/project_roots/project_entries
191
+ -- re-scope the teams/blackboard_entries pair above (team_id → project_id); project_entries
192
+ -- adds source/origin_ref so a promoted or ingested entry tracks where it came from.
193
+ CREATE TABLE IF NOT EXISTS projects (
194
+ id TEXT PRIMARY KEY,
195
+ slug TEXT NOT NULL UNIQUE,
196
+ title TEXT,
197
+ description TEXT,
198
+ tenant_id TEXT,
199
+ status TEXT NOT NULL DEFAULT 'active',
200
+ created_at INTEGER NOT NULL,
201
+ updated_at INTEGER NOT NULL
202
+ );
203
+ CREATE TABLE IF NOT EXISTS project_roots (
204
+ project_id TEXT NOT NULL,
205
+ root_path TEXT NOT NULL,
206
+ created_at INTEGER NOT NULL,
207
+ UNIQUE(project_id, root_path)
208
+ );
209
+ CREATE INDEX IF NOT EXISTS idx_project_roots_project ON project_roots(project_id);
210
+ CREATE TABLE IF NOT EXISTS project_entries (
211
+ id TEXT PRIMARY KEY,
212
+ project_id TEXT NOT NULL,
213
+ kind TEXT NOT NULL,
214
+ title TEXT NOT NULL,
215
+ body TEXT,
216
+ status TEXT NOT NULL DEFAULT 'active',
217
+ supersedes TEXT,
218
+ tags TEXT,
219
+ author_id TEXT NOT NULL,
220
+ source TEXT NOT NULL DEFAULT 'manual',
221
+ origin_ref TEXT,
222
+ created_at INTEGER NOT NULL,
223
+ updated_at INTEGER NOT NULL
224
+ );
225
+ CREATE INDEX IF NOT EXISTS idx_project_entries_project ON project_entries(project_id);
226
+ CREATE INDEX IF NOT EXISTS idx_project_entries_project_kind ON project_entries(project_id, kind);
227
+ -- Project composition graph (#406). kind is 'contains' (umbrella→sub-project, INHERITS)
228
+ -- or 'depends_on' (reference-only, never inherits). Cycles are rejected on insert in
229
+ -- src/db/projects.ts (addEdge). Both-direction indexes: from_project for descend/deps,
230
+ -- to_project for the ancestors() walk.
231
+ CREATE TABLE IF NOT EXISTS project_edges (
232
+ from_project TEXT NOT NULL,
233
+ to_project TEXT NOT NULL,
234
+ kind TEXT NOT NULL,
235
+ created_at INTEGER NOT NULL,
236
+ UNIQUE(from_project, to_project, kind)
237
+ );
238
+ CREATE INDEX IF NOT EXISTS idx_project_edges_from ON project_edges(from_project, kind);
239
+ CREATE INDEX IF NOT EXISTS idx_project_edges_to ON project_edges(to_project, kind);
190
240
  CREATE TABLE IF NOT EXISTS plans (
191
241
  id TEXT PRIMARY KEY, thread_id INTEGER NOT NULL, team_id TEXT, channel TEXT, version INTEGER NOT NULL DEFAULT 1,
192
242
  title TEXT, objective TEXT, nodes TEXT NOT NULL DEFAULT '[]', edges TEXT NOT NULL DEFAULT '[]',