@magic-spells/constellation 0.1.1 → 0.2.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 (110) hide show
  1. package/README.md +33 -3
  2. package/dist/cli/index.js +49 -7
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/{mcp → core}/git.d.ts +19 -0
  5. package/dist/{mcp → core}/git.js +59 -2
  6. package/dist/core/git.js.map +1 -0
  7. package/dist/core/index.d.ts +1 -0
  8. package/dist/core/index.js +1 -0
  9. package/dist/core/index.js.map +1 -1
  10. package/dist/core/repos.d.ts +40 -0
  11. package/dist/core/repos.js +93 -0
  12. package/dist/core/repos.js.map +1 -0
  13. package/dist/core/sync.d.ts +28 -0
  14. package/dist/core/sync.js +85 -0
  15. package/dist/core/sync.js.map +1 -0
  16. package/dist/core/types.d.ts +12 -0
  17. package/dist/mcp/server.js +313 -28
  18. package/dist/mcp/server.js.map +1 -1
  19. package/dist/serve/server.js +28 -4
  20. package/dist/serve/server.js.map +1 -1
  21. package/docs/001-file-format.md +25 -0
  22. package/docs/002-mcp.md +30 -0
  23. package/package.json +1 -1
  24. package/schemas/plan.json +27 -0
  25. package/skill/SKILL.md +65 -0
  26. package/skill/methodology.md +17 -0
  27. package/skill/types/plan.md +19 -0
  28. package/viewer/dist/assets/{arc-z-d28NWT.js → arc-CMMRgIq9.js} +1 -1
  29. package/viewer/dist/assets/architecture-7EHR7CIX-Cbb-syEI.js +1 -0
  30. package/viewer/dist/assets/{architectureDiagram-3BPJPVTR-CjoeulX0.js → architectureDiagram-3BPJPVTR-CB5p_qlN.js} +1 -1
  31. package/viewer/dist/assets/{blockDiagram-GPEHLZMM-BGlWRy-x.js → blockDiagram-GPEHLZMM-DpnMj_e6.js} +1 -1
  32. package/viewer/dist/assets/{c4Diagram-AAUBKEIU-B09gKF2I.js → c4Diagram-AAUBKEIU-f91ngVN8.js} +1 -1
  33. package/viewer/dist/assets/channel-C2bkEQHQ.js +1 -0
  34. package/viewer/dist/assets/{chunk-2J33WTMH-Cfc4xM5m.js → chunk-2J33WTMH-y7Pz5tMO.js} +1 -1
  35. package/viewer/dist/assets/{chunk-3OPIFGDE-CfJIsbrs.js → chunk-3OPIFGDE-Ir_ghIUQ.js} +1 -1
  36. package/viewer/dist/assets/{chunk-4BX2VUAB-B1J5W5bz.js → chunk-4BX2VUAB-BTPyM1ux.js} +1 -1
  37. package/viewer/dist/assets/{chunk-55IACEB6-Bjg14U7h.js → chunk-55IACEB6-CtNd8e4Y.js} +1 -1
  38. package/viewer/dist/assets/{chunk-5ZQYHXKU-BCvNFwZ5.js → chunk-5ZQYHXKU-Dd3yz721.js} +1 -1
  39. package/viewer/dist/assets/{chunk-727SXJPM-XfiLvG1c.js → chunk-727SXJPM-CAM8mGbv.js} +1 -1
  40. package/viewer/dist/assets/{chunk-AQP2D5EJ-BS-Yzcyy.js → chunk-AQP2D5EJ-C3nW9Uve.js} +1 -1
  41. package/viewer/dist/assets/{chunk-BSJP7CBP-DdbVG_ma.js → chunk-BSJP7CBP-r6MRPvAH.js} +1 -1
  42. package/viewer/dist/assets/{chunk-CSCIHK7Q-C_v3KOxf.js → chunk-CSCIHK7Q-Cn-ZTxsS.js} +1 -1
  43. package/viewer/dist/assets/{chunk-FMBD7UC4-Djdrd8lQ.js → chunk-FMBD7UC4-5axXeZJa.js} +1 -1
  44. package/viewer/dist/assets/{chunk-KSCS5N6A-CLg-rwxg.js → chunk-KSCS5N6A-BwmNx0l0.js} +1 -1
  45. package/viewer/dist/assets/{chunk-L5ZTLDWV-DCOC9Gbq.js → chunk-L5ZTLDWV-C2YNkUjB.js} +1 -1
  46. package/viewer/dist/assets/{chunk-LZXEDZCA-D_cw-C3l.js → chunk-LZXEDZCA-CznuDgQD.js} +2 -2
  47. package/viewer/dist/assets/{chunk-ND2GUHAM-7krqLOpO.js → chunk-ND2GUHAM-CokDnghi.js} +1 -1
  48. package/viewer/dist/assets/{chunk-NZK2D7GU-BHqkidnq.js → chunk-NZK2D7GU-aaU0v4QE.js} +1 -1
  49. package/viewer/dist/assets/{chunk-O5CBEL6O-D953Jfjo.js → chunk-O5CBEL6O-ByfKhkZf.js} +1 -1
  50. package/viewer/dist/assets/chunk-QZHKN3VN-DJRjGeIo.js +1 -0
  51. package/viewer/dist/assets/chunk-WU5MYG2G-C63BVvr_.js +1 -0
  52. package/viewer/dist/assets/{chunk-XPW4576I-tr-y3uUk.js → chunk-XPW4576I-Amo3awtq.js} +1 -1
  53. package/viewer/dist/assets/classDiagram-4FO5ZUOK-z_bO1V-j.js +1 -0
  54. package/viewer/dist/assets/classDiagram-v2-Q7XG4LA2-z_bO1V-j.js +1 -0
  55. package/viewer/dist/assets/{cose-bilkent-S5V4N54A-RHqSF1Fo.js → cose-bilkent-S5V4N54A-CO8yESCW.js} +1 -1
  56. package/viewer/dist/assets/{dagre-BM42HDAG-CaRlC_3n.js → dagre-BM42HDAG-BnmNVNI2.js} +1 -1
  57. package/viewer/dist/assets/{diagram-2AECGRRQ-zBc7NQxu.js → diagram-2AECGRRQ-lcGXcbei.js} +1 -1
  58. package/viewer/dist/assets/{diagram-5GNKFQAL-CdFUltct.js → diagram-5GNKFQAL-Cj3-lPUt.js} +1 -1
  59. package/viewer/dist/assets/{diagram-KO2AKTUF-DKBMUmIp.js → diagram-KO2AKTUF-CtXdRI4M.js} +1 -1
  60. package/viewer/dist/assets/{diagram-LMA3HP47-aj0REjiQ.js → diagram-LMA3HP47-CIyLAzX7.js} +1 -1
  61. package/viewer/dist/assets/{diagram-OG6HWLK6-Bqtmu-Nc.js → diagram-OG6HWLK6-BwF4bQCv.js} +1 -1
  62. package/viewer/dist/assets/{dist-CkpbBMm1.js → dist-DzXuQd4N.js} +1 -1
  63. package/viewer/dist/assets/{erDiagram-TEJ5UH35-_EpsTRRO.js → erDiagram-TEJ5UH35-DjwFw9yq.js} +1 -1
  64. package/viewer/dist/assets/eventmodeling-FCH6USID-BlKFmSzA.js +1 -0
  65. package/viewer/dist/assets/{flowDiagram-I6XJVG4X-ChGqvvgX.js → flowDiagram-I6XJVG4X-CzXbf7vQ.js} +1 -1
  66. package/viewer/dist/assets/{ganttDiagram-6RSMTGT7-BjcMfREp.js → ganttDiagram-6RSMTGT7-BYcIhfmm.js} +1 -1
  67. package/viewer/dist/assets/{gitGraph-WXDBUCRP-DfwN1rQD.js → gitGraph-WXDBUCRP-H8XeJ9QB.js} +1 -1
  68. package/viewer/dist/assets/{gitGraphDiagram-PVQCEYII-ClI7X9vN.js → gitGraphDiagram-PVQCEYII-DtAPXKFK.js} +1 -1
  69. package/viewer/dist/assets/index-Bqs39Itl.css +2 -0
  70. package/viewer/dist/assets/index-DLPCG_Or.js +98 -0
  71. package/viewer/dist/assets/{info-J43DQDTF-CofQIoJ2.js → info-J43DQDTF-BQScorZ0.js} +1 -1
  72. package/viewer/dist/assets/{infoDiagram-5YYISTIA-D2BcKzo_.js → infoDiagram-5YYISTIA-Udl1omvH.js} +1 -1
  73. package/viewer/dist/assets/{ishikawaDiagram-YF4QCWOH-CXw1XdbZ.js → ishikawaDiagram-YF4QCWOH-VnfzT_hk.js} +1 -1
  74. package/viewer/dist/assets/{journeyDiagram-JHISSGLW-3Jhqf-KB.js → journeyDiagram-JHISSGLW-Btn4EVoD.js} +1 -1
  75. package/viewer/dist/assets/{kanban-definition-UN3LZRKU-ClSj7gDE.js → kanban-definition-UN3LZRKU-flOzWhPe.js} +1 -1
  76. package/viewer/dist/assets/{line-DeameHHf.js → line-DJJ9BLuY.js} +1 -1
  77. package/viewer/dist/assets/{linear-BDDnY--V.js → linear-C2SUy5ns.js} +1 -1
  78. package/viewer/dist/assets/{mermaid-parser.core-Cah87FZ1.js → mermaid-parser.core-xhSeYiZo.js} +2 -2
  79. package/viewer/dist/assets/{mermaid.core-lOcajBDU.js → mermaid.core-DmeSIitY.js} +3 -3
  80. package/viewer/dist/assets/{mindmap-definition-RKZ34NQL-CebNj7Vy.js → mindmap-definition-RKZ34NQL-CCJcMKx1.js} +1 -1
  81. package/viewer/dist/assets/{packet-YPE3B663-BlKTjmdv.js → packet-YPE3B663-DS8RscAk.js} +1 -1
  82. package/viewer/dist/assets/{pie-LRSECV5Y-CHjlSKV3.js → pie-LRSECV5Y-I2ZQhrNJ.js} +1 -1
  83. package/viewer/dist/assets/{pieDiagram-4H26LBE5-zWcP8Zx3.js → pieDiagram-4H26LBE5-BS5HMs4j.js} +1 -1
  84. package/viewer/dist/assets/{quadrantDiagram-W4KKPZXB-BSBO8usz.js → quadrantDiagram-W4KKPZXB-W4H8cw2_.js} +1 -1
  85. package/viewer/dist/assets/{radar-GUYGQ44K-BDmaTdGw.js → radar-GUYGQ44K-kf6QvSne.js} +1 -1
  86. package/viewer/dist/assets/{requirementDiagram-4Y6WPE33-C1wh6GaA.js → requirementDiagram-4Y6WPE33-DSbuDQGQ.js} +1 -1
  87. package/viewer/dist/assets/{sankeyDiagram-5OEKKPKP-L3mqPVcH.js → sankeyDiagram-5OEKKPKP-CH8Qr2vL.js} +1 -1
  88. package/viewer/dist/assets/{sequenceDiagram-3UESZ5HK-7vbfvJiz.js → sequenceDiagram-3UESZ5HK-EugVcdad.js} +1 -1
  89. package/viewer/dist/assets/{src-CslsSLVV.js → src-CM_9kdrU.js} +1 -1
  90. package/viewer/dist/assets/{stateDiagram-AJRCARHV-CfEgJmUy.js → stateDiagram-AJRCARHV-DiFl94Qy.js} +1 -1
  91. package/viewer/dist/assets/stateDiagram-v2-BHNVJYJU-BlJV8F13.js +1 -0
  92. package/viewer/dist/assets/{timeline-definition-PNZ67QCA-DMhMwUyE.js → timeline-definition-PNZ67QCA-B0KW9GUN.js} +1 -1
  93. package/viewer/dist/assets/{treeView-BLDUP644-aGCNEij1.js → treeView-BLDUP644-CAEY4mxq.js} +1 -1
  94. package/viewer/dist/assets/{treemap-LRROVOQU-DTgPn5R6.js → treemap-LRROVOQU-VVaEaTlU.js} +1 -1
  95. package/viewer/dist/assets/{vennDiagram-CIIHVFJN-BZygSp3k.js → vennDiagram-CIIHVFJN-BSLGNfJd.js} +1 -1
  96. package/viewer/dist/assets/{wardley-L42UT6IY-BSxi2k9r.js → wardley-L42UT6IY-C-5cXbAU.js} +1 -1
  97. package/viewer/dist/assets/{wardleyDiagram-YWT4CUSO-ZBjbz3Mj.js → wardleyDiagram-YWT4CUSO-COk4TVnQ.js} +1 -1
  98. package/viewer/dist/assets/{xychartDiagram-2RQKCTM6-VFJI2tv3.js → xychartDiagram-2RQKCTM6-CClGJLJp.js} +1 -1
  99. package/viewer/dist/index.html +2 -2
  100. package/dist/mcp/git.js.map +0 -1
  101. package/viewer/dist/assets/architecture-7EHR7CIX-QBvJmJV9.js +0 -1
  102. package/viewer/dist/assets/channel-COWeTobO.js +0 -1
  103. package/viewer/dist/assets/chunk-QZHKN3VN-D7xg9F2p.js +0 -1
  104. package/viewer/dist/assets/chunk-WU5MYG2G-CWG30QUB.js +0 -1
  105. package/viewer/dist/assets/classDiagram-4FO5ZUOK-BsU5cU-N.js +0 -1
  106. package/viewer/dist/assets/classDiagram-v2-Q7XG4LA2-BsU5cU-N.js +0 -1
  107. package/viewer/dist/assets/eventmodeling-FCH6USID-BEUuQhV3.js +0 -1
  108. package/viewer/dist/assets/index-BRID9hB2.js +0 -98
  109. package/viewer/dist/assets/index-CI5JmpBT.css +0 -2
  110. package/viewer/dist/assets/stateDiagram-v2-BHNVJYJU-Dj6oCjNw.js +0 -1
@@ -4,14 +4,15 @@ import { fileURLToPath } from 'node:url';
4
4
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
6
  import { z } from 'zod';
7
- import { isHandleShaped, isKnownHandle, typeForHandle } from '../core/handles.js';
7
+ import { isHandleShaped, isKnownHandle, TYPE_FOLDERS, typeForHandle, } from '../core/handles.js';
8
8
  import { loadPlan } from '../core/indexer.js';
9
9
  import { lintPlan } from '../core/lint.js';
10
10
  import { resolvePlanDir } from '../core/resolve.js';
11
11
  import { TYPE_NAMES } from '../core/types.js';
12
- import { diffPlan, planDirty, planLog, writeSyncPoint } from './git.js';
12
+ import { diffPlan, planDirty, planLog, writeSyncPoint } from '../core/git.js';
13
13
  import { searchCards } from './search.js';
14
14
  import { applyCardPatch, createCardFile, deepMerge, relPathForHandle, reservedFieldKeys, updateCardFile, } from '../core/writer.js';
15
+ import { connectedRepoToFm, listConnectedRepos, readConnectedRepos, removeConnectedRepoEntry, resolveConnectedRepo, upsertConnectedRepo, } from '../core/repos.js';
15
16
  const INSTRUCTIONS = `# Constellation MCP
16
17
 
17
18
  The project's architecture plan lives as markdown files in a constellation/ folder.
@@ -27,6 +28,11 @@ Writes are validated: every write tool lints and returns issues for the file it
27
28
  update_card patch.fields deep-merges (arrays replace, null deletes); body replaces.
28
29
  Body-only updates never reformat frontmatter.
29
30
 
31
+ describe_type is the type reference, served by this server: call it with no args for the
32
+ catalog of all 17 card types, or with a type (e.g. describe_type PAGE) for that type's
33
+ frontmatter schema + a golden example. Consult it before authoring a type you haven't used
34
+ this session — you don't need the authoring skill loaded to get the fields right.
35
+
30
36
  Change tracking is git: diff_plan reports per-card changes since the sync marker (or HEAD).
31
37
  Never stamp dirty flags into cards.
32
38
 
@@ -47,6 +53,22 @@ macro view. ALWAYS verify the sub-agents' work yourself after they have all fini
47
53
  each change against its cards and run the build/tests; never trust their reports alone — then
48
54
  set the sync point once.
49
55
 
56
+ Plan-first when changing code: when asked to build a feature or change behavior in an area
57
+ this plan covers, do NOT edit code first. The plan you make leads with Constellation — read
58
+ the affected neighborhood (get_card / traverse / search, connected: "full"), then add or
59
+ update the cards so they describe the desired END STATE (work that isn't built yet is
60
+ status: planned), wiring every connection between the affected cards. Show that set of card
61
+ changes as the proposal; on approval, bring the CODE up to match via the sync loop above.
62
+ FINISH by reconciling — re-read the touched cards against the code, run check_integrity so
63
+ no affected card is left an orphan and every connection is set, bump status (planned →
64
+ building → built → verified), commit, and set_sync_point. In plan mode the write tools are
65
+ unavailable by design (the read tools — get_card, list_cards, search, traverse,
66
+ describe_type, check_integrity, diff_plan, plan_log — are marked read-only and stay
67
+ available), so spend plan mode READING: pull in as much of the relevant plan as you can
68
+ (traverse from the entry points, connected: "full") to build a strong model of the project
69
+ fast, fold the intended card edits into the plan you present, and write them to
70
+ Constellation first, before any code, once the user approves.
71
+
50
72
  For migrations or large scaffolds, use create_cards and add_connections (batched, one
51
73
  lint pass) instead of many single calls — connections between cards in the same batch
52
74
  resolve without transient "does not resolve" errors. A card is created even when issues
@@ -74,7 +96,7 @@ and confirm it with the user (it's plan.md's name: and the viewer's title; chang
74
96
  anytime via update_card on PLAN-PROJECT); then follow the DATA (DB → DATATYPE →
75
97
  API → PAGE, with FLOW/STATE for paths and lifecycles) and the USER (ROLE + auth FLOW
76
98
  first, then PAGE/COMPONENT and key journeys) and the EDGES (EXTERNAL/JOB/EVENT); then zoom
77
- into central or complex areas. Read before you ask — ask the user only for intent,
99
+ into central or complex areas. For a non-trivial plan, after the macro pass act as the ORCHESTRATOR here too: partition the build into independent neighborhoods (the DATA, the USER, the EDGES) and fan out a sub-agent per neighborhood in parallel — but assign each card to exactly one agent (two agents calling update_card on the same card race, later write wins) and have them return card specs you write via batched create_cards/add_connections, then verify each agent's work and lint once; use one agent for a small plan. Read before you ask — ask the user only for intent,
78
100
  priorities, and history the code can't reveal. Then find gaps IN THE PLAN: step back and
79
101
  hunt blind spots the user may not have considered — missing unhappy paths and lifecycle
80
102
  states, auth/permission gaps, and cross-cutting concerns plans forget (security, privacy,
@@ -84,6 +106,21 @@ short, prioritized list of recommendations and ask about the judgment calls. For
84
106
  method use the bootstrap_plan or audit_plan prompt. Status is planned → building → built →
85
107
  verified; verify only against real code.
86
108
 
109
+ Connected repos (multi-repo work): a project can declare sibling repos in PLAN-PROJECT
110
+ connected_repos (name + on-disk path + description) — list_connected_repos shows them with
111
+ reachability, add_connected_repo links one (reciprocate:true also writes the reverse link
112
+ into the other repo, with the user's OK). These are REPO-level links only: cards never connect
113
+ across repos, and each repo's plan stays self-contained and lints alone. To work on a connected
114
+ repo, pass repo: "<name>" to any read or write tool (get_card, search, traverse, update_card,
115
+ …) and it reads/writes THAT repo's plan; omit repo for the current one. To answer a question
116
+ about a connected repo, first read its plan with repo:; if you need the real code (or its plan
117
+ can't answer), spawn a sub-agent scoped to that repo's path to investigate and report back —
118
+ and if its plan had the gap, fill it. For one change spanning repos: examine each repo's area,
119
+ write the per-repo card updates with repo: set on EVERY write (never omit it cross-repo, or the
120
+ write lands in the wrong place), then fan out a per-repo implementer sub-agent (each runs in
121
+ plain single-repo mode, blind to the others) and reconcile + set_sync_point per repo. Inside a
122
+ single repo, repo is unnecessary and everything behaves exactly as before.
123
+
87
124
  To let the user browse the plan visually, start_viewer launches a local web server that
88
125
  renders the plan as an editable site and returns its URL (it scans forward from port 4747
89
126
  for a free port, so always read the actual port from the response). ALWAYS post that URL
@@ -92,7 +129,38 @@ The viewer runs until stop_viewer or until this server process exits.`;
92
129
  // The full plan-from-code playbook lives in one file (skill/methodology.md), shared by the
93
130
  // skill and the MCP prompts so the two can't drift. Resolve it relative to this module:
94
131
  // from dist/mcp/server.js (or src/mcp/server.ts) '../..' is the package/repo root.
95
- const METHODOLOGY_PATH = path.join(fileURLToPath(new URL('../..', import.meta.url)), 'skill', 'methodology.md');
132
+ // Package root: from dist/mcp/server.js (or src/mcp/server.ts) '../..' is the
133
+ // package/repo root. The skill folder, type docs, and JSON schemas all ship here.
134
+ const PKG_ROOT = fileURLToPath(new URL('../..', import.meta.url));
135
+ const METHODOLOGY_PATH = path.join(PKG_ROOT, 'skill', 'methodology.md');
136
+ const SCHEMAS_DIR = path.join(PKG_ROOT, 'schemas');
137
+ const TYPE_DOCS_DIR = path.join(PKG_ROOT, 'skill', 'types');
138
+ /** Parsed JSON Schema for a type's frontmatter, or null if it can't be read. */
139
+ async function readTypeSchema(folder) {
140
+ try {
141
+ return JSON.parse(await readFile(path.join(SCHEMAS_DIR, `${folder}.json`), 'utf8'));
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
147
+ /** The authoring reference (field table + golden example) for a type, or null. */
148
+ async function readTypeDoc(folder) {
149
+ try {
150
+ return await readFile(path.join(TYPE_DOCS_DIR, `${folder}.md`), 'utf8');
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
156
+ /** First sentence of a schema's top-level description — its one-line purpose. */
157
+ function schemaPurpose(schema) {
158
+ const desc = schema && typeof schema === 'object' && 'description' in schema
159
+ ? String(schema.description ?? '')
160
+ : '';
161
+ const match = desc.match(/^.*?\.(?:\s|$)/);
162
+ return (match ? match[0] : desc).trim();
163
+ }
96
164
  let methodologyCache = null;
97
165
  async function methodologyText() {
98
166
  if (methodologyCache === null) {
@@ -120,6 +188,25 @@ function fail(code, message) {
120
188
  isError: true,
121
189
  };
122
190
  }
191
+ async function openUrl(url) {
192
+ try {
193
+ const { spawn } = await import('node:child_process');
194
+ const child = process.platform === 'darwin'
195
+ ? spawn('open', [url], { stdio: 'ignore', detached: true })
196
+ : process.platform === 'win32'
197
+ ? spawn('cmd', ['/c', 'start', '', url], {
198
+ stdio: 'ignore',
199
+ detached: true,
200
+ windowsHide: true,
201
+ })
202
+ : spawn('xdg-open', [url], { stdio: 'ignore', detached: true });
203
+ child.on('error', () => { });
204
+ child.unref();
205
+ }
206
+ catch {
207
+ // Opening the browser is best-effort; callers still receive the URL.
208
+ }
209
+ }
123
210
  function summary(card) {
124
211
  return {
125
212
  handle: card.handle,
@@ -147,8 +234,12 @@ function issuesForFile(issues, relPath) {
147
234
  const detailSchema = z.enum(['none', 'summary', 'full']);
148
235
  const typeSchema = z.enum(TYPE_NAMES);
149
236
  const statusSchema = z.enum(['planned', 'building', 'built', 'verified']);
237
+ const repoSchema = z
238
+ .string()
239
+ .optional()
240
+ .describe('Target a connected repo by its connected_repos name (or a path). Omit to use the current repo.');
150
241
  export function buildServer(options = {}) {
151
- const server = new McpServer({ name: 'constellation', version: '0.1.1' }, { instructions: INSTRUCTIONS });
242
+ const server = new McpServer({ name: 'constellation', version: '0.2.0' }, { instructions: INSTRUCTIONS });
152
243
  server.registerPrompt('bootstrap_plan', {
153
244
  title: 'Bootstrap a plan from the codebase',
154
245
  description: 'Analyze this repository macro→micro and build (or extend) the Constellation plan: follow the data and the user/auth journeys, capture flows, diagrams, and state machines, ask the user what the code cannot answer, and flag gaps.',
@@ -182,18 +273,48 @@ export function buildServer(options = {}) {
182
273
  }
183
274
  // A single web viewer owned by this server process; null until start_viewer runs.
184
275
  let viewer = null;
185
- /** Wrap a handler with plan resolution and error reporting. */
276
+ const noPlanFound = () => fail('NO_PLAN_FOUND', `No constellation/ folder found by walking up from ${process.cwd()}. This MCP ` +
277
+ `server uses its own working directory — if that isn't your repo, set "cwd" to ` +
278
+ 'the repo root in your MCP client config. Otherwise call init_plan (optionally ' +
279
+ 'with { path } pointing at the repo root), or run `constellation init`.');
280
+ /**
281
+ * Resolve which plan a call targets. With no `repo`, the home plan (walk up
282
+ * from cwd); with `repo`, a connected repo selected by its connected_repos
283
+ * name or by a path. Returns the resolved root or a ready-to-return error.
284
+ */
285
+ async function resolveTarget(repo) {
286
+ const home = await planRoot();
287
+ if (!repo) {
288
+ return home ? { root: home } : { error: noPlanFound() };
289
+ }
290
+ if (home) {
291
+ const resolved = await resolveConnectedRepo(home, repo);
292
+ if (resolved)
293
+ return { root: resolved.root };
294
+ const names = (await readConnectedRepos(home)).map((r) => r.name);
295
+ return {
296
+ error: fail('UNKNOWN_REPO', `Connected repo "${repo}" not found. Pass a connected_repos name ` +
297
+ `(${names.length ? names.join(', ') : 'none declared — add one with add_connected_repo'}) ` +
298
+ 'or a path to a repo that has a constellation/ plan.'),
299
+ };
300
+ }
301
+ // No home plan: a name can't be looked up, but a path can still resolve.
302
+ const byPath = await resolvePlanDir(path.isAbsolute(repo) ? repo : path.resolve(process.cwd(), repo));
303
+ if (byPath)
304
+ return { root: byPath };
305
+ return {
306
+ error: fail('UNKNOWN_REPO', `No plan found for repo "${repo}". With no plan in the current directory, pass a ` +
307
+ 'path to a repo that has a constellation/ plan.'),
308
+ };
309
+ }
310
+ /** Wrap a handler with plan resolution and error reporting; `repo` selects a connected repo. */
186
311
  function withPlan(handler) {
187
312
  return async (args) => {
188
- const root = await planRoot();
189
- if (!root) {
190
- return fail('NO_PLAN_FOUND', `No constellation/ folder found by walking up from ${process.cwd()}. This MCP ` +
191
- `server uses its own working directory — if that isn't your repo, set "cwd" to ` +
192
- 'the repo root in your MCP client config. Otherwise call init_plan (optionally ' +
193
- 'with { path } pointing at the repo root), or run `constellation init`.');
194
- }
313
+ const target = await resolveTarget(args?.repo);
314
+ if ('error' in target)
315
+ return target.error;
195
316
  try {
196
- return await handler(root, args);
317
+ return await handler(target.root, args);
197
318
  }
198
319
  catch (err) {
199
320
  return fail('INTERNAL', err instanceof Error ? err.message : String(err));
@@ -227,10 +348,12 @@ export function buildServer(options = {}) {
227
348
  }
228
349
  });
229
350
  server.registerTool('get_card', {
351
+ annotations: { readOnlyHint: true },
230
352
  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.',
231
353
  inputSchema: {
232
354
  handle: z.string(),
233
355
  connected: detailSchema.optional().describe('default: summary'),
356
+ repo: repoSchema,
234
357
  },
235
358
  }, withPlan(async (root, { handle, connected }) => {
236
359
  const index = await loadPlan(root);
@@ -243,6 +366,7 @@ export function buildServer(options = {}) {
243
366
  });
244
367
  }));
245
368
  server.registerTool('list_cards', {
369
+ annotations: { readOnlyHint: true },
246
370
  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).',
247
371
  inputSchema: {
248
372
  types: z.array(typeSchema).optional(),
@@ -253,6 +377,7 @@ export function buildServer(options = {}) {
253
377
  .optional()
254
378
  .describe('false = orphans only; true = connected only'),
255
379
  limit: z.number().int().min(1).max(500).optional(),
380
+ repo: repoSchema,
256
381
  },
257
382
  }, withPlan(async (root, { types, kind, status, connected, limit }) => {
258
383
  const index = await loadPlan(root);
@@ -270,12 +395,14 @@ export function buildServer(options = {}) {
270
395
  });
271
396
  }));
272
397
  server.registerTool('search', {
398
+ annotations: { readOnlyHint: true },
273
399
  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.',
274
400
  inputSchema: {
275
401
  q: z.string(),
276
402
  types: z.array(typeSchema).optional(),
277
403
  limit: z.number().int().min(1).max(100).optional(),
278
404
  connected: detailSchema.optional().describe('default: none'),
405
+ repo: repoSchema,
279
406
  },
280
407
  }, withPlan(async (root, { q, types, limit, connected }) => {
281
408
  const index = await loadPlan(root);
@@ -290,12 +417,14 @@ export function buildServer(options = {}) {
290
417
  });
291
418
  }));
292
419
  server.registerTool('traverse', {
420
+ annotations: { readOnlyHint: true },
293
421
  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.',
294
422
  inputSchema: {
295
423
  start: z.union([z.string(), z.array(z.string()).min(1)]),
296
424
  depth: z.number().int().min(0).max(5).optional().describe('default: 2'),
297
425
  types: z.array(typeSchema).optional(),
298
426
  detail: z.enum(['summary', 'full']).optional().describe('default: summary'),
427
+ repo: repoSchema,
299
428
  },
300
429
  }, withPlan(async (root, { start, depth, types, detail }) => {
301
430
  const index = await loadPlan(root);
@@ -339,8 +468,41 @@ export function buildServer(options = {}) {
339
468
  const connections = index.connections.filter((c) => distance.has(c.a) && distance.has(c.b));
340
469
  return ok({ cards, connections, not_found: missing });
341
470
  }));
471
+ server.registerTool('describe_type', {
472
+ annotations: { readOnlyHint: true },
473
+ description: 'The card-type reference, served straight from this package. Call with no args for the catalog — all 17 types with their prefix, folder, and one-line purpose. Call with a type for everything needed to author one: the frontmatter JSON Schema (fields, which are required, descriptions) plus the golden example and authoring guidance. Use it before writing a card of a type you have not authored this session — it is the contract create_card/create_cards/update_card validate against (W002/W003), so you do not need the authoring skill loaded to get the fields right.',
474
+ inputSchema: {
475
+ type: typeSchema.optional().describe('omit for the full catalog'),
476
+ },
477
+ }, async ({ type }) => {
478
+ if (!type) {
479
+ const types = await Promise.all(TYPE_NAMES.map(async (t) => {
480
+ const folder = TYPE_FOLDERS[t];
481
+ return {
482
+ type: t,
483
+ prefix: `${t}-`,
484
+ folder,
485
+ purpose: schemaPurpose(await readTypeSchema(folder)),
486
+ };
487
+ }));
488
+ return ok({ types });
489
+ }
490
+ const folder = TYPE_FOLDERS[type];
491
+ const [schema, reference] = await Promise.all([
492
+ readTypeSchema(folder),
493
+ readTypeDoc(folder),
494
+ ]);
495
+ return ok({
496
+ type,
497
+ prefix: `${type}-`,
498
+ folder,
499
+ reserved: ['name', 'kind', 'status', 'connections'],
500
+ schema,
501
+ reference,
502
+ });
503
+ });
342
504
  server.registerTool('create_card', {
343
- 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.',
505
+ description: 'Create a new card. The handle determines type and file location. fields = type-specific frontmatter call describe_type(type) first for its field schema and a golden example; 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.',
344
506
  inputSchema: {
345
507
  handle: z.string(),
346
508
  name: z.string().optional(),
@@ -353,6 +515,7 @@ export function buildServer(options = {}) {
353
515
  .boolean()
354
516
  .optional()
355
517
  .describe('default true; false skips lint and returns no issues'),
518
+ repo: repoSchema,
356
519
  },
357
520
  }, withPlan(async (root, args) => {
358
521
  const handle = args.handle.toUpperCase();
@@ -411,6 +574,7 @@ export function buildServer(options = {}) {
411
574
  }))
412
575
  .min(1)
413
576
  .max(500),
577
+ repo: repoSchema,
414
578
  },
415
579
  }, withPlan(async (root, { cards }) => {
416
580
  const index = await loadPlan(root);
@@ -479,6 +643,7 @@ export function buildServer(options = {}) {
479
643
  .array(z.object({ from: z.string(), to: z.string() }))
480
644
  .min(1)
481
645
  .max(1000),
646
+ repo: repoSchema,
482
647
  },
483
648
  }, withPlan(async (root, { connections }) => {
484
649
  const index = await loadPlan(root);
@@ -509,7 +674,7 @@ export function buildServer(options = {}) {
509
674
  for (const [src, targets] of additions) {
510
675
  const card = index.cards.get(src);
511
676
  const existingList = Array.isArray(card.frontmatter.connections)
512
- ? card.frontmatter.connections
677
+ ? card.frontmatter.connections.filter((c) => typeof c === 'string')
513
678
  : [];
514
679
  const merged = [...new Set([...existingList, ...targets])];
515
680
  const frontmatter = applyCardPatch(card.frontmatter, { connections: merged });
@@ -534,6 +699,7 @@ export function buildServer(options = {}) {
534
699
  })
535
700
  .optional(),
536
701
  body: z.string().optional(),
702
+ repo: repoSchema,
537
703
  },
538
704
  }, withPlan(async (root, { handle, patch, body }) => {
539
705
  const index = await loadPlan(root);
@@ -560,7 +726,7 @@ export function buildServer(options = {}) {
560
726
  }));
561
727
  server.registerTool('delete_card', {
562
728
  description: 'Delete a card file. Returns the handles that referenced it (their references are now dangling) plus resulting lint issues.',
563
- inputSchema: { handle: z.string() },
729
+ inputSchema: { handle: z.string(), repo: repoSchema },
564
730
  }, withPlan(async (root, { handle }) => {
565
731
  const index = await loadPlan(root);
566
732
  const card = index.cards.get(handle.toUpperCase());
@@ -580,7 +746,7 @@ export function buildServer(options = {}) {
580
746
  }));
581
747
  server.registerTool('add_connection', {
582
748
  description: 'Connect two cards by appending `to` to `from`’s connections list. No-op if they are already connected through any source.',
583
- inputSchema: { from: z.string(), to: z.string() },
749
+ inputSchema: { from: z.string(), to: z.string(), repo: repoSchema },
584
750
  }, withPlan(async (root, args) => {
585
751
  const index = await loadPlan(root);
586
752
  const from = index.cards.get(args.from.toUpperCase());
@@ -607,7 +773,7 @@ export function buildServer(options = {}) {
607
773
  }));
608
774
  server.registerTool('remove_connection', {
609
775
  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.',
610
- inputSchema: { a: z.string(), b: z.string() },
776
+ inputSchema: { a: z.string(), b: z.string(), repo: repoSchema },
611
777
  }, withPlan(async (root, args) => {
612
778
  const index = await loadPlan(root);
613
779
  const cardA = index.cards.get(args.a.toUpperCase());
@@ -659,9 +825,130 @@ export function buildServer(options = {}) {
659
825
  issues: lint.issues.filter((i) => touched.has(i.file)),
660
826
  });
661
827
  }));
828
+ server.registerTool('list_connected_repos', {
829
+ annotations: { readOnlyHint: true },
830
+ description: 'List the sibling repos declared on PLAN-PROJECT connected_repos, each with its path, description, and whether it is reachable on this machine. Use a name as the `repo` selector on other tools to read or write that repo. Repo-level links only — not card connections.',
831
+ inputSchema: { repo: repoSchema },
832
+ }, withPlan(async (root) => {
833
+ const repos = await listConnectedRepos(root);
834
+ return ok({
835
+ connected_repos: repos.map((r) => ({
836
+ name: r.name,
837
+ path: r.path,
838
+ description: r.description ?? null,
839
+ reachable: r.reachable,
840
+ })),
841
+ });
842
+ }));
843
+ server.registerTool('add_connected_repo', {
844
+ description: 'Declare a sibling repo on PLAN-PROJECT connected_repos (a repo-level link, not a card connection). name is the lowercase `repo` selector; path is relative to this repo root (e.g. ../pyramid-server). reciprocate:true also writes the reverse link into the target repo — only do this with the user’s OK, since it edits the other repo. Upserts by name.',
845
+ inputSchema: {
846
+ name: z
847
+ .string()
848
+ .describe('lowercase id used as the `repo` selector (e.g. pyramid-server)'),
849
+ path: z
850
+ .string()
851
+ .describe('path to the connected repo root, relative to this repo (e.g. ../pyramid-server)'),
852
+ description: z.string().optional(),
853
+ reciprocate: z
854
+ .boolean()
855
+ .optional()
856
+ .describe('also add the reverse link into the target repo (writes there). default false'),
857
+ reverse_description: z
858
+ .string()
859
+ .optional()
860
+ .describe('description for the reverse link when reciprocate is set; defaults to none'),
861
+ repo: repoSchema,
862
+ },
863
+ }, withPlan(async (root, args) => {
864
+ const { name, path: repoPath, description, reciprocate, reverse_description } = args;
865
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
866
+ return fail('INVALID_NAME', `"${name}" is not a valid repo name (lowercase letters, digits, and hyphens).`);
867
+ }
868
+ const index = await loadPlan(root);
869
+ const planCard = index.cards.get('PLAN-PROJECT');
870
+ if (!planCard) {
871
+ return fail('NO_PLAN_PROJECT', 'No plan.md (PLAN-PROJECT) at the plan root to record connected_repos on.');
872
+ }
873
+ const entry = { name, path: repoPath, description };
874
+ const next = upsertConnectedRepo(await readConnectedRepos(root), entry);
875
+ const frontmatter = applyCardPatch(planCard.frontmatter, {
876
+ fields: { connected_repos: next.map(connectedRepoToFm) },
877
+ });
878
+ await updateCardFile(planCard.filePath, { frontmatter });
879
+ let reciprocated;
880
+ if (reciprocate) {
881
+ const target = await resolveConnectedRepo(root, repoPath);
882
+ if (!target) {
883
+ reciprocated = { ok: false, reason: 'target repo not reachable (no plan found at path)' };
884
+ }
885
+ else {
886
+ const targetPlan = (await loadPlan(target.root)).cards.get('PLAN-PROJECT');
887
+ if (!targetPlan) {
888
+ reciprocated = { ok: false, reason: 'target repo has no plan.md (PLAN-PROJECT)' };
889
+ }
890
+ else {
891
+ const homeRepoRoot = path.dirname(root);
892
+ const reverseName = path
893
+ .basename(homeRepoRoot)
894
+ .toLowerCase()
895
+ .replace(/[^a-z0-9-]/g, '-')
896
+ .replace(/^-+|-+$/g, '') || 'home';
897
+ const reverseEntry = {
898
+ name: reverseName,
899
+ path: path.relative(target.repoRoot, homeRepoRoot) || '.',
900
+ description: reverse_description,
901
+ };
902
+ const targetNext = upsertConnectedRepo(await readConnectedRepos(target.root), reverseEntry);
903
+ await updateCardFile(targetPlan.filePath, {
904
+ frontmatter: applyCardPatch(targetPlan.frontmatter, {
905
+ fields: { connected_repos: targetNext.map(connectedRepoToFm) },
906
+ }),
907
+ });
908
+ reciprocated = {
909
+ ok: true,
910
+ repo_root: target.repoRoot,
911
+ entry: connectedRepoToFm(reverseEntry),
912
+ };
913
+ }
914
+ }
915
+ }
916
+ const lint = await lintPlan(root);
917
+ return ok({
918
+ connected_repos: next.map(connectedRepoToFm),
919
+ reciprocated,
920
+ issues: issuesForFile(lint.issues, planCard.relPath),
921
+ });
922
+ }));
923
+ server.registerTool('remove_connected_repo', {
924
+ description: 'Remove a sibling repo from PLAN-PROJECT connected_repos by name. Does not touch the other repo.',
925
+ inputSchema: { name: z.string(), repo: repoSchema },
926
+ }, withPlan(async (root, { name }) => {
927
+ const planCard = (await loadPlan(root)).cards.get('PLAN-PROJECT');
928
+ if (!planCard) {
929
+ return fail('NO_PLAN_PROJECT', 'No plan.md (PLAN-PROJECT) at the plan root.');
930
+ }
931
+ const existing = await readConnectedRepos(root);
932
+ if (!existing.some((r) => r.name === name)) {
933
+ return ok({ removed: false, connected_repos: existing.map(connectedRepoToFm) });
934
+ }
935
+ const next = removeConnectedRepoEntry(existing, name);
936
+ await updateCardFile(planCard.filePath, {
937
+ frontmatter: applyCardPatch(planCard.frontmatter, {
938
+ fields: { connected_repos: next.length > 0 ? next.map(connectedRepoToFm) : null },
939
+ }),
940
+ });
941
+ const lint = await lintPlan(root);
942
+ return ok({
943
+ removed: true,
944
+ connected_repos: next.map(connectedRepoToFm),
945
+ issues: issuesForFile(lint.issues, planCard.relPath),
946
+ });
947
+ }));
662
948
  server.registerTool('check_integrity', {
949
+ annotations: { readOnlyHint: true },
663
950
  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.',
664
- inputSchema: {},
951
+ inputSchema: { repo: repoSchema },
665
952
  }, withPlan(async (root) => {
666
953
  const lint = await lintPlan(root);
667
954
  const orphans = [...lint.index.cards.keys()]
@@ -676,19 +963,23 @@ export function buildServer(options = {}) {
676
963
  });
677
964
  }));
678
965
  server.registerTool('diff_plan', {
966
+ annotations: { readOnlyHint: true },
679
967
  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.',
680
968
  inputSchema: {
681
969
  base: z.string().optional(),
682
970
  head: z.string().optional(),
971
+ repo: repoSchema,
683
972
  },
684
973
  }, withPlan(async (root, { base, head }) => {
685
974
  return ok(await diffPlan(root, base, head));
686
975
  }));
687
976
  server.registerTool('plan_log', {
977
+ annotations: { readOnlyHint: true },
688
978
  description: 'Git history of one card: the commits that touched its file.',
689
979
  inputSchema: {
690
980
  handle: z.string(),
691
981
  limit: z.number().int().min(1).max(100).optional(),
982
+ repo: repoSchema,
692
983
  },
693
984
  }, withPlan(async (root, { handle, limit }) => {
694
985
  const index = await loadPlan(root);
@@ -701,7 +992,7 @@ export function buildServer(options = {}) {
701
992
  }));
702
993
  server.registerTool('set_sync_point', {
703
994
  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.',
704
- inputSchema: { sha: z.string().optional() },
995
+ inputSchema: { sha: z.string().optional(), repo: repoSchema },
705
996
  }, withPlan(async (root, { sha }) => {
706
997
  const point = await writeSyncPoint(root, sha);
707
998
  const dirty = await planDirty(root);
@@ -763,13 +1054,7 @@ export function buildServer(options = {}) {
763
1054
  const url = `http://localhost:${running.port}/`;
764
1055
  viewer = { server: running, planRoot: root, url };
765
1056
  if (open) {
766
- const { spawn } = await import('node:child_process');
767
- const cmd = process.platform === 'darwin'
768
- ? 'open'
769
- : process.platform === 'win32'
770
- ? 'start'
771
- : 'xdg-open';
772
- spawn(cmd, [url], { stdio: 'ignore', detached: true }).unref();
1057
+ await openUrl(url);
773
1058
  }
774
1059
  return ok({ url, port: running.port, plan_root: root, editable: !(readonly ?? false) });
775
1060
  }));