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,352 @@
1
+ import {
2
+ addEdge,
3
+ addProjectRoot,
4
+ edgesFor,
5
+ ensureProject,
6
+ getAgent,
7
+ getProject,
8
+ getProjectEntry,
9
+ listProjects,
10
+ promoteBlackboardEntry,
11
+ removeEdge,
12
+ resolveProject,
13
+ resolveProjectEntries,
14
+ resolveProjectForCwd,
15
+ retractProjectEntry,
16
+ rootsForProject,
17
+ searchProjectEntries,
18
+ ValidationError,
19
+ writeProjectEntry,
20
+ } from "./db";
21
+ import { McpNotFoundError } from "./mcp-errors";
22
+ import { ingestClaudeMd } from "./project-ingest";
23
+ import { authContextFromMcp, type AuthContext } from "./services/auth-context";
24
+ import { cleanString, cleanStringArray, optionalEnum } from "./validation";
25
+ import { PROJECT_EDGE_KINDS, PROJECT_ENTRY_KINDS, PROJECT_ENTRY_SOURCES, type ProjectEdgeKind, type ProjectEntryKind, type ProjectEntrySource } from "./types";
26
+
27
+ // MCP tools for the durable Projects knowledge scope (#405). Mirrors
28
+ // src/mcp-blackboard-tools.ts: same cleanString/cleanStringArray validation and
29
+ // scope-gating shape. Unlike the blackboard (purely caller-team-scoped), a project
30
+ // is durable and cross-cutting and must be addressable: entry verbs take an OPTIONAL
31
+ // `project` ref (id or slug) and fall back to callerProject(auth) — the project
32
+ // resolved from the caller's spawn cwd. That fallback is the blackboard parallel.
33
+
34
+ export const PROJECT_TOOLS = [
35
+ {
36
+ name: "relay_project_list",
37
+ description: "List the durable projects (the cross-repo knowledge scopes).",
38
+ requiredScopes: ["project:read"],
39
+ inputSchema: {
40
+ type: "object",
41
+ properties: { includeArchived: { type: "boolean" } },
42
+ additionalProperties: false,
43
+ },
44
+ },
45
+ {
46
+ name: "relay_project_get",
47
+ description: "Fetch one project and its registered roots. Defaults to the caller's resolved project (from its spawn cwd) when `project` is omitted.",
48
+ requiredScopes: ["project:read"],
49
+ inputSchema: {
50
+ type: "object",
51
+ properties: { project: { type: "string", description: "Project id or slug; defaults to the caller's project." } },
52
+ additionalProperties: false,
53
+ },
54
+ },
55
+ {
56
+ name: "relay_project_add_root",
57
+ description: "Create-or-update a project by slug and register a repo root path. The create-on-first-use entry point for a new project.",
58
+ requiredScopes: ["project:write"],
59
+ inputSchema: {
60
+ type: "object",
61
+ properties: {
62
+ slug: { type: "string" },
63
+ rootPath: { type: "string" },
64
+ title: { type: "string" },
65
+ description: { type: "string" },
66
+ tenantId: { type: "string" },
67
+ },
68
+ required: ["slug", "rootPath"],
69
+ additionalProperties: false,
70
+ },
71
+ },
72
+ {
73
+ name: "relay_project_write_entry",
74
+ description: "Create a project-scoped entry (rule/fact/decision/memory). Targets `project` if given, else the caller's resolved project.",
75
+ requiredScopes: ["project:write"],
76
+ inputSchema: {
77
+ type: "object",
78
+ properties: {
79
+ project: { type: "string", description: "Project id or slug; defaults to the caller's project." },
80
+ kind: { type: "string", enum: PROJECT_ENTRY_KINDS },
81
+ title: { type: "string" },
82
+ body: { type: "string" },
83
+ tags: { type: "array", items: { type: "string" } },
84
+ supersedes: { type: "string" },
85
+ source: { type: "string", enum: PROJECT_ENTRY_SOURCES },
86
+ originRef: { type: "string" },
87
+ },
88
+ required: ["kind", "title"],
89
+ additionalProperties: false,
90
+ },
91
+ },
92
+ {
93
+ name: "relay_project_read",
94
+ description: "Read a project's active entries, optionally filtered by kind. Includes entries inherited from transitive `contains` ancestors (each tagged `inherited:true`) unless `includeInherited:false`. `depends_on` targets are never folded in — read them explicitly via their own `project` ref. Targets `project` if given, else the caller's resolved project.",
95
+ requiredScopes: ["project:read"],
96
+ inputSchema: {
97
+ type: "object",
98
+ properties: {
99
+ project: { type: "string" },
100
+ kind: { type: "string", enum: PROJECT_ENTRY_KINDS },
101
+ includeInactive: { type: "boolean" },
102
+ includeInherited: { type: "boolean", description: "Default true: merge in contains-ancestor entries. Set false for only this project's own." },
103
+ },
104
+ additionalProperties: false,
105
+ },
106
+ },
107
+ {
108
+ name: "relay_project_search",
109
+ description: "Search a project's active entries by title, body, or tags. Targets `project` if given, else the caller's resolved project.",
110
+ requiredScopes: ["project:read"],
111
+ inputSchema: {
112
+ type: "object",
113
+ properties: {
114
+ project: { type: "string" },
115
+ query: { type: "string" },
116
+ kind: { type: "string", enum: PROJECT_ENTRY_KINDS },
117
+ },
118
+ required: ["query"],
119
+ additionalProperties: false,
120
+ },
121
+ },
122
+ {
123
+ name: "relay_project_retract",
124
+ description: "Retract a project entry without deleting its ledger row. Targets `project` if given, else the caller's resolved project.",
125
+ requiredScopes: ["project:write"],
126
+ inputSchema: {
127
+ type: "object",
128
+ properties: {
129
+ project: { type: "string" },
130
+ id: { type: "string" },
131
+ },
132
+ required: ["id"],
133
+ additionalProperties: false,
134
+ },
135
+ },
136
+ {
137
+ name: "relay_project_add_edge",
138
+ description: "Add a composition-graph edge between two projects. `contains` (umbrella→sub-project) INHERITS — the sub-project's reads pull in the umbrella's entries. `depends_on` is reference-only and never inherits. Cycles are rejected. `from`/`to` are project ids or slugs.",
139
+ requiredScopes: ["project:write"],
140
+ inputSchema: {
141
+ type: "object",
142
+ properties: {
143
+ from: { type: "string", description: "Source project id or slug (the umbrella for `contains`, the consumer for `depends_on`)." },
144
+ to: { type: "string", description: "Target project id or slug (the contained sub-project, or the dependency)." },
145
+ kind: { type: "string", enum: PROJECT_EDGE_KINDS },
146
+ },
147
+ required: ["from", "to", "kind"],
148
+ additionalProperties: false,
149
+ },
150
+ },
151
+ {
152
+ name: "relay_project_remove_edge",
153
+ description: "Remove a composition-graph edge between two projects. `from`/`to` are project ids or slugs.",
154
+ requiredScopes: ["project:write"],
155
+ inputSchema: {
156
+ type: "object",
157
+ properties: {
158
+ from: { type: "string" },
159
+ to: { type: "string" },
160
+ kind: { type: "string", enum: PROJECT_EDGE_KINDS },
161
+ },
162
+ required: ["from", "to", "kind"],
163
+ additionalProperties: false,
164
+ },
165
+ },
166
+ {
167
+ name: "relay_project_promote_entry",
168
+ description: "Promote an active blackboard finding (`bb_…`) into durable project knowledge. Copies its content as a `promoted` project entry whose `origin_ref` back-references the blackboard id (kinds: decision→decision, fact→fact, ruled_out→rule). Explicit only — nothing is auto-promoted. Re-promoting the same finding supersedes the prior copy rather than duplicating it. Targets `project` if given, else the caller's resolved project.",
169
+ requiredScopes: ["project:write"],
170
+ inputSchema: {
171
+ type: "object",
172
+ properties: {
173
+ blackboardEntryId: { type: "string", description: "The bb_… blackboard entry to promote." },
174
+ project: { type: "string", description: "Project id or slug; defaults to the caller's project." },
175
+ },
176
+ required: ["blackboardEntryId"],
177
+ additionalProperties: false,
178
+ },
179
+ },
180
+ {
181
+ name: "relay_project_ingest_claudemd",
182
+ description: "Ingest CLAUDE.md from each registered root of a project into project_entries (source:'ingested'), splitting by heading into one entry per section. Idempotent: re-running supersedes prior ingested entries from the same file/section, retracts sections that vanished, and never touches manual/promoted entries. Targets `project` if given, else the caller's resolved project.",
183
+ requiredScopes: ["project:write"],
184
+ inputSchema: {
185
+ type: "object",
186
+ properties: {
187
+ project: { type: "string", description: "Project id or slug; defaults to the caller's project." },
188
+ },
189
+ additionalProperties: false,
190
+ },
191
+ },
192
+ ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
193
+
194
+ type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
195
+
196
+ /** The project resolved from the caller's identity: the projectId stamped on the
197
+ * token at spawn (cwd→root binding), else a live resolution from the caller agent's
198
+ * cwd. Analogous to callerTeam(auth); returns undefined gracefully when the caller
199
+ * is in no registered root. */
200
+ function callerProjectId(ctx: AuthContext): string | undefined {
201
+ if (ctx.constraints?.projectId) return ctx.constraints.projectId;
202
+ if (!ctx.callerAgentId) return undefined;
203
+ const agent = getAgent(ctx.callerAgentId);
204
+ const cwd = typeof agent?.meta?.cwd === "string" ? agent.meta.cwd : undefined;
205
+ const project = cwd ? resolveProjectForCwd(cwd) : null;
206
+ return project?.id;
207
+ }
208
+
209
+ /** Resolve the project an entry verb operates on: the explicit `project` ref (id or
210
+ * slug) when given, else the caller's resolved project. Throws when neither yields one. */
211
+ function targetProjectId(ctx: AuthContext, explicit: string | undefined): string {
212
+ if (explicit) {
213
+ const project = resolveProject(explicit);
214
+ if (!project) throw new McpNotFoundError("project not found");
215
+ return project.id;
216
+ }
217
+ const id = callerProjectId(ctx);
218
+ if (!id) throw new ValidationError("no project: pass `project`, or spawn inside a registered project root");
219
+ return id;
220
+ }
221
+
222
+ function authorId(ctx: AuthContext): string {
223
+ return ctx.callerAgentId ?? ctx.actor.id;
224
+ }
225
+
226
+ function cleanKind(value: unknown): ProjectEntryKind | undefined {
227
+ return optionalEnum(value, "kind", PROJECT_ENTRY_KINDS) as ProjectEntryKind | undefined;
228
+ }
229
+
230
+ function cleanEdgeKind(value: unknown): ProjectEdgeKind {
231
+ const kind = optionalEnum(value, "kind", PROJECT_EDGE_KINDS) as ProjectEdgeKind | undefined;
232
+ if (!kind) throw new ValidationError(`kind must be one of: ${PROJECT_EDGE_KINDS.join(", ")}`);
233
+ return kind;
234
+ }
235
+
236
+ /** Resolve an explicit project ref (id or slug) to its id, or throw not-found. The
237
+ * graph edges address projects directly, never via caller-cwd fallback. */
238
+ function requireProjectId(ref: unknown, field: string): string {
239
+ const explicit = cleanString(ref, field, { required: true, max: 200 })!;
240
+ const project = resolveProject(explicit);
241
+ if (!project) throw new McpNotFoundError(`project not found: ${explicit}`);
242
+ return project.id;
243
+ }
244
+
245
+ function ensureSameProject(entryId: string, projectId: string): void {
246
+ const entry = getProjectEntry(entryId);
247
+ if (!entry) throw new McpNotFoundError("project entry not found");
248
+ if (entry.projectId !== projectId) throw new McpNotFoundError("project entry not found");
249
+ }
250
+
251
+ export function relayProjectTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
252
+ const ctx = authContextFromMcp(auth);
253
+
254
+ if (name === "relay_project_list") {
255
+ return { projects: listProjects({ includeArchived: args.includeArchived === true }) };
256
+ }
257
+
258
+ if (name === "relay_project_get") {
259
+ const explicit = cleanString(args.project, "project", { max: 200 });
260
+ const id = explicit
261
+ ? (resolveProject(explicit)?.id ?? (() => { throw new McpNotFoundError("project not found"); })())
262
+ : callerProjectId(ctx);
263
+ if (!id) throw new McpNotFoundError("project not found");
264
+ const project = getProject(id);
265
+ if (!project) throw new McpNotFoundError("project not found");
266
+ return { project, roots: rootsForProject(id), edges: edgesFor(id) };
267
+ }
268
+
269
+ if (name === "relay_project_add_root") {
270
+ const slug = cleanString(args.slug, "slug", { required: true, max: 200 })!;
271
+ const rootPath = cleanString(args.rootPath, "rootPath", { required: true, max: 4000 })!;
272
+ const project = ensureProject({
273
+ slug,
274
+ title: cleanString(args.title, "title", { max: 240 }),
275
+ description: cleanString(args.description, "description", { max: 16_000 }),
276
+ tenantId: cleanString(args.tenantId, "tenantId", { max: 200 }),
277
+ });
278
+ const roots = addProjectRoot(project.id, rootPath);
279
+ return { project, roots };
280
+ }
281
+
282
+ if (name === "relay_project_write_entry") {
283
+ const projectId = targetProjectId(ctx, cleanString(args.project, "project", { max: 200 }));
284
+ const kind = cleanKind(args.kind);
285
+ if (!kind) throw new ValidationError("kind required");
286
+ const supersedes = cleanString(args.supersedes, "supersedes", { max: 120 });
287
+ if (supersedes) ensureSameProject(supersedes, projectId);
288
+ return writeProjectEntry({
289
+ projectId,
290
+ authorId: authorId(ctx),
291
+ kind,
292
+ title: cleanString(args.title, "title", { required: true, max: 240 })!,
293
+ body: cleanString(args.body, "body", { max: 16_000 }),
294
+ tags: cleanStringArray(args.tags, "tags", { itemMax: 80, maxItems: 32 }),
295
+ supersedes,
296
+ source: optionalEnum(args.source, "source", PROJECT_ENTRY_SOURCES) as ProjectEntrySource | undefined,
297
+ originRef: cleanString(args.originRef, "originRef", { max: 200 }),
298
+ }) as unknown as Record<string, unknown>;
299
+ }
300
+
301
+ if (name === "relay_project_read") {
302
+ const projectId = targetProjectId(ctx, cleanString(args.project, "project", { max: 200 }));
303
+ return {
304
+ entries: resolveProjectEntries(projectId, {
305
+ kind: cleanKind(args.kind),
306
+ includeInactive: args.includeInactive === true,
307
+ includeInherited: args.includeInherited !== false,
308
+ }),
309
+ };
310
+ }
311
+
312
+ if (name === "relay_project_search") {
313
+ const projectId = targetProjectId(ctx, cleanString(args.project, "project", { max: 200 }));
314
+ return { entries: searchProjectEntries(projectId, cleanString(args.query, "query", { required: true, max: 240 })!, { kind: cleanKind(args.kind) }) };
315
+ }
316
+
317
+ if (name === "relay_project_retract") {
318
+ const projectId = targetProjectId(ctx, cleanString(args.project, "project", { max: 200 }));
319
+ const id = cleanString(args.id, "id", { required: true, max: 120 })!;
320
+ ensureSameProject(id, projectId);
321
+ const entry = retractProjectEntry(id);
322
+ if (!entry) throw new McpNotFoundError("project entry not found");
323
+ return entry as unknown as Record<string, unknown>;
324
+ }
325
+
326
+ if (name === "relay_project_add_edge") {
327
+ const from = requireProjectId(args.from, "from");
328
+ const to = requireProjectId(args.to, "to");
329
+ const edge = addEdge(from, to, cleanEdgeKind(args.kind));
330
+ return { edge: edge as unknown as Record<string, unknown> };
331
+ }
332
+
333
+ if (name === "relay_project_remove_edge") {
334
+ const from = requireProjectId(args.from, "from");
335
+ const to = requireProjectId(args.to, "to");
336
+ const removed = removeEdge(from, to, cleanEdgeKind(args.kind));
337
+ return { removed };
338
+ }
339
+
340
+ if (name === "relay_project_promote_entry") {
341
+ const projectId = targetProjectId(ctx, cleanString(args.project, "project", { max: 200 }));
342
+ const blackboardEntryId = cleanString(args.blackboardEntryId, "blackboardEntryId", { required: true, max: 120 })!;
343
+ return promoteBlackboardEntry({ blackboardEntryId, projectId }) as unknown as Record<string, unknown>;
344
+ }
345
+
346
+ if (name === "relay_project_ingest_claudemd") {
347
+ const projectId = targetProjectId(ctx, cleanString(args.project, "project", { max: 200 }));
348
+ return ingestClaudeMd(projectId, authorId(ctx)) as unknown as Record<string, unknown>;
349
+ }
350
+
351
+ throw new ValidationError(`unknown tool: ${name}`);
352
+ }
package/src/mcp.ts CHANGED
@@ -49,6 +49,8 @@ import { ServiceAuthError } from "./services/errors";
49
49
  import { PLAN_TOOLS, relayPlanTool } from "./mcp-plan-tools";
50
50
  import { TEAM_TOOLS, relayTeamTool } from "./mcp-team-tools";
51
51
  import { BLACKBOARD_TOOLS, relayBlackboardTool } from "./mcp-blackboard-tools";
52
+ import { PROJECT_TOOLS, relayProjectTool } from "./mcp-project-tools";
53
+ import { PROJECT_INSTRUCTION_POLICY_NAMES, parseProjectInstructions } from "./project-instructions";
52
54
  import { AGENT_ACTION_TOOL, relayAgentAction } from "./mcp-agent-action";
53
55
  import type { ActivityKind, AgentLifecycle, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
54
56
  import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
@@ -309,6 +311,7 @@ const TOOLS: ToolDefinition[] = [
309
311
  prompt: { type: "string", description: "Initial task/message delivered to the agent on launch — spawn and hand it its first instruction in one call (no separate follow-up message needed)." },
310
312
  expectReply: { type: "boolean", description: "Require the worker to report back: it gets a pending reply obligation to you and CANNOT go idle until it delivers its result (which routes to your inbox and wakes you). Defaults true when you pass a prompt — set false for a true fire-and-forget worker that needn't report (e.g. one that only lands a branch)." },
311
313
  systemPromptAppend: { type: "string" },
314
+ projectInstructions: { type: "object", description: "#409 — project instruction projection (default OFF). When set, relay composes a knowledge preamble from the spawn cwd's resolved project (own + inherited entries) and prepends it to systemPromptAppend, instead of relying on host CLAUDE.md. `policy: \"all\"` includes ingested host content; `\"vanilla\"` excludes it (manual+promoted only). Omit to keep current behavior.", properties: { policy: { type: "string", enum: PROJECT_INSTRUCTION_POLICY_NAMES } }, additionalProperties: false },
312
315
  profile: { type: "string", description: "Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota)." },
313
316
  tags: { type: "array", items: { type: "string" } },
314
317
  capabilities: { type: "array", items: { type: "string" } },
@@ -459,6 +462,7 @@ const TOOLS: ToolDefinition[] = [
459
462
  ...TEAM_TOOLS,
460
463
  ...PLAN_TOOLS,
461
464
  ...BLACKBOARD_TOOLS,
465
+ ...PROJECT_TOOLS,
462
466
  ];
463
467
 
464
468
  export async function postMcp(req: Request): Promise<Response> {
@@ -574,6 +578,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
574
578
  else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
575
579
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
576
580
  else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
581
+ else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args);
577
582
  else throw new ValidationError(`unknown tool: ${name}`);
578
583
 
579
584
  auditToolCall(auth, name, "ok", result);
@@ -871,6 +876,7 @@ async function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknow
871
876
  prompt: optionalString(args.prompt, "prompt", 16_000),
872
877
  expectReply: typeof args.expectReply === "boolean" ? args.expectReply : undefined,
873
878
  systemPromptAppend: optionalString(args.systemPromptAppend, "systemPromptAppend", 64_000),
879
+ projectInstructions: parseProjectInstructions(args.projectInstructions, (m) => new ValidationError(m)),
874
880
  tags: optionalStringArray(args.tags, "tags"),
875
881
  capabilities: optionalStringArray(args.capabilities, "capabilities"),
876
882
  providerArgs: optionalStringArray(args.providerArgs, "providerArgs"),
@@ -0,0 +1,186 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { listProjectEntries, rootsForProject, retractProjectEntry, writeProjectEntry } from "./db";
4
+ import type { ProjectEntry } from "./types";
5
+
6
+ // CLAUDE.md ingest on-ramp (#407, part of epic #403). Makes git a *feeder* of
7
+ // project knowledge: read CLAUDE.md from each registered root and project its
8
+ // headings into project_entries with source:'ingested'. The parse is deliberately
9
+ // dumb and predictable — split by heading, one entry per heading — not clever.
10
+ //
11
+ // IDEMPOTENT re-ingest is the core invariant: re-running reconciles against the
12
+ // prior 'ingested' entries keyed on origin_ref (file path + section anchor) —
13
+ // unchanged sections are left alone, changed sections supersede their prior entry,
14
+ // and sections that vanished from the file are retracted. `manual` and `promoted`
15
+ // entries are NEVER read or touched by ingest — only source:'ingested' is.
16
+
17
+ const INGEST_FILENAME = "CLAUDE.md";
18
+
19
+ export interface ParsedSection {
20
+ /** Heading text (the entry title). For pre-heading preamble content: "(preamble)". */
21
+ title: string;
22
+ /** Section body — lines under the heading up to the next heading, trimmed. May be "". */
23
+ body: string;
24
+ /** GitHub-style heading slug, deduped within the file. "" for the preamble section. */
25
+ anchor: string;
26
+ }
27
+
28
+ export interface IngestRootResult {
29
+ rootPath: string;
30
+ filePath: string;
31
+ found: boolean;
32
+ sections: number;
33
+ }
34
+
35
+ export interface IngestResult {
36
+ projectId: string;
37
+ created: number;
38
+ updated: number;
39
+ unchanged: number;
40
+ retracted: number;
41
+ roots: IngestRootResult[];
42
+ }
43
+
44
+ const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
45
+
46
+ /** GitHub-style heading slug: lowercase, spaces→hyphens, drop non-word/-/space. */
47
+ function slugify(heading: string): string {
48
+ return heading
49
+ .trim()
50
+ .toLowerCase()
51
+ .replace(/[^\w\s-]/g, "")
52
+ .replace(/\s+/g, "-")
53
+ .replace(/-+/g, "-")
54
+ .replace(/^-|-$/g, "");
55
+ }
56
+
57
+ /** Split a CLAUDE.md document into sections by heading. One section per heading
58
+ * line (any level), body = the lines until the next heading. Content before the
59
+ * first heading becomes a "(preamble)" section only when it has real content.
60
+ * Anchors are deduped (`-1`, `-2`, …) so each section gets a stable, unique ref. */
61
+ export function parseClaudeMdSections(content: string): ParsedSection[] {
62
+ const lines = content.split("\n");
63
+ const sections: ParsedSection[] = [];
64
+ const anchorCounts = new Map<string, number>();
65
+
66
+ let title: string | null = null; // null until the first heading; preamble accumulates meanwhile
67
+ let bodyLines: string[] = [];
68
+
69
+ const dedupeAnchor = (base: string): string => {
70
+ const slug = base || "section";
71
+ const seen = anchorCounts.get(slug) ?? 0;
72
+ anchorCounts.set(slug, seen + 1);
73
+ return seen === 0 ? slug : `${slug}-${seen}`;
74
+ };
75
+
76
+ const flush = () => {
77
+ const body = bodyLines.join("\n").trim();
78
+ if (title === null) {
79
+ // Preamble: emit only if it carries real content.
80
+ if (body) sections.push({ title: "(preamble)", body, anchor: "" });
81
+ } else {
82
+ sections.push({ title, body, anchor: dedupeAnchor(slugify(title)) });
83
+ }
84
+ bodyLines = [];
85
+ };
86
+
87
+ for (const raw of lines) {
88
+ const m = raw.match(HEADING_RE);
89
+ if (m) {
90
+ flush();
91
+ title = m[2]!.trim();
92
+ } else {
93
+ bodyLines.push(raw);
94
+ }
95
+ }
96
+ flush();
97
+
98
+ return sections;
99
+ }
100
+
101
+ /** The origin_ref for an ingested section: the absolute file path, plus the
102
+ * section anchor as a fragment when the section came from a heading. */
103
+ function originRefFor(filePath: string, anchor: string): string {
104
+ return anchor ? `${filePath}#${anchor}` : filePath;
105
+ }
106
+
107
+ /** Ingest CLAUDE.md from every registered root of a project into project_entries
108
+ * (source:'ingested'). Idempotent — see the module header. Returns a summary of
109
+ * what changed. Reads files synchronously; missing CLAUDE.md in a root is skipped
110
+ * gracefully (found:false), it is not an error. */
111
+ export function ingestClaudeMd(projectId: string, authorId: string): IngestResult {
112
+ // Index prior *ingested* entries by origin_ref (newest active wins). We only ever
113
+ // read source:'ingested' here — manual/promoted are invisible to ingest by design.
114
+ const byOrigin = new Map<string, ProjectEntry>();
115
+ for (const entry of listProjectEntries(projectId)) {
116
+ if (entry.source !== "ingested" || !entry.originRef) continue;
117
+ if (!byOrigin.has(entry.originRef)) byOrigin.set(entry.originRef, entry);
118
+ }
119
+
120
+ const seen = new Set<string>();
121
+ const roots: IngestRootResult[] = [];
122
+ let created = 0;
123
+ let updated = 0;
124
+ let unchanged = 0;
125
+
126
+ for (const root of rootsForProject(projectId)) {
127
+ const filePath = join(root.rootPath, INGEST_FILENAME);
128
+ let content: string;
129
+ try {
130
+ content = readFileSync(filePath, "utf8");
131
+ } catch (err) {
132
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
133
+ roots.push({ rootPath: root.rootPath, filePath, found: false, sections: 0 });
134
+ continue;
135
+ }
136
+ throw err;
137
+ }
138
+
139
+ const sections = parseClaudeMdSections(content);
140
+ for (const section of sections) {
141
+ const originRef = originRefFor(filePath, section.anchor);
142
+ seen.add(originRef);
143
+ const prior = byOrigin.get(originRef);
144
+ if (prior) {
145
+ if (prior.title === section.title && (prior.body ?? "") === section.body) {
146
+ unchanged++;
147
+ continue;
148
+ }
149
+ writeProjectEntry({
150
+ projectId,
151
+ authorId,
152
+ kind: "rule",
153
+ title: section.title,
154
+ body: section.body || undefined,
155
+ source: "ingested",
156
+ originRef,
157
+ supersedes: prior.id,
158
+ });
159
+ updated++;
160
+ } else {
161
+ writeProjectEntry({
162
+ projectId,
163
+ authorId,
164
+ kind: "rule",
165
+ title: section.title,
166
+ body: section.body || undefined,
167
+ source: "ingested",
168
+ originRef,
169
+ });
170
+ created++;
171
+ }
172
+ }
173
+ roots.push({ rootPath: root.rootPath, filePath, found: true, sections: sections.length });
174
+ }
175
+
176
+ // Retract ingested entries whose section disappeared from the file (or whose
177
+ // file is gone) — they were not re-seen this pass. Never touches manual/promoted.
178
+ let retracted = 0;
179
+ for (const [originRef, entry] of byOrigin) {
180
+ if (seen.has(originRef)) continue;
181
+ retractProjectEntry(entry.id);
182
+ retracted++;
183
+ }
184
+
185
+ return { projectId, created, updated, unchanged, retracted, roots };
186
+ }