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,595 @@
1
+ /**
2
+ * Application wiring: workspace, per-project stores, scope discovery, event fan-out.
3
+ * Keeper runtime, source organizing and the other services attach here in later batches.
4
+ */
5
+ import { LedgerService } from '../ledger/adapters.ts';
6
+ import { processEngines, processRunner } from '../process/index.ts';
7
+ import { codeMapEngines } from '../codemap/engines.ts';
8
+ import type { KEngines } from './k-views.ts';
9
+ import { EventEmitter } from 'node:events';
10
+ import { existsSync, readFileSync } from 'node:fs';
11
+ import type { Coverage, OrganizeSchedule, Project, ScopeItem, ScopeQuestion, Source } from '../model/types.ts';
12
+ import type { TakeoverDepth } from '../model/vocab.ts';
13
+ import { DEPTH_NOTE_ID } from '../keeper/organize/takeover.ts';
14
+ import { startRefusal, takeoverState } from '../keeper/organize/takeover-state.ts';
15
+ import { scheduleOf } from '../keeper/organize/schedule.ts';
16
+ import { lastRoundEnd } from '../keeper/organize/schedule-state.ts';
17
+ import { HttpError } from './http.ts';
18
+ import { newId } from '../model/ids.ts';
19
+ import { decideScope, discoverBase, type DiscoveredItem, type DiscoveryBase } from '../scope/discover.ts';
20
+ import { scanSignature } from '../scope/skip.ts';
21
+ import { syncAllScopeUsedAs } from '../keeper/scope-tools.ts';
22
+ import { anchorLabel, makeFileSource } from '../sources/anchor.ts';
23
+ import { ProjectStore } from '../store/project-store.ts';
24
+ import { Workspace } from '../store/workspace.ts';
25
+ import { projectKeeperHome, projectDir } from '../store/paths.ts';
26
+ import { saveVersion, type VersionReason } from '../store/versions.ts';
27
+ import { canonicalPath, expandHome, normalizePath, samePath } from '../util/paths.ts';
28
+ import { fullIntake, incrementalIntake, type IntakeResult } from '../intake/intake.ts';
29
+ import { applyMaterialRules } from '../intake/material-rules.ts';
30
+ import { ScopeWatcher, type PendingChange } from '../sources/watch.ts';
31
+ import { KeeperRuntime, type KeeperEvent } from '../keeper/runtime.ts';
32
+ import { OrganizingService, writeClerkCoverage } from '../keeper/organize/service.ts';
33
+ import { ConversationService } from '../keeper/conversation.ts';
34
+
35
+ export interface AppEvent {
36
+ readonly type: 'workspace' | 'project' | 'assets' | 'keeper' | 'chat';
37
+ readonly projectId?: string;
38
+ readonly collection?: string;
39
+ readonly ids?: readonly string[];
40
+ readonly data?: unknown;
41
+ }
42
+
43
+ /** The scope items the owner added by hand — not the locations the project was given, which the boundary is drawn from.
44
+ * An item recorded before locations were kept in the file system's spelling is told by the directory it names. */
45
+ export const ownerScopeItems = (project: Project): ScopeItem[] => project.scope.filter((i) => i.addedBy === 'owner' && !project.locations.some((l) => samePath(l, canonicalPath(i.path))));
46
+
47
+ export class App extends EventEmitter {
48
+ readonly home: string;
49
+ readonly workspace: Workspace;
50
+ private readonly stores = new Map<string, ProjectStore>();
51
+ private readonly watchers = new Map<string, ScopeWatcher>();
52
+ private readonly intakeRunning = new Set<string>();
53
+ /** Called with settled changes; the organizing service (B3) subscribes here. */
54
+ onSettled: ((projectId: string, changes: readonly PendingChange[], result: IntakeResult) => void) | null = null;
55
+
56
+ /** The Keeper runtime on pi; `initKeeper()` must run before jobs are queued. */
57
+ readonly keeper: KeeperRuntime;
58
+
59
+ constructor(home = projectKeeperHome(), options: { readonly organizing?: boolean } = {}) {
60
+ super();
61
+ // The home in the file system's own spelling, like every project location (util/paths.ts `canonicalPath`).
62
+ this.home = canonicalPath(home);
63
+ this.workspace = Workspace.open(this.home);
64
+ this.workspace.on('change', () => this.emit('event', { type: 'workspace' } satisfies AppEvent));
65
+ this.keeper = new KeeperRuntime(this.workspace, (id) => this.store(id), (id) => this.project(id));
66
+ this.keeper.on('event', (event: KeeperEvent) => {
67
+ this.emit('event', { type: event.kind === 'delta' ? 'chat' : 'keeper', projectId: event.projectId, data: event } satisfies AppEvent);
68
+ });
69
+ this.organizing = new OrganizingService(this, options.organizing ?? true);
70
+ this.organizing.start();
71
+ this.conversation = new ConversationService(this);
72
+ this.conversation.start();
73
+ this.keeper.hooks.requestRelook = (projectId, scope, reason) => { void reason; this.organizing.requestRelook(projectId, { ...scope, label: '' }); };
74
+ // The ledger (§1.16): a round's first step brings it up to date, every step queries it, the writers cite its entries
75
+ // by id, and the views read the versions, the lineage and the coverage from it.
76
+ this.ledger = new LedgerService({ home: this.home });
77
+ this.organizing.setLedgerRunner(this.ledger.runner);
78
+ this.keeper.hooks.ledgerFor = (projectId) => this.ledger.hook(projectId);
79
+ // The process engine (§2.12): a round's process step computes the breakpoints and moves the send-backs on; the
80
+ // process view reads each work item's steps and four things, and a plan's actual shape, from it.
81
+ this.organizing.setProcessRunner(processRunner(this.ledger));
82
+ this.kEngines = { ...this.kEngines, ledger: this.ledger.engines(), process: processEngines(this.ledger), codemap: codeMapEngines(this.ledger) };
83
+ this.onSettled = (projectId, changes, result) => this.organizing.onSettled(projectId, changes, result);
84
+ }
85
+
86
+ /** Automatic organizing: plans Keeper jobs from the assets, keeps coverage and the graph current. */
87
+ readonly organizing: OrganizingService;
88
+ /** The ledger and the process engines the increment K views use (k-views.ts); set when the build carries them. */
89
+ kEngines: KEngines = {};
90
+ /** The projects' ledgers (§1.16), one SQLite file per project in its assets. */
91
+ readonly ledger: LedgerService;
92
+ /** The owner's conversation with the Keeper (§6.8). */
93
+ readonly conversation: ConversationService;
94
+
95
+ async initKeeper(options: { readonly fakeProvider?: boolean } = {}): Promise<void> {
96
+ await this.keeper.init();
97
+ if (options.fakeProvider) {
98
+ // Dev-only test double (E8): lets the workbench run end to end without a real provider.
99
+ const { startFakeProvider, FAKE_MODEL } = await import('../keeper/fake-provider.ts');
100
+ const fake = await startFakeProvider();
101
+ this.keeper.models.registerProvider('fake', { name: 'Fake provider', baseUrl: fake.url, apiKey: 'fake', api: 'openai-completions', models: [FAKE_MODEL] });
102
+ this.keeper.setModel({ provider: 'fake', id: FAKE_MODEL.id, thinking: null });
103
+ console.warn(`[keeper] using the fake provider at ${fake.url}; answers are canned`);
104
+ }
105
+ this.migrateTakeovers();
106
+ this.organizing.resumeAll();
107
+ this.organizing.watchSchedule();
108
+ }
109
+
110
+ /**
111
+ * Bring the projects of a home from before D105 to the two pages (Spec §6.10; plan K CT item 7). A project that has
112
+ * rounds was taken over under the old flow: its depth becomes the depth its rounds actually ran — whatever button was
113
+ * pressed last — its start is the start of its first round, its rhythm becomes a saved-as-default schedule (`Daily`:
114
+ * every day at the time of day its last round ended; `Continuous` stays), and a depth question still standing in
115
+ * `Notes (attention)` is withdrawn, since no note asks for the depth any more. Running it again changes nothing.
116
+ */
117
+ migrateTakeovers(): void {
118
+ for (const p of this.workspace.list()) {
119
+ try {
120
+ const store = this.store(p.id);
121
+ const state = takeoverState(store, p);
122
+ if (state.phase === 'Not started') continue;
123
+ let next: Project = p;
124
+ if ((p.takeoverDepth ?? null) !== state.chosen) next = { ...next, takeoverDepth: state.chosen };
125
+ if (!p.takeoverStartedAt && state.startedAt) next = { ...next, takeoverStartedAt: state.startedAt };
126
+ if (!p.schedule) next = { ...next, schedule: scheduleOf(p, { takeoverDoneAt: state.completedAt, lastRoundEnd: lastRoundEnd(store) }) };
127
+ if (next !== p) this.updateProject(next);
128
+ const note = store.notes.get(DEPTH_NOTE_ID);
129
+ if (note && note.status === 'Current') {
130
+ const now = new Date().toISOString();
131
+ store.notes.put({ ...note, status: 'Withdrawn', withdrawnReason: 'The depth is now chosen on the Takeover page before the takeover starts; no note asks for it.', updatedAt: now }, { jobId: null, summary: 'The depth question is withdrawn: the depth is chosen on the Takeover page before Start' });
132
+ }
133
+ } catch (e) { console.warn(`[takeover ${p.id}] ${(e as Error).message}`); }
134
+ }
135
+ }
136
+
137
+ /** Whether the project's material is being read in right now (the planner waits for it after `Start`). */
138
+ intakeBusy(projectId: string): boolean { return this.intakeRunning.has(projectId); }
139
+ private readonly clearing = new Set<string>();
140
+ /** Whether the project is being cleared: nothing is planned or written for it meanwhile. */
141
+ isClearing(projectId: string): boolean { return this.clearing.has(projectId); }
142
+
143
+ /**
144
+ * Record that the owner started the takeover at a depth, or — once it is done — chose a deeper one (§3.7; D105). Only
145
+ * the record: `startTakeover` is what the `Start` button does.
146
+ */
147
+ markTakeoverStarted(projectId: string, depth: TakeoverDepth): Project {
148
+ const now = new Date().toISOString();
149
+ const before = this.project(projectId);
150
+ // `Start` is the owner asking for work: a pause left on the project does not hold it.
151
+ if (before.organizingPaused && this.keeper.ready) this.keeper.pauseOrganizing(projectId, false);
152
+ return this.updateProject({ ...before, takeoverDepth: depth, takeoverDepthChosenAt: now, takeoverStartedAt: before.takeoverStartedAt ?? now, organizingPaused: false });
153
+ }
154
+
155
+ /**
156
+ * The owner pressed `Start` on the `Takeover` page (§3.7, §6.10; D105; CKC-13 AC-19, AC-23; CKC-04 AC-10). On a project
157
+ * not started: the depth is recorded, the boundary is drawn and the material read, and the takeover runs through — the
158
+ * first usable picture, then the chosen depth, without asking again. On a project whose takeover is done, a deeper
159
+ * depth goes on from what is done. The same depth, a shallower one, or any depth while the takeover is under way is
160
+ * refused, with why: nothing changes. `keyUsable` is whether a model with a usable key is there.
161
+ */
162
+ startTakeover(projectId: string, depth: unknown, keyUsable = true): Project {
163
+ const store = this.store(projectId);
164
+ const state = takeoverState(store, this.project(projectId));
165
+ const refusal = startRefusal(state, depth, keyUsable);
166
+ if (refusal) throw new HttpError(409, refusal);
167
+ const project = this.markTakeoverStarted(projectId, depth as TakeoverDepth);
168
+ if (state.phase === 'Not started') {
169
+ const scoped = this.scopeProject(projectId);
170
+ setImmediate(() => { void this.intakeProject(projectId).catch((e) => console.warn(`[intake ${projectId}] ${(e as Error).message}`)); });
171
+ return scoped;
172
+ }
173
+ void this.organizing.replan(projectId);
174
+ return project;
175
+ }
176
+
177
+ /**
178
+ * Clear the project (§3.7 清空, §8.5; D105; CKC-13 AC-40, AC-41): everything the Keeper organized for it goes, and the
179
+ * project is as it was before the takeover — `Not organized yet`, every depth selectable. It takes the word `DELETE`;
180
+ * anything else changes nothing. Work under way is stopped first and waited for. What stays: the project's own files
181
+ * and version control (nothing here writes to them), the ProjectKeeper folder inside the project, the project's name
182
+ * and the locations the owner gave, the settings of the Keeper view (the schedule among them), and what was spent
183
+ * before, kept as one total.
184
+ */
185
+ async clearProject(projectId: string, confirmation: unknown): Promise<{ project: Project; stopped: number; leftBehind: string[] }> {
186
+ if (confirmation !== 'DELETE') throw new HttpError(400, 'Type DELETE to confirm. Nothing was changed.');
187
+ const store = this.store(projectId);
188
+ if (takeoverState(store, this.project(projectId)).phase === 'Not started') throw new HttpError(409, 'This project has not been organized: there is nothing to clear.');
189
+ if (this.clearing.has(projectId)) throw new HttpError(409, 'This project is already being cleared.');
190
+ this.clearing.add(projectId);
191
+ try {
192
+ // What takes changes in and what applies decisions to the scope stop first, so nothing new arrives while it clears.
193
+ this.watchers.get(projectId)?.stop();
194
+ this.watchers.delete(projectId);
195
+ const rescope = this.rescopeTimers.get(projectId);
196
+ if (rescope) { clearTimeout(rescope); this.rescopeTimers.delete(projectId); }
197
+ const stopped = this.keeper.ready ? await this.keeper.stopProject(projectId) : 0;
198
+ for (let i = 0; i < 600 && this.intakeRunning.has(projectId); i++) await new Promise((r) => setTimeout(r, 100));
199
+ await this.ledger.idle(projectId);
200
+ const spent = this.keeper.usageSummary(projectId).total;
201
+ const before = this.project(projectId);
202
+ const prior = before.usageBeforeClear ?? null;
203
+ const add = (a: number | null, b: number | null) => (a === null && b === null ? null : (a ?? 0) + (b ?? 0));
204
+ const usageBeforeClear = {
205
+ usage: prior ? { input: prior.usage.input + spent.input, output: prior.usage.output + spent.output, cacheRead: prior.usage.cacheRead + spent.cacheRead, cacheWrite: prior.usage.cacheWrite + spent.cacheWrite, cost: add(prior.usage.cost, spent.cost) } : spent,
206
+ clearedAt: new Date().toISOString(), times: (prior?.times ?? 0) + 1,
207
+ };
208
+ this.ledger.release(projectId);
209
+ const leftBehind = store.clearAll();
210
+ this.keeper.forgetProject(projectId);
211
+ this.scopeBases.delete(projectId);
212
+ // The project's record goes back to what the owner gave: its name, its locations and the items the owner added.
213
+ const { takeoverDepth: _d, takeoverDepthChosenAt: _c, takeoverStartedAt: _s, lastRoundAt: _l, followUpAt: _f, scheduleHandled: _h, toolchain: _t, takenOverByTable: _tbl, ...kept } = before;
214
+ const project = this.updateProject({ ...kept, scope: ownerScopeItems(before), scopeQuestions: [], roles: [], lastScopedAt: null, organizingPaused: false, usageBeforeClear });
215
+ this.refreshCoverageAfterIntake(projectId);
216
+ await store.flush();
217
+ return { project, stopped, leftBehind };
218
+ } finally {
219
+ this.clearing.delete(projectId);
220
+ }
221
+ }
222
+
223
+ /** §3.8 (D105): the owner saved the schedule on the `Daily` page. Times before the save are not made up for. */
224
+ setSchedule(projectId: string, schedule: OrganizeSchedule): Project {
225
+ const project = this.updateProject({ ...this.project(projectId), schedule, scheduleHandled: null });
226
+ void this.organizing.replan(projectId);
227
+ return project;
228
+ }
229
+
230
+ /** Stamp the round the planner just started, without replanning again (§3.2 daily rhythm). */
231
+ setProjectRound(projectId: string, at: string): void {
232
+ this.updateProject({ ...this.project(projectId), lastRoundAt: at });
233
+ this.saveVersion(projectId, 'Organized'); // the end of a round is the other point worth comparing from (D45)
234
+ }
235
+
236
+ /** The port the workbench listens on (set by `pk serve`). */
237
+ servedPort: number | null = null;
238
+ /**
239
+ * Where the agent entry has to point (Spec §7.10): `pk` looks for the workbench of the default ProjectKeeper home on
240
+ * that home's port, so a workbench serving another home, or on another port, is named by its port in the usage.
241
+ */
242
+ usageWhere(): { port: number | null } {
243
+ if (!this.servedPort) return { port: null };
244
+ // Another home is always named by its port, so the default home's settings file is not opened for it: this
245
+ // workbench (the demo, say) must not depend on whether that file can be read.
246
+ const usualHome = this.home === projectKeeperHome();
247
+ return { port: !usualHome || this.servedPort !== this.workspace.settings.port ? this.servedPort : null };
248
+ }
249
+
250
+ /**
251
+ * The owner pressed Follow up: the next plan opens a round whatever the rhythm says (§3.2). While organizing is
252
+ * paused the round still runs — the owner asked for it — and the pause holds again once it has run out of work
253
+ * (Spec §3.8, §3.10); work the pause was holding is part of this round.
254
+ */
255
+ followUp(projectId: string): Project {
256
+ // Follow up is daily organizing: it waits for the takeover to be done (§6.9; CKC-07 AC-17).
257
+ const phase = takeoverState(this.store(projectId), this.project(projectId)).phase;
258
+ if (phase !== 'Done') throw new HttpError(409, phase === 'Not started' ? 'Start the takeover on the Takeover page first: Follow up organizes what changed after it.' : 'The takeover is still under way: Follow up is available once it is done.');
259
+ const project = this.updateProject({ ...this.project(projectId), followUpAt: new Date().toISOString() });
260
+ if (project.organizingPaused) this.keeper.pauseOrganizing(projectId, false);
261
+ void this.organizing.replan(projectId);
262
+ return project;
263
+ }
264
+
265
+ pauseOrganizing(projectId: string, paused: boolean): Project {
266
+ const project = this.updateProject({ ...this.project(projectId), organizingPaused: paused });
267
+ this.keeper.pauseOrganizing(projectId, paused);
268
+ void this.organizing.replan(projectId);
269
+ return project;
270
+ }
271
+
272
+ store(projectId: string): ProjectStore {
273
+ let store = this.stores.get(projectId);
274
+ if (!store) {
275
+ if (!this.workspace.get(projectId)) throw new Error(`Unknown project: ${projectId}`);
276
+ store = ProjectStore.open(projectId, this.home);
277
+ store.on('change', (event: { collection: string; ids: readonly string[] }) => {
278
+ this.emit('event', { type: 'assets', projectId, collection: event.collection, ids: event.ids } satisfies AppEvent);
279
+ // The scope is decided again, and then the material rules are applied to the sources already read (§1.1, §1.15).
280
+ if (event.collection === 'rules' || event.collection === 'scopeJudgements') this.scheduleRescope(projectId);
281
+ });
282
+ this.stores.set(projectId, store);
283
+ }
284
+ return store;
285
+ }
286
+
287
+ project(projectId: string): Project {
288
+ const project = this.workspace.get(projectId);
289
+ if (!project) throw new Error(`Unknown project: ${projectId}`);
290
+ return project;
291
+ }
292
+
293
+ updateProject(project: Project): Project {
294
+ const saved = this.workspace.update(project);
295
+ this.emit('event', { type: 'project', projectId: project.id } satisfies AppEvent);
296
+ return saved;
297
+ }
298
+
299
+ /**
300
+ * Add a project (§3.7 stage 1; D105; CKC-04 AC-10, CKC-13 AC-20). Adding starts nothing: no boundary is drawn, nothing
301
+ * is read, no round runs and no change counts as waiting until the owner chooses a depth and presses `Start`
302
+ * (`startTakeover`). The project opens on the `Takeover` page, `Not organized yet`.
303
+ */
304
+ addProject(name: string, locations: readonly string[]): Project {
305
+ const project = this.workspace.add(name, locations);
306
+ this.store(project.id);
307
+ return project;
308
+ }
309
+
310
+ /** Read everything in scope into sources; established parts appear as they are read. The boundary is drawn first when it never was. */
311
+ async intakeProject(projectId: string): Promise<IntakeResult | null> {
312
+ if (this.intakeRunning.has(projectId)) return null;
313
+ this.intakeRunning.add(projectId);
314
+ let result: IntakeResult;
315
+ try {
316
+ if (!this.project(projectId).lastScopedAt) this.scopeProject(projectId);
317
+ const project = this.project(projectId);
318
+ const store = this.store(projectId);
319
+ result = await fullIntake(store, project);
320
+ // What waits is the organizing service's list, written now: intake records only its failures (QC K).
321
+ this.refreshCoverageAfterIntake(projectId);
322
+ await store.flush();
323
+ this.emit('event', { type: 'project', projectId } satisfies AppEvent);
324
+ // A project being cleared is not watched again: what was read is about to go.
325
+ if (this.workspace.settings.watchProjects !== false && !this.clearing.has(projectId)) this.startWatching(projectId);
326
+ } finally {
327
+ this.intakeRunning.delete(projectId);
328
+ }
329
+ // Planned once the reading is over: the planner waits for it after `Start` (clerk.ts `plan`).
330
+ this.organizing.onIntake(projectId);
331
+ return result;
332
+ }
333
+
334
+ startWatching(projectId: string): ScopeWatcher {
335
+ const project = this.project(projectId);
336
+ let watcher = this.watchers.get(projectId);
337
+ if (watcher) { watcher.update(project); return watcher; }
338
+ watcher = new ScopeWatcher(project);
339
+ watcher.on('pending', (pending: PendingChange[]) => {
340
+ // Changes are caught without owner action; they show as pending until settled (§3.2).
341
+ const store = this.store(projectId);
342
+ const current = store.coverage;
343
+ const scope = current.scopes.find((s) => s.id === 'project');
344
+ if (!scope) return;
345
+ const known = new Set(scope.pending.map((p) => p.ref));
346
+ const additions = pending.filter((p) => !known.has(p.ref)).map(({ kind, ref, label, since }) => ({ kind, ref, label, since }));
347
+ if (additions.length === 0) return;
348
+ const paused = this.project(projectId).organizingPaused;
349
+ const coverage = paused ? 'Organizing paused' as const : scope.organizing.length ? 'Organizing' as const : 'Changes pending' as const;
350
+ const updated = { ...scope, pending: [...scope.pending, ...additions], coverage };
351
+ store.setCoverage({ ...current, scopes: [updated, ...current.scopes.filter((s) => s.id !== 'project')] });
352
+ });
353
+ watcher.on('settled', (changes: PendingChange[]) => {
354
+ const w = this.watchers.get(projectId);
355
+ if (!w) return;
356
+ w.take(changes);
357
+ const store = this.store(projectId);
358
+ const result = incrementalIntake(store, this.project(projectId), changes);
359
+ this.refreshCoverageAfterIntake(projectId);
360
+ void store.flush();
361
+ this.onSettled?.(projectId, changes, result);
362
+ });
363
+ watcher.on('warning', (message: string) => console.warn(`[watch ${projectId}] ${message}`));
364
+ watcher.start();
365
+ this.watchers.set(projectId, watcher);
366
+ return watcher;
367
+ }
368
+
369
+ pendingChanges(projectId: string): PendingChange[] {
370
+ return this.watchers.get(projectId)?.list() ?? [];
371
+ }
372
+
373
+ refreshCoverage(projectId: string): void {
374
+ // The coverage the organizing service writes (pending, levels, failures), not intake's count by fact records, which
375
+ // the clerk method's rounds no longer write (QC AY follow-up).
376
+ writeClerkCoverage(this, projectId);
377
+ }
378
+
379
+ /**
380
+ * After intake read material: the coverage's list of what waits, the organizing levels and `Update pending` come from the
381
+ * organizing service at once (Spec §1.11, §3.2; CKC-07 AC-1, AC-10), whether or not automatic organizing runs. Intake
382
+ * records only what it could not take in, which this carries over. A failure here never fails the intake.
383
+ */
384
+ private refreshCoverageAfterIntake(projectId: string): void {
385
+ try { this.refreshCoverage(projectId); } catch (e) { console.warn(`[coverage ${projectId}] ${(e as Error).message}`); }
386
+ }
387
+
388
+ /** What the last reading of each project's disk and git found; a change in what was decided is applied to it again. */
389
+ private readonly scopeBases = new Map<string, DiscoveryBase>();
390
+
391
+ /** Stage 1 of takeover: draw the boundary and record why each item is in or out. */
392
+ scopeProject(projectId: string): Project {
393
+ const project = this.project(projectId);
394
+ const base = discoverBase(project, { existingQuestions: project.scopeQuestions, projectKeeperHome: this.home });
395
+ this.scopeBases.set(projectId, base);
396
+ return this.decideProjectScope(projectId, base, true);
397
+ }
398
+
399
+ /**
400
+ * The owner's Rescan (§6.7): read the project from the disk again — the boundary is drawn anew, then what is in it
401
+ * is read, so material added since the last read appears. (Drawing the boundary alone never reads new material.)
402
+ */
403
+ rescanProject(projectId: string): Project {
404
+ const scoped = this.scopeProject(projectId);
405
+ setImmediate(() => { void this.intakeProject(projectId); });
406
+ return scoped;
407
+ }
408
+
409
+ /** Apply what the Keeper, the project's rules and the owner decided (§1.1, §1.15, §3.9) to what discovery read. */
410
+ private decideProjectScope(projectId: string, base: DiscoveryBase, drawn: boolean): Project {
411
+ const project = this.project(projectId);
412
+ const store = this.store(projectId);
413
+ const ownerItems = ownerScopeItems(project);
414
+ const result = decideScope(base, {
415
+ existingQuestions: project.scopeQuestions, ownerItems,
416
+ // §1.15, §1.1: the project's rules and the Keeper's classifications shape the listing (CKC-04 AC-1, AC-14, AC-17).
417
+ rules: store.rules.all(), judgements: store.scopeJudgements.all(),
418
+ sourceLabel: (id) => { const s = store.sources.get(id); return s ? anchorLabel(s.anchor) : null; },
419
+ }); // B2 toolchain was read into `base` by discoverBase (with this.home) and is carried through decideScope
420
+ const found: ScopeItem[] = result.items.map((item) => this.materializeItem(store, item));
421
+ // The Keeper's own project folder stays out of the material at every rescope, whatever the scan found (Spec §1.14;
422
+ // QC AW-2): it is the assets written out, never a project document.
423
+ const folders = store.authorizations.filter((a) => !a.revokedAt && a.projectFolder != null).map((a) => ({ id: a.id, path: normalizePath(a.projectFolder!.path) }));
424
+ const scope: ScopeItem[] = [
425
+ ...found.filter((i) => !folders.some((f) => i.path.toLowerCase() === f.path.toLowerCase() && i.relation !== 'Excluded')),
426
+ ...folders.filter((f) => !found.some((i) => i.path.toLowerCase() === f.path.toLowerCase() && i.relation === 'Excluded')).map((f): ScopeItem => ({
427
+ id: newId('scope'), path: f.path, category: 'Directory', relation: 'Excluded',
428
+ reason: `ProjectKeeper's project folder (${f.id}): written from the assets under the owner's authorization, not read as the project's material`,
429
+ reasonSourceIds: [], sessionHost: null, readOnly: false, copyOf: null, worktreeOf: null, versionControl: 'unknown',
430
+ missing: existsSync(f.path) ? null : { reason: 'Path does not exist' }, addedBy: 'owner',
431
+ })),
432
+ ];
433
+ syncAllScopeUsedAs(store, scope); // the documents of third-party material are Reference only (§1.1)
434
+ const keeperFiles = [{ path: projectDir(projectId, this.home), kind: 'ProjectKeeper project assets (outside the project)' }];
435
+ const updated: Project = {
436
+ ...project, scope, scopeQuestions: result.questions, keeperFiles, toolchain: result.toolchain, roles: result.roles,
437
+ language: result.language, lastScopedAt: drawn ? new Date().toISOString() : project.lastScopedAt,
438
+ };
439
+ const coverage: Coverage = {
440
+ ...store.coverage,
441
+ missingSourceKinds: result.missingSourceKinds,
442
+ scopes: store.coverage.scopes.length > 0 ? store.coverage.scopes : [{
443
+ id: 'project', kind: 'project', label: project.name, coverage: 'Changes pending', asOf: null, commit: null,
444
+ pending: [], organizing: [], failed: [], lastRelookAt: null,
445
+ }],
446
+ };
447
+ store.setCoverage(coverage, { jobId: null, summary: 'Scope discovered' });
448
+ this.updateProject(updated);
449
+ // Then the project's material rules, on what is read already (§1.15; CKC-04 AC-14): after the scope, so a source in
450
+ // a location the decision just changed is judged against the scope as it now is. What the scope newly reads is read
451
+ // by the intake that follows, and intake applies them to it as well; no reading happens here.
452
+ try { if (applyMaterialRules(store, updated) > 0) this.refreshCoverage(projectId); } catch (e) { console.warn(`[rules ${projectId}] ${(e as Error).message}`); }
453
+ return updated;
454
+ }
455
+
456
+ private materializeItem(store: ProjectStore, item: DiscoveredItem): ScopeItem {
457
+ const { reasonRef, sessions: _sessions, ...rest } = item;
458
+ if (!reasonRef) return rest;
459
+ const source = this.reasonSource(store, item, reasonRef);
460
+ return { ...rest, reasonSourceIds: [...new Set([...rest.reasonSourceIds, source.id])] }; // a rule's source first, then the folder's own file
461
+ }
462
+
463
+ /**
464
+ * Apply what was decided to the scope again — a rule written, a classification recorded, a question answered — from
465
+ * what discovery last read (or read it now when there is nothing in memory); when what is read from where changed,
466
+ * read the project again (Spec §1.1).
467
+ */
468
+ rescope(projectId: string): Project {
469
+ // Before `Start` no boundary is drawn (D105): there is nothing to apply a decision to yet.
470
+ if (!this.project(projectId).lastScopedAt) return this.project(projectId);
471
+ const before = scanSignature(this.project(projectId).scope);
472
+ const base = this.scopeBases.get(projectId);
473
+ const after = base ? this.decideProjectScope(projectId, base, false) : this.scopeProject(projectId);
474
+ if (scanSignature(after.scope) !== before) setImmediate(() => { void this.intakeProject(projectId); });
475
+ return after;
476
+ }
477
+
478
+ private readonly rescopeTimers = new Map<string, NodeJS.Timeout>();
479
+ /** The project's rules and the Keeper's classifications shape the listing: once their writes settle, apply them again. */
480
+ private scheduleRescope(projectId: string): void {
481
+ if (this.clearing.has(projectId)) return;
482
+ const pending = this.rescopeTimers.get(projectId);
483
+ if (pending) clearTimeout(pending);
484
+ const timer = setTimeout(() => {
485
+ this.rescopeTimers.delete(projectId);
486
+ try { this.rescope(projectId); } catch (error) { console.warn(`[scope ${projectId}] ${(error as Error).message}`); }
487
+ }, 1500);
488
+ timer.unref?.();
489
+ this.rescopeTimers.set(projectId, timer);
490
+ }
491
+
492
+ private reasonSource(store: ProjectStore, item: DiscoveredItem, ref: NonNullable<DiscoveredItem['reasonRef']>): Source {
493
+ let fileText = '';
494
+ try { fileText = readFileSync(ref.path, 'utf8'); } catch { fileText = ref.excerpt; }
495
+ const source = makeFileSource({
496
+ projectId: store.projectId, path: ref.path, headingPath: ref.headingPath, lineStart: ref.lineStart,
497
+ lineEnd: ref.lineEnd, excerpt: ref.excerpt, fileText, scopeItemId: item.id,
498
+ });
499
+ const existing = store.sources.get(source.id);
500
+ if (existing && existing.version.fingerprint === source.version.fingerprint && existing.excerpt === source.excerpt) return existing; // read already, unchanged
501
+ const merged: Source = existing ? { ...source, usedAs: existing.usedAs, usedAsBy: existing.usedAsBy } : source;
502
+ store.sources.put(merged, { jobId: null, summary: `Scope reason read from ${ref.path}` });
503
+ return merged;
504
+ }
505
+
506
+ addScopeItem(projectId: string, input: { path: string; category: ScopeItem['category']; relation: ScopeItem['relation']; reason: string }): Project {
507
+ const project = this.project(projectId);
508
+ const path = canonicalPath(expandHome(input.path)); // a path the owner typed: `~` is the home, and it is kept in the file system's spelling, like a location
509
+ const item: ScopeItem = {
510
+ id: newId('scope'), path, category: input.category, relation: input.relation, reason: input.reason,
511
+ reasonSourceIds: [], sessionHost: null, readOnly: input.relation === 'Session source', copyOf: null, worktreeOf: null,
512
+ versionControl: existsSync(path) ? 'unknown' : 'unknown', missing: existsSync(path) ? null : { reason: 'Path does not exist' },
513
+ addedBy: 'owner',
514
+ };
515
+ return this.updateProject({ ...project, scope: [...project.scope.filter((i) => i.path !== path || i.category !== item.category), item] });
516
+ }
517
+
518
+ removeScopeItem(projectId: string, itemId: string): Project {
519
+ const project = this.project(projectId);
520
+ const item = project.scope.find((i) => i.id === itemId);
521
+ if (!item) throw new Error(`Unknown scope item: ${itemId}`);
522
+ // Removing a Keeper-discovered item records it as an owner exclusion so a rescan honours it.
523
+ const scope = item.addedBy === 'owner'
524
+ ? project.scope.filter((i) => i.id !== itemId)
525
+ : project.scope.map((i) => i.id === itemId ? { ...i, relation: 'Excluded' as const, reason: `Excluded by the owner (was: ${i.reason})`, addedBy: 'owner' as const } : i);
526
+ return this.updateProject({ ...project, scope });
527
+ }
528
+
529
+ answerScopeQuestion(projectId: string, questionId: string, answer: string): Project {
530
+ const project = this.project(projectId);
531
+ const questions: ScopeQuestion[] = project.scopeQuestions.map((q) => q.id === questionId
532
+ ? { ...q, answer: { text: answer, at: new Date().toISOString(), sourceId: null } } : q);
533
+ const updated = this.updateProject({ ...project, scopeQuestions: questions });
534
+ // A copy whose source the owner named: treat the answer as the copy's origin and rescan.
535
+ const q = questions.find((x) => x.id === questionId);
536
+ const typed = expandHome(answer.trim()); // a source under the home directory may be written with `~`
537
+ if (q && /copy/i.test(q.question) && /^[A-Za-z]:\\|^\//.test(typed)) {
538
+ const scope = updated.scope.map((i) => i.relation === 'Copy of another project' && !i.copyOf
539
+ ? { ...i, copyOf: canonicalPath(typed), addedBy: 'owner' as const, reason: `${i.reason}; the owner named the source: ${answer.trim()}` } : i);
540
+ this.updateProject({ ...updated, scope });
541
+ return this.scopeProject(projectId);
542
+ }
543
+ // Any other answer shapes the listing too (ignored documents taken in, a Keeper's question settled; §1.1, §3.9).
544
+ return q ? this.rescope(projectId) : updated;
545
+ }
546
+
547
+ /** The owner's response on a note (§2.7, §4.4). Decided and Delegated go through the conversation; this records the response itself. */
548
+ respondToNote(projectId: string, noteId: string, response: 'Discussed' | 'Decided' | 'Delegated' | 'No action needed'): void {
549
+ const store = this.store(projectId);
550
+ const note = store.notes.get(noteId);
551
+ if (!note) throw new Error(`Unknown note: ${noteId}`);
552
+ store.notes.put({ ...note, ownerResponse: response, updatedAt: new Date().toISOString() }, { jobId: null, summary: `Owner response: ${response}` });
553
+ }
554
+
555
+ /** Revoke a standing authorization (§1.14): nothing runs on it afterwards; work running under it stops and reports. */
556
+ revokeAuthorization(projectId: string, authorizationId: string): { stoppedJobs: string[] } {
557
+ const store = this.store(projectId);
558
+ const a = store.authorizations.get(authorizationId);
559
+ if (!a) throw new Error(`Unknown authorization: ${authorizationId}`);
560
+ store.authorizations.put({ ...a, revokedAt: new Date().toISOString() }, { jobId: null, summary: 'Standing authorization revoked by the owner' });
561
+ const stopped: string[] = [];
562
+ for (const job of store.jobs.filter((j) => j.status === 'Running' && j.requestBasis?.kind === 'authorization' && j.requestBasis.ref === authorizationId)) {
563
+ if (this.keeper.stopJob(projectId, job.id)) stopped.push(job.id);
564
+ }
565
+ return { stoppedJobs: stopped };
566
+ }
567
+
568
+ markOpened(projectId: string): { previous: string | null } {
569
+ const project = this.project(projectId);
570
+ const previous = project.lastOpenedAt;
571
+ this.updateProject({ ...project, lastOpenedAt: new Date().toISOString() });
572
+ this.workspace.setLastProject(projectId);
573
+ // Two points in time can only be compared if both were kept, and "since I last opened it" is the comparison the
574
+ // owner asks for most (D45), so the picture is saved on the way in as well as when a round of organizing ends.
575
+ this.saveVersion(projectId, 'Opened');
576
+ return { previous };
577
+ }
578
+
579
+ /** Keep the current picture as a version to compare against later (§6.3, D45). Never fails a caller. */
580
+ saveVersion(projectId: string, reason: VersionReason): void {
581
+ try { saveVersion(projectDir(projectId, this.home), this.store(projectId), reason); }
582
+ catch (e) { console.warn(`[versions ${projectId}] ${(e as Error).message}`); }
583
+ }
584
+
585
+ async flushAll(): Promise<void> {
586
+ for (const store of this.stores.values()) await store.flush();
587
+ }
588
+
589
+ stopAll(): void {
590
+ this.organizing.stopWatchingSchedule();
591
+ for (const w of this.watchers.values()) w.stop();
592
+ this.watchers.clear();
593
+ this.ledger.release();
594
+ }
595
+ }