projectkeeper 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +21 -0
  3. package/README.md +283 -2
  4. package/README.zh-CN.md +285 -0
  5. package/THIRD_PARTY_NOTICES.md +53 -0
  6. package/bin/pk.mjs +6 -0
  7. package/bin/postinstall.mjs +5 -0
  8. package/bin/strip-types.mjs +28 -0
  9. package/package.json +68 -4
  10. package/patches/README.md +111 -0
  11. package/patches/pi-ai-0.85.0.ts +122 -0
  12. package/patches/pi-ai-0.87.1.ts +122 -0
  13. package/scripts/apply-pi-streaming-patch.ts +86 -0
  14. package/skills/clerk/coverage/SKILL.md +35 -0
  15. package/skills/clerk/cross-check/SKILL.md +118 -0
  16. package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
  17. package/skills/clerk/lane-follow-up/SKILL.md +94 -0
  18. package/skills/clerk/lane-plan/SKILL.md +107 -0
  19. package/skills/clerk/lane-slot/SKILL.md +113 -0
  20. package/skills/clerk/lane-topic/SKILL.md +116 -0
  21. package/skills/clerk/orientation/SKILL.md +160 -0
  22. package/skills/clerk/reconcile/SKILL.md +77 -0
  23. package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
  24. package/skills/clerk/spot-check/SKILL.md +52 -0
  25. package/skills/clerk/synthesis/SKILL.md +65 -0
  26. package/skills/table-follow-up/SKILL.md +42 -0
  27. package/src/cli.ts +319 -0
  28. package/src/codemap/README.md +13 -0
  29. package/src/codemap/engines.ts +102 -0
  30. package/src/codemap/facts.ts +504 -0
  31. package/src/context/agent-entry.ts +232 -0
  32. package/src/context/ask.ts +97 -0
  33. package/src/context/assemble.ts +1058 -0
  34. package/src/context/code-source.ts +103 -0
  35. package/src/context/k-briefs.ts +114 -0
  36. package/src/context/lineage.ts +100 -0
  37. package/src/context/object-brief.ts +300 -0
  38. package/src/context/owner-words.ts +191 -0
  39. package/src/context/pack-fixture.ts +342 -0
  40. package/src/context/project-rules.ts +160 -0
  41. package/src/intake/intake.ts +392 -0
  42. package/src/intake/material-rules.ts +166 -0
  43. package/src/intake/removal.ts +316 -0
  44. package/src/intake/skipped.ts +72 -0
  45. package/src/keeper/adjustment.ts +565 -0
  46. package/src/keeper/bounds/boundary.ts +509 -0
  47. package/src/keeper/bounds/command.ts +1665 -0
  48. package/src/keeper/bounds/paths.ts +164 -0
  49. package/src/keeper/bounds/reads.ts +718 -0
  50. package/src/keeper/bounds/shell-write-guard.ts +521 -0
  51. package/src/keeper/capabilities.ts +136 -0
  52. package/src/keeper/clerk-steps.ts +151 -0
  53. package/src/keeper/clerk-tools.ts +1424 -0
  54. package/src/keeper/conversation.ts +253 -0
  55. package/src/keeper/evidence.ts +662 -0
  56. package/src/keeper/fake-provider.ts +132 -0
  57. package/src/keeper/held.ts +15 -0
  58. package/src/keeper/history-tools.ts +163 -0
  59. package/src/keeper/jobs/answering.ts +53 -0
  60. package/src/keeper/key-check.ts +105 -0
  61. package/src/keeper/ledger-tools.ts +295 -0
  62. package/src/keeper/link-check.ts +185 -0
  63. package/src/keeper/merge.ts +260 -0
  64. package/src/keeper/note-origin.ts +56 -0
  65. package/src/keeper/numbers-check.ts +102 -0
  66. package/src/keeper/open-in-pi.ts +57 -0
  67. package/src/keeper/organize/absence.ts +266 -0
  68. package/src/keeper/organize/carried-on.ts +214 -0
  69. package/src/keeper/organize/clerk-coverage.ts +293 -0
  70. package/src/keeper/organize/clerk-prompts.ts +132 -0
  71. package/src/keeper/organize/clerk.ts +1395 -0
  72. package/src/keeper/organize/coverage-gate.ts +19 -0
  73. package/src/keeper/organize/coverage-tools.ts +765 -0
  74. package/src/keeper/organize/entries.ts +153 -0
  75. package/src/keeper/organize/entry-gate.ts +289 -0
  76. package/src/keeper/organize/fill-tools.ts +718 -0
  77. package/src/keeper/organize/follow-up.ts +280 -0
  78. package/src/keeper/organize/generation-check.ts +504 -0
  79. package/src/keeper/organize/graph.ts +230 -0
  80. package/src/keeper/organize/lane-tools.ts +289 -0
  81. package/src/keeper/organize/looked-tools.ts +54 -0
  82. package/src/keeper/organize/materials.ts +243 -0
  83. package/src/keeper/organize/owner-lines.ts +493 -0
  84. package/src/keeper/organize/place-tools.ts +216 -0
  85. package/src/keeper/organize/placement-inference.ts +1045 -0
  86. package/src/keeper/organize/placement-time.ts +320 -0
  87. package/src/keeper/organize/placing.ts +821 -0
  88. package/src/keeper/organize/positioning.ts +33 -0
  89. package/src/keeper/organize/readiness.ts +47 -0
  90. package/src/keeper/organize/reading.ts +401 -0
  91. package/src/keeper/organize/round-blocks.ts +65 -0
  92. package/src/keeper/organize/round-news.ts +164 -0
  93. package/src/keeper/organize/round-open.ts +586 -0
  94. package/src/keeper/organize/schedule-state.ts +39 -0
  95. package/src/keeper/organize/schedule.ts +137 -0
  96. package/src/keeper/organize/service.ts +409 -0
  97. package/src/keeper/organize/skills.ts +116 -0
  98. package/src/keeper/organize/slots.ts +85 -0
  99. package/src/keeper/organize/stage-gate.ts +175 -0
  100. package/src/keeper/organize/stage-tools.ts +471 -0
  101. package/src/keeper/organize/standing-notes.ts +141 -0
  102. package/src/keeper/organize/table-follow-up.ts +330 -0
  103. package/src/keeper/organize/takeover-clerk.ts +832 -0
  104. package/src/keeper/organize/takeover-state.ts +103 -0
  105. package/src/keeper/organize/takeover.ts +216 -0
  106. package/src/keeper/organize/update-pending.ts +535 -0
  107. package/src/keeper/organize/workbench-placement.ts +92 -0
  108. package/src/keeper/own-keys.ts +114 -0
  109. package/src/keeper/owner-text.ts +165 -0
  110. package/src/keeper/project-folder.ts +334 -0
  111. package/src/keeper/prompts.ts +101 -0
  112. package/src/keeper/references.ts +177 -0
  113. package/src/keeper/relook/compose.ts +120 -0
  114. package/src/keeper/relook/prompts.ts +36 -0
  115. package/src/keeper/roles.ts +259 -0
  116. package/src/keeper/round-tools.ts +227 -0
  117. package/src/keeper/route.ts +148 -0
  118. package/src/keeper/rules.ts +97 -0
  119. package/src/keeper/runtime.ts +1858 -0
  120. package/src/keeper/scope-tools.ts +194 -0
  121. package/src/keeper/sendback-text.ts +83 -0
  122. package/src/keeper/step-timing.ts +207 -0
  123. package/src/keeper/testing/clerk-script.ts +212 -0
  124. package/src/keeper/testing/streaming-arguments.ts +57 -0
  125. package/src/keeper/tools.ts +2340 -0
  126. package/src/keeper/utterance-tools.ts +64 -0
  127. package/src/keeper/verbatim.ts +397 -0
  128. package/src/ledger/adapters.ts +186 -0
  129. package/src/ledger/arrangements.ts +257 -0
  130. package/src/ledger/blame.ts +58 -0
  131. package/src/ledger/code-engine-child.ts +69 -0
  132. package/src/ledger/code-engine.ts +548 -0
  133. package/src/ledger/code.ts +607 -0
  134. package/src/ledger/docs.ts +294 -0
  135. package/src/ledger/git-read.ts +321 -0
  136. package/src/ledger/index.ts +1850 -0
  137. package/src/ledger/item-range.ts +16 -0
  138. package/src/ledger/lines.ts +328 -0
  139. package/src/ledger/numbering.ts +269 -0
  140. package/src/ledger/ranges.ts +74 -0
  141. package/src/ledger/rebuild-worker.ts +15 -0
  142. package/src/ledger/rebuild.ts +387 -0
  143. package/src/ledger/repo-scan.ts +181 -0
  144. package/src/ledger/schema.ts +450 -0
  145. package/src/ledger/sessions.ts +134 -0
  146. package/src/ledger/text-scan.ts +466 -0
  147. package/src/ledger/time.ts +185 -0
  148. package/src/ledger/ts-symbols.ts +180 -0
  149. package/src/ledger/views.ts +377 -0
  150. package/src/mcp/server.ts +127 -0
  151. package/src/mcp/tools.ts +187 -0
  152. package/src/model/ids.ts +23 -0
  153. package/src/model/k-types.ts +912 -0
  154. package/src/model/time.ts +85 -0
  155. package/src/model/types.ts +1382 -0
  156. package/src/model/views-k.ts +707 -0
  157. package/src/model/vocab.ts +269 -0
  158. package/src/process/analysis.ts +79 -0
  159. package/src/process/breakpoint-candidates.ts +383 -0
  160. package/src/process/breakpoints.ts +746 -0
  161. package/src/process/claim-check.ts +295 -0
  162. package/src/process/delivery-links.ts +52 -0
  163. package/src/process/facts.ts +487 -0
  164. package/src/process/generations.ts +138 -0
  165. package/src/process/index.ts +243 -0
  166. package/src/process/integration.ts +128 -0
  167. package/src/process/placement.ts +107 -0
  168. package/src/process/plan.ts +276 -0
  169. package/src/process/sendbacks.ts +244 -0
  170. package/src/process/text.ts +185 -0
  171. package/src/process/units.ts +543 -0
  172. package/src/process/unnumbered.ts +431 -0
  173. package/src/process/work.ts +884 -0
  174. package/src/scope/candidates.ts +86 -0
  175. package/src/scope/decisions.ts +229 -0
  176. package/src/scope/discover.ts +741 -0
  177. package/src/scope/ignore.ts +126 -0
  178. package/src/scope/ignored-place.ts +93 -0
  179. package/src/scope/skip.ts +161 -0
  180. package/src/scope/toolchain.ts +180 -0
  181. package/src/scope/worktree.ts +187 -0
  182. package/src/server/api.ts +549 -0
  183. package/src/server/app.ts +595 -0
  184. package/src/server/drafts-api.ts +170 -0
  185. package/src/server/folders-api.ts +243 -0
  186. package/src/server/graph-view.ts +639 -0
  187. package/src/server/http.ts +224 -0
  188. package/src/server/k-agent-api.ts +56 -0
  189. package/src/server/k-rounds-d99.ts +91 -0
  190. package/src/server/k-views.ts +405 -0
  191. package/src/server/keeper-page.ts +446 -0
  192. package/src/server/keys-api.ts +239 -0
  193. package/src/server/project-folder-api.ts +104 -0
  194. package/src/server/vendor.ts +14 -0
  195. package/src/server/workbench-content.ts +561 -0
  196. package/src/sources/anchor.ts +129 -0
  197. package/src/sources/files.ts +267 -0
  198. package/src/sources/gitobs.ts +41 -0
  199. package/src/sources/history.ts +231 -0
  200. package/src/sources/scan-worker.ts +10 -0
  201. package/src/sources/sessions/locate.ts +292 -0
  202. package/src/sources/sessions/read.ts +346 -0
  203. package/src/sources/sessions/scope.ts +42 -0
  204. package/src/sources/sessions/utterances.ts +223 -0
  205. package/src/sources/watch.ts +215 -0
  206. package/src/store/json-file.ts +50 -0
  207. package/src/store/paths.ts +39 -0
  208. package/src/store/project-store.ts +295 -0
  209. package/src/store/versions.ts +172 -0
  210. package/src/store/workspace.ts +216 -0
  211. package/src/table/check.ts +333 -0
  212. package/src/table/follow-up-tools.ts +30 -0
  213. package/src/table/follow-up.ts +344 -0
  214. package/src/table/import.ts +610 -0
  215. package/src/table/project-home.ts +61 -0
  216. package/src/table/sessions.ts +168 -0
  217. package/src/table/takeover.ts +483 -0
  218. package/src/util/git-check.ts +29 -0
  219. package/src/util/git.ts +411 -0
  220. package/src/util/own-ts.ts +15 -0
  221. package/src/util/paths.ts +225 -0
  222. package/template/FILLING-IN.md +148 -0
  223. package/template/examples/papertrail.json +747 -0
  224. package/template/projectkeeper.schema.json +1001 -0
  225. package/ui/app.js +1135 -0
  226. package/ui/failures.js +82 -0
  227. package/ui/flyout.js +120 -0
  228. package/ui/folder-chooser.js +264 -0
  229. package/ui/glossary.js +156 -0
  230. package/ui/graph-fit.js +157 -0
  231. package/ui/graph-material.js +61 -0
  232. package/ui/graph-palette.js +109 -0
  233. package/ui/graph-tools.js +62 -0
  234. package/ui/graph.js +1712 -0
  235. package/ui/index.html +31 -0
  236. package/ui/k/code-view.js +230 -0
  237. package/ui/k/draft-view.js +93 -0
  238. package/ui/k/k-views.css +224 -0
  239. package/ui/k/round-tree.js +450 -0
  240. package/ui/k/scope-k.js +159 -0
  241. package/ui/k-data.js +118 -0
  242. package/ui/k-fold.js +106 -0
  243. package/ui/k-process.css +302 -0
  244. package/ui/k-process.js +934 -0
  245. package/ui/keeper-activity.js +175 -0
  246. package/ui/keeper-view.js +801 -0
  247. package/ui/keeper.css +101 -0
  248. package/ui/key-order.js +62 -0
  249. package/ui/list-fold.js +67 -0
  250. package/ui/markdown.d.ts +21 -0
  251. package/ui/markdown.js +472 -0
  252. package/ui/placement.d.ts +92 -0
  253. package/ui/placement.js +445 -0
  254. package/ui/popover-place.d.ts +16 -0
  255. package/ui/popover-place.js +89 -0
  256. package/ui/popover.js +198 -0
  257. package/ui/project-folder.js +37 -0
  258. package/ui/shell-state.js +40 -0
  259. package/ui/strip-rows.js +171 -0
  260. package/ui/styles.css +492 -0
  261. package/ui/theme-pick.js +36 -0
  262. package/ui/theme.js +74 -0
  263. package/ui/themes/LICENSE.md +13 -0
  264. package/ui/themes/README.md +44 -0
  265. package/ui/themes/TOKENS.md +291 -0
  266. package/ui/themes/a1/board.css +1557 -0
  267. package/ui/themes/a1/pk.css +392 -0
  268. package/ui/themes/b1/board.css +1163 -0
  269. package/ui/themes/b1/pk.css +218 -0
  270. package/ui/themes/d2/board.css +518 -0
  271. package/ui/themes/d2/pk.css +243 -0
  272. package/ui/themes/guard.css +73 -0
  273. package/ui/themes/i1/board.css +776 -0
  274. package/ui/themes/i1/pk.css +223 -0
  275. package/ui/themes/manifest.json +13 -0
  276. package/ui/views.js +2796 -0
@@ -0,0 +1,446 @@
1
+ /**
2
+ * The `Keeper` view as two pages (Spec §6.10, §3.7, §3.8; D105; CKC-03 AC-12, CKC-13 AC-19, AC-23, AC-37~AC-41,
3
+ * CKC-07 AC-28~AC-30): `Takeover` — choose a depth, `Start`, the progress as the Keeper works, the briefing at the end,
4
+ * `Clear` — and `Daily` — the schedule the owner sets, `Follow up` now, one line of status.
5
+ *
6
+ * Everything here is read from the record: where the takeover stands and which depth ran come from the rounds
7
+ * (takeover-state.ts), the schedule from what the owner saved (schedule.ts), the briefing's numbers from the assets.
8
+ * The briefing's words are the ones the takeover's synthesis wrote into the assets — the product overview, each area's
9
+ * understanding, the notes — put together by the program; nothing in it is written for the page alone.
10
+ */
11
+ import { existsSync, readdirSync } from 'node:fs';
12
+ import { tableDir } from '../store/paths.ts';
13
+ import { join } from 'node:path';
14
+ import { ownerScopeItems, type App } from './app.ts';
15
+ import { HttpApp, HttpError } from './http.ts';
16
+ import type { ContextPackage, ContextRequest, FollowUpRound, KeeperJob, OrganizeSchedule, Project, Usage } from '../model/types.ts';
17
+ import { tableExtraOf } from '../keeper/organize/table-follow-up.ts';
18
+ import type { ClerkRound } from '../model/k-types.ts';
19
+ import type { TakeoverDepth } from '../model/vocab.ts';
20
+ import type { ProjectStore } from '../store/project-store.ts';
21
+ import { roundDepth, takeoverState, startRefusal, type TakeoverState } from '../keeper/organize/takeover-state.ts';
22
+ import { DAY_NAMES, SCHEDULE_FREQUENCIES, parseSchedule, scheduleText } from '../keeper/organize/schedule.ts';
23
+ import { nextScheduled, scheduleInForce } from '../keeper/organize/schedule-state.ts';
24
+ import { organizingHeld } from '../keeper/held.ts';
25
+ import { needsYou } from './graph-view.ts';
26
+ import { internalRefs, laneNamesOf } from '../keeper/owner-text.ts';
27
+ import { roundsView } from './k-views.ts';
28
+ import { projectFolderState, type ProjectFolderState } from './project-folder-api.ts';
29
+
30
+ // ───────────────────────── the three depths, as the page says them (§3.7 选择深度) ─────────────────────────
31
+
32
+ /** What the Keeper does at each depth, and what the owner gets beyond the first usable picture: fixed interface text. */
33
+ export const DEPTH_TEXT: Readonly<Record<TakeoverDepth, { readonly does: string; readonly gains: string }>> = {
34
+ Full: {
35
+ does: 'Digs the whole history by question: a lane for each plan or stage and for each topic orientation finds, including deleted documents, side branches, everything you said in the sessions, every receipt and report; the code as it stands across every territory. Every material planned for a close reading is accounted for — read, or said to be read in part or not needed, with why — before the cross-check.',
36
+ gains: 'The full lineage of every item; intent that was buried and requirements that were dropped; the complete course and checks of every piece of work; the anomalies of every code territory.',
37
+ },
38
+ Focused: {
39
+ does: 'Digs only what the current objects, the work still open, and the breakpoint candidates and anomalies the program computed involve, with the history they directly touch. The rest of the history stays in the ledger and is read when needed.',
40
+ gains: 'The lineage of the current objects; the course of the work in progress; how each anomaly came about.',
41
+ },
42
+ 'First picture only': {
43
+ does: 'No deepening. New changes enter daily organizing as usual; history is read only when an investigation or a question needs it.',
44
+ gains: 'Nothing beyond the first usable picture, and no extra cost.',
45
+ },
46
+ };
47
+
48
+ /** What a takeover does, said before it starts (§6.10 还没开始): fixed interface text. */
49
+ export const TAKEOVER_INTRO = 'A takeover draws the project’s boundary, forms a first usable picture — the product, its areas, the planned work, the graph — then digs the history to the depth you choose, and ends with a briefing here. The Keeper reads the project; it does not change the project’s files. Nothing runs until you press Start.';
50
+
51
+ // ───────────────────────── small helpers ─────────────────────────
52
+
53
+ const zero: Usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: null };
54
+ const addCost = (a: number | null, b: number | null): number | null => (a === null && b === null ? null : (a ?? 0) + (b ?? 0));
55
+ const addUsage = (a: Usage, b: Usage): Usage => ({ input: a.input + b.input, output: a.output + b.output, cacheRead: a.cacheRead + b.cacheRead, cacheWrite: a.cacheWrite + b.cacheWrite, cost: addCost(a.cost, b.cost) });
56
+ const modelText = (j: KeeperJob): string | null => (j.model ? `${j.model.provider}/${j.model.id}` : null);
57
+
58
+ /** The jobs of a round: its steps, and everything they sent. */
59
+ export function roundJobs(store: ProjectStore, round: ClerkRound): KeeperJob[] {
60
+ const all = store.jobs.all();
61
+ const steps = all.filter((j) => j.step?.roundId === round.id);
62
+ const seen = new Map(steps.map((j) => [j.id, j]));
63
+ let frontier = steps.map((j) => j.id);
64
+ while (frontier.length) {
65
+ const kids = all.filter((j) => j.parentJobId !== null && frontier.includes(j.parentJobId) && !seen.has(j.id));
66
+ for (const k of kids) seen.set(k.id, k);
67
+ frontier = kids.map((k) => k.id);
68
+ }
69
+ return [...seen.values()];
70
+ }
71
+
72
+ export interface RoundRow {
73
+ readonly id: string;
74
+ readonly number: number;
75
+ readonly kind: ClerkRound['kind'];
76
+ readonly depth: TakeoverDepth | null;
77
+ readonly status: string;
78
+ readonly startedAt: string;
79
+ readonly endedAt: string | null;
80
+ readonly wallMs: number;
81
+ readonly cost: number | null;
82
+ readonly models: readonly string[];
83
+ readonly startedBy: string | null;
84
+ }
85
+
86
+ /**
87
+ * D107: a project taken over by the table is followed up by table follow-ups, each recorded as a Follow up round record
88
+ * (`store.rounds`) beside the job that ran it — no clerk round. The same row, read from those.
89
+ */
90
+ export function tableRoundRow(store: ProjectStore, round: FollowUpRound, now = Date.now()): RoundRow {
91
+ const job = round.mainJobId ? store.jobs.get(round.mainJobId) ?? null : null;
92
+ const ran = job && job.agent !== 'program' ? [job] : [];
93
+ return {
94
+ id: round.id, number: round.number, kind: 'Follow up', depth: null,
95
+ status: job?.status ?? (round.endedAt ? 'Done' : 'Running'), startedAt: round.startedAt, endedAt: round.endedAt,
96
+ wallMs: (round.endedAt ? Date.parse(round.endedAt) : now) - Date.parse(round.startedAt),
97
+ cost: ran.reduce<number | null>((c, j) => addCost(c, j.usage.cost), null),
98
+ models: [...new Set(ran.map(modelText).filter((m): m is string => m !== null))],
99
+ startedBy: tableExtraOf(job ?? undefined)?.startedBy ?? null,
100
+ };
101
+ }
102
+
103
+ /** One row for a round: which kind, when, its status, its time and cost, the models that ran it. */
104
+ export function roundRow(store: ProjectStore, round: ClerkRound, now = Date.now()): RoundRow {
105
+ const jobs = roundJobs(store, round).filter((j) => j.agent !== 'program');
106
+ return {
107
+ id: round.id, number: round.number, kind: round.kind, depth: round.kind === 'First usable' ? 'First picture only' : roundDepth(round, store.jobs.get(round.rootJobId)),
108
+ status: round.status, startedAt: round.startedAt, endedAt: round.endedAt,
109
+ wallMs: (round.endedAt ? Date.parse(round.endedAt) : now) - Date.parse(round.startedAt),
110
+ cost: jobs.reduce<number | null>((c, j) => addCost(c, j.usage.cost), null),
111
+ models: [...new Set(jobs.map(modelText).filter((m): m is string => m !== null))],
112
+ startedBy: round.startedBy ?? null,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Text the Keeper wrote, for the owner, without what the owner-text check finds (Spec §6.13 不露内部标识; owner-text.ts):
118
+ * a store id, a lane named as a lane, a section of a lane's report. The briefing is put together by the program, which
119
+ * cannot send the words back to be written again, so what the check finds is taken out, with the brackets it leaves.
120
+ */
121
+ export function withoutInternalIds(text: string, laneNames: readonly string[] = []): string {
122
+ let out = text;
123
+ for (const r of internalRefs({ text }, laneNames)) out = out.split(r.text).join('');
124
+ return out.replace(/[((]\s*[、,,;;]?\s*[))]/g, '').replace(/[ \t]{2,}/g, ' ').trim();
125
+ }
126
+ /** The first sentence of a text, in either punctuation. */
127
+ export function firstSentence(text: string): string {
128
+ const flat = text.replace(/\*\*[^*]+\*\*\s*/g, '').replace(/\s+/g, ' ').trim();
129
+ const m = /^(.+?[。!?!?]|.+?\.(?=\s|$))/.exec(flat);
130
+ return (m ? m[1]! : flat).trim();
131
+ }
132
+
133
+ // ───────────────────────── the briefing (§3.7 接手简报; CKC-13 AC-39) ─────────────────────────
134
+
135
+ export interface TakeoverBriefing {
136
+ /** What this project is: the product overview the takeover recorded. */
137
+ readonly project: { readonly name: string; readonly text: string | null; readonly nodeId: string | null };
138
+ /** The areas, one sentence each; each opens on the graph. */
139
+ readonly areas: readonly { readonly nodeId: string; readonly name: string; readonly sentence: string }[];
140
+ /** Where the work stands: under each plan, how many items are done, under way, not started, on hold; and what is under way now. */
141
+ readonly work: {
142
+ readonly plans: readonly { readonly nodeId: string | null; readonly name: string; readonly progress: string | null; readonly done: number; readonly inProgress: number; readonly notStarted: number; readonly onHold: number; readonly total: number }[];
143
+ readonly noPlan: { readonly done: number; readonly inProgress: number; readonly notStarted: number; readonly onHold: number; readonly total: number };
144
+ readonly underWay: readonly { readonly nodeId: string; readonly name: string }[];
145
+ };
146
+ /** What waits for the owner's decision: one line each, opening the note or the scope question itself. */
147
+ readonly waiting: readonly { readonly kind: 'note' | 'scope-question'; readonly id: string; readonly text: string }[];
148
+ /** What was read and what it cost. */
149
+ readonly read: {
150
+ readonly depth: TakeoverDepth | null;
151
+ readonly models: readonly string[];
152
+ readonly levels: readonly { readonly level: string; readonly materials: number; readonly byKind: Readonly<Record<string, number>> }[];
153
+ readonly historyNotOrganized: number;
154
+ readonly missingSourceKinds: readonly { readonly kind: string; readonly reason: string }[];
155
+ readonly rounds: readonly RoundRow[];
156
+ readonly total: { readonly wallMs: number; readonly cost: number | null };
157
+ };
158
+ }
159
+
160
+ export function takeoverBriefing(store: ProjectStore, project: Project, state: TakeoverState): TakeoverBriefing {
161
+ const lanes = laneNamesOf(store);
162
+ const clean = (text: string) => withoutInternalIds(text, lanes);
163
+ const nodeOfRef = new Map(store.nodes.all().map((n) => [n.refId, n]));
164
+ const current = <T extends { validity?: string }>(x: T) => !x.validity || x.validity === 'Current';
165
+ const product = store.reference.filter((r) => r.category === 'Product' && current(r))[0] ?? null;
166
+ const areas = store.reference.filter((r) => r.category === 'Area' && current(r)).map((r) => {
167
+ const u = store.areas.find((a) => a.referenceId === r.id);
168
+ return { nodeId: nodeOfRef.get(r.id)?.id ?? r.id, name: r.name, sentence: clean(firstSentence(u?.effectNow || r.text || '')) };
169
+ });
170
+ // Work by plan: a work item belongs to the plans it has a relation to on the graph.
171
+ const planRefs = store.reference.filter((r) => r.category === 'Plan' && current(r));
172
+ const planNode = new Map(planRefs.map((p) => [nodeOfRef.get(p.id)?.id ?? p.id, p]));
173
+ const threads = store.threads.filter((t) => t.validity === 'Current');
174
+ const threadIds = new Set(threads.map((t) => t.id));
175
+ const plansOf = new Map<string, Set<string>>();
176
+ for (const r of store.relations.all()) {
177
+ const [work, plan] = threadIds.has(r.from) && planNode.has(r.to) ? [r.from, r.to] : threadIds.has(r.to) && planNode.has(r.from) ? [r.to, r.from] : [null, null];
178
+ if (!work || !plan) continue;
179
+ if (!plansOf.has(work)) plansOf.set(work, new Set());
180
+ plansOf.get(work)!.add(plan);
181
+ }
182
+ const tally = () => ({ done: 0, inProgress: 0, notStarted: 0, onHold: 0, total: 0 });
183
+ const count = (t: ReturnType<typeof tally>, progress: string) => {
184
+ t.total += 1;
185
+ if (progress === 'Done') t.done += 1; else if (progress === 'In progress') t.inProgress += 1; else if (progress === 'On hold') t.onHold += 1; else t.notStarted += 1;
186
+ };
187
+ const byPlan = new Map([...planNode.keys()].map((id) => [id, tally()]));
188
+ const noPlan = tally();
189
+ for (const t of threads) {
190
+ const plans = plansOf.get(t.id);
191
+ if (!plans?.size) { count(noPlan, t.progress); continue; }
192
+ for (const p of plans) count(byPlan.get(p)!, t.progress);
193
+ }
194
+ const waiting = needsYou(store, project)
195
+ .filter((x) => (x.kind === 'note' && x.ask === 'For your decision') || x.kind === 'scope-question')
196
+ .map((x) => ({ kind: x.kind as 'note' | 'scope-question', id: x.id, text: x.kind === 'note' ? clean(x.label) : x.label }));
197
+ const rounds = state.rounds.map((r) => roundRow(store, r));
198
+ const tk = store.coverage.takeover ?? null;
199
+ return {
200
+ project: { name: product?.name ?? project.name, text: product ? clean(product.text) : null, nodeId: product ? nodeOfRef.get(product.id)?.id ?? product.id : null },
201
+ areas,
202
+ work: {
203
+ plans: [...planNode.entries()].map(([nodeId, p]) => ({ nodeId, name: p.name, progress: p.progress ?? null, ...byPlan.get(nodeId)! })),
204
+ noPlan,
205
+ underWay: threads.filter((t) => t.progress === 'In progress').map((t) => ({ nodeId: t.id, name: `${t.ids[0] ? `${t.ids[0]} ` : ''}${t.title}` })),
206
+ },
207
+ waiting,
208
+ read: {
209
+ depth: state.ran, models: [...new Set(rounds.flatMap((r) => r.models))],
210
+ levels: (tk?.levels ?? []).filter((l) => l.materials > 0).map((l) => ({ level: l.level, materials: l.materials, byKind: l.byKind })),
211
+ historyNotOrganized: tk?.historyNotOrganized ?? 0,
212
+ missingSourceKinds: store.coverage.missingSourceKinds,
213
+ rounds,
214
+ total: { wallMs: rounds.reduce((n, r) => n + r.wallMs, 0), cost: rounds.reduce<number | null>((c, r) => addCost(c, r.cost), null) },
215
+ },
216
+ };
217
+ }
218
+
219
+ // ───────────────────────── Clear: what goes and what stays (§3.7 清空; CKC-13 AC-40) ─────────────────────────
220
+
221
+ export interface ClearPreview {
222
+ readonly removed: readonly { readonly what: string; readonly count: number | null; readonly note?: string }[];
223
+ /** Decisions and corrections the owner gave only to the Keeper — in the conversation or on a note — not in the project's documents. */
224
+ readonly ownerOnly: number;
225
+ readonly running: number;
226
+ readonly kept: readonly { readonly what: string; readonly detail: string }[];
227
+ }
228
+
229
+ export function clearPreview(app: App, projectId: string): ClearPreview {
230
+ const store = app.store(projectId);
231
+ const project = app.project(projectId);
232
+ const notes = store.notes.all();
233
+ const versions = notes.reduce((n, x) => n + x.versions.length, 0);
234
+ const discussion = notes.reduce((n, x) => n + x.discussion.length, 0);
235
+ const responses = notes.filter((x) => x.ownerResponse !== null).length;
236
+ // What the owner said to the Keeper and nowhere else: messages in the Keeper conversation, replies on notes, responses.
237
+ const ownerMessages = store.sources.filter((s) => s.said?.by === 'owner' && s.anchor.kind === 'session' && (s.anchor as { host?: string }).host === 'pi').length;
238
+ const ownerReplies = notes.reduce((n, x) => n + x.discussion.filter((d) => d.role === 'owner').length, 0);
239
+ const conversations = app.conversation.sessions(projectId).length;
240
+ const spent = app.keeper.usageSummary(projectId).total;
241
+ const prior = project.usageBeforeClear?.usage ?? null;
242
+ const total = prior ? addUsage(prior, spent) : spent;
243
+ const folders = store.authorizations.filter((a) => a.projectFolder != null).map((a) => a.projectFolder!.path);
244
+ const usual = project.locations[0] ? join(project.locations[0], 'projectkeeper') : null;
245
+ const folder = folders[0] ?? (usual && existsSync(usual) ? usual : null);
246
+ let inside: string[] = [];
247
+ if (folder && existsSync(folder)) { try { inside = readdirSync(folder).sort(); } catch { inside = []; } }
248
+ const active = store.jobs.filter((j) => j.status === 'Queued' || j.status === 'Running' || j.status === 'Paused' || j.status === 'Waiting for quota' ).length;
249
+ return {
250
+ removed: [
251
+ { what: 'Scope items the Keeper worked out', count: project.scope.length - ownerScopeItems(project).length, note: project.scopeQuestions.length ? `and ${project.scopeQuestions.length} scope question${project.scopeQuestions.length === 1 ? '' : 's'} with ${project.scopeQuestions.filter((q) => q.answer).length} of your answers` : undefined },
252
+ { what: 'Sources read (anchors and excerpts)', count: store.sources.size },
253
+ { what: 'The ledger of the project’s history', count: null, note: existsSync(app.ledger.file(projectId)) ? 'built; it can be computed again from the project' : 'not built yet' },
254
+ { what: 'Product reference items', count: store.reference.size },
255
+ { what: 'Work items, with their course', count: store.threads.size },
256
+ { what: 'Graph nodes and relations', count: store.nodes.size + store.relations.size },
257
+ { what: 'Notes', count: notes.length, note: `${versions} version${versions === 1 ? '' : 's'}, ${discussion} discussion entr${discussion === 1 ? 'y' : 'ies'}, ${responses} of your responses` },
258
+ { what: 'Change records', count: store.changes.size },
259
+ { what: 'Entry marks, breakpoints and send-backs', count: store.marks.size + store.breakpoints.size + store.sendbacks.size },
260
+ { what: 'Judgement records and the trace of every write', count: store.judgements.size },
261
+ { what: 'Rounds, with their briefs, reports and adoption records', count: store.clerkRounds.size, note: `${store.roundDocs.size} document${store.roundDocs.size === 1 ? '' : 's'}, ${store.jobs.size} piece${store.jobs.size === 1 ? '' : 's'} of Keeper work` },
262
+ { what: 'Context packs', count: store.contexts.size },
263
+ { what: 'Keeper conversations for this project', count: conversations, note: 'pi’s own session files stay in pi’s folder' },
264
+ { what: 'Standing authorizations', count: store.authorizations.size, note: store.authorizations.size ? 'the Keeper needs your Project folder authorization again before it writes the folder in the project' : undefined },
265
+ ],
266
+ ownerOnly: ownerMessages + ownerReplies + responses,
267
+ running: active,
268
+ kept: [
269
+ { what: 'The project’s own files and version control', detail: `Nothing in ${project.locations.join(', ')} is touched.` },
270
+ { what: 'The ProjectKeeper folder inside the project', detail: folder ? `${folder} stays where it is${inside.length ? `, with ${inside.join(', ')}` : ' (empty or not written yet)'}. Remove it in the project yourself if you want it gone.` : 'There is none in this project.' },
271
+ { what: 'The project in the workspace', detail: `Its name (${project.name}), the location${project.locations.length === 1 ? '' : 's'} you gave, and the ${ownerScopeItems(project).length} scope item(s) you added yourself.` },
272
+ { what: 'The settings of the Keeper view', detail: 'The Keeper agent, the model and keys, the schedule, execution-agent access.' },
273
+ { what: 'What was spent so far', detail: `${total.cost == null ? 'Cost not reported' : `$${total.cost.toFixed(2)}`} (${total.input.toLocaleString('en-US')} tokens in, ${total.output.toLocaleString('en-US')} out) stays in Usage as one total, named as from before the clear.` },
274
+ // D107: a takeover by the table is the agent's record, not the Keeper's organizing; it can be taken in again.
275
+ ...(existsSync(tableDir(projectId, app.home))
276
+ ? [{ what: 'The table from a takeover by the table', detail: `The table, its history, the session material and the takeover's work folders stay in ${tableDir(projectId, app.home)}. pk import takes the table in again.` }]
277
+ : []),
278
+ ],
279
+ };
280
+ }
281
+
282
+ // ───────────────────────── the page ─────────────────────────
283
+
284
+ export interface KeeperPageView {
285
+ /** §6.10 the header: which of the three the project is in. */
286
+ readonly state: 'Not taken over' | 'Takeover under way' | 'Daily';
287
+ /** The page that opens: `takeover` until the takeover is done, `daily` from then on. */
288
+ readonly page: 'takeover' | 'daily';
289
+ readonly takeover: {
290
+ readonly phase: TakeoverState['phase'];
291
+ readonly intro: string;
292
+ readonly chosen: TakeoverDepth | null;
293
+ readonly ran: TakeoverDepth | null;
294
+ readonly startedAt: string | null;
295
+ readonly completedAt: string | null;
296
+ readonly options: readonly { readonly depth: TakeoverDepth; readonly does: string; readonly gains: string; readonly selectable: boolean; readonly why: string | null; readonly ran: boolean; readonly chosen: boolean }[];
297
+ /** Whether a model with a usable key is there, and which model new work would run on. */
298
+ readonly key: { readonly usable: boolean; readonly model: string | null; readonly keyName: string | null; readonly thinking: string | null; readonly reason: string | null };
299
+ /** While it is under way: the stage of the takeover (§3.7) — drawing the boundary, the first usable picture, deepening. */
300
+ readonly stage: string | null;
301
+ /** The round under way or last run, as `Keeper activity` shows it: its steps, the main agent's stage, its lanes. */
302
+ readonly round: ReturnType<typeof roundsView>[number] | null;
303
+ readonly rounds: readonly RoundRow[];
304
+ readonly total: { readonly wallMs: number; readonly cost: number | null; readonly models: readonly string[] };
305
+ /** What waits for the owner while it runs: scope questions and notes for decision, each opening itself. */
306
+ readonly waiting: TakeoverBriefing['waiting'];
307
+ readonly briefing: TakeoverBriefing | null;
308
+ };
309
+ readonly daily: {
310
+ readonly available: boolean;
311
+ readonly why: string | null;
312
+ readonly schedule: OrganizeSchedule;
313
+ readonly scheduleText: string;
314
+ readonly nextAt: string | null;
315
+ readonly frequencies: readonly string[];
316
+ readonly dayNames: readonly string[];
317
+ readonly lastRound: (RoundRow & { readonly nothingNew: boolean | null; readonly statement: string | null; readonly resultId: string | null }) | null;
318
+ readonly lastTime: Project['scheduleHandled'];
319
+ readonly pending: number;
320
+ readonly paused: boolean;
321
+ readonly running: RoundRow | null;
322
+ };
323
+ readonly usageBeforeClear: Project['usageBeforeClear'];
324
+ /** The project-folder authorization (§1.14): what it allows, whether it stands, and the control's state. */
325
+ readonly projectFolder: ProjectFolderState;
326
+ }
327
+
328
+ /**
329
+ * The key and model a takeover would run on (§6.10 第一次用; CKC-03 AC-36): the project's main key and model when that
330
+ * key can be used, else the key standing in for it. No usable key at all — none configured, every one refused or out of
331
+ * quota — and `Start` cannot be pressed. A key is usable when it has credentials and is not out of quota or refused.
332
+ */
333
+ export async function takeoverKey(app: App, projectId: string): Promise<KeeperPageView['takeover']['key']> {
334
+ if (!app.keeper.ready) return { usable: false, model: null, keyName: null, thinking: null, reason: 'The Keeper runtime is not attached.' };
335
+ const provider = await app.keeper.providerState(projectId).catch(() => null);
336
+ const keys = await app.keeper.keysView().catch(() => []);
337
+ const usable = (id: string) => { const k = keys.find((x) => x.id === id); return Boolean(k && (k.state.status === 'Usable' || k.state.status === 'Rate-limited')); };
338
+ const m = provider?.model ?? null;
339
+ if (!provider?.connected || !m || !usable(m.provider)) {
340
+ const any = keys.some((k) => usable(k.id));
341
+ return { usable: false, model: null, keyName: null, thinking: null, reason: any ? 'The key chosen for this project cannot be used now: choose another in Model provider below.' : 'No usable key: add one in Model provider below — choose the provider, paste the key, Save.' };
342
+ }
343
+ return { usable: true, model: `${m.provider}/${m.id}`, keyName: keys.find((k) => k.id === m.provider)?.name ?? m.provider, thinking: m.thinking ?? null, reason: null };
344
+ }
345
+
346
+ export async function keeperPageView(app: App, projectId: string, now = Date.now()): Promise<KeeperPageView> {
347
+ const store = app.store(projectId);
348
+ const project = app.project(projectId);
349
+ const state = takeoverState(store, project);
350
+ const key = await takeoverKey(app, projectId);
351
+ const all = store.clerkRounds.all().sort((a, b) => a.startedAt.localeCompare(b.startedAt));
352
+ const views = roundsView(store);
353
+ const latestTakeover = state.rounds[state.rounds.length - 1] ?? null;
354
+ const rounds = state.rounds.map((r) => roundRow(store, r, now));
355
+ const first = state.rounds.find((r) => r.kind === 'First usable');
356
+ const stage = state.phase !== 'Under way' ? null : !first ? 'Drawing the boundary and reading the material' : first.status !== 'Done' ? 'First usable picture' : `Deepening (${state.chosen})`;
357
+ const waiting = needsYou(store, project)
358
+ .filter((x) => (x.kind === 'note' && x.ask === 'For your decision') || x.kind === 'scope-question')
359
+ .map((x) => ({ kind: x.kind as 'note' | 'scope-question', id: x.id, text: x.kind === 'note' ? withoutInternalIds(x.label, laneNamesOf(store)) : x.label }));
360
+ // Daily: the last round that closed, whichever kind, and what started it.
361
+ const closed = all.filter((r) => r.endedAt).sort((a, b) => (b.endedAt ?? '').localeCompare(a.endedAt ?? ''))[0] ?? null;
362
+ const record = closed?.followUpRoundId ? store.rounds.get(closed.followUpRoundId) ?? null : null;
363
+ const news = record?.result?.news ?? null;
364
+ // A project taken over by the table: its follow-ups are the Follow up round records the table follow-up writes (D107).
365
+ const tableRounds = store.rounds.all().filter((r) => tableExtraOf(r.mainJobId ? store.jobs.get(r.mainJobId) : undefined) !== null);
366
+ const lastTable = tableRounds.filter((r) => r.endedAt).sort((a, b) => (b.endedAt ?? '').localeCompare(a.endedAt ?? ''))[0] ?? null;
367
+ const runningTable = tableRounds.find((r) => !r.endedAt) ?? null;
368
+ const runningRound = all.find((r) => r.status === 'Running') ?? null;
369
+ const schedule = scheduleInForce(project, state.completedAt);
370
+ const scope = store.coverage.scopes.find((s) => s.id === 'project');
371
+ const done = state.phase === 'Done';
372
+ const next = done && !organizingHeld(project) ? nextScheduled(project, state.completedAt, now) : null;
373
+ return {
374
+ state: state.phase === 'Not started' ? 'Not taken over' : done ? 'Daily' : 'Takeover under way',
375
+ page: done ? 'daily' : 'takeover',
376
+ takeover: {
377
+ phase: state.phase, intro: TAKEOVER_INTRO, chosen: state.chosen, ran: state.ran, startedAt: state.startedAt, completedAt: state.completedAt,
378
+ options: state.options.map((o) => ({ ...o, ...DEPTH_TEXT[o.depth] })),
379
+ key, stage,
380
+ round: latestTakeover ? views.find((v) => v.id === latestTakeover.id) ?? null : null,
381
+ rounds,
382
+ total: { wallMs: rounds.reduce((n, r) => n + r.wallMs, 0), cost: rounds.reduce<number | null>((c, r) => addCost(c, r.cost), null), models: [...new Set(rounds.flatMap((r) => r.models))] },
383
+ waiting,
384
+ briefing: done ? takeoverBriefing(store, project, state) : null,
385
+ },
386
+ daily: {
387
+ available: done,
388
+ why: done ? null : state.phase === 'Not started' ? 'Daily organizing begins once the takeover is done. Choose a depth and press Start on the Takeover page.' : 'Daily organizing begins once the takeover is done; it is still under way on the Takeover page.',
389
+ schedule, scheduleText: scheduleText(schedule), nextAt: next ? next.toISOString() : null,
390
+ frequencies: SCHEDULE_FREQUENCIES, dayNames: DAY_NAMES,
391
+ lastRound: project.takenOverByTable
392
+ ? (lastTable ? { ...tableRoundRow(store, lastTable, now), nothingNew: lastTable.result?.news?.nothingNew ?? null, statement: lastTable.result?.news?.statement ?? null, resultId: lastTable.result ? lastTable.id : null } : null)
393
+ : closed ? { ...roundRow(store, closed, now), nothingNew: news ? news.nothingNew : null, statement: news ? news.statement : null, resultId: record?.result ? record.id : null } : null,
394
+ lastTime: project.scheduleHandled ?? null,
395
+ pending: scope?.pending.length ?? 0,
396
+ paused: Boolean(project.organizingPaused),
397
+ running: project.takenOverByTable ? (runningTable ? tableRoundRow(store, runningTable, now) : null) : runningRound ? roundRow(store, runningRound, now) : null,
398
+ },
399
+ usageBeforeClear: project.usageBeforeClear ?? null,
400
+ projectFolder: projectFolderState(app, projectId),
401
+ };
402
+ }
403
+
404
+ /**
405
+ * What an execution agent gets when it asks for the context of a project that has not been organized — just added, or
406
+ * cleared (Spec §7.10, §3.7 清空; CKC-13 AC-41): the statement that it is not organized, never an empty pack.
407
+ */
408
+ export function notOrganizedPackage(project: Project, request: ContextRequest, keeperStatus: string): ContextPackage {
409
+ const at = new Date().toISOString();
410
+ const markdown = [
411
+ `# ${project.name}: not organized yet`,
412
+ '',
413
+ 'ProjectKeeper has not organized this project: its owner has not started the takeover, or cleared what was organized. There is no project picture to give yet — this is not an empty pack, and nothing here says the project has no plans, work or decisions.',
414
+ '',
415
+ 'Until the owner starts the takeover (workbench → Keeper → Takeover → choose a depth → Start), read the project itself.',
416
+ '',
417
+ `Project location${project.locations.length === 1 ? '' : 's'}: ${project.locations.join(', ')}`,
418
+ ].join('\n');
419
+ return { id: `ctx_not-organized-${project.id}`, projectId: project.id, request, asOf: at, commit: null, keeperStatus, markdown, citedSourceIds: [], generatedAt: at, deliveries: [] };
420
+ }
421
+
422
+ export function registerKeeperPageRoutes(http: HttpApp, app: App): void {
423
+ http.route('GET', '/api/projects/:id/keeper-page', ({ params }) => keeperPageView(app, params.id!));
424
+ // `Start` (§3.7; CKC-13 AC-19, AC-23): the depth chosen on the page; refused with the reason when it cannot start.
425
+ http.route('POST', '/api/projects/:id/takeover/start', async ({ params, body }) => {
426
+ const depth = (body as { depth?: unknown } | null)?.depth;
427
+ const key = await takeoverKey(app, params.id!);
428
+ const refusal = startRefusal(takeoverState(app.store(params.id!), app.project(params.id!)), depth, key.usable);
429
+ if (refusal) throw new HttpError(409, refusal);
430
+ app.startTakeover(params.id!, depth, true);
431
+ return keeperPageView(app, params.id!);
432
+ });
433
+ // `Clear` (§3.7 清空; CKC-13 AC-40, AC-41): what would go and what stays, then the clearing itself, which takes `DELETE`.
434
+ http.route('GET', '/api/projects/:id/takeover/clear', ({ params }) => clearPreview(app, params.id!));
435
+ http.route('POST', '/api/projects/:id/takeover/clear', async ({ params, body }) => {
436
+ const r = await app.clearProject(params.id!, (body as { confirm?: unknown } | null)?.confirm);
437
+ return { cleared: true, stopped: r.stopped, leftBehind: r.leftBehind, page: await keeperPageView(app, params.id!) };
438
+ });
439
+ // The schedule (§3.8; CKC-07 AC-29): saved as given; what was changed and not saved has no effect.
440
+ http.route('POST', '/api/projects/:id/keeper/schedule', async ({ params, body }) => {
441
+ const parsed = parseSchedule(body);
442
+ if ('error' in parsed) throw new HttpError(400, parsed.error);
443
+ app.setSchedule(params.id!, parsed.schedule);
444
+ return keeperPageView(app, params.id!);
445
+ });
446
+ }