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/popover.js ADDED
@@ -0,0 +1,198 @@
1
+ // The details popover (Spec §6.4; CKC-09 AC-34, AC-36): one floating window beside the object that was picked. It takes
2
+ // no column and moves nothing else. This module is only its mechanics — where it stands (popover-place.js), following
3
+ // its object while the graph pans, a list scrolls or the main view changes width, keyboard and focus, closing, and
4
+ // updating in place without a redraw when nothing changed. What is shown inside it comes from the caller.
5
+ import { placePopover } from './popover-place.js';
6
+
7
+ const FOCUSABLE = 'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [tabindex]:not([tabindex="-1"])';
8
+
9
+ /**
10
+ * @param {{ bounds: () => {left:number,top:number,right:number,bottom:number}, sheet: () => boolean,
11
+ * normalize?: (html: string) => string, keepsOpen?: (target: Element) => boolean, onClose?: (why: string) => void,
12
+ * avoid?: () => Array<{left:number,top:number,right:number,bottom:number}> }} o
13
+ * `bounds` is the main view's visible area; `sheet` says the window is narrow, where the popover is an overlay along
14
+ * the bottom instead; `normalize` strips what changes without meaning (relative clocks) before two renderings are
15
+ * compared; `keepsOpen` names the places where a press does not count as "elsewhere" (the Keeper, used beside it);
16
+ * `avoid` names rectangles it keeps off when it can (the pill at the bottom right).
17
+ */
18
+ export function createPopover({ bounds, sheet, normalize = (s) => s, keepsOpen = () => false, onClose = () => {}, avoid = () => [] }) {
19
+ const content = document.createElement('div');
20
+ content.className = 'popover-content';
21
+ const el = document.createElement('section');
22
+ el.className = 'popover';
23
+ el.id = 'popover';
24
+ el.hidden = true;
25
+ el.tabIndex = -1;
26
+ el.setAttribute('role', 'dialog');
27
+ el.setAttribute('aria-label', 'Details');
28
+ el.append(content);
29
+ const scrim = document.createElement('div');
30
+ scrim.className = 'popover-scrim';
31
+ scrim.hidden = true;
32
+ document.body.append(scrim, el);
33
+
34
+ /** @type {null | { key: string, anchor: () => (object|null), opener: () => (Element|null), render: Function, sig: string|null, side: string|null, lastAnchor: object|null }} */
35
+ let current = null;
36
+ let pending = 0;
37
+ let frame = 0;
38
+ let natural = null; // its size with no limit applied, measured when the content changes
39
+ let dismissed = { key: '', at: 0 };
40
+
41
+ const focusables = () => [...el.querySelectorAll(FOCUSABLE)].filter((n) => n.getClientRects().length > 0);
42
+ const scroller = () => content.querySelector('.popover-body');
43
+
44
+ function place({ measure = false } = {}) {
45
+ if (!current || el.hidden) return;
46
+ if (sheet()) {
47
+ // Narrow window: an overlay along the bottom (Spec §6.15); the stylesheet places it.
48
+ el.classList.add('sheet');
49
+ scrim.hidden = false;
50
+ for (const p of ['left', 'top', 'width', 'maxHeight']) el.style[p] = '';
51
+ el.dataset.side = 'sheet';
52
+ natural = null;
53
+ return;
54
+ }
55
+ el.classList.remove('sheet');
56
+ scrim.hidden = true;
57
+ const area = bounds();
58
+ const found = current.anchor();
59
+ if (found) current.lastAnchor = found;
60
+ // No object to stand beside (it is not on screen in any form): the top left of the main view.
61
+ const anchor = current.lastAnchor ?? { left: area.left + 16, top: area.top + 16, right: area.left + 16, bottom: area.top + 16 };
62
+ let scrollTop = null;
63
+ if (measure || !natural) {
64
+ // Lifting the height limit to measure would throw away how far its body is scrolled, so that is put back.
65
+ scrollTop = scroller()?.scrollTop ?? 0;
66
+ el.style.maxHeight = '';
67
+ el.style.width = '';
68
+ natural = { width: el.offsetWidth, height: el.offsetHeight };
69
+ }
70
+ const p = placePopover({ anchor, size: natural, bounds: area, keepSide: current.side, avoid: avoid() });
71
+ current.side = p.side;
72
+ el.style.left = `${p.left}px`;
73
+ el.style.top = `${p.top}px`;
74
+ el.style.width = p.width < natural.width ? `${p.width}px` : '';
75
+ el.style.maxHeight = p.height < natural.height ? `${p.height}px` : '';
76
+ el.dataset.side = p.side;
77
+ if (scrollTop !== null && scroller()) scroller().scrollTop = scrollTop;
78
+ }
79
+ /** Follow the object: at most once a frame, however many pan, scroll and resize events arrive. */
80
+ function reposition(opts) {
81
+ if (!current) return;
82
+ if (opts?.measure) natural = null;
83
+ if (frame) return;
84
+ frame = requestAnimationFrame(() => { frame = 0; place(); });
85
+ }
86
+
87
+ async function draw({ force = false, focus = false } = {}) {
88
+ const mine = current;
89
+ if (!mine) return;
90
+ const token = ++pending;
91
+ const mount = document.createElement('div');
92
+ try { await mine.render(mount, api); }
93
+ catch (e) { const box = document.createElement('div'); box.className = 'popover-body faint'; box.textContent = e.message; mount.replaceChildren(box); }
94
+ if (token !== pending || current !== mine) return;
95
+ const sig = normalize(mount.innerHTML);
96
+ if (!force && sig === mine.sig) { reposition(); return; } // nothing changed: nothing is redrawn
97
+ mine.sig = sig;
98
+ const hadFocus = el.contains(document.activeElement);
99
+ const index = hadFocus ? focusables().indexOf(document.activeElement) : -1;
100
+ const scrollTop = scroller()?.scrollTop ?? 0;
101
+ content.replaceChildren(...mount.childNodes);
102
+ el.hidden = false;
103
+ place({ measure: true });
104
+ if (scroller()) scroller().scrollTop = scrollTop;
105
+ if (focus) el.focus({ preventScroll: true });
106
+ else if (hadFocus) (focusables()[index] ?? el).focus({ preventScroll: true });
107
+ }
108
+
109
+ function close(why = 'close') {
110
+ if (!current) return;
111
+ const was = current;
112
+ current = null;
113
+ pending++;
114
+ const hadFocus = el.contains(document.activeElement);
115
+ el.hidden = true;
116
+ scrim.hidden = true;
117
+ content.replaceChildren();
118
+ natural = null;
119
+ // Focus goes back to where it was before the popover opened — unless the owner has already put it somewhere else
120
+ // (a press on another control). A press on something that takes no focus leaves it on the page body; that is
121
+ // settled a moment later, once the browser has finished with the press.
122
+ const opener = () => { const to = was.opener(); return to?.isConnected ? to : null; };
123
+ if (why === 'escape' || why === 'button') opener()?.focus({ preventScroll: true });
124
+ else if (hadFocus) setTimeout(() => { if (!current && (document.activeElement === document.body || !document.activeElement)) opener()?.focus({ preventScroll: true }); }, 0);
125
+ onClose(why);
126
+ }
127
+
128
+ const api = {
129
+ el,
130
+ isOpen: () => Boolean(current),
131
+ key: () => current?.key ?? null,
132
+ /**
133
+ * Open beside an object, or move to another one. `anchor()` returns the object's rectangle now (null when it is
134
+ * not on screen); `opener()` the element focus returns to; `render(mount, popover)` fills the content.
135
+ * Picking the object whose popover the same press has just closed leaves it closed: that press put it away.
136
+ */
137
+ open({ key, anchor, opener, render, label = 'Details' }) {
138
+ if (dismissed.key === key && performance.now() - dismissed.at < 700) { dismissed = { key: '', at: 0 }; return false; }
139
+ const same = current?.key === key;
140
+ current = { key, anchor, opener: opener ?? (() => null), render, sig: null, side: same ? current.side : null, lastAnchor: null };
141
+ el.setAttribute('aria-label', label);
142
+ if (!same) { el.hidden = true; content.replaceChildren(); natural = null; }
143
+ void draw({ force: true, focus: true });
144
+ return true;
145
+ },
146
+ /** Another page in the same popover (an object's note, and back): same place, same opener. */
147
+ show({ key, render, label }) {
148
+ if (!current) return;
149
+ current = { ...current, key, render, sig: null };
150
+ if (label) el.setAttribute('aria-label', label);
151
+ void draw({ force: true, focus: true });
152
+ },
153
+ /** The assets changed: the content is fetched again and swapped only if it differs; the popover stays where it is. */
154
+ refresh: (opts) => draw(opts),
155
+ /** A new anchor for the same popover (the view was rebuilt around it, or it was sent to the graph). */
156
+ reanchor(anchor) { if (current) { current.anchor = anchor; current.lastAnchor = null; current.side = null; reposition(); } },
157
+ reposition,
158
+ close,
159
+ focus: () => el.focus({ preventScroll: true }),
160
+ };
161
+
162
+ // Escape closes it and hands focus back; Tab moves through its items and stays inside it. One Escape closes one
163
+ // thing: a dialog on top of the popover takes it first, and the floating Keeper window does not also get it.
164
+ document.addEventListener('keydown', (e) => {
165
+ if (!current) return;
166
+ if (e.key === 'Escape') {
167
+ if (document.querySelector('dialog[open]')) return;
168
+ e.preventDefault();
169
+ e.stopPropagation();
170
+ close('escape');
171
+ return;
172
+ }
173
+ if (e.key !== 'Tab' || !el.contains(document.activeElement)) return;
174
+ const items = focusables();
175
+ if (!items.length) { e.preventDefault(); return; }
176
+ const at = items.indexOf(document.activeElement);
177
+ if (e.shiftKey && at <= 0) { e.preventDefault(); items[items.length - 1].focus(); }
178
+ else if (!e.shiftKey && at === items.length - 1) { e.preventDefault(); items[0].focus(); }
179
+ }, true);
180
+
181
+ // A press elsewhere closes it. The Keeper is not elsewhere (it is used beside the popover), nor is a dialog opened
182
+ // from it, nor the graph's canvas, which says for itself what a tap means (another object, the background, a pan).
183
+ document.addEventListener('pointerdown', (e) => {
184
+ if (!current || !(e.target instanceof Element)) return;
185
+ if (el.contains(e.target) || e.target.closest('dialog, #toast') || keepsOpen(e.target)) return;
186
+ dismissed = { key: current.key, at: performance.now() };
187
+ close('outside');
188
+ }, true);
189
+ scrim.addEventListener('click', () => close('outside'));
190
+
191
+ // It follows its object: a scroll anywhere (the List, the rail, the bottom strip) and any change of the window.
192
+ document.addEventListener('scroll', (e) => { if (current && !el.contains(e.target)) reposition(); }, true);
193
+ addEventListener('resize', () => reposition({ measure: true }));
194
+ // A fold opened inside it changes its height: measure again and check it still fits. (`toggle` does not bubble.)
195
+ el.addEventListener('toggle', () => reposition({ measure: true }), true);
196
+
197
+ return api;
198
+ }
@@ -0,0 +1,37 @@
1
+ // The project-folder authorization on the Keeper page (Spec §1.14): what the page says in each state, the words recorded
2
+ // as the owner's when they press Authorize, and what a grant did. Text only, so it is tested without a browser
3
+ // (src/ui/project-folder.test.ts); keeper-view.js draws it.
4
+
5
+ const same = (t) => t;
6
+
7
+ /** The state in one word, and the lines under the sentence that says what the authorization allows. */
8
+ export function folderStatus(pf, { time = same, rel = same } = {}) {
9
+ if (pf.granted) {
10
+ const w = pf.lastWrite;
11
+ return {
12
+ tag: 'granted', tone: 'green', action: 'withdraw',
13
+ lines: [
14
+ `Folder: ${pf.path}`,
15
+ pf.commits ? 'The Keeper commits it itself: each commit contains only this folder and is never pushed.' : 'Write-only: the Keeper commits nothing; what it writes appears as uncommitted changes.',
16
+ `Granted ${time(pf.grantedAt)} · ${w ? `last written ${rel(w.at)}${w.commit ? ` in commit ${w.commit.slice(0, 7)}` : ', not committed'}` : 'not written yet'}`,
17
+ ],
18
+ };
19
+ }
20
+ const lines = ['Not granted: the Keeper writes nothing into the project.'];
21
+ if (pf.withdrawnAt) lines.push(`Withdrawn ${time(pf.withdrawnAt)}.${pf.exists ? ` ${pf.path} is still in the project, with its commits; it is yours to keep or delete.` : ''}`);
22
+ return { tag: 'not granted', tone: '', action: 'grant', lines };
23
+ }
24
+
25
+ /** The owner's statement a press of Authorize stands for: it is recorded with the authorization, as their words are. */
26
+ export function grantWords({ folder, commits }) {
27
+ const name = String(folder).replace(/[\\/]+$/, '');
28
+ return `Authorized on the Keeper page: the Keeper may maintain ${name}/ in this project and ${commits ? 'commit that folder alone' : 'write it without committing'}; it never pushes.`;
29
+ }
30
+
31
+ /** What the grant did, for the message after it: the files written, the commit, or why there is none. */
32
+ export function grantOutcome(sync) {
33
+ if (!sync || sync.status === 'not-authorized') return 'Authorized';
34
+ const files = sync.changedFiles?.length ?? 0;
35
+ const wrote = files ? `wrote ${files} file${files === 1 ? '' : 's'}` : 'the folder was already up to date';
36
+ return `Authorized: ${wrote}${sync.commit ? `, commit ${sync.commit.slice(0, 7)}` : ''}${sync.reason ? `. ${sync.reason}` : ''}`;
37
+ }
@@ -0,0 +1,40 @@
1
+ // The workbench's panels (Spec §6.1, D100; CKC-09 AC-43, AC-44): what the shell remembers and decides, apart from the
2
+ // DOM, so the rules are tested with `node --test` (src/ui/shell-state.test.ts). app.js applies them to the page.
3
+
4
+ /**
5
+ * Whether the left rail is folded to its strip. A choice of this browser, like the theme (Spec §6.1, §6.16): it lives in
6
+ * `localStorage`, never in the project's assets. Storage can be missing or throw (a private window, blocked site data);
7
+ * the choice then holds on this page only, and the page works the same.
8
+ */
9
+ export const RAIL_KEY = 'pk.rail.folded';
10
+
11
+ export function railMemory(storage = globalThis.localStorage) {
12
+ let here = null; // the choice on this page, when the browser keeps none
13
+ return {
14
+ folded() {
15
+ try {
16
+ const v = storage?.getItem(RAIL_KEY);
17
+ if (v === '1' || v === '0') return v === '1';
18
+ } catch { /* no storage: the choice made on this page */ }
19
+ return here ?? false;
20
+ },
21
+ setFolded(folded) {
22
+ here = Boolean(folded);
23
+ try { storage?.setItem(RAIL_KEY, here ? '1' : '0'); } catch { /* remembered on this page only */ }
24
+ return here;
25
+ },
26
+ };
27
+ }
28
+
29
+ /**
30
+ * What one Escape does in the workbench, when nothing on top of it has taken it (a dialog, the popover and the control
31
+ * row's panels take theirs first, in the capture phase): close the reading sheet over the drawer, else restore the
32
+ * maximised main panel. A key pressed inside the Keeper conversation (its window closes itself on Escape; in the docked
33
+ * panel the owner is typing) does not restore the layout under it.
34
+ */
35
+ export function escapeAction({ dialogOpen = false, sheetOpen = false, maximised = false, inKeeper = false } = {}) {
36
+ if (dialogOpen) return null;
37
+ if (sheetOpen) return 'close-sheet';
38
+ if (maximised && !inKeeper) return 'restore';
39
+ return null;
40
+ }
@@ -0,0 +1,171 @@
1
+ // The bottom strip, one line per item (Spec §6.2; CKC-09 AC-38, after WorkflowKeeper's D55): every row is the same four
2
+ // slots — mark · one sentence · object · time — and what used to be tags written into the line are its leading marks,
3
+ // named on hover. This module says how the slots of a row are taken from an item of /overview, which mark stands for
4
+ // which word, and what the header of `Notes (attention)` says about the notes that are not in it (D50). No DOM here:
5
+ // items in, rows out, so the rules are tested with `node --test` (src/ui/strip-rows.test.ts). views.js draws them.
6
+ //
7
+ // A row is { key, marks: [{ glyph, tone, title }], text, object: { text, title }, time, noteId, opens }. `tone` is a
8
+ // class of the stylesheet, which gives it one of the colours the interface already has; `time` is an ISO string or
9
+ // null; `opens` is an object the row stands for (it then opens that object instead of unfolding).
10
+
11
+ /** What a note asks of the owner. WorkflowKeeper's ladder for the same three steps (‼ ! ·); the first is the strongest. */
12
+ const ASK = Object.freeze({
13
+ 'For your decision': { glyph: '‼', tone: 'decide' },
14
+ 'Worth discussing': { glyph: '!', tone: 'discuss' },
15
+ 'For information': { glyph: '·', tone: 'info' },
16
+ });
17
+ /** How a note came about (Spec §4.1): a letter in a dashed frame, as the origin tag had a dashed frame. */
18
+ const ORIGIN = Object.freeze({ 'Product re-look': 'R', 'Change follow-up': 'C', 'Investigation': 'I', 'Owner question': 'Q' });
19
+ /** What is listed with the notes and is not one. */
20
+ const NOT_A_NOTE = Object.freeze({
21
+ 'scope-question': { glyph: '?', tone: 'decide', name: 'Scope question', about: 'Project scope' },
22
+ job: { glyph: '✓', tone: 'good', name: 'Request finished', about: 'Your request' },
23
+ round: { glyph: '↻', tone: 'info', name: 'Follow up result', about: 'Change follow-up' },
24
+ });
25
+ /** How a piece of work was cut out of the stream (Spec §1.8, D56). */
26
+ const WORK = Object.freeze({ Session: 'S', Execution: 'E', 'Time range': 'T' });
27
+ /** The effect of a record written before pieces of work; the tones are the colours the effect tags had. */
28
+ const EFFECT = Object.freeze({
29
+ Added: { glyph: '+', tone: 'good' }, Approved: { glyph: '✓', tone: 'good' }, Completed: { glyph: '■', tone: 'good' },
30
+ Replaced: { glyph: '⇄', tone: 'bad' }, Abandoned: { glyph: '×', tone: 'bad' }, Deferred: { glyph: '‖', tone: 'wait' },
31
+ Corrected: { glyph: '~', tone: 'plain' }, Removed: { glyph: '−', tone: 'plain' },
32
+ });
33
+ const PROGRESS = Object.freeze({ Planned: '○', 'In progress': '◑', Done: '●', 'On hold': '‖' });
34
+
35
+ const NONE = Object.freeze({ text: '', title: '' });
36
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
37
+ const askMark = (ask, prefix = '') => { const a = ASK[ask]; return a ? { glyph: a.glyph, tone: a.tone, title: `${prefix}${ask}` } : null; };
38
+ const effectMark = (effect) => { const e = EFFECT[effect] ?? { glyph: '•', tone: 'plain' }; return { glyph: e.glyph, tone: e.tone, title: effect ?? 'Change' }; };
39
+
40
+ /** The object slot of a note: what it hangs on. One object by name, the first of several with how many more, or the project. */
41
+ function mountSlot(object) {
42
+ if (!object) return NONE;
43
+ if (object.kind === 'project') return { text: 'Whole project', title: 'Whole project' };
44
+ const names = (object.objects ?? []).map((o) => o.label);
45
+ if (names.length === 0) return NONE;
46
+ return { text: names.length === 1 ? names[0] : `${names[0]} +${names.length - 1}`, title: names.join('\n') };
47
+ }
48
+
49
+ /** A row of `Notes (attention)`: a note, a scope question, a finished request or a Follow up result. */
50
+ export function attentionRow(x) {
51
+ const base = { key: `${x.kind}:${x.id}`, text: x.label ?? '', time: x.at ?? null, noteId: null, opens: null };
52
+ if (x.kind === 'note') {
53
+ const origin = x.cameFrom?.kind ? { glyph: ORIGIN[x.cameFrom.kind] ?? '•', tone: 'origin', title: `Came from: ${x.cameFrom.kind}${x.cameFrom.changes?.length ? ` — ${x.cameFrom.changes.map((c) => c.title).join('; ')}` : ''}` } : null;
54
+ return { ...base, marks: [askMark(x.ask), origin].filter(Boolean), object: mountSlot(x.object), noteId: x.id };
55
+ }
56
+ const k = NOT_A_NOTE[x.kind] ?? { glyph: '•', tone: 'info', name: x.kind, about: '' };
57
+ // A Follow up result with news names the objects its news is about, as a note names what it hangs on; one without
58
+ // (a round closed before rounds counted their news) says where it belongs.
59
+ const objects = x.kind === 'round' && x.news ? mountSlot(x.object) : NONE;
60
+ return { ...base, marks: [{ glyph: k.glyph, tone: k.tone, title: x.ask ? `${k.name} · ${x.ask}` : k.name }], object: objects.text ? objects : { text: k.about, title: k.about } };
61
+ }
62
+
63
+ /** A row of `Recent changes`: one piece of work with how many net changes it made; an older record by effect and title. */
64
+ export function changeRow(c) {
65
+ const removed = c.removed?.length ? { glyph: '−', tone: 'bad', title: `Removed: ${c.removed.map((x) => x.label).join(', ')}` } : null;
66
+ if (c.work) {
67
+ const n = (c.items ?? []).length || 1;
68
+ const count = plural(n, 'change');
69
+ return { key: `change:${c.id}`, marks: [{ glyph: WORK[c.work.kind] ?? '•', tone: 'work', title: c.work.kind }, removed].filter(Boolean), text: c.work.label, object: { text: count, title: count }, time: c.at ?? null, noteId: null, opens: null };
70
+ }
71
+ return { key: `change:${c.id}`, marks: [effectMark(c.effect), removed].filter(Boolean), text: c.title ?? '', object: NONE, time: c.at ?? null, noteId: null, opens: null };
72
+ }
73
+
74
+ /**
75
+ * The rows of `Since last visit`: each Follow up round since then, summed up in one line as `Notes (attention)` gives it —
76
+ * the same row, opened or not (Spec §3.8, §5.5) — then the work that moved on (each is an object, and opens it), the
77
+ * changes, the new notes.
78
+ */
79
+ export function sinceRows(s) {
80
+ if (!s) return [];
81
+ return [
82
+ ...(s.rounds ?? []).map((r) => attentionRow(r)),
83
+ ...(s.threads ?? []).map((t) => ({ key: `work:${t.id}`, marks: [{ glyph: PROGRESS[t.progress] ?? '○', tone: 'work', title: `Work · ${t.progress}` }], text: t.title, object: { text: t.progress ?? '', title: t.progress ?? '' }, time: t.updatedAt ?? null, noteId: null, opens: { kind: 'node', id: t.id, label: t.title } })),
84
+ ...(s.changes ?? []).map((c) => ({ key: `change:${c.id}`, marks: [effectMark(c.effect)], text: c.title, object: NONE, time: c.at ?? null, noteId: null, opens: null })),
85
+ ...(s.notes ?? []).map((n) => ({ key: `note:${n.id}`, marks: [askMark(n.ask)].filter(Boolean), text: n.title, object: mountSlot(n.object), time: n.at ?? null, noteId: n.id, opens: null })),
86
+ ];
87
+ }
88
+
89
+ // ── The drawer and the ❓ on objects (Spec §6.1, §6.2; D100; CKC-09 AC-38) ─────────────────────────────────────
90
+ /**
91
+ * Whether an item of `Notes (attention)` is marked ❓ on its objects instead of listed in the drawer: a note that hangs on
92
+ * a node, a relation or a path the project still has (§2.7 keeps it in `Notes (attention)`; D100 puts it on the object).
93
+ * A note on the whole project, one whose objects are all gone, and everything that is not a note stay in the drawer —
94
+ * an item with nowhere to be marked is never dropped.
95
+ */
96
+ export function markedOnObjects(item) {
97
+ return item?.kind === 'note' && Boolean(item.object) && item.object.kind !== 'project' && (item.object.objects?.length ?? 0) > 0;
98
+ }
99
+
100
+ /**
101
+ * The attention notes marked ❓ on objects. SEAM (CF → CE): the Graph and the List draw the ❓ marks from CE's own
102
+ * `objectAttentionNotes(view)`; until that lands, the drawer counts with this, which follows the same rule
103
+ * (`markedOnObjects`). Once CE exports it, views.js `onObjectNotes` calls CE's function and this stays as its test.
104
+ */
105
+ export function objectAttentionNotes(ov) {
106
+ return (ov?.needsYou ?? []).filter(markedOnObjects);
107
+ }
108
+
109
+ /** What the drawer's `Notes (attention)` lists: the whole-project part (Spec §6.2). */
110
+ export function drawerAttention(ov) {
111
+ return (ov?.needsYou ?? []).filter((x) => !markedOnObjects(x));
112
+ }
113
+
114
+ /**
115
+ * The objects the ❓ count shows when it is pressed (the Graph and the List see only these, with what they need to be
116
+ * understood): each note's nodes, a relation by its two ends (the line is drawn between them), every object of a path.
117
+ * `relations` are the graph's, to find a relation's ends; one the graph does not have is left out.
118
+ */
119
+ export function attentionTargets(notes, relations = []) {
120
+ const ends = new Map((relations ?? []).map((r) => [r.id, [r.from, r.to]]));
121
+ const ids = new Set();
122
+ for (const n of notes ?? []) {
123
+ for (const o of n.object?.objects ?? []) {
124
+ if (o.kind === 'relation') for (const id of ends.get(o.id) ?? []) ids.add(id);
125
+ else ids.add(o.id);
126
+ }
127
+ }
128
+ return [...ids];
129
+ }
130
+
131
+ /**
132
+ * The drawer's one line (Spec §6.1, §6.2): how many rows `Notes (attention)` and `Recent changes` have, whether there is a
133
+ * `Since last visit`, and how many attention notes are marked ❓ on objects. `onObjects` defaults to the rule above.
134
+ */
135
+ export function drawerSummary(ov, onObjects = objectAttentionNotes(ov)) {
136
+ return {
137
+ attention: drawerAttention(ov).length,
138
+ changes: (ov?.recentChanges ?? []).length,
139
+ since: Boolean(ov?.sinceLastVisit),
140
+ onObjects: onObjects.length,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * The header of `Notes (attention)` (D50; CKC-09 AC-1, AC-38; Spec §6.2): it says, in sight, why this list holds fewer
146
+ * notes than the project has — how many only inform, how many the owner has answered, how many wait on objects (❓) —
147
+ * and counts what is listed here and is not a note apart, never folded into the note number (§2.4). `hint` is the same
148
+ * in full sentences, for the hover. `onObjects` is the number of attention notes marked ❓ on objects.
149
+ */
150
+ export function attentionHeader(ov, onObjects = objectAttentionNotes(ov).length) {
151
+ const list = drawerAttention(ov);
152
+ const of = (kind) => list.filter((x) => x.kind === kind).length;
153
+ const notes = of('note'), questions = of('scope-question'), finished = of('job'), rounds = of('round');
154
+ // A Follow up result is here when its round found something new (it carries the news, CKC-07 AC-27); one from a round
155
+ // closed before rounds counted their news is here, as it always was, when it left objects on the old understanding.
156
+ const withNews = list.filter((x) => x.kind === 'round' && x.news).length;
157
+ const earlier = rounds - withNews;
158
+ const c = ov?.noteCounts ?? null;
159
+ const count = c ? `${notes} of ${plural(c.current, 'note')}` : notes ? plural(notes, 'note') : '';
160
+ const why = c ? [c.information ? `${c.information} for information` : '', c.answered ? `${c.answered} answered` : '', onObjects ? `❓ ${onObjects} on objects` : ''].filter(Boolean).join(' · ') : onObjects ? `❓ ${onObjects} on objects` : '';
161
+ const also = [questions ? plural(questions, 'scope question') : '', finished ? `${plural(finished, 'request')} done` : '', rounds ? plural(rounds, 'Follow up result') : ''].filter(Boolean).join(' · ');
162
+ const onObjectsSaid = onObjects ? ` ${onObjects} ${onObjects === 1 ? 'waits' : 'wait'} on you on ${onObjects === 1 ? 'its object' : 'their objects'}, marked ❓ there; press the ❓ count to see only those objects.` : '';
163
+ const hint = [
164
+ c ? `${notes} of the project's ${c.current} current notes wait on you here. The other ${c.current - notes} are not here: ${c.information} are for information only and ${c.answered} you have already answered.${onObjectsSaid} The Notes log has all of them.` : `Notes on the whole project that ask something of you and you have not answered yet.${onObjectsSaid} The Notes log has all of them.`,
165
+ questions ? `Also here, counted separately: ${plural(questions, 'unanswered scope question')}.` : '',
166
+ finished ? `${plural(finished, 'request')} you gave finished and wait${finished === 1 ? 's' : ''} for you to look.` : '',
167
+ withNews ? `${plural(withNews, 'Follow up result')} with something new.` : '',
168
+ earlier ? `${plural(earlier, 'Follow up result')} from before rounds counted what they found new, with objects still on the old understanding.` : '',
169
+ ].filter(Boolean).join(' ');
170
+ return { count, why: why ? `Not here: ${why}` : '', also: also ? `Also here: ${also}` : '', hint, onObjects };
171
+ }