agent-relay-server 0.57.1 → 0.58.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 (147) hide show
  1. package/docs/openapi.json +151 -1
  2. package/package.json +2 -2
  3. package/public/assets/MessageBubble-CHKzD4RY.js +2 -0
  4. package/public/assets/MessageBubble-CHKzD4RY.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-CJROpj66.js → PlanGraphPanel-BFVTJa0q.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CJROpj66.js.map → PlanGraphPanel-BFVTJa0q.js.map} +1 -1
  7. package/public/assets/activity-M-dFLU_O.js +2 -0
  8. package/public/assets/{activity-D2m3VCXV.js.map → activity-M-dFLU_O.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CIf4b1V7.js → agent-profiles-CvHSoyCX.js} +2 -2
  10. package/public/assets/{agent-profiles-CIf4b1V7.js.map → agent-profiles-CvHSoyCX.js.map} +1 -1
  11. package/public/assets/agents-DZBM5dp_.js +2 -0
  12. package/public/assets/{agents-lDwoWazM.js.map → agents-DZBM5dp_.js.map} +1 -1
  13. package/public/assets/{analytics-OsZ2HJxy.js → analytics-bvjb3Urs.js} +3 -3
  14. package/public/assets/{analytics-OsZ2HJxy.js.map → analytics-bvjb3Urs.js.map} +1 -1
  15. package/public/assets/{automation-4eeiOl19.js → automation-CDSn9Uq-.js} +2 -2
  16. package/public/assets/{automation-4eeiOl19.js.map → automation-CDSn9Uq-.js.map} +1 -1
  17. package/public/assets/{badge-DBZY8x3C.js → badge-Dj6PREuI.js} +2 -2
  18. package/public/assets/{badge-DBZY8x3C.js.map → badge-Dj6PREuI.js.map} +1 -1
  19. package/public/assets/branch-state-badge-CdYg2HYc.js +2 -0
  20. package/public/assets/{branch-state-badge-B9WqTxZJ.js.map → branch-state-badge-CdYg2HYc.js.map} +1 -1
  21. package/public/assets/{button-ClDtrv2m.js → button-Cqpufqam.js} +2 -2
  22. package/public/assets/{button-ClDtrv2m.js.map → button-Cqpufqam.js.map} +1 -1
  23. package/public/assets/card-OmDF73CE.js +2 -0
  24. package/public/assets/{card-Du4YivOo.js.map → card-OmDF73CE.js.map} +1 -1
  25. package/public/assets/{channels-HK6sLYpZ.js → channels-BkxrdOFZ.js} +2 -2
  26. package/public/assets/{channels-HK6sLYpZ.js.map → channels-BkxrdOFZ.js.map} +1 -1
  27. package/public/assets/chat-YzX1z76l.js +2 -0
  28. package/public/assets/chat-YzX1z76l.js.map +1 -0
  29. package/public/assets/{connectors-3mMNW4Qk.js → connectors-Bg3rHgl_.js} +2 -2
  30. package/public/assets/{connectors-3mMNW4Qk.js.map → connectors-Bg3rHgl_.js.map} +1 -1
  31. package/public/assets/copy-button-DI7b8A7_.js +2 -0
  32. package/public/assets/{copy-button-DXWhNsIl.js.map → copy-button-DI7b8A7_.js.map} +1 -1
  33. package/public/assets/display-CketawEF.js.map +1 -1
  34. package/public/assets/{dist-B7p3TbNs.js → dist-1x-LE9Uk.js} +2 -2
  35. package/public/assets/{dist-B7p3TbNs.js.map → dist-1x-LE9Uk.js.map} +1 -1
  36. package/public/assets/{dist-DhkQna4j.js → dist-C5t_OxI0.js} +2 -2
  37. package/public/assets/{dist-DhkQna4j.js.map → dist-C5t_OxI0.js.map} +1 -1
  38. package/public/assets/{dist-edv2cbLj.js → dist-CNjnTZEI.js} +2 -2
  39. package/public/assets/{dist-edv2cbLj.js.map → dist-CNjnTZEI.js.map} +1 -1
  40. package/public/assets/{dist-GEKfMylz.js → dist-CV3sgFiv.js} +2 -2
  41. package/public/assets/{dist-GEKfMylz.js.map → dist-CV3sgFiv.js.map} +1 -1
  42. package/public/assets/{dist-C0PA00sq.js → dist-Ca3ujqG_.js} +2 -2
  43. package/public/assets/{dist-C0PA00sq.js.map → dist-Ca3ujqG_.js.map} +1 -1
  44. package/public/assets/{dist-AopAI8S8.js → dist-CoUvwLTy.js} +2 -2
  45. package/public/assets/{dist-AopAI8S8.js.map → dist-CoUvwLTy.js.map} +1 -1
  46. package/public/assets/{dist-BafqksRv.js → dist-DV2tZ2fq.js} +2 -2
  47. package/public/assets/{dist-BafqksRv.js.map → dist-DV2tZ2fq.js.map} +1 -1
  48. package/public/assets/{dist-C6ROz7Mo.js → dist-DkeTrbTA.js} +2 -2
  49. package/public/assets/{dist-C6ROz7Mo.js.map → dist-DkeTrbTA.js.map} +1 -1
  50. package/public/assets/dist-DmhyU0Na.js +2 -0
  51. package/public/assets/{dist-BX1qUU9z.js.map → dist-DmhyU0Na.js.map} +1 -1
  52. package/public/assets/dist-DxkMwkrr.js +2 -0
  53. package/public/assets/{dist-T7h6jNA1.js.map → dist-DxkMwkrr.js.map} +1 -1
  54. package/public/assets/dist-yYauqYKR.js +2 -0
  55. package/public/assets/{dist-DEYe1tib.js.map → dist-yYauqYKR.js.map} +1 -1
  56. package/public/assets/{es2015-DwZxGtk9.js → es2015-DcavLcQ0.js} +2 -2
  57. package/public/assets/{es2015-DwZxGtk9.js.map → es2015-DcavLcQ0.js.map} +1 -1
  58. package/public/assets/{formatted-body-impl-BOLz9dDs.js → formatted-body-impl-D8i9qqL1.js} +2 -2
  59. package/public/assets/{formatted-body-impl-BOLz9dDs.js.map → formatted-body-impl-D8i9qqL1.js.map} +1 -1
  60. package/public/assets/index-Ba2RDJaA.js +21 -0
  61. package/public/assets/{index-0rOVZ5nm.js.map → index-Ba2RDJaA.js.map} +1 -1
  62. package/public/assets/index-CtFXWxBJ.css +2 -0
  63. package/public/assets/{input-DFZRjj8b.js → input-C_dl_9fG.js} +2 -2
  64. package/public/assets/{input-DFZRjj8b.js.map → input-C_dl_9fG.js.map} +1 -1
  65. package/public/assets/{insights-DwtSIqYc.js → insights-D1aANwZ2.js} +2 -2
  66. package/public/assets/{insights-DwtSIqYc.js.map → insights-D1aANwZ2.js.map} +1 -1
  67. package/public/assets/{integrations-Dd6haLpy.js → integrations-BrAr9bap.js} +2 -2
  68. package/public/assets/{integrations-Dd6haLpy.js.map → integrations-BrAr9bap.js.map} +1 -1
  69. package/public/assets/{lib-Kn4-gfGD.js → lib-B3iMVsf-.js} +2 -2
  70. package/public/assets/{lib-Kn4-gfGD.js.map → lib-B3iMVsf-.js.map} +1 -1
  71. package/public/assets/lucide-react-CMhBract.js +9 -0
  72. package/public/assets/{lucide-react-C3j5Huiw.js.map → lucide-react-CMhBract.js.map} +1 -1
  73. package/public/assets/{maintenance-DVzbjn4x.js → maintenance-C-xspsMG.js} +2 -2
  74. package/public/assets/{maintenance-DVzbjn4x.js.map → maintenance-C-xspsMG.js.map} +1 -1
  75. package/public/assets/{managed-agents-CJXaWFgZ.js → managed-agents-Bf3ni0vo.js} +2 -2
  76. package/public/assets/{managed-agents-CJXaWFgZ.js.map → managed-agents-Bf3ni0vo.js.map} +1 -1
  77. package/public/assets/{markdown-preview-impl-CCoHxXmF.js → markdown-preview-impl-DKbUBTf_.js} +2 -2
  78. package/public/assets/{markdown-preview-impl-CCoHxXmF.js.map → markdown-preview-impl-DKbUBTf_.js.map} +1 -1
  79. package/public/assets/memory-DeLwJFwH.js +2 -0
  80. package/public/assets/{memory-f9IAAOSG.js.map → memory-DeLwJFwH.js.map} +1 -1
  81. package/public/assets/{messages-DuSBSnsM.js → messages-Cww6dZnM.js} +2 -2
  82. package/public/assets/{messages-DuSBSnsM.js.map → messages-Cww6dZnM.js.map} +1 -1
  83. package/public/assets/{orchestrators-BUCE7jk3.js → orchestrators-DRjwROXw.js} +2 -2
  84. package/public/assets/{orchestrators-BUCE7jk3.js.map → orchestrators-DRjwROXw.js.map} +1 -1
  85. package/public/assets/{overview-B0_IOXOo.js → overview-DjcRomU_.js} +2 -2
  86. package/public/assets/{overview-B0_IOXOo.js.map → overview-DjcRomU_.js.map} +1 -1
  87. package/public/assets/{pairs-DdA8n3dX.js → pairs-DT0u2fnW.js} +2 -2
  88. package/public/assets/{pairs-DdA8n3dX.js.map → pairs-DT0u2fnW.js.map} +1 -1
  89. package/public/assets/{react-dom-2FP7-B7N.js → react-dom-Dtl9-x_1.js} +2 -2
  90. package/public/assets/{react-dom-2FP7-B7N.js.map → react-dom-Dtl9-x_1.js.map} +1 -1
  91. package/public/assets/{security-07gu7r1C.js → security-Bwg3oFI8.js} +2 -2
  92. package/public/assets/{security-07gu7r1C.js.map → security-Bwg3oFI8.js.map} +1 -1
  93. package/public/assets/{select-CLQutzAh.js → select-Cariwv69.js} +2 -2
  94. package/public/assets/{select-CLQutzAh.js.map → select-Cariwv69.js.map} +1 -1
  95. package/public/assets/{settings-E4QJiNjt.js → settings-Bd6Ug3DO.js} +4 -4
  96. package/public/assets/{settings-E4QJiNjt.js.map → settings-Bd6Ug3DO.js.map} +1 -1
  97. package/public/assets/store-BgJq_lN9.js +9 -0
  98. package/public/assets/{store-DmxnYVm1.js.map → store-BgJq_lN9.js.map} +1 -1
  99. package/public/assets/{tasks-BB1nTWjm.js → tasks-DVz1zhOU.js} +2 -2
  100. package/public/assets/{tasks-BB1nTWjm.js.map → tasks-DVz1zhOU.js.map} +1 -1
  101. package/public/assets/teams-TqFntFB_.js +2 -0
  102. package/public/assets/{teams-BtY2fi5X.js.map → teams-TqFntFB_.js.map} +1 -1
  103. package/public/assets/{terminal-viewer-impl-CHCUDXgj.js → terminal-viewer-impl-OsJ5crlx.js} +3 -3
  104. package/public/assets/{terminal-viewer-impl-CHCUDXgj.js.map → terminal-viewer-impl-OsJ5crlx.js.map} +1 -1
  105. package/public/assets/{use-keyboard-viewport-BUkrw3Gx.js → use-keyboard-viewport-BYHcL7sP.js} +2 -2
  106. package/public/assets/{use-keyboard-viewport-BUkrw3Gx.js.map → use-keyboard-viewport-BYHcL7sP.js.map} +1 -1
  107. package/public/assets/work-queue-B4b2jPLR.js +2 -0
  108. package/public/assets/{work-queue-CPXo2IdX.js.map → work-queue-B4b2jPLR.js.map} +1 -1
  109. package/public/assets/workspaces-m97LP4Eu.js +3 -0
  110. package/public/assets/{workspaces-D_vNXWDf.js.map → workspaces-m97LP4Eu.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/teams.ts +13 -2
  117. package/src/maintenance.ts +9 -2
  118. package/src/mcp-project-tools.ts +352 -0
  119. package/src/mcp.ts +3 -0
  120. package/src/project-ingest.ts +186 -0
  121. package/src/routes/index.ts +5 -0
  122. package/src/routes/projects.ts +55 -0
  123. package/src/runtime-tokens.ts +6 -0
  124. package/src/security.ts +4 -0
  125. package/src/services/spawn-agent.ts +7 -1
  126. package/src/services/team.ts +5 -1
  127. package/src/token-db.ts +3 -3
  128. package/public/assets/MessageBubble-BqFAi9gS.js +0 -2
  129. package/public/assets/MessageBubble-BqFAi9gS.js.map +0 -1
  130. package/public/assets/activity-D2m3VCXV.js +0 -2
  131. package/public/assets/agents-lDwoWazM.js +0 -2
  132. package/public/assets/branch-state-badge-B9WqTxZJ.js +0 -2
  133. package/public/assets/card-Du4YivOo.js +0 -2
  134. package/public/assets/chat-B9FJi94P.js +0 -2
  135. package/public/assets/chat-B9FJi94P.js.map +0 -1
  136. package/public/assets/copy-button-DXWhNsIl.js +0 -2
  137. package/public/assets/dist-BX1qUU9z.js +0 -2
  138. package/public/assets/dist-DEYe1tib.js +0 -2
  139. package/public/assets/dist-T7h6jNA1.js +0 -2
  140. package/public/assets/index-0rOVZ5nm.js +0 -21
  141. package/public/assets/index-wugqmbGk.css +0 -2
  142. package/public/assets/lucide-react-C3j5Huiw.js +0 -9
  143. package/public/assets/memory-f9IAAOSG.js +0 -2
  144. package/public/assets/store-DmxnYVm1.js +0 -9
  145. package/public/assets/teams-BtY2fi5X.js +0 -2
  146. package/public/assets/work-queue-CPXo2IdX.js +0 -2
  147. package/public/assets/workspaces-D_vNXWDf.js +0 -3
@@ -45,7 +45,7 @@ import { workspaceActiveClaim } from "./workspace-claim";
45
45
  import { reapOrphanedWorktrees } from "./workspace-orphans";
46
46
  import { deriveBranchState, DIRTY_WORKTREE_LAND_SKIP_REASON, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
47
47
  import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
48
- import { getStewardConfig } from "./config-store";
48
+ import { getStewardConfig, getLandingConfig } from "./config-store";
49
49
  import { ensureRepoSteward } from "./steward";
50
50
  import { emitRelayEvent } from "./events";
51
51
  import { getLifecycleManager } from "./lifecycle-manager";
@@ -809,7 +809,14 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
809
809
  continue;
810
810
  }
811
811
 
812
- const result = requestWorkspaceMerge(ws, { strategy: "rebase-ff", requestedBy: "auto-merge" });
812
+ // Honor the operator's instance landing.strategy (#419-followup): when PR mode is on
813
+ // (`landing.strategy = "pr"`), autonomous worker lands must open a PR too — not silently
814
+ // direct-merge. Passing an explicit "rebase-ff" here SHADOWS the instance config inside
815
+ // requestWorkspaceMerge (explicit wins over instanceStrategy), which is the bug that made
816
+ // PR mode never engage for spawned workers. For "pr" we OMIT the explicit strategy so
817
+ // requestWorkspaceMerge resolves it from the config and defaults autoMerge to "on-green";
818
+ // the default "direct" keeps the historical deterministic rebase-ff (no behavior change).
819
+ const result = requestWorkspaceMerge(ws, getLandingConfig().strategy === "pr" ? { requestedBy: "auto-merge" } : { strategy: "rebase-ff", requestedBy: "auto-merge" });
813
820
  if (!result.ok) {
814
821
  // 409 = another merge holds this repo's lease this tick; retry next sweep.
815
822
  heldByLease.push(ws.id);
@@ -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,7 @@ 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";
52
53
  import { AGENT_ACTION_TOOL, relayAgentAction } from "./mcp-agent-action";
53
54
  import type { ActivityKind, AgentLifecycle, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
54
55
  import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
@@ -459,6 +460,7 @@ const TOOLS: ToolDefinition[] = [
459
460
  ...TEAM_TOOLS,
460
461
  ...PLAN_TOOLS,
461
462
  ...BLACKBOARD_TOOLS,
463
+ ...PROJECT_TOOLS,
462
464
  ];
463
465
 
464
466
  export async function postMcp(req: Request): Promise<Response> {
@@ -574,6 +576,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
574
576
  else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
575
577
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
576
578
  else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
579
+ else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args);
577
580
  else throw new ValidationError(`unknown tool: ${name}`);
578
581
 
579
582
  auditToolCall(auth, name, "ok", result);
@@ -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
+ }
@@ -28,6 +28,7 @@ import { getOrchestratorDirectories, getOrchestratorFileRead, getOrchestratorFil
28
28
  import { getPairById, getPairs, postAcceptPair, postHangupPair, postPair, postPairMessage, postRejectPair } from "./pairs";
29
29
  import { getPlanByIdRoute, getPlansRoute } from "./plans";
30
30
  import { getBlackboardEntryRoute, getBlackboardRoute } from "./blackboard";
31
+ import { getProjectEntriesRoute, getProjectEntryRoute, getProjectRoute, getProjectsRoute } from "./projects";
31
32
  import { getRecipeByName, getRecipeInstanceArtifacts, getRecipeInstanceById, getRecipeInstances, getRecipes, postRecipeInstanceArtifacts, postRecipeStart, postRecipeStop } from "./recipes";
32
33
  import { getStewardConfigRoute, getWorkspaceConfigRoute, putStewardConfigRoute, putWorkspaceConfigRoute } from "./steward";
33
34
  import { getTaskById, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, postRenewTaskClaim } from "./tasks";
@@ -280,6 +281,10 @@ const routes: Route[] = [
280
281
  route("GET", "/api/plans/:id", getPlanByIdRoute),
281
282
  route("GET", "/api/blackboard", getBlackboardRoute),
282
283
  route("GET", "/api/blackboard/:id", getBlackboardEntryRoute),
284
+ route("GET", "/api/projects", getProjectsRoute),
285
+ route("GET", "/api/projects/:id", getProjectRoute),
286
+ route("GET", "/api/projects/:id/entries", getProjectEntriesRoute),
287
+ route("GET", "/api/projects/:id/entries/:entryId", getProjectEntryRoute),
283
288
 
284
289
  route("GET", "/api/spec", getApiSpec),
285
290
  route("GET", "/api/docs", getApiDocs),
@@ -0,0 +1,55 @@
1
+ import { getProjectEntry, getProjectEntryChain, listProjectEntries, listProjects, resolveProject, rootsForProject } from "../db";
2
+ import { cleanString, optionalEnum } from "../validation";
3
+ import { error, json, type Handler } from "./_shared";
4
+ import { PROJECT_ENTRY_KINDS, type ProjectEntryKind } from "../types";
5
+
6
+ // Read routes for the durable Projects scope (#405), templated on routes/blackboard.ts.
7
+ // Writes go through MCP (relay_project_*), mirroring the blackboard precedent where the
8
+ // HTTP surface is read-only. Scope-gated /api/projects → project:read in src/security.ts.
9
+
10
+ function routeKind(req: Request): ProjectEntryKind | undefined {
11
+ return optionalEnum(new URL(req.url).searchParams.get("kind") ?? undefined, "kind", PROJECT_ENTRY_KINDS) as ProjectEntryKind | undefined;
12
+ }
13
+
14
+ export const getProjectsRoute: Handler = (req) => {
15
+ try {
16
+ const includeArchived = new URL(req.url).searchParams.get("includeArchived") === "true";
17
+ return json({ projects: listProjects({ includeArchived }) });
18
+ } catch (err) {
19
+ return error(err instanceof Error ? err.message : "projects route failed");
20
+ }
21
+ };
22
+
23
+ export const getProjectRoute: Handler = (req, params) => {
24
+ try {
25
+ const ref = cleanString(params.id, "id", { required: true, max: 200 })!;
26
+ const project = resolveProject(ref);
27
+ if (!project) return error("project not found", 404);
28
+ return json({ project, roots: rootsForProject(project.id) });
29
+ } catch (err) {
30
+ return error(err instanceof Error ? err.message : "projects route failed");
31
+ }
32
+ };
33
+
34
+ export const getProjectEntriesRoute: Handler = (req, params) => {
35
+ try {
36
+ const ref = cleanString(params.id, "id", { required: true, max: 200 })!;
37
+ const project = resolveProject(ref);
38
+ if (!project) return error("project not found", 404);
39
+ const includeInactive = new URL(req.url).searchParams.get("includeInactive") === "true";
40
+ return json({ entries: listProjectEntries(project.id, { kind: routeKind(req), includeInactive }) });
41
+ } catch (err) {
42
+ return error(err instanceof Error ? err.message : "projects route failed");
43
+ }
44
+ };
45
+
46
+ export const getProjectEntryRoute: Handler = (req, params) => {
47
+ try {
48
+ const id = cleanString(params.entryId, "entryId", { required: true, max: 120 })!;
49
+ const entry = getProjectEntry(id);
50
+ if (!entry) return error("project entry not found", 404);
51
+ return json({ entry, chain: getProjectEntryChain(id) });
52
+ } catch (err) {
53
+ return error(err instanceof Error ? err.message : "projects route failed");
54
+ }
55
+ };