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.
- package/docs/openapi.json +151 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-CHKzD4RY.js → MessageBubble-BrEKymoV.js} +2 -2
- package/public/assets/{MessageBubble-CHKzD4RY.js.map → MessageBubble-BrEKymoV.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BFVTJa0q.js → PlanGraphPanel-CXix1C-H.js} +2 -2
- package/public/assets/{PlanGraphPanel-BFVTJa0q.js.map → PlanGraphPanel-CXix1C-H.js.map} +1 -1
- package/public/assets/{activity-M-dFLU_O.js → activity-CRafQ1Je.js} +2 -2
- package/public/assets/{activity-M-dFLU_O.js.map → activity-CRafQ1Je.js.map} +1 -1
- package/public/assets/{agent-profiles-CvHSoyCX.js → agent-profiles-DW4o5-ND.js} +2 -2
- package/public/assets/{agent-profiles-CvHSoyCX.js.map → agent-profiles-DW4o5-ND.js.map} +1 -1
- package/public/assets/agents-Cbegv0Hm.js +2 -0
- package/public/assets/{agents-DZBM5dp_.js.map → agents-Cbegv0Hm.js.map} +1 -1
- package/public/assets/analytics-DX6T7JX3.js +910 -0
- package/public/assets/{analytics-bvjb3Urs.js.map → analytics-DX6T7JX3.js.map} +1 -1
- package/public/assets/{automation-CDSn9Uq-.js → automation-CzSBv4gg.js} +2 -2
- package/public/assets/{automation-CDSn9Uq-.js.map → automation-CzSBv4gg.js.map} +1 -1
- package/public/assets/{badge-Dj6PREuI.js → badge-Cy6qO_Jz.js} +2 -2
- package/public/assets/{badge-Dj6PREuI.js.map → badge-Cy6qO_Jz.js.map} +1 -1
- package/public/assets/{branch-state-badge-CdYg2HYc.js → branch-state-badge-CRA50r81.js} +2 -2
- package/public/assets/{branch-state-badge-CdYg2HYc.js.map → branch-state-badge-CRA50r81.js.map} +1 -1
- package/public/assets/{button-Cqpufqam.js → button-73wpRw65.js} +2 -2
- package/public/assets/{button-Cqpufqam.js.map → button-73wpRw65.js.map} +1 -1
- package/public/assets/{card-OmDF73CE.js → card-BU6PvVHv.js} +2 -2
- package/public/assets/{card-OmDF73CE.js.map → card-BU6PvVHv.js.map} +1 -1
- package/public/assets/{channels-BkxrdOFZ.js → channels-B1MQCPCT.js} +2 -2
- package/public/assets/{channels-BkxrdOFZ.js.map → channels-B1MQCPCT.js.map} +1 -1
- package/public/assets/chat-BqIaFYGJ.js +2 -0
- package/public/assets/{chat-YzX1z76l.js.map → chat-BqIaFYGJ.js.map} +1 -1
- package/public/assets/{connectors-Bg3rHgl_.js → connectors-C9aFJIrh.js} +2 -2
- package/public/assets/{connectors-Bg3rHgl_.js.map → connectors-C9aFJIrh.js.map} +1 -1
- package/public/assets/{copy-button-DI7b8A7_.js → copy-button-HRopUwui.js} +2 -2
- package/public/assets/{copy-button-DI7b8A7_.js.map → copy-button-HRopUwui.js.map} +1 -1
- package/public/assets/display-CketawEF.js.map +1 -1
- package/public/assets/{dist-CNjnTZEI.js → dist-CC5f7oYw.js} +2 -2
- package/public/assets/{dist-CNjnTZEI.js.map → dist-CC5f7oYw.js.map} +1 -1
- package/public/assets/{dist-DkeTrbTA.js → dist-CLHMeXYi.js} +2 -2
- package/public/assets/{dist-DkeTrbTA.js.map → dist-CLHMeXYi.js.map} +1 -1
- package/public/assets/{dist-C5t_OxI0.js → dist-Ci1MM51P.js} +2 -2
- package/public/assets/{dist-C5t_OxI0.js.map → dist-Ci1MM51P.js.map} +1 -1
- package/public/assets/{dist-CV3sgFiv.js → dist-Cko2KPW8.js} +2 -2
- package/public/assets/{dist-CV3sgFiv.js.map → dist-Cko2KPW8.js.map} +1 -1
- package/public/assets/dist-D41tzJYg.js +2 -0
- package/public/assets/{dist-yYauqYKR.js.map → dist-D41tzJYg.js.map} +1 -1
- package/public/assets/{dist-CoUvwLTy.js → dist-DFLciw0h.js} +2 -2
- package/public/assets/{dist-CoUvwLTy.js.map → dist-DFLciw0h.js.map} +1 -1
- package/public/assets/dist-DUoZnlB2.js +2 -0
- package/public/assets/{dist-DmhyU0Na.js.map → dist-DUoZnlB2.js.map} +1 -1
- package/public/assets/dist-De2oguBm.js +2 -0
- package/public/assets/{dist-DxkMwkrr.js.map → dist-De2oguBm.js.map} +1 -1
- package/public/assets/{dist-1x-LE9Uk.js → dist-DmN94rc8.js} +2 -2
- package/public/assets/{dist-1x-LE9Uk.js.map → dist-DmN94rc8.js.map} +1 -1
- package/public/assets/{dist-DV2tZ2fq.js → dist-Kkpnjx_M.js} +2 -2
- package/public/assets/{dist-DV2tZ2fq.js.map → dist-Kkpnjx_M.js.map} +1 -1
- package/public/assets/{dist-Ca3ujqG_.js → dist-T7u7IZGJ.js} +2 -2
- package/public/assets/{dist-Ca3ujqG_.js.map → dist-T7u7IZGJ.js.map} +1 -1
- package/public/assets/{es2015-DcavLcQ0.js → es2015-Df08Opjx.js} +2 -2
- package/public/assets/{es2015-DcavLcQ0.js.map → es2015-Df08Opjx.js.map} +1 -1
- package/public/assets/{formatted-body-impl-D8i9qqL1.js → formatted-body-impl-DOXH7oDF.js} +2 -2
- package/public/assets/{formatted-body-impl-D8i9qqL1.js.map → formatted-body-impl-DOXH7oDF.js.map} +1 -1
- package/public/assets/index-DaNOQJci.css +2 -0
- package/public/assets/index-br_Y6Cvt.js +21 -0
- package/public/assets/{index-Ba2RDJaA.js.map → index-br_Y6Cvt.js.map} +1 -1
- package/public/assets/{input-C_dl_9fG.js → input-B2EDz-BE.js} +2 -2
- package/public/assets/{input-C_dl_9fG.js.map → input-B2EDz-BE.js.map} +1 -1
- package/public/assets/{insights-D1aANwZ2.js → insights-B1edq5lk.js} +2 -2
- package/public/assets/{insights-D1aANwZ2.js.map → insights-B1edq5lk.js.map} +1 -1
- package/public/assets/{integrations-BrAr9bap.js → integrations-Cm4fjp7_.js} +2 -2
- package/public/assets/{integrations-BrAr9bap.js.map → integrations-Cm4fjp7_.js.map} +1 -1
- package/public/assets/{lib-B3iMVsf-.js → lib-BZrjIp-o.js} +3 -3
- package/public/assets/{lib-B3iMVsf-.js.map → lib-BZrjIp-o.js.map} +1 -1
- package/public/assets/lucide-react-H_-8G79A.js +9 -0
- package/public/assets/{lucide-react-CMhBract.js.map → lucide-react-H_-8G79A.js.map} +1 -1
- package/public/assets/{maintenance-C-xspsMG.js → maintenance-D6zvw0fU.js} +2 -2
- package/public/assets/{maintenance-C-xspsMG.js.map → maintenance-D6zvw0fU.js.map} +1 -1
- package/public/assets/{managed-agents-Bf3ni0vo.js → managed-agents-CzfEznM1.js} +2 -2
- package/public/assets/{managed-agents-Bf3ni0vo.js.map → managed-agents-CzfEznM1.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DKbUBTf_.js → markdown-preview-impl-DZlCaTHQ.js} +2 -2
- package/public/assets/{markdown-preview-impl-DKbUBTf_.js.map → markdown-preview-impl-DZlCaTHQ.js.map} +1 -1
- package/public/assets/memory-CUUAzJDc.js +2 -0
- package/public/assets/{memory-DeLwJFwH.js.map → memory-CUUAzJDc.js.map} +1 -1
- package/public/assets/messages-6QZH46LG.js +2 -0
- package/public/assets/{messages-Cww6dZnM.js.map → messages-6QZH46LG.js.map} +1 -1
- package/public/assets/{orchestrators-DRjwROXw.js → orchestrators-CrcqkPuc.js} +2 -2
- package/public/assets/{orchestrators-DRjwROXw.js.map → orchestrators-CrcqkPuc.js.map} +1 -1
- package/public/assets/overview-418xXZO_.js +2 -0
- package/public/assets/{overview-DjcRomU_.js.map → overview-418xXZO_.js.map} +1 -1
- package/public/assets/pairs-CnedATgT.js +2 -0
- package/public/assets/{pairs-DT0u2fnW.js.map → pairs-CnedATgT.js.map} +1 -1
- package/public/assets/{react-dom-Dtl9-x_1.js → react-dom-WI6TjVe7.js} +2 -2
- package/public/assets/{react-dom-Dtl9-x_1.js.map → react-dom-WI6TjVe7.js.map} +1 -1
- package/public/assets/{security-Bwg3oFI8.js → security-DS13pbag.js} +2 -2
- package/public/assets/{security-Bwg3oFI8.js.map → security-DS13pbag.js.map} +1 -1
- package/public/assets/{select-Cariwv69.js → select-BOprGZDS.js} +2 -2
- package/public/assets/{select-Cariwv69.js.map → select-BOprGZDS.js.map} +1 -1
- package/public/assets/{settings-Bd6Ug3DO.js → settings-BgInn_f3.js} +4 -4
- package/public/assets/{settings-Bd6Ug3DO.js.map → settings-BgInn_f3.js.map} +1 -1
- package/public/assets/{store-BgJq_lN9.js → store-DHO_a8BD.js} +5 -5
- package/public/assets/{store-BgJq_lN9.js.map → store-DHO_a8BD.js.map} +1 -1
- package/public/assets/{tasks-DVz1zhOU.js → tasks-B-4oCPuv.js} +2 -2
- package/public/assets/{tasks-DVz1zhOU.js.map → tasks-B-4oCPuv.js.map} +1 -1
- package/public/assets/teams-sCQAs_QA.js +2 -0
- package/public/assets/{teams-TqFntFB_.js.map → teams-sCQAs_QA.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-OsJ5crlx.js → terminal-viewer-impl-BhqS2XpS.js} +3 -3
- package/public/assets/{terminal-viewer-impl-OsJ5crlx.js.map → terminal-viewer-impl-BhqS2XpS.js.map} +1 -1
- package/public/assets/{use-keyboard-viewport-BYHcL7sP.js → use-keyboard-viewport-fOL_3RHB.js} +2 -2
- package/public/assets/{use-keyboard-viewport-BYHcL7sP.js.map → use-keyboard-viewport-fOL_3RHB.js.map} +1 -1
- package/public/assets/{work-queue-B4b2jPLR.js → work-queue-BDhjlwUH.js} +2 -2
- package/public/assets/{work-queue-B4b2jPLR.js.map → work-queue-BDhjlwUH.js.map} +1 -1
- package/public/assets/workspaces-6I-C69nf.js +3 -0
- package/public/assets/{workspaces-m97LP4Eu.js.map → workspaces-6I-C69nf.js.map} +1 -1
- package/public/index.html +21 -21
- package/src/db/index.ts +2 -0
- package/src/db/project-entries.ts +270 -0
- package/src/db/projects.ts +304 -0
- package/src/db/schema.ts +50 -0
- package/src/db/stats.ts +209 -38
- package/src/db/teams.ts +13 -2
- package/src/maintenance.ts +9 -2
- package/src/mcp-project-tools.ts +352 -0
- package/src/mcp.ts +6 -0
- package/src/project-ingest.ts +186 -0
- package/src/project-instructions.ts +176 -0
- package/src/routes/agents-spawn.ts +2 -0
- package/src/routes/commands.ts +11 -5
- package/src/routes/index.ts +5 -0
- package/src/routes/projects.ts +55 -0
- package/src/runtime-tokens.ts +6 -0
- package/src/security.ts +4 -0
- package/src/services/spawn-agent.ts +33 -2
- package/src/services/team.ts +5 -1
- package/src/token-db.ts +3 -3
- package/src/workspace-merge.ts +11 -0
- package/src/workspace-pr-completion.ts +57 -1
- package/public/assets/agents-DZBM5dp_.js +0 -2
- package/public/assets/analytics-bvjb3Urs.js +0 -910
- package/public/assets/chat-YzX1z76l.js +0 -2
- package/public/assets/dist-DmhyU0Na.js +0 -2
- package/public/assets/dist-DxkMwkrr.js +0 -2
- package/public/assets/dist-yYauqYKR.js +0 -2
- package/public/assets/index-Ba2RDJaA.js +0 -21
- package/public/assets/index-CtFXWxBJ.css +0 -2
- package/public/assets/lucide-react-CMhBract.js +0 -9
- package/public/assets/memory-DeLwJFwH.js +0 -2
- package/public/assets/messages-Cww6dZnM.js +0 -2
- package/public/assets/overview-DjcRomU_.js +0 -2
- package/public/assets/pairs-DT0u2fnW.js +0 -2
- package/public/assets/teams-TqFntFB_.js +0 -2
- 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
|
+
}
|