@magic-spells/constellation 0.2.2 → 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 (159) 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 +4 -2
  49. package/dist/core/code.js +22 -7
  50. package/dist/core/code.js.map +1 -1
  51. package/dist/core/git.d.ts +2 -0
  52. package/dist/core/git.js +6 -0
  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 +2 -0
  61. package/dist/core/sync.js +3 -1
  62. package/dist/core/sync.js.map +1 -1
  63. package/dist/core/validate.js +14 -1
  64. package/dist/core/validate.js.map +1 -1
  65. package/dist/core/writer.d.ts +29 -0
  66. package/dist/core/writer.js +98 -3
  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 +300 -99
  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/package.json +2 -2
  76. package/skill/SKILL.md +11 -5
  77. package/skill/methodology.md +6 -2
  78. package/viewer/dist/assets/{arc-YuzGWkw8.js → arc-ByV7p1fN.js} +1 -1
  79. package/viewer/dist/assets/architecture-7EHR7CIX-FCXXAu3O.js +1 -0
  80. package/viewer/dist/assets/{architectureDiagram-3BPJPVTR-BgksqMlF.js → architectureDiagram-3BPJPVTR-CMawC2nt.js} +1 -1
  81. package/viewer/dist/assets/{blockDiagram-GPEHLZMM-Bc6JILFK.js → blockDiagram-GPEHLZMM-Bp0MKYXV.js} +1 -1
  82. package/viewer/dist/assets/{c4Diagram-AAUBKEIU-CRVt42df.js → c4Diagram-AAUBKEIU-BLCX9gmN.js} +1 -1
  83. package/viewer/dist/assets/channel-GJMBi5UK.js +1 -0
  84. package/viewer/dist/assets/{chunk-2J33WTMH-CpuwKeX6.js → chunk-2J33WTMH-C6iOidst.js} +1 -1
  85. package/viewer/dist/assets/{chunk-3OPIFGDE-BG_ijJhE.js → chunk-3OPIFGDE-B9xItVw4.js} +1 -1
  86. package/viewer/dist/assets/{chunk-4BX2VUAB-Bgz1tnDP.js → chunk-4BX2VUAB-Bl0HWb9B.js} +1 -1
  87. package/viewer/dist/assets/{chunk-55IACEB6-NdWURd2K.js → chunk-55IACEB6-BYilo1WJ.js} +1 -1
  88. package/viewer/dist/assets/{chunk-5ZQYHXKU-wzW5x_7z.js → chunk-5ZQYHXKU-DOCnP8P8.js} +1 -1
  89. package/viewer/dist/assets/{chunk-727SXJPM-BxKeJXYf.js → chunk-727SXJPM-D9YKAzha.js} +1 -1
  90. package/viewer/dist/assets/{chunk-AQP2D5EJ-gPm6xhPX.js → chunk-AQP2D5EJ-B1KE4h_b.js} +1 -1
  91. package/viewer/dist/assets/{chunk-BSJP7CBP-C30ZXEUD.js → chunk-BSJP7CBP-DA3pfXCw.js} +1 -1
  92. package/viewer/dist/assets/{chunk-CSCIHK7Q-C9h7yfl1.js → chunk-CSCIHK7Q-Bv0Fa0Zn.js} +6 -6
  93. package/viewer/dist/assets/{chunk-FMBD7UC4-BcSnkYc3.js → chunk-FMBD7UC4-k-EzzpU2.js} +1 -1
  94. package/viewer/dist/assets/{chunk-KSCS5N6A-rxSD5-MD.js → chunk-KSCS5N6A-CUTkv0jR.js} +1 -1
  95. package/viewer/dist/assets/{chunk-L5ZTLDWV-DGwFsVWy.js → chunk-L5ZTLDWV-C_SwE6oB.js} +1 -1
  96. package/viewer/dist/assets/{chunk-LZXEDZCA-BY3sTuvt.js → chunk-LZXEDZCA-DtwqVIDt.js} +2 -2
  97. package/viewer/dist/assets/{chunk-ND2GUHAM-PqmKwdzq.js → chunk-ND2GUHAM-JJiAqLG2.js} +1 -1
  98. package/viewer/dist/assets/{chunk-NZK2D7GU-BAZbzxhl.js → chunk-NZK2D7GU-BQ8Dn7FA.js} +1 -1
  99. package/viewer/dist/assets/{chunk-O5CBEL6O-SvZfZ-0j.js → chunk-O5CBEL6O-BRpqOJ1I.js} +1 -1
  100. package/viewer/dist/assets/chunk-QZHKN3VN-DA8qsAVF.js +1 -0
  101. package/viewer/dist/assets/chunk-WU5MYG2G-Dp8LMm21.js +1 -0
  102. package/viewer/dist/assets/{chunk-XPW4576I-1OXPbRQX.js → chunk-XPW4576I-C1NcqPJd.js} +1 -1
  103. package/viewer/dist/assets/classDiagram-4FO5ZUOK-wnkp0LBB.js +1 -0
  104. package/viewer/dist/assets/classDiagram-v2-Q7XG4LA2-wnkp0LBB.js +1 -0
  105. package/viewer/dist/assets/{cose-bilkent-S5V4N54A-DtCvLpah.js → cose-bilkent-S5V4N54A-BsJFb39B.js} +1 -1
  106. package/viewer/dist/assets/{dagre-BM42HDAG-B55Ilz10.js → dagre-BM42HDAG-CRPovG4Q.js} +1 -1
  107. package/viewer/dist/assets/{diagram-2AECGRRQ-ClqSMarp.js → diagram-2AECGRRQ-D6GCKn1u.js} +1 -1
  108. package/viewer/dist/assets/{diagram-5GNKFQAL-Dx74UdxY.js → diagram-5GNKFQAL-CoHqKdf3.js} +1 -1
  109. package/viewer/dist/assets/{diagram-KO2AKTUF-Vj0buH8e.js → diagram-KO2AKTUF-BEkMb40X.js} +1 -1
  110. package/viewer/dist/assets/{diagram-LMA3HP47-DpEm8RZd.js → diagram-LMA3HP47-B6mfW9S2.js} +1 -1
  111. package/viewer/dist/assets/{diagram-OG6HWLK6-Crl3BdwC.js → diagram-OG6HWLK6-OBBkuJvR.js} +1 -1
  112. package/viewer/dist/assets/{dist-C2uHOEFB.js → dist-C4BxOpXa.js} +1 -1
  113. package/viewer/dist/assets/{erDiagram-TEJ5UH35-B-Mf-EHP.js → erDiagram-TEJ5UH35-C2i3TB2-.js} +1 -1
  114. package/viewer/dist/assets/eventmodeling-FCH6USID-CCZDiBK0.js +1 -0
  115. package/viewer/dist/assets/{flowDiagram-I6XJVG4X-B9stChWN.js → flowDiagram-I6XJVG4X-SreJFib5.js} +1 -1
  116. package/viewer/dist/assets/{ganttDiagram-6RSMTGT7-DzWlclwM.js → ganttDiagram-6RSMTGT7-DO7GLSdw.js} +1 -1
  117. package/viewer/dist/assets/{gitGraph-WXDBUCRP-CdVI3GTp.js → gitGraph-WXDBUCRP-Cgwn_Q5_.js} +1 -1
  118. package/viewer/dist/assets/{gitGraphDiagram-PVQCEYII-Dn9yRc1N.js → gitGraphDiagram-PVQCEYII-DLwaMyDa.js} +1 -1
  119. package/viewer/dist/assets/{index-BsGRgBOD.js → index-COz6c_JS.js} +2 -2
  120. package/viewer/dist/assets/{info-J43DQDTF-CXV3Mm9T.js → info-J43DQDTF-Ci-BiM87.js} +1 -1
  121. package/viewer/dist/assets/{infoDiagram-5YYISTIA-zIUka_J2.js → infoDiagram-5YYISTIA-Bip5dlKM.js} +1 -1
  122. package/viewer/dist/assets/{ishikawaDiagram-YF4QCWOH-DsZwNuE3.js → ishikawaDiagram-YF4QCWOH-VXV_9uEH.js} +1 -1
  123. package/viewer/dist/assets/{journeyDiagram-JHISSGLW-BhSTSB2s.js → journeyDiagram-JHISSGLW-DQz-8gAS.js} +1 -1
  124. package/viewer/dist/assets/{kanban-definition-UN3LZRKU-sCAldj86.js → kanban-definition-UN3LZRKU-BTWpEcf0.js} +1 -1
  125. package/viewer/dist/assets/{line-D7b3fopb.js → line-BnRWAabF.js} +1 -1
  126. package/viewer/dist/assets/{linear-BWlvVc_d.js → linear-BR6v_8SI.js} +1 -1
  127. package/viewer/dist/assets/{mermaid-parser.core-BdEYBvaj.js → mermaid-parser.core-CSsnrQhR.js} +2 -2
  128. package/viewer/dist/assets/{mermaid.core-C9bGWeJU.js → mermaid.core-I1kLnpG2.js} +3 -3
  129. package/viewer/dist/assets/{mindmap-definition-RKZ34NQL-DbTvvku-.js → mindmap-definition-RKZ34NQL-Cq66Ilr1.js} +1 -1
  130. package/viewer/dist/assets/{packet-YPE3B663-DRfLruyq.js → packet-YPE3B663-BjSCEGVd.js} +1 -1
  131. package/viewer/dist/assets/{pie-LRSECV5Y-47xXMkK-.js → pie-LRSECV5Y-Di72yxoZ.js} +1 -1
  132. package/viewer/dist/assets/{pieDiagram-4H26LBE5-Ckno9E1M.js → pieDiagram-4H26LBE5-TmsfKHOO.js} +1 -1
  133. package/viewer/dist/assets/{quadrantDiagram-W4KKPZXB-XB5kJ_2c.js → quadrantDiagram-W4KKPZXB-Dp9iSvCk.js} +1 -1
  134. package/viewer/dist/assets/{radar-GUYGQ44K-CLwIH5Zr.js → radar-GUYGQ44K-CZeWC3Jm.js} +1 -1
  135. package/viewer/dist/assets/{requirementDiagram-4Y6WPE33-D3fApL0X.js → requirementDiagram-4Y6WPE33-lbTbiw59.js} +1 -1
  136. package/viewer/dist/assets/{sankeyDiagram-5OEKKPKP-6UdaRhuZ.js → sankeyDiagram-5OEKKPKP-DqmoGDJk.js} +1 -1
  137. package/viewer/dist/assets/{sequenceDiagram-3UESZ5HK-ClIrrs0w.js → sequenceDiagram-3UESZ5HK-B6_EiWF6.js} +1 -1
  138. package/viewer/dist/assets/{src-BPb55SrS.js → src-Dz3SjXJY.js} +1 -1
  139. package/viewer/dist/assets/{stateDiagram-AJRCARHV-CCsrcv5R.js → stateDiagram-AJRCARHV-Z6QRASCx.js} +1 -1
  140. package/viewer/dist/assets/stateDiagram-v2-BHNVJYJU-BePyUY0u.js +1 -0
  141. package/viewer/dist/assets/{timeline-definition-PNZ67QCA-BkiWQlVI.js → timeline-definition-PNZ67QCA-XuUZAYAP.js} +1 -1
  142. package/viewer/dist/assets/{treeView-BLDUP644-m1mGpAE8.js → treeView-BLDUP644-DuGsPB97.js} +1 -1
  143. package/viewer/dist/assets/{treemap-LRROVOQU-Bapvwi0Q.js → treemap-LRROVOQU-CSJx8IZ0.js} +1 -1
  144. package/viewer/dist/assets/{vennDiagram-CIIHVFJN-BlH71Nmx.js → vennDiagram-CIIHVFJN-BnebDryc.js} +1 -1
  145. package/viewer/dist/assets/{wardley-L42UT6IY-CPRSoKgV.js → wardley-L42UT6IY-DqqoXPSq.js} +1 -1
  146. package/viewer/dist/assets/{wardleyDiagram-YWT4CUSO-C-ptFJ9t.js → wardleyDiagram-YWT4CUSO-Bchy871K.js} +1 -1
  147. package/viewer/dist/assets/{xychartDiagram-2RQKCTM6-C4rfeH4W.js → xychartDiagram-2RQKCTM6-Bl_zzGZm.js} +1 -1
  148. package/viewer/dist/index.html +1 -1
  149. package/docs/001-file-format.md +0 -265
  150. package/docs/002-mcp.md +0 -186
  151. package/docs/003-mcp-upgrade-plan.md +0 -138
  152. package/viewer/dist/assets/architecture-7EHR7CIX-BAboPvK1.js +0 -1
  153. package/viewer/dist/assets/channel-D8YnBgqu.js +0 -1
  154. package/viewer/dist/assets/chunk-QZHKN3VN-ClYnv9g5.js +0 -1
  155. package/viewer/dist/assets/chunk-WU5MYG2G-CvL2Yleq.js +0 -1
  156. package/viewer/dist/assets/classDiagram-4FO5ZUOK-B8YUd1jx.js +0 -1
  157. package/viewer/dist/assets/classDiagram-v2-Q7XG4LA2-B8YUd1jx.js +0 -1
  158. package/viewer/dist/assets/eventmodeling-FCH6USID-CZ5gxAxG.js +0 -1
  159. package/viewer/dist/assets/stateDiagram-v2-BHNVJYJU-C1phrPNi.js +0 -1
@@ -9,12 +9,13 @@ 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 { changedFilesSince, diffPlan, headSha, planDirty, planLog, readSyncPoint, writeSyncPoint, } from '../core/git.js';
12
+ import { changedFilesSince, diffPlan, headSha, planDirty, planLog, readSyncPoint, resolveCommit, writeSyncPoint, } from '../core/git.js';
13
13
  import { computeSyncStatus } from '../core/sync.js';
14
14
  import { boundPathsForCard, resolveCodeForCard } from '../core/code.js';
15
15
  import { searchCards } from './search.js';
16
- import { applyCardPatch, bodyHeadingTexts, createCardFile, deepMerge, relPathForHandle, replaceBodySection, reservedFieldKeys, updateCardFile, withAppendedNote, } from '../core/writer.js';
17
- 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';
18
19
  const INSTRUCTIONS = `# Constellation MCP
19
20
 
20
21
  Constellation is this project's durable, cross-session memory for AI agents — treat it as
@@ -40,9 +41,9 @@ Retrieval is hydrated: get_card / search / traverse can return connected cards w
40
41
  their FULL frontmatter and body in one call (connected: "full"). Use that when you are
41
42
  about to work on an area; use "summary" for orientation. get_card can also hand back the
42
43
  CODE a card is bound to — code: "paths" returns the resolved file paths of its connected
43
- FILE cards (path:) plus its own code_refs; code: "direct" attaches their contents (capped,
44
- binaries/lockfiles/generated skipped) so a background coder starts from intent + current
45
- code in one call. assemble turns a delta (or a handle set) into a work package: the changed
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
46
47
  cards + their neighborhood (full) + bound code + a heuristic build order + FILE-DISJOINT
47
48
  units you can fan out one sub-agent per, with no two touching the same file.
48
49
 
@@ -52,7 +53,11 @@ Body-only updates never reformat frontmatter. Prefer SMALL, cheap writes over re
52
53
  whole card — make the honest update the easy one: append_note adds an append-only typed note
53
54
  (decision / gotcha / state / deviation / verified) with no full-body rewrite; edit_section
54
55
  replaces a single ## section in place. Reach for these to record a correction the moment you
55
- learn it, so cards stay true instead of drifting.
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.
56
61
 
57
62
  describe_type is the type reference, served by this server: call it with no args for the
58
63
  catalog of all 17 card types, or with a type (e.g. describe_type PAGE) for that type's
@@ -94,8 +99,8 @@ changes as the proposal; on approval, bring the CODE up to match via the sync lo
94
99
  FINISH by reconciling — re-read the touched cards against the code, run check_integrity so
95
100
  no affected card is left an orphan and every connection is set, bump status (planned →
96
101
  building → built → verified), commit, and set_sync_point. In plan mode the write tools are
97
- unavailable by design (the read tools — get_card, list_cards, search, traverse, assemble,
98
- describe_type, check_integrity, diff_plan, plan_log, stale_report, check_sync,
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,
99
104
  list_connected_repos — are marked read-only and stay available), so spend plan mode READING:
100
105
  pull in as much of the relevant plan as you can
101
106
  (traverse from the entry points, connected: "full") to build a strong model of the project
@@ -106,7 +111,11 @@ For migrations or large scaffolds, use create_cards and add_connections (batched
106
111
  lint pass) instead of many single calls — connections between cards in the same batch
107
112
  resolve without transient "does not resolve" errors. A card is created even when issues
108
113
  are returned (issues are lint state, not failure). check_integrity reports orphans
109
- (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.
110
119
 
111
120
  The plan folder is found by walking up from the working directory, BOUNDED by the repo
112
121
  root (it never adopts another repo's plan). If no plan exists in this repo (tools return
@@ -281,9 +290,7 @@ async function computeStaleCards(root, index, base) {
281
290
  marker = null;
282
291
  }
283
292
  const fallback = base ?? marker ?? null;
284
- const stale = [];
285
- const noBaseline = [];
286
- let checked = 0;
293
+ const claims = [];
287
294
  for (const card of index.cards.values()) {
288
295
  const verifiedSha = typeof card.frontmatter.verified_sha === 'string'
289
296
  ? card.frontmatter.verified_sha
@@ -293,20 +300,39 @@ async function computeStaleCards(root, index, base) {
293
300
  continue;
294
301
  if (boundPathsForCard(index, card).length === 0)
295
302
  continue;
296
- checked++;
297
- const baseline = verifiedSha ?? fallback ?? undefined;
298
303
  const resolved = await resolveCodeForCard(root, index, card, 'paths');
299
- const paths = resolved.files.map((f) => f.path);
300
- const missing = resolved.files.filter((f) => !f.exists).map((f) => f.path);
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) {
301
330
  if (!baseline) {
302
331
  noBaseline.push({ handle: card.handle, status: card.status ?? null, files: paths });
303
332
  continue;
304
333
  }
305
- let changed;
306
- try {
307
- changed = await changedFilesSince(root, baseline, paths);
308
- }
309
- catch {
334
+ const changed = changedBy.get(baseline);
335
+ if (changed === 'unreachable') {
310
336
  noBaseline.push({
311
337
  handle: card.handle,
312
338
  status: card.status ?? null,
@@ -322,13 +348,13 @@ async function computeStaleCards(root, index, base) {
322
348
  name: card.name ?? null,
323
349
  status: card.status ?? null,
324
350
  baseline: baseline.slice(0, 12),
325
- baseline_source: verifiedSha ? 'verified_sha' : base ? 'argument' : 'sync-marker',
351
+ baseline_source,
326
352
  changed_files: changedFiles,
327
353
  missing_files: missing,
328
354
  });
329
355
  }
330
356
  }
331
- return { checked, stale, no_baseline: noBaseline };
357
+ return { checked: claims.length, stale, no_baseline: noBaseline };
332
358
  }
333
359
  // Rough dependency tiers for assemble's suggested build order: data first, then
334
360
  // contracts, then surfaces. Connections are undirected, so this is a heuristic
@@ -387,6 +413,17 @@ function partitionByFiles(seeds, filesBy) {
387
413
  const detailSchema = z.enum(['none', 'summary', 'full']);
388
414
  const typeSchema = z.enum(TYPE_NAMES);
389
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
+ }
390
427
  const noteKindSchema = z.enum(['decision', 'gotcha', 'state', 'deviation', 'verified']);
391
428
  const codeModeSchema = z.enum(['none', 'paths', 'direct']);
392
429
  const repoSchema = z
@@ -504,7 +541,7 @@ export function buildServer(options = {}) {
504
541
  });
505
542
  server.registerTool('get_card', {
506
543
  annotations: { readOnlyHint: true },
507
- 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 (capped, binaries/lockfiles/generated skipped) so a background coder starts from intent + current code in one call.',
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.',
508
545
  inputSchema: {
509
546
  handle: z.string(),
510
547
  connected: detailSchema.optional().describe('default: summary'),
@@ -548,11 +585,11 @@ export function buildServer(options = {}) {
548
585
  }));
549
586
  server.registerTool('list_cards', {
550
587
  annotations: { readOnlyHint: true },
551
- 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).',
552
589
  inputSchema: {
553
590
  types: z.array(typeSchema).optional(),
554
591
  kind: z.string().optional(),
555
- status: statusSchema.optional(),
592
+ status: statusesSchema.describe('one status or a list; "none" = cards with no status set'),
556
593
  connected: z
557
594
  .boolean()
558
595
  .optional()
@@ -563,11 +600,12 @@ export function buildServer(options = {}) {
563
600
  }, withPlan(async (root, { types, kind, status, connected, limit }) => {
564
601
  const index = await loadPlan(root);
565
602
  const typeFilter = types && types.length > 0 ? new Set(types) : null;
603
+ const statusFilter = statusSetOf(status);
566
604
  const isConnected = (h) => (index.connectedHandles.get(h)?.size ?? 0) > 0;
567
605
  const matched = [...index.cards.values()]
568
606
  .filter((c) => !typeFilter || typeFilter.has(c.type))
569
607
  .filter((c) => !kind || c.kind === kind)
570
- .filter((c) => !status || c.status === status)
608
+ .filter((c) => !statusFilter || statusFilter.has(c.status ?? 'none'))
571
609
  .filter((c) => connected === undefined || isConnected(c.handle) === connected)
572
610
  .sort((a, b) => a.handle.localeCompare(b.handle));
573
611
  return ok({
@@ -599,15 +637,16 @@ export function buildServer(options = {}) {
599
637
  }));
600
638
  server.registerTool('traverse', {
601
639
  annotations: { readOnlyHint: true },
602
- 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.',
603
641
  inputSchema: {
604
642
  start: z.union([z.string(), z.array(z.string()).min(1)]),
605
643
  depth: z.number().int().min(0).max(5).optional().describe('default: 2'),
606
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.'),
607
646
  detail: z.enum(['summary', 'full']).optional().describe('default: summary'),
608
647
  repo: repoSchema,
609
648
  },
610
- }, withPlan(async (root, { start, depth, types, detail }) => {
649
+ }, withPlan(async (root, { start, depth, types, status, detail }) => {
611
650
  const index = await loadPlan(root);
612
651
  const starts = (Array.isArray(start) ? start : [start]).map((s) => s.toUpperCase());
613
652
  const missing = starts.filter((s) => !index.cards.has(s));
@@ -637,7 +676,12 @@ export function buildServer(options = {}) {
637
676
  }
638
677
  frontier = next;
639
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);
640
682
  const cards = [...distance.entries()]
683
+ .filter(([handle]) => !statusFilter ||
684
+ statusFilter.has(index.cards.get(handle).status ?? 'none'))
641
685
  .map(([handle, dist]) => {
642
686
  const card = index.cards.get(handle);
643
687
  return {
@@ -646,7 +690,8 @@ export function buildServer(options = {}) {
646
690
  };
647
691
  })
648
692
  .sort((a, b) => a.distance - b.distance || a.handle.localeCompare(b.handle));
649
- 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));
650
695
  return ok({ cards, connections, not_found: missing });
651
696
  }));
652
697
  server.registerTool('assemble', {
@@ -674,9 +719,12 @@ export function buildServer(options = {}) {
674
719
  }, withPlan(async (root, { handles, base, depth, code }) => {
675
720
  const index = await loadPlan(root);
676
721
  let seeds;
722
+ let notFound = [];
677
723
  let delta = null;
678
724
  if (handles && handles.length > 0) {
679
- seeds = [...new Set(handles.map((h) => h.toUpperCase()))].filter((h) => index.cards.has(h));
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));
680
728
  }
681
729
  else {
682
730
  let diff;
@@ -693,6 +741,7 @@ export function buildServer(options = {}) {
693
741
  return ok({
694
742
  base: delta,
695
743
  seeds: [],
744
+ not_found: notFound,
696
745
  units: [],
697
746
  note: handles
698
747
  ? 'None of the given handles exist in the plan.'
@@ -744,6 +793,7 @@ export function buildServer(options = {}) {
744
793
  return ok({
745
794
  base: delta,
746
795
  seeds,
796
+ not_found: notFound,
747
797
  reached_handles: reached,
748
798
  suggested_order: suggestedOrder,
749
799
  units,
@@ -960,12 +1010,17 @@ export function buildServer(options = {}) {
960
1010
  const touched = new Set();
961
1011
  for (const [src, targets] of additions) {
962
1012
  const card = index.cards.get(src);
963
- const existingList = Array.isArray(card.frontmatter.connections)
964
- ? card.frontmatter.connections.filter((c) => typeof c === 'string')
965
- : [];
966
- const merged = [...new Set([...existingList, ...targets])];
967
- const frontmatter = applyCardPatch(card.frontmatter, { connections: merged });
968
- 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
+ });
969
1024
  touched.add(card.relPath);
970
1025
  added += targets.size;
971
1026
  }
@@ -1010,10 +1065,12 @@ export function buildServer(options = {}) {
1010
1065
  return fail('STALE', `${card.handle} changed on disk`);
1011
1066
  }
1012
1067
  }
1013
- const frontmatter = patch
1014
- ? applyCardPatch(card.frontmatter, patch)
1015
- : undefined;
1016
- await updateCardFile(card.filePath, { frontmatter, body });
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
+ }));
1017
1074
  const lint = await lintPlan(root);
1018
1075
  const updated = lint.index.cards.get(card.handle);
1019
1076
  return ok({
@@ -1041,8 +1098,11 @@ export function buildServer(options = {}) {
1041
1098
  const note = { kind, text };
1042
1099
  if (sha)
1043
1100
  note.sha = sha;
1044
- const frontmatter = withAppendedNote(card.frontmatter, note);
1045
- await updateCardFile(card.filePath, { frontmatter });
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
+ }));
1046
1106
  const lint = await lintPlan(root);
1047
1107
  const updated = lint.index.cards.get(card.handle);
1048
1108
  return ok({
@@ -1053,6 +1113,56 @@ export function buildServer(options = {}) {
1053
1113
  issues: issuesForFile(lint.issues, card.relPath),
1054
1114
  });
1055
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
+ }));
1056
1166
  server.registerTool('edit_section', {
1057
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).',
1058
1168
  inputSchema: {
@@ -1068,17 +1178,25 @@ export function buildServer(options = {}) {
1068
1178
  const card = index.cards.get(handle.toUpperCase());
1069
1179
  if (!card)
1070
1180
  return fail('NOT_FOUND', `No card with handle ${handle}`);
1071
- const body = replaceBodySection(card.body, section, text);
1072
- if (body === null) {
1073
- const headings = bodyHeadingTexts(card.body);
1074
- const target = section.trim().replace(/^#+\s*/, '').toLowerCase();
1075
- const matchCount = headings.filter((h) => h.toLowerCase() === target).length;
1076
- if (matchCount > 1) {
1077
- return 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.`);
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;
1078
1195
  }
1079
- return 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.`);
1080
- }
1081
- await updateCardFile(card.filePath, { body });
1196
+ return { body };
1197
+ });
1198
+ if (failure)
1199
+ return failure;
1082
1200
  const lint = await lintPlan(root);
1083
1201
  const updated = lint.index.cards.get(card.handle);
1084
1202
  return ok({
@@ -1105,36 +1223,65 @@ export function buildServer(options = {}) {
1105
1223
  const card = index.cards.get(handle.toUpperCase());
1106
1224
  if (!card)
1107
1225
  return fail('NOT_FOUND', `No card with handle ${handle}`);
1226
+ const warnings = [];
1108
1227
  let resolvedSha = sha;
1109
- let warning;
1110
- if (!resolvedSha) {
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 {
1111
1239
  try {
1112
1240
  resolvedSha = await headSha(root);
1113
1241
  }
1114
1242
  catch {
1115
- warning =
1116
- 'Not a git repo (or no commits): stamped verified_at + status only. Drift detection needs a verified_sha baseline — pass sha or commit first.';
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.
1117
1259
  }
1118
1260
  }
1119
1261
  const verifiedAt = new Date().toISOString();
1120
1262
  const fields = { verified_at: verifiedAt };
1121
1263
  if (resolvedSha)
1122
1264
  fields.verified_sha = resolvedSha;
1123
- let frontmatter = applyCardPatch(card.frontmatter, { status: 'verified', fields });
1124
- if (note) {
1125
- const n = { kind: 'verified', text: note };
1126
- if (resolvedSha)
1127
- n.sha = resolvedSha;
1128
- frontmatter = withAppendedNote(frontmatter, n);
1129
- }
1130
- await updateCardFile(card.filePath, { frontmatter });
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
+ });
1131
1278
  const lint = await lintPlan(root);
1132
1279
  const updated = lint.index.cards.get(card.handle);
1133
1280
  return ok({
1134
1281
  card: updated ? full(updated) : null,
1135
1282
  verified_sha: resolvedSha ?? null,
1136
1283
  verified_at: verifiedAt,
1137
- warning,
1284
+ warning: warnings.length > 0 ? warnings.join(' ') : undefined,
1138
1285
  issues: issuesForFile(lint.issues, card.relPath),
1139
1286
  });
1140
1287
  }));
@@ -1158,6 +1305,38 @@ export function buildServer(options = {}) {
1158
1305
  issues: lint.issues.filter((i) => handleToken.test(i.message)),
1159
1306
  });
1160
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
+ }));
1161
1340
  server.registerTool('add_connection', {
1162
1341
  description: 'Connect two cards by appending `to` to `from`’s connections list. No-op if they are already connected through any source.',
1163
1342
  inputSchema: { from: z.string(), to: z.string(), repo: repoSchema },
@@ -1171,13 +1350,18 @@ export function buildServer(options = {}) {
1171
1350
  if (index.connectedHandles.get(from.handle)?.has(to.handle)) {
1172
1351
  return ok({ already_connected: true, between: [from.handle, to.handle] });
1173
1352
  }
1174
- const existing = Array.isArray(from.frontmatter.connections)
1175
- ? from.frontmatter.connections
1176
- : [];
1177
- const frontmatter = deepMerge(from.frontmatter, {
1178
- 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
+ };
1179
1364
  });
1180
- await updateCardFile(from.filePath, { frontmatter });
1181
1365
  const lint = await lintPlan(root);
1182
1366
  return ok({
1183
1367
  connected: [from.handle, to.handle],
@@ -1200,17 +1384,24 @@ export function buildServer(options = {}) {
1200
1384
  [cardA, cardB.handle],
1201
1385
  [cardB, cardA.handle],
1202
1386
  ]) {
1203
- const list = Array.isArray(card.frontmatter.connections)
1204
- ? card.frontmatter.connections
1205
- : [];
1206
- 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;
1207
1396
  const next = list.filter((h) => h !== other);
1208
- const frontmatter = deepMerge(card.frontmatter, {
1209
- connections: next.length > 0 ? next : null,
1210
- });
1211
- 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)
1212
1404
  removedFrom.push(card.handle);
1213
- }
1214
1405
  }
1215
1406
  const lint = await lintPlan(root);
1216
1407
  const after = lint.index;
@@ -1285,11 +1476,15 @@ export function buildServer(options = {}) {
1285
1476
  return fail('NO_PLAN_PROJECT', 'No plan.md (PLAN-PROJECT) at the plan root to record connected_repos on.');
1286
1477
  }
1287
1478
  const entry = { name, path: repoPath, description };
1288
- const next = upsertConnectedRepo(await readConnectedRepos(root), entry);
1289
- const frontmatter = applyCardPatch(planCard.frontmatter, {
1290
- 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
+ };
1291
1487
  });
1292
- await updateCardFile(planCard.filePath, { frontmatter });
1293
1488
  let reciprocated;
1294
1489
  if (reciprocate) {
1295
1490
  const target = await resolveConnectedRepo(root, repoPath);
@@ -1313,12 +1508,13 @@ export function buildServer(options = {}) {
1313
1508
  path: path.relative(target.repoRoot, homeRepoRoot) || '.',
1314
1509
  description: reverse_description,
1315
1510
  };
1316
- const targetNext = upsertConnectedRepo(await readConnectedRepos(target.root), reverseEntry);
1317
- await updateCardFile(targetPlan.filePath, {
1318
- frontmatter: applyCardPatch(targetPlan.frontmatter, {
1319
- 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
+ },
1320
1516
  }),
1321
- });
1517
+ }));
1322
1518
  reciprocated = {
1323
1519
  ok: true,
1324
1520
  repo_root: target.repoRoot,
@@ -1346,11 +1542,16 @@ export function buildServer(options = {}) {
1346
1542
  if (!existing.some((r) => r.name === name)) {
1347
1543
  return ok({ removed: false, connected_repos: existing.map(connectedRepoToFm) });
1348
1544
  }
1349
- const next = removeConnectedRepoEntry(existing, name);
1350
- await updateCardFile(planCard.filePath, {
1351
- frontmatter: applyCardPatch(planCard.frontmatter, {
1352
- fields: { connected_repos: next.length > 0 ? next.map(connectedRepoToFm) : null },
1353
- }),
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
+ };
1354
1555
  });
1355
1556
  const lint = await lintPlan(root);
1356
1557
  return ok({
@@ -1448,9 +1649,9 @@ export function buildServer(options = {}) {
1448
1649
  repo: repoSchema,
1449
1650
  },
1450
1651
  }, withPlan(async (root, { base }) => {
1451
- const status = await computeSyncStatus(root);
1452
- const index = await loadPlan(root);
1453
- const r = await computeStaleCards(root, index, base);
1652
+ const lint = await lintPlan(root);
1653
+ const status = await computeSyncStatus(root, { lint });
1654
+ const r = await computeStaleCards(root, lint.index, base);
1454
1655
  return ok({
1455
1656
  advisory: 'Advisory only — the MCP server reports sync state, it cannot block. Use as a definition-of-done gate before calling work complete.',
1456
1657
  state: status.state,