@magic-spells/constellation 0.1.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 (190) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +147 -0
  3. package/dist/cli/index.d.ts +2 -0
  4. package/dist/cli/index.js +127 -0
  5. package/dist/cli/index.js.map +1 -0
  6. package/dist/core/extract.d.ts +9 -0
  7. package/dist/core/extract.js +56 -0
  8. package/dist/core/extract.js.map +1 -0
  9. package/dist/core/handles.d.ts +13 -0
  10. package/dist/core/handles.js +45 -0
  11. package/dist/core/handles.js.map +1 -0
  12. package/dist/core/index.d.ts +8 -0
  13. package/dist/core/index.js +9 -0
  14. package/dist/core/index.js.map +1 -0
  15. package/dist/core/indexer.d.ts +3 -0
  16. package/dist/core/indexer.js +209 -0
  17. package/dist/core/indexer.js.map +1 -0
  18. package/dist/core/lint.d.ts +9 -0
  19. package/dist/core/lint.js +19 -0
  20. package/dist/core/lint.js.map +1 -0
  21. package/dist/core/parse.d.ts +7 -0
  22. package/dist/core/parse.js +19 -0
  23. package/dist/core/parse.js.map +1 -0
  24. package/dist/core/resolve.d.ts +12 -0
  25. package/dist/core/resolve.js +57 -0
  26. package/dist/core/resolve.js.map +1 -0
  27. package/dist/core/scaffold.d.ts +3 -0
  28. package/dist/core/scaffold.js +36 -0
  29. package/dist/core/scaffold.js.map +1 -0
  30. package/dist/core/types.d.ts +50 -0
  31. package/dist/core/types.js +5 -0
  32. package/dist/core/types.js.map +1 -0
  33. package/dist/core/validate.d.ts +6 -0
  34. package/dist/core/validate.js +64 -0
  35. package/dist/core/validate.js.map +1 -0
  36. package/dist/core/writer.d.ts +34 -0
  37. package/dist/core/writer.js +170 -0
  38. package/dist/core/writer.js.map +1 -0
  39. package/dist/mcp/git.d.ts +34 -0
  40. package/dist/mcp/git.js +172 -0
  41. package/dist/mcp/git.js.map +1 -0
  42. package/dist/mcp/search.d.ts +11 -0
  43. package/dist/mcp/search.js +52 -0
  44. package/dist/mcp/search.js.map +1 -0
  45. package/dist/mcp/server.d.ts +7 -0
  46. package/dist/mcp/server.js +684 -0
  47. package/dist/mcp/server.js.map +1 -0
  48. package/dist/serve/server.d.ts +12 -0
  49. package/dist/serve/server.js +281 -0
  50. package/dist/serve/server.js.map +1 -0
  51. package/docs/001-file-format.md +222 -0
  52. package/docs/002-mcp.md +109 -0
  53. package/examples/constellation/agent/AGENT-CODE-STYLE.md +14 -0
  54. package/examples/constellation/api/API-TICKETS.md +24 -0
  55. package/examples/constellation/component/COMPONENT-TICKET-CARD.md +16 -0
  56. package/examples/constellation/datatype/DATATYPE-CREATE-TICKET-INPUT.md +15 -0
  57. package/examples/constellation/datatype/DATATYPE-TICKET.md +20 -0
  58. package/examples/constellation/db/DB-TICKETS.md +21 -0
  59. package/examples/constellation/diagram/DIAGRAM-SYSTEM-OVERVIEW.md +19 -0
  60. package/examples/constellation/doc/DOC-TICKET-LIFECYCLE.md +22 -0
  61. package/examples/constellation/event/EVENT-TICKET-CREATED.md +13 -0
  62. package/examples/constellation/external/EXTERNAL-EMAIL-PROVIDER.md +13 -0
  63. package/examples/constellation/file/FILE-TICKETS-ROUTE.md +12 -0
  64. package/examples/constellation/flow/FLOW-CREATE-TICKET.md +16 -0
  65. package/examples/constellation/job/JOB-AUTO-ASSIGN.md +15 -0
  66. package/examples/constellation/page/PAGE-INBOX.md +19 -0
  67. package/examples/constellation/plan.md +23 -0
  68. package/examples/constellation/role/ROLE-SUPPORT-AGENT.md +12 -0
  69. package/examples/constellation/state/STATE-TICKET.md +31 -0
  70. package/examples/constellation/test/TEST-CREATE-TICKET.md +17 -0
  71. package/package.json +80 -0
  72. package/schemas/agent.json +21 -0
  73. package/schemas/api.json +47 -0
  74. package/schemas/card.json +37 -0
  75. package/schemas/component.json +40 -0
  76. package/schemas/datatype.json +8 -0
  77. package/schemas/db.json +51 -0
  78. package/schemas/diagram.json +95 -0
  79. package/schemas/doc.json +8 -0
  80. package/schemas/event.json +28 -0
  81. package/schemas/external.json +24 -0
  82. package/schemas/file.json +22 -0
  83. package/schemas/flow.json +20 -0
  84. package/schemas/job.json +25 -0
  85. package/schemas/page.json +36 -0
  86. package/schemas/plan.json +13 -0
  87. package/schemas/role.json +18 -0
  88. package/schemas/state.json +35 -0
  89. package/schemas/test.json +13 -0
  90. package/skill/SKILL.md +105 -0
  91. package/skill/types/agent.md +26 -0
  92. package/skill/types/api.md +38 -0
  93. package/skill/types/component.md +30 -0
  94. package/skill/types/datatype.md +26 -0
  95. package/skill/types/db.md +33 -0
  96. package/skill/types/diagram.md +32 -0
  97. package/skill/types/doc.md +23 -0
  98. package/skill/types/event.md +31 -0
  99. package/skill/types/external.md +30 -0
  100. package/skill/types/file.md +28 -0
  101. package/skill/types/flow.md +29 -0
  102. package/skill/types/job.md +27 -0
  103. package/skill/types/page.md +28 -0
  104. package/skill/types/plan.md +37 -0
  105. package/skill/types/role.md +25 -0
  106. package/skill/types/state.md +38 -0
  107. package/skill/types/test.md +28 -0
  108. package/viewer/dist/assets/arc-Kj6pF3JI.js +1 -0
  109. package/viewer/dist/assets/architecture-7EHR7CIX-CGfWeim3.js +1 -0
  110. package/viewer/dist/assets/architectureDiagram-3BPJPVTR-C5bZdErB.js +36 -0
  111. package/viewer/dist/assets/array-BifhSqXX.js +1 -0
  112. package/viewer/dist/assets/blockDiagram-GPEHLZMM-C1Q6l6fE.js +132 -0
  113. package/viewer/dist/assets/c4Diagram-AAUBKEIU-BmM6Tmtq.js +10 -0
  114. package/viewer/dist/assets/channel-19IdUS_c.js +1 -0
  115. package/viewer/dist/assets/chunk-2J33WTMH-z09tLTpZ.js +1 -0
  116. package/viewer/dist/assets/chunk-3OPIFGDE-BynpXh1r.js +62 -0
  117. package/viewer/dist/assets/chunk-4BX2VUAB-CDOVuPyG.js +1 -0
  118. package/viewer/dist/assets/chunk-55IACEB6-nBwigOgn.js +1 -0
  119. package/viewer/dist/assets/chunk-5ZQYHXKU-Bxe5xIy_.js +2 -0
  120. package/viewer/dist/assets/chunk-727SXJPM-DZmTgL68.js +206 -0
  121. package/viewer/dist/assets/chunk-AQP2D5EJ-B7wr_Owx.js +231 -0
  122. package/viewer/dist/assets/chunk-BSJP7CBP-DbAKfVCK.js +1 -0
  123. package/viewer/dist/assets/chunk-CSCIHK7Q-C0rsBwqP.js +124 -0
  124. package/viewer/dist/assets/chunk-FMBD7UC4-BAtzt0wv.js +15 -0
  125. package/viewer/dist/assets/chunk-KSCS5N6A-CXXwf52I.js +10 -0
  126. package/viewer/dist/assets/chunk-L5ZTLDWV-DS4vRI1U.js +1 -0
  127. package/viewer/dist/assets/chunk-LZXEDZCA-CclT9MXr.js +2 -0
  128. package/viewer/dist/assets/chunk-ND2GUHAM-CUSnPl8t.js +1 -0
  129. package/viewer/dist/assets/chunk-NNHCCRGN-DlpIbxXb.js +159 -0
  130. package/viewer/dist/assets/chunk-NZK2D7GU-Dh986nJk.js +1 -0
  131. package/viewer/dist/assets/chunk-O5CBEL6O-JAEZ_pS6.js +70 -0
  132. package/viewer/dist/assets/chunk-QZHKN3VN-BKc_Kg2Z.js +1 -0
  133. package/viewer/dist/assets/chunk-WU5MYG2G-9ssTSMzt.js +1 -0
  134. package/viewer/dist/assets/chunk-XPW4576I-BwMZI0gv.js +32 -0
  135. package/viewer/dist/assets/classDiagram-4FO5ZUOK-DXv85WFd.js +1 -0
  136. package/viewer/dist/assets/classDiagram-v2-Q7XG4LA2-DXv85WFd.js +1 -0
  137. package/viewer/dist/assets/cose-bilkent-S5V4N54A-NGC7gYHM.js +1 -0
  138. package/viewer/dist/assets/cytoscape.esm-h6BdjjI9.js +321 -0
  139. package/viewer/dist/assets/dagre-BM42HDAG-RD63uyvd.js +4 -0
  140. package/viewer/dist/assets/dagre-Bx709z4p.js +1 -0
  141. package/viewer/dist/assets/defaultLocale-C8Fc0cco.js +1 -0
  142. package/viewer/dist/assets/diagram-2AECGRRQ-hwnqqCcb.js +43 -0
  143. package/viewer/dist/assets/diagram-5GNKFQAL-q8EaoZSG.js +10 -0
  144. package/viewer/dist/assets/diagram-KO2AKTUF-D4_5Qf-l.js +3 -0
  145. package/viewer/dist/assets/diagram-LMA3HP47-D8pwekFs.js +24 -0
  146. package/viewer/dist/assets/diagram-OG6HWLK6-D9KinIWZ.js +24 -0
  147. package/viewer/dist/assets/dist-CFOOgrqc.js +1 -0
  148. package/viewer/dist/assets/erDiagram-TEJ5UH35-D0Wfq250.js +85 -0
  149. package/viewer/dist/assets/eventmodeling-FCH6USID-D3KRSuC1.js +1 -0
  150. package/viewer/dist/assets/flowDiagram-I6XJVG4X-Y2DY-Ze2.js +162 -0
  151. package/viewer/dist/assets/ganttDiagram-6RSMTGT7-BnqkeLVw.js +292 -0
  152. package/viewer/dist/assets/gitGraph-WXDBUCRP-Cft7usRT.js +1 -0
  153. package/viewer/dist/assets/gitGraphDiagram-PVQCEYII-D-cYtraK.js +106 -0
  154. package/viewer/dist/assets/graphlib-B8gBHxth.js +1 -0
  155. package/viewer/dist/assets/index-CDR-riG2.css +2 -0
  156. package/viewer/dist/assets/index-DRPsTWe2.js +98 -0
  157. package/viewer/dist/assets/info-J43DQDTF-Djc8Bx3F.js +1 -0
  158. package/viewer/dist/assets/infoDiagram-5YYISTIA-D-ehtyyJ.js +2 -0
  159. package/viewer/dist/assets/init-D6jRqBbL.js +1 -0
  160. package/viewer/dist/assets/ishikawaDiagram-YF4QCWOH-Ct3f6bH-.js +70 -0
  161. package/viewer/dist/assets/journeyDiagram-JHISSGLW-DXlULEmi.js +139 -0
  162. package/viewer/dist/assets/kanban-definition-UN3LZRKU-3vE9h-R7.js +89 -0
  163. package/viewer/dist/assets/katex-Vhh-h91d.js +257 -0
  164. package/viewer/dist/assets/line-B8MygbLB.js +1 -0
  165. package/viewer/dist/assets/linear-CfMuM0B3.js +1 -0
  166. package/viewer/dist/assets/mermaid-parser.core-DzlZTbbh.js +4 -0
  167. package/viewer/dist/assets/mermaid.core-IM-sPiyq.js +9 -0
  168. package/viewer/dist/assets/mindmap-definition-RKZ34NQL-CMnpAq1T.js +96 -0
  169. package/viewer/dist/assets/ordinal-hYBb2elL.js +1 -0
  170. package/viewer/dist/assets/packet-YPE3B663-D44AzgHh.js +1 -0
  171. package/viewer/dist/assets/path-BWPyau1x.js +1 -0
  172. package/viewer/dist/assets/pie-LRSECV5Y-DL8AVJH_.js +1 -0
  173. package/viewer/dist/assets/pieDiagram-4H26LBE5-FvKK5jd7.js +30 -0
  174. package/viewer/dist/assets/quadrantDiagram-W4KKPZXB-CmjSkU8c.js +7 -0
  175. package/viewer/dist/assets/radar-GUYGQ44K-BvfZTVyH.js +1 -0
  176. package/viewer/dist/assets/requirementDiagram-4Y6WPE33-BOjca3VH.js +84 -0
  177. package/viewer/dist/assets/rough.esm-CSKSodPl.js +1 -0
  178. package/viewer/dist/assets/sankeyDiagram-5OEKKPKP-ANcjfNix.js +40 -0
  179. package/viewer/dist/assets/sequenceDiagram-3UESZ5HK-BLQ9AL7I.js +162 -0
  180. package/viewer/dist/assets/src-CAMdANUp.js +1 -0
  181. package/viewer/dist/assets/stateDiagram-AJRCARHV-D6CriBS6.js +1 -0
  182. package/viewer/dist/assets/stateDiagram-v2-BHNVJYJU-DcTp66RQ.js +1 -0
  183. package/viewer/dist/assets/timeline-definition-PNZ67QCA-BNhWZ_DL.js +120 -0
  184. package/viewer/dist/assets/treeView-BLDUP644-CY6Ph5Pu.js +1 -0
  185. package/viewer/dist/assets/treemap-LRROVOQU-DChSA_Qx.js +1 -0
  186. package/viewer/dist/assets/vennDiagram-CIIHVFJN-C01WznAC.js +34 -0
  187. package/viewer/dist/assets/wardley-L42UT6IY-BJ8uNoJu.js +1 -0
  188. package/viewer/dist/assets/wardleyDiagram-YWT4CUSO-DwDEzlVm.js +78 -0
  189. package/viewer/dist/assets/xychartDiagram-2RQKCTM6-BCvIDwU0.js +7 -0
  190. package/viewer/dist/index.html +20 -0
@@ -0,0 +1,684 @@
1
+ import { rm } from 'node:fs/promises';
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { z } from 'zod';
5
+ import { isHandleShaped, isKnownHandle, typeForHandle } from '../core/handles.js';
6
+ import { loadPlan } from '../core/indexer.js';
7
+ import { lintPlan } from '../core/lint.js';
8
+ import { resolvePlanDir } from '../core/resolve.js';
9
+ import { TYPE_NAMES } from '../core/types.js';
10
+ import { diffPlan, planDirty, planLog, writeSyncPoint } from './git.js';
11
+ import { searchCards } from './search.js';
12
+ import { applyCardPatch, createCardFile, deepMerge, relPathForHandle, reservedFieldKeys, updateCardFile, } from '../core/writer.js';
13
+ const INSTRUCTIONS = `# Constellation MCP
14
+
15
+ The project's architecture plan lives as markdown files in a constellation/ folder.
16
+ Each file is a **card** (the filename is the handle: api/API-TICKETS.md = API-TICKETS);
17
+ cards are linked by undirected **connections** derived from the connections: frontmatter
18
+ list, handle-shaped frontmatter values, [[HANDLE]] body links, and mermaid node IDs.
19
+
20
+ Retrieval is hydrated: get_card / search / traverse can return connected cards with
21
+ their FULL frontmatter and body in one call (connected: "full"). Use that when you are
22
+ about to work on an area; use "summary" for orientation.
23
+
24
+ Writes are validated: every write tool lints and returns issues for the file it touched.
25
+ update_card patch.fields deep-merges (arrays replace, null deletes); body replaces.
26
+ Body-only updates never reformat frontmatter.
27
+
28
+ Change tracking is git: diff_plan reports per-card changes since the sync marker (or
29
+ HEAD); traverse the changed handles for blast radius; set_sync_point after reconciling
30
+ code (commit the plan first — it warns if the plan is uncommitted). Never stamp dirty
31
+ flags into cards.
32
+
33
+ For migrations or large scaffolds, use create_cards and add_connections (batched, one
34
+ lint pass) instead of many single calls — connections between cards in the same batch
35
+ resolve without transient "does not resolve" errors. A card is created even when issues
36
+ are returned (issues are lint state, not failure). check_integrity reports orphans
37
+ (zero-connection cards), and list_cards connected:false lists them.
38
+
39
+ The plan folder is found by walking up from the working directory, BOUNDED by the repo
40
+ root (it never adopts another repo's plan). If no plan exists in this repo (tools return
41
+ NO_PLAN_FOUND), call init_plan once — create_card works immediately after.
42
+
43
+ To let the user browse the plan visually, start_viewer launches a local web server that
44
+ renders the plan as an editable site and returns its URL (it scans forward from port 4747
45
+ for a free port, so always read the actual port from the response). ALWAYS post that URL
46
+ back to the user as a clickable link, e.g. http://localhost:4747/, and tell them the port.
47
+ The viewer runs until stop_viewer or until this server process exits.`;
48
+ function ok(data) {
49
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
50
+ }
51
+ function fail(code, message) {
52
+ return {
53
+ content: [{ type: 'text', text: JSON.stringify({ error: { code, message } }) }],
54
+ isError: true,
55
+ };
56
+ }
57
+ function summary(card) {
58
+ return {
59
+ handle: card.handle,
60
+ type: card.type,
61
+ kind: card.kind ?? null,
62
+ name: card.name ?? null,
63
+ status: card.status ?? null,
64
+ };
65
+ }
66
+ function full(card) {
67
+ return { ...summary(card), frontmatter: card.frontmatter, body: card.body };
68
+ }
69
+ function connectedCards(index, handle, detail) {
70
+ if (detail === 'none')
71
+ return undefined;
72
+ const handles = [...(index.connectedHandles.get(handle) ?? [])].sort();
73
+ const cards = handles
74
+ .map((h) => index.cards.get(h))
75
+ .filter((c) => Boolean(c));
76
+ return cards.map((c) => (detail === 'full' ? full(c) : summary(c)));
77
+ }
78
+ function issuesForFile(issues, relPath) {
79
+ return issues.filter((i) => i.file === relPath);
80
+ }
81
+ const detailSchema = z.enum(['none', 'summary', 'full']);
82
+ const typeSchema = z.enum(TYPE_NAMES);
83
+ const statusSchema = z.enum(['planned', 'building', 'built', 'verified']);
84
+ export function buildServer(options = {}) {
85
+ const server = new McpServer({ name: 'constellation', version: '0.1.0' }, { instructions: INSTRUCTIONS });
86
+ async function planRoot() {
87
+ return options.planRoot ?? resolvePlanDir();
88
+ }
89
+ // A single web viewer owned by this server process; null until start_viewer runs.
90
+ let viewer = null;
91
+ /** Wrap a handler with plan resolution and error reporting. */
92
+ function withPlan(handler) {
93
+ return async (args) => {
94
+ const root = await planRoot();
95
+ if (!root) {
96
+ return fail('NO_PLAN_FOUND', `No constellation/ folder found by walking up from ${process.cwd()}. This MCP ` +
97
+ `server uses its own working directory — if that isn't your repo, set "cwd" to ` +
98
+ 'the repo root in your MCP client config. Otherwise call init_plan (optionally ' +
99
+ 'with { path } pointing at the repo root), or run `constellation init`.');
100
+ }
101
+ try {
102
+ return await handler(root, args);
103
+ }
104
+ catch (err) {
105
+ return fail('INTERNAL', err instanceof Error ? err.message : String(err));
106
+ }
107
+ };
108
+ }
109
+ server.registerTool('init_plan', {
110
+ description: 'Bootstrap a new plan: create a constellation/ folder with a starter plan.md. Use only when no plan exists yet (other tools return NO_PLAN_FOUND). After this, create_card works immediately.',
111
+ inputSchema: {
112
+ path: z
113
+ .string()
114
+ .optional()
115
+ .describe('directory to create constellation/ in (default: cwd)'),
116
+ },
117
+ }, async ({ path: target }) => {
118
+ try {
119
+ const { initPlan } = await import('../core/scaffold.js');
120
+ const created = await initPlan(target ?? process.cwd());
121
+ return ok({
122
+ created,
123
+ next: 'Add cards with create_card; update plan.md via update_card on PLAN-PROJECT.',
124
+ });
125
+ }
126
+ catch (err) {
127
+ return fail('INIT_FAILED', err instanceof Error ? err.message : String(err));
128
+ }
129
+ });
130
+ server.registerTool('get_card', {
131
+ description: 'Fetch one card by handle, optionally with all connected cards hydrated. connected: "full" returns the complete frontmatter and body of every connected card — use it when about to work on an area.',
132
+ inputSchema: {
133
+ handle: z.string(),
134
+ connected: detailSchema.optional().describe('default: summary'),
135
+ },
136
+ }, withPlan(async (root, { handle, connected }) => {
137
+ const index = await loadPlan(root);
138
+ const card = index.cards.get(handle.toUpperCase());
139
+ if (!card)
140
+ return fail('NOT_FOUND', `No card with handle ${handle}`);
141
+ return ok({
142
+ card: full(card),
143
+ connected_cards: connectedCards(index, card.handle, connected ?? 'summary'),
144
+ });
145
+ }));
146
+ server.registerTool('list_cards', {
147
+ description: 'Catalog of cards filtered by type, kind, status, and/or connectedness. connected:false returns orphans (cards with zero connections). Returns summaries (handle, type, kind, name, status).',
148
+ inputSchema: {
149
+ types: z.array(typeSchema).optional(),
150
+ kind: z.string().optional(),
151
+ status: statusSchema.optional(),
152
+ connected: z
153
+ .boolean()
154
+ .optional()
155
+ .describe('false = orphans only; true = connected only'),
156
+ limit: z.number().int().min(1).max(500).optional(),
157
+ },
158
+ }, withPlan(async (root, { types, kind, status, connected, limit }) => {
159
+ const index = await loadPlan(root);
160
+ const typeFilter = types && types.length > 0 ? new Set(types) : null;
161
+ const isConnected = (h) => (index.connectedHandles.get(h)?.size ?? 0) > 0;
162
+ const matched = [...index.cards.values()]
163
+ .filter((c) => !typeFilter || typeFilter.has(c.type))
164
+ .filter((c) => !kind || c.kind === kind)
165
+ .filter((c) => !status || c.status === status)
166
+ .filter((c) => connected === undefined || isConnected(c.handle) === connected)
167
+ .sort((a, b) => a.handle.localeCompare(b.handle));
168
+ return ok({
169
+ total: matched.length,
170
+ cards: matched.slice(0, limit ?? 200).map(summary),
171
+ });
172
+ }));
173
+ server.registerTool('search', {
174
+ description: 'Scored full-text search over handles, names, kinds, and bodies. Set connected: "full" to hydrate each match with the complete content of its connected cards — fuzzy query to working context in one call.',
175
+ inputSchema: {
176
+ q: z.string(),
177
+ types: z.array(typeSchema).optional(),
178
+ limit: z.number().int().min(1).max(100).optional(),
179
+ connected: detailSchema.optional().describe('default: none'),
180
+ },
181
+ }, withPlan(async (root, { q, types, limit, connected }) => {
182
+ const index = await loadPlan(root);
183
+ const hits = searchCards(index, q, types).slice(0, limit ?? 20);
184
+ return ok({
185
+ matches: hits.map((hit) => ({
186
+ card: summary(hit.card),
187
+ score: hit.score,
188
+ excerpt: hit.excerpt,
189
+ connected_cards: connectedCards(index, hit.card.handle, connected ?? 'none'),
190
+ })),
191
+ });
192
+ }));
193
+ server.registerTool('traverse', {
194
+ description: 'Breadth-first walk of the connection graph from one or more starting handles. Seed it with diff_plan output for impact analysis. detail: "full" includes frontmatter and body of every reached card.',
195
+ inputSchema: {
196
+ start: z.union([z.string(), z.array(z.string()).min(1)]),
197
+ depth: z.number().int().min(0).max(5).optional().describe('default: 2'),
198
+ types: z.array(typeSchema).optional(),
199
+ detail: z.enum(['summary', 'full']).optional().describe('default: summary'),
200
+ },
201
+ }, withPlan(async (root, { start, depth, types, detail }) => {
202
+ const index = await loadPlan(root);
203
+ const starts = (Array.isArray(start) ? start : [start]).map((s) => s.toUpperCase());
204
+ const missing = starts.filter((s) => !index.cards.has(s));
205
+ if (missing.length === starts.length) {
206
+ return fail('NOT_FOUND', `No cards found for: ${missing.join(', ')}`);
207
+ }
208
+ const typeFilter = types && types.length > 0 ? new Set(types) : null;
209
+ const maxDepth = depth ?? 2;
210
+ const distance = new Map();
211
+ let frontier = starts.filter((s) => index.cards.has(s));
212
+ for (const s of frontier)
213
+ distance.set(s, 0);
214
+ for (let d = 1; d <= maxDepth && frontier.length > 0; d++) {
215
+ const next = [];
216
+ for (const handle of frontier) {
217
+ for (const neighbor of index.connectedHandles.get(handle) ?? []) {
218
+ if (distance.has(neighbor))
219
+ continue;
220
+ const card = index.cards.get(neighbor);
221
+ if (!card)
222
+ continue;
223
+ if (typeFilter && !typeFilter.has(card.type))
224
+ continue;
225
+ distance.set(neighbor, d);
226
+ next.push(neighbor);
227
+ }
228
+ }
229
+ frontier = next;
230
+ }
231
+ const cards = [...distance.entries()]
232
+ .map(([handle, dist]) => {
233
+ const card = index.cards.get(handle);
234
+ return {
235
+ ...(detail === 'full' ? full(card) : summary(card)),
236
+ distance: dist,
237
+ };
238
+ })
239
+ .sort((a, b) => a.distance - b.distance || a.handle.localeCompare(b.handle));
240
+ const connections = index.connections.filter((c) => distance.has(c.a) && distance.has(c.b));
241
+ return ok({ cards, connections, not_found: missing });
242
+ }));
243
+ server.registerTool('create_card', {
244
+ description: 'Create a new card. The handle determines type and file location. fields = type-specific frontmatter (see the type schemas); body = markdown. The card IS created even when issues are returned — issues are the current lint state, not a failure. Set validate:false to skip linting during bulk import (then run check_integrity once at the end). For many cards at once, prefer create_cards.',
245
+ inputSchema: {
246
+ handle: z.string(),
247
+ name: z.string().optional(),
248
+ kind: z.string().optional(),
249
+ status: statusSchema.optional(),
250
+ connections: z.array(z.string()).optional(),
251
+ fields: z.record(z.string(), z.unknown()).optional(),
252
+ body: z.string().optional(),
253
+ validate: z
254
+ .boolean()
255
+ .optional()
256
+ .describe('default true; false skips lint and returns no issues'),
257
+ },
258
+ }, withPlan(async (root, args) => {
259
+ const handle = args.handle.toUpperCase();
260
+ if (!isHandleShaped(handle) || !typeForHandle(handle)) {
261
+ return fail('INVALID_HANDLE', `${args.handle} is not a valid handle (uppercase PREFIX-NAME with a canonical prefix)`);
262
+ }
263
+ const index = await loadPlan(root);
264
+ if (index.cards.has(handle)) {
265
+ return fail('CARD_EXISTS', `${handle} already exists`);
266
+ }
267
+ const badConnections = (args.connections ?? []).filter((c) => !isKnownHandle(c.toUpperCase()));
268
+ if (badConnections.length > 0) {
269
+ return fail('INVALID_CONNECTION', `Not valid handles: ${badConnections.join(', ')}`);
270
+ }
271
+ const reserved = reservedFieldKeys(args.fields);
272
+ if (reserved.length > 0) {
273
+ return fail('INVALID_FIELDS', `fields cannot contain reserved keys: ${reserved.join(', ')}`);
274
+ }
275
+ const fm = {};
276
+ if (args.name !== undefined)
277
+ fm.name = args.name;
278
+ if (args.kind !== undefined)
279
+ fm.kind = args.kind;
280
+ if (args.status !== undefined)
281
+ fm.status = args.status;
282
+ Object.assign(fm, args.fields ?? {});
283
+ if (args.connections && args.connections.length > 0) {
284
+ fm.connections = args.connections.map((c) => c.toUpperCase());
285
+ }
286
+ const relPath = await createCardFile(root, handle, fm, args.body ?? '');
287
+ if (args.validate === false) {
288
+ const written = await loadPlan(root);
289
+ const card = written.cards.get(handle);
290
+ return ok({ card: card ? full(card) : null, file: relPath });
291
+ }
292
+ const lint = await lintPlan(root);
293
+ const card = lint.index.cards.get(handle);
294
+ return ok({
295
+ card: card ? full(card) : null,
296
+ file: relPath,
297
+ issues: issuesForFile(lint.issues, relPath),
298
+ });
299
+ }));
300
+ server.registerTool('create_cards', {
301
+ description: 'Create many cards in one call (migrations, large scaffolds). Validates every handle up front, writes all valid cards, then lints ONCE — so connections between cards in the same batch resolve without transient "does not resolve" errors. Cards are created even if issues are returned. Returns { created, failed, cards, issues }.',
302
+ inputSchema: {
303
+ cards: z
304
+ .array(z.object({
305
+ handle: z.string(),
306
+ name: z.string().optional(),
307
+ kind: z.string().optional(),
308
+ status: statusSchema.optional(),
309
+ connections: z.array(z.string()).optional(),
310
+ fields: z.record(z.string(), z.unknown()).optional(),
311
+ body: z.string().optional(),
312
+ }))
313
+ .min(1)
314
+ .max(500),
315
+ },
316
+ }, withPlan(async (root, { cards }) => {
317
+ const index = await loadPlan(root);
318
+ const existing = new Set(index.cards.keys());
319
+ const created = [];
320
+ const failed = [];
321
+ for (const spec of cards) {
322
+ const handle = spec.handle.toUpperCase();
323
+ if (!isHandleShaped(handle) || !typeForHandle(handle)) {
324
+ failed.push({ handle: spec.handle, error: 'INVALID_HANDLE' });
325
+ continue;
326
+ }
327
+ if (existing.has(handle)) {
328
+ failed.push({ handle, error: 'CARD_EXISTS' });
329
+ continue;
330
+ }
331
+ const badConns = (spec.connections ?? []).filter((c) => !isKnownHandle(c.toUpperCase()));
332
+ if (badConns.length > 0) {
333
+ failed.push({ handle, error: `INVALID_CONNECTION: ${badConns.join(', ')}` });
334
+ continue;
335
+ }
336
+ const reserved = reservedFieldKeys(spec.fields);
337
+ if (reserved.length > 0) {
338
+ failed.push({
339
+ handle,
340
+ error: `INVALID_FIELDS: fields cannot contain reserved keys: ${reserved.join(', ')}`,
341
+ });
342
+ continue;
343
+ }
344
+ const fm = {};
345
+ if (spec.name !== undefined)
346
+ fm.name = spec.name;
347
+ if (spec.kind !== undefined)
348
+ fm.kind = spec.kind;
349
+ if (spec.status !== undefined)
350
+ fm.status = spec.status;
351
+ Object.assign(fm, spec.fields ?? {});
352
+ if (spec.connections && spec.connections.length > 0) {
353
+ fm.connections = spec.connections.map((c) => c.toUpperCase());
354
+ }
355
+ try {
356
+ await createCardFile(root, handle, fm, spec.body ?? '');
357
+ existing.add(handle);
358
+ created.push(handle);
359
+ }
360
+ catch (err) {
361
+ failed.push({ handle, error: err instanceof Error ? err.message : String(err) });
362
+ }
363
+ }
364
+ const lint = await lintPlan(root);
365
+ const createdFiles = new Set(created.map((h) => relPathForHandle(h)));
366
+ const createdSet = new Set(created);
367
+ return ok({
368
+ created: created.length,
369
+ failed,
370
+ cards: [...lint.index.cards.values()]
371
+ .filter((c) => createdSet.has(c.handle))
372
+ .map(summary),
373
+ issues: lint.issues.filter((i) => createdFiles.has(i.file)),
374
+ });
375
+ }));
376
+ server.registerTool('add_connections', {
377
+ description: 'Add many connections in one call. Each {from,to} is appended to the source card’s connections list (idempotent and undirected — already-connected pairs are skipped). Lints once at the end. Returns { added, failed, errors }.',
378
+ inputSchema: {
379
+ connections: z
380
+ .array(z.object({ from: z.string(), to: z.string() }))
381
+ .min(1)
382
+ .max(1000),
383
+ },
384
+ }, withPlan(async (root, { connections }) => {
385
+ const index = await loadPlan(root);
386
+ const failed = [];
387
+ const additions = new Map();
388
+ const queuedPairs = new Set();
389
+ for (const { from, to } of connections) {
390
+ const f = from.toUpperCase();
391
+ const t = to.toUpperCase();
392
+ const fromCard = index.cards.get(f);
393
+ const toCard = index.cards.get(t);
394
+ if (!fromCard || !toCard) {
395
+ failed.push({ from, to, error: `NOT_FOUND: ${!fromCard ? from : to}` });
396
+ continue;
397
+ }
398
+ if (f === t)
399
+ continue;
400
+ const pair = f < t ? `${f}|${t}` : `${t}|${f}`;
401
+ if (index.connectedHandles.get(f)?.has(t) || queuedPairs.has(pair))
402
+ continue;
403
+ queuedPairs.add(pair);
404
+ if (!additions.has(f))
405
+ additions.set(f, new Set());
406
+ additions.get(f).add(t);
407
+ }
408
+ let added = 0;
409
+ for (const [src, targets] of additions) {
410
+ const card = index.cards.get(src);
411
+ const existingList = Array.isArray(card.frontmatter.connections)
412
+ ? card.frontmatter.connections
413
+ : [];
414
+ const merged = [...new Set([...existingList, ...targets])];
415
+ const frontmatter = applyCardPatch(card.frontmatter, { connections: merged });
416
+ await updateCardFile(card.filePath, { frontmatter });
417
+ added += targets.size;
418
+ }
419
+ const lint = await lintPlan(root);
420
+ return ok({ added, failed, errors: lint.errors });
421
+ }));
422
+ server.registerTool('update_card', {
423
+ description: 'Update a card. patch.name/kind/status set or delete (null); patch.connections replaces the list; patch.fields deep-merges into type-specific frontmatter (arrays replace, null deletes). body replaces the whole body. Body-only updates never reformat frontmatter.',
424
+ inputSchema: {
425
+ handle: z.string(),
426
+ patch: z
427
+ .object({
428
+ name: z.string().nullable().optional(),
429
+ kind: z.string().nullable().optional(),
430
+ status: statusSchema.nullable().optional(),
431
+ connections: z.array(z.string()).nullable().optional(),
432
+ fields: z.record(z.string(), z.unknown()).optional(),
433
+ })
434
+ .optional(),
435
+ body: z.string().optional(),
436
+ },
437
+ }, withPlan(async (root, { handle, patch, body }) => {
438
+ const index = await loadPlan(root);
439
+ const card = index.cards.get(handle.toUpperCase());
440
+ if (!card)
441
+ return fail('NOT_FOUND', `No card with handle ${handle}`);
442
+ if (!patch && body === undefined) {
443
+ return fail('EMPTY_UPDATE', 'Provide patch and/or body');
444
+ }
445
+ const reserved = reservedFieldKeys(patch?.fields);
446
+ if (reserved.length > 0) {
447
+ return fail('INVALID_FIELDS', `fields cannot contain reserved keys: ${reserved.join(', ')}`);
448
+ }
449
+ const frontmatter = patch
450
+ ? applyCardPatch(card.frontmatter, patch)
451
+ : undefined;
452
+ await updateCardFile(card.filePath, { frontmatter, body });
453
+ const lint = await lintPlan(root);
454
+ const updated = lint.index.cards.get(card.handle);
455
+ return ok({
456
+ card: updated ? full(updated) : null,
457
+ issues: issuesForFile(lint.issues, card.relPath),
458
+ });
459
+ }));
460
+ server.registerTool('delete_card', {
461
+ description: 'Delete a card file. Returns the handles that referenced it (their references are now dangling) plus resulting lint issues.',
462
+ inputSchema: { handle: z.string() },
463
+ }, withPlan(async (root, { handle }) => {
464
+ const index = await loadPlan(root);
465
+ const card = index.cards.get(handle.toUpperCase());
466
+ if (!card)
467
+ return fail('NOT_FOUND', `No card with handle ${handle}`);
468
+ const referencedBy = [...(index.connectedHandles.get(card.handle) ?? [])].sort();
469
+ await rm(card.filePath);
470
+ const lint = await lintPlan(root);
471
+ // Exact-token match so deleting API-USER doesn't surface API-USERS' issues;
472
+ // handles only contain [A-Z0-9-], so those chars delimit a whole handle.
473
+ const handleToken = new RegExp(`(?<![A-Z0-9-])${card.handle}(?![A-Z0-9-])`);
474
+ return ok({
475
+ deleted: card.handle,
476
+ referenced_by: referencedBy,
477
+ issues: lint.issues.filter((i) => handleToken.test(i.message)),
478
+ });
479
+ }));
480
+ server.registerTool('add_connection', {
481
+ description: 'Connect two cards by appending `to` to `from`’s connections list. No-op if they are already connected through any source.',
482
+ inputSchema: { from: z.string(), to: z.string() },
483
+ }, withPlan(async (root, args) => {
484
+ const index = await loadPlan(root);
485
+ const from = index.cards.get(args.from.toUpperCase());
486
+ const to = index.cards.get(args.to.toUpperCase());
487
+ if (!from || !to) {
488
+ return fail('NOT_FOUND', `No card: ${!from ? args.from : args.to}`);
489
+ }
490
+ if (index.connectedHandles.get(from.handle)?.has(to.handle)) {
491
+ return ok({ already_connected: true, between: [from.handle, to.handle] });
492
+ }
493
+ const existing = Array.isArray(from.frontmatter.connections)
494
+ ? from.frontmatter.connections
495
+ : [];
496
+ const frontmatter = deepMerge(from.frontmatter, {
497
+ connections: [...existing, to.handle],
498
+ });
499
+ await updateCardFile(from.filePath, { frontmatter });
500
+ return ok({ connected: [from.handle, to.handle], declared_on: from.handle });
501
+ }));
502
+ server.registerTool('remove_connection', {
503
+ description: 'Remove a connection by deleting it from either card’s connections list. Reports if the cards remain connected through other sources (frontmatter fields, body links, mermaid) that must be edited manually.',
504
+ inputSchema: { a: z.string(), b: z.string() },
505
+ }, withPlan(async (root, args) => {
506
+ const index = await loadPlan(root);
507
+ const cardA = index.cards.get(args.a.toUpperCase());
508
+ const cardB = index.cards.get(args.b.toUpperCase());
509
+ if (!cardA || !cardB) {
510
+ return fail('NOT_FOUND', `No card: ${!cardA ? args.a : args.b}`);
511
+ }
512
+ const removedFrom = [];
513
+ for (const [card, other] of [
514
+ [cardA, cardB.handle],
515
+ [cardB, cardA.handle],
516
+ ]) {
517
+ const list = Array.isArray(card.frontmatter.connections)
518
+ ? card.frontmatter.connections
519
+ : [];
520
+ if (list.includes(other)) {
521
+ const next = list.filter((h) => h !== other);
522
+ const frontmatter = deepMerge(card.frontmatter, {
523
+ connections: next.length > 0 ? next : null,
524
+ });
525
+ await updateCardFile(card.filePath, { frontmatter });
526
+ removedFrom.push(card.handle);
527
+ }
528
+ }
529
+ const after = await loadPlan(root);
530
+ const stillConnected = after.connectedHandles.get(cardA.handle)?.has(cardB.handle) ?? false;
531
+ const remainingSources = [];
532
+ if (stillConnected) {
533
+ for (const [card, other] of [
534
+ [after.cards.get(cardA.handle), cardB.handle],
535
+ [after.cards.get(cardB.handle), cardA.handle],
536
+ ]) {
537
+ if (card.refs.frontmatter.includes(other))
538
+ remainingSources.push(`frontmatter field on ${card.handle}`);
539
+ if (card.refs.body.includes(other))
540
+ remainingSources.push(`[[link]] in body of ${card.handle}`);
541
+ if (card.refs.mermaid.includes(other))
542
+ remainingSources.push(`mermaid block in ${card.handle}`);
543
+ }
544
+ }
545
+ return ok({
546
+ removed_from: removedFrom,
547
+ still_connected: stillConnected,
548
+ remaining_sources: remainingSources,
549
+ });
550
+ }));
551
+ server.registerTool('check_integrity', {
552
+ description: 'Lint the whole plan: broken handles, dangling references, wrong folders, schema violations, plus orphans (cards with zero connections). Errors break the graph; warnings and orphans are quality signals.',
553
+ inputSchema: {},
554
+ }, withPlan(async (root) => {
555
+ const lint = await lintPlan(root);
556
+ const orphans = [...lint.index.cards.keys()]
557
+ .filter((h) => (lint.index.connectedHandles.get(h)?.size ?? 0) === 0)
558
+ .sort();
559
+ return ok({
560
+ cards: lint.index.cards.size,
561
+ connections: lint.index.connections.length,
562
+ errors: lint.errors,
563
+ warnings: lint.warnings,
564
+ orphans,
565
+ });
566
+ }));
567
+ server.registerTool('diff_plan', {
568
+ description: 'Per-card plan changes from git. base defaults to the sync marker (constellation/.sync.json) or HEAD; head defaults to the working tree. Returns added/modified/removed cards with changed frontmatter keys. Feed the handles to traverse for blast radius.',
569
+ inputSchema: {
570
+ base: z.string().optional(),
571
+ head: z.string().optional(),
572
+ },
573
+ }, withPlan(async (root, { base, head }) => {
574
+ return ok(await diffPlan(root, base, head));
575
+ }));
576
+ server.registerTool('plan_log', {
577
+ description: 'Git history of one card: the commits that touched its file.',
578
+ inputSchema: {
579
+ handle: z.string(),
580
+ limit: z.number().int().min(1).max(100).optional(),
581
+ },
582
+ }, withPlan(async (root, { handle, limit }) => {
583
+ const index = await loadPlan(root);
584
+ const card = index.cards.get(handle.toUpperCase());
585
+ const relPath = card?.relPath ?? relPathForHandle(handle.toUpperCase());
586
+ return ok({
587
+ handle: handle.toUpperCase(),
588
+ commits: await planLog(root, relPath, limit ?? 20),
589
+ });
590
+ }));
591
+ server.registerTool('set_sync_point', {
592
+ description: 'Record that code has been reconciled with the plan as of a commit (default HEAD). diff_plan uses this marker as its default base. Commit the plan first: if constellation/ has uncommitted changes, the marker points at a commit that lacks them and the response includes a warning.',
593
+ inputSchema: { sha: z.string().optional() },
594
+ }, withPlan(async (root, { sha }) => {
595
+ const point = await writeSyncPoint(root, sha);
596
+ const dirty = await planDirty(root);
597
+ return ok({
598
+ ...point,
599
+ warning: dirty
600
+ ? `constellation/ has uncommitted changes; marker ${point.synced_sha.slice(0, 8)} does not include them — commit the plan first, then set_sync_point.`
601
+ : undefined,
602
+ });
603
+ }));
604
+ server.registerTool('start_viewer', {
605
+ description: 'Start a local web server that renders this plan as a browsable, editable site, and return its URL (e.g. http://localhost:4747/). Idempotent: if the viewer is already running, returns the existing URL. The server runs until stop_viewer or until this MCP process exits. ALWAYS reply to the user with the returned url as a clickable link and state the port it bound to.',
606
+ inputSchema: {
607
+ port: z
608
+ .number()
609
+ .int()
610
+ .min(0)
611
+ .max(65535)
612
+ .optional()
613
+ .describe('default 4747; 0 picks any free port'),
614
+ readonly: z
615
+ .boolean()
616
+ .optional()
617
+ .describe('disable editing from the browser (default false)'),
618
+ open: z
619
+ .boolean()
620
+ .optional()
621
+ .describe('open the URL in the local default browser (default false)'),
622
+ },
623
+ }, withPlan(async (root, { port, readonly, open }) => {
624
+ if (viewer) {
625
+ return ok({ already_running: true, url: viewer.url, plan_root: viewer.planRoot });
626
+ }
627
+ const { startServer } = await import('../serve/server.js');
628
+ const requested = port ?? 4747;
629
+ // With the default port, walk forward until one is free so concurrent viewers
630
+ // (each project runs its own MCP process) land on distinct, predictable URLs.
631
+ // An explicitly requested port is honored exactly — a collision is an error.
632
+ const span = port === undefined ? 20 : 1;
633
+ let running = null;
634
+ let lastErr = null;
635
+ for (let p = requested; p < requested + span; p++) {
636
+ try {
637
+ running = await startServer({ planRoot: root, port: p, readonly: readonly ?? false });
638
+ break;
639
+ }
640
+ catch (err) {
641
+ lastErr = err;
642
+ if (err?.code === 'EADDRINUSE')
643
+ continue;
644
+ return fail('VIEWER_FAILED', err instanceof Error ? err.message : String(err));
645
+ }
646
+ }
647
+ if (!running) {
648
+ return fail('PORT_IN_USE', port === undefined
649
+ ? `No free port found in ${requested}–${requested + span - 1}.`
650
+ : `Port ${requested} is already in use. Pass a different port, or 0 for any free port.`);
651
+ }
652
+ const url = `http://localhost:${running.port}/`;
653
+ viewer = { server: running, planRoot: root, url };
654
+ if (open) {
655
+ const { spawn } = await import('node:child_process');
656
+ const cmd = process.platform === 'darwin'
657
+ ? 'open'
658
+ : process.platform === 'win32'
659
+ ? 'start'
660
+ : 'xdg-open';
661
+ spawn(cmd, [url], { stdio: 'ignore', detached: true }).unref();
662
+ }
663
+ return ok({ url, port: running.port, plan_root: root, editable: !(readonly ?? false) });
664
+ }));
665
+ server.registerTool('stop_viewer', {
666
+ description: 'Stop the web viewer started by start_viewer. No-op if it is not running.',
667
+ inputSchema: {},
668
+ }, async () => {
669
+ if (!viewer)
670
+ return ok({ running: false });
671
+ const { url } = viewer;
672
+ await viewer.server.close();
673
+ viewer = null;
674
+ return ok({ stopped: true, was: url });
675
+ });
676
+ return server;
677
+ }
678
+ export async function startMcpServer() {
679
+ const server = buildServer();
680
+ await server.connect(new StdioServerTransport());
681
+ // stdout belongs to the protocol; greet on stderr.
682
+ console.error('constellation mcp: ready (stdio)');
683
+ }
684
+ //# sourceMappingURL=server.js.map