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,549 @@
1
+ /**
2
+ * JSON API consumed by the workbench and the CLI. Handlers stay thin: the App and the
3
+ * services own the behaviour.
4
+ */
5
+ import type { App } from './app.ts';
6
+ import { HttpApp, HttpError, optionalString, requireString } from './http.ts';
7
+ import { SCOPE_CATEGORY, SCOPE_RELATION, isOneOf } from '../model/vocab.ts';
8
+ import { ORGANIZING_LEVEL } from '../model/vocab.ts';
9
+ import type { KeeperJob, Usage } from '../model/types.ts';
10
+ import { anchorLabel } from '../sources/anchor.ts';
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ import { fingerprint } from '../model/ids.ts';
13
+ import { cameFromView, changeRow, factDetail, graphView, nodeDetail, overview, relationDetail, threadDetail } from './graph-view.ts';
14
+ import { howThisProjectWorks, isRemoved, jobOpens, organizingLevels, organizingPlanView, scopeListView, settledByRuleView, toolchainView } from './workbench-content.ts';
15
+ import { diffVersions, listVersions, readVersion, snapshot } from '../store/versions.ts';
16
+ import { projectDir } from '../store/paths.ts';
17
+ import { pendingCount } from '../keeper/organize/follow-up.ts';
18
+ import { agentUsage, assembleContext, contextOptions } from '../context/assemble.ts';
19
+ import { knownPart, resolveProject } from '../context/ask.ts';
20
+ import { fileLocation, isCodeSource, locationText } from '../context/code-source.ts';
21
+ import { sourceOriginal } from '../context/object-brief.ts';
22
+ import { rulePage } from '../context/project-rules.ts';
23
+ import { resolveMergedId } from '../keeper/merge.ts';
24
+ import { samePath } from '../util/paths.ts';
25
+ import { registerKRoutes } from './k-views.ts';
26
+ import { registerProjectFolderRoutes } from './project-folder-api.ts';
27
+ import { registerKAgentRoutes } from './k-agent-api.ts';
28
+ import { registerDraftRoutes } from './drafts-api.ts';
29
+ import { notOrganizedPackage, registerKeeperPageRoutes } from './keeper-page.ts';
30
+ import { registerKeyRoutes } from './keys-api.ts';
31
+ import { registerFolderRoutes } from './folders-api.ts';
32
+ import { takeoverState } from '../keeper/organize/takeover-state.ts';
33
+ import { MODEL_STEP_KINDS, STEP_SETTING_AS, THINKING_LEVELS } from '../keeper/clerk-steps.ts';
34
+
35
+ export function registerRoutes(http: HttpApp, app: App, uiDir: string, vendorDir: string): void {
36
+ // Increment K: the process view, versions and lineage, Code, a round's tree, the Project scope additions (views-k.ts).
37
+ registerKRoutes(http, app);
38
+ registerProjectFolderRoutes(http, app);
39
+ registerKAgentRoutes(http, app);
40
+ registerDraftRoutes(http, app);
41
+ // The Keeper view's two pages: Takeover (Start, Clear) and Daily (the schedule) — keeper-page.ts (D105).
42
+ registerKeeperPageRoutes(http, app);
43
+ registerKeyRoutes(http, app);
44
+ // The folders of a directory, for the folder chooser where a location is typed (folders-api.ts).
45
+ registerFolderRoutes(http);
46
+ const projectSummary = (id: string) => {
47
+ const project = app.project(id);
48
+ const store = app.store(id);
49
+ return {
50
+ ...project,
51
+ counts: {
52
+ sources: store.sources.size, facts: store.facts.size, threads: store.threads.size, notes: store.notes.filter((n) => n.status === 'Current').length,
53
+ // `nodes` is everything the graph draws; `reference` is the product reference alone. Reporting one as the
54
+ // other turned a graph total into a reference count in a status report, so both are given by name.
55
+ changes: store.changes.size, nodes: store.nodes.size, reference: store.reference.size,
56
+ followUp: pendingCount(store), // §5.5: downstream objects still to judge, and how many changes they belong to
57
+ },
58
+ coverage: store.coverage,
59
+ };
60
+ };
61
+
62
+ http.route('GET', '/api/workspace', () => ({
63
+ projects: app.workspace.list().map((p) => ({ id: p.id, name: p.name, locations: p.locations, lastOpenedAt: p.lastOpenedAt, createdAt: p.createdAt })),
64
+ settings: app.workspace.settings,
65
+ lastProjectId: app.workspace.lastProjectId,
66
+ home: app.home,
67
+ }));
68
+
69
+ http.route('POST', '/api/projects', ({ body }) => {
70
+ const b = (body ?? {}) as { name?: unknown; locations?: unknown };
71
+ const name = requireString(b.name, 'name');
72
+ const locations = Array.isArray(b.locations) ? b.locations.filter((l): l is string => typeof l === 'string' && l.trim().length > 0).map((l) => l.trim()) : [];
73
+ if (locations.length === 0) throw new HttpError(400, 'At least one location is required');
74
+ return projectSummary(app.addProject(name, locations).id);
75
+ });
76
+
77
+ http.route('GET', '/api/projects/:id', ({ params }) => projectSummary(params.id!));
78
+
79
+ http.route('POST', '/api/projects/:id/opened', ({ params }) => app.markOpened(params.id!));
80
+
81
+ http.route('GET', '/api/projects/:id/scope', ({ params }) => {
82
+ const project = app.project(params.id!);
83
+ const store = app.store(params.id!);
84
+ const reasonSources = Object.fromEntries(project.scope.flatMap((i) => i.reasonSourceIds.map((sid) => {
85
+ const s = store.sources.get(sid);
86
+ return [sid, s ? { id: s.id, title: s.title, label: anchorLabel(s.anchor), excerpt: s.excerpt } : null];
87
+ })));
88
+ return {
89
+ scope: project.scope, questions: project.scopeQuestions, keeperFiles: project.keeperFiles, roles: project.roles,
90
+ language: project.language, lastScopedAt: project.lastScopedAt, coverage: store.coverage,
91
+ authorizations: store.authorizations.filter((a) => !a.revokedAt), reasonSources, organizingPaused: project.organizingPaused,
92
+ // §6.7: the list sectioned (third-party material and generated output apart, ignored directories holding
93
+ // documents apart; CKC-04 AC-13, AC-17), the toolchain, and what the rules settle directly (§1.11, §3.7).
94
+ scopeView: scopeListView(project), toolchain: toolchainView(project), settledByRule: settledByRuleView(store, project),
95
+ // The interface's choices come from the vocabularies, never from a second list written into the page.
96
+ scopeVocab: { categories: SCOPE_CATEGORY, relations: SCOPE_RELATION, organizingLevels: ORGANIZING_LEVEL },
97
+ // §6.7 (D62, D64; CKC-21 AC-11): the project's rules in their three groups, and the framing round's plan and focus.
98
+ howThisProjectWorks: howThisProjectWorks(store), organizingPlan: organizingPlanView(store),
99
+ };
100
+ });
101
+
102
+ // Before `Start` the Keeper reads nothing (D105; CKC-13 AC-20): the boundary is drawn and the material read by the takeover.
103
+ const started = (id: string) => { if (takeoverState(app.store(id), app.project(id)).phase === 'Not started') throw new HttpError(409, 'This project has not been started: choose a depth and press Start on the Takeover page. Nothing is read before that.'); return id; };
104
+ http.route('POST', '/api/projects/:id/scope/rescan', ({ params }) => projectSummary(app.rescanProject(started(params.id!)).id));
105
+ http.route('POST', '/api/projects/:id/intake', async ({ params }) => (await app.intakeProject(started(params.id!))) ?? { running: true });
106
+
107
+ http.route('POST', '/api/projects/:id/scope/items', ({ params, body }) => {
108
+ const b = (body ?? {}) as Record<string, unknown>;
109
+ const category = b.category;
110
+ const relation = b.relation;
111
+ if (!isOneOf(SCOPE_CATEGORY, category)) throw new HttpError(400, `category must be one of ${SCOPE_CATEGORY.join(', ')}`);
112
+ if (!isOneOf(SCOPE_RELATION, relation)) throw new HttpError(400, `relation must be one of ${SCOPE_RELATION.join(', ')}`);
113
+ return app.addScopeItem(params.id!, { path: requireString(b.path, 'path'), category, relation, reason: requireString(b.reason, 'reason') }).scope;
114
+ });
115
+
116
+ http.route('DELETE', '/api/projects/:id/scope/items/:itemId', ({ params }) => app.removeScopeItem(params.id!, params.itemId!).scope);
117
+
118
+ http.route('POST', '/api/projects/:id/scope/questions/:qid', ({ params, body }) => {
119
+ const b = (body ?? {}) as { answer?: unknown };
120
+ return app.answerScopeQuestion(params.id!, params.qid!, requireString(b.answer, 'answer')).scopeQuestions;
121
+ });
122
+
123
+ http.route('GET', '/api/projects/:id/sources', ({ params, query }) => {
124
+ const store = app.store(params.id!);
125
+ const text = (query.get('q') ?? '').toLowerCase();
126
+ const usedAs = query.get('usedAs');
127
+ const availability = query.get('availability');
128
+ const level = query.get('level'); // §6.7: the list also filters by organizing level (§1.11)
129
+ const levels = organizingLevels(store, app.project(params.id!), app.pendingChanges(params.id!));
130
+ return store.sources.all()
131
+ .filter((s) => !text || s.title.toLowerCase().includes(text) || anchorLabel(s.anchor).toLowerCase().includes(text) || s.excerpt.toLowerCase().includes(text))
132
+ .filter((s) => !usedAs || (usedAs === 'Not yet judged' ? s.usedAs === null : s.usedAs === usedAs))
133
+ .filter((s) => !availability || s.availability === availability)
134
+ .filter((s) => !level || levels.ofSource(s) === level)
135
+ .map((s) => ({ id: s.id, title: s.title, label: anchorLabel(s.anchor), kind: s.anchor.kind, usedAs: s.usedAs, availability: s.availability, scopeItemId: s.scopeItemId, readAt: s.version.readAt, ids: s.ids, hasCredential: s.hasCredential, bytes: s.bytes, level: levels.ofSource(s) }))
136
+ .sort((a, b) => a.label.localeCompare(b.label));
137
+ });
138
+
139
+ // One route for both readers of an original (Spec §6.4, §7.10): the workbench gets the source as it is; the agent
140
+ // entry (`for=agent`) gets the text it prints — the same reading, except that a code file gives where it is and the
141
+ // version read, and never its lines (D65; CKC-12 AC-22, AC-43).
142
+ http.route('GET', '/api/projects/:id/sources/:sid', ({ params, query }) => {
143
+ const store = app.store(params.id!);
144
+ const source = store.sources.get(params.sid!);
145
+ if (!source) throw new HttpError(404, 'Unknown source');
146
+ // Re-check availability at read time: the excerpt stays as saved (§6.4).
147
+ let availability = source.availability;
148
+ let current: 'same' | 'changed' | 'missing' = 'same';
149
+ if (source.anchor.kind === 'file') {
150
+ if (!existsSync(source.anchor.path)) current = 'missing';
151
+ else {
152
+ try {
153
+ if (fingerprint(readFileSync(source.anchor.path)) !== source.version.fingerprint) current = 'changed';
154
+ } catch { current = 'missing'; }
155
+ }
156
+ if (current === 'missing' && !availability) availability = 'No longer available';
157
+ if (current === 'changed' && !availability) availability = 'Changed since read';
158
+ }
159
+ const project = app.project(params.id!);
160
+ const origin = project.scope.find((i) => i.id === source.scopeItemId);
161
+ if (query.get('for') === 'agent') {
162
+ const code = isCodeSource(source) ? fileLocation(project, source) : null;
163
+ const path = source.anchor.kind === 'file' ? source.anchor.path : null;
164
+ const otherParts = path === null ? [] : store.sources.filter((x) => x.id !== source.id && x.anchor.kind === 'file' && samePath(x.anchor.path, path))
165
+ .map((x) => ({ id: x.id, label: anchorLabel(x.anchor).slice(path.length).replace(/^ › /, '') || anchorLabel(x.anchor) }));
166
+ const text = sourceOriginal({
167
+ title: source.title, kind: source.anchor.kind, label: anchorLabel(source.anchor), readAt: source.version.readAt, commit: source.version.commit ?? null,
168
+ fingerprint: source.version.fingerprint, usedAs: source.usedAs, origin: origin ? { path: origin.path, relation: origin.relation } : null,
169
+ availabilityNow: availability, currentState: current, hasCredential: source.hasCredential,
170
+ excerpt: code ? '' : source.excerpt, lineStart: source.anchor.kind === 'file' ? source.anchor.lineStart : 1,
171
+ code: code ? { location: locationText(code), file: code.file } : null, otherParts,
172
+ });
173
+ return { id: source.id, kind: source.anchor.kind, code: code !== null, text };
174
+ }
175
+ return { ...source, label: anchorLabel(source.anchor), availabilityNow: availability, currentState: current, origin: origin ? { path: origin.path, relation: origin.relation } : null, trace: store.traceFor('sources', source.id, 20) };
176
+ });
177
+ // A project rule by its id, for the agent entry (Spec §7.10, §1.15): every rule id a pack names can be read.
178
+ http.route('GET', '/api/projects/:id/rules/:rid', ({ params }) => {
179
+ const store = app.store(params.id!);
180
+ const rule = store.rules.get(params.rid!);
181
+ if (!rule) throw new HttpError(404, 'Unknown rule');
182
+ const sources = rule.sourceIds.map((id) => { const s = store.sources.get(id); return { id, title: s?.title ?? id, label: s ? anchorLabel(s.anchor) : id }; });
183
+ const next = rule.replacedBy ? store.rules.get(rule.replacedBy) : undefined;
184
+ const withoutOwner = store.marks.find((m) => m.targetId === rule.id && m.kind === 'Decided without owner' && !m.closed) ?? null;
185
+ return { id: rule.id, kind: 'rule', text: rulePage({ rule, sources, replacedBy: next ? { id: next.id, summary: next.summary } : null, withoutOwner }) };
186
+ });
187
+
188
+ // ---- Graph, strip, details (§6.2–§6.5) ----
189
+ http.route('GET', '/api/projects/:id/graph', ({ params }) => {
190
+ const store = app.store(params.id!);
191
+ return { ...graphView(store, app.project(params.id!)), changes: store.changes.all().map((c) => ({ id: c.id, affects: c.affects, propagation: c.propagation })) };
192
+ });
193
+ http.route('GET', '/api/projects/:id/overview', ({ params, query }) => overview(app.store(params.id!), app.project(params.id!), optionalString(query.get('since')), optionalString(query.get('selection'))));
194
+ http.route('GET', '/api/projects/:id/nodes/:nid', ({ params }) => { const d = nodeDetail(app.store(params.id!), app.project(params.id!), params.nid!); if (!d) throw new HttpError(404, 'Unknown node'); return d; });
195
+ http.route('GET', '/api/projects/:id/relations/:rid', ({ params }) => { const d = relationDetail(app.store(params.id!), params.rid!); if (!d) throw new HttpError(404, 'Unknown relation'); return d; });
196
+ http.route('GET', '/api/projects/:id/facts/:fid', ({ params }) => { const d = factDetail(app.store(params.id!), params.fid!); if (!d) throw new HttpError(404, 'Unknown fact record'); return d; });
197
+ http.route('GET', '/api/projects/:id/threads/:tid', ({ params }) => { const d = threadDetail(app.store(params.id!), params.tid!); if (!d) throw new HttpError(404, 'Unknown work thread'); return d; });
198
+ http.route('GET', '/api/projects/:id/notes', ({ params }) => ({ notes: app.store(params.id!).notes.all(), relookDone: app.store(params.id!).judgements.size > 0 }));
199
+ http.route('GET', '/api/projects/:id/notes/:nid', ({ params }) => { const store = app.store(params.id!); const n = store.notes.get(params.nid!); if (!n) throw new HttpError(404, 'Unknown note'); const j = store.judgements.get(n.versions[n.versions.length - 1]!.judgementRecordId) ?? null; return { ...n, judgement: j, cameFrom: cameFromView(store, n) }; });
200
+ http.route('GET', '/api/projects/:id/changes', ({ params }) => {
201
+ const store = app.store(params.id!);
202
+ const rows = store.changes.all().sort((a, b) => a.at.localeCompare(b.at)).map((c) => changeRow(store, c));
203
+ const segments: { name: string | null; sourceId: string | null; changes: typeof rows }[] = [];
204
+ for (const r of rows) {
205
+ const name = r.segment?.name ?? null;
206
+ let seg = segments.find((s) => s.name === name);
207
+ if (!seg) { seg = { name, sourceId: r.segment?.sourceId ?? null, changes: [] }; segments.push(seg); }
208
+ seg.changes.push(r);
209
+ }
210
+ const pending = store.coverage.scopes.reduce((n, s) => n + s.pending.length, 0);
211
+ return { changes: rows, segments, pending };
212
+ });
213
+ const store_ = (id: string) => app.store(id);
214
+
215
+ // ---- Keeper: activity, control, connections (§6.9, §6.10) ----
216
+ const jobRow = (j: KeeperJob) => ({
217
+ id: j.id, kind: j.kind, initiator: j.initiator, agent: j.agent, scope: j.scope, status: j.status, queuedAt: j.queuedAt, startedAt: j.startedAt, endedAt: j.endedAt,
218
+ savedResults: j.savedResults, usage: j.usage, model: j.model, steps: j.steps.slice(-40), error: j.error, requestBasis: j.requestBasis, parentJobId: j.parentJobId,
219
+ resultText: j.resultText, priority: j.priority, sessionFile: j.sessionFile,
220
+ // CKC-03 AC-23: reads the boundary refused during this job (each is also a step of its own).
221
+ boundaryDenials: j.boundaryDenials ?? 0,
222
+ // §6.9: the round that recorded the project's rules and the plan and focus opens them (§3.7).
223
+ opens: jobOpens(j),
224
+ });
225
+
226
+ /** A job and what it delegated are one piece of work: the tree is shown together and the usage is counted both ways —
227
+ * each job on its own, and the whole tree under the job that started it (D59 design 1; §6.9). */
228
+ const delegationOf = (all: readonly KeeperJob[]) => {
229
+ const children = new Map<string, KeeperJob[]>();
230
+ for (const j of all) if (j.parentJobId) children.set(j.parentJobId, [...(children.get(j.parentJobId) ?? []), j]);
231
+ const withDelegated = (j: KeeperJob): Usage => (children.get(j.id) ?? []).reduce((u, c) => {
232
+ const t = withDelegated(c);
233
+ return { input: u.input + t.input, output: u.output + t.output, cacheRead: u.cacheRead + t.cacheRead, cacheWrite: u.cacheWrite + t.cacheWrite, cost: u.cost === null && t.cost === null ? null : (u.cost ?? 0) + (t.cost ?? 0) };
234
+ }, { ...j.usage });
235
+ const under = (j: KeeperJob): KeeperJob[] => (children.get(j.id) ?? []).flatMap((c) => [c, ...under(c)]);
236
+ // Boundary denials are recorded on the job that was refused; the main job's total counts the tree under it.
237
+ const denialsDelegated = (j: KeeperJob): number => (children.get(j.id) ?? []).reduce((n, c) => n + denialsDelegated(c), j.boundaryDenials ?? 0);
238
+ return { children, withDelegated, under, denialsDelegated };
239
+ };
240
+
241
+ http.route('GET', '/api/projects/:id/activity', async ({ params }) => {
242
+ const store = app.store(params.id!);
243
+ const project = app.project(params.id!);
244
+ const status = await app.keeper.status(params.id!);
245
+ const provider = await app.keeper.providerState(params.id!);
246
+ const all = store.jobs.all();
247
+ const { children, withDelegated, under, denialsDelegated } = delegationOf(all);
248
+ const recent = all.sort((a, b) => (b.startedAt ?? b.queuedAt).localeCompare(a.startedAt ?? a.queuedAt)).slice(0, 200);
249
+ // A subagent is shown with the job that sent it even when it falls outside the most recent 200, so the tree is
250
+ // never missing a branch the reader can see the top of.
251
+ const shown = new Map(recent.map((j) => [j.id, j]));
252
+ for (const j of recent) for (const c of under(j)) shown.set(c.id, c);
253
+ const jobs = [...shown.values()].map((j) => ({ ...jobRow(j), delegated: (children.get(j.id) ?? []).length, usageWithDelegated: withDelegated(j), boundaryDenialsWithDelegated: denialsDelegated(j) }));
254
+ return { ...status, jobs, organizingPaused: project.organizingPaused, agent: 'pi', model: provider.model, connected: provider.connected };
255
+ });
256
+ http.route('GET', '/api/projects/:id/activity/:jobId', ({ params }) => {
257
+ const store = app.store(params.id!);
258
+ const job = store.jobs.get(params.jobId!);
259
+ if (!job) throw new HttpError(404, 'Unknown job');
260
+ return { ...jobRow(job), steps: job.steps, trace: store.traceByJob(job.id, 200) };
261
+ });
262
+ // Follow-up rounds: what each round judged, and the owner marking one as looked at (D56 item 3).
263
+ http.route('GET', '/api/projects/:id/rounds', ({ params }) => {
264
+ const store = app.store(params.id!);
265
+ const name = (id: string) => store.nodes.get(id)?.label ?? store.reference.get(id)?.name ?? store.threads.get(id)?.title ?? id;
266
+ return {
267
+ rounds: store.rounds.all().sort((a, b) => b.number - a.number).map((r) => ({
268
+ ...r,
269
+ result: r.result ? { ...r.result, behind: r.result.behind.map((b) => ({ ...b, label: name(b.nodeId) })) } : null,
270
+ mainJob: r.mainJobId ? store.jobs.get(r.mainJobId)?.scope.label ?? null : null,
271
+ })),
272
+ };
273
+ });
274
+ http.route('POST', '/api/projects/:id/rounds/:roundId/seen', ({ params }) => {
275
+ const store = app.store(params.id!);
276
+ const round = store.rounds.get(params.roundId!);
277
+ if (!round) throw new HttpError(404, 'Unknown round');
278
+ store.rounds.put({ ...round, seenAt: new Date().toISOString() });
279
+ return { ok: true };
280
+ });
281
+ http.route('POST', '/api/projects/:id/organize', async ({ params }) => ({ coverage: await app.organizing.replan(params.id!) }));
282
+ http.route('POST', '/api/projects/:id/relook', ({ params, body }) => {
283
+ const b = (body ?? {}) as { kind?: unknown; id?: unknown };
284
+ const kind = b.kind === 'area' || b.kind === 'thread' ? b.kind : 'project';
285
+ const id = kind === 'project' ? null : requireString(b.id, 'id');
286
+ const job = app.organizing.requestRelook(params.id!, { kind, id, label: '' });
287
+ return { jobId: job.id, status: job.status };
288
+ });
289
+ // Confirm on a note (owner 2026-09-22): the owner's fixed statement with the note as context, answered in the background.
290
+ http.route('POST', '/api/projects/:id/notes/:nid/confirm', ({ params }) => app.conversation.confirm(params.id!, params.nid!));
291
+ http.route('GET', '/api/projects/:id/authorizations', ({ params }) => ({ authorizations: app.store(params.id!).authorizations.all() }));
292
+ http.route('POST', '/api/projects/:id/authorizations/:aid/revoke', ({ params }) => app.revokeAuthorization(params.id!, params.aid!));
293
+ http.route('GET', '/api/projects/:id/requests', ({ params }) => ({ requests: app.store(params.id!).requests.all() }));
294
+ http.route('POST', '/api/projects/:id/notes/:nid/response', ({ params, body }) => {
295
+ const response = requireString((body as { response?: unknown })?.response, 'response');
296
+ if (!['Discussed', 'Decided', 'Delegated', 'No action needed'].includes(response)) throw new HttpError(400, 'response must be Discussed, Decided, Delegated or No action needed');
297
+ app.respondToNote(params.id!, params.nid!, response as 'Discussed');
298
+ return { ok: true };
299
+ });
300
+ http.route('POST', '/api/projects/:id/keeper/pause',({ params }) => ({ organizingPaused: app.pauseOrganizing(params.id!, true).organizingPaused }));
301
+ http.route('POST', '/api/projects/:id/keeper/resume', ({ params }) => ({ organizingPaused: app.pauseOrganizing(params.id!, false).organizingPaused }));
302
+ // Compare (D45): the versions that were kept, and the difference between any two of them.
303
+ http.route('GET', '/api/projects/:id/versions', ({ params }) => ({ versions: listVersions(projectDir(params.id!, app.home)) }));
304
+ http.route('GET', '/api/projects/:id/compare', ({ params, query }) => {
305
+ const dir = projectDir(params.id!, app.home);
306
+ const all = listVersions(dir);
307
+ if (all.length === 0) return { versions: [], error: 'No version has been kept yet; one is saved each time you open the project and at the end of each round.' };
308
+ const toId = query.get('to') || 'now';
309
+ const fromId = query.get('from') || all[all.length - 1]!.id;
310
+ const before = readVersion(dir, fromId);
311
+ const after = toId === 'now' ? snapshot(app.store(params.id!), 'Asked') : readVersion(dir, toId);
312
+ if (!before || !after) return { versions: all, error: 'That version is no longer kept.' };
313
+ const { items, counts } = diffVersions(before, after);
314
+ return { versions: all, from: { id: fromId, at: before.at }, to: { id: toId, at: after.at }, counts, items };
315
+ });
316
+ // Follow up (the dock's and the `Daily` page's, the same action): organize what has changed since the last round, now,
317
+ // instead of waiting for the next scheduled time. Refused, with why, until the takeover is done (§6.9).
318
+ http.route('POST', '/api/projects/:id/keeper/follow-up', ({ params }) => {
319
+ const id = params.id!;
320
+ app.followUp(id);
321
+ const store = app.store(id);
322
+ const scope = store.coverage.scopes.find((s) => s.id === 'project');
323
+ return { queued: (scope?.pending.length ?? 0) + (scope?.organizing.length ?? 0) };
324
+ });
325
+ http.route('POST', '/api/projects/:id/keeper/jobs/:jobId/stop', ({ params }) => ({ ok: app.keeper.stopJob(params.id!, params.jobId!) }));
326
+ // Continue a stopped job, retry a failed one (§3.10, §6.9). A round's step goes through the organizing service, which
327
+ // runs again what the program did itself (the ledger, the process, the program's part of a step) — the runtime runs
328
+ // only a model's jobs — and counts the owner's run again as the owner's.
329
+ http.route('POST', '/api/projects/:id/keeper/jobs/:jobId/continue', ({ params }) => ({ ok: app.organizing.runAgain(params.id!, params.jobId!) }));
330
+ http.route('POST', '/api/projects/:id/keeper/jobs/:jobId/retry', ({ params }) => ({ ok: app.organizing.runAgain(params.id!, params.jobId!) }));
331
+ http.route('POST', '/api/projects/:id/keeper/jobs/:jobId/steer', ({ params, body }) => ({ ok: app.keeper.steer(params.id!, params.jobId!, requireString((body as { text?: unknown })?.text, 'text')) }));
332
+ http.route('POST', '/api/projects/:id/keeper/open-in-pi', ({ params, body }) => {
333
+ const jobId = optionalString((body as { jobId?: unknown })?.jobId);
334
+ const job = jobId ? app.store(params.id!).jobs.get(jobId) : undefined;
335
+ return app.keeper.openInPi(app.project(params.id!), job?.sessionFile ?? null);
336
+ });
337
+
338
+ http.route('GET', '/api/projects/:id/connections', async ({ params }) => {
339
+ const project = app.project(params.id!);
340
+ const store = app.store(params.id!);
341
+ const status = await app.keeper.status(params.id!);
342
+ const provider = await app.keeper.providerState(params.id!);
343
+ let resources: Awaited<ReturnType<App['keeper']['resources']>> = { resources: [], projectTrusted: null, trustDecision: 'unknown' };
344
+ let resourcesError: string | null = null;
345
+ try { resources = await app.keeper.resources(project); } catch (e) { resourcesError = (e as Error).message; }
346
+ // §6.10 `Execution-agent access` lists the hosts that receive context; none is integrated in P1 (host integration is P3).
347
+ const executionAgents = { connected: [] as { host: string; status: string }[], note: 'No execution agent is connected yet. Context is prepared and copied from Agent context, and any agent can query the Keeper without a host integration; nothing is installed into any agent.' };
348
+ return {
349
+ keeperAgent: {
350
+ name: 'pi', status: status.status, reason: status.reason, detail: status.detail, capabilities: app.keeper.capabilities(),
351
+ resources: resources.resources, projectTrusted: resources.projectTrusted, trustDecision: resources.trustDecision, resourcesError,
352
+ resourceChanges: app.keeper.resourceChangesOf(params.id!),
353
+ },
354
+ modelProvider: { connected: provider.connected, reason: provider.reason, provider: provider.model?.provider ?? null, model: provider.model?.id ?? null, thinking: provider.model?.thinking ?? null, available: provider.available, backups: provider.backups, fallback: provider.fallback, switches: provider.switches },
355
+ organizing: app.keeper.laneState(params.id!),
356
+ // §3.7, §1.11: what the project's rules settle directly, and which rule or plan settled it.
357
+ settledByRule: settledByRuleView(store, project),
358
+ usage: app.keeper.usageSummary(params.id!),
359
+ memory: { keeps: ['Project scope', 'Sources and excerpts', 'Product reference', 'Fact records, work threads, area understanding', 'Graph nodes and relations', 'Notes with versions and discussion', 'Change records and propagation', 'Entry marks', 'Judgement records and trace', 'Standing authorizations', 'Context packages'], doesNotPromise: 'Native sessions moving losslessly between runtimes', location: project.keeperFiles },
360
+ executionAgents,
361
+ };
362
+ });
363
+
364
+ http.route('POST', '/api/settings/model', ({ body }) => {
365
+ const b = (body ?? {}) as Record<string, unknown>;
366
+ const provider = requireString(b.provider, 'provider');
367
+ const id = requireString(b.id, 'id');
368
+ const thinking = optionalString(b.thinking) ?? null;
369
+ if (!app.keeper.models.getModel(provider, id)) throw new HttpError(400, `Unknown model ${provider}/${id}`);
370
+ const backups = Array.isArray(b.backups) ? (b.backups as { provider?: unknown; id?: unknown }[]).map((x) => ({ provider: requireString(x.provider, 'backups[].provider'), id: requireString(x.id, 'backups[].id'), thinking })) : undefined;
371
+ for (const x of backups ?? []) if (!app.keeper.models.getModel(x.provider, x.id)) throw new HttpError(400, `Unknown backup model ${x.provider}/${x.id}`);
372
+ app.keeper.setModel({ provider, id, thinking }, backups);
373
+ return { model: app.workspace.settings.model, backups: app.workspace.settings.modelBackups };
374
+ });
375
+ http.route('POST', '/api/settings/organizing', ({ body }) => {
376
+ const b = (body ?? {}) as Record<string, unknown>;
377
+ const lanes = Number(b.lanesPerKey ?? b.lanes);
378
+ if (!Number.isFinite(lanes) || lanes < 1 || lanes > 32) throw new HttpError(400, 'lanesPerKey must be a number from 1 to 32');
379
+ app.keeper.setLanesPerKey(lanes);
380
+ for (const p of app.workspace.list()) void app.organizing.replan(p.id); // fill the new lanes now, not at the next event
381
+ return app.keeper.laneState();
382
+ });
383
+ // Keys are entered in Keeper → Model provider and kept in ProjectKeeper's home (keys-api.ts, own-keys.ts; D105).
384
+ // Each model step of a round can run on its own model and thinking level (CKC-03 AC-29, CKC-23 AC-11): the model on
385
+ // the provider of whichever key the lane uses, so a step keeps its key and quota window; a step not set uses the
386
+ // chosen model. What each step actually ran on is recorded on its job and shown in Keeper activity.
387
+ // D99: the main agent and its lanes are model jobs of a round too, each settable (the runtime looks a job's setting up by
388
+ // its step kind); the table shows them with the steps from before D99.
389
+ const stepKinds: readonly string[] = [...new Set<string>([...MODEL_STEP_KINDS, 'main', 'lane'])];
390
+ // D103: a step left unset runs on the chosen model, except one that defaults to another step's setting (`as`: the
391
+ // synthesis runs on what the main agent is set to).
392
+ http.route('GET', '/api/settings/steps', () => ({ steps: app.workspace.settings.steps ?? {}, kinds: stepKinds, thinking: THINKING_LEVELS, as: STEP_SETTING_AS }));
393
+ http.route('POST', '/api/settings/steps', ({ body }) => {
394
+ const raw = (body as { steps?: unknown } | null)?.steps;
395
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new HttpError(400, 'steps must be an object keyed by step, e.g. { "orientation": { "model": "glm-5.3", "thinking": "max" } }');
396
+ const chosen = app.workspace.settings.model;
397
+ const next: Record<string, { model?: string; thinking?: string }> = { ...(app.workspace.settings.steps ?? {}) };
398
+ for (const [kind, value] of Object.entries(raw as Record<string, unknown>)) {
399
+ if (!stepKinds.includes(kind)) throw new HttpError(400, `${kind} is not a model step of a round; they are ${stepKinds.join(', ')}`);
400
+ if (value === null) { delete next[kind]; continue; } // back to the chosen model
401
+ const v = (value ?? {}) as Record<string, unknown>;
402
+ const model = optionalString(v.model) ?? undefined;
403
+ const thinking = optionalString(v.thinking) ?? undefined;
404
+ if (thinking && !(THINKING_LEVELS as readonly string[]).includes(thinking)) throw new HttpError(400, `thinking must be one of ${THINKING_LEVELS.join(', ')}`);
405
+ if (model && chosen && !app.keeper.models.getModel(chosen.provider, model)) throw new HttpError(400, `${chosen.provider} has no model ${model}`);
406
+ next[kind] = { ...(model ? { model } : {}), ...(thinking ? { thinking } : {}) };
407
+ }
408
+ app.workspace.setSettings({ steps: next });
409
+ return { steps: app.workspace.settings.steps ?? {} };
410
+ });
411
+ http.route('GET', '/api/settings/providers', () => app.keeper.models.getProviders().map((p) => ({ id: p.id, name: p.name ?? p.id, configured: app.keeper.models.hasConfiguredAuth(p.id) })));
412
+
413
+ // ---- Keeper conversation (§6.8) ----
414
+ const parseContext = (raw: unknown): { kind: string; id: string; label: string } | null => {
415
+ let c: unknown = raw;
416
+ if (typeof raw === 'string') { try { c = JSON.parse(raw); } catch { c = null; } }
417
+ const o = c as { kind?: unknown; id?: unknown; label?: unknown } | null;
418
+ return o && typeof o.kind === 'string' && typeof o.id === 'string' ? { kind: o.kind, id: o.id, label: typeof o.label === 'string' ? o.label : o.id } : null;
419
+ };
420
+ http.route('GET', '/api/projects/:id/chat', ({ params, query }) => {
421
+ const sessions = app.conversation.sessions(params.id!);
422
+ const requested = optionalString(query.get('conversation'));
423
+ const conversationId = requested && sessions.some((s) => s.id === requested) ? requested : requested === 'new' ? null : sessions[0]?.id ?? null;
424
+ return { conversationId, sessions, turns: conversationId ? app.conversation.turns(params.id!, conversationId) : [], existing: app.conversation.existing(params.id!, parseContext(query.get('context'))), branchingSupported: app.keeper.branchingSupported, telemetry: app.conversation.telemetry(params.id!, conversationId) };
425
+ });
426
+ http.route('POST', '/api/projects/:id/chat', ({ params, body }) => {
427
+ const b = (body ?? {}) as Record<string, unknown>;
428
+ return app.conversation.send(params.id!, { text: requireString(b.text, 'text').trim(), context: parseContext(b.context), conversationId: optionalString(b.conversationId) });
429
+ });
430
+ http.route('POST', '/api/projects/:id/chat/branch', async ({ params, body }) => {
431
+ const b = (body ?? {}) as Record<string, unknown>;
432
+ return app.conversation.branch(params.id!, requireString(b.conversationId, 'conversationId'), requireString(b.jobId, 'jobId'));
433
+ });
434
+
435
+ // ---- Agent context and the query entry (§7.6, §7.8) ----
436
+ http.route('GET', '/api/projects/:id/context/options', ({ params }) => ({ ...contextOptions(app.store(params.id!), app.project(params.id!), app.ledger.ledger(params.id!)), roles: app.project(params.id!).roles }));
437
+ http.route('GET', '/api/projects/:id/context', async ({ params, query }) => {
438
+ const scopeKind = query.get('scope') ?? 'project';
439
+ const kind = (['project', 'area', 'work', 'path', 'question'] as const).find((k) => k === scopeKind) ?? 'project';
440
+ // A work item merged into another is reached through the one kept (Spec §1.4, §7.10).
441
+ const ids = (query.get('ids') ?? '').split(',').map((s) => s.trim()).filter(Boolean).map((id) => resolveMergedId(app.store(params.id!), id));
442
+ const purpose = query.get('purpose') === 'Work' ? 'Work' : 'Start';
443
+ const kindRaw = query.get('kind') ?? 'Implement';
444
+ const workKind = (['Implement', 'Review', 'Plan', 'Discuss product', 'Investigate'] as const).find((k) => k === kindRaw) ?? 'Implement';
445
+ const recipient = optionalString(query.get('recipient')) ?? 'Incoming agent';
446
+ // A project not organized yet — just added, or cleared — says so; it does not answer with an empty pack (§7.10, §3.7).
447
+ if (takeoverState(app.store(params.id!), app.project(params.id!)).phase === 'Not started') return notOrganizedPackage(app.project(params.id!), { scope: { kind, ids }, purpose, kind: workKind, recipient, lastSessionAt: optionalString(query.get('since')), taskVersion: optionalString(query.get('taskVersion')) }, app.keeper.ready ? (await app.keeper.status(params.id!)).status : 'Not connected');
448
+ const status = await app.keeper.status(params.id!);
449
+ // The project's ledger, when it has one, gives the rest of a work's lineage (`How it got here`, CKC-12 AC-35).
450
+ const pkg = assembleContext(app.store(params.id!), app.project(params.id!), { scope: { kind, ids }, purpose, kind: workKind, recipient, lastSessionAt: optionalString(query.get('since')), taskVersion: optionalString(query.get('taskVersion')) }, status.status, app.usageWhere(), app.ledger.ledger(params.id!));
451
+ return pkg;
452
+ });
453
+ // Whatever an id in a pack names (Spec §7.10): its kind, so the agent entry knows how to read it. An id of another
454
+ // project is not read here; the answer says which project it belongs to, or that it is not found.
455
+ const kindOf = (store: ReturnType<App['store']>, id: string): string | null => {
456
+ if (store.threads.has(id)) return 'work';
457
+ const ref = store.reference.get(id);
458
+ if (ref) return ref.category === 'Area' ? 'area' : 'reference';
459
+ if (store.sources.has(id)) return 'source';
460
+ if (store.notes.has(id)) return 'note';
461
+ if (store.changes.has(id)) return 'change';
462
+ if (store.marks.has(id)) return 'mark';
463
+ if (store.facts.has(id)) return 'fact';
464
+ if (store.relations.has(id)) return 'relation';
465
+ if (store.rules.has(id)) return 'rule';
466
+ if (store.nodes.has(id)) return 'node';
467
+ if (store.breakpoints.has(id)) return 'breakpoint';
468
+ if (store.sendbacks.has(id)) return 'sendback';
469
+ if (store.patches.has(id)) return 'patch';
470
+ if (store.territories.has(id)) return 'territory';
471
+ return null;
472
+ };
473
+ const findObject = (projectId: string, asked: string) => {
474
+ const store = app.store(projectId);
475
+ const kept = resolveMergedId(store, asked);
476
+ const directKind = kindOf(store, kept);
477
+ if (directKind) return { id: kept, kind: directKind, mergedFrom: kept !== asked ? asked : null };
478
+ const patch = store.patches.find((p) => p.number === asked);
479
+ if (patch) return { id: patch.id, kind: 'patch', mergedFrom: null };
480
+ const number = store.numbers.find((n) => n.number === asked || n.projectNumber === asked);
481
+ if (number) {
482
+ const target = resolveMergedId(store, number.objectId);
483
+ const targetKind = kindOf(store, target);
484
+ if (targetKind) return { id: target, kind: targetKind, mergedFrom: null };
485
+ }
486
+ return null;
487
+ };
488
+ http.route('GET', '/api/projects/:id/lookup/:oid', ({ params }) => {
489
+ const oid = params.oid!;
490
+ if (/[\\/]/.test(oid) || /^[A-Za-z]:/.test(oid)) throw new HttpError(400, 'Only ids from the assets are read here, not paths');
491
+ app.project(params.id!);
492
+ // The id of a work item merged into another reaches the one kept (Spec §1.4, §7.10; CKC-06 AC-28): the answer
493
+ // names the kept id, which is what the caller reads next.
494
+ const found = findObject(params.id!, oid);
495
+ if (found) return found.mergedFrom ? { id: found.id, kind: found.kind, projectId: params.id, mergedFrom: found.mergedFrom } : { id: found.id, kind: found.kind, projectId: params.id };
496
+ for (const p of app.workspace.list()) {
497
+ if (p.id === params.id) continue;
498
+ if (findObject(p.id, oid)) return { id: oid, kind: null, elsewhere: { projectId: p.id, name: p.name } };
499
+ }
500
+ return { id: oid, kind: null, elsewhere: null };
501
+ });
502
+ http.route('GET', '/api/projects/:id/marks/:mid', ({ params }) => {
503
+ const store = app.store(params.id!);
504
+ const m = store.marks.get(params.mid!);
505
+ if (!m) throw new HttpError(404, 'Unknown mark');
506
+ const label = (id: string) => store.reference.get(id)?.name ?? store.threads.get(id)?.title ?? store.nodes.get(id)?.label ?? (store.sources.get(id) ? anchorLabel(store.sources.get(id)!.anchor) : id);
507
+ return { ...m, targetLabel: label(m.targetId), clueSources: m.clueSourceIds.map((id) => { const s = store.sources.get(id); return { id, title: s?.title ?? id, label: s ? anchorLabel(s.anchor) : id }; }), note: m.noteId ? { id: m.noteId, title: store.notes.get(m.noteId)?.versions.at(-1)?.title ?? m.noteId } : null };
508
+ });
509
+ http.route('GET', '/api/projects/:id/usage', ({ params }) => ({ lines: agentUsage(params.id!, app.usageWhere()) }));
510
+ http.route('GET', '/api/resolve', ({ query }) => {
511
+ const cwd = optionalString(query.get('cwd'));
512
+ if (!cwd) throw new HttpError(400, 'cwd is required');
513
+ const p = resolveProject(app.workspace.list(), cwd);
514
+ return p ? { projectId: p.id, name: p.name } : { projectId: null, message: `${cwd} is not inside any project added to ProjectKeeper.` };
515
+ });
516
+ http.route('POST', '/api/projects/:id/ask', ({ params, body }) => {
517
+ const b = (body ?? {}) as Record<string, unknown>;
518
+ const question = requireString(b.question, 'question').trim();
519
+ const store = app.store(params.id!);
520
+ const known = knownPart(store, question);
521
+ const turn = app.conversation.send(params.id!, { text: question, context: null, conversationId: optionalString(b.thread), asker: 'agent', waiting: known.pendingSourceIds });
522
+ return { known: known.text, knownSourceIds: known.sourceIds, waitingSourceIds: known.pendingSourceIds, investigation: { jobId: turn.jobId, thread: turn.conversationId, status: turn.status, fetch: `/api/projects/${params.id}/ask/${turn.jobId}` }, note: 'The known part comes from the organized assets. The investigation runs in the Keeper; fetch it by job id, or keep asking in the same thread.' };
523
+ });
524
+ http.route('GET', '/api/projects/:id/ask/:jobId', ({ params }) => {
525
+ const job = app.store(params.id!).jobs.get(params.jobId!);
526
+ if (!job) throw new HttpError(404, 'Unknown job');
527
+ const store = app.store(params.id!);
528
+ const cited = [...new Set((job.resultText ?? '').match(/src_[0-9a-f]{16}/g) ?? [])].map((id) => { const s = store.sources.get(id); return { id, title: s?.title ?? id, label: s ? anchorLabel(s.anchor) : id }; });
529
+ return { jobId: job.id, status: job.status, answer: job.resultText, error: job.error, steps: job.steps.length, startedAt: job.startedAt, sources: cited, thread: (job.task as { extra?: { conversationId?: string } } | null)?.extra?.conversationId ?? null };
530
+ });
531
+
532
+ http.route('GET', '/api/projects/:id/search', ({ params, query }) => {
533
+ const store = app.store(params.id!);
534
+ const q = (query.get('q') ?? '').trim().toLowerCase();
535
+ if (!q) return { results: [] };
536
+ const hit = (text: string | null | undefined) => (text ?? '').toLowerCase().includes(q);
537
+ const results: { type: string; id: string; label: string; detail: string }[] = [];
538
+ // A node found here is shown on the graph; what was removed is not on it (§2.1, §6.12). The change that removed it
539
+ // is still found, and opens in the Change log.
540
+ for (const n of store.nodes.all()) if (hit(n.label) && n.validity !== 'Removed' && !isRemoved(store, n.refId)) results.push({ type: 'node', id: n.id, label: n.label, detail: n.category });
541
+ for (const s of store.sources.all()) if (hit(s.title) || hit(anchorLabel(s.anchor)) || hit(s.excerpt)) results.push({ type: 'source', id: s.id, label: s.title, detail: anchorLabel(s.anchor) });
542
+ for (const c of store.changes.all()) if (hit(c.title) || hit(c.summary)) results.push({ type: 'change', id: c.id, label: c.title, detail: c.at });
543
+ for (const n of store.notes.all()) { const v = n.versions[n.versions.length - 1]; if (v && (hit(v.title) || hit(v.preview))) results.push({ type: 'note', id: n.id, label: v.title, detail: n.status }); }
544
+ return { results: results.slice(0, 60) };
545
+ });
546
+
547
+ http.static('/vendor/', vendorDir);
548
+ http.static('/', uiDir);
549
+ }