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,330 @@
1
+ /**
2
+ * The table follow-up, planned and taken in (D106, D107). A project taken over by the ProjectKeeper table
3
+ * (`takenOverByTable`) is never given the clerk method's rounds: its Follow up — the owner's button, the schedule,
4
+ * Continuous — is one pi job that carries the same table on. The job gets:
5
+ * - a brief of what changed since the table's `asOf` (src/table/follow-up.ts);
6
+ * - a short skill (skills/table-follow-up);
7
+ * - read access to the project and the ledger;
8
+ * - one writer, `pk_write_table`.
9
+ * The writer is `write` below:
10
+ * - it sets `asOf` and `filledBy`;
11
+ * - it checks the whole table as `pk check` does, and refuses it with the problem list;
12
+ * - or else it saves the table, takes it in (import.ts `applyTable`), commits the version when the table lives in
13
+ * ProjectKeeper's own folder, and records the run as a Follow up round, so the top bar and Keeper activity say what
14
+ * happened.
15
+ * The program's check is the only hard gate: mid-tier models did worse under many rules and gates, and well with a plain
16
+ * reading and a clear output (D106).
17
+ */
18
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
19
+ import { basename, dirname, join } from 'node:path';
20
+ import { execFileSync } from 'node:child_process';
21
+ import type { App } from '../../server/app.ts';
22
+ import type { KeeperJob, PendingMaterial, Project } from '../../model/types.ts';
23
+ import type { RoundNews, RoundNewsItem } from '../../model/k-types.ts';
24
+ import type { ProjectStore } from '../../store/project-store.ts';
25
+ import { organizingHeld } from '../held.ts';
26
+ import { roundIdOf, roundResultOf } from '../adjustment.ts';
27
+ import { KEEPER_AUTHOR } from '../project-folder.ts';
28
+ import { git, LONG_PATHS } from '../../util/git.ts';
29
+ import { checkFacts, checkShape, type TableProblem } from '../../table/check.ts';
30
+ import { applyTable, storeIdsOf, type Table } from '../../table/import.ts';
31
+ import {
32
+ TABLE_FOLLOW_UP, briefText, draftFileOf, followUpPrompt, readTable, sessionMaterial, tableChanges, tableDiff, tablePlace,
33
+ type TableChanges, type TableDiff, type TablePlace,
34
+ } from '../../table/follow-up.ts';
35
+ import type { TableWriteResult } from '../../table/follow-up-tools.ts';
36
+ import { takeoverState } from './takeover-state.ts';
37
+ import { scheduleDueFor, scheduleInForce } from './schedule-state.ts';
38
+ import { writeClerkCoverage } from './service.ts';
39
+
40
+ const ACTIVE: readonly string[] = ['Queued', 'Running', 'Waiting for quota', 'Paused'];
41
+ const RESTARTED = /restarted while this work was running/i;
42
+ /** A table follow-up reads and edits one table: two hours is far more than it needs, and only guards a session that never ends. */
43
+ const TIMEOUT_MS = 2 * 60 * 60_000;
44
+ const STARTED_BY = { owner: 'you pressed Follow up', schedule: 'the schedule', continuous: 'Continuous' } as const;
45
+ type StartedBy = keyof typeof STARTED_BY;
46
+
47
+ /** What a table follow-up's job carries in its task, set when it is queued. */
48
+ export interface TableJobExtra {
49
+ readonly kind: typeof TABLE_FOLLOW_UP;
50
+ readonly tableFile: string;
51
+ readonly projectDir: string;
52
+ readonly inHome: boolean;
53
+ /** The working copy the session edits and hands in. */
54
+ readonly draftFile: string;
55
+ /** The asOf the program sets on what the session hands in: where each repository stood when the brief was made. */
56
+ readonly asOf: TableChanges['asOf'];
57
+ /**
58
+ * When the brief was made: taken before the ledger was brought up to date, so an owner message written after it is the
59
+ * next follow-up's. The program sets it as `filledBy.at`, which the next brief reads the owner's new messages from.
60
+ */
61
+ readonly briefAt: string;
62
+ readonly startedBy: StartedBy;
63
+ }
64
+
65
+ export const tableExtraOf = (job: Pick<KeeperJob, 'task'> | undefined): TableJobExtra | null => {
66
+ const extra = (job?.task as { extra?: { kind?: string } } | null | undefined)?.extra;
67
+ return extra?.kind === TABLE_FOLLOW_UP ? extra as TableJobExtra : null;
68
+ };
69
+ /** A table follow-up's job: the model's session, or the program's record of a follow-up that found nothing to do. */
70
+ export const isTableJob = (job: Pick<KeeperJob, 'task'>): boolean => tableExtraOf(job) !== null
71
+ || (job.task as { extra?: { kind?: string } } | null)?.extra?.kind === `${TABLE_FOLLOW_UP}:program`;
72
+ export const tableJobs = (store: ProjectStore): KeeperJob[] => store.jobs.filter(isTableJob).sort((a, b) => a.queuedAt.localeCompare(b.queuedAt));
73
+
74
+ /** Whether a job took a version of the table in: its run is recorded as a Follow up round (`recordRound`). */
75
+ const tookIn = (store: ProjectStore, jobId: string): boolean => store.rounds.find((r) => r.mainJobId === jobId && r.result !== null) !== undefined;
76
+
77
+ export class TableFollowUp {
78
+ private readonly app: App;
79
+ /** The clock the schedule is read against; a test sets it (service.ts `setClock`). */
80
+ clock: () => number = () => Date.now();
81
+
82
+ constructor(app: App) {
83
+ this.app = app;
84
+ // The runtime gives a table follow-up's session its writer through this hook (runtime.ts).
85
+ if (app?.keeper?.hooks) app.keeper.hooks.tableWriter = (projectId, jobId) => (input) => this.write(projectId, jobId, input);
86
+ }
87
+
88
+ /**
89
+ * One planning pass for a table project, in place of the clerk method's (service.ts `plan`). A follow-up is due when
90
+ * the owner pressed Follow up since the last one ended; on Continuous, when the table is behind; else at a scheduled
91
+ * time when the table is behind. Behind means a commit since its asOf, or an owner message since it was filled in.
92
+ * A follow-up that failed or was stopped is not started again by itself: the owner retries it or presses Follow up.
93
+ * One that a restart cut short goes on.
94
+ */
95
+ async plan(projectId: string): Promise<void> {
96
+ const app = this.app;
97
+ if (app.isClearing?.(projectId)) return;
98
+ const store = app.store(projectId);
99
+ const project = app.project(projectId);
100
+ const jobs = tableJobs(store);
101
+ this.settle(store, jobs);
102
+ if (jobs.some((j) => ACTIVE.includes(j.status))) { this.scheduledTime(store, project, true, () => false); return; }
103
+ const last = jobs[jobs.length - 1];
104
+ if (organizingHeld(project)) return;
105
+ if (last && last.status === 'Stopped' && RESTARTED.test(last.error ?? '') && last.agent !== 'program') { app.keeper.restartJob(projectId, last.id); return; }
106
+ const state = takeoverState(store, project);
107
+ const place = tablePlace(project, app.home);
108
+ if (state.phase !== 'Done' || !place) return;
109
+ const lastEnd = jobs.reduce((at, j) => ((j.endedAt ?? '') > at ? j.endedAt ?? '' : at), '');
110
+ const held = last !== undefined && (last.status === 'Failed' || last.status === 'Stopped');
111
+ // Commits are read from git as they are now; the owner's messages from the ledger as it stood when last rebuilt.
112
+ const behind = () => { try { return !tableChanges(readTable(place.tableFile), place.projectDir, app.ledger.ledger(projectId), { diffs: false }).nothing; } catch { return false; } };
113
+ let startedBy: StartedBy;
114
+ if ((project.followUpAt ?? '') > lastEnd) startedBy = 'owner';
115
+ else if (held) return;
116
+ else if (scheduleInForce(project, state.completedAt).frequency === 'Continuous') { if (!behind()) return; startedBy = 'continuous'; }
117
+ else if (this.scheduledTime(store, project, false, behind)) startedBy = 'schedule';
118
+ else return;
119
+ await this.start(store, app.project(projectId), place, startedBy);
120
+ }
121
+
122
+ /** A session that ended without a version the check took in did not do its work: it is recorded as failed, with why. */
123
+ private settle(store: ProjectStore, jobs: readonly KeeperJob[]): void {
124
+ for (const job of jobs) {
125
+ if (job.agent === 'program' || job.status !== 'Done' || tookIn(store, job.id)) continue;
126
+ store.jobs.put({ ...job, status: 'Failed', error: 'The session ended without handing in a table the check took in (pk_write_table)' }, { jobId: job.id, summary: `${job.scope.label}: no table was taken in` });
127
+ }
128
+ }
129
+
130
+ /**
131
+ * A scheduled time that has come (§3.8): recorded as dealt with, and whether a follow-up starts for it — not while one
132
+ * runs, and not when the table is not behind.
133
+ */
134
+ private scheduledTime(store: ProjectStore, project: Project, running: boolean, behind: () => boolean): boolean {
135
+ const state = takeoverState(store, project);
136
+ if (state.phase !== 'Done') return false;
137
+ const due = scheduleDueFor(store, project, state.completedAt, this.clock());
138
+ if (!due) return false;
139
+ const outcome = running ? 'a round was running' as const : behind() ? 'round' as const : 'nothing changed' as const;
140
+ this.app.updateProject({ ...this.app.project(project.id), scheduleHandled: { slot: due.slot.toISOString(), at: new Date(this.clock()).toISOString(), outcome, roundId: null } });
141
+ return outcome === 'round';
142
+ }
143
+
144
+ /**
145
+ * Start a follow-up: the ledger brought up to date (it holds the owner's messages), the brief made, and the working
146
+ * copy written beside the project's assets, where the session may edit it; then the job queued. A table not behind
147
+ * gets no session: the program records that nothing changed.
148
+ */
149
+ private async start(store: ProjectStore, project: Project, place: TablePlace, startedBy: StartedBy): Promise<void> {
150
+ const app = this.app;
151
+ const startedAt = new Date().toISOString();
152
+ try { await app.ledger.rebuild(project); } catch (e) { console.warn(`[table follow-up ${project.id}] the ledger was not brought up to date: ${(e as Error).message}`); }
153
+ const label = `Table follow-up (${STARTED_BY[startedBy]})`;
154
+ let table: Table;
155
+ let changes: TableChanges;
156
+ try {
157
+ table = readTable(place.tableFile);
158
+ changes = tableChanges(table, place.projectDir, app.ledger.ledger(project.id));
159
+ } catch (e) {
160
+ this.programJob(project.id, label, 'Failed', `The table ${place.tableFile} could not be read: ${(e as Error).message}`);
161
+ return;
162
+ }
163
+ if (changes.nothing) {
164
+ const job = this.programJob(project.id, label, 'Done', `Nothing changed since the table's asOf: no commit, and no owner message since ${table.project.filledBy.at}.`);
165
+ this.recordRound(store, project.id, job.id, startedAt, 'Nothing new: no commit and no owner message since the table was filled in', true, []);
166
+ app.setProjectRound(project.id, new Date().toISOString());
167
+ return;
168
+ }
169
+ const runId = startedAt.replace(/[:.]/g, '-');
170
+ const draftFile = draftFileOf(store, runId);
171
+ // Earlier working copies are of follow-ups that have ended.
172
+ const drafts = dirname(draftFile);
173
+ mkdirSync(drafts, { recursive: true });
174
+ for (const f of readdirSync(drafts)) if (f.endsWith('.json')) rmSync(join(drafts, f), { force: true });
175
+ writeFileSync(draftFile, readFileSync(place.tableFile, 'utf8'));
176
+ const extra: TableJobExtra = { kind: TABLE_FOLLOW_UP, tableFile: place.tableFile, projectDir: place.projectDir, inHome: place.inHome, draftFile, asOf: changes.asOf, briefAt: startedAt, startedBy };
177
+ app.keeper.enqueue(project.id, {
178
+ kind: 'Organizing', initiator: 'auto', priority: 1, timeoutMs: TIMEOUT_MS,
179
+ scope: { kind: TABLE_FOLLOW_UP, ids: [], label },
180
+ prompt: followUpPrompt(briefText(table, place, draftFile, changes)),
181
+ task: extra,
182
+ });
183
+ }
184
+
185
+ /** A follow-up the program settled by itself, in Keeper activity beside the sessions. */
186
+ private programJob(projectId: string, label: string, status: 'Done' | 'Failed', text: string): KeeperJob {
187
+ const job = this.app.keeper.recordProgramJob(projectId, { kind: 'Organizing', scope: { kind: TABLE_FOLLOW_UP, ids: [], label }, step: null, parentJobId: null, task: { kind: `${TABLE_FOLLOW_UP}:program` } });
188
+ this.app.keeper.endProgramJob(projectId, job.id, status, status === 'Done' ? { resultText: text } : { error: text });
189
+ return this.app.store(projectId).jobs.get(job.id) ?? job;
190
+ }
191
+
192
+ /**
193
+ * `pk_write_table` (D107): one version of the table handed in by a follow-up's session — the one given, or else its
194
+ * working copy. The program sets `asOf` and `filledBy.at` (the brief's) and `filledBy.agent` (this run), checks the shape
195
+ * and then the facts, with the session material built from the ledger and the sources the table already cites. A table
196
+ * with any problem is refused with the list and nothing changes. One that holds is saved, taken in, committed to the
197
+ * table's own history when it lives in ProjectKeeper's folder, and recorded as a Follow up round.
198
+ */
199
+ async write(projectId: string, jobId: string, input: unknown): Promise<TableWriteResult> {
200
+ const app = this.app;
201
+ const store = app.store(projectId);
202
+ const job = store.jobs.get(jobId);
203
+ const extra = tableExtraOf(job);
204
+ if (!job || !extra) return { ok: false, text: 'This job is not a table follow-up, so it has no table to hand in.' };
205
+ let given: unknown = input;
206
+ if (given === undefined || given === null || given === '') {
207
+ try { given = JSON.parse(readFileSync(extra.draftFile, 'utf8')); } catch (e) { return { ok: false, text: `Your working copy ${extra.draftFile} is not readable as JSON: ${(e as Error).message}` }; }
208
+ } else if (typeof given === 'string') {
209
+ try { given = JSON.parse(given); } catch (e) { return { ok: false, text: `table is not readable as JSON: ${(e as Error).message}` }; }
210
+ }
211
+ if (!given || typeof given !== 'object' || Array.isArray(given)) return { ok: false, text: 'The table must be one JSON object.' };
212
+ const at = new Date().toISOString();
213
+ const agent = `ProjectKeeper Keeper (pi${job.model ? `, ${job.model.provider}/${job.model.id}` : ''})`;
214
+ const t = given as Record<string, unknown>;
215
+ const header = t.project && typeof t.project === 'object' ? t.project as Record<string, unknown> : null;
216
+ const filled = header?.filledBy && typeof header.filledBy === 'object' ? header.filledBy as Record<string, unknown> : {};
217
+ const stamped = header ? { ...t, project: { ...header, asOf: extra.asOf, filledBy: { agent, at: extra.briefAt ?? at, ...(filled.read !== undefined ? { read: filled.read } : {}) } } } : t;
218
+ const refused = (problems: readonly TableProblem[]): TableWriteResult => ({
219
+ ok: false,
220
+ text: [
221
+ `The table was not taken in: ${problems.length} problem${problems.length === 1 ? '' : 's'}. Nothing changed. Fix each one in your working copy and call pk_write_table again.`,
222
+ ...problems.slice(0, 80).map((p) => `${p.at} ${p.problem}`),
223
+ ...(problems.length > 80 ? [`… and ${problems.length - 80} more`] : []),
224
+ ].join('\n'),
225
+ });
226
+ const shape = checkShape(stamped);
227
+ if (shape.length) return refused(shape);
228
+ const table = stamped as unknown as Table;
229
+ const sessions = sessionMaterial(app.ledger.ledger(projectId), store, table);
230
+ const facts = checkFacts(stamped, extra.projectDir, sessions);
231
+ if (facts.length) return refused(facts);
232
+
233
+ let before: Table | null = null;
234
+ try { before = readTable(extra.tableFile); } catch { before = null; }
235
+ const diff = before ? tableDiff(before, table) : null;
236
+ const tmp = `${extra.tableFile}.${jobId}.tmp`;
237
+ writeFileSync(tmp, `${JSON.stringify(table, null, 2)}\n`);
238
+ renameSync(tmp, extra.tableFile);
239
+ await applyTable(app, projectId, table, { tableFile: extra.tableFile, projectDir: extra.projectDir, sessions, jobId, summary: `Taken in from the table follow-up ${jobId}` });
240
+ const what = diff ? diff.line : 'the table file could not be read before, so what changed is not known';
241
+ // The table's own history (D107 addendum): kept beside the table in ProjectKeeper's folder; never in the project.
242
+ const commit = extra.inHome ? commitTableVersion(extra.tableFile, `Table follow-up ${jobId} (${STARTED_BY[extra.startedBy]}): ${what}`) : null;
243
+ const news = diff ? this.noteNews(table, projectId, diff) : [];
244
+ const nothingNew = diff !== null && diff.count === 0;
245
+ this.recordRound(store, projectId, jobId, job.startedAt ?? at, nothingNew ? 'Nothing new' : `the table follow-up changed ${what}`, nothingNew, news);
246
+ app.setProjectRound(projectId, at);
247
+ writeClerkCoverage(app, projectId);
248
+ await store.flush();
249
+ return {
250
+ ok: true,
251
+ text: [
252
+ `Taken in: ${what}.`,
253
+ commit && 'commit' in commit ? `The version is committed to the table's history (${commit.commit.slice(0, 12)}).` : commit ? `The version is saved but not committed to the table's history: ${commit.reason}` : null,
254
+ `The table is saved at ${extra.tableFile}. End now with a few sentences: what you changed, and why.`,
255
+ ].filter(Boolean).join(' '),
256
+ };
257
+ }
258
+
259
+ /** The notes a version added or changed, as the round's news names them (§3.8: each with where it is). */
260
+ private noteNews(table: Table, projectId: string, diff: TableDiff): RoundNewsItem[] {
261
+ const notes = diff.lists.find((l) => l.list === 'notes');
262
+ if (!notes) return [];
263
+ const ids = storeIdsOf(table, projectId);
264
+ return [...notes.added.map((id) => [id, 'new'] as const), ...notes.changed.map((id) => [id, 'changed'] as const)].flatMap(([id, detail]) => {
265
+ const n = table.notes.find((x) => x.id === id);
266
+ return n ? [{ id: ids.get(id)!, label: n.title, position: `Notes · ${n.title}`, objectId: n.on[0] ? ids.get(n.on[0]) ?? null : null, detail }] : [];
267
+ });
268
+ }
269
+
270
+ /**
271
+ * The follow-up's run as a Follow up round record (`store.rounds`) with its result, so the top bar's last Follow up
272
+ * (service.ts `lastFollowUp`) and the round's result say what it changed, or that it found nothing new. A session
273
+ * that hands in several versions keeps one record.
274
+ */
275
+ private recordRound(store: ProjectStore, projectId: string, jobId: string, startedAt: string, statement: string, nothingNew: boolean, notes: readonly RoundNewsItem[]): void {
276
+ const existing = store.rounds.find((r) => r.mainJobId === jobId);
277
+ const number = existing?.number ?? Math.max(0, ...store.rounds.all().map((r) => r.number)) + 1;
278
+ const id = existing?.id ?? roundIdOf(number);
279
+ const at = new Date().toISOString();
280
+ const base = { id, projectId, number, startedAt: existing?.startedAt ?? startedAt, endedAt: at, mainJobId: jobId, result: null };
281
+ const trace = { jobId, summary: `Table follow-up: ${statement}` };
282
+ store.rounds.put(base, trace);
283
+ const news: RoundNews = { breakpoints: [], sendbacks: [], sixThings: [], patches: [], notes, nothingNew, statement: nothingNew ? 'Nothing new' : statement, complete: true };
284
+ store.rounds.put({ ...base, result: roundResultOf(store, id, `Table follow-up: ${statement}`, at, news) }, trace);
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Commit one version of a table kept in ProjectKeeper's own folder to a git repository of the table's folder, made on
290
+ * first use (D107 addendum three). Only the table file is added, and the Keeper is the author and the committer. Returns the commit, or why there is none.
291
+ */
292
+ export function commitTableVersion(tableFile: string, message: string): { readonly commit: string } | { readonly reason: string } {
293
+ const dir = dirname(tableFile);
294
+ const name = basename(tableFile);
295
+ const [, authorName = 'ProjectKeeper', email = 'keeper@projectkeeper.invalid'] = /^(.*) <(.*)>$/.exec(KEEPER_AUTHOR) ?? [];
296
+ const run = (args: readonly string[]): { ok: boolean; out: string; err: string } => {
297
+ try {
298
+ const out = execFileSync('git', ['--no-pager', '-c', `user.name=${authorName}`, '-c', `user.email=${email}`, ...LONG_PATHS, '-C', dir, ...args], {
299
+ encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, timeout: 20_000, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
300
+ });
301
+ return { ok: true, out, err: '' };
302
+ } catch (e) { const x = e as { stdout?: string; stderr?: string; message?: string }; return { ok: false, out: x.stdout ?? '', err: (x.stderr || x.message || String(e)).trim() }; }
303
+ };
304
+ if (!existsSync(join(dir, '.git'))) {
305
+ const init = run(['init', '-q']);
306
+ if (!init.ok) return { reason: `git init failed: ${init.err}` };
307
+ }
308
+ const add = run(['add', '--', name]);
309
+ if (!add.ok) return { reason: `git add failed: ${add.err}` };
310
+ if (run(['diff', '--cached', '--quiet', '--', name]).ok && git(dir, ['rev-parse', '--verify', 'HEAD']).ok) return { reason: 'the table is the same as the version committed last' };
311
+ const commit = run(['commit', '-q', '-m', message, '--', name]);
312
+ if (!commit.ok) return { reason: `git commit failed: ${commit.err}` };
313
+ return { commit: git(dir, ['rev-parse', 'HEAD']).out.trim() };
314
+ }
315
+
316
+ /**
317
+ * What waits for a table project's next follow-up, the way the coverage lists it (§1.11): each commit since the table's
318
+ * asOf, and each session with an owner message since the table was filled in. Read from git and the ledger as they are.
319
+ */
320
+ export function tablePending(app: App, project: Project): PendingMaterial[] {
321
+ const place = tablePlace(project, app.home);
322
+ if (!place) return [];
323
+ let changes: TableChanges;
324
+ try { changes = tableChanges(readTable(place.tableFile), place.projectDir, app.ledger.ledger(project.id), { diffs: false }); } catch { return []; }
325
+ const pending: PendingMaterial[] = [];
326
+ for (const r of changes.repos) for (const c of r.commits) pending.push({ kind: 'commit', ref: `${join(place.projectDir, r.path)}@${c.hash}`, label: `${c.hash.slice(0, 8)} ${c.subject}`.trim(), since: c.at });
327
+ const sessions = new Map<string, PendingMaterial>();
328
+ for (const m of changes.owner) if (!sessions.has(m.session)) sessions.set(m.session, { kind: 'session', ref: m.session, label: `${m.session.split(':')[0]} session ${m.session.split(':').slice(1).join(':').slice(0, 8)}`, since: m.at ?? project.takenOverByTable?.at ?? '' });
329
+ return [...pending, ...sessions.values()].sort((a, b) => a.since.localeCompare(b.since) || a.label.localeCompare(b.label));
330
+ }