@magic-spells/constellation 0.2.1 → 0.3.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 (160) hide show
  1. package/README.md +13 -6
  2. package/constellation/agent/AGENT-GUIDANCE.md +22 -0
  3. package/constellation/component/COMPONENT-EDITABLE.md +9 -0
  4. package/constellation/component/COMPONENT-STATUS-PILL.md +9 -0
  5. package/constellation/component/COMPONENT-SYNC-PILL.md +9 -0
  6. package/constellation/diagram/DIAGRAM-ARCHITECTURE.md +28 -0
  7. package/constellation/doc/DOC-CARD-TYPES.md +36 -0
  8. package/constellation/doc/DOC-CHANGE-TRACKING.md +26 -0
  9. package/constellation/doc/DOC-CONNECTED-REPOS.md +25 -0
  10. package/constellation/doc/DOC-DIAGRAMS.md +22 -0
  11. package/constellation/doc/DOC-FILE-FORMAT.md +54 -0
  12. package/constellation/doc/DOC-LINT-CODES.md +35 -0
  13. package/constellation/doc/DOC-MCP-SERVER.md +59 -0
  14. package/constellation/doc/DOC-MCP-UPGRADES.md +56 -0
  15. package/constellation/external/EXTERNAL-AJV.md +7 -0
  16. package/constellation/external/EXTERNAL-GIT.md +7 -0
  17. package/constellation/external/EXTERNAL-GRAY-MATTER.md +7 -0
  18. package/constellation/external/EXTERNAL-MCP-SDK.md +7 -0
  19. package/constellation/external/EXTERNAL-ZOD.md +7 -0
  20. package/constellation/file/FILE-CLI.md +9 -0
  21. package/constellation/file/FILE-CODE.md +9 -0
  22. package/constellation/file/FILE-EXTRACT.md +9 -0
  23. package/constellation/file/FILE-GIT.md +9 -0
  24. package/constellation/file/FILE-HANDLES.md +9 -0
  25. package/constellation/file/FILE-INDEXER.md +11 -0
  26. package/constellation/file/FILE-LINT.md +9 -0
  27. package/constellation/file/FILE-MCP-SEARCH.md +12 -0
  28. package/constellation/file/FILE-MCP-SERVER.md +9 -0
  29. package/constellation/file/FILE-PARSE.md +9 -0
  30. package/constellation/file/FILE-RENAME.md +18 -0
  31. package/constellation/file/FILE-REPOS.md +9 -0
  32. package/constellation/file/FILE-RESOLVE.md +9 -0
  33. package/constellation/file/FILE-SCAFFOLD.md +12 -0
  34. package/constellation/file/FILE-SERVE.md +9 -0
  35. package/constellation/file/FILE-SYNC.md +9 -0
  36. package/constellation/file/FILE-TYPES.md +9 -0
  37. package/constellation/file/FILE-VALIDATE.md +11 -0
  38. package/constellation/file/FILE-WRITER.md +11 -0
  39. package/constellation/flow/FLOW-DRIFT-DETECT.md +11 -0
  40. package/constellation/flow/FLOW-LINT-PIPELINE.md +14 -0
  41. package/constellation/flow/FLOW-SYNC-PLAN.md +13 -0
  42. package/constellation/page/PAGE-VIEWER-CARD.md +9 -0
  43. package/constellation/page/PAGE-VIEWER-HOME.md +9 -0
  44. package/constellation/plan.md +59 -0
  45. package/constellation/test/TEST-SUITE.md +9 -0
  46. package/dist/cli/index.js +37 -0
  47. package/dist/cli/index.js.map +1 -1
  48. package/dist/core/code.d.ts +48 -0
  49. package/dist/core/code.js +184 -0
  50. package/dist/core/code.js.map +1 -0
  51. package/dist/core/git.d.ts +10 -0
  52. package/dist/core/git.js +42 -4
  53. package/dist/core/git.js.map +1 -1
  54. package/dist/core/rename.d.ts +24 -0
  55. package/dist/core/rename.js +72 -0
  56. package/dist/core/rename.js.map +1 -0
  57. package/dist/core/repos.d.ts +2 -0
  58. package/dist/core/repos.js +5 -1
  59. package/dist/core/repos.js.map +1 -1
  60. package/dist/core/sync.d.ts +3 -0
  61. package/dist/core/sync.js +21 -9
  62. package/dist/core/sync.js.map +1 -1
  63. package/dist/core/validate.js +27 -3
  64. package/dist/core/validate.js.map +1 -1
  65. package/dist/core/writer.d.ts +54 -0
  66. package/dist/core/writer.js +163 -4
  67. package/dist/core/writer.js.map +1 -1
  68. package/dist/mcp/search.d.ts +2 -2
  69. package/dist/mcp/search.js +27 -5
  70. package/dist/mcp/search.js.map +1 -1
  71. package/dist/mcp/server.js +726 -63
  72. package/dist/mcp/server.js.map +1 -1
  73. package/dist/serve/server.js +6 -7
  74. package/dist/serve/server.js.map +1 -1
  75. package/examples/constellation/datatype/DATATYPE-TICKET.md +5 -0
  76. package/package.json +5 -3
  77. package/schemas/card.json +46 -2
  78. package/skill/SKILL.md +42 -9
  79. package/skill/methodology.md +11 -4
  80. package/viewer/dist/assets/{arc-CMMRgIq9.js → arc-ByV7p1fN.js} +1 -1
  81. package/viewer/dist/assets/architecture-7EHR7CIX-FCXXAu3O.js +1 -0
  82. package/viewer/dist/assets/{architectureDiagram-3BPJPVTR-CB5p_qlN.js → architectureDiagram-3BPJPVTR-CMawC2nt.js} +1 -1
  83. package/viewer/dist/assets/{blockDiagram-GPEHLZMM-DpnMj_e6.js → blockDiagram-GPEHLZMM-Bp0MKYXV.js} +1 -1
  84. package/viewer/dist/assets/{c4Diagram-AAUBKEIU-f91ngVN8.js → c4Diagram-AAUBKEIU-BLCX9gmN.js} +1 -1
  85. package/viewer/dist/assets/channel-GJMBi5UK.js +1 -0
  86. package/viewer/dist/assets/{chunk-2J33WTMH-y7Pz5tMO.js → chunk-2J33WTMH-C6iOidst.js} +1 -1
  87. package/viewer/dist/assets/{chunk-3OPIFGDE-Ir_ghIUQ.js → chunk-3OPIFGDE-B9xItVw4.js} +1 -1
  88. package/viewer/dist/assets/{chunk-4BX2VUAB-BTPyM1ux.js → chunk-4BX2VUAB-Bl0HWb9B.js} +1 -1
  89. package/viewer/dist/assets/{chunk-55IACEB6-CtNd8e4Y.js → chunk-55IACEB6-BYilo1WJ.js} +1 -1
  90. package/viewer/dist/assets/{chunk-5ZQYHXKU-Dd3yz721.js → chunk-5ZQYHXKU-DOCnP8P8.js} +1 -1
  91. package/viewer/dist/assets/{chunk-727SXJPM-CAM8mGbv.js → chunk-727SXJPM-D9YKAzha.js} +1 -1
  92. package/viewer/dist/assets/{chunk-AQP2D5EJ-C3nW9Uve.js → chunk-AQP2D5EJ-B1KE4h_b.js} +1 -1
  93. package/viewer/dist/assets/{chunk-BSJP7CBP-r6MRPvAH.js → chunk-BSJP7CBP-DA3pfXCw.js} +1 -1
  94. package/viewer/dist/assets/{chunk-CSCIHK7Q-Cn-ZTxsS.js → chunk-CSCIHK7Q-Bv0Fa0Zn.js} +6 -6
  95. package/viewer/dist/assets/{chunk-FMBD7UC4-5axXeZJa.js → chunk-FMBD7UC4-k-EzzpU2.js} +1 -1
  96. package/viewer/dist/assets/{chunk-KSCS5N6A-BwmNx0l0.js → chunk-KSCS5N6A-CUTkv0jR.js} +1 -1
  97. package/viewer/dist/assets/{chunk-L5ZTLDWV-C2YNkUjB.js → chunk-L5ZTLDWV-C_SwE6oB.js} +1 -1
  98. package/viewer/dist/assets/{chunk-LZXEDZCA-CznuDgQD.js → chunk-LZXEDZCA-DtwqVIDt.js} +2 -2
  99. package/viewer/dist/assets/{chunk-ND2GUHAM-CokDnghi.js → chunk-ND2GUHAM-JJiAqLG2.js} +1 -1
  100. package/viewer/dist/assets/{chunk-NZK2D7GU-aaU0v4QE.js → chunk-NZK2D7GU-BQ8Dn7FA.js} +1 -1
  101. package/viewer/dist/assets/{chunk-O5CBEL6O-ByfKhkZf.js → chunk-O5CBEL6O-BRpqOJ1I.js} +1 -1
  102. package/viewer/dist/assets/chunk-QZHKN3VN-DA8qsAVF.js +1 -0
  103. package/viewer/dist/assets/chunk-WU5MYG2G-Dp8LMm21.js +1 -0
  104. package/viewer/dist/assets/{chunk-XPW4576I-Amo3awtq.js → chunk-XPW4576I-C1NcqPJd.js} +1 -1
  105. package/viewer/dist/assets/classDiagram-4FO5ZUOK-wnkp0LBB.js +1 -0
  106. package/viewer/dist/assets/classDiagram-v2-Q7XG4LA2-wnkp0LBB.js +1 -0
  107. package/viewer/dist/assets/{cose-bilkent-S5V4N54A-CO8yESCW.js → cose-bilkent-S5V4N54A-BsJFb39B.js} +1 -1
  108. package/viewer/dist/assets/{dagre-BM42HDAG-BnmNVNI2.js → dagre-BM42HDAG-CRPovG4Q.js} +1 -1
  109. package/viewer/dist/assets/{diagram-2AECGRRQ-lcGXcbei.js → diagram-2AECGRRQ-D6GCKn1u.js} +1 -1
  110. package/viewer/dist/assets/{diagram-5GNKFQAL-Cj3-lPUt.js → diagram-5GNKFQAL-CoHqKdf3.js} +1 -1
  111. package/viewer/dist/assets/{diagram-KO2AKTUF-CtXdRI4M.js → diagram-KO2AKTUF-BEkMb40X.js} +1 -1
  112. package/viewer/dist/assets/{diagram-LMA3HP47-CIyLAzX7.js → diagram-LMA3HP47-B6mfW9S2.js} +1 -1
  113. package/viewer/dist/assets/{diagram-OG6HWLK6-BwF4bQCv.js → diagram-OG6HWLK6-OBBkuJvR.js} +1 -1
  114. package/viewer/dist/assets/{dist-DzXuQd4N.js → dist-C4BxOpXa.js} +1 -1
  115. package/viewer/dist/assets/{erDiagram-TEJ5UH35-DjwFw9yq.js → erDiagram-TEJ5UH35-C2i3TB2-.js} +1 -1
  116. package/viewer/dist/assets/eventmodeling-FCH6USID-CCZDiBK0.js +1 -0
  117. package/viewer/dist/assets/{flowDiagram-I6XJVG4X-CzXbf7vQ.js → flowDiagram-I6XJVG4X-SreJFib5.js} +1 -1
  118. package/viewer/dist/assets/{ganttDiagram-6RSMTGT7-BYcIhfmm.js → ganttDiagram-6RSMTGT7-DO7GLSdw.js} +1 -1
  119. package/viewer/dist/assets/{gitGraph-WXDBUCRP-H8XeJ9QB.js → gitGraph-WXDBUCRP-Cgwn_Q5_.js} +1 -1
  120. package/viewer/dist/assets/{gitGraphDiagram-PVQCEYII-DtAPXKFK.js → gitGraphDiagram-PVQCEYII-DLwaMyDa.js} +1 -1
  121. package/viewer/dist/assets/{index-DLPCG_Or.js → index-COz6c_JS.js} +4 -4
  122. package/viewer/dist/assets/{info-J43DQDTF-BQScorZ0.js → info-J43DQDTF-Ci-BiM87.js} +1 -1
  123. package/viewer/dist/assets/{infoDiagram-5YYISTIA-Udl1omvH.js → infoDiagram-5YYISTIA-Bip5dlKM.js} +1 -1
  124. package/viewer/dist/assets/{ishikawaDiagram-YF4QCWOH-VnfzT_hk.js → ishikawaDiagram-YF4QCWOH-VXV_9uEH.js} +1 -1
  125. package/viewer/dist/assets/{journeyDiagram-JHISSGLW-Btn4EVoD.js → journeyDiagram-JHISSGLW-DQz-8gAS.js} +1 -1
  126. package/viewer/dist/assets/{kanban-definition-UN3LZRKU-flOzWhPe.js → kanban-definition-UN3LZRKU-BTWpEcf0.js} +1 -1
  127. package/viewer/dist/assets/{line-DJJ9BLuY.js → line-BnRWAabF.js} +1 -1
  128. package/viewer/dist/assets/{linear-C2SUy5ns.js → linear-BR6v_8SI.js} +1 -1
  129. package/viewer/dist/assets/{mermaid-parser.core-xhSeYiZo.js → mermaid-parser.core-CSsnrQhR.js} +2 -2
  130. package/viewer/dist/assets/{mermaid.core-DmeSIitY.js → mermaid.core-I1kLnpG2.js} +3 -3
  131. package/viewer/dist/assets/{mindmap-definition-RKZ34NQL-CCJcMKx1.js → mindmap-definition-RKZ34NQL-Cq66Ilr1.js} +1 -1
  132. package/viewer/dist/assets/{packet-YPE3B663-DS8RscAk.js → packet-YPE3B663-BjSCEGVd.js} +1 -1
  133. package/viewer/dist/assets/{pie-LRSECV5Y-I2ZQhrNJ.js → pie-LRSECV5Y-Di72yxoZ.js} +1 -1
  134. package/viewer/dist/assets/{pieDiagram-4H26LBE5-BS5HMs4j.js → pieDiagram-4H26LBE5-TmsfKHOO.js} +1 -1
  135. package/viewer/dist/assets/{quadrantDiagram-W4KKPZXB-W4H8cw2_.js → quadrantDiagram-W4KKPZXB-Dp9iSvCk.js} +1 -1
  136. package/viewer/dist/assets/{radar-GUYGQ44K-kf6QvSne.js → radar-GUYGQ44K-CZeWC3Jm.js} +1 -1
  137. package/viewer/dist/assets/{requirementDiagram-4Y6WPE33-DSbuDQGQ.js → requirementDiagram-4Y6WPE33-lbTbiw59.js} +1 -1
  138. package/viewer/dist/assets/{sankeyDiagram-5OEKKPKP-CH8Qr2vL.js → sankeyDiagram-5OEKKPKP-DqmoGDJk.js} +1 -1
  139. package/viewer/dist/assets/{sequenceDiagram-3UESZ5HK-EugVcdad.js → sequenceDiagram-3UESZ5HK-B6_EiWF6.js} +1 -1
  140. package/viewer/dist/assets/{src-CM_9kdrU.js → src-Dz3SjXJY.js} +1 -1
  141. package/viewer/dist/assets/{stateDiagram-AJRCARHV-DiFl94Qy.js → stateDiagram-AJRCARHV-Z6QRASCx.js} +1 -1
  142. package/viewer/dist/assets/stateDiagram-v2-BHNVJYJU-BePyUY0u.js +1 -0
  143. package/viewer/dist/assets/{timeline-definition-PNZ67QCA-B0KW9GUN.js → timeline-definition-PNZ67QCA-XuUZAYAP.js} +1 -1
  144. package/viewer/dist/assets/{treeView-BLDUP644-CAEY4mxq.js → treeView-BLDUP644-DuGsPB97.js} +1 -1
  145. package/viewer/dist/assets/{treemap-LRROVOQU-VVaEaTlU.js → treemap-LRROVOQU-CSJx8IZ0.js} +1 -1
  146. package/viewer/dist/assets/{vennDiagram-CIIHVFJN-BSLGNfJd.js → vennDiagram-CIIHVFJN-BnebDryc.js} +1 -1
  147. package/viewer/dist/assets/{wardley-L42UT6IY-C-5cXbAU.js → wardley-L42UT6IY-DqqoXPSq.js} +1 -1
  148. package/viewer/dist/assets/{wardleyDiagram-YWT4CUSO-COk4TVnQ.js → wardleyDiagram-YWT4CUSO-Bchy871K.js} +1 -1
  149. package/viewer/dist/assets/{xychartDiagram-2RQKCTM6-CClGJLJp.js → xychartDiagram-2RQKCTM6-Bl_zzGZm.js} +1 -1
  150. package/viewer/dist/index.html +1 -1
  151. package/docs/001-file-format.md +0 -247
  152. package/docs/002-mcp.md +0 -158
  153. package/viewer/dist/assets/architecture-7EHR7CIX-Cbb-syEI.js +0 -1
  154. package/viewer/dist/assets/channel-C2bkEQHQ.js +0 -1
  155. package/viewer/dist/assets/chunk-QZHKN3VN-DJRjGeIo.js +0 -1
  156. package/viewer/dist/assets/chunk-WU5MYG2G-C63BVvr_.js +0 -1
  157. package/viewer/dist/assets/classDiagram-4FO5ZUOK-z_bO1V-j.js +0 -1
  158. package/viewer/dist/assets/classDiagram-v2-Q7XG4LA2-z_bO1V-j.js +0 -1
  159. package/viewer/dist/assets/eventmodeling-FCH6USID-BlKFmSzA.js +0 -1
  160. package/viewer/dist/assets/stateDiagram-v2-BHNVJYJU-BlJV8F13.js +0 -1
@@ -1,4 +1,4 @@
1
- import { readFile, rm } from 'node:fs/promises';
1
+ import { readFile, rm, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
@@ -9,12 +9,29 @@ 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 '../core/git.js';
12
+ import { changedFilesSince, diffPlan, headSha, planDirty, planLog, readSyncPoint, resolveCommit, writeSyncPoint, } from '../core/git.js';
13
+ import { computeSyncStatus } from '../core/sync.js';
14
+ import { boundPathsForCard, resolveCodeForCard } from '../core/code.js';
13
15
  import { searchCards } from './search.js';
14
- import { applyCardPatch, createCardFile, deepMerge, relPathForHandle, reservedFieldKeys, updateCardFile, } from '../core/writer.js';
15
- import { connectedRepoToFm, listConnectedRepos, readConnectedRepos, removeConnectedRepoEntry, resolveConnectedRepo, upsertConnectedRepo, } from '../core/repos.js';
16
+ import { applyCardPatch, bodyHeadingTexts, createCardFile, deepMerge, mutateCardFile, relPathForHandle, replaceBodySection, reservedFieldKeys, withAppendedNote, } from '../core/writer.js';
17
+ import { renameCard, RenameCardError } from '../core/rename.js';
18
+ import { connectedRepoToFm, connectedReposFromFrontmatter, listConnectedRepos, readConnectedRepos, removeConnectedRepoEntry, resolveConnectedRepo, upsertConnectedRepo, } from '../core/repos.js';
16
19
  const INSTRUCTIONS = `# Constellation MCP
17
20
 
21
+ Constellation is this project's durable, cross-session memory for AI agents — treat it as
22
+ memory you share with every past and future agent, not docs you skim. Before changing code
23
+ an area's cards cover, READ those cards: you're recovering prior agents' understanding, not
24
+ starting fresh. After changing that code, bring the cards back into line — that's part of
25
+ "done," like updating tests. The payoff is that understanding COMPOUNDS across sessions
26
+ instead of being re-derived from scratch each time; that only holds if you keep the cards
27
+ true. A card you can't trust is worse than no card.
28
+
29
+ Put in cards what the code can't say — intent, decisions (and the alternatives you rejected),
30
+ current built/live state, gotchas, cross-cutting rules. Do NOT duplicate what the repo already
31
+ holds — DDL, signatures, code: link to it instead; copies drift. "built"/"verified" is a
32
+ claim, not a fact — stamp it with set_verified so a later agent can re-check whether the bound
33
+ code moved (stale_report / check_sync). Durability, not distrust.
34
+
18
35
  The project's architecture plan lives as markdown files in a constellation/ folder.
19
36
  Each file is a **card** (the filename is the handle: api/API-TICKETS.md = API-TICKETS);
20
37
  cards are linked by undirected **connections** derived from the connections: frontmatter
@@ -22,11 +39,25 @@ list, handle-shaped frontmatter values, [[HANDLE]] body links, and mermaid node
22
39
 
23
40
  Retrieval is hydrated: get_card / search / traverse can return connected cards with
24
41
  their FULL frontmatter and body in one call (connected: "full"). Use that when you are
25
- about to work on an area; use "summary" for orientation.
42
+ about to work on an area; use "summary" for orientation. get_card can also hand back the
43
+ CODE a card is bound to — code: "paths" returns the resolved file paths of its connected
44
+ FILE cards (path:) plus its own code_refs; code: "direct" attaches their contents (over-cap
45
+ files truncated, binaries/lockfiles/generated skipped) so a background coder starts from
46
+ intent + current code in one call. assemble turns a delta (or a handle set) into a work package: the changed
47
+ cards + their neighborhood (full) + bound code + a heuristic build order + FILE-DISJOINT
48
+ units you can fan out one sub-agent per, with no two touching the same file.
26
49
 
27
50
  Writes are validated: every write tool lints and returns issues for the file it touched.
28
51
  update_card patch.fields deep-merges (arrays replace, null deletes); body replaces.
29
- Body-only updates never reformat frontmatter.
52
+ Body-only updates never reformat frontmatter. Prefer SMALL, cheap writes over rewriting a
53
+ whole card — make the honest update the easy one: append_note adds an append-only typed note
54
+ (decision / gotcha / state / deviation / verified) with no full-body rewrite; edit_section
55
+ replaces a single ## section in place. Reach for these to record a correction the moment you
56
+ learn it, so cards stay true instead of drifting. Notes are retrievable memory: search
57
+ matches note text, and list_notes lists them across cards by kind — every gotcha or decision
58
+ in one call. To rename a handle, use rename_card: it moves the file and rewrites every
59
+ reference plan-wide (connections, frontmatter values, [[links]], mermaid node IDs) as whole
60
+ tokens — never delete-and-recreate to rename.
30
61
 
31
62
  describe_type is the type reference, served by this server: call it with no args for the
32
63
  catalog of all 17 card types, or with a type (e.g. describe_type PAGE) for that type's
@@ -34,7 +65,13 @@ frontmatter schema + a golden example. Consult it before authoring a type you ha
34
65
  this session — you don't need the authoring skill loaded to get the fields right.
35
66
 
36
67
  Change tracking is git: diff_plan reports per-card changes since the sync marker (or HEAD).
37
- Never stamp dirty flags into cards.
68
+ Never stamp dirty flags or changelogs into cards — "what changed" is git's job. The one
69
+ recorded baseline that IS allowed is verification provenance: set_verified stamps verified_sha
70
+ (the git sha you checked a card against) + verified_at. That is the basis of a claim, not a
71
+ change flag — and the staleness VERDICT is always recomputed live (stale_report / check_sync),
72
+ never stored. stale_report lists built/verified cards whose bound code changed since their
73
+ verified_sha (reverse drift); check_sync rolls that per-card drift plus the plan-global state
74
+ into one definition-of-done verdict (advisory — the server reports, it can't block).
38
75
 
39
76
  "Sync the plan" / "sync the plan to the code" = bring the CODE up to match the plan (the
40
77
  plan is the source of truth — behavior changes in the plan FIRST, then in code, never the
@@ -62,9 +99,10 @@ changes as the proposal; on approval, bring the CODE up to match via the sync lo
62
99
  FINISH by reconciling — re-read the touched cards against the code, run check_integrity so
63
100
  no affected card is left an orphan and every connection is set, bump status (planned →
64
101
  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
102
+ unavailable by design (the read tools — get_card, list_cards, list_notes, search, traverse,
103
+ assemble, describe_type, check_integrity, diff_plan, plan_log, stale_report, check_sync,
104
+ list_connected_repos — are marked read-only and stay available), so spend plan mode READING:
105
+ pull in as much of the relevant plan as you can
68
106
  (traverse from the entry points, connected: "full") to build a strong model of the project
69
107
  fast, fold the intended card edits into the plan you present, and write them to
70
108
  Constellation first, before any code, once the user approves.
@@ -73,7 +111,11 @@ For migrations or large scaffolds, use create_cards and add_connections (batched
73
111
  lint pass) instead of many single calls — connections between cards in the same batch
74
112
  resolve without transient "does not resolve" errors. A card is created even when issues
75
113
  are returned (issues are lint state, not failure). check_integrity reports orphans
76
- (zero-connection cards), and list_cards connected:false lists them.
114
+ (zero-connection cards), and list_cards connected:false lists them. The BACKLOG view —
115
+ everything not yet built — is list_cards status: ["planned", "building", "none"] ("none" =
116
+ cards with no status set, which usually means unbuilt). traverse takes the same status
117
+ filter as a post-filter: the walk still passes through built cards, so a built hub never
118
+ hides the planned work behind it.
77
119
 
78
120
  The plan folder is found by walking up from the working directory, BOUNDED by the repo
79
121
  root (it never adopts another repo's plan). If no plan exists in this repo (tools return
@@ -231,15 +273,165 @@ function connectedCards(index, handle, detail) {
231
273
  function issuesForFile(issues, relPath) {
232
274
  return issues.filter((i) => i.file === relPath);
233
275
  }
276
+ /**
277
+ * Code-side drift: for every card that makes a claim about code it is bound to
278
+ * (status built/verified, or carrying a verified_sha) compare its bound files
279
+ * against its baseline (its own verified_sha, else the passed base, else the
280
+ * sync marker). A card whose bound code changed — or whose bound file vanished —
281
+ * since it was verified is stale. The verdict is computed live and never stored.
282
+ * Shared by stale_report and check_sync.
283
+ */
284
+ async function computeStaleCards(root, index, base) {
285
+ let marker = null;
286
+ try {
287
+ marker = (await readSyncPoint(root))?.synced_sha ?? null;
288
+ }
289
+ catch {
290
+ marker = null;
291
+ }
292
+ const fallback = base ?? marker ?? null;
293
+ const claims = [];
294
+ for (const card of index.cards.values()) {
295
+ const verifiedSha = typeof card.frontmatter.verified_sha === 'string'
296
+ ? card.frontmatter.verified_sha
297
+ : undefined;
298
+ const isClaim = card.status === 'built' || card.status === 'verified' || Boolean(verifiedSha);
299
+ if (!isClaim)
300
+ continue;
301
+ if (boundPathsForCard(index, card).length === 0)
302
+ continue;
303
+ const resolved = await resolveCodeForCard(root, index, card, 'paths');
304
+ claims.push({
305
+ card,
306
+ baseline: verifiedSha ?? fallback,
307
+ baseline_source: verifiedSha ? 'verified_sha' : base ? 'argument' : 'sync-marker',
308
+ paths: resolved.files.map((f) => f.path),
309
+ missing: resolved.files.filter((f) => !f.exists).map((f) => f.path),
310
+ });
311
+ }
312
+ // Pass 2: one git call per DISTINCT baseline (usually just the sync marker),
313
+ // not one per card — a plan with 100 verified cards must not spawn 100 diffs.
314
+ const changedBy = new Map();
315
+ const baselines = new Set(claims.map((c) => c.baseline).filter((b) => Boolean(b)));
316
+ for (const baseline of baselines) {
317
+ const union = [
318
+ ...new Set(claims.filter((c) => c.baseline === baseline).flatMap((c) => c.paths)),
319
+ ];
320
+ try {
321
+ changedBy.set(baseline, await changedFilesSince(root, baseline, union));
322
+ }
323
+ catch {
324
+ changedBy.set(baseline, 'unreachable');
325
+ }
326
+ }
327
+ const stale = [];
328
+ const noBaseline = [];
329
+ for (const { card, baseline, baseline_source, paths, missing } of claims) {
330
+ if (!baseline) {
331
+ noBaseline.push({ handle: card.handle, status: card.status ?? null, files: paths });
332
+ continue;
333
+ }
334
+ const changed = changedBy.get(baseline);
335
+ if (changed === 'unreachable') {
336
+ noBaseline.push({
337
+ handle: card.handle,
338
+ status: card.status ?? null,
339
+ files: paths,
340
+ reason: `baseline ${baseline.slice(0, 8)} unreachable in git history`,
341
+ });
342
+ continue;
343
+ }
344
+ const changedFiles = paths.filter((p) => changed.has(p));
345
+ if (changedFiles.length > 0 || missing.length > 0) {
346
+ stale.push({
347
+ handle: card.handle,
348
+ name: card.name ?? null,
349
+ status: card.status ?? null,
350
+ baseline: baseline.slice(0, 12),
351
+ baseline_source,
352
+ changed_files: changedFiles,
353
+ missing_files: missing,
354
+ });
355
+ }
356
+ }
357
+ return { checked: claims.length, stale, no_baseline: noBaseline };
358
+ }
359
+ // Rough dependency tiers for assemble's suggested build order: data first, then
360
+ // contracts, then surfaces. Connections are undirected, so this is a heuristic
361
+ // by type, not a true topological sort.
362
+ const TYPE_TIER = {
363
+ DB: 0, DATATYPE: 1, ROLE: 2, EXTERNAL: 2, EVENT: 2,
364
+ API: 3, JOB: 3, STATE: 4, FLOW: 4, COMPONENT: 5, PAGE: 6,
365
+ FILE: 7, TEST: 8, DOC: 9, DIAGRAM: 9, AGENT: 9, PLAN: 9,
366
+ };
367
+ const tierOf = (type) => TYPE_TIER[type] ?? 5;
368
+ /**
369
+ * Partition seed handles into groups whose bound file sets are disjoint, so each
370
+ * group can be handed to its own sub-agent with no risk of two agents editing
371
+ * the same file. Seeds that share any bound file land in the same group.
372
+ */
373
+ function partitionByFiles(seeds, filesBy) {
374
+ const parent = new Map();
375
+ seeds.forEach((s) => parent.set(s, s));
376
+ const find = (x) => {
377
+ let root = x;
378
+ while (parent.get(root) !== root)
379
+ root = parent.get(root);
380
+ while (parent.get(x) !== root) {
381
+ const next = parent.get(x);
382
+ parent.set(x, root);
383
+ x = next;
384
+ }
385
+ return root;
386
+ };
387
+ const union = (a, b) => parent.set(find(a), find(b));
388
+ const fileOwner = new Map();
389
+ for (const s of seeds) {
390
+ for (const f of filesBy.get(s) ?? []) {
391
+ const owner = fileOwner.get(f);
392
+ if (owner)
393
+ union(owner, s);
394
+ else
395
+ fileOwner.set(f, s);
396
+ }
397
+ }
398
+ const groups = new Map();
399
+ for (const s of seeds) {
400
+ const r = find(s);
401
+ if (!groups.has(r))
402
+ groups.set(r, { handles: [], files: new Set() });
403
+ const g = groups.get(r);
404
+ g.handles.push(s);
405
+ for (const f of filesBy.get(s) ?? [])
406
+ g.files.add(f);
407
+ }
408
+ return [...groups.values()].map((g) => ({
409
+ handles: g.handles.sort(),
410
+ files: [...g.files].sort(),
411
+ }));
412
+ }
234
413
  const detailSchema = z.enum(['none', 'summary', 'full']);
235
414
  const typeSchema = z.enum(TYPE_NAMES);
236
415
  const statusSchema = z.enum(['planned', 'building', 'built', 'verified']);
416
+ // Filter variant: "none" selects cards with no status at all — unset usually
417
+ // means nobody has claimed the card is built, so backlog queries want it.
418
+ const statusFilterSchema = z.enum(['planned', 'building', 'built', 'verified', 'none']);
419
+ const statusesSchema = z
420
+ .union([statusFilterSchema, z.array(statusFilterSchema).min(1)])
421
+ .optional();
422
+ function statusSetOf(status) {
423
+ if (status === undefined)
424
+ return null;
425
+ return new Set(Array.isArray(status) ? status : [status]);
426
+ }
427
+ const noteKindSchema = z.enum(['decision', 'gotcha', 'state', 'deviation', 'verified']);
428
+ const codeModeSchema = z.enum(['none', 'paths', 'direct']);
237
429
  const repoSchema = z
238
430
  .string()
239
431
  .optional()
240
432
  .describe('Target a connected repo by its connected_repos name (or a path). Omit to use the current repo.');
241
433
  export function buildServer(options = {}) {
242
- const server = new McpServer({ name: 'constellation', version: '0.2.1' }, { instructions: INSTRUCTIONS });
434
+ const server = new McpServer({ name: 'constellation', version: '0.2.2' }, { instructions: INSTRUCTIONS });
243
435
  server.registerPrompt('bootstrap_plan', {
244
436
  title: 'Bootstrap a plan from the codebase',
245
437
  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.',
@@ -349,29 +541,55 @@ export function buildServer(options = {}) {
349
541
  });
350
542
  server.registerTool('get_card', {
351
543
  annotations: { readOnlyHint: true },
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.',
544
+ 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. code: "paths" returns the resolved file paths the card is bound to (connected FILE cards plus code_refs); code: "direct" attaches their contents (over-cap files truncated with truncated:true; binaries/lockfiles/generated skipped) so a background coder starts from intent + current code in one call.',
353
545
  inputSchema: {
354
546
  handle: z.string(),
355
547
  connected: detailSchema.optional().describe('default: summary'),
548
+ code: codeModeSchema
549
+ .optional()
550
+ .describe('attach bound code: none (default) | paths | direct'),
551
+ notes_kind: noteKindSchema
552
+ .optional()
553
+ .describe('filter the returned card.notes to one kind'),
554
+ notes_limit: z
555
+ .number()
556
+ .int()
557
+ .min(1)
558
+ .max(100)
559
+ .optional()
560
+ .describe('return only the most recent N notes'),
356
561
  repo: repoSchema,
357
562
  },
358
- }, withPlan(async (root, { handle, connected }) => {
563
+ }, withPlan(async (root, { handle, connected, code, notes_kind, notes_limit }) => {
359
564
  const index = await loadPlan(root);
360
565
  const card = index.cards.get(handle.toUpperCase());
361
566
  if (!card)
362
567
  return fail('NOT_FOUND', `No card with handle ${handle}`);
363
- return ok({
364
- card: full(card),
568
+ let cardView = full(card);
569
+ if ((notes_kind || notes_limit) && Array.isArray(card.frontmatter.notes)) {
570
+ let notes = card.frontmatter.notes.filter((n) => Boolean(n) && typeof n === 'object');
571
+ if (notes_kind)
572
+ notes = notes.filter((n) => n.kind === notes_kind);
573
+ if (notes_limit)
574
+ notes = notes.slice(-notes_limit);
575
+ cardView = { ...cardView, frontmatter: { ...card.frontmatter, notes } };
576
+ }
577
+ const result = {
578
+ card: cardView,
365
579
  connected_cards: connectedCards(index, card.handle, connected ?? 'summary'),
366
- });
580
+ };
581
+ if (code && code !== 'none') {
582
+ result.code = await resolveCodeForCard(root, index, card, code);
583
+ }
584
+ return ok(result);
367
585
  }));
368
586
  server.registerTool('list_cards', {
369
587
  annotations: { readOnlyHint: true },
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).',
588
+ description: 'Catalog of cards filtered by type, kind, status, and/or connectedness. status takes one value or a list; "none" selects cards with no status at all — status: ["planned", "building", "none"] is the backlog view (everything not yet built). connected:false returns orphans (cards with zero connections). Returns summaries (handle, type, kind, name, status).',
371
589
  inputSchema: {
372
590
  types: z.array(typeSchema).optional(),
373
591
  kind: z.string().optional(),
374
- status: statusSchema.optional(),
592
+ status: statusesSchema.describe('one status or a list; "none" = cards with no status set'),
375
593
  connected: z
376
594
  .boolean()
377
595
  .optional()
@@ -382,11 +600,12 @@ export function buildServer(options = {}) {
382
600
  }, withPlan(async (root, { types, kind, status, connected, limit }) => {
383
601
  const index = await loadPlan(root);
384
602
  const typeFilter = types && types.length > 0 ? new Set(types) : null;
603
+ const statusFilter = statusSetOf(status);
385
604
  const isConnected = (h) => (index.connectedHandles.get(h)?.size ?? 0) > 0;
386
605
  const matched = [...index.cards.values()]
387
606
  .filter((c) => !typeFilter || typeFilter.has(c.type))
388
607
  .filter((c) => !kind || c.kind === kind)
389
- .filter((c) => !status || c.status === status)
608
+ .filter((c) => !statusFilter || statusFilter.has(c.status ?? 'none'))
390
609
  .filter((c) => connected === undefined || isConnected(c.handle) === connected)
391
610
  .sort((a, b) => a.handle.localeCompare(b.handle));
392
611
  return ok({
@@ -418,15 +637,16 @@ export function buildServer(options = {}) {
418
637
  }));
419
638
  server.registerTool('traverse', {
420
639
  annotations: { readOnlyHint: true },
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.',
640
+ 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. status filters the RESULT only — the walk still passes through non-matching cards, so a built hub never hides the planned work behind it (status: ["planned", "building", "none"] = open work in this neighborhood). types, by contrast, prunes the walk itself.',
422
641
  inputSchema: {
423
642
  start: z.union([z.string(), z.array(z.string()).min(1)]),
424
643
  depth: z.number().int().min(0).max(5).optional().describe('default: 2'),
425
644
  types: z.array(typeSchema).optional(),
645
+ status: statusesSchema.describe('post-filter on returned cards; "none" = no status set. The walk passes through non-matching cards.'),
426
646
  detail: z.enum(['summary', 'full']).optional().describe('default: summary'),
427
647
  repo: repoSchema,
428
648
  },
429
- }, withPlan(async (root, { start, depth, types, detail }) => {
649
+ }, withPlan(async (root, { start, depth, types, status, detail }) => {
430
650
  const index = await loadPlan(root);
431
651
  const starts = (Array.isArray(start) ? start : [start]).map((s) => s.toUpperCase());
432
652
  const missing = starts.filter((s) => !index.cards.has(s));
@@ -456,7 +676,12 @@ export function buildServer(options = {}) {
456
676
  }
457
677
  frontier = next;
458
678
  }
679
+ // Status is a post-filter: the walk above passed THROUGH every card, so
680
+ // a built hub in the middle never hides open work behind it.
681
+ const statusFilter = statusSetOf(status);
459
682
  const cards = [...distance.entries()]
683
+ .filter(([handle]) => !statusFilter ||
684
+ statusFilter.has(index.cards.get(handle).status ?? 'none'))
460
685
  .map(([handle, dist]) => {
461
686
  const card = index.cards.get(handle);
462
687
  return {
@@ -465,9 +690,121 @@ export function buildServer(options = {}) {
465
690
  };
466
691
  })
467
692
  .sort((a, b) => a.distance - b.distance || a.handle.localeCompare(b.handle));
468
- const connections = index.connections.filter((c) => distance.has(c.a) && distance.has(c.b));
693
+ const surviving = new Set(cards.map((c) => c.handle));
694
+ const connections = index.connections.filter((c) => surviving.has(c.a) && surviving.has(c.b));
469
695
  return ok({ cards, connections, not_found: missing });
470
696
  }));
697
+ server.registerTool('assemble', {
698
+ annotations: { readOnlyHint: true },
699
+ description: 'Turn a set of cards (or the plan delta since a base) into a ready-to-work package: the seed cards plus their connected neighborhood (full), the code each is bound to (code: paths|direct), a heuristic build order (data → contracts → surfaces), and — the key bit — the seeds split into FILE-DISJOINT units so you can hand one sub-agent per unit with no risk of two agents editing the same file. Omit handles to assemble everything changed since base (default: the sync marker). This is the orchestration bridge: diff_plan + traverse + code attach in one call.',
700
+ inputSchema: {
701
+ handles: z
702
+ .array(z.string())
703
+ .optional()
704
+ .describe('seed handles; omit to use the plan delta since base'),
705
+ base: z
706
+ .string()
707
+ .optional()
708
+ .describe('base for the delta when handles is omitted (default: sync marker)'),
709
+ depth: z
710
+ .number()
711
+ .int()
712
+ .min(0)
713
+ .max(3)
714
+ .optional()
715
+ .describe('neighborhood depth around each seed (default: 1)'),
716
+ code: codeModeSchema.optional().describe('attach bound code per card (default: paths)'),
717
+ repo: repoSchema,
718
+ },
719
+ }, withPlan(async (root, { handles, base, depth, code }) => {
720
+ const index = await loadPlan(root);
721
+ let seeds;
722
+ let notFound = [];
723
+ let delta = null;
724
+ if (handles && handles.length > 0) {
725
+ const requested = [...new Set(handles.map((h) => h.toUpperCase()))];
726
+ seeds = requested.filter((h) => index.cards.has(h));
727
+ notFound = requested.filter((h) => !index.cards.has(h));
728
+ }
729
+ else {
730
+ let diff;
731
+ try {
732
+ diff = await diffPlan(root, base);
733
+ }
734
+ catch (err) {
735
+ return fail('BAD_BASE', `Could not diff against base ${base ?? '(marker/HEAD)'}: ${err instanceof Error ? err.message : String(err)}. Pass a reachable base sha, or omit it to use the sync marker.`);
736
+ }
737
+ delta = { base: diff.base, base_source: diff.base_source };
738
+ seeds = [...new Set(diff.changes.map((c) => c.handle))].filter((h) => index.cards.has(h));
739
+ }
740
+ if (seeds.length === 0) {
741
+ return ok({
742
+ base: delta,
743
+ seeds: [],
744
+ not_found: notFound,
745
+ units: [],
746
+ note: handles
747
+ ? 'None of the given handles exist in the plan.'
748
+ : 'No plan changes since base — nothing to assemble.',
749
+ });
750
+ }
751
+ const maxDepth = depth ?? 1;
752
+ const distance = new Map();
753
+ let frontier = seeds;
754
+ for (const s of seeds)
755
+ distance.set(s, 0);
756
+ for (let d = 1; d <= maxDepth && frontier.length > 0; d++) {
757
+ const next = [];
758
+ for (const h of frontier) {
759
+ for (const n of index.connectedHandles.get(h) ?? []) {
760
+ if (distance.has(n) || !index.cards.has(n))
761
+ continue;
762
+ distance.set(n, d);
763
+ next.push(n);
764
+ }
765
+ }
766
+ frontier = next;
767
+ }
768
+ const reached = [...distance.keys()];
769
+ const codeMode = code ?? 'paths';
770
+ const filesBy = new Map();
771
+ for (const s of seeds) {
772
+ filesBy.set(s, boundPathsForCard(index, index.cards.get(s)).map((b) => b.path));
773
+ }
774
+ const partitions = partitionByFiles(seeds, filesBy);
775
+ const suggestedOrder = [...reached].sort((a, b) => tierOf(index.cards.get(a).type) - tierOf(index.cards.get(b).type) ||
776
+ a.localeCompare(b));
777
+ const units = [];
778
+ for (const part of partitions) {
779
+ const cards = [];
780
+ for (const h of part.handles) {
781
+ const card = index.cards.get(h);
782
+ const entry = {
783
+ ...full(card),
784
+ connected_cards: connectedCards(index, h, 'full'),
785
+ };
786
+ if (codeMode !== 'none') {
787
+ entry.code = await resolveCodeForCard(root, index, card, codeMode);
788
+ }
789
+ cards.push(entry);
790
+ }
791
+ units.push({ handles: part.handles, files: part.files, cards });
792
+ }
793
+ return ok({
794
+ base: delta,
795
+ seeds,
796
+ not_found: notFound,
797
+ reached_handles: reached,
798
+ suggested_order: suggestedOrder,
799
+ units,
800
+ fanout: {
801
+ unit_count: units.length,
802
+ note: units.length > 1
803
+ ? `${units.length} file-disjoint units — assign one sub-agent each; no two share a bound file. Still assign each card to exactly one agent.`
804
+ : 'One unit — the seeds share bound files (or have none); do not split across agents.',
805
+ },
806
+ });
807
+ }));
471
808
  server.registerTool('describe_type', {
472
809
  annotations: { readOnlyHint: true },
473
810
  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.',
@@ -673,12 +1010,17 @@ export function buildServer(options = {}) {
673
1010
  const touched = new Set();
674
1011
  for (const [src, targets] of additions) {
675
1012
  const card = index.cards.get(src);
676
- const existingList = Array.isArray(card.frontmatter.connections)
677
- ? card.frontmatter.connections.filter((c) => typeof c === 'string')
678
- : [];
679
- const merged = [...new Set([...existingList, ...targets])];
680
- const frontmatter = applyCardPatch(card.frontmatter, { connections: merged });
681
- await updateCardFile(card.filePath, { frontmatter });
1013
+ // Merge against the file's CURRENT list inside the lock, not the index
1014
+ // snapshot a concurrent write must not be clobbered.
1015
+ await mutateCardFile(card.filePath, (current) => {
1016
+ const existingList = Array.isArray(current.frontmatter.connections)
1017
+ ? current.frontmatter.connections.filter((c) => typeof c === 'string')
1018
+ : [];
1019
+ const merged = [...new Set([...existingList, ...targets])];
1020
+ return {
1021
+ frontmatter: applyCardPatch(current.frontmatter, { connections: merged }),
1022
+ };
1023
+ });
682
1024
  touched.add(card.relPath);
683
1025
  added += targets.size;
684
1026
  }
@@ -699,9 +1041,13 @@ export function buildServer(options = {}) {
699
1041
  })
700
1042
  .optional(),
701
1043
  body: z.string().optional(),
1044
+ if_mtime: z
1045
+ .number()
1046
+ .optional()
1047
+ .describe('optional stale-write guard: current rounded file mtime from a client-side stat or viewer payload'),
702
1048
  repo: repoSchema,
703
1049
  },
704
- }, withPlan(async (root, { handle, patch, body }) => {
1050
+ }, withPlan(async (root, { handle, patch, body, if_mtime }) => {
705
1051
  const index = await loadPlan(root);
706
1052
  const card = index.cards.get(handle.toUpperCase());
707
1053
  if (!card)
@@ -713,14 +1059,229 @@ export function buildServer(options = {}) {
713
1059
  if (reserved.length > 0) {
714
1060
  return fail('INVALID_FIELDS', `fields cannot contain reserved keys: ${reserved.join(', ')}`);
715
1061
  }
716
- const frontmatter = patch
717
- ? applyCardPatch(card.frontmatter, patch)
718
- : undefined;
719
- await updateCardFile(card.filePath, { frontmatter, body });
1062
+ if (typeof if_mtime === 'number' && if_mtime !== 0) {
1063
+ const current = Math.round((await stat(card.filePath)).mtimeMs);
1064
+ if (current !== if_mtime) {
1065
+ return fail('STALE', `${card.handle} changed on disk`);
1066
+ }
1067
+ }
1068
+ // Apply the patch to the file's CURRENT frontmatter inside the lock —
1069
+ // patch semantics compose with a concurrent write instead of undoing it.
1070
+ await mutateCardFile(card.filePath, (current) => ({
1071
+ frontmatter: patch ? applyCardPatch(current.frontmatter, patch) : undefined,
1072
+ body,
1073
+ }));
1074
+ const lint = await lintPlan(root);
1075
+ const updated = lint.index.cards.get(card.handle);
1076
+ return ok({
1077
+ card: updated ? full(updated) : null,
1078
+ issues: issuesForFile(lint.issues, card.relPath),
1079
+ });
1080
+ }));
1081
+ server.registerTool('append_note', {
1082
+ description: 'Append one typed note to a card\'s memory — append-only, NO full-body rewrite (cheap, so the honest path stays the cheap path). kind: decision (a choice + why) | gotcha (a non-obvious trap) | state (current built/live reality) | deviation (where code intentionally differs from the card) | verified (a verification note). Capture what the code can\'t say; do NOT paste code/DDL/signatures that live in the repo — link to them. Notes are queryable by kind (get_card notes_kind) and ordered newest-last.',
1083
+ inputSchema: {
1084
+ handle: z.string(),
1085
+ kind: noteKindSchema,
1086
+ text: z.string().min(1).max(4000),
1087
+ sha: z
1088
+ .string()
1089
+ .optional()
1090
+ .describe('optional git sha this note was recorded against'),
1091
+ repo: repoSchema,
1092
+ },
1093
+ }, withPlan(async (root, { handle, kind, text, sha }) => {
1094
+ const index = await loadPlan(root);
1095
+ const card = index.cards.get(handle.toUpperCase());
1096
+ if (!card)
1097
+ return fail('NOT_FOUND', `No card with handle ${handle}`);
1098
+ const note = { kind, text };
1099
+ if (sha)
1100
+ note.sha = sha;
1101
+ // Append to the CURRENT notes list inside the lock so two concurrent
1102
+ // appends both land — the memory tool must never lose a note.
1103
+ await mutateCardFile(card.filePath, (current) => ({
1104
+ frontmatter: withAppendedNote(current.frontmatter, note),
1105
+ }));
1106
+ const lint = await lintPlan(root);
1107
+ const updated = lint.index.cards.get(card.handle);
1108
+ return ok({
1109
+ card: updated ? full(updated) : null,
1110
+ note_count: Array.isArray(updated?.frontmatter.notes)
1111
+ ? updated.frontmatter.notes.length
1112
+ : 0,
1113
+ issues: issuesForFile(lint.issues, card.relPath),
1114
+ });
1115
+ }));
1116
+ server.registerTool('list_notes', {
1117
+ annotations: { readOnlyHint: true },
1118
+ description: 'All typed notes across the plan (the memory recorded via append_note), in handle order and newest-last within each card. Filter by kind — "show me every gotcha" / "every decision" in one call — and/or by handles. The cross-card view of the plan\'s memory; for one card\'s notes use get_card notes_kind/notes_limit.',
1119
+ inputSchema: {
1120
+ kind: noteKindSchema.optional().describe('return only notes of this kind'),
1121
+ handles: z
1122
+ .array(z.string())
1123
+ .optional()
1124
+ .describe('restrict to these cards; omit for the whole plan'),
1125
+ limit: z
1126
+ .number()
1127
+ .int()
1128
+ .min(1)
1129
+ .max(500)
1130
+ .optional()
1131
+ .describe('default 200'),
1132
+ repo: repoSchema,
1133
+ },
1134
+ }, withPlan(async (root, { kind, handles, limit }) => {
1135
+ const index = await loadPlan(root);
1136
+ const wanted = handles && handles.length > 0
1137
+ ? new Set(handles.map((h) => h.toUpperCase()))
1138
+ : null;
1139
+ const notes = [];
1140
+ const sorted = [...index.cards.values()].sort((a, b) => a.handle.localeCompare(b.handle));
1141
+ for (const card of sorted) {
1142
+ if (wanted && !wanted.has(card.handle))
1143
+ continue;
1144
+ const list = Array.isArray(card.frontmatter.notes)
1145
+ ? card.frontmatter.notes
1146
+ : [];
1147
+ for (const n of list) {
1148
+ if (!n || typeof n !== 'object')
1149
+ continue;
1150
+ const note = n;
1151
+ if (typeof note.text !== 'string')
1152
+ continue;
1153
+ const noteKind = typeof note.kind === 'string' ? note.kind : 'note';
1154
+ if (kind && noteKind !== kind)
1155
+ continue;
1156
+ notes.push({
1157
+ handle: card.handle,
1158
+ kind: noteKind,
1159
+ text: note.text,
1160
+ ...(typeof note.sha === 'string' ? { sha: note.sha } : {}),
1161
+ });
1162
+ }
1163
+ }
1164
+ return ok({ total: notes.length, notes: notes.slice(0, limit ?? 200) });
1165
+ }));
1166
+ server.registerTool('edit_section', {
1167
+ description: 'Replace the content under one markdown heading in a card\'s body, keeping every other section byte-for-byte — a cheap, surgical alternative to rewriting the whole body. Match the heading by its text (case-insensitive, no #). Errors if no such heading exists (use update_card to set the whole body or add a section).',
1168
+ inputSchema: {
1169
+ handle: z.string(),
1170
+ section: z
1171
+ .string()
1172
+ .describe('heading text to replace under, e.g. "Notes" or "Current state"'),
1173
+ text: z.string().describe('new markdown content for that section (heading kept)'),
1174
+ repo: repoSchema,
1175
+ },
1176
+ }, withPlan(async (root, { handle, section, text }) => {
1177
+ const index = await loadPlan(root);
1178
+ const card = index.cards.get(handle.toUpperCase());
1179
+ if (!card)
1180
+ return fail('NOT_FOUND', `No card with handle ${handle}`);
1181
+ // Replace against the CURRENT body inside the lock — concurrent edits to
1182
+ // different sections compose instead of the later one clobbering.
1183
+ let failure = null;
1184
+ await mutateCardFile(card.filePath, (current) => {
1185
+ const body = replaceBodySection(current.body, section, text);
1186
+ if (body === null) {
1187
+ const headings = bodyHeadingTexts(current.body);
1188
+ const target = section.trim().replace(/^#+\s*/, '').toLowerCase();
1189
+ const matchCount = headings.filter((h) => h.toLowerCase() === target).length;
1190
+ failure =
1191
+ matchCount > 1
1192
+ ? fail('AMBIGUOUS_SECTION', `${card.handle} has ${matchCount} headings called "${section}"; edit_section can't tell them apart. Use update_card to set the whole body.`)
1193
+ : fail('SECTION_NOT_FOUND', `No heading "${section}" in ${card.handle}. Headings present: ${headings.length ? headings.join(', ') : '(none)'}. Use update_card to set the whole body or add the section.`);
1194
+ return null;
1195
+ }
1196
+ return { body };
1197
+ });
1198
+ if (failure)
1199
+ return failure;
1200
+ const lint = await lintPlan(root);
1201
+ const updated = lint.index.cards.get(card.handle);
1202
+ return ok({
1203
+ card: updated ? full(updated) : null,
1204
+ issues: issuesForFile(lint.issues, card.relPath),
1205
+ });
1206
+ }));
1207
+ server.registerTool('set_verified', {
1208
+ description: 'Mark a card verified against the real code: stamp verified_sha (the git sha you checked it at, default HEAD) + verified_at, set status to verified, and optionally append a verified note. The verified_sha is the BASELINE for code-side drift (stale_report / check_sync): later, if the card\'s bound code changed since this sha, the claim is flagged for re-verification. This is durability, not distrust — verify only against code you actually checked.',
1209
+ inputSchema: {
1210
+ handle: z.string(),
1211
+ sha: z
1212
+ .string()
1213
+ .optional()
1214
+ .describe('git sha verified against (default: current HEAD)'),
1215
+ note: z
1216
+ .string()
1217
+ .optional()
1218
+ .describe('optional verification note appended to the card'),
1219
+ repo: repoSchema,
1220
+ },
1221
+ }, withPlan(async (root, { handle, sha, note }) => {
1222
+ const index = await loadPlan(root);
1223
+ const card = index.cards.get(handle.toUpperCase());
1224
+ if (!card)
1225
+ return fail('NOT_FOUND', `No card with handle ${handle}`);
1226
+ const warnings = [];
1227
+ let resolvedSha = sha;
1228
+ if (resolvedSha) {
1229
+ // Normalize to the full commit sha; a typo'd baseline would otherwise
1230
+ // surface only later, as an unreachable baseline in stale_report.
1231
+ try {
1232
+ resolvedSha = await resolveCommit(root, resolvedSha);
1233
+ }
1234
+ catch {
1235
+ warnings.push(`sha ${resolvedSha} does not resolve to a commit in this repo; stamped as given — stale_report will report this card's baseline as unreachable until it does.`);
1236
+ }
1237
+ }
1238
+ else {
1239
+ try {
1240
+ resolvedSha = await headSha(root);
1241
+ }
1242
+ catch {
1243
+ warnings.push('Not a git repo (or no commits): stamped verified_at + status only. Drift detection needs a verified_sha baseline — pass sha or commit first.');
1244
+ }
1245
+ }
1246
+ // A verified_sha is a claim about COMMITTED code. Uncommitted edits to the
1247
+ // bound files are not covered by it — mirror set_sync_point's dirty warning.
1248
+ if (resolvedSha) {
1249
+ try {
1250
+ const bound = boundPathsForCard(index, card).map((b) => b.path);
1251
+ const dirty = await changedFilesSince(root, 'HEAD', bound);
1252
+ const dirtyBound = bound.filter((p) => dirty.has(p));
1253
+ if (dirtyBound.length > 0) {
1254
+ warnings.push(`Bound file(s) have uncommitted changes the baseline does not include: ${dirtyBound.join(', ')}. Commit first, then set_verified.`);
1255
+ }
1256
+ }
1257
+ catch {
1258
+ // Best-effort: no git repo or nothing bound — nothing to warn about.
1259
+ }
1260
+ }
1261
+ const verifiedAt = new Date().toISOString();
1262
+ const fields = { verified_at: verifiedAt };
1263
+ if (resolvedSha)
1264
+ fields.verified_sha = resolvedSha;
1265
+ await mutateCardFile(card.filePath, (current) => {
1266
+ let frontmatter = applyCardPatch(current.frontmatter, {
1267
+ status: 'verified',
1268
+ fields,
1269
+ });
1270
+ if (note) {
1271
+ const n = { kind: 'verified', text: note };
1272
+ if (resolvedSha)
1273
+ n.sha = resolvedSha;
1274
+ frontmatter = withAppendedNote(frontmatter, n);
1275
+ }
1276
+ return { frontmatter };
1277
+ });
720
1278
  const lint = await lintPlan(root);
721
1279
  const updated = lint.index.cards.get(card.handle);
722
1280
  return ok({
723
1281
  card: updated ? full(updated) : null,
1282
+ verified_sha: resolvedSha ?? null,
1283
+ verified_at: verifiedAt,
1284
+ warning: warnings.length > 0 ? warnings.join(' ') : undefined,
724
1285
  issues: issuesForFile(lint.issues, card.relPath),
725
1286
  });
726
1287
  }));
@@ -744,6 +1305,38 @@ export function buildServer(options = {}) {
744
1305
  issues: lint.issues.filter((i) => handleToken.test(i.message)),
745
1306
  });
746
1307
  }));
1308
+ server.registerTool('rename_card', {
1309
+ description: 'Rename a card\'s handle and rewrite every reference to it across the plan — connections lists, handle-shaped frontmatter values, [[links]], mermaid nodes, and prose mentions (whole-token matches only; API-USER never touches API-USERS). The file moves to the new handle\'s path, so a cross-type rename (new prefix) also moves folders and the card\'s fields are then validated against the new type\'s schema. Returns the handles whose references were rewritten plus lint issues for every touched file.',
1310
+ inputSchema: { from: z.string(), to: z.string(), repo: repoSchema },
1311
+ }, withPlan(async (root, args) => {
1312
+ let result;
1313
+ try {
1314
+ result = await renameCard(root, args.from, args.to);
1315
+ }
1316
+ catch (err) {
1317
+ if (err instanceof RenameCardError)
1318
+ return fail(err.code, err.message);
1319
+ throw err;
1320
+ }
1321
+ if (result.noop) {
1322
+ return ok({ renamed: null, note: 'from and to are the same handle — nothing to do.' });
1323
+ }
1324
+ const lint = await lintPlan(root);
1325
+ const touched = new Set([result.file]);
1326
+ for (const h of result.references_updated) {
1327
+ const rel = lint.index.cards.get(h)?.relPath;
1328
+ if (rel)
1329
+ touched.add(rel);
1330
+ }
1331
+ const renamed = lint.index.cards.get(result.to);
1332
+ return ok({
1333
+ renamed: { from: result.from, to: result.to },
1334
+ file: result.file,
1335
+ references_updated: result.references_updated,
1336
+ card: renamed ? full(renamed) : null,
1337
+ issues: lint.issues.filter((i) => touched.has(i.file)),
1338
+ });
1339
+ }));
747
1340
  server.registerTool('add_connection', {
748
1341
  description: 'Connect two cards by appending `to` to `from`’s connections list. No-op if they are already connected through any source.',
749
1342
  inputSchema: { from: z.string(), to: z.string(), repo: repoSchema },
@@ -757,13 +1350,18 @@ export function buildServer(options = {}) {
757
1350
  if (index.connectedHandles.get(from.handle)?.has(to.handle)) {
758
1351
  return ok({ already_connected: true, between: [from.handle, to.handle] });
759
1352
  }
760
- const existing = Array.isArray(from.frontmatter.connections)
761
- ? from.frontmatter.connections
762
- : [];
763
- const frontmatter = deepMerge(from.frontmatter, {
764
- connections: [...existing, to.handle],
1353
+ await mutateCardFile(from.filePath, (current) => {
1354
+ const existing = Array.isArray(current.frontmatter.connections)
1355
+ ? current.frontmatter.connections.filter((c) => typeof c === 'string')
1356
+ : [];
1357
+ if (existing.includes(to.handle))
1358
+ return null; // raced: already added
1359
+ return {
1360
+ frontmatter: deepMerge(current.frontmatter, {
1361
+ connections: [...existing, to.handle],
1362
+ }),
1363
+ };
765
1364
  });
766
- await updateCardFile(from.filePath, { frontmatter });
767
1365
  const lint = await lintPlan(root);
768
1366
  return ok({
769
1367
  connected: [from.handle, to.handle],
@@ -786,17 +1384,24 @@ export function buildServer(options = {}) {
786
1384
  [cardA, cardB.handle],
787
1385
  [cardB, cardA.handle],
788
1386
  ]) {
789
- const list = Array.isArray(card.frontmatter.connections)
790
- ? card.frontmatter.connections
791
- : [];
792
- if (list.includes(other)) {
1387
+ let removed = false;
1388
+ await mutateCardFile(card.filePath, (current) => {
1389
+ // Keep malformed (non-string) entries as-is — lint owns reporting them.
1390
+ const list = Array.isArray(current.frontmatter.connections)
1391
+ ? current.frontmatter.connections
1392
+ : [];
1393
+ if (!list.includes(other))
1394
+ return null;
1395
+ removed = true;
793
1396
  const next = list.filter((h) => h !== other);
794
- const frontmatter = deepMerge(card.frontmatter, {
795
- connections: next.length > 0 ? next : null,
796
- });
797
- await updateCardFile(card.filePath, { frontmatter });
1397
+ return {
1398
+ frontmatter: deepMerge(current.frontmatter, {
1399
+ connections: next.length > 0 ? next : null,
1400
+ }),
1401
+ };
1402
+ });
1403
+ if (removed)
798
1404
  removedFrom.push(card.handle);
799
- }
800
1405
  }
801
1406
  const lint = await lintPlan(root);
802
1407
  const after = lint.index;
@@ -871,11 +1476,15 @@ export function buildServer(options = {}) {
871
1476
  return fail('NO_PLAN_PROJECT', 'No plan.md (PLAN-PROJECT) at the plan root to record connected_repos on.');
872
1477
  }
873
1478
  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) },
1479
+ let next = [];
1480
+ await mutateCardFile(planCard.filePath, (current) => {
1481
+ next = upsertConnectedRepo(connectedReposFromFrontmatter(current.frontmatter), entry);
1482
+ return {
1483
+ frontmatter: applyCardPatch(current.frontmatter, {
1484
+ fields: { connected_repos: next.map(connectedRepoToFm) },
1485
+ }),
1486
+ };
877
1487
  });
878
- await updateCardFile(planCard.filePath, { frontmatter });
879
1488
  let reciprocated;
880
1489
  if (reciprocate) {
881
1490
  const target = await resolveConnectedRepo(root, repoPath);
@@ -899,12 +1508,13 @@ export function buildServer(options = {}) {
899
1508
  path: path.relative(target.repoRoot, homeRepoRoot) || '.',
900
1509
  description: reverse_description,
901
1510
  };
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) },
1511
+ await mutateCardFile(targetPlan.filePath, (current) => ({
1512
+ frontmatter: applyCardPatch(current.frontmatter, {
1513
+ fields: {
1514
+ connected_repos: upsertConnectedRepo(connectedReposFromFrontmatter(current.frontmatter), reverseEntry).map(connectedRepoToFm),
1515
+ },
906
1516
  }),
907
- });
1517
+ }));
908
1518
  reciprocated = {
909
1519
  ok: true,
910
1520
  repo_root: target.repoRoot,
@@ -932,11 +1542,16 @@ export function buildServer(options = {}) {
932
1542
  if (!existing.some((r) => r.name === name)) {
933
1543
  return ok({ removed: false, connected_repos: existing.map(connectedRepoToFm) });
934
1544
  }
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
- }),
1545
+ let next = [];
1546
+ await mutateCardFile(planCard.filePath, (current) => {
1547
+ next = removeConnectedRepoEntry(connectedReposFromFrontmatter(current.frontmatter), name);
1548
+ return {
1549
+ frontmatter: applyCardPatch(current.frontmatter, {
1550
+ fields: {
1551
+ connected_repos: next.length > 0 ? next.map(connectedRepoToFm) : null,
1552
+ },
1553
+ }),
1554
+ };
940
1555
  });
941
1556
  const lint = await lintPlan(root);
942
1557
  return ok({
@@ -1003,6 +1618,54 @@ export function buildServer(options = {}) {
1003
1618
  : undefined,
1004
1619
  });
1005
1620
  }));
1621
+ server.registerTool('stale_report', {
1622
+ annotations: { readOnlyHint: true },
1623
+ description: 'Code-side drift: cards that claim something about code (status built/verified, or carrying a verified_sha) whose BOUND code changed since they were verified. Binding = directly-connected FILE cards (path:) + the card\'s own code_refs. Each card\'s baseline is its verified_sha, else base, else the sync marker. Reports changed_files and vanished missing_files per stale card, plus cards with no baseline to check against. This makes a "built/verified" claim re-verifiable instead of taken on faith. Feed the handles to traverse or assemble.',
1624
+ inputSchema: {
1625
+ base: z
1626
+ .string()
1627
+ .optional()
1628
+ .describe('fallback baseline sha for cards without verified_sha'),
1629
+ repo: repoSchema,
1630
+ },
1631
+ }, withPlan(async (root, { base }) => {
1632
+ const index = await loadPlan(root);
1633
+ const r = await computeStaleCards(root, index, base);
1634
+ return ok({
1635
+ checked: r.checked,
1636
+ stale_count: r.stale.length,
1637
+ stale: r.stale,
1638
+ no_baseline: r.no_baseline,
1639
+ });
1640
+ }));
1641
+ server.registerTool('check_sync', {
1642
+ annotations: { readOnlyHint: true },
1643
+ description: 'Definition-of-done check: one glanceable verdict on whether the plan and code are in sync. Combines the plan-global state (in-sync / drifted / dirty / never-synced) — plan changes and code commits since the marker, lint integrity, status rollup — with the per-card code-side drift from stale_report. Advisory only: the server reports, it cannot block; treat code changed without its bound cards re-verified as "not done yet".',
1644
+ inputSchema: {
1645
+ base: z
1646
+ .string()
1647
+ .optional()
1648
+ .describe('fallback baseline sha for per-card drift (default: sync marker)'),
1649
+ repo: repoSchema,
1650
+ },
1651
+ }, withPlan(async (root, { base }) => {
1652
+ const lint = await lintPlan(root);
1653
+ const status = await computeSyncStatus(root, { lint });
1654
+ const r = await computeStaleCards(root, lint.index, base);
1655
+ return ok({
1656
+ advisory: 'Advisory only — the MCP server reports sync state, it cannot block. Use as a definition-of-done gate before calling work complete.',
1657
+ state: status.state,
1658
+ marker: status.marker,
1659
+ plan_dirty: status.plan_dirty,
1660
+ plan_changes_since_marker: status.plan_changes_since_marker,
1661
+ code_commits_since_marker: status.code_commits_since_marker,
1662
+ integrity: status.integrity,
1663
+ status_rollup: status.status_rollup,
1664
+ total_cards: status.total_cards,
1665
+ stale_cards: r.stale,
1666
+ cards_without_baseline: r.no_baseline,
1667
+ });
1668
+ }));
1006
1669
  server.registerTool('start_viewer', {
1007
1670
  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.',
1008
1671
  inputSchema: {