projectkeeper 0.0.0-stage → 1.0.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 (276) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +21 -0
  3. package/README.md +283 -2
  4. package/README.zh-CN.md +285 -0
  5. package/THIRD_PARTY_NOTICES.md +53 -0
  6. package/bin/pk.mjs +6 -0
  7. package/bin/postinstall.mjs +5 -0
  8. package/bin/strip-types.mjs +28 -0
  9. package/package.json +68 -4
  10. package/patches/README.md +111 -0
  11. package/patches/pi-ai-0.85.0.ts +122 -0
  12. package/patches/pi-ai-0.87.1.ts +122 -0
  13. package/scripts/apply-pi-streaming-patch.ts +86 -0
  14. package/skills/clerk/coverage/SKILL.md +35 -0
  15. package/skills/clerk/cross-check/SKILL.md +118 -0
  16. package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
  17. package/skills/clerk/lane-follow-up/SKILL.md +94 -0
  18. package/skills/clerk/lane-plan/SKILL.md +107 -0
  19. package/skills/clerk/lane-slot/SKILL.md +113 -0
  20. package/skills/clerk/lane-topic/SKILL.md +116 -0
  21. package/skills/clerk/orientation/SKILL.md +160 -0
  22. package/skills/clerk/reconcile/SKILL.md +77 -0
  23. package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
  24. package/skills/clerk/spot-check/SKILL.md +52 -0
  25. package/skills/clerk/synthesis/SKILL.md +65 -0
  26. package/skills/table-follow-up/SKILL.md +42 -0
  27. package/src/cli.ts +319 -0
  28. package/src/codemap/README.md +13 -0
  29. package/src/codemap/engines.ts +102 -0
  30. package/src/codemap/facts.ts +504 -0
  31. package/src/context/agent-entry.ts +232 -0
  32. package/src/context/ask.ts +97 -0
  33. package/src/context/assemble.ts +1058 -0
  34. package/src/context/code-source.ts +103 -0
  35. package/src/context/k-briefs.ts +114 -0
  36. package/src/context/lineage.ts +100 -0
  37. package/src/context/object-brief.ts +300 -0
  38. package/src/context/owner-words.ts +191 -0
  39. package/src/context/pack-fixture.ts +342 -0
  40. package/src/context/project-rules.ts +160 -0
  41. package/src/intake/intake.ts +392 -0
  42. package/src/intake/material-rules.ts +166 -0
  43. package/src/intake/removal.ts +316 -0
  44. package/src/intake/skipped.ts +72 -0
  45. package/src/keeper/adjustment.ts +565 -0
  46. package/src/keeper/bounds/boundary.ts +509 -0
  47. package/src/keeper/bounds/command.ts +1665 -0
  48. package/src/keeper/bounds/paths.ts +164 -0
  49. package/src/keeper/bounds/reads.ts +718 -0
  50. package/src/keeper/bounds/shell-write-guard.ts +521 -0
  51. package/src/keeper/capabilities.ts +136 -0
  52. package/src/keeper/clerk-steps.ts +151 -0
  53. package/src/keeper/clerk-tools.ts +1424 -0
  54. package/src/keeper/conversation.ts +253 -0
  55. package/src/keeper/evidence.ts +662 -0
  56. package/src/keeper/fake-provider.ts +132 -0
  57. package/src/keeper/held.ts +15 -0
  58. package/src/keeper/history-tools.ts +163 -0
  59. package/src/keeper/jobs/answering.ts +53 -0
  60. package/src/keeper/key-check.ts +105 -0
  61. package/src/keeper/ledger-tools.ts +295 -0
  62. package/src/keeper/link-check.ts +185 -0
  63. package/src/keeper/merge.ts +260 -0
  64. package/src/keeper/note-origin.ts +56 -0
  65. package/src/keeper/numbers-check.ts +102 -0
  66. package/src/keeper/open-in-pi.ts +57 -0
  67. package/src/keeper/organize/absence.ts +266 -0
  68. package/src/keeper/organize/carried-on.ts +214 -0
  69. package/src/keeper/organize/clerk-coverage.ts +293 -0
  70. package/src/keeper/organize/clerk-prompts.ts +132 -0
  71. package/src/keeper/organize/clerk.ts +1395 -0
  72. package/src/keeper/organize/coverage-gate.ts +19 -0
  73. package/src/keeper/organize/coverage-tools.ts +765 -0
  74. package/src/keeper/organize/entries.ts +153 -0
  75. package/src/keeper/organize/entry-gate.ts +289 -0
  76. package/src/keeper/organize/fill-tools.ts +718 -0
  77. package/src/keeper/organize/follow-up.ts +280 -0
  78. package/src/keeper/organize/generation-check.ts +504 -0
  79. package/src/keeper/organize/graph.ts +230 -0
  80. package/src/keeper/organize/lane-tools.ts +289 -0
  81. package/src/keeper/organize/looked-tools.ts +54 -0
  82. package/src/keeper/organize/materials.ts +243 -0
  83. package/src/keeper/organize/owner-lines.ts +493 -0
  84. package/src/keeper/organize/place-tools.ts +216 -0
  85. package/src/keeper/organize/placement-inference.ts +1045 -0
  86. package/src/keeper/organize/placement-time.ts +320 -0
  87. package/src/keeper/organize/placing.ts +821 -0
  88. package/src/keeper/organize/positioning.ts +33 -0
  89. package/src/keeper/organize/readiness.ts +47 -0
  90. package/src/keeper/organize/reading.ts +401 -0
  91. package/src/keeper/organize/round-blocks.ts +65 -0
  92. package/src/keeper/organize/round-news.ts +164 -0
  93. package/src/keeper/organize/round-open.ts +586 -0
  94. package/src/keeper/organize/schedule-state.ts +39 -0
  95. package/src/keeper/organize/schedule.ts +137 -0
  96. package/src/keeper/organize/service.ts +409 -0
  97. package/src/keeper/organize/skills.ts +116 -0
  98. package/src/keeper/organize/slots.ts +85 -0
  99. package/src/keeper/organize/stage-gate.ts +175 -0
  100. package/src/keeper/organize/stage-tools.ts +471 -0
  101. package/src/keeper/organize/standing-notes.ts +141 -0
  102. package/src/keeper/organize/table-follow-up.ts +330 -0
  103. package/src/keeper/organize/takeover-clerk.ts +832 -0
  104. package/src/keeper/organize/takeover-state.ts +103 -0
  105. package/src/keeper/organize/takeover.ts +216 -0
  106. package/src/keeper/organize/update-pending.ts +535 -0
  107. package/src/keeper/organize/workbench-placement.ts +92 -0
  108. package/src/keeper/own-keys.ts +114 -0
  109. package/src/keeper/owner-text.ts +165 -0
  110. package/src/keeper/project-folder.ts +334 -0
  111. package/src/keeper/prompts.ts +101 -0
  112. package/src/keeper/references.ts +177 -0
  113. package/src/keeper/relook/compose.ts +120 -0
  114. package/src/keeper/relook/prompts.ts +36 -0
  115. package/src/keeper/roles.ts +259 -0
  116. package/src/keeper/round-tools.ts +227 -0
  117. package/src/keeper/route.ts +148 -0
  118. package/src/keeper/rules.ts +97 -0
  119. package/src/keeper/runtime.ts +1858 -0
  120. package/src/keeper/scope-tools.ts +194 -0
  121. package/src/keeper/sendback-text.ts +83 -0
  122. package/src/keeper/step-timing.ts +207 -0
  123. package/src/keeper/testing/clerk-script.ts +212 -0
  124. package/src/keeper/testing/streaming-arguments.ts +57 -0
  125. package/src/keeper/tools.ts +2340 -0
  126. package/src/keeper/utterance-tools.ts +64 -0
  127. package/src/keeper/verbatim.ts +397 -0
  128. package/src/ledger/adapters.ts +186 -0
  129. package/src/ledger/arrangements.ts +257 -0
  130. package/src/ledger/blame.ts +58 -0
  131. package/src/ledger/code-engine-child.ts +69 -0
  132. package/src/ledger/code-engine.ts +548 -0
  133. package/src/ledger/code.ts +607 -0
  134. package/src/ledger/docs.ts +294 -0
  135. package/src/ledger/git-read.ts +321 -0
  136. package/src/ledger/index.ts +1850 -0
  137. package/src/ledger/item-range.ts +16 -0
  138. package/src/ledger/lines.ts +328 -0
  139. package/src/ledger/numbering.ts +269 -0
  140. package/src/ledger/ranges.ts +74 -0
  141. package/src/ledger/rebuild-worker.ts +15 -0
  142. package/src/ledger/rebuild.ts +387 -0
  143. package/src/ledger/repo-scan.ts +181 -0
  144. package/src/ledger/schema.ts +450 -0
  145. package/src/ledger/sessions.ts +134 -0
  146. package/src/ledger/text-scan.ts +466 -0
  147. package/src/ledger/time.ts +185 -0
  148. package/src/ledger/ts-symbols.ts +180 -0
  149. package/src/ledger/views.ts +377 -0
  150. package/src/mcp/server.ts +127 -0
  151. package/src/mcp/tools.ts +187 -0
  152. package/src/model/ids.ts +23 -0
  153. package/src/model/k-types.ts +912 -0
  154. package/src/model/time.ts +85 -0
  155. package/src/model/types.ts +1382 -0
  156. package/src/model/views-k.ts +707 -0
  157. package/src/model/vocab.ts +269 -0
  158. package/src/process/analysis.ts +79 -0
  159. package/src/process/breakpoint-candidates.ts +383 -0
  160. package/src/process/breakpoints.ts +746 -0
  161. package/src/process/claim-check.ts +295 -0
  162. package/src/process/delivery-links.ts +52 -0
  163. package/src/process/facts.ts +487 -0
  164. package/src/process/generations.ts +138 -0
  165. package/src/process/index.ts +243 -0
  166. package/src/process/integration.ts +128 -0
  167. package/src/process/placement.ts +107 -0
  168. package/src/process/plan.ts +276 -0
  169. package/src/process/sendbacks.ts +244 -0
  170. package/src/process/text.ts +185 -0
  171. package/src/process/units.ts +543 -0
  172. package/src/process/unnumbered.ts +431 -0
  173. package/src/process/work.ts +884 -0
  174. package/src/scope/candidates.ts +86 -0
  175. package/src/scope/decisions.ts +229 -0
  176. package/src/scope/discover.ts +741 -0
  177. package/src/scope/ignore.ts +126 -0
  178. package/src/scope/ignored-place.ts +93 -0
  179. package/src/scope/skip.ts +161 -0
  180. package/src/scope/toolchain.ts +180 -0
  181. package/src/scope/worktree.ts +187 -0
  182. package/src/server/api.ts +549 -0
  183. package/src/server/app.ts +595 -0
  184. package/src/server/drafts-api.ts +170 -0
  185. package/src/server/folders-api.ts +243 -0
  186. package/src/server/graph-view.ts +639 -0
  187. package/src/server/http.ts +224 -0
  188. package/src/server/k-agent-api.ts +56 -0
  189. package/src/server/k-rounds-d99.ts +91 -0
  190. package/src/server/k-views.ts +405 -0
  191. package/src/server/keeper-page.ts +446 -0
  192. package/src/server/keys-api.ts +239 -0
  193. package/src/server/project-folder-api.ts +104 -0
  194. package/src/server/vendor.ts +14 -0
  195. package/src/server/workbench-content.ts +561 -0
  196. package/src/sources/anchor.ts +129 -0
  197. package/src/sources/files.ts +267 -0
  198. package/src/sources/gitobs.ts +41 -0
  199. package/src/sources/history.ts +231 -0
  200. package/src/sources/scan-worker.ts +10 -0
  201. package/src/sources/sessions/locate.ts +292 -0
  202. package/src/sources/sessions/read.ts +346 -0
  203. package/src/sources/sessions/scope.ts +42 -0
  204. package/src/sources/sessions/utterances.ts +223 -0
  205. package/src/sources/watch.ts +215 -0
  206. package/src/store/json-file.ts +50 -0
  207. package/src/store/paths.ts +39 -0
  208. package/src/store/project-store.ts +295 -0
  209. package/src/store/versions.ts +172 -0
  210. package/src/store/workspace.ts +216 -0
  211. package/src/table/check.ts +333 -0
  212. package/src/table/follow-up-tools.ts +30 -0
  213. package/src/table/follow-up.ts +344 -0
  214. package/src/table/import.ts +610 -0
  215. package/src/table/project-home.ts +61 -0
  216. package/src/table/sessions.ts +168 -0
  217. package/src/table/takeover.ts +483 -0
  218. package/src/util/git-check.ts +29 -0
  219. package/src/util/git.ts +411 -0
  220. package/src/util/own-ts.ts +15 -0
  221. package/src/util/paths.ts +225 -0
  222. package/template/FILLING-IN.md +148 -0
  223. package/template/examples/papertrail.json +747 -0
  224. package/template/projectkeeper.schema.json +1001 -0
  225. package/ui/app.js +1135 -0
  226. package/ui/failures.js +82 -0
  227. package/ui/flyout.js +120 -0
  228. package/ui/folder-chooser.js +264 -0
  229. package/ui/glossary.js +156 -0
  230. package/ui/graph-fit.js +157 -0
  231. package/ui/graph-material.js +61 -0
  232. package/ui/graph-palette.js +109 -0
  233. package/ui/graph-tools.js +62 -0
  234. package/ui/graph.js +1712 -0
  235. package/ui/index.html +31 -0
  236. package/ui/k/code-view.js +230 -0
  237. package/ui/k/draft-view.js +93 -0
  238. package/ui/k/k-views.css +224 -0
  239. package/ui/k/round-tree.js +450 -0
  240. package/ui/k/scope-k.js +159 -0
  241. package/ui/k-data.js +118 -0
  242. package/ui/k-fold.js +106 -0
  243. package/ui/k-process.css +302 -0
  244. package/ui/k-process.js +934 -0
  245. package/ui/keeper-activity.js +175 -0
  246. package/ui/keeper-view.js +801 -0
  247. package/ui/keeper.css +101 -0
  248. package/ui/key-order.js +62 -0
  249. package/ui/list-fold.js +67 -0
  250. package/ui/markdown.d.ts +21 -0
  251. package/ui/markdown.js +472 -0
  252. package/ui/placement.d.ts +92 -0
  253. package/ui/placement.js +445 -0
  254. package/ui/popover-place.d.ts +16 -0
  255. package/ui/popover-place.js +89 -0
  256. package/ui/popover.js +198 -0
  257. package/ui/project-folder.js +37 -0
  258. package/ui/shell-state.js +40 -0
  259. package/ui/strip-rows.js +171 -0
  260. package/ui/styles.css +492 -0
  261. package/ui/theme-pick.js +36 -0
  262. package/ui/theme.js +74 -0
  263. package/ui/themes/LICENSE.md +13 -0
  264. package/ui/themes/README.md +44 -0
  265. package/ui/themes/TOKENS.md +291 -0
  266. package/ui/themes/a1/board.css +1557 -0
  267. package/ui/themes/a1/pk.css +392 -0
  268. package/ui/themes/b1/board.css +1163 -0
  269. package/ui/themes/b1/pk.css +218 -0
  270. package/ui/themes/d2/board.css +518 -0
  271. package/ui/themes/d2/pk.css +243 -0
  272. package/ui/themes/guard.css +73 -0
  273. package/ui/themes/i1/board.css +776 -0
  274. package/ui/themes/i1/pk.css +223 -0
  275. package/ui/themes/manifest.json +13 -0
  276. package/ui/views.js +2796 -0
@@ -0,0 +1,377 @@
1
+ /**
2
+ * The workbench's increment K views built from the ledger (src/model/views-k.ts; Spec §6.3 versions, §6.4 `How it got
3
+ * here`, §6.7 coverage, §6.17 `Code`). The ledger gives the facts — versions, dated steps, sizes, dependencies, tests,
4
+ * last changes, merges; the assets give what was judged — which document an object is, the semantic patches, the
5
+ * send-backs, the code territories and their anomalies. What neither has is left empty rather than invented.
6
+ */
7
+ import type { ProjectStore } from '../store/project-store.ts';
8
+ import type { Project, Source } from '../model/types.ts';
9
+ import type { CodeTerritory, EvidenceRef, Occurred } from '../model/k-types.ts';
10
+ import type {
11
+ CodeFileView, CodeView, DocCurrentView, LineageStepKind, LineageStepView, LineageView, ScopeKView, TerritoryDetailView, TerritoryView, VersionView, VersionsView,
12
+ } from '../model/views-k.ts';
13
+ import { endsWithPath, isWithin, normalizePath, pathKey } from '../util/paths.ts';
14
+ import { dayOf, materialTime, msOf, undated } from './time.ts';
15
+ import type { Ledger, ProvenanceStep } from './index.ts';
16
+ import { CodeMapIndex, codeLevels } from '../codemap/facts.ts';
17
+ import { replacedNumbers } from './lines.ts';
18
+ import { splitMarkdown } from '../sources/files.ts';
19
+ import { itemRanges } from './item-range.ts';
20
+
21
+ // ───────────────────────── which material an object is ─────────────────────────
22
+
23
+ interface Anchors { readonly paths: { repo: string; path: string }[]; readonly nums: string[]; readonly commits: string[] }
24
+
25
+ /** The object behind an id: a reference item, a work item, or a graph node pointing at one of them. */
26
+ function objectOf(store: ProjectStore, objectId: string): { kind: 'reference' | 'thread' | 'node'; ids: readonly string[]; sourceIds: readonly string[]; name: string } | null {
27
+ const ref = store.reference.get(objectId);
28
+ if (ref) return { kind: 'reference', ids: ref.ids, sourceIds: ref.sourceIds, name: ref.name };
29
+ const thread = store.threads.get(objectId);
30
+ if (thread) {
31
+ const facts = thread.factRecordIds.flatMap((f) => store.facts.get(f)?.aboutSourceIds ?? []);
32
+ return { kind: 'thread', ids: thread.ids, sourceIds: [...facts, ...thread.executionFacts.flatMap((s) => s.sourceIds), ...thread.qcFacts.flatMap((s) => s.sourceIds)], name: thread.title };
33
+ }
34
+ const node = store.nodes.get(objectId);
35
+ if (node) {
36
+ if (node.refKind === 'reference' || node.refKind === 'thread') {
37
+ const inner = objectOf(store, node.refId);
38
+ if (inner) return { ...inner, sourceIds: [...inner.sourceIds, ...node.sourceIds] };
39
+ }
40
+ return { kind: 'node', ids: [], sourceIds: node.sourceIds, name: node.label };
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /** Repository-relative paths, numbers and commits an object's sources point at, by the repositories of the ledger. */
46
+ function anchorsOf(ledger: Ledger, store: ProjectStore, objectId: string): Anchors | null {
47
+ const o = objectOf(store, objectId);
48
+ if (!o) return null;
49
+ const repos = ledger.repos();
50
+ const paths: { repo: string; path: string }[] = [];
51
+ const commits: string[] = [];
52
+ const add = (repo: string, path: string) => { if (!paths.some((p) => p.repo === repo && p.path === path)) paths.push({ repo, path }); };
53
+ for (const id of o.sourceIds) {
54
+ const s: Source | undefined = store.sources.get(id);
55
+ if (!s) continue;
56
+ const a = s.anchor;
57
+ if (a.kind === 'file') {
58
+ const repo = repos.filter((r) => isWithin(r.path, a.path)).sort((x, y) => y.path.length - x.path.length)[0];
59
+ if (repo) add(repo.id, normalizePath(a.path).slice(normalizePath(repo.path).length + 1).split('\\').join('/'));
60
+ } else if (a.kind === 'revision') {
61
+ const repo = repos.find((r) => pathKey(r.path) === pathKey(a.repo));
62
+ if (repo) add(repo.id, a.path);
63
+ } else if (a.kind === 'commit') commits.push(a.commit);
64
+ }
65
+ return { paths, nums: [...o.ids], commits };
66
+ }
67
+
68
+ /** The document an object mainly is: the path most of its sources are in. */
69
+ function mainPath(anchors: Anchors, store: ProjectStore, objectId: string): { repo: string; path: string } | null {
70
+ if (anchors.paths.length === 0) return null;
71
+ const o = objectOf(store, objectId);
72
+ const count = new Map<string, number>();
73
+ for (const id of o?.sourceIds ?? []) {
74
+ const a = store.sources.get(id)?.anchor;
75
+ const p = a?.kind === 'file' ? a.path : a?.kind === 'revision' ? a.path : null;
76
+ if (!p) continue;
77
+ const hit = anchors.paths.find((x) => normalizePath(p).split('\\').join('/').endsWith(x.path));
78
+ if (hit) count.set(`${hit.repo}\x1f${hit.path}`, (count.get(`${hit.repo}\x1f${hit.path}`) ?? 0) + 1);
79
+ }
80
+ const best = [...count].sort((a, b) => b[1] - a[1])[0]?.[0] ?? `${anchors.paths[0]!.repo}\x1f${anchors.paths[0]!.path}`;
81
+ const [repo, path] = best.split('\x1f') as [string, string];
82
+ return { repo, path };
83
+ }
84
+
85
+ /** The project's own label for a version (`v3.0` in its title or first lines), else its short commit. */
86
+ function versionLabel(ledger: Ledger, id: string, commit: string): string {
87
+ const e = ledger.resolve(id);
88
+ const head = (e?.text ?? '').split(/\r?\n/).slice(0, 8).join('\n');
89
+ const m = /(?:^|[\s((【·*])[vV](\d+(?:\.\d+)+)(?![\d.])/.exec(head);
90
+ return m ? `v${m[1]}` : commit.slice(0, 7);
91
+ }
92
+
93
+ const ev = (id: string, label: string, occurred: Occurred, line: string | null = null): EvidenceRef => ({ kind: 'ledger', id, label, line, occurred });
94
+
95
+ // ───────────────────────── versions and the current one ─────────────────────────
96
+
97
+ export function versionsView(ledger: Ledger, store: ProjectStore, objectId: string): VersionsView | null {
98
+ const anchors = anchorsOf(ledger, store, objectId);
99
+ if (!anchors) return null;
100
+ const main = mainPath(anchors, store, objectId);
101
+ if (!main) return { objectId, versions: [], historyFrom: null, document: null };
102
+ const v = ledger.docVersions(main.path, { repo: main.repo });
103
+ if (typeof v === 'string') return { objectId, versions: [], historyFrom: historyFrom(ledger, main.repo), document: main.path };
104
+ const patches = store.patches.filter((p) => p.status !== 'Rejected');
105
+ const versions: VersionView[] = v.versions.map((ver, i) => {
106
+ const next = v.versions[i + 1];
107
+ const verMs = msOf(ver.occurred.at) ?? 0;
108
+ // A semantic patch withdrew (part of) this version when it names this version, or a line this version has, or the
109
+ // document itself, and was made after this version and before the next one replaced it.
110
+ const patch = patches.find((p) => [p.oldAnchor, p.candidate].some((a) => {
111
+ if (!a) return false;
112
+ if (a.id === ver.id) return true;
113
+ if (a.kind === 'ledger' && a.id.startsWith('sup:')) {
114
+ const s = ledger.resolve(a.id);
115
+ return Boolean(s && s.label.startsWith(`${ver.path}:`) && (msOf(s.occurred.at) ?? 0) <= verMs);
116
+ }
117
+ return a.kind === 'file' && a.id === ver.path && (msOf(p.occurred.at) ?? 0) >= verMs && (!next || (msOf(p.occurred.at) ?? 0) < (msOf(next.occurred.at) ?? Infinity));
118
+ }));
119
+ return {
120
+ label: versionLabel(ledger, ver.id, ver.commit), commit: ver.commit, occurred: ver.occurred,
121
+ sections: ver.diff ? { added: ver.diff.added, removed: ver.diff.removed, changed: ver.diff.changed } : { added: [], removed: [], changed: [] },
122
+ supersededBy: patch ? { patchId: patch.id, number: patch.number, title: patch.title, partial: patch.partial } : null,
123
+ current: ver.current, history: !ver.current,
124
+ };
125
+ });
126
+ return { objectId, versions, historyFrom: historyFrom(ledger, main.repo), document: main.path };
127
+ }
128
+
129
+ /** The day a repository's document version history starts, when content before it is not versioned (AC-15); else null. */
130
+ function historyFrom(ledger: Ledger, repo: string): string | null {
131
+ return ledger.historyStart(repo);
132
+ }
133
+
134
+ export function docCurrentView(ledger: Ledger, store: ProjectStore, objectId: string): DocCurrentView | null {
135
+ const anchors = anchorsOf(ledger, store, objectId);
136
+ if (!anchors) return null;
137
+ const main = mainPath(anchors, store, objectId);
138
+ if (!main) return null;
139
+ const v = ledger.docVersions(main.path, { repo: main.repo });
140
+ if (typeof v === 'string') return null;
141
+ const cur = [...v.versions].reverse().find((x) => x.current) ?? null;
142
+ return {
143
+ objectId, versions: v.versions.length, historyFrom: historyFrom(ledger, main.repo),
144
+ current: cur ? `${versionLabel(ledger, cur.id, cur.commit)}${versionLabel(ledger, cur.id, cur.commit).startsWith('v') ? ` · ${cur.commit.slice(0, 7)}` : ''} · ${dayOf(cur.occurred.at)}` : null,
145
+ };
146
+ }
147
+
148
+ // ───────────────────────── How it got here ─────────────────────────
149
+
150
+ const STEP_KIND: Record<ProvenanceStep['kind'], LineageStepKind | null> = {
151
+ 'first appeared': 'First appeared', changed: 'Changed', deleted: 'Changed', 'says superseded': 'Replaced', 'obsolete-list row': 'Replaced',
152
+ 'named in plan': 'Planned', arrangement: 'Work', commit: 'Commit', merged: 'Merged', verdict: 'Verdict', mentioned: null, 'owner said': null, now: 'Now',
153
+ };
154
+
155
+ export function lineageView(ledger: Ledger, store: ProjectStore, objectId: string): LineageView | null {
156
+ const anchors = anchorsOf(ledger, store, objectId);
157
+ if (!anchors) return null;
158
+ const main = mainPath(anchors, store, objectId);
159
+ const sourceIds = objectOf(store, objectId)?.sourceIds ?? [];
160
+ const wholeFile = anchors.nums.length === 0 && sourceIds.some((id) => {
161
+ const a = store.sources.get(id)?.anchor;
162
+ return a?.kind === 'file' && a.headingPath.length === 0;
163
+ });
164
+ // Two traces, kept apart (owner 2026-09-30: a decision's popover read the history of DECISIONS.md — its 15 versions,
165
+ // the patch that archived a whole generation — as the decision's own): the object's own (its numbers, its commits)
166
+ // and the document it sits in (the document's versions, commits, where it stands now). An object that is the whole
167
+ // document owns the document's trace.
168
+ const docPaths = main ? [main.path] : anchors.paths.map((p) => p.path).slice(0, 3);
169
+ const ownProv = ledger.provenance({ nums: anchors.nums, commits: anchors.commits.slice(0, 10), repo: main?.repo }).steps;
170
+ const ownKeys = new Set(ownProv.map((s) => `${s.kind}|${s.entry}`));
171
+ const docAbout = wholeFile ? 'object' as const : 'document' as const;
172
+ const traced = [
173
+ ...ledger.provenance({ paths: docPaths, repo: main?.repo }).steps.filter((s) => !ownKeys.has(`${s.kind}|${s.entry}`)).map((s) => ({ s, about: docAbout })),
174
+ ...ownProv.map((s) => ({ s, about: 'object' as const })),
175
+ ].sort((a, b) => (msOf(a.s.occurred.at) ?? 0) - (msOf(b.s.occurred.at) ?? 0) || Number(a.s.kind === 'now') - Number(b.s.kind === 'now'));
176
+ const steps: LineageStepView[] = [];
177
+ const earliest = traced.find(({ s }) => s.kind !== 'now')?.s;
178
+ for (const { s, about } of traced) {
179
+ if (s.kind === 'says superseded' || s.kind === 'obsolete-list row') continue;
180
+ let kind = STEP_KIND[s.kind];
181
+ if (s.kind === 'owner said' && s === earliest) kind = 'First appeared';
182
+ if (!kind) continue;
183
+ steps.push({ kind, occurred: s.occurred, title: s.title, evidence: s.entry ? [ev(s.entry, s.title, s.occurred)] : [], history: s.history, about });
184
+ }
185
+ const ranges = sourceIds.flatMap((id) => {
186
+ const a = store.sources.get(id)?.anchor;
187
+ if (a?.kind !== 'file' || !main || !normalizePath(a.path).split('\\').join('/').endsWith(main.path)) return [];
188
+ return a.lineStart > 0 && a.lineEnd >= a.lineStart ? itemRanges(ledger.db, main.repo, main.path, anchors.nums, { from: a.lineStart, to: a.lineEnd }) : [];
189
+ });
190
+ if (ranges.length === 0 && main) {
191
+ const row = ledger.db.prepare("SELECT t.content FROM code_files f JOIN texts t ON t.key = 'blob:' || f.repo || ':' || f.blob WHERE f.repo = ? AND f.path = ? LIMIT 1").get(main.repo, main.path) as { content: string } | undefined;
192
+ if (row) {
193
+ const sections = splitMarkdown(row.content);
194
+ for (const id of sourceIds) {
195
+ const a = store.sources.get(id)?.anchor;
196
+ if (a?.kind !== 'file' || a.headingPath.length === 0) continue;
197
+ for (const section of sections) if (a.headingPath.join('\x1f') === section.headingPath.join('\x1f'))
198
+ ranges.push({ from: section.lineStart, to: section.lineEnd });
199
+ }
200
+ }
201
+ }
202
+ const ownNumbers = new Set(anchors.nums);
203
+ const seenSupersessions = new Set<string>();
204
+ for (const row of ledger.db.prepare("SELECT key, repo, source, path, replaced, replacement, pattern, syntax, text, first_line, current_line, current, first_commit FROM supersedes").all() as Record<string, unknown>[]) {
205
+ const id = String(row.key);
206
+ if (seenSupersessions.has(id)) continue;
207
+ const path = String(row.path ?? '');
208
+ const line = Number(row.current_line ?? row.first_line ?? 0);
209
+ const ownPath = !!main && String(row.repo ?? '') === main.repo && path === main.path;
210
+ const withinRange = ownPath && (wholeFile || ranges.some((r) => line >= r.from && line <= r.to));
211
+ const oldNumbers = replacedNumbers(typeof row.replaced === 'string' ? row.replaced : null);
212
+ const explicitlyOld = (!main || String(row.repo ?? '') === main.repo) && oldNumbers.some((n) => ownNumbers.has(n));
213
+ if (!withinRange && !explicitlyOld) continue;
214
+ seenSupersessions.add(id);
215
+ const resolved = ledger.resolve(id);
216
+ if (!resolved) continue;
217
+ const where = row.source === 'commit' ? `commit ${String(row.first_commit ?? '').slice(0, 10)}` : `${path}${line ? `:${line}` : ''}`;
218
+ const title = `${where} reads (${String(row.syntax ?? row.pattern)}): ${String(row.text).slice(0, 160)}`;
219
+ const kind = explicitlyOld ? 'Replaced' : 'Replaces';
220
+ steps.push({ kind, occurred: resolved.occurred, title, evidence: [ev(id, title, resolved.occurred)], history: Number(row.current) !== 1, about: 'object' });
221
+ }
222
+ // What the assets judged: the semantic patches that withdrew it or that it made, the send-backs aimed at it.
223
+ for (const p of store.patches.filter((x) => x.status !== 'Rejected')) {
224
+ const own = p.affects.includes(objectId);
225
+ const affects = own || (anchors.nums.length === 0 && [p.oldAnchor, p.newAnchor, p.decision].some((a) => a && main && (a.id === main.path || a.id.startsWith(`doc:${main.path}@`))));
226
+ if (!affects) continue;
227
+ steps.push({ kind: 'Replaced', occurred: p.occurred, title: `${p.number} ${p.title}: ${p.invalidated} → ${p.replacedBy}`, evidence: [p.candidate, ...(p.decision ? [p.decision] : [])], history: false, about: own ? 'object' : docAbout });
228
+ }
229
+ for (const sb of store.sendbacks.filter((x) => x.targetId === objectId)) {
230
+ steps.push({ kind: 'Send-back', occurred: sb.occurred, title: `Send-back to ${sb.to} (${sb.stage}): ${sb.what}`, evidence: sb.evidence, history: sb.stage === 'Closed', about: 'object' });
231
+ }
232
+ steps.sort((a, b) => (msOf(a.occurred.at) ?? 0) - (msOf(b.occurred.at) ?? 0) || Number(a.kind === 'Now') - Number(b.kind === 'Now'));
233
+ return { objectId, steps, document: main?.path ?? null };
234
+ }
235
+
236
+ // ───────────────────────── Project scope: what the ledger covers ─────────────────────────
237
+
238
+ export function coverageView(ledger: Ledger): NonNullable<ScopeKView['ledger']> {
239
+ const c = ledger.coverage();
240
+ // How deep each language of the code is read, as `Code` says it (codemap `codeLevels`): the same data, not a second rule.
241
+ const read = new Map(codeLevels(c.languages).map((l) => [l.language, l]));
242
+ const missing = [
243
+ ...c.sessions.missing.map((m) => ({ host: m.host, from: m.from, to: m.to, why: m.why })),
244
+ ...(c.sessions.daysWithCommitsButNoSession ?? []).map((g) => ({ host: 'all hosts', from: g.from, to: g.to, why: `${g.commitDays} day${g.commitDays === 1 ? '' : 's'} with commits on the trunk and no readable session` })),
245
+ ];
246
+ return {
247
+ repos: c.repos.map((r) => ({ repo: r.path, commits: r.commits, trunkCommits: r.trunkCommits, branches: r.branches, merges: r.merges, from: r.from, to: r.to, historyFrom: r.importRoots.length ? r.historyFrom : null })),
248
+ // Every language the project has, each with how far the ledger goes in it, measured (D98); a file tree only is said as
249
+ // such (QC AY). A language of the code carries what `Code` says of it.
250
+ languages: c.languages.map((l) => { const code = read.get(l.language); return { language: l.language, level: l.referenceLevel, files: l.files, ...(code ? { code } : {}) }; }),
251
+ sessions: { read: c.sessions.sessions, hosts: c.sessions.hosts.map((h) => h.host), missing },
252
+ unversionedDocs: c.outsideVersionControl.documents,
253
+ lastRebuild: c.lastRebuild ? { at: c.lastRebuild.at, ms: c.lastRebuild.ms } : null,
254
+ notRead: notReadOf(c),
255
+ };
256
+ }
257
+
258
+ /** What the ledger keeps without reading it, for Project scope to say (QC AY): null when it read everything. */
259
+ function notReadOf(c: ReturnType<Ledger['coverage']>): NonNullable<NonNullable<ScopeKView['ledger']>['notRead']> | null {
260
+ const noText = c.repos.flatMap((r) => (r.documentsWithoutText ? [r.documentsWithoutText] : []));
261
+ const loose = c.outsideVersionControl.notRead;
262
+ if (!noText.length && !loose) return null;
263
+ const paths = noText.flatMap((n) => n.paths);
264
+ return {
265
+ versionsWithoutText: noText.reduce((n, x) => n + x.versions, 0),
266
+ paths: paths.slice(0, 20),
267
+ morePaths: Math.max(0, paths.length - 20) + noText.reduce((n, x) => n + x.morePaths, 0),
268
+ reports: noText.flatMap((n) => n.reports),
269
+ unversionedTooLarge: loose?.files ?? 0,
270
+ unversionedPaths: loose?.paths ?? [],
271
+ effect: 'The ledger keeps these without their text: the superseded lines, numbers and verdicts in them are not in it. Read one in full at its path when a question needs it.',
272
+ };
273
+ }
274
+
275
+ // ───────────────────────── Code ─────────────────────────
276
+
277
+ interface FileRow { repo: string; path: string; lines: number | null; generated: number; classification: string | null; test: number; test_cases: number; last_commit: string | null; last_at: string | null; lang: string | null }
278
+
279
+ const under = (paths: readonly string[], p: string): boolean => paths.some((t) => { const x = t.replace(/\\/g, '/').replace(/\/+$/, ''); return p === x || p.startsWith(`${x}/`); });
280
+ /** Dart and Flutter keep a directory's tests under `test/` mirroring `lib/`: those count for the territory too. */
281
+ const testMirror = (paths: readonly string[]): string[] => paths.flatMap((p) => { const m = /^(.*?)(?:^|\/)lib(\/.*)?$/.exec(p.replace(/\\/g, '/')); return m ? [`${m[1] ? `${m[1]}/` : ''}test${m[2] ?? ''}`] : []; });
282
+
283
+ function commitFacts(ledger: Ledger, hash: string | null): { commit: string; occurred: Occurred; subject: string } | null {
284
+ if (!hash) return null;
285
+ const e = ledger.resolve(`commit:${hash.slice(0, 12)}`);
286
+ return e ? { commit: hash.slice(0, 12), occurred: e.occurred, subject: e.label.slice(8) } : null;
287
+ }
288
+
289
+ /** Until the model draws territories, show actual disjoint directories with their own measured facts. */
290
+ function unmappedTerritories(ledger: Ledger, repo: string): CodeTerritory[] {
291
+ const paths = (ledger.db.prepare('SELECT path FROM code_files WHERE repo = ?').all(repo) as { path: string }[]).map((r) => r.path);
292
+ let depth = 1;
293
+ let groups = new Set(paths.map((p) => p.split('/').slice(0, depth).join('/')));
294
+ while (groups.size === 1 && paths.every((p) => p.split('/').length > depth) && depth < 8) {
295
+ depth++;
296
+ groups = new Set(paths.map((p) => p.split('/').slice(0, depth).join('/')));
297
+ }
298
+ return [...groups].sort().map((path) => ({
299
+ id: `unmapped:${repo}:${path}`, projectId: '', name: path, summary: 'No territories drawn yet', repo, paths: [path],
300
+ kind: 'shared', areaId: null, alsoServes: [], anomalies: [], roundId: null, jobId: null, updatedAt: '',
301
+ }));
302
+ }
303
+
304
+ export function codeView(ledger: Ledger, store: ProjectStore, project: Project): CodeView | null {
305
+ const repos = ledger.repos();
306
+ const main = repos[0];
307
+ if (!main) return null;
308
+ const headFacts = commitFacts(ledger, main.head);
309
+ const judged = store.territories.filter((t) => t.projectId === project.id);
310
+ const territories = judged.length ? judged : repos.flatMap((r) => unmappedTerritories(ledger, r.id));
311
+ const repoOf = (t: { repo: string }) => repos.find((r) => r.id === t.repo || pathKey(r.path) === pathKey(t.repo) || endsWithPath(r.path, t.repo)) ?? main;
312
+ const files = new Map<string, FileRow[]>();
313
+ const filesOf = (repo: string): FileRow[] => {
314
+ if (!files.has(repo)) files.set(repo, ledger.db.prepare('SELECT * FROM code_files WHERE repo = ?').all(repo) as unknown as FileRow[]);
315
+ return files.get(repo)!;
316
+ };
317
+ const indexes = new Map(repos.map((r) => [r.id, new CodeMapIndex(ledger, store, r.id)]));
318
+ const areaName = (id: string) => store.reference.get(id)?.name ?? id;
319
+ const views: TerritoryView[] = territories.map((t) => {
320
+ const repo = repoOf(t);
321
+ const index = indexes.get(repo.id)!;
322
+ const mine = filesOf(repo.id).filter((f) => under(t.paths, f.path));
323
+ const product = mine.filter((f) => !f.generated && !f.classification);
324
+ const gen = mine.filter((f) => f.generated);
325
+ const others = territories.filter((o) => o.id !== t.id && repoOf(o).id === repo.id);
326
+ const inMine = (p: string) => under(t.paths, p);
327
+ const counts = index.dependencyCounts(t.paths, others);
328
+ const dependsOn = Object.keys(counts.dependsOn);
329
+ const dependedBy = Object.keys(counts.dependedBy);
330
+ const tests = filesOf(repo.id).filter((f) => f.test && (inMine(f.path) || under(testMirror(t.paths), f.path)));
331
+ const last = [...mine].filter((f) => f.last_at).sort((a, b) => (msOf(materialTime(b.last_at!) ?? b.last_at) ?? 0) - (msOf(materialTime(a.last_at!) ?? a.last_at) ?? 0))[0];
332
+ const builtBy = index.builtBy(t.paths);
333
+ return {
334
+ id: t.id, name: t.name, summary: t.summary, repo: repo.path, paths: t.paths,
335
+ row: t.kind === 'area' && t.areaId ? { kind: 'area', areaId: t.areaId, areaName: areaName(t.areaId) } : t.kind === 'non-product' ? { kind: 'non-product' } : { kind: 'shared' },
336
+ size: { files: t.kind === 'non-product' ? mine.length : product.length, lines: (t.kind === 'non-product' ? mine : product).reduce((n, f) => n + (f.lines ?? 0), 0), generatedFiles: gen.length, generatedLines: gen.reduce((n, f) => n + (f.lines ?? 0), 0) },
337
+ dependsOn, dependedBy, dependsOnCounts: counts.dependsOn, dependedByCounts: counts.dependedBy,
338
+ alsoServes: t.alsoServes.map((a) => ({ areaId: a, areaName: areaName(a) })),
339
+ builtBy, generations: index.generationOrigins(t.paths).map((g) => ({ generationId: g.generationId, name: g.name, share: g.share, works: g.work })), currentUse: index.currentUse(t.paths),
340
+ tests: tests.reduce((n, f) => n + (f.test_cases || 1), 0),
341
+ lastChange: last ? commitFacts(ledger, last.last_commit) : null,
342
+ anomalies: t.anomalies.map((a) => ({ kind: a.kind, text: a.text, evidence: a.evidence, basis: a.basis === 'Explicit' ? 'Explicit' : 'Inferred', sendBackId: a.sendBackId, noteIds: a.noteIds })),
343
+ };
344
+ });
345
+ const coverage = ledger.coverage();
346
+ return {
347
+ projectId: project.id,
348
+ // A head the ledger has no commit for is first seen by the rebuild that read it, not at the moment of the query (CKC-22 AC-10).
349
+ version: { repo: main.path, branch: main.headBranch, commit: (main.head ?? '').slice(0, 12), occurred: headFacts?.occurred ?? undated(coverage.lastRebuild?.at ?? '', null) },
350
+ // How deep each language of the code is read, from the coverage the ledger measured (codemap `codeLevels`, D98 补).
351
+ levels: codeLevels(coverage.languages),
352
+ generatedAt: coverage.lastRebuild?.at ?? new Date().toISOString(),
353
+ territories: views,
354
+ };
355
+ }
356
+
357
+ export function territoryView(ledger: Ledger, store: ProjectStore, territoryId: string): TerritoryDetailView | null {
358
+ const t = store.territories.get(territoryId) ?? ledger.repos().flatMap((r) => unmappedTerritories(ledger, r.id)).find((x) => x.id === territoryId);
359
+ if (!t) return null;
360
+ const repos = ledger.repos();
361
+ const repo = repos.find((r) => r.id === t.repo || pathKey(r.path) === pathKey(t.repo)) ?? repos[0];
362
+ if (!repo) return null;
363
+ const files = (ledger.db.prepare('SELECT * FROM code_files WHERE repo = ?').all(repo.id) as unknown as FileRow[]).filter((f) => under(t.paths, f.path));
364
+ const edges = ledger.db.prepare('SELECT src, dst FROM code_deps WHERE repo = ? AND external = 0').all(repo.id) as { src: string; dst: string }[];
365
+ const index = new CodeMapIndex(ledger, store, repo.id);
366
+ const out: CodeFileView[] = files.sort((a, b) => a.path.localeCompare(b.path)).map((f) => {
367
+ const work = index.workForCommit(f.last_commit);
368
+ return {
369
+ path: f.path, lines: f.lines ?? 0, generated: f.generated === 1,
370
+ importedBy: [...new Set(edges.filter((e) => e.dst === f.path).map((e) => e.src))].sort(),
371
+ imports: [...new Set(edges.filter((e) => e.src === f.path).map((e) => e.dst))].sort(),
372
+ lastCommit: commitFacts(ledger, f.last_commit),
373
+ workId: work?.id ?? null, workLabel: work?.label ?? null,
374
+ };
375
+ });
376
+ return { territoryId, files: out };
377
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `pk mcp`: ProjectKeeper as a local MCP server (D108), for agents and clients that take their tools over MCP — Claude
3
+ * Desktop, Cursor, Claude Code, Codex, any MCP client. The command line `pk` stays the primary way; this is an entry for
4
+ * others, and its tools are thin wrappers over what `pk` already does (src/mcp/tools.ts).
5
+ *
6
+ * The transport is MCP's stdio transport, written here so that nothing is added to the dependencies (D108): one
7
+ * JSON-RPC 2.0 message per line on stdin, one per line on stdout, and nothing else ever on stdout — what the server has
8
+ * to say besides goes to stderr. Implemented is what a client needs to list and call tools: `initialize`, the
9
+ * `notifications/initialized` notification, `ping`, `tools/list` and `tools/call`. A line that is not JSON is answered
10
+ * with -32700, a method the server does not have with -32601, and no message, however wrong, stops the server.
11
+ */
12
+ import { readFileSync } from 'node:fs';
13
+ import { dirname, resolve } from 'node:path';
14
+ import { createInterface } from 'node:readline';
15
+ import type { Readable, Writable } from 'node:stream';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { projectKeeperTools, type McpDefaults, type McpTool, type ToolResult } from './tools.ts';
18
+
19
+ /** The protocol versions this server speaks, newest first. A client asking for one of them gets it echoed; any other gets the newest. */
20
+ export const PROTOCOL_VERSIONS: readonly string[] = ['2025-06-18', '2025-03-26', '2024-11-05'];
21
+
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+ /** ProjectKeeper's version, as the server names itself to the client. */
24
+ export const SERVER_VERSION = (JSON.parse(readFileSync(resolve(here, '..', '..', 'package.json'), 'utf8')) as { version: string }).version;
25
+
26
+ type Id = string | number | null;
27
+ type Message = { jsonrpc?: unknown; id?: unknown; method?: unknown; params?: unknown; result?: unknown; error?: unknown };
28
+ type Response = { jsonrpc: '2.0'; id: Id; result: unknown } | { jsonrpc: '2.0'; id: Id; error: { code: number; message: string } };
29
+
30
+ const PARSE_ERROR = -32700;
31
+ const INVALID_REQUEST = -32600;
32
+ const METHOD_NOT_FOUND = -32601;
33
+ const INVALID_PARAMS = -32602;
34
+ const INTERNAL_ERROR = -32603;
35
+
36
+ const failure = (id: Id, code: number, message: string): Response => ({ jsonrpc: '2.0', id, error: { code, message } });
37
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
38
+ const validId = (v: unknown): v is string | number => typeof v === 'string' || (typeof v === 'number' && Number.isFinite(v));
39
+
40
+ /**
41
+ * Answer one message: a request gets its response, a notification (no id) and a client's response to us get nothing.
42
+ * Never throws: whatever goes wrong inside becomes an error response.
43
+ */
44
+ async function answer(message: unknown, tools: readonly McpTool[], log: (line: string) => void): Promise<Response | null> {
45
+ if (!isRecord(message)) return failure(null, INVALID_REQUEST, 'Invalid Request: a message is a JSON object');
46
+ const m = message as Message;
47
+ const hasId = 'id' in m && m.id !== undefined;
48
+ // A response to a request of ours: this server sends none, so there is nothing to match it with.
49
+ if (typeof m.method !== 'string') return hasId && !('result' in m || 'error' in m) ? failure(validId(m.id) ? m.id : null, INVALID_REQUEST, 'Invalid Request: no method') : null;
50
+ if (!hasId) {
51
+ // Notifications are never answered: `notifications/initialized`, `notifications/cancelled` and the rest.
52
+ return null;
53
+ }
54
+ if (!validId(m.id)) return failure(null, INVALID_REQUEST, 'Invalid Request: the id is a string or a number');
55
+ const id = m.id;
56
+ if (m.jsonrpc !== '2.0') return failure(id, INVALID_REQUEST, 'Invalid Request: jsonrpc must be "2.0"');
57
+ const params = isRecord(m.params) ? m.params : {};
58
+ try {
59
+ switch (m.method) {
60
+ case 'initialize': {
61
+ const asked = typeof params.protocolVersion === 'string' ? params.protocolVersion : '';
62
+ return {
63
+ jsonrpc: '2.0', id, result: {
64
+ protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
65
+ capabilities: { tools: {} },
66
+ serverInfo: { name: 'projectkeeper', version: SERVER_VERSION },
67
+ },
68
+ };
69
+ }
70
+ case 'ping':
71
+ return { jsonrpc: '2.0', id, result: {} };
72
+ case 'tools/list':
73
+ return { jsonrpc: '2.0', id, result: { tools: tools.map(({ name, description, inputSchema, annotations }) => ({ name, description, inputSchema, annotations })) } };
74
+ case 'tools/call': {
75
+ const tool = tools.find((t) => t.name === params.name);
76
+ if (!tool) return failure(id, INVALID_PARAMS, `Unknown tool: ${String(params.name)}`);
77
+ if (params.arguments !== undefined && params.arguments !== null && !isRecord(params.arguments)) return failure(id, INVALID_PARAMS, 'arguments must be an object');
78
+ const result: ToolResult = await tool.call((params.arguments as Record<string, unknown> | undefined) ?? {});
79
+ return { jsonrpc: '2.0', id, result };
80
+ }
81
+ default:
82
+ return failure(id, METHOD_NOT_FOUND, `Method not found: ${m.method}`);
83
+ }
84
+ } catch (e) {
85
+ log(`[pk mcp] ${m.method}: ${(e as Error)?.stack ?? String(e)}`);
86
+ return failure(id, INTERNAL_ERROR, `Internal error: ${(e as Error)?.message ?? String(e)}`);
87
+ }
88
+ }
89
+
90
+ /** Options of `pk mcp`: the defaults its tools fall back on, and where its own remarks go (stderr). */
91
+ export interface McpServerOptions extends Partial<McpDefaults> {
92
+ readonly log?: (line: string) => void;
93
+ /** The tools to serve; ProjectKeeper's own five unless a test gives others. */
94
+ readonly tools?: readonly McpTool[];
95
+ }
96
+
97
+ /**
98
+ * Serve MCP over a pair of streams (stdin and stdout for `pk mcp`) until the input ends. Requests are answered as they
99
+ * finish, each on its own line; a batch (an array, as the 2025-03-26 protocol allows) is answered with one array.
100
+ */
101
+ export async function serveMcp(input: Readable, output: Writable, options: McpServerOptions = {}): Promise<void> {
102
+ const log = options.log ?? ((line: string) => process.stderr.write(`${line}\n`));
103
+ const tools = options.tools ?? projectKeeperTools({ port: options.port ?? null, project: options.project ?? null, cwd: resolve(options.cwd ?? process.cwd()), home: options.home });
104
+ const send = (message: Response | Response[]) => { output.write(`${JSON.stringify(message)}\n`); };
105
+ const pending = new Set<Promise<void>>();
106
+ const lines = createInterface({ input, crlfDelay: Infinity });
107
+ for await (const raw of lines) {
108
+ const line = raw.trim();
109
+ if (!line) continue;
110
+ let message: unknown;
111
+ try { message = JSON.parse(line); }
112
+ catch { send(failure(null, PARSE_ERROR, 'Parse error: a line is one JSON-RPC message')); continue; }
113
+ const task = (async () => {
114
+ if (Array.isArray(message)) {
115
+ if (!message.length) { send(failure(null, INVALID_REQUEST, 'Invalid Request: an empty batch')); return; }
116
+ const answers = (await Promise.all(message.map((m) => answer(m, tools, log)))).filter((a): a is Response => a !== null);
117
+ if (answers.length) send(answers);
118
+ return;
119
+ }
120
+ const reply = await answer(message, tools, log);
121
+ if (reply) send(reply);
122
+ })().catch((e) => { log(`[pk mcp] ${(e as Error)?.stack ?? String(e)}`); });
123
+ pending.add(task);
124
+ void task.finally(() => pending.delete(task));
125
+ }
126
+ await Promise.allSettled([...pending]);
127
+ }