@magic-spells/constellation 0.1.0 → 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 (121) hide show
  1. package/README.md +41 -4
  2. package/dist/cli/index.js +62 -10
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/cli/update-check.d.ts +6 -0
  5. package/dist/cli/update-check.js +70 -0
  6. package/dist/cli/update-check.js.map +1 -0
  7. package/dist/{mcp → core}/git.d.ts +19 -0
  8. package/dist/{mcp → core}/git.js +59 -2
  9. package/dist/core/git.js.map +1 -0
  10. package/dist/core/index.d.ts +1 -0
  11. package/dist/core/index.js +1 -0
  12. package/dist/core/index.js.map +1 -1
  13. package/dist/core/repos.d.ts +40 -0
  14. package/dist/core/repos.js +93 -0
  15. package/dist/core/repos.js.map +1 -0
  16. package/dist/core/resolve.js +10 -1
  17. package/dist/core/resolve.js.map +1 -1
  18. package/dist/core/scaffold.d.ts +16 -3
  19. package/dist/core/scaffold.js +30 -7
  20. package/dist/core/scaffold.js.map +1 -1
  21. package/dist/core/sync.d.ts +28 -0
  22. package/dist/core/sync.js +85 -0
  23. package/dist/core/sync.js.map +1 -0
  24. package/dist/core/types.d.ts +12 -0
  25. package/dist/mcp/server.js +434 -38
  26. package/dist/mcp/server.js.map +1 -1
  27. package/dist/serve/server.js +28 -4
  28. package/dist/serve/server.js.map +1 -1
  29. package/docs/001-file-format.md +25 -0
  30. package/docs/002-mcp.md +56 -7
  31. package/examples/constellation/plan.md +1 -1
  32. package/package.json +3 -1
  33. package/schemas/plan.json +27 -0
  34. package/skill/SKILL.md +131 -1
  35. package/skill/methodology.md +228 -0
  36. package/skill/types/plan.md +19 -0
  37. package/viewer/dist/assets/{arc-Kj6pF3JI.js → arc-CMMRgIq9.js} +1 -1
  38. package/viewer/dist/assets/architecture-7EHR7CIX-Cbb-syEI.js +1 -0
  39. package/viewer/dist/assets/{architectureDiagram-3BPJPVTR-C5bZdErB.js → architectureDiagram-3BPJPVTR-CB5p_qlN.js} +1 -1
  40. package/viewer/dist/assets/{blockDiagram-GPEHLZMM-C1Q6l6fE.js → blockDiagram-GPEHLZMM-DpnMj_e6.js} +1 -1
  41. package/viewer/dist/assets/{c4Diagram-AAUBKEIU-BmM6Tmtq.js → c4Diagram-AAUBKEIU-f91ngVN8.js} +1 -1
  42. package/viewer/dist/assets/channel-C2bkEQHQ.js +1 -0
  43. package/viewer/dist/assets/{chunk-2J33WTMH-z09tLTpZ.js → chunk-2J33WTMH-y7Pz5tMO.js} +1 -1
  44. package/viewer/dist/assets/{chunk-3OPIFGDE-BynpXh1r.js → chunk-3OPIFGDE-Ir_ghIUQ.js} +1 -1
  45. package/viewer/dist/assets/{chunk-4BX2VUAB-CDOVuPyG.js → chunk-4BX2VUAB-BTPyM1ux.js} +1 -1
  46. package/viewer/dist/assets/{chunk-55IACEB6-nBwigOgn.js → chunk-55IACEB6-CtNd8e4Y.js} +1 -1
  47. package/viewer/dist/assets/{chunk-5ZQYHXKU-Bxe5xIy_.js → chunk-5ZQYHXKU-Dd3yz721.js} +1 -1
  48. package/viewer/dist/assets/{chunk-727SXJPM-DZmTgL68.js → chunk-727SXJPM-CAM8mGbv.js} +1 -1
  49. package/viewer/dist/assets/{chunk-AQP2D5EJ-B7wr_Owx.js → chunk-AQP2D5EJ-C3nW9Uve.js} +1 -1
  50. package/viewer/dist/assets/{chunk-BSJP7CBP-DbAKfVCK.js → chunk-BSJP7CBP-r6MRPvAH.js} +1 -1
  51. package/viewer/dist/assets/{chunk-CSCIHK7Q-C0rsBwqP.js → chunk-CSCIHK7Q-Cn-ZTxsS.js} +1 -1
  52. package/viewer/dist/assets/{chunk-FMBD7UC4-BAtzt0wv.js → chunk-FMBD7UC4-5axXeZJa.js} +1 -1
  53. package/viewer/dist/assets/{chunk-KSCS5N6A-CXXwf52I.js → chunk-KSCS5N6A-BwmNx0l0.js} +1 -1
  54. package/viewer/dist/assets/{chunk-L5ZTLDWV-DS4vRI1U.js → chunk-L5ZTLDWV-C2YNkUjB.js} +1 -1
  55. package/viewer/dist/assets/chunk-LZXEDZCA-CznuDgQD.js +2 -0
  56. package/viewer/dist/assets/{chunk-ND2GUHAM-CUSnPl8t.js → chunk-ND2GUHAM-CokDnghi.js} +1 -1
  57. package/viewer/dist/assets/{chunk-NZK2D7GU-Dh986nJk.js → chunk-NZK2D7GU-aaU0v4QE.js} +1 -1
  58. package/viewer/dist/assets/{chunk-O5CBEL6O-JAEZ_pS6.js → chunk-O5CBEL6O-ByfKhkZf.js} +1 -1
  59. package/viewer/dist/assets/chunk-QZHKN3VN-DJRjGeIo.js +1 -0
  60. package/viewer/dist/assets/chunk-WU5MYG2G-C63BVvr_.js +1 -0
  61. package/viewer/dist/assets/{chunk-XPW4576I-BwMZI0gv.js → chunk-XPW4576I-Amo3awtq.js} +1 -1
  62. package/viewer/dist/assets/classDiagram-4FO5ZUOK-z_bO1V-j.js +1 -0
  63. package/viewer/dist/assets/classDiagram-v2-Q7XG4LA2-z_bO1V-j.js +1 -0
  64. package/viewer/dist/assets/{cose-bilkent-S5V4N54A-NGC7gYHM.js → cose-bilkent-S5V4N54A-CO8yESCW.js} +1 -1
  65. package/viewer/dist/assets/{dagre-BM42HDAG-RD63uyvd.js → dagre-BM42HDAG-BnmNVNI2.js} +1 -1
  66. package/viewer/dist/assets/{diagram-2AECGRRQ-hwnqqCcb.js → diagram-2AECGRRQ-lcGXcbei.js} +1 -1
  67. package/viewer/dist/assets/{diagram-5GNKFQAL-q8EaoZSG.js → diagram-5GNKFQAL-Cj3-lPUt.js} +1 -1
  68. package/viewer/dist/assets/{diagram-KO2AKTUF-D4_5Qf-l.js → diagram-KO2AKTUF-CtXdRI4M.js} +1 -1
  69. package/viewer/dist/assets/{diagram-LMA3HP47-D8pwekFs.js → diagram-LMA3HP47-CIyLAzX7.js} +1 -1
  70. package/viewer/dist/assets/{diagram-OG6HWLK6-D9KinIWZ.js → diagram-OG6HWLK6-BwF4bQCv.js} +1 -1
  71. package/viewer/dist/assets/{dist-CFOOgrqc.js → dist-DzXuQd4N.js} +1 -1
  72. package/viewer/dist/assets/{erDiagram-TEJ5UH35-D0Wfq250.js → erDiagram-TEJ5UH35-DjwFw9yq.js} +1 -1
  73. package/viewer/dist/assets/eventmodeling-FCH6USID-BlKFmSzA.js +1 -0
  74. package/viewer/dist/assets/{flowDiagram-I6XJVG4X-Y2DY-Ze2.js → flowDiagram-I6XJVG4X-CzXbf7vQ.js} +1 -1
  75. package/viewer/dist/assets/{ganttDiagram-6RSMTGT7-BnqkeLVw.js → ganttDiagram-6RSMTGT7-BYcIhfmm.js} +1 -1
  76. package/viewer/dist/assets/{gitGraph-WXDBUCRP-Cft7usRT.js → gitGraph-WXDBUCRP-H8XeJ9QB.js} +1 -1
  77. package/viewer/dist/assets/{gitGraphDiagram-PVQCEYII-D-cYtraK.js → gitGraphDiagram-PVQCEYII-DtAPXKFK.js} +1 -1
  78. package/viewer/dist/assets/index-Bqs39Itl.css +2 -0
  79. package/viewer/dist/assets/index-DLPCG_Or.js +98 -0
  80. package/viewer/dist/assets/{info-J43DQDTF-Djc8Bx3F.js → info-J43DQDTF-BQScorZ0.js} +1 -1
  81. package/viewer/dist/assets/{infoDiagram-5YYISTIA-D-ehtyyJ.js → infoDiagram-5YYISTIA-Udl1omvH.js} +1 -1
  82. package/viewer/dist/assets/{ishikawaDiagram-YF4QCWOH-Ct3f6bH-.js → ishikawaDiagram-YF4QCWOH-VnfzT_hk.js} +1 -1
  83. package/viewer/dist/assets/{journeyDiagram-JHISSGLW-DXlULEmi.js → journeyDiagram-JHISSGLW-Btn4EVoD.js} +1 -1
  84. package/viewer/dist/assets/{kanban-definition-UN3LZRKU-3vE9h-R7.js → kanban-definition-UN3LZRKU-flOzWhPe.js} +1 -1
  85. package/viewer/dist/assets/{line-B8MygbLB.js → line-DJJ9BLuY.js} +1 -1
  86. package/viewer/dist/assets/{linear-CfMuM0B3.js → linear-C2SUy5ns.js} +1 -1
  87. package/viewer/dist/assets/{mermaid-parser.core-DzlZTbbh.js → mermaid-parser.core-xhSeYiZo.js} +2 -2
  88. package/viewer/dist/assets/{mermaid.core-IM-sPiyq.js → mermaid.core-DmeSIitY.js} +3 -3
  89. package/viewer/dist/assets/{mindmap-definition-RKZ34NQL-CMnpAq1T.js → mindmap-definition-RKZ34NQL-CCJcMKx1.js} +1 -1
  90. package/viewer/dist/assets/{packet-YPE3B663-D44AzgHh.js → packet-YPE3B663-DS8RscAk.js} +1 -1
  91. package/viewer/dist/assets/{pie-LRSECV5Y-DL8AVJH_.js → pie-LRSECV5Y-I2ZQhrNJ.js} +1 -1
  92. package/viewer/dist/assets/{pieDiagram-4H26LBE5-FvKK5jd7.js → pieDiagram-4H26LBE5-BS5HMs4j.js} +1 -1
  93. package/viewer/dist/assets/{quadrantDiagram-W4KKPZXB-CmjSkU8c.js → quadrantDiagram-W4KKPZXB-W4H8cw2_.js} +1 -1
  94. package/viewer/dist/assets/{radar-GUYGQ44K-BvfZTVyH.js → radar-GUYGQ44K-kf6QvSne.js} +1 -1
  95. package/viewer/dist/assets/{requirementDiagram-4Y6WPE33-BOjca3VH.js → requirementDiagram-4Y6WPE33-DSbuDQGQ.js} +1 -1
  96. package/viewer/dist/assets/{sankeyDiagram-5OEKKPKP-ANcjfNix.js → sankeyDiagram-5OEKKPKP-CH8Qr2vL.js} +1 -1
  97. package/viewer/dist/assets/{sequenceDiagram-3UESZ5HK-BLQ9AL7I.js → sequenceDiagram-3UESZ5HK-EugVcdad.js} +1 -1
  98. package/viewer/dist/assets/src-CM_9kdrU.js +1 -0
  99. package/viewer/dist/assets/{stateDiagram-AJRCARHV-D6CriBS6.js → stateDiagram-AJRCARHV-DiFl94Qy.js} +1 -1
  100. package/viewer/dist/assets/stateDiagram-v2-BHNVJYJU-BlJV8F13.js +1 -0
  101. package/viewer/dist/assets/{timeline-definition-PNZ67QCA-BNhWZ_DL.js → timeline-definition-PNZ67QCA-B0KW9GUN.js} +1 -1
  102. package/viewer/dist/assets/{treeView-BLDUP644-CY6Ph5Pu.js → treeView-BLDUP644-CAEY4mxq.js} +1 -1
  103. package/viewer/dist/assets/{treemap-LRROVOQU-DChSA_Qx.js → treemap-LRROVOQU-VVaEaTlU.js} +1 -1
  104. package/viewer/dist/assets/{vennDiagram-CIIHVFJN-C01WznAC.js → vennDiagram-CIIHVFJN-BSLGNfJd.js} +1 -1
  105. package/viewer/dist/assets/{wardley-L42UT6IY-BJ8uNoJu.js → wardley-L42UT6IY-C-5cXbAU.js} +1 -1
  106. package/viewer/dist/assets/{wardleyDiagram-YWT4CUSO-DwDEzlVm.js → wardleyDiagram-YWT4CUSO-COk4TVnQ.js} +1 -1
  107. package/viewer/dist/assets/{xychartDiagram-2RQKCTM6-BCvIDwU0.js → xychartDiagram-2RQKCTM6-CClGJLJp.js} +1 -1
  108. package/viewer/dist/index.html +3 -3
  109. package/dist/mcp/git.js.map +0 -1
  110. package/viewer/dist/assets/architecture-7EHR7CIX-CGfWeim3.js +0 -1
  111. package/viewer/dist/assets/channel-19IdUS_c.js +0 -1
  112. package/viewer/dist/assets/chunk-LZXEDZCA-CclT9MXr.js +0 -2
  113. package/viewer/dist/assets/chunk-QZHKN3VN-BKc_Kg2Z.js +0 -1
  114. package/viewer/dist/assets/chunk-WU5MYG2G-9ssTSMzt.js +0 -1
  115. package/viewer/dist/assets/classDiagram-4FO5ZUOK-DXv85WFd.js +0 -1
  116. package/viewer/dist/assets/classDiagram-v2-Q7XG4LA2-DXv85WFd.js +0 -1
  117. package/viewer/dist/assets/eventmodeling-FCH6USID-D3KRSuC1.js +0 -1
  118. package/viewer/dist/assets/index-CDR-riG2.css +0 -2
  119. package/viewer/dist/assets/index-DRPsTWe2.js +0 -98
  120. package/viewer/dist/assets/src-CAMdANUp.js +0 -1
  121. package/viewer/dist/assets/stateDiagram-v2-BHNVJYJU-DcTp66RQ.js +0 -1
@@ -1,15 +1,18 @@
1
- import { rm } from 'node:fs/promises';
1
+ import { readFile, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
2
4
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
6
  import { z } from 'zod';
5
- import { isHandleShaped, isKnownHandle, typeForHandle } from '../core/handles.js';
7
+ import { isHandleShaped, isKnownHandle, TYPE_FOLDERS, typeForHandle, } from '../core/handles.js';
6
8
  import { loadPlan } from '../core/indexer.js';
7
9
  import { lintPlan } from '../core/lint.js';
8
10
  import { resolvePlanDir } from '../core/resolve.js';
9
11
  import { TYPE_NAMES } from '../core/types.js';
10
- import { diffPlan, planDirty, planLog, writeSyncPoint } from './git.js';
12
+ import { diffPlan, planDirty, planLog, writeSyncPoint } from '../core/git.js';
11
13
  import { searchCards } from './search.js';
12
14
  import { applyCardPatch, createCardFile, deepMerge, relPathForHandle, reservedFieldKeys, updateCardFile, } from '../core/writer.js';
15
+ import { connectedRepoToFm, listConnectedRepos, readConnectedRepos, removeConnectedRepoEntry, resolveConnectedRepo, upsertConnectedRepo, } from '../core/repos.js';
13
16
  const INSTRUCTIONS = `# Constellation MCP
14
17
 
15
18
  The project's architecture plan lives as markdown files in a constellation/ folder.
@@ -25,10 +28,46 @@ Writes are validated: every write tool lints and returns issues for the file it
25
28
  update_card patch.fields deep-merges (arrays replace, null deletes); body replaces.
26
29
  Body-only updates never reformat frontmatter.
27
30
 
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.
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
+
36
+ Change tracking is git: diff_plan reports per-card changes since the sync marker (or HEAD).
37
+ Never stamp dirty flags into cards.
38
+
39
+ "Sync the plan" / "sync the plan to the code" = bring the CODE up to match the plan (the
40
+ plan is the source of truth — behavior changes in the plan FIRST, then in code, never the
41
+ reverse). It is NOT merely stamping the marker. The loop: diff_plan (base = marker) for what
42
+ changed → traverse the changed handles (detail: "full") for blast radius → update the
43
+ application code to match those cards → run the build/tests and bump card status → commit,
44
+ then set_sync_point to advance the marker (commit the plan first — it warns if the plan is
45
+ uncommitted). When the diff is large and the affected areas don't share files, act as the
46
+ ORCHESTRATOR rather than editing it all yourself: partition the blast radius into
47
+ independent, non-overlapping neighborhoods (split on file boundaries so no two agents touch
48
+ the same file, AND assign each plan card to exactly one agent — two agents calling
49
+ update_card on the same card race, and the later write silently clobbers the earlier) and
50
+ fan out a sub-agent per neighborhood in parallel; use one agent when the change is small or
51
+ files overlap. Delegating keeps your context clean and lets you hold the
52
+ macro view. ALWAYS verify the sub-agents' work yourself after they have all finished — re-read
53
+ each change against its cards and run the build/tests; never trust their reports alone — then
54
+ set the sync point once.
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.
32
71
 
33
72
  For migrations or large scaffolds, use create_cards and add_connections (batched, one
34
73
  lint pass) instead of many single calls — connections between cards in the same batch
@@ -40,11 +79,106 @@ The plan folder is found by walking up from the working directory, BOUNDED by th
40
79
  root (it never adopts another repo's plan). If no plan exists in this repo (tools return
41
80
  NO_PLAN_FOUND), call init_plan once — create_card works immediately after.
42
81
 
82
+ When building or auditing a plan, act as a senior engineer and architect advising the user,
83
+ not a scribe taking dictation: don't assume they know everything — bring expertise, name
84
+ trade-offs and risks, propose what's missing, and explain the why so they can decide. Hold
85
+ the bar high and with integrity: do it right, be honest about built-vs-planned and
86
+ verified-vs-assumed, and surface uncertainty rather than papering over it. But don't
87
+ over-engineer — there's elegance in simplicity: calibrate to the project's scope, recommend
88
+ the smallest change that most improves the plan, and don't manufacture gaps to look thorough.
89
+ The aim is a plan the user would be proud to ship. Hold it to one bar above all: if every
90
+ line of code were deleted, the app could be rebuilt from the plan alone — aim for that
91
+ coverage (not volume), and whatever you couldn't rebuild is the gap.
92
+
93
+ Work macro→micro: orient (manifest, routes, folder
94
+ layout) and seed PLAN-PROJECT + a system DIAGRAM — propose a human-readable project name
95
+ and confirm it with the user (it's plan.md's name: and the viewer's title; change it
96
+ anytime via update_card on PLAN-PROJECT); then follow the DATA (DB → DATATYPE →
97
+ API → PAGE, with FLOW/STATE for paths and lifecycles) and the USER (ROLE + auth FLOW
98
+ first, then PAGE/COMPONENT and key journeys) and the EDGES (EXTERNAL/JOB/EVENT); then zoom
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,
100
+ priorities, and history the code can't reveal. Then find gaps IN THE PLAN: step back and
101
+ hunt blind spots the user may not have considered — missing unhappy paths and lifecycle
102
+ states, auth/permission gaps, and cross-cutting concerns plans forget (security, privacy,
103
+ observability, rate limits, pagination, migrations, testing). The mechanical checks
104
+ (check_integrity orphans, dangling refs, code-without-cards) are just hygiene. Give a
105
+ short, prioritized list of recommendations and ask about the judgment calls. For the full
106
+ method use the bootstrap_plan or audit_plan prompt. Status is planned → building → built →
107
+ verified; verify only against real code.
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
+
43
124
  To let the user browse the plan visually, start_viewer launches a local web server that
44
125
  renders the plan as an editable site and returns its URL (it scans forward from port 4747
45
126
  for a free port, so always read the actual port from the response). ALWAYS post that URL
46
127
  back to the user as a clickable link, e.g. http://localhost:4747/, and tell them the port.
47
128
  The viewer runs until stop_viewer or until this server process exits.`;
129
+ // The full plan-from-code playbook lives in one file (skill/methodology.md), shared by the
130
+ // skill and the MCP prompts so the two can't drift. Resolve it relative to this module:
131
+ // from dist/mcp/server.js (or src/mcp/server.ts) '../..' is the package/repo root.
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
+ }
164
+ let methodologyCache = null;
165
+ async function methodologyText() {
166
+ if (methodologyCache === null) {
167
+ methodologyCache = await readFile(METHODOLOGY_PATH, 'utf8');
168
+ }
169
+ return methodologyCache;
170
+ }
171
+ /** A prompt body = a one-line mode intro followed by the shared methodology. */
172
+ async function planPromptBody(intro) {
173
+ try {
174
+ return `${intro}\n\n${await methodologyText()}`;
175
+ }
176
+ catch {
177
+ return (`${intro}\n\n(Could not read the methodology file; follow the macro→micro summary ` +
178
+ `in the server instructions: orient, follow the data, follow the user/auth, follow ` +
179
+ `the edges, zoom in, ask only what the code can't answer, find gaps, recommend.)`);
180
+ }
181
+ }
48
182
  function ok(data) {
49
183
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
50
184
  }
@@ -54,6 +188,25 @@ function fail(code, message) {
54
188
  isError: true,
55
189
  };
56
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
+ }
57
210
  function summary(card) {
58
211
  return {
59
212
  handle: card.handle,
@@ -81,25 +234,87 @@ function issuesForFile(issues, relPath) {
81
234
  const detailSchema = z.enum(['none', 'summary', 'full']);
82
235
  const typeSchema = z.enum(TYPE_NAMES);
83
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.');
84
241
  export function buildServer(options = {}) {
85
- const server = new McpServer({ name: 'constellation', version: '0.1.0' }, { instructions: INSTRUCTIONS });
242
+ const server = new McpServer({ name: 'constellation', version: '0.2.0' }, { instructions: INSTRUCTIONS });
243
+ server.registerPrompt('bootstrap_plan', {
244
+ title: 'Bootstrap a plan from the codebase',
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.',
246
+ }, async () => ({
247
+ messages: [
248
+ {
249
+ role: 'user',
250
+ content: {
251
+ type: 'text',
252
+ text: await planPromptBody('Bootstrap (or extend) the Constellation plan for THIS repository by analyzing its code. If no plan exists, call init_plan first. Work the method below end to end, then report the gaps you found and a short, prioritized list of recommendations.'),
253
+ },
254
+ },
255
+ ],
256
+ }));
257
+ server.registerPrompt('audit_plan', {
258
+ title: 'Audit the plan against the code',
259
+ description: 'Reconcile the existing plan with the codebase: find gaps, orphans, stale or missing cards, and dangling refs; verify card statuses against the real code; and make tasteful recommendations.',
260
+ }, async () => ({
261
+ messages: [
262
+ {
263
+ role: 'user',
264
+ content: {
265
+ type: 'text',
266
+ text: await planPromptBody('Audit the existing Constellation plan for THIS repository against its code. Lean on Steps 7–8 (find gaps, recommend) and on verifying statuses against real code, but use the whole method as a checklist. Report what is missing, stale, or orphaned, then a short, prioritized list of recommendations.'),
267
+ },
268
+ },
269
+ ],
270
+ }));
86
271
  async function planRoot() {
87
272
  return options.planRoot ?? resolvePlanDir();
88
273
  }
89
274
  // A single web viewer owned by this server process; null until start_viewer runs.
90
275
  let viewer = null;
91
- /** 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. */
92
311
  function withPlan(handler) {
93
312
  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
- }
313
+ const target = await resolveTarget(args?.repo);
314
+ if ('error' in target)
315
+ return target.error;
101
316
  try {
102
- return await handler(root, args);
317
+ return await handler(target.root, args);
103
318
  }
104
319
  catch (err) {
105
320
  return fail('INTERNAL', err instanceof Error ? err.message : String(err));
@@ -107,20 +322,25 @@ export function buildServer(options = {}) {
107
322
  };
108
323
  }
109
324
  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.',
325
+ 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). Pass name to set the project name (shown as the viewer title); if omitted it defaults to a title-cased folder name (pyramid-server → "Pyramid Server"). Propose a name, confirm it with the user, and change it anytime via update_card on PLAN-PROJECT. After this, create_card works immediately.',
111
326
  inputSchema: {
112
327
  path: z
113
328
  .string()
114
329
  .optional()
115
330
  .describe('directory to create constellation/ in (default: cwd)'),
331
+ name: z
332
+ .string()
333
+ .optional()
334
+ .describe('project name for plan.md (default: a title-cased folder name)'),
116
335
  },
117
- }, async ({ path: target }) => {
336
+ }, async ({ path: target, name }) => {
118
337
  try {
119
338
  const { initPlan } = await import('../core/scaffold.js');
120
- const created = await initPlan(target ?? process.cwd());
339
+ const { root: created, name: projectName } = await initPlan(target ?? process.cwd(), { name });
121
340
  return ok({
122
341
  created,
123
- next: 'Add cards with create_card; update plan.md via update_card on PLAN-PROJECT.',
342
+ name: projectName,
343
+ next: `Plan created with project name "${projectName}" (the viewer title). Confirm the name with the user — change it anytime via update_card on PLAN-PROJECT (patch.name). Then add cards with create_card.`,
124
344
  });
125
345
  }
126
346
  catch (err) {
@@ -128,10 +348,12 @@ export function buildServer(options = {}) {
128
348
  }
129
349
  });
130
350
  server.registerTool('get_card', {
351
+ annotations: { readOnlyHint: true },
131
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.',
132
353
  inputSchema: {
133
354
  handle: z.string(),
134
355
  connected: detailSchema.optional().describe('default: summary'),
356
+ repo: repoSchema,
135
357
  },
136
358
  }, withPlan(async (root, { handle, connected }) => {
137
359
  const index = await loadPlan(root);
@@ -144,6 +366,7 @@ export function buildServer(options = {}) {
144
366
  });
145
367
  }));
146
368
  server.registerTool('list_cards', {
369
+ annotations: { readOnlyHint: true },
147
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).',
148
371
  inputSchema: {
149
372
  types: z.array(typeSchema).optional(),
@@ -154,6 +377,7 @@ export function buildServer(options = {}) {
154
377
  .optional()
155
378
  .describe('false = orphans only; true = connected only'),
156
379
  limit: z.number().int().min(1).max(500).optional(),
380
+ repo: repoSchema,
157
381
  },
158
382
  }, withPlan(async (root, { types, kind, status, connected, limit }) => {
159
383
  const index = await loadPlan(root);
@@ -171,12 +395,14 @@ export function buildServer(options = {}) {
171
395
  });
172
396
  }));
173
397
  server.registerTool('search', {
398
+ annotations: { readOnlyHint: true },
174
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.',
175
400
  inputSchema: {
176
401
  q: z.string(),
177
402
  types: z.array(typeSchema).optional(),
178
403
  limit: z.number().int().min(1).max(100).optional(),
179
404
  connected: detailSchema.optional().describe('default: none'),
405
+ repo: repoSchema,
180
406
  },
181
407
  }, withPlan(async (root, { q, types, limit, connected }) => {
182
408
  const index = await loadPlan(root);
@@ -191,12 +417,14 @@ export function buildServer(options = {}) {
191
417
  });
192
418
  }));
193
419
  server.registerTool('traverse', {
420
+ annotations: { readOnlyHint: true },
194
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.',
195
422
  inputSchema: {
196
423
  start: z.union([z.string(), z.array(z.string()).min(1)]),
197
424
  depth: z.number().int().min(0).max(5).optional().describe('default: 2'),
198
425
  types: z.array(typeSchema).optional(),
199
426
  detail: z.enum(['summary', 'full']).optional().describe('default: summary'),
427
+ repo: repoSchema,
200
428
  },
201
429
  }, withPlan(async (root, { start, depth, types, detail }) => {
202
430
  const index = await loadPlan(root);
@@ -240,8 +468,41 @@ export function buildServer(options = {}) {
240
468
  const connections = index.connections.filter((c) => distance.has(c.a) && distance.has(c.b));
241
469
  return ok({ cards, connections, not_found: missing });
242
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
+ });
243
504
  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.',
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.',
245
506
  inputSchema: {
246
507
  handle: z.string(),
247
508
  name: z.string().optional(),
@@ -254,6 +515,7 @@ export function buildServer(options = {}) {
254
515
  .boolean()
255
516
  .optional()
256
517
  .describe('default true; false skips lint and returns no issues'),
518
+ repo: repoSchema,
257
519
  },
258
520
  }, withPlan(async (root, args) => {
259
521
  const handle = args.handle.toUpperCase();
@@ -312,6 +574,7 @@ export function buildServer(options = {}) {
312
574
  }))
313
575
  .min(1)
314
576
  .max(500),
577
+ repo: repoSchema,
315
578
  },
316
579
  }, withPlan(async (root, { cards }) => {
317
580
  const index = await loadPlan(root);
@@ -380,6 +643,7 @@ export function buildServer(options = {}) {
380
643
  .array(z.object({ from: z.string(), to: z.string() }))
381
644
  .min(1)
382
645
  .max(1000),
646
+ repo: repoSchema,
383
647
  },
384
648
  }, withPlan(async (root, { connections }) => {
385
649
  const index = await loadPlan(root);
@@ -406,18 +670,20 @@ export function buildServer(options = {}) {
406
670
  additions.get(f).add(t);
407
671
  }
408
672
  let added = 0;
673
+ const touched = new Set();
409
674
  for (const [src, targets] of additions) {
410
675
  const card = index.cards.get(src);
411
676
  const existingList = Array.isArray(card.frontmatter.connections)
412
- ? card.frontmatter.connections
677
+ ? card.frontmatter.connections.filter((c) => typeof c === 'string')
413
678
  : [];
414
679
  const merged = [...new Set([...existingList, ...targets])];
415
680
  const frontmatter = applyCardPatch(card.frontmatter, { connections: merged });
416
681
  await updateCardFile(card.filePath, { frontmatter });
682
+ touched.add(card.relPath);
417
683
  added += targets.size;
418
684
  }
419
685
  const lint = await lintPlan(root);
420
- return ok({ added, failed, errors: lint.errors });
686
+ return ok({ added, failed, issues: lint.issues.filter((i) => touched.has(i.file)) });
421
687
  }));
422
688
  server.registerTool('update_card', {
423
689
  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.',
@@ -433,6 +699,7 @@ export function buildServer(options = {}) {
433
699
  })
434
700
  .optional(),
435
701
  body: z.string().optional(),
702
+ repo: repoSchema,
436
703
  },
437
704
  }, withPlan(async (root, { handle, patch, body }) => {
438
705
  const index = await loadPlan(root);
@@ -459,7 +726,7 @@ export function buildServer(options = {}) {
459
726
  }));
460
727
  server.registerTool('delete_card', {
461
728
  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() },
729
+ inputSchema: { handle: z.string(), repo: repoSchema },
463
730
  }, withPlan(async (root, { handle }) => {
464
731
  const index = await loadPlan(root);
465
732
  const card = index.cards.get(handle.toUpperCase());
@@ -479,7 +746,7 @@ export function buildServer(options = {}) {
479
746
  }));
480
747
  server.registerTool('add_connection', {
481
748
  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() },
749
+ inputSchema: { from: z.string(), to: z.string(), repo: repoSchema },
483
750
  }, withPlan(async (root, args) => {
484
751
  const index = await loadPlan(root);
485
752
  const from = index.cards.get(args.from.toUpperCase());
@@ -497,11 +764,16 @@ export function buildServer(options = {}) {
497
764
  connections: [...existing, to.handle],
498
765
  });
499
766
  await updateCardFile(from.filePath, { frontmatter });
500
- return ok({ connected: [from.handle, to.handle], declared_on: from.handle });
767
+ const lint = await lintPlan(root);
768
+ return ok({
769
+ connected: [from.handle, to.handle],
770
+ declared_on: from.handle,
771
+ issues: issuesForFile(lint.issues, from.relPath),
772
+ });
501
773
  }));
502
774
  server.registerTool('remove_connection', {
503
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.',
504
- inputSchema: { a: z.string(), b: z.string() },
776
+ inputSchema: { a: z.string(), b: z.string(), repo: repoSchema },
505
777
  }, withPlan(async (root, args) => {
506
778
  const index = await loadPlan(root);
507
779
  const cardA = index.cards.get(args.a.toUpperCase());
@@ -526,7 +798,8 @@ export function buildServer(options = {}) {
526
798
  removedFrom.push(card.handle);
527
799
  }
528
800
  }
529
- const after = await loadPlan(root);
801
+ const lint = await lintPlan(root);
802
+ const after = lint.index;
530
803
  const stillConnected = after.connectedHandles.get(cardA.handle)?.has(cardB.handle) ?? false;
531
804
  const remainingSources = [];
532
805
  if (stillConnected) {
@@ -542,15 +815,140 @@ export function buildServer(options = {}) {
542
815
  remainingSources.push(`mermaid block in ${card.handle}`);
543
816
  }
544
817
  }
818
+ const touched = new Set(removedFrom
819
+ .map((h) => after.cards.get(h)?.relPath)
820
+ .filter((p) => Boolean(p)));
545
821
  return ok({
546
822
  removed_from: removedFrom,
547
823
  still_connected: stillConnected,
548
824
  remaining_sources: remainingSources,
825
+ issues: lint.issues.filter((i) => touched.has(i.file)),
826
+ });
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),
549
946
  });
550
947
  }));
551
948
  server.registerTool('check_integrity', {
949
+ annotations: { readOnlyHint: true },
552
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.',
553
- inputSchema: {},
951
+ inputSchema: { repo: repoSchema },
554
952
  }, withPlan(async (root) => {
555
953
  const lint = await lintPlan(root);
556
954
  const orphans = [...lint.index.cards.keys()]
@@ -565,19 +963,23 @@ export function buildServer(options = {}) {
565
963
  });
566
964
  }));
567
965
  server.registerTool('diff_plan', {
966
+ annotations: { readOnlyHint: true },
568
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.',
569
968
  inputSchema: {
570
969
  base: z.string().optional(),
571
970
  head: z.string().optional(),
971
+ repo: repoSchema,
572
972
  },
573
973
  }, withPlan(async (root, { base, head }) => {
574
974
  return ok(await diffPlan(root, base, head));
575
975
  }));
576
976
  server.registerTool('plan_log', {
977
+ annotations: { readOnlyHint: true },
577
978
  description: 'Git history of one card: the commits that touched its file.',
578
979
  inputSchema: {
579
980
  handle: z.string(),
580
981
  limit: z.number().int().min(1).max(100).optional(),
982
+ repo: repoSchema,
581
983
  },
582
984
  }, withPlan(async (root, { handle, limit }) => {
583
985
  const index = await loadPlan(root);
@@ -590,7 +992,7 @@ export function buildServer(options = {}) {
590
992
  }));
591
993
  server.registerTool('set_sync_point', {
592
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.',
593
- inputSchema: { sha: z.string().optional() },
995
+ inputSchema: { sha: z.string().optional(), repo: repoSchema },
594
996
  }, withPlan(async (root, { sha }) => {
595
997
  const point = await writeSyncPoint(root, sha);
596
998
  const dirty = await planDirty(root);
@@ -652,13 +1054,7 @@ export function buildServer(options = {}) {
652
1054
  const url = `http://localhost:${running.port}/`;
653
1055
  viewer = { server: running, planRoot: root, url };
654
1056
  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();
1057
+ await openUrl(url);
662
1058
  }
663
1059
  return ok({ url, port: running.port, plan_root: root, editable: !(readonly ?? false) });
664
1060
  }));