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
package/ui/app.js ADDED
@@ -0,0 +1,1135 @@
1
+ // ProjectKeeper workbench. Plain ES module, no build step. Fixed interface text is English
2
+ // and identical across projects (Spec §6.13); Keeper-written content comes from the assets.
3
+ import { views, hideDrawer, stripSheetOpen } from './views.js';
4
+ import { railMemory, escapeAction } from './shell-state.js';
5
+ import { openGlossary } from './glossary.js';
6
+ import { createPopover } from './popover.js';
7
+ import { createFlyout } from './flyout.js';
8
+ import { standBeside } from './popover-place.js';
9
+ import { createFolderChooser, locationLines, withLocation } from './folder-chooser.js';
10
+ import { initTheme, themePicker } from './theme.js';
11
+ import { updateCounts } from './k-process.js';
12
+ import { failuresOf, reasonText, shortRef, skippedOf, skippedText } from './failures.js';
13
+
14
+ // The system the page is shown on (the workbench is served to its own machine): for the examples and key names that differ.
15
+ const PLATFORM = navigator.userAgentData?.platform ?? navigator.platform ?? '';
16
+ const MAC = /mac|iphone|ipad/i.test(PLATFORM);
17
+ const WINDOWS = /win/i.test(PLATFORM);
18
+
19
+ const $ = (sel, root = document) => root.querySelector(sel);
20
+ export const h = (tag, attrs = {}, ...children) => {
21
+ const el = document.createElement(tag);
22
+ for (const [k, v] of Object.entries(attrs)) {
23
+ if (v === null || v === undefined || v === false) continue;
24
+ if (k === 'class') el.className = v;
25
+ else if (k === 'html') el.innerHTML = v;
26
+ else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
27
+ else if (k === 'dataset') Object.assign(el.dataset, v);
28
+ else if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
29
+ else el.setAttribute(k, v === true ? '' : String(v));
30
+ }
31
+ for (const child of children.flat(Infinity)) {
32
+ if (child === null || child === undefined || child === false) continue;
33
+ el.append(child instanceof Node ? child : document.createTextNode(String(child)));
34
+ }
35
+ return el;
36
+ };
37
+ export const clear = (el) => { while (el.firstChild) el.removeChild(el.firstChild); return el; };
38
+ // DOM append() turns null into the text "null"; this one skips empty children.
39
+ export const append = (el, ...nodes) => { for (const n of nodes.flat(Infinity)) if (n !== null && n !== undefined && n !== false) el.append(n); return el; };
40
+ export const fmtTime = (iso) => { if (!iso) return '—'; const d = new Date(iso); return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(undefined, { hour12: false }); };
41
+ export const fmtRel = (iso) => { if (!iso) return '—'; const ms = Date.now() - new Date(iso).getTime(); const m = Math.round(ms / 60000); if (m < 1) return 'just now'; if (m < 60) return `${m} min ago`; const hrs = Math.round(m / 60); if (hrs < 48) return `${hrs} h ago`; return `${Math.round(hrs / 24)} d ago`; };
42
+ // Relative clocks change every minute; comparing outerHTML with them made the strip and pill swap for no asset change.
43
+ export const visHtml = (s) => String(s ?? '').replace(/\bjust now\b|\b\d+ min ago\b|\b\d+ h ago\b|\b\d+ d ago\b/g, '#rel#');
44
+
45
+ export async function api(path, options = {}) {
46
+ const res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...options, body: options.body === undefined ? undefined : JSON.stringify(options.body) });
47
+ if (res.status === 204) return null;
48
+ const text = await res.text();
49
+ let data = null;
50
+ try { data = text ? JSON.parse(text) : null; } catch { data = { error: text }; }
51
+ if (!res.ok) throw new Error((data && data.error) || `${res.status} ${res.statusText}`);
52
+ return data;
53
+ }
54
+
55
+ export function toast(text) {
56
+ const el = $('#toast');
57
+ el.textContent = text;
58
+ el.classList.add('show');
59
+ clearTimeout(el._t);
60
+ el._t = setTimeout(() => el.classList.remove('show'), 2800);
61
+ }
62
+
63
+ /**
64
+ * The one dialog. Opened from inside itself (a source or a fact record from the full details, one object's details
65
+ * from another's) the new content goes on top and the one underneath is kept as it was — its scroll position, its
66
+ * open folds, its listeners — so `Back` and Escape return to it instead of dropping the owner out of what they were
67
+ * reading; `Close` closes the lot. Focus returns to where it was before the first one opened.
68
+ */
69
+ const dialogUnder = []; // the layers under the one showing, oldest first
70
+ let dialogTop = null; // { onClose, opener }
71
+ export function openDialog(title, bodyNodes, { onClose } = {}) {
72
+ const dialog = $('#dialog');
73
+ const stacked = dialog.open && dialogTop !== null;
74
+ if (stacked) {
75
+ dialogUnder.push({ ...dialogTop, nodes: [...dialog.childNodes], className: dialog.className, scrollTop: dialog.querySelector('.dialog-body')?.scrollTop ?? 0, focus: document.activeElement, rerender: dialog._rerender ?? null, details: dialog._details ?? null });
76
+ dialog.replaceChildren(); // detached, not destroyed: they go back as they are
77
+ } else {
78
+ dialogUnder.length = 0;
79
+ clear(dialog);
80
+ }
81
+ dialog.className = '';
82
+ dialog._rerender = null;
83
+ dialog._details = null;
84
+ dialogTop = { onClose, opener: stacked ? dialogUnder[0].opener : document.activeElement };
85
+ dialog.append(
86
+ h('div', { class: 'dialog-head' },
87
+ stacked ? h('button', { class: 'btn small', title: 'Back to what was open before', onClick: closeDialogLayer }, '‹ Back') : null,
88
+ h('h2', { id: 'dialog-title' }, title),
89
+ h('button', { class: 'btn small', onClick: () => dialog.close() }, 'Close')),
90
+ h('div', { class: 'dialog-body' }, ...bodyNodes),
91
+ );
92
+ dialog.onclose = () => {
93
+ const opener = dialogTop?.opener ?? null;
94
+ dialogTop?.onClose?.();
95
+ while (dialogUnder.length) dialogUnder.pop().onClose?.();
96
+ dialogTop = null;
97
+ dialog._rerender = null;
98
+ dialog._details = null;
99
+ if (opener?.isConnected && opener.focus) opener.focus();
100
+ else if (popover.isOpen()) popover.focus();
101
+ };
102
+ dialog.oncancel = (e) => { if (dialogUnder.length) { e.preventDefault(); closeDialogLayer(); } };
103
+ dialog.onclick = (e) => { if (e.target === dialog) closeDialogLayer(); };
104
+ if (!dialog.open) dialog.showModal();
105
+ dialog.querySelector('.dialog-body').scrollTop = 0;
106
+ return dialog;
107
+ }
108
+ /** Close what is showing: back to the layer under it, or, when it is the only one, close the dialog. */
109
+ export function closeDialogLayer() {
110
+ const dialog = $('#dialog');
111
+ if (!dialogUnder.length) { if (dialog.open) dialog.close(); return; }
112
+ dialogTop?.onClose?.();
113
+ const under = dialogUnder.pop();
114
+ dialog.replaceChildren(...under.nodes);
115
+ dialog.className = under.className;
116
+ dialog._rerender = under.rerender;
117
+ dialog._details = under.details;
118
+ dialogTop = { onClose: under.onClose, opener: under.opener };
119
+ const body = dialog.querySelector('.dialog-body');
120
+ if (body) body.scrollTop = under.scrollTop;
121
+ if (under.focus?.isConnected) under.focus.focus({ preventScroll: true });
122
+ void dialog._details?.refresh();
123
+ }
124
+
125
+ const VIEWS = [
126
+ ['graph', 'Project graph'], ['notes', 'Notes log'], ['changes', 'Change log'],
127
+ ['context', 'Agent context'], ['scope', 'Project scope'], ['keeper', 'Keeper'],
128
+ ];
129
+ /** Each view's icon, what the folded rail keeps (Spec §6.1: 只留视图的图标). Strokes in the current colour. */
130
+ const VIEW_ICON = {
131
+ graph: "<circle cx='5' cy='6' r='2.2'/><circle cx='15' cy='5' r='2.2'/><circle cx='10' cy='15' r='2.2'/><path d='M7 6.5l6-1M6.2 8l2.8 5M13.9 7l-2.8 6'/>",
132
+ notes: "<rect x='4' y='3' width='12' height='14' rx='2'/><path d='M7 7.5h6M7 10.5h6M7 13.5h3.5'/>",
133
+ changes: "<circle cx='10' cy='10' r='6.5'/><path d='M10 6.5V10l2.5 2'/>",
134
+ context: "<path d='M3.5 6.5L10 3l6.5 3.5v7L10 17l-6.5-3.5z'/><path d='M3.5 6.5L10 10l6.5-3.5M10 10v7'/>",
135
+ scope: "<circle cx='10' cy='10' r='6.5'/><circle cx='10' cy='10' r='3'/><path d='M10 1.5v3M10 15.5v3M1.5 10h3M15.5 10h3'/>",
136
+ keeper: "<rect x='4' y='7' width='12' height='9' rx='2.5'/><path d='M10 4v3M7.8 11h.01M12.2 11h.01M8 13.8h4'/>",
137
+ };
138
+ // Parsed as markup, so the icon is an SVG element (document.createElement('svg') would make an unknown HTML one).
139
+ const viewIcon = (id) => h('span', { class: 'nav-icon', 'aria-hidden': 'true', html: `<svg viewBox='0 0 20 20'>${VIEW_ICON[id] ?? ''}</svg>` });
140
+
141
+ export const state = {
142
+ workspace: null, projectId: null, project: null, view: 'graph', selection: null, // {kind, id}
143
+ keeperOpen: false, keeperContext: null, conversationId: null, filters: {}, scale: 'Overview', lastVisit: null, counts: {},
144
+ };
145
+
146
+ function parseHash() {
147
+ const m = /^#\/p\/([^/]+)(?:\/([a-z]+))?(?:\/(.*))?$/.exec(location.hash);
148
+ if (!m) return { projectId: null, view: 'graph', rest: '' };
149
+ return { projectId: decodeURIComponent(m[1]), view: m[2] || 'graph', rest: m[3] || '' };
150
+ }
151
+ export function navigate(projectId, view, rest = '') {
152
+ location.hash = `#/p/${encodeURIComponent(projectId)}/${view}${rest ? '/' + rest : ''}`;
153
+ }
154
+ /**
155
+ * Pick an object (or, with null, nothing). Every way of pointing at one comes through here: the graph, the List, the
156
+ * outline, search, the bottom strip, a link. The view marks it without rebuilding itself, and its details open in the
157
+ * popover beside it (Spec §6.4). `origin: 'graph'` is a tap on the graph itself: the graph stays where it is, and a
158
+ * second tap on the same object puts the popover away. `popover: false` only marks the selection.
159
+ */
160
+ export function select(selection, opts = {}) {
161
+ state.selection = selection;
162
+ const crumb = $('#focus-crumb');
163
+ if (crumb) { crumb.textContent = focusLabel(); crumb.title = focusLabel(); }
164
+ views[state.view]?.onSelect?.(selection, state, opts);
165
+ markOutline();
166
+ if (!selection) { popover.close('deselect'); return; }
167
+ if (opts.popover === false) return;
168
+ if (opts.origin === 'graph' && popover.key() === selectionKey(selection)) { popover.close('toggle'); return; }
169
+ showDetails(selection, opts);
170
+ }
171
+ const selectionKey = (s) => `${s.kind}:${s.id}`;
172
+
173
+ // ── details popover ───────────────────────────────────────────────────────
174
+ const NARROW = matchMedia('(max-width: 700px)');
175
+ const rectOf = (el) => { const r = el.getBoundingClientRect(); return r.width || r.height ? { left: r.left, top: r.top, right: r.right, bottom: r.bottom } : null; };
176
+ /** The main view's visible area: what the docked Keeper leaves of it (Spec §6.4), inside the window. */
177
+ function mainArea() {
178
+ const main = $('#main');
179
+ const r = main ? main.getBoundingClientRect() : { left: 0, top: 0, right: innerWidth, bottom: innerHeight };
180
+ const area = { left: Math.max(0, r.left), top: Math.max(0, r.top), right: Math.min(innerWidth, r.right), bottom: Math.min(innerHeight, r.bottom) };
181
+ const keeper = document.getElementById('keeper');
182
+ if (keeper?.classList.contains('open') && keeper.classList.contains('docked')) area.right = Math.min(area.right, keeper.getBoundingClientRect().left);
183
+ return area;
184
+ }
185
+ /** The pill at the bottom right, as a rectangle floating surfaces keep off when they can (Spec §6.1). */
186
+ function pillRects() {
187
+ const r = document.querySelector('#keeper-dock .dock-pill')?.getBoundingClientRect();
188
+ return r && (r.width || r.height) ? [{ left: r.left, top: r.top, right: r.right, bottom: r.bottom }] : [];
189
+ }
190
+ export const popover = createPopover({
191
+ bounds: mainArea,
192
+ sheet: () => NARROW.matches,
193
+ normalize: visHtml,
194
+ // The Keeper is used beside the popover, so a press in it, on its pill or on `Ask Keeper` is not "elsewhere"; the
195
+ // graph's canvas says for itself what a tap means (another object, the background, or the start of a pan).
196
+ keepsOpen: (target) => Boolean(target.closest('#keeper, #keeper-dock, [data-keeps-popover], #cy')),
197
+ avoid: pillRects,
198
+ // Closing the popover takes the graph's mark away with it (a blank click and another selection do too).
199
+ onClose: () => { views.graph.clearMarks?.(); },
200
+ });
201
+ /** The panels of a view's control row — `Filter`, the readability mark — float inside the same area (Spec §6.3). */
202
+ export const flyout = createFlyout({ bounds: mainArea, avoid: pillRects });
203
+
204
+ /**
205
+ * The object as it is shown in the main view, when it is: a List cell, a note's row or card. `place` narrows it to one
206
+ * list (see `placeIn`): the same note can have a row in two columns of the bottom strip.
207
+ */
208
+ function shownInMain(selection, place = null) {
209
+ const main = $('#main');
210
+ if (!main || !selection) return null;
211
+ const id = CSS.escape(selection.id);
212
+ const found = selection.kind === 'note' ? main.querySelectorAll(`[data-note="${id}"]`) : main.querySelectorAll(`button[data-node="${id}"], [data-node="${id}"]`);
213
+ return [...found].find((el) => el.getClientRects().length > 0 && (place === null || placeIn(el) === place)) ?? null;
214
+ }
215
+ /** Which list of the main view an element is in, told by where that list is: a rebuilt list is made of new elements. */
216
+ function placeIn(el) {
217
+ const list = el.closest('section, table');
218
+ const among = list?.parentElement;
219
+ return among ? `${among.classList[0] ?? among.tagName} ${list.tagName} ${[...among.children].indexOf(list)}` : '';
220
+ }
221
+ /**
222
+ * What the popover stands beside, asked again every time it is placed: the object on the graph when it is drawn
223
+ * there; else the thing that was pressed, when that is in the main view (a List cell, a row of the bottom strip, a
224
+ * card, a link); else the object wherever the main view shows it; else, on the graph, what the object hangs on (the
225
+ * ends of a relation that is not drawn, the objects a note is mounted on — `hints`, filled in once its details are
226
+ * read); else the thing pressed outside the main view (an outline row, a link in the conversation). Nothing on screen
227
+ * at all: null, and the popover takes the main view's corner.
228
+ *
229
+ * When what it stood beside goes away (an answered note leaves Notes (attention), a filter hides the node), the answer
230
+ * is null as well and the popover stays where it is: it moves up this list when the owner brings the object itself on
231
+ * screen, never down it to something lesser because the assets changed, and never across to the object's row in
232
+ * another list.
233
+ */
234
+ function anchorFor(selection, trigger, hints, { toGraph = false } = {}) {
235
+ const inMain = (el) => Boolean(el?.isConnected && $('#main')?.contains(el));
236
+ const onGraph = () => views.graph.anchorRect?.(selection) ?? null;
237
+ let place = null;
238
+ const inView = () => {
239
+ const shown = inMain(trigger) ? trigger : shownInMain(selection, place);
240
+ if (!shown) return null;
241
+ place = placeIn(shown);
242
+ return rectOf(shown);
243
+ };
244
+ const hangsOn = () => views.graph.hintRect?.(hints) ?? null;
245
+ const outside = () => (trigger?.isConnected && !trigger.closest('dialog') ? rectOf(trigger) : null);
246
+ // `Show on graph` names the objects to stand beside: they come before the row the note was picked from.
247
+ return standBeside(toGraph ? [onGraph, hangsOn, inView, outside] : [onGraph, inView, hangsOn, outside]);
248
+ }
249
+ /** Where focus returns: the element itself, or — a rebuilt list replaced it — the one standing for the same object. */
250
+ function openerFor(trigger) {
251
+ if (!trigger) return () => null;
252
+ const host = trigger.closest('[id]')?.id ?? null;
253
+ const attr = trigger.dataset?.node ? ['node', trigger.dataset.node] : trigger.dataset?.note ? ['note', trigger.dataset.note] : null;
254
+ return () => (trigger.isConnected ? trigger : host && attr ? document.getElementById(host)?.querySelector(`[data-${attr[0]}="${CSS.escape(attr[1])}"]`) ?? null : null);
255
+ }
256
+ function showDetails(selection, { trigger, anchorIds } = {}) {
257
+ const active = document.activeElement;
258
+ const from = trigger !== undefined ? trigger : active && active !== document.body && !popover.el.contains(active) ? active : null;
259
+ const hints = [...(anchorIds ?? [])];
260
+ popover.open({
261
+ key: selectionKey(selection), label: `Details: ${selection.label ?? selection.id}`,
262
+ anchor: anchorFor(selection, from, hints, { toGraph: hints.length > 0 }), opener: openerFor(from),
263
+ render: (mount, p) => views.details.popover(mount, selection, p, { hints }),
264
+ });
265
+ }
266
+ /** The view under the popover was rebuilt, or the assets changed: same popover, same place, content only if it differs. */
267
+ export function refreshDetails() {
268
+ void popover.refresh();
269
+ void $('#dialog')?._details?.refresh();
270
+ }
271
+
272
+ async function loadWorkspace() {
273
+ state.workspace = await api('/api/workspace');
274
+ }
275
+
276
+ async function loadProject(id) {
277
+ state.project = await api(`/api/projects/${encodeURIComponent(id)}`);
278
+ state.counts = state.project.counts || {};
279
+ }
280
+
281
+ export async function refreshProject() {
282
+ if (!state.projectId) return;
283
+ await loadProject(state.projectId);
284
+ patchCoveragePill();
285
+ patchNavCounts();
286
+ void updateCounts();
287
+ await renderBody();
288
+ renderOutline();
289
+ }
290
+
291
+ // ── chrome ────────────────────────────────────────────────────────────────
292
+ /**
293
+ * The workbench is a set of floating panels with gaps between them, all made from one card (Spec §6.1, §6.16; D100;
294
+ * CKC-09 AC-45): the top bar; the left rail, which folds to a strip of view icons (AC-43); the main panel, where Graph,
295
+ * List and Code switch, whose header carries the view's one row of controls and the button that makes it fill the
296
+ * window (AC-44); the drawer under it, one line until it is pulled up (AC-38, views.js); and the Keeper conversation,
297
+ * docked on the right as a panel of its own (§6.8). The pill at the bottom right stands beside the drawer.
298
+ */
299
+ function renderChrome() {
300
+ const app = $('#app');
301
+ clear(app);
302
+ const ws = state.workspace;
303
+ const projects = ws?.projects ?? [];
304
+ const p = state.project;
305
+ const rail = h('aside', { class: 'rail panel', id: 'rail', 'aria-label': 'Project and views' },
306
+ h('div', { class: 'rail-head' },
307
+ h('span', { class: 'rail-section rail-full' }, 'Workspace'),
308
+ h('button', { class: 'rail-fold', id: 'rail-fold', 'data-keeps-popover': true, onClick: () => setRailFolded(!railFolded()) })),
309
+ h('div', { class: 'project-picker rail-full' },
310
+ h('select', { class: 'input', 'aria-label': 'Project', onChange: (e) => { if (e.target.value) navigate(e.target.value, state.view); } },
311
+ h('option', { value: '' }, projects.length ? 'Choose a project' : 'No projects yet'),
312
+ ...projects.map((x) => h('option', { value: x.id, selected: x.id === state.projectId }, x.name))),
313
+ h('button', { class: 'btn small', title: 'Add project', onClick: addProjectDialog }, '+')),
314
+ // The views first, under the project (owner 2026-09-30: from Project scope there was no way back to the graph in
315
+ // sight — the views sat under the long Project structure). They keep their icons when the rail is folded; the name
316
+ // is then in the hover (and read out). The structure below scrolls by itself, so the views stay at the top.
317
+ p ? h('div', { class: 'rail-section rail-full' }, 'Views') : null,
318
+ p ? h('nav', { class: 'views', 'aria-label': 'Views' }, ...VIEWS.map(([id, label]) => h('button', { class: `nav-item${state.view === id ? ' active' : ''}`, 'data-view': id, title: label, 'aria-label': label, onClick: () => navigate(p.id, id) }, viewIcon(id), h('span', { class: 'nav-label' }, label), countFor(id) !== null ? h('span', { class: 'count' }, countFor(id)) : null))) : null,
319
+ p ? h('div', { class: 'rail-section outline-head rail-full' }, h('span', {}, 'Project structure'),
320
+ h('span', { class: 'outline-modes' },
321
+ h('button', { class: outlineMode() === 'all' ? 'active' : '', title: 'Everything in the project, finished or not', onClick: () => setOutlineMode('all') }, 'All'),
322
+ h('button', { class: outlineMode() === 'todo' ? 'active' : '', title: 'Only the work that is still open', onClick: () => setOutlineMode('todo') }, 'To do'))) : null,
323
+ p ? h('div', { class: 'outline rail-full', id: 'outline' }) : null,
324
+ h('div', { class: 'rail-foot' }, p ? keeperEntry() : null),
325
+ );
326
+ const topbar = h('div', { class: 'topbar panel' },
327
+ h('div', { class: 'topbar-main' },
328
+ h('div', { class: 'brand' }, h('span', { class: 'brand-mark', 'aria-hidden': 'true', html: "<svg viewBox='0 0 32 32'><rect width='32' height='32' rx='8' fill='var(--pk-raised,#23211d)'/><path d='M23 8H12l-5 8 5 8h11M14 8v16M7 16h16' fill='none' stroke='currentColor' stroke-width='2'/></svg>" }), h('span', { class: 'brand-name' }, 'ProjectKeeper')),
329
+ h('div', { class: 'crumb' }, p ? [h('strong', { title: p.name }, p.name), h('span', { class: 'sep' }, '·'), h('span', { id: 'focus-crumb', title: focusLabel() }, focusLabel())] : h('strong', {}, 'No project open'))),
330
+ h('div', { class: 'top-actions' },
331
+ p ? coveragePill() : null,
332
+ p ? failedButton() : null,
333
+ // The breakpoints still lit and send-backs still open, beside the coverage (Spec §6.2, §2.12; CKC-24 AC-7).
334
+ // k-process fills it once the process view has answered; nothing shows while that endpoint is not built.
335
+ p ? h('span', { id: 'k-bp-count' }) : null,
336
+ p ? h('button', { class: 'btn small', title: 'What the categories, relations and status words mean, and how the Keeper obtains them', onClick: () => openGlossary() }, 'Vocabulary') : null,
337
+ p ? h('button', { class: 'btn small', 'data-keeps-popover': true, title: 'Open the conversation with the Keeper on the right, about what is selected', onClick: () => toggleKeeper(true) }, 'Ask Keeper') : null,
338
+ // The theme, beside the search (WorkflowKeeper keeps its picker in the top bar): a choice of this browser (Spec §6.16).
339
+ themePicker(),
340
+ p ? h('button', { class: 'search-trigger', onClick: openSearch }, h('span', {}, 'Search'), h('kbd', {}, MAC ? '⌘K' : 'Ctrl K')) : null),
341
+ );
342
+ // The main panel: its header carries the view's own row of controls (or the view's name) and the button that makes
343
+ // it fill the window; below, the view alone — an object's details open in the popover beside it (Spec §6.1).
344
+ const maxBtn = h('button', { class: 'panel-max', id: 'panel-max', 'data-keeps-popover': true, onClick: () => setMaximised(!maximised()) });
345
+ const mainPanel = h('section', { class: 'main-panel panel', id: 'main-panel', 'aria-label': 'Main view' },
346
+ h('div', { class: 'panel-head' },
347
+ p ? h('div', { class: 'top-view-tools', id: 'top-view-tools', hidden: true }) : null,
348
+ h('span', { class: 'panel-title', id: 'panel-title' }, p ? viewLabel(state.view) : ''),
349
+ maxBtn),
350
+ h('div', { class: 'body', id: 'body' }, h('main', { id: 'main' })));
351
+ // The drawer: views.js fills it under the Project graph, and it keeps the band beside the pill everywhere else.
352
+ const drawer = h('section', { class: 'drawer panel empty', id: 'drawer', 'aria-label': 'Notes and recent changes' });
353
+ const mobileNav = p ? h('div', { class: 'mobile-nav' }, ...VIEWS.map(([id, label]) => h('button', { class: state.view === id ? 'active' : '', 'data-view': id, onClick: () => navigate(p.id, id) }, label))) : null;
354
+ append(app, topbar, mobileNav, rail, mainPanel, drawer);
355
+ if (p) app.append(keeperDock());
356
+ applyRailFold();
357
+ applyMaximised();
358
+ popover.close('rebuild');
359
+ watchLayout(topbar, mainPanel.querySelector('main'));
360
+ syncPillWidth();
361
+ renderBody();
362
+ renderOutline();
363
+ renderKeeper();
364
+ void updateCounts();
365
+ }
366
+ const viewLabel = (id) => VIEWS.find(([v]) => v === id)?.[1] ?? '';
367
+
368
+ /**
369
+ * The view's own row of controls, in the main panel's header (Spec §6.1 "每个视图自己的控件在它顶上一行"; D68): the
370
+ * Graph/List/Code controls there, and the view's name when a view has none.
371
+ */
372
+ export function setTopTools(tools = null) {
373
+ const mount = $('#top-view-tools');
374
+ const title = $('#panel-title');
375
+ if (title) { title.textContent = viewLabel(state.view); title.hidden = Boolean(tools); }
376
+ if (!mount) return;
377
+ clear(mount);
378
+ if (tools) mount.append(tools);
379
+ mount.hidden = !tools;
380
+ }
381
+
382
+ /**
383
+ * Sizes the stylesheet cannot know: where the top bar ends (the docked Keeper starts under it), and how wide the pill is
384
+ * (the drawer stops short of it). The popover follows the main view when its size changes.
385
+ */
386
+ let layoutWatch = null;
387
+ function watchLayout(topbar, main) {
388
+ layoutWatch?.disconnect();
389
+ if (!('ResizeObserver' in window)) return;
390
+ layoutWatch = new ResizeObserver(() => {
391
+ document.documentElement.style.setProperty('--pk-top', `${Math.round(topbar.getBoundingClientRect().bottom)}px`);
392
+ popover.reposition({ measure: true });
393
+ flyout.reposition?.();
394
+ });
395
+ layoutWatch.observe(topbar);
396
+ layoutWatch.observe(main);
397
+ }
398
+ function syncPillWidth() {
399
+ const pill = document.querySelector('#keeper-dock .dock-pill');
400
+ if (pill?.offsetWidth) document.documentElement.style.setProperty('--pk-pill-w', `${Math.ceil(pill.offsetWidth)}px`);
401
+ }
402
+
403
+ // ── the rail folds, the main panel fills the window (Spec §6.1; D100; CKC-09 AC-43, AC-44) ──────────────────
404
+ /** Folded or not is this browser's (shell-state.js): remembered like the theme, never in the project's assets. */
405
+ const railPref = railMemory((() => { try { return window.localStorage; } catch { return null; } })());
406
+ const railFolded = () => railPref.folded();
407
+ function setRailFolded(folded) {
408
+ railPref.setFolded(folded);
409
+ applyRailFold();
410
+ popover.reposition({ measure: true });
411
+ }
412
+ function applyRailFold() {
413
+ const folded = railFolded();
414
+ document.body.classList.toggle('rail-folded', folded);
415
+ const btn = $('#rail-fold');
416
+ if (!btn) return;
417
+ btn.textContent = folded ? '»' : '«';
418
+ btn.title = folded ? 'Unfold the left rail' : 'Fold the left rail to a strip of view icons';
419
+ btn.setAttribute('aria-label', btn.title);
420
+ btn.setAttribute('aria-expanded', String(!folded));
421
+ btn.setAttribute('aria-controls', 'rail');
422
+ }
423
+
424
+ /**
425
+ * The main panel fills the window, and the same button — or Escape — puts it back. The top bar, the rail and the drawer
426
+ * step aside; the view's controls, the popover, the ❓ marks and the pill stay, and the Keeper still docks on the right
427
+ * with the panel giving way (Spec §6.1, U73). The view is the same element throughout, so the selection, what is open
428
+ * and where it is scrolled stay as they were. Not remembered: a reload opens the whole workbench.
429
+ */
430
+ let isMaximised = false;
431
+ const maximised = () => isMaximised;
432
+ export function setMaximised(on) {
433
+ isMaximised = Boolean(on);
434
+ applyMaximised();
435
+ popover.reposition({ measure: true });
436
+ flyout.reposition?.();
437
+ }
438
+ function applyMaximised() {
439
+ document.body.classList.toggle('maximised', isMaximised);
440
+ const btn = $('#panel-max');
441
+ if (!btn) return;
442
+ // The expand mark of the Claude desktop app the owner pointed at: four corners out, or in to restore.
443
+ btn.innerHTML = isMaximised
444
+ ? "<svg viewBox='0 0 20 20' aria-hidden='true'><path d='M8 3v5H3M12 3v5h5M8 17v-5H3M12 17v-5h5'/></svg>"
445
+ : "<svg viewBox='0 0 20 20' aria-hidden='true'><path d='M3 8V3h5M17 8V3h-5M3 12v5h5M17 12v5h-5'/></svg>";
446
+ btn.title = isMaximised ? 'Restore the workbench (Esc)' : 'Fill the window with this panel';
447
+ btn.setAttribute('aria-label', btn.title);
448
+ btn.setAttribute('aria-pressed', String(isMaximised));
449
+ }
450
+
451
+ function countFor(view) {
452
+ const c = state.counts || {};
453
+ if (view === 'notes') return c.notes ?? null;
454
+ if (view === 'changes') return c.changes ?? null;
455
+ if (view === 'scope') return c.sources ?? null;
456
+ return null;
457
+ }
458
+
459
+ function focusLabel() {
460
+ const s = state.selection;
461
+ if (!s) return 'Whole project';
462
+ return s.label || `${s.kind} ${s.id}`;
463
+ }
464
+
465
+ /** A project not organized yet — just added, or cleared — has no counts to show (Spec §6.14; D105): the pill says so and opens the Takeover page. */
466
+ const notOrganized = () => state.project?.coverage?.state === 'Not organized yet';
467
+ function notOrganizedPill() {
468
+ return h('button', { class: 'coverage-pill', title: 'Nothing runs until you choose a depth and press Start · opens the Takeover page', onClick: () => navigate(state.projectId, 'keeper', 'takeover') }, h('span', { class: 'tag amber' }, 'Not organized yet'));
469
+ }
470
+
471
+ function coveragePill() {
472
+ if (notOrganized()) return notOrganizedPill();
473
+ const cov = state.project?.coverage;
474
+ const scope = cov?.scopes?.find((s) => s.id === 'project') || cov?.scopes?.[0];
475
+ const pending = (cov?.scopes ?? []).reduce((n, s) => n + s.pending.length, 0);
476
+ const organizing = (cov?.scopes ?? []).reduce((n, s) => n + s.organizing.length, 0);
477
+ const fu = state.counts?.followUp?.objects ?? state.counts?.followUp?.entries ?? 0;
478
+ const fuEntries = state.counts?.followUp?.entries ?? 0;
479
+ const parts = [
480
+ h('span', {}, 'As of ', h('b', {}, scope?.asOf ? fmtRel(scope.asOf) : '—')),
481
+ h('span', {}, h('b', {}, organizing), ' organizing'),
482
+ h('span', {}, h('b', {}, pending), ' pending'),
483
+ fu ?h('span', { title: `Change follow-up: ${fu} object${fu === 1 ? '' : 's'} not yet judged against ${fuEntries} change entr${fuEntries === 1 ? 'y' : 'ies'} (one per change that reached it)` }, h('b', {}, fu), ' to follow up') : null,
484
+ h('span', { class: `tag ${scope?.coverage === 'Up to date' ? 'green' : 'amber'}` }, scope?.coverage ?? 'Changes pending'),
485
+ ];
486
+ // A Follow up round that found nothing new says so here and nowhere else (Spec §3.8, §6.2; CKC-07 AC-27, CKC-24
487
+ // AC-15); one that found something has its item in `Notes (attention)`. The sentence is a line of its own under the
488
+ // counts, so the pill grows a line rather than pushing the top bar's buttons onto another row.
489
+ const last = cov?.lastFollowUp?.nothingNew ? cov.lastFollowUp : null;
490
+ return h('button', { class: `coverage-pill${last ? ' with-last' : ''}`, title: 'Coverage · opens Project scope', onClick: () => navigate(state.projectId, 'scope', 'coverage') },
491
+ last ? [h('span', { class: 'cov-parts' }, ...parts), h('span', { class: 'cov-last-round', title: `Follow up round ${last.round} ended ${fmtTime(last.endedAt)}` }, last.statement)] : parts);
492
+ }
493
+
494
+ /**
495
+ * What intake could not take in, beside the coverage pill (CKC-07 AC-10): the count of distinct files, which opens their
496
+ * list with the reasons (owner 2026-09-30: "2 failed" opened nothing, and it was one file recorded twice). A button of
497
+ * its own, not a part of the pill, so pressing it does not go to Project scope. It counts what went wrong and stayed
498
+ * undone; a file too large to read is skipped, not failed — not counted here and not red (D105), listed in Project scope
499
+ * (`skippedBlock`).
500
+ */
501
+ function failedButton() {
502
+ const list = failuresOf(state.project?.coverage);
503
+ return h('button', { class: 'tag red cov-failed', id: 'cov-failed', hidden: list.length === 0, title: 'What could not be taken in, and why', onClick: openFailed }, `${list.length} failed`);
504
+ }
505
+
506
+ function openFailed() {
507
+ const list = failuresOf(state.project?.coverage);
508
+ const locations = state.project?.locations ?? [];
509
+ const body = h('div', { class: 'stack failed-list' },
510
+ h('p', { class: 'muted' }, list.length
511
+ ? `Intake did not take ${list.length === 1 ? 'this file' : `these ${list.length} files`} in, so the Keeper has not read ${list.length === 1 ? 'it' : 'them'}. Each is tried again when it changes; the latest attempt is shown.`
512
+ : 'Everything in scope was taken in.'),
513
+ ...list.map((f) => h('div', { class: 'failed-item' },
514
+ h('div', { class: 'row wrap' }, h('b', { class: 'mono' }, shortRef(f.ref, locations)), h('small', { class: 'faint', title: fmtTime(f.at) }, fmtRel(f.at))),
515
+ h('div', {}, reasonText(f.reason)),
516
+ h('div', { class: 'faint mono' }, f.ref, f.scope && list.some((x) => x.scope !== f.scope) ? ` · ${f.scope}` : ''))),
517
+ h('div', { class: 'row wrap' }, h('button', { class: 'btn small', title: 'Coverage and what waits, in Project scope', onClick: () => { $('#dialog')?.close(); navigate(state.projectId, 'scope', 'coverage'); } }, 'Coverage in Project scope')));
518
+ openDialog(`Could not be taken in (${list.length})`, [body]);
519
+ }
520
+
521
+ /**
522
+ * `Skipped: too large` in Project scope's coverage (Spec §6.7, D105; CKC-04 AC-18): each file over the size intake reads
523
+ * of one file, with its path, its size and the limit — apart from the failures, in no colour of an error. The owner can
524
+ * leave one out of the scope from its row. Mounted after the scope view has drawn its coverage section.
525
+ */
526
+ function skippedBlock() {
527
+ const scope = state.project?.scope ?? [];
528
+ const norm = (p) => String(p).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
529
+ const leftOut = scope.filter((i) => i.relation === 'Excluded').map((i) => norm(i.path));
530
+ const list = skippedOf(state.project?.coverage).filter((f) => !leftOut.some((root) => norm(f.ref) === root || norm(f.ref).startsWith(`${root}/`)));
531
+ if (list.length === 0) return null;
532
+ const locations = state.project?.locations ?? [];
533
+ const exclude = async (f) => {
534
+ try {
535
+ await api(`/api/projects/${state.projectId}/scope/items`, { method: 'POST', body: { path: f.ref, category: 'Directory', relation: 'Excluded', reason: `Too large to read (${skippedText(f)}); left out by the owner` } });
536
+ toast('Left out of the scope');
537
+ await refreshProject();
538
+ } catch (e) { toast(e.message); }
539
+ };
540
+ return h('div', { class: 'stack skipped-list', id: 'cov-skipped' },
541
+ h('h4', {}, `Skipped: too large (${list.length})`),
542
+ h('div', { class: 'faint' }, `${list.length === 1 ? 'This file is' : 'These files are'} over the size intake reads of one file, so ${list.length === 1 ? 'its text is' : 'their text is'} not read. That is not a failure and nothing waits on it: the ledger still records the path, the size and the versions.`),
543
+ ...list.map((f) => h('div', { class: 'row wrap skipped-item' },
544
+ h('span', { class: 'mono', title: f.ref }, shortRef(f.ref, locations)),
545
+ h('small', {}, skippedText(f)),
546
+ h('small', { class: 'faint', title: fmtTime(f.at) }, fmtRel(f.at)),
547
+ h('button', { class: 'btn small', title: 'Leave this file out of the scope: it is no longer listed here', onClick: () => exclude(f) }, 'Exclude from scope'))));
548
+ }
549
+
550
+ function mountSkipped(main) {
551
+ const host = main.querySelector('#coverage .content');
552
+ const block = host ? skippedBlock() : null;
553
+ if (!block) return;
554
+ const table = host.querySelector(':scope > table');
555
+ if (table) table.after(block); else host.prepend(block);
556
+ }
557
+
558
+ function patchCoveragePill() {
559
+ if (!state.project) return;
560
+ const failed = $('#cov-failed');
561
+ const nextFailed = failedButton();
562
+ if (failed && visHtml(failed.outerHTML) !== visHtml(nextFailed.outerHTML)) failed.replaceWith(nextFailed);
563
+ const pill = $('.coverage-pill');
564
+ const next = coveragePill();
565
+ if (!pill) { $('.top-actions')?.prepend(next); if (!failed) next.after(nextFailed); return; }
566
+ if (visHtml(pill.outerHTML) === visHtml(next.outerHTML)) {
567
+ const asOf = pill.querySelector(':scope > span:first-child b');
568
+ const nextAsOf = next.querySelector(':scope > span:first-child b');
569
+ if (asOf && nextAsOf) asOf.textContent = nextAsOf.textContent;
570
+ return;
571
+ }
572
+ pill.replaceWith(next);
573
+ }
574
+
575
+ function patchNavCounts() {
576
+ document.querySelectorAll('nav.views .nav-item').forEach((btn) => {
577
+ const id = btn.dataset.view;
578
+ if (!id) return;
579
+ const n = countFor(id);
580
+ let span = btn.querySelector('.count');
581
+ if (n === null) { span?.remove(); return; }
582
+ if (!span) btn.append(h('span', { class: 'count' }, n));
583
+ else span.textContent = n;
584
+ });
585
+ }
586
+
587
+ function patchNavActive() {
588
+ document.querySelectorAll('nav.views .nav-item, .mobile-nav button').forEach((btn) => btn.classList.toggle('active', btn.dataset.view === state.view));
589
+ const title = $('#panel-title');
590
+ if (title) title.textContent = viewLabel(state.view);
591
+ }
592
+
593
+ async function patchProjectPicker() {
594
+ await loadWorkspace();
595
+ const sel = $('.project-picker select');
596
+ if (!sel) { if ($('#app')) renderChrome(); return; }
597
+ const projects = state.workspace?.projects ?? [];
598
+ const nextIds = projects.map((x) => x.id);
599
+ if (state.projectId && !nextIds.includes(state.projectId)) { renderChrome(); return; }
600
+ const current = [...sel.options].map((o) => `${o.value}\0${o.textContent}`).join('|');
601
+ const next = ['\0' + (projects.length ? 'Choose a project' : 'No projects yet'), ...projects.map((x) => `${x.id}\0${x.name}`)].join('|');
602
+ if (current === next && sel.value === (state.projectId ?? '')) return;
603
+ const val = state.projectId ?? '';
604
+ clear(sel);
605
+ sel.append(h('option', { value: '' }, projects.length ? 'Choose a project' : 'No projects yet'),
606
+ ...projects.map((x) => h('option', { value: x.id, selected: x.id === val }, x.name)));
607
+ sel.value = val;
608
+ }
609
+
610
+ /**
611
+ * What the rail lists: everything the project has, or only the work that is still open. The rail used to show the
612
+ * open work alone, which made a finished area look empty beside the List (owner 2026-09-17: make it obvious which
613
+ * of the two you are looking at). The choice is a per-viewer convenience, so it lives in this browser.
614
+ */
615
+ const OUTLINE_KEY = 'pk.outline.mode';
616
+ // When this browser has no storage, the choice still applies on this page.
617
+ let outlineMemory = null;
618
+ function outlineMode() {
619
+ try {
620
+ const v = localStorage.getItem(OUTLINE_KEY);
621
+ if (v === 'all' || v === 'todo') return v;
622
+ } catch { /* no storage: the in-memory choice below */ }
623
+ return outlineMemory === 'all' || outlineMemory === 'todo' ? outlineMemory : 'todo';
624
+ }
625
+ function setOutlineMode(mode) {
626
+ outlineMemory = mode;
627
+ try { localStorage.setItem(OUTLINE_KEY, mode); } catch { /* remembered on this page only */ }
628
+ const modes = document.querySelector('.outline-modes');
629
+ if (modes) for (const b of modes.querySelectorAll('button')) b.classList.toggle('active', (mode === 'all' && b.textContent === 'All') || (mode === 'todo' && b.textContent === 'To do'));
630
+ renderOutline();
631
+ }
632
+
633
+ export let activity = { status: 'Not connected', reason: '', jobs: [] };
634
+ export async function refreshActivity() {
635
+ if (!state.projectId) return;
636
+ try { activity = await api(`/api/projects/${encodeURIComponent(state.projectId)}/activity`); } catch { /* keep old */ }
637
+ if (!state.project) return;
638
+ paintDock();
639
+ views.conversation.sync?.();
640
+ const entry = $('#keeper-entry');
641
+ const nextEntry = keeperEntry();
642
+ if (entry && entry.outerHTML !== nextEntry.outerHTML) entry.replaceWith(nextEntry);
643
+ }
644
+
645
+ /**
646
+ * The Keeper's status at the foot of the rail: what it is doing, in its own words, where the owner has been reading
647
+ * it (owner, 2026-09-17: "左边原来的 pi 的状态栏恢复一下"). It lists the jobs running now, as it always did; the
648
+ * rail keeps it to a few lines so a burst of parallel work cannot push the rest of the rail off screen.
649
+ */
650
+ function keeperEntry() {
651
+ const st = activity.status;
652
+ const color = st === 'Working' || st === 'Working on your request' ? 'amber' : st === 'Idle' ? 'green' : st === 'Not connected' || st === 'Unavailable' ? 'red' : 'blue';
653
+ const running = activity.jobs.filter((j) => j.status === 'Running');
654
+ const all = running.map((j) => `${j.kind}: ${j.scope.label}`);
655
+ return h('button', { class: 'keeper-entry', id: 'keeper-entry', title: all.join('\n') || st, onClick: () => openActivity() },
656
+ h('span', { class: 'pi-mark' }, 'π'),
657
+ h('span', { class: 'kw' }, h('b', {}, h('span', { class: `dot ${color}` }), ' ', st), h('small', {}, all.length ? all.join(' · ') : activity.reason || 'Keeper activity')));
658
+ }
659
+
660
+ /**
661
+ * The Keeper's dock, bottom right (owner, 2026-09-17, pointing at WorkflowKeeper's): `Follow up` asks for a round of
662
+ * organizing now, and `Pi` opens the conversation on the right or puts it away (D67; Spec §6.9). The dot on the robot
663
+ * says whether it is working. The pill moves left with the main view while the conversation is docked, and every
664
+ * view keeps the corner under it clear, so it never lies on text (Spec §6.1).
665
+ */
666
+ function keeperDock() {
667
+ const st = activity.status;
668
+ const color = st === 'Working' || st === 'Working on your request' ? 'amber' : st === 'Idle' ? 'green' : st === 'Not connected' || st === 'Unavailable' ? 'red' : 'blue';
669
+ const running = activity.jobs.filter((j) => j.status === 'Running');
670
+ const all = running.map((j) => `${j.kind}: ${j.scope.label}`);
671
+ const busy = running.length > 0;
672
+ // Follow up is daily organizing: until the takeover is done it is not available, and says what to do first (Spec §6.9).
673
+ const understanding = state.project?.coverage?.state;
674
+ const takeoverFirst = understanding === 'Not organized yet' ? 'Start the takeover on the Takeover page first' : understanding !== 'Takeover complete' ? 'The takeover is still under way: Follow up is available once it is done' : null;
675
+ return h('div', { class: 'keeper-dock', id: 'keeper-dock' },
676
+ h('div', { class: 'dock-pill' },
677
+ h('button', {
678
+ class: 'dock-follow', disabled: busy || Boolean(takeoverFirst),
679
+ title: takeoverFirst ?? (busy ? `The Keeper is already working: ${all.join('; ')}` : 'Organize what changed since the last round, now'),
680
+ onClick: followUp,
681
+ }, h('span', {}, '↻'), ' Follow up'),
682
+ h('span', { class: 'dock-sep' }),
683
+ // The robot opens the conversation: asking the Keeper something is what the owner comes to the bottom right for.
684
+ // What it is doing now is the rail's job, and the dot here only says whether it is busy.
685
+ h('button', { class: `dock-robot${state.keeperOpen ? ' on' : ''}`, 'aria-pressed': String(Boolean(state.keeperOpen)), 'aria-label': 'Conversation with the Keeper',
686
+ title: state.keeperOpen ? 'Put the conversation with the Keeper away' : busy ? `Talk to the Keeper (working: ${all.join('; ')})` : 'Talk to the Keeper', onClick: () => toggleKeeper() },
687
+ h('svg', { viewBox: '0 0 24 24', html: "<rect x='4' y='8' width='16' height='11' rx='3'/><path d='M12 4v4M8.5 13h.01M15.5 13h.01M9 16.5h6M2.5 12v3M21.5 12v3'/>" }),
688
+ h('span', {}, 'Pi'), h('span', { class: `dot ${color}` }))));
689
+ }
690
+
691
+ /** Redraw the pill when what it says changed; a button of it that had focus keeps it. */
692
+ function paintDock() {
693
+ const dock = $('#keeper-dock');
694
+ if (!dock) return;
695
+ const next = keeperDock();
696
+ if (dock.outerHTML === next.outerHTML) return;
697
+ const focused = dock.contains(document.activeElement) ? document.activeElement.className.split(' ')[0] : null;
698
+ dock.replaceWith(next);
699
+ syncPillWidth();
700
+ if (focused) next.querySelector(`.${focused}`)?.focus({ preventScroll: true });
701
+ }
702
+
703
+ async function followUp() {
704
+ if (!state.projectId) return;
705
+ try {
706
+ const r = await api(`/api/projects/${encodeURIComponent(state.projectId)}/keeper/follow-up`, { method: 'POST' });
707
+ toast(r?.queued ? `Follow up: organizing ${r.queued} material${r.queued === 1 ? '' : 's'}` : 'Follow up: nothing has changed since the last round');
708
+ await refreshActivity();
709
+ } catch (e) { toast(`Follow up: ${e.message}`); }
710
+ }
711
+
712
+ async function renderOutline() {
713
+ const el = $('#outline');
714
+ if (!el || !state.projectId) return;
715
+ try {
716
+ const graph = await api(`/api/projects/${encodeURIComponent(state.projectId)}/graph`);
717
+ const areas = graph.nodes.filter((n) => n.category === 'Area' && n.validity === 'Current');
718
+ const mode = outlineMode();
719
+ const rows = [];
720
+ if (areas.length === 0) rows.push(h('div', { class: 'empty' }, 'No areas established yet'));
721
+ for (const area of areas) {
722
+ // The rail lists the work that is still open, so an area whose work is finished looks empty next to the List,
723
+ // which shows every work item. The count says both, so the difference is visible (owner asked, 2026-09-17).
724
+ const all = graph.nodes.filter((n) => n.category === 'Work item' && n.areaId === area.refId && n.validity === 'Current');
725
+ const open = all.filter((n) => n.progress !== 'Done');
726
+ const works = mode === 'all' ? all : open;
727
+ const count = all.length === 0 ? '' : mode === 'all' ? String(all.length) : `${open.length}/${all.length}`;
728
+ // Buttons, so the outline is a keyboard way to every object the graph draws on a canvas (CKC-09 AC-34). A name
729
+ // that does not fit takes a second line, and the hover has it whole, the area's with its count under it (D68).
730
+ rows.push(h('button', { class: `ol-area${state.selection?.id === area.id ? ' sel' : ''}`, 'data-node': area.id, title: all.length ? `${area.label}\n${open.length} of ${all.length} work items are still open` : area.label, onClick: () => select({ kind: 'node', id: area.id, label: area.label, category: 'Area' }, { reveal: true }) }, h('span', {}, area.label), h('small', {}, count)));
731
+ for (const w of works.slice(0, mode === 'all' ? 30 : 12)) rows.push(h('button', { class: `ol-work${state.selection?.id === w.id ? ' sel' : ''}`, 'data-node': w.id, title: w.label, onClick: () => select({ kind: 'node', id: w.id, label: w.label, category: 'Work item' }, { reveal: true }) }, h('span', {}, w.label)));
732
+ }
733
+ // Rebuilt on every asset event, this list blinked the whole rail while the Keeper worked: only replace it when
734
+ // what it shows has changed (owner, 2026-09-17).
735
+ const next = h('div', {}, ...rows);
736
+ if (el.innerHTML === next.innerHTML) return;
737
+ clear(el).append(...rows);
738
+ } catch (e) { clear(el).append(h('div', { class: 'empty' }, e.message)); }
739
+ }
740
+
741
+ /** The outline follows the selection (Spec §6.1) without being rebuilt: the row that was pressed keeps the focus. */
742
+ function markOutline() {
743
+ document.querySelectorAll('#outline [data-node]').forEach((row) => row.classList.toggle('sel', row.dataset.node === state.selection?.id));
744
+ }
745
+
746
+ // ── body ────────────────────────────────────────────────────────────────
747
+ export async function renderBody() {
748
+ const main = $('#main');
749
+ const body = $('#body');
750
+ if (!main) return;
751
+ flyout.close(); // the control row it belonged to is rebuilt with the view
752
+ setTopTools(null);
753
+ clear(main);
754
+ main.className = state.view === 'graph' ? 'graph-main' : '';
755
+ body.classList.toggle('graph-body', state.view === 'graph' && Boolean(state.projectId));
756
+ // The drawer belongs to the Project graph (Graph, List and Code fill it again); elsewhere it only keeps the band
757
+ // beside the pill, so no view runs under it (Spec §6.1).
758
+ if (state.view !== 'graph' || !state.projectId) hideDrawer();
759
+ if (!state.projectId) {
760
+ main.append(h('div', { class: 'empty' }, h('h2', {}, 'Add a project to begin'), h('p', {}, 'ProjectKeeper reads a project as it is: directories, repositories, worktrees and the agent sessions that worked in them. Nothing in the project is changed.'), h('button', { class: 'btn primary', onClick: addProjectDialog }, 'Add project')));
761
+ return;
762
+ }
763
+ const view = views[state.view] || views.graph;
764
+ try {
765
+ await view.render(main, state);
766
+ if (state.view === 'scope') mountSkipped(main);
767
+ } catch (e) {
768
+ main.append(h('div', { class: 'empty' }, h('h2', {}, 'This view could not load'), h('p', {}, e.message)));
769
+ }
770
+ const crumb = $('#focus-crumb');
771
+ if (crumb) { crumb.textContent = focusLabel(); crumb.title = focusLabel(); }
772
+ markOutline();
773
+ // Arriving with an object to show (a search result, a link, `Show on graph`): its popover opens beside it here.
774
+ // Otherwise a popover that is up stays up over the rebuilt view, beside the same object.
775
+ const arriving = state.pendingPopover;
776
+ state.pendingPopover = null;
777
+ if (arriving && state.selection) showDetails(state.selection, { trigger: null, anchorIds: arriving.anchorIds });
778
+ else refreshDetails();
779
+ }
780
+
781
+ // ── Keeper conversation: docked on the right, or popped out (Spec §6.8; CKC-10 AC-19, AC-20) ──────────────
782
+ /**
783
+ * Opening the conversation — from `Pi`, `Ask Keeper`, a popover, a note — docks it on the right, from under the top
784
+ * bar to the bottom, and the main view gives it its width, so nothing is covered (WorkflowKeeper's D81). `Pi` toggles;
785
+ * the other entries open. A context given, or else the selected object, comes with it.
786
+ */
787
+ export function toggleKeeper(open, context) {
788
+ const next = open ?? !state.keeperOpen;
789
+ if (context !== undefined) state.keeperContext = context;
790
+ else if (next && state.selection) state.keeperContext = state.selection;
791
+ state.keeperOpen = next;
792
+ renderKeeper({ focus: next });
793
+ }
794
+
795
+ /**
796
+ * One panel, two forms, and it is never rebuilt when it changes form: docked on the right, or — `Pop out` — the
797
+ * window of 2026-09-18, which drops in from the top, is dragged by its header, resized from its corner, remembers
798
+ * where it was put and returns to the middle on a double-click of its header; `Dock` puts it back. It is the same
799
+ * element either way, so the conversation, a half-typed message and the scroll position stay; it lives outside the
800
+ * page shell, so neither a change of view nor a rebuilt shell touches it. Closed and opened again, it is docked.
801
+ */
802
+ const KEEPER_POS = 'pk.keeper.pos'; // the window: { left, top, width?, height? }
803
+ const KEEPER_WIDTH = 'pk.keeper.width'; // the docked panel's width
804
+ const remembered = (key) => { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; } };
805
+ const remember = (key, value) => { try { if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, JSON.stringify(value)); } catch { /* a convenience only */ } };
806
+ const isFloating = (panel) => panel.classList.contains('floating');
807
+
808
+ function keeperPanel() {
809
+ let panel = document.getElementById('keeper');
810
+ if (panel) return panel;
811
+ const resizer = h('div', { class: 'keeper-resizer', role: 'separator', 'aria-orientation': 'vertical', 'aria-label': 'Width of the conversation', tabindex: 0, title: 'Drag to make the conversation wider or narrower (or use the arrow keys)' });
812
+ panel = h('section', { class: 'keeper-panel docked', id: 'keeper', tabindex: -1, 'aria-label': 'Conversation with the Keeper' }, resizer, h('div', { class: 'keeper-inner' }));
813
+ document.body.append(panel);
814
+
815
+ // The window is dragged by its header; two presses on the header in quick succession put it back in the middle.
816
+ // (Not a `dblclick` listener: the drag captures the pointer, so a double-click is delivered to the panel and never
817
+ // says it was on the header — the double-click of 2026-09-18 did nothing for that reason.)
818
+ let drag = null;
819
+ let lastPress = null;
820
+ panel.addEventListener('pointerdown', (e) => {
821
+ if (!isFloating(panel) || !e.target.closest('.keeper-head') || e.target.closest('button, input, select, textarea, a')) return;
822
+ const second = lastPress && e.timeStamp - lastPress.at < 450 && Math.hypot(e.clientX - lastPress.x, e.clientY - lastPress.y) < 8;
823
+ lastPress = second ? null : { at: e.timeStamp, x: e.clientX, y: e.clientY };
824
+ if (second) {
825
+ e.preventDefault();
826
+ placeKeeper(panel, null);
827
+ remember(KEEPER_POS, { width: panel.offsetWidth, height: panel.offsetHeight }); // its size is kept, its place forgotten
828
+ return;
829
+ }
830
+ const r = panel.getBoundingClientRect();
831
+ drag = { dx: e.clientX - r.left, dy: e.clientY - r.top, id: e.pointerId };
832
+ panel.setPointerCapture(e.pointerId);
833
+ panel.classList.add('dragging');
834
+ e.preventDefault();
835
+ });
836
+ panel.addEventListener('pointermove', (e) => { if (drag && e.pointerId === drag.id) { drag.moved = true; placeKeeper(panel, { left: e.clientX - drag.dx, top: e.clientY - drag.dy }); } });
837
+ const stop = (e) => {
838
+ if (!drag || e.pointerId !== drag.id) return;
839
+ const moved = drag.moved;
840
+ drag = null;
841
+ panel.classList.remove('dragging');
842
+ if (moved) rememberWindow(panel); // a press that did not move it is not a new place to remember
843
+ };
844
+ panel.addEventListener('pointerup', stop);
845
+ panel.addEventListener('pointercancel', stop);
846
+ // Escape puts the window away. The docked panel is part of the workbench and stays.
847
+ panel.addEventListener('keydown', (e) => { if (e.key === 'Escape' && isFloating(panel)) toggleKeeper(false); });
848
+ panel.addEventListener('animationend', () => panel.classList.remove('dropping'));
849
+ addEventListener('resize', () => { if (panel.classList.contains('open') && isFloating(panel)) placeKeeper(panel, { left: panel.offsetLeft, top: panel.offsetTop }); });
850
+
851
+ // The docked panel is made wider or narrower from its left edge; the main view follows (CKC-10 AC-19).
852
+ const setWidth = (w) => { panel.style.width = `${Math.round(w)}px`; };
853
+ let sizing = null;
854
+ resizer.addEventListener('pointerdown', (e) => { sizing = e.pointerId; resizer.setPointerCapture(e.pointerId); panel.classList.add('sizing'); e.preventDefault(); });
855
+ resizer.addEventListener('pointermove', (e) => { if (sizing === e.pointerId) setWidth(innerWidth - e.clientX); });
856
+ const sized = (e) => { if (sizing !== e.pointerId) return; sizing = null; panel.classList.remove('sizing'); remember(KEEPER_WIDTH, panel.offsetWidth); };
857
+ resizer.addEventListener('pointerup', sized);
858
+ resizer.addEventListener('pointercancel', sized);
859
+ resizer.addEventListener('keydown', (e) => {
860
+ const step = e.key === 'ArrowLeft' ? 24 : e.key === 'ArrowRight' ? -24 : 0;
861
+ if (!step) return;
862
+ e.preventDefault();
863
+ setWidth(panel.offsetWidth + step);
864
+ remember(KEEPER_WIDTH, panel.offsetWidth);
865
+ });
866
+ // The width it really has — the stylesheet keeps it between a minimum and what the main view can spare — is what
867
+ // the main view and the pill give way by (WorkflowKeeper's `--pi-w`).
868
+ if ('ResizeObserver' in window) new ResizeObserver(() => { syncKeeperLayout(); if (panel.classList.contains('open') && isFloating(panel) && !panel.classList.contains('dropping')) rememberWindow(panel); }).observe(panel);
869
+ return panel;
870
+ }
871
+ function rememberWindow(panel) {
872
+ if (!panel.offsetWidth) return;
873
+ remember(KEEPER_POS, { left: panel.offsetLeft, top: panel.offsetTop, width: panel.offsetWidth, height: panel.offsetHeight });
874
+ }
875
+ /** Put the window at a spot (kept inside the screen, header always reachable), or, with null, in the middle near the top. */
876
+ function placeKeeper(win, at) {
877
+ const w = win.offsetWidth || Math.min(560, innerWidth - 32);
878
+ const hgt = win.offsetHeight || Math.min(innerHeight * 0.72, 680);
879
+ const left = at ? at.left : (innerWidth - w) / 2;
880
+ const top = at ? at.top : Math.max(56, innerHeight * 0.08);
881
+ win.style.left = `${Math.round(Math.min(Math.max(8, left), innerWidth - Math.min(w, 120)))}px`;
882
+ win.style.top = `${Math.round(Math.min(Math.max(8, top), innerHeight - 48))}px`;
883
+ win.style.setProperty('--pk-fall', `${Math.round(Math.min(Math.max(8, top), innerHeight - 48) + hgt + 40)}px`);
884
+ }
885
+ /** The main view and the pill give way by the docked panel's real width; with it popped out or closed, by nothing. */
886
+ function syncKeeperLayout() {
887
+ const panel = document.getElementById('keeper');
888
+ const docked = Boolean(panel && state.keeperOpen && panel.classList.contains('open') && !isFloating(panel));
889
+ if (docked) document.documentElement.style.setProperty('--pk-keeper-w', `${Math.round(panel.getBoundingClientRect().width)}px`);
890
+ if (document.body.classList.contains('keeper-docked') !== docked) document.body.classList.toggle('keeper-docked', docked);
891
+ popover.reposition({ measure: true });
892
+ }
893
+ /** Give the one panel its form. Its content is not touched. */
894
+ function setKeeperForm(panel, form, { drop = false } = {}) {
895
+ const floating = form === 'floating';
896
+ if (isFloating(panel) && !floating) rememberWindow(panel);
897
+ panel.classList.toggle('floating', floating);
898
+ panel.classList.toggle('docked', !floating);
899
+ panel.classList.remove('dropping', 'dragging', 'sizing');
900
+ panel.setAttribute('role', floating ? 'dialog' : 'complementary');
901
+ for (const p of ['left', 'top', 'width', 'height']) panel.style[p] = '';
902
+ if (floating) {
903
+ const saved = remembered(KEEPER_POS);
904
+ if (saved?.width && saved?.height) { panel.style.width = `${saved.width}px`; panel.style.height = `${saved.height}px`; }
905
+ placeKeeper(panel, saved && Number.isFinite(saved.left) ? saved : null);
906
+ if (drop) { void panel.offsetWidth; panel.classList.add('dropping'); } // it falls in from the top (a fade with reduced motion)
907
+ } else {
908
+ const width = remembered(KEEPER_WIDTH);
909
+ if (Number.isFinite(width)) panel.style.width = `${width}px`;
910
+ }
911
+ }
912
+ /** `Pop out` and `Dock`: the same conversation in its other form, with what was typed and where it was scrolled. */
913
+ export function setKeeperMode(mode) {
914
+ const panel = document.getElementById('keeper');
915
+ if (!panel || !state.keeperOpen || (mode === 'floating') === isFloating(panel)) return;
916
+ const restore = views.conversation.holdScroll();
917
+ setKeeperForm(panel, mode, { drop: mode === 'floating' });
918
+ syncKeeperLayout();
919
+ views.conversation.sync();
920
+ restore();
921
+ }
922
+ export const keeperMode = () => (document.getElementById('keeper')?.classList.contains('floating') ? 'floating' : 'docked');
923
+
924
+ function renderKeeper({ focus = false } = {}) {
925
+ const existing = document.getElementById('keeper');
926
+ if (!state.projectId) { existing?.classList.remove('open'); syncKeeperLayout(); return; }
927
+ if (!state.keeperOpen) {
928
+ if (existing?.classList.contains('open')) { existing.classList.remove('open'); setKeeperForm(existing, 'docked'); } // opened again, it is docked
929
+ syncKeeperLayout();
930
+ paintDock();
931
+ return;
932
+ }
933
+ const panel = keeperPanel();
934
+ const wasOpen = panel.classList.contains('open');
935
+ if (!wasOpen) { setKeeperForm(panel, 'docked'); panel.classList.add('open'); }
936
+ // Mounted once; after that only what changed is updated — the state and context in its head, and the conversation
937
+ // itself when the project, the session or the context is another one. A change of view changes none of them.
938
+ views.conversation.render(panel.querySelector('.keeper-inner'), state);
939
+ syncKeeperLayout();
940
+ paintDock();
941
+ if (focus && !wasOpen) views.conversation.focus();
942
+ }
943
+
944
+ export function openActivity() {
945
+ views.activity.open(state);
946
+ }
947
+
948
+ // ── dialogs ──────────────────────────────────────────────────────────────
949
+ function addProjectDialog() {
950
+ const name = h('input', { class: 'input', placeholder: 'Project name' });
951
+ // The example is a path of the system the page runs on; `~` is the home directory on every one.
952
+ const loc = h('textarea', { class: 'input', placeholder: WINDOWS ? 'One location per line, e.g. D:\\myproject\nD:\\myproject-worktrees\\feature' : 'One location per line, e.g. ~/code/myproject\n~/code/myproject-worktrees/feature' });
953
+ const err = h('small', { class: 'faint' });
954
+ const submit = async () => {
955
+ const locations = loc.value.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
956
+ if (!name.value.trim() || locations.length === 0) { err.textContent = 'A name and at least one location are needed.'; return; }
957
+ try {
958
+ const project = await api('/api/projects', { method: 'POST', body: { name: name.value, locations } });
959
+ $('#dialog').close();
960
+ await loadWorkspace();
961
+ // The project opens on its Takeover page; nothing runs until the owner chooses a depth and presses Start (D105).
962
+ navigate(project.id, 'keeper', 'takeover');
963
+ toast('Project added — choose a depth and press Start');
964
+ } catch (e) { err.textContent = e.message; }
965
+ };
966
+ // `Browse…` walks to a folder instead of typing its path; what is chosen becomes a line of the field, which stays
967
+ // a field: typed and pasted paths are taken as before (folder-chooser.js).
968
+ const chooser = createFolderChooser({
969
+ h, api, added: () => locationLines(loc.value), start: () => locationLines(loc.value).pop() ?? '',
970
+ onAdd: (path) => { loc.value = withLocation(loc.value, path); err.textContent = ''; },
971
+ });
972
+ openDialog('Add project', [
973
+ h('p', { class: 'muted' }, 'Give the project a name and at least one location. Adding it starts nothing: on its Takeover page you choose the key, the model and a depth, and press Start. The Keeper then works out what belongs to it (repositories, worktrees, copies, session logs) and asks only when an ambiguity would change the result.'),
974
+ h('div', { class: 'field' }, h('label', {}, 'Name'), name),
975
+ h('div', { class: 'field' }, h('div', { class: 'row spread' }, h('label', {}, 'Locations'), chooser.button), loc, chooser.panel),
976
+ err,
977
+ h('div', { class: 'dialog-foot' }, h('button', { class: 'btn primary', onClick: submit }, 'Add project')),
978
+ ]);
979
+ name.focus();
980
+ }
981
+
982
+ async function openSearch() {
983
+ const input = h('input', { class: 'input', placeholder: 'Search nodes, sources, changes and notes…' });
984
+ const results = h('div', { class: 'search-results' });
985
+ let timer = null;
986
+ const run = async () => {
987
+ const q = input.value.trim();
988
+ clear(results);
989
+ if (!q) return;
990
+ try {
991
+ const r = await api(`/api/projects/${encodeURIComponent(state.projectId)}/search?q=${encodeURIComponent(q)}`);
992
+ if (r.results.length === 0) { results.append(h('div', { class: 'faint', style: { padding: '8px' } }, 'No matches')); return; }
993
+ for (const x of r.results) {
994
+ results.append(h('button', { class: 'search-result', onClick: () => { $('#dialog').close(); views.search.go(x, state); } }, h('span', { class: 'tag' }, x.type), h('span', {}, x.label), h('small', {}, x.detail || '')));
995
+ }
996
+ } catch (e) { results.append(h('div', { class: 'faint' }, e.message)); }
997
+ };
998
+ input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(run, 150); });
999
+ openDialog('Search', [input, results]);
1000
+ input.focus();
1001
+ }
1002
+
1003
+ // ── events ────────────────────────────────────────────────────────────────
1004
+ const PAGE_ASSETS = {
1005
+ notes: ['notes'], changes: ['changes'], scope: ['coverage', 'sources', 'authorizations', 'rules', 'plans', 'marks'],
1006
+ keeper: ['jobs', 'authorizations', 'requests'], context: ['nodes', 'threads', 'notes', 'contexts'],
1007
+ };
1008
+
1009
+ async function flushAssets(cols) {
1010
+ const only = (names) => cols.size > 0 && [...cols].every((c) => names.includes(c));
1011
+ // The Keeper view follows the Keeper's work — the Takeover page fills in as it goes — updated in place (views.keeper.onAssets).
1012
+ const keeperPage = () => { if (state.view === 'keeper') void views.keeper.onAssets?.(); };
1013
+ if (only(['jobs'])) {
1014
+ await refreshActivity();
1015
+ views.activity.onEvent?.();
1016
+ keeperPage();
1017
+ return;
1018
+ }
1019
+ await loadProject(state.projectId).catch(() => {});
1020
+ patchCoveragePill();
1021
+ patchNavCounts();
1022
+ paintDock();
1023
+ if (only(['coverage']) || only(['coverage', 'jobs'])) {
1024
+ if (cols.has('jobs')) { await refreshActivity(); views.activity.onEvent?.(); }
1025
+ if (state.view === 'scope') await renderBody();
1026
+ keeperPage();
1027
+ return;
1028
+ }
1029
+ const view = views[state.view];
1030
+ // The popover and the full details stay where they are; their content is fetched again and swapped only if it
1031
+ // differs (CKC-09 AC-34: updated in place, and no redraw when nothing changed).
1032
+ void updateCounts({ refresh: true });
1033
+ if (view?.onAssets) {
1034
+ await view.onAssets();
1035
+ refreshDetails();
1036
+ } else {
1037
+ const needed = PAGE_ASSETS[state.view];
1038
+ const hit = !needed || [...cols].some((c) => c === '*' || needed.includes(c));
1039
+ if (hit) await renderBody();
1040
+ else refreshDetails();
1041
+ }
1042
+ renderOutline();
1043
+ }
1044
+
1045
+ function connectEvents() {
1046
+ const es = new EventSource('/api/events');
1047
+ let pending = null;
1048
+ let pendingCols = new Set();
1049
+ es.addEventListener('app', (e) => {
1050
+ const event = JSON.parse(e.data);
1051
+ if (event.type === 'workspace') { void patchProjectPicker(); return; }
1052
+ if (event.projectId && event.projectId !== state.projectId) return;
1053
+ if (event.type === 'keeper' || event.type === 'chat') { views.conversation.onEvent?.(event, state); refreshActivity(); if (event.type === 'keeper') views.activity.onEvent?.(event, state); return; }
1054
+ pendingCols.add(event.type === 'project' ? 'coverage' : (event.collection || '*'));
1055
+ clearTimeout(pending);
1056
+ // A busy Keeper writes assets in bursts; redrawing 120 ms after each one made the page twitch. One redraw a
1057
+ // second still feels live and coalesces a burst into a single update (owner, 2026-09-17). jobs and coverage
1058
+ // alone do not rebuild the graph or details (owner, 2026-09-18).
1059
+ pending = setTimeout(() => {
1060
+ const cols = pendingCols;
1061
+ pendingCols = new Set();
1062
+ pending = null;
1063
+ void flushAssets(cols);
1064
+ }, 1000);
1065
+ });
1066
+ }
1067
+
1068
+ async function route() {
1069
+ let { projectId, view, rest } = parseHash();
1070
+ const changedProject = projectId !== state.projectId;
1071
+ const prevView = state.view;
1072
+ const prevRest = state.routeRest || '';
1073
+ // Older links keep working: Sources now lives inside Project scope (D34); Connections is the Keeper view (D35).
1074
+ if (view === 'sources') { view = 'scope'; rest = rest || 'sources'; }
1075
+ if (view === 'connections') view = 'keeper';
1076
+ const nextView = VIEWS.some(([id]) => id === view) ? view : 'graph';
1077
+ if (!changedProject && nextView === prevView && (rest || '') === prevRest && state.projectId === projectId) return;
1078
+ state.view = nextView;
1079
+ state.routeRest = rest;
1080
+ if (changedProject || nextView !== prevView) views.graph.closeSheet?.();
1081
+ if (changedProject) {
1082
+ state.selection = null;
1083
+ state.projectId = projectId;
1084
+ state.project = null;
1085
+ // The conversation and its context belong to the project that was open; the panel itself stays as it is.
1086
+ state.conversationId = null;
1087
+ state.keeperContext = null;
1088
+ state.pendingPopover = null;
1089
+ if (projectId) {
1090
+ try {
1091
+ await loadProject(projectId);
1092
+ const opened = await api(`/api/projects/${encodeURIComponent(projectId)}/opened`, { method: 'POST' });
1093
+ state.lastVisit = opened?.previous ?? null;
1094
+ await refreshActivity();
1095
+ // A project not organized yet opens on the Takeover page of the Keeper view (Spec §6.1, §6.14; D105).
1096
+ if (notOrganized() && state.view !== 'keeper') {
1097
+ state.view = 'keeper';
1098
+ state.routeRest = 'takeover';
1099
+ history.replaceState(null, '', `#/p/${encodeURIComponent(projectId)}/keeper/takeover`);
1100
+ }
1101
+ } catch (e) { toast(e.message); state.projectId = null; }
1102
+ }
1103
+ renderChrome();
1104
+ return;
1105
+ }
1106
+ patchNavActive();
1107
+ // What the popover stood beside is gone with the view; one that travels with the owner (`Show on graph`, a search
1108
+ // result) opens again beside the object where they arrive.
1109
+ if (nextView !== prevView && !state.pendingPopover) popover.close('navigate');
1110
+ await renderBody();
1111
+ }
1112
+
1113
+ document.addEventListener('keydown', (e) => {
1114
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k' && state.projectId) { e.preventDefault(); openSearch(); }
1115
+ if (e.key !== 'Escape' || e.defaultPrevented) return;
1116
+ // One Escape closes one thing (the dialog, the popover and the control row's panels have taken theirs already):
1117
+ // the reading sheet over the drawer, else the maximised panel goes back into the workbench (shell-state.js).
1118
+ const inKeeper = e.target instanceof Element && Boolean(e.target.closest('#keeper'));
1119
+ const act = escapeAction({ dialogOpen: $('#dialog').open, sheetOpen: stripSheetOpen(), maximised: maximised(), inKeeper });
1120
+ if (act === 'close-sheet' && views.graph.closeSheet?.({ focus: true })) e.preventDefault();
1121
+ else if (act === 'restore') { e.preventDefault(); setMaximised(false); $('#panel-max')?.focus({ preventScroll: true }); }
1122
+ });
1123
+ window.addEventListener('hashchange', route);
1124
+ // The theme changed: the graph repaints its pictures in the new colours, in place. Nothing else on the page is touched
1125
+ // — the stylesheets did the rest — and nothing is fetched again (Spec §6.16; CKC-09 AC-40).
1126
+ document.addEventListener('pk:theme', () => { views.graph.repaint?.(); });
1127
+ // The theme's sheets are in before anything is drawn, so the graph reads its colours once and paints once.
1128
+ await initTheme();
1129
+ await loadWorkspace();
1130
+ if (!location.hash && state.workspace.lastProjectId) navigate(state.workspace.lastProjectId, 'graph');
1131
+ await route();
1132
+ // A workspace with no project yet: route() has nothing to change to, so the shell — with `Add a project to begin` — is
1133
+ // drawn here (Spec §6.14). It used to stay blank until some workspace event came.
1134
+ if (!$('#app').firstChild) renderChrome();
1135
+ connectEvents();