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,114 @@
1
+ /**
2
+ * The owner's own keys (Spec §6.10 "key、模型与路由", §3.1; D105; CKC-03 AC-33).
3
+ *
4
+ * A key the owner enters in `Keeper` → `Model provider` is kept on this machine, in ProjectKeeper's own home
5
+ * (`<home>/keys.json`), beside `workspace.json` and outside every project: it belongs to no project, it is there after a
6
+ * restart, and every project can choose it. Its value is written in that one file and nowhere else — not in a project's
7
+ * files, not in the project's ProjectKeeper folder, its assets, exports, trace, session records or context packs, and not
8
+ * in a log (the grep test in own-keys.test.ts). Nothing this module returns to a caller outside the runtime carries the
9
+ * value: `publicKey` is what the workbench sees, with a full mask (U98: not one character shown).
10
+ *
11
+ * Each key runs under a provider id of its own (`<provider>~<n>`), registered with pi like an extra key (runtime.ts
12
+ * `registerOwnKeys`), so it has its own lanes, its own quota window and its own place in a project's backup order.
13
+ */
14
+ import { readJson, writeJsonAtomic } from '../store/json-file.ts';
15
+ import { ensureHome } from '../store/paths.ts';
16
+ import { chmodSync, statSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+
19
+ export interface OwnKey {
20
+ /** The provider id the key runs under in pi, e.g. `deepseek~1`. */
21
+ readonly id: string;
22
+ /** The provider whose endpoint and models it uses, e.g. `deepseek`. */
23
+ readonly provider: string;
24
+ readonly name: string;
25
+ readonly value: string;
26
+ readonly addedAt: string;
27
+ readonly replacedAt: string | null;
28
+ }
29
+
30
+ /** What the workbench is told of a key: never the value. */
31
+ export interface PublicOwnKey {
32
+ readonly id: string;
33
+ readonly provider: string;
34
+ readonly name: string;
35
+ readonly mask: string;
36
+ readonly addedAt: string;
37
+ readonly replacedAt: string | null;
38
+ }
39
+
40
+ /** The mask shown for a saved key: the same for every key, so not one character of it shows (U98). */
41
+ export const KEY_MASK = '••••••••••••';
42
+
43
+ interface KeysFile { readonly version: 1; readonly keys: readonly OwnKey[] }
44
+
45
+ export const keysFile = (home: string): string => join(home, 'keys.json');
46
+ /** The key file is its owner's alone on macOS and Linux, like any credentials file (`~/.ssh`, pi's own `auth.json`). */
47
+ const KEYS_FILE_MODE = 0o600;
48
+
49
+ export const publicKey = (k: OwnKey): PublicOwnKey => ({ id: k.id, provider: k.provider, name: k.name, mask: KEY_MASK, addedAt: k.addedAt, replacedAt: k.replacedAt });
50
+
51
+ /** A key value as pasted: one line, no surrounding space or quotes. Refused when empty or when it has inner spaces. */
52
+ export function cleanKeyValue(raw: unknown): string {
53
+ if (typeof raw !== 'string') throw new Error('The key is missing');
54
+ const value = raw.trim().replace(/^["'`]+|["'`]+$/g, '').trim();
55
+ if (!value) throw new Error('The key is empty');
56
+ if (/\s/.test(value)) throw new Error('A key is one word without spaces or line breaks: paste only the key');
57
+ return value;
58
+ }
59
+
60
+ export class OwnKeyStore {
61
+ private keys: OwnKey[];
62
+ private readonly home: string;
63
+ constructor(home: string) {
64
+ this.home = home;
65
+ // A key file written before it was kept to its owner (or copied in) is closed to other accounts as it is read.
66
+ if (process.platform !== 'win32') { try { if (statSync(keysFile(home)).mode & 0o077) chmodSync(keysFile(home), KEYS_FILE_MODE); } catch { /* no key file yet */ } }
67
+ this.keys = [...readJson<KeysFile>(keysFile(home), { version: 1, keys: [] }).keys];
68
+ }
69
+
70
+ list(): readonly OwnKey[] { return this.keys; }
71
+ get(id: string): OwnKey | undefined { return this.keys.find((k) => k.id === id); }
72
+
73
+ /** A new key for `provider`. Several keys of one provider each get an id of their own. */
74
+ add(provider: string, name: string | null, value: string, now = new Date().toISOString()): OwnKey {
75
+ let n = 1;
76
+ while (this.keys.some((k) => k.id === `${provider}~${n}`)) n++;
77
+ const sameProvider = this.keys.filter((k) => k.provider === provider).length;
78
+ const key: OwnKey = { id: `${provider}~${n}`, provider, name: name?.trim() || (sameProvider ? `${provider} key ${sameProvider + 1}` : `${provider} key`), value, addedAt: now, replacedAt: null };
79
+ this.keys = [...this.keys, key];
80
+ this.save();
81
+ return key;
82
+ }
83
+
84
+ /** A new value for a saved key (and a new name, when given): it keeps its id, so the routes that name it stand. */
85
+ replace(id: string, value: string | null, name: string | null = null, now = new Date().toISOString()): OwnKey {
86
+ const at = this.keys.findIndex((k) => k.id === id);
87
+ if (at < 0) throw new Error(`No key ${id}`);
88
+ const old = this.keys[at]!;
89
+ const next: OwnKey = { ...old, ...(value ? { value, replacedAt: now } : {}), ...(name?.trim() ? { name: name.trim() } : {}) };
90
+ this.keys = this.keys.map((k, i) => (i === at ? next : k));
91
+ this.save();
92
+ return next;
93
+ }
94
+
95
+ remove(id: string): OwnKey | null {
96
+ const old = this.get(id) ?? null;
97
+ if (!old) return null;
98
+ this.keys = this.keys.filter((k) => k.id !== id);
99
+ this.save();
100
+ return old;
101
+ }
102
+
103
+ /** Text with every saved key's value taken out (a provider's error message may quote what it was sent). */
104
+ redact(text: string): string {
105
+ let out = text;
106
+ for (const k of this.keys) if (k.value.length >= 6) out = out.split(k.value).join(KEY_MASK);
107
+ return out;
108
+ }
109
+
110
+ private save(): void {
111
+ ensureHome(this.home);
112
+ writeJsonAtomic(keysFile(this.home), { version: 1, keys: this.keys } satisfies KeysFile, KEYS_FILE_MODE);
113
+ }
114
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The form of text the owner reads, checked by the program (Spec §4.2, §6.13; D105; CKC-08 AC-26, AC-27). The owner, of
3
+ * a note whose body carried a store id, a lane report's section number and a run of decision numbers, and that opened
4
+ * with no question: 「这个可读性太差了。」
5
+ *
6
+ * Two checks, each a refusal that costs the model one retry rather than a rule it has to keep in mind:
7
+ *
8
+ * 1. **No internal identifiers** in a note, a round's Result, or any other text written for the owner: ProjectKeeper's
9
+ * own store ids (`mark_…`, `ref_…`, `rule_…`, `note_…` and the rest the store mints), a lane named as a lane, and a
10
+ * lane report's or brief's section reference. The project's own numbers (D13, CKC-08, E151) are the project's words
11
+ * and pass. The refusal names each one found and says where the evidence goes instead: the sources and links.
12
+ * 2. **A `For your decision` note opens with the question and gives the options**: the first sentence is the question
13
+ * the owner is asked to decide; at least two options, each with what follows from it.
14
+ *
15
+ * What the program cannot tell — whether a numbered thing is said in plain words before its number is used — stays with
16
+ * the skill and the spot-check (U96).
17
+ */
18
+ import type { NoteOption } from '../model/types.ts';
19
+ import type { ProjectStore } from '../store/project-store.ts';
20
+
21
+ /**
22
+ * The prefixes the store mints ids with (model/ids.ts `newId`, `stableId`; every call site in src/). A test holds this
23
+ * list against those call sites, so a new collection's ids are recognised too.
24
+ */
25
+ export const STORE_ID_PREFIXES = [
26
+ 'mark', 'ref', 'rule', 'note', 'thread', 'rel', 'sb', 'bp', 'rdoc', 'draft', 'job', 'gen', 'terr', 'crd',
27
+ 'scope', 'scopeq', 'scopej', 'src', 'fact', 'chg', 'jdg', 'item', 'step', 'link', 'st', 'msg', 'auth', 'patch', 'merge',
28
+ 'layer', 'knum', 'area', 'req', 'ctx',
29
+ ] as const;
30
+
31
+ /**
32
+ * A store id as it is minted: the prefix, an underscore, then hex (a stable id: 16 of them) or base36 time and hex (a
33
+ * new id), at least twelve characters with a digit among them — so `rule_based` or `thread_safety` is no id.
34
+ */
35
+ const STORE_ID = new RegExp(`(?<![A-Za-z0-9_])(?:${[...STORE_ID_PREFIXES].sort((a, b) => b.length - a.length).join('|')})_(?=[0-9a-z]*[0-9])[0-9a-z]{12,}(?![A-Za-z0-9])`, 'g');
36
+
37
+ /** `§3`, `§ 一`, `section 2.1`, `第三节`: a section of a document. */
38
+ const SECTION = String.raw`(?:§\s*[0-9一二三四五六七八九十百]+(?:\.[0-9]+)*|[Ss]ection\s+[0-9]+(?:\.[0-9]+)*|第\s*[0-9一二三四五六七八九十百]+\s*[节章])`;
39
+ /** A lane's report or brief, named as such: `路报告`, `路的任务书`, `lane report`, `lane's brief`. */
40
+ const LANE_DOCUMENT = String.raw`(?:路(?:的)?(?:报告|任务书)|[Ll]anes?(?:['’]s)?\s+(?:report|brief))`;
41
+ /** A lane report's or brief's section: the document named as a lane's, then the section within a few characters. */
42
+ const LANE_SECTION = new RegExp(`${LANE_DOCUMENT}[^\\n。;;,,]{0,12}?${SECTION}`, 'g');
43
+
44
+ const escaped = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
45
+
46
+ /** The names of the lanes this project's rounds sent (their briefs and reports are filed under them): internal names. */
47
+ export function laneNamesOf(store: ProjectStore): string[] {
48
+ const names = new Set<string>();
49
+ for (const r of store.clerkRounds.all()) {
50
+ for (const p of r.paths) names.add(p);
51
+ for (const s of r.sweepsAdded ?? []) names.add(s.path);
52
+ }
53
+ for (const d of store.roundDocs.all()) if (d.path) names.add(d.path);
54
+ return [...names].map((n) => n.trim()).filter((n) => n.length >= 3).sort((a, b) => b.length - a.length);
55
+ }
56
+
57
+ export interface InternalRef {
58
+ readonly kind: 'store id' | 'lane report section' | 'lane name';
59
+ /** The text found, as written. */
60
+ readonly text: string;
61
+ /** Which part of the writing holds it: `title`, `preview`, `currentView`, `facts[2]` … */
62
+ readonly where: string;
63
+ }
64
+
65
+ /**
66
+ * The internal identifiers in a piece of writing, part by part. `laneNames` are the names the project's rounds gave
67
+ * their lanes: one is found where it is used as a lane (`<name> 路`, `the <name> lane`) or as the owner of a report or a
68
+ * brief (`<name> 报告 §6`), never as an ordinary word of the sentence.
69
+ */
70
+ export function internalRefs(parts: Readonly<Record<string, string | null | undefined>>, laneNames: readonly string[] = []): InternalRef[] {
71
+ const found: InternalRef[] = [];
72
+ const seen = new Set<string>();
73
+ const add = (kind: InternalRef['kind'], text: string, where: string) => {
74
+ const key = `${kind}\n${text}`;
75
+ if (seen.has(key)) return;
76
+ seen.add(key);
77
+ found.push({ kind, text: text.trim(), where });
78
+ };
79
+ const names = laneNames.map(escaped);
80
+ const edge = String.raw`[A-Za-z0-9_-]`;
81
+ const laneSection = names.length ? new RegExp(`(?<!${edge})(?:${names.join('|')})(?!${edge})[^\\n。;;,,]{0,10}?(?:报告|任务书|[Rr]eport|[Bb]rief)[^\\n。;;,,]{0,12}?${SECTION}`, 'g') : null;
82
+ const laneName = names.length ? new RegExp(`(?<!${edge})(?:${names.join('|')})(?!${edge})\\s*(?:路(?![由径线])|\\s(?:lane|sweep)\\b)|\\b(?:lane|sweep)\\s+(?:${names.join('|')})(?!${edge})`, 'g') : null;
83
+ for (const [where, value] of Object.entries(parts)) {
84
+ const text = value ?? '';
85
+ if (!text) continue;
86
+ for (const m of text.matchAll(STORE_ID)) add('store id', m[0], where);
87
+ // A section reference found both ways (`路报告 §一`, `generations 路报告 §一`) is said once, by the longer.
88
+ const all = [LANE_SECTION, laneSection].flatMap((re) => (re ? [...text.matchAll(re)].map((m) => m[0]) : []));
89
+ const sections = all.filter((s) => !all.some((o) => o !== s && o.includes(s)));
90
+ for (const s of sections) add('lane report section', s, where);
91
+ // A lane named inside a section reference is said by that reference.
92
+ if (laneName) for (const m of text.matchAll(laneName)) if (!sections.some((s) => s.includes(m[0].trim()))) add('lane name', m[0], where);
93
+ }
94
+ return found;
95
+ }
96
+
97
+ const KIND_WORDS: Record<InternalRef['kind'], string> = {
98
+ 'store id': 'a ProjectKeeper store id',
99
+ 'lane report section': 'a section of a lane’s report or brief',
100
+ 'lane name': 'the name of a lane',
101
+ };
102
+
103
+ /** The found identifiers in one sentence: each as written, with what it is and where. */
104
+ function refsSentence(refs: readonly InternalRef[]): string {
105
+ const shown = refs.slice(0, 10).map((r) => `“${r.text}” in ${r.where} (${KIND_WORDS[r.kind]})`);
106
+ return `${shown.join('; ')}${refs.length > 10 ? `; and ${refs.length - 10} more` : ''}`;
107
+ }
108
+
109
+ /** The first sentence of a text: up to its first sentence mark or line break. A leading `[As far as read — …]` is the program's stamp, not the sentence. */
110
+ export function firstSentence(text: string): string {
111
+ const bare = text.replace(/^\[As far as read — [^\]]*\]\s*/, '').trim();
112
+ const m = /^[\s\S]*?(?:[??!!。]|\.(?=\s|$)|\n|$)/.exec(bare);
113
+ return (m ? m[0] : bare).trim();
114
+ }
115
+
116
+ const isQuestion = (sentence: string) => /[??]$/.test(sentence);
117
+
118
+ export interface OwnerNoteText {
119
+ readonly ask: string;
120
+ readonly title: string;
121
+ readonly preview: string;
122
+ /** The rest of the note the owner reads, by part: `currentView`, `facts[0]` … */
123
+ readonly body: Readonly<Record<string, string | null | undefined>>;
124
+ readonly options: readonly NoteOption[];
125
+ }
126
+
127
+ /**
128
+ * Why a note may not be written in this form, or null: everything wrong with it in one answer, so one retry sets it
129
+ * right. `how` says how the note is written again (the tool and its id), for the last sentence.
130
+ */
131
+ export function noteFormRefusal(n: OwnerNoteText, laneNames: readonly string[], how = 'Write it again'): string | null {
132
+ const problems: string[] = [];
133
+ const optionParts = Object.fromEntries(n.options.flatMap((o, i) => [[`options[${i}].option`, o.option], [`options[${i}].then`, o.then]]));
134
+ const refs = internalRefs({ title: n.title, preview: n.preview, ...n.body, ...optionParts }, laneNames);
135
+ if (refs.length) {
136
+ problems.push(`It carries ${refs.length === 1 ? 'an internal identifier' : `${refs.length} internal identifiers`}, which the owner cannot read: ${refsSentence(refs)}. Say what each thing is — its name, or the project’s own number (a decision, contract or task number) after saying what that is — and leave these out. The evidence goes in the note’s sources and links, not in its sentences: sourceIds on the facts, the objects it hangs on (mountIds), looked, changeIds, codeAnomalies.`);
137
+ }
138
+ if (n.ask === 'For your decision') {
139
+ const first = firstSentence(n.preview);
140
+ if (!isQuestion(first)) {
141
+ problems.push(`A For your decision note opens with the question: the first sentence of preview is what the owner is asked to decide, as one clear question ending in a question mark. This one opens with “${first.length > 140 ? `${first.slice(0, 140)}…` : first}”. Put the question first; what happened and why it comes up now follows in the body, in plain words.`);
142
+ }
143
+ const whole = n.options.filter((o) => o.option.trim() && o.then.trim());
144
+ if (whole.length < 2) {
145
+ problems.push(`A For your decision note gives the owner the options: at least two in options, one per choice, each with what follows from it (then) — what is changed and where; what happens if it is left alone is an option too. This one gives ${whole.length}${n.options.length > whole.length ? ` (${n.options.length - whole.length} without option or then)` : ''}.`);
146
+ }
147
+ }
148
+ if (problems.length === 0) return null;
149
+ return `The owner reads this note, and it is not in the form a note is read in. ${problems.join(' ')} ${how}. Nothing was written.`;
150
+ }
151
+
152
+ /** A decision note's options as lines, one per choice with what follows from it (§4.2): for the places that show a note as text. */
153
+ export function optionsLines(options: readonly NoteOption[] | undefined): string[] {
154
+ return (options ?? []).map((o) => `- ${o.option} — ${o.then}`);
155
+ }
156
+
157
+ /**
158
+ * Why a text written for the owner that is no note — a round's Result — may not be written, or null: it carries
159
+ * internal identifiers. The reader reaches an object by its name on the workbench; the round's records keep the ids.
160
+ */
161
+ export function ownerTextRefusal(what: string, parts: Readonly<Record<string, string | null | undefined>>, laneNames: readonly string[]): string | null {
162
+ const refs = internalRefs(parts, laneNames);
163
+ if (refs.length === 0) return null;
164
+ return `${what} is read by the owner, and it carries ${refs.length === 1 ? 'an internal identifier' : `${refs.length} internal identifiers`}: ${refsSentence(refs)}. Name each object by what it is — its title, or the project’s own number — and say what the lanes found without naming a lane or a section of its report: the workbench opens an object from its name, and the round’s own records (the reports, the adoption record, the handover) keep the ids. Write it again without them. Nothing was written.`;
165
+ }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * The one owner-authorized folder in a project (Spec §1.14, §1.17; CKC-26 AC-7–AC-11).
3
+ * The Markdown below is also exported for the workbench, so it need not maintain a second rendition.
4
+ */
5
+ import { execFileSync } from 'node:child_process';
6
+ import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, writeFileSync, type Stats } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
9
+ import type { SemanticPatch, EvidenceRef, Occurred } from '../model/k-types.ts';
10
+ import type { Authorization, Project, ReferenceItem, Source } from '../model/types.ts';
11
+ import { newId } from '../model/ids.ts';
12
+ import { anchorLabel } from '../sources/anchor.ts';
13
+ import type { ProjectStore } from '../store/project-store.ts';
14
+ import { git, gitToplevel, LONG_PATHS, type GitResult } from '../util/git.ts';
15
+ import { canonicalPath, gitPathLimit, nameForm } from '../util/paths.ts';
16
+
17
+ const FILES = ['README.md', 'semantic-patches.md', 'keeper-numbers.md', 'owner-decisions.md'] as const;
18
+ /** The Keeper's commits name it as their author (D89: the Keeper commits the folder itself), so the ledger and `git log`
19
+ * tell them from the owner's work; the committer stays the repository's own identity and no configuration is written.
20
+ * Where git knows no identity at all (a machine with no `user.name` and `user.email`), the Keeper is the committer too,
21
+ * for that one commit, so the commit does not fail. */
22
+ const KEEPER_NAME = 'ProjectKeeper';
23
+ const KEEPER_EMAIL = 'keeper@projectkeeper.invalid';
24
+ export const KEEPER_AUTHOR = `${KEEPER_NAME} <${KEEPER_EMAIL}>`;
25
+ const now = (): string => new Date().toISOString();
26
+ const sortBy = <T>(items: readonly T[], key: (item: T) => string): T[] =>
27
+ [...items].sort((a, b) => key(a).localeCompare(key(b), 'en'));
28
+ const inline = (value: string): string => value.replace(/\s+/g, ' ').trim();
29
+ const block = (value: string): string => value.trim().replace(/\r\n?/g, '\n') || 'Not recorded';
30
+
31
+ function statIfPresent(path: string): Stats | null {
32
+ try { return lstatSync(path); }
33
+ catch (error) {
34
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
35
+ throw error;
36
+ }
37
+ }
38
+
39
+ function inside(parent: string, child: string): boolean {
40
+ const rel = relative(parent, child);
41
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
42
+ }
43
+
44
+ /** Resolve a folder, never a project root, hidden folder, git metadata path or path outside the project's first root. */
45
+ export function projectFolderPath(project: Project, requested = 'projectkeeper'): string {
46
+ const location = project.locations[0];
47
+ if (!location) throw new Error('The project has no root location');
48
+ // The root in the file system's own spelling (short names expanded, links resolved): the one git reports the
49
+ // repository in, so the folder is found inside it when the commit is made.
50
+ const root = canonicalPath(location);
51
+ if (!lstatSync(root).isDirectory()) throw new Error('The project root is not a directory');
52
+ if (!requested.trim() || requested.includes('\0') || /^[A-Za-z]:[^\\/]/.test(requested) || requested.split(/[\\/]/).some((part) => part === '..' || part === '.')) throw new Error('Invalid project folder path');
53
+ // Spelled as it will stay: Windows drops a trailing dot or space from a name, so such a name would write somewhere
54
+ // other than the path shown and granted (QC AW-2). The owner gives the path relative to the root (the grant checks);
55
+ // the authorization keeps it resolved, and every write checks that resolved path again here.
56
+ if (requested.split(/[\\/]/).some((part) => /[. ]$/.test(part))) throw new Error('Invalid project folder path: a name ending in a dot or a space is not kept as written');
57
+ const folder = resolve(root, requested);
58
+ if (!inside(root, folder)) throw new Error('The project folder must be inside the project root');
59
+ const parts = relative(root, folder).split(sep);
60
+ if (parts.some((part) => part.startsWith('.'))) throw new Error('The project folder must not be hidden or inside .git');
61
+ let cursor = root;
62
+ for (const part of parts) {
63
+ const parent = cursor;
64
+ cursor = join(cursor, part);
65
+ const stat = statIfPresent(cursor);
66
+ if (!stat) continue;
67
+ // An existing name is used exactly as it is on disk: another case or an 8.3 short alias would name the same folder
68
+ // under a spelling the grant never showed (QC AW-2).
69
+ // (Asked in the form names are kept in: on a Mac the listing may give the other Unicode form of the same name.)
70
+ if (!readdirSync(parent).map((name) => nameForm(name)).includes(nameForm(part))) throw new Error(`Invalid project folder path: ${part} names an existing folder spelled differently on disk`);
71
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Unsafe project folder component: ${cursor}`);
72
+ if (!inside(root, realpathSync(cursor))) throw new Error(`Project folder escapes through a link: ${cursor}`);
73
+ }
74
+ const entries = statIfPresent(folder) ? readdirSync(folder) : [];
75
+ if (entries.length) {
76
+ const readme = join(folder, 'README.md');
77
+ const readmeStat = statIfPresent(readme);
78
+ if (!readmeStat?.isFile() || readmeStat.isSymbolicLink() || !readFileSync(readme, 'utf8').includes('This folder is maintained by ProjectKeeper')) {
79
+ throw new Error('The chosen folder already contains project files and is not a ProjectKeeper folder');
80
+ }
81
+ if (entries.some((entry) => !(FILES as readonly string[]).includes(entry))) throw new Error('The ProjectKeeper folder contains files outside its managed set');
82
+ }
83
+ return folder;
84
+ }
85
+
86
+ export interface ProjectFolderAuthorizationInput {
87
+ readonly path?: string;
88
+ readonly commits?: boolean;
89
+ readonly quote: string;
90
+ readonly sourceId?: string;
91
+ }
92
+
93
+ /** The route supplies an explicit owner statement; no model or project material can create this authorization. */
94
+ export function grantProjectFolderAuthorization(store: ProjectStore, project: Project, input: ProjectFolderAuthorizationInput): Authorization {
95
+ if (store.projectId !== project.id) throw new Error('The project and asset store do not match');
96
+ if (!input.quote.trim()) throw new Error('The owner authorization quote is required');
97
+ // The owner names the folder relative to the project root (QC AW-2): an absolute path, even one inside the project,
98
+ // is refused rather than silently taken as the same folder.
99
+ if (input.path !== undefined && isAbsolute(input.path)) throw new Error('Invalid project folder path: give it relative to the project root');
100
+ if (store.authorizations.find((a) => a.projectFolder != null && !a.revokedAt)) throw new Error('An active Project folder authorization already exists; revoke it before changing the folder');
101
+ if (input.sourceId) {
102
+ const source = store.sources.get(input.sourceId);
103
+ if (!source || source.anchor.kind !== 'session' || source.said?.by === 'agent') throw new Error(`Invalid owner source: ${input.sourceId}`);
104
+ }
105
+ const path = projectFolderPath(project, input.path);
106
+ const commits = input.commits ?? true;
107
+ const authorization: Authorization = {
108
+ id: newId('auth'), projectId: project.id,
109
+ scope: `ProjectKeeper may maintain ${path} and ${commits ? 'make path-limited commits for that folder' : 'leave its changes uncommitted'}; it will not push or edit other project files.`,
110
+ sourceId: input.sourceId ?? '', quote: input.quote.trim(), at: now(), revokedAt: null,
111
+ projectFolder: { path, commits, lastWrite: null },
112
+ };
113
+ store.authorizations.put(authorization, { jobId: null, basisSourceIds: input.sourceId ? [input.sourceId] : [], summary: 'Owner authorized the ProjectKeeper project folder' });
114
+ return authorization;
115
+ }
116
+
117
+ function occurrence(when: Occurred): string {
118
+ const main = when.undated ? `Undated; first observed ${when.at}` : when.at;
119
+ // Spec §2.11: a second time the material gives is kept beside the first ("另记"), not judged a conflict.
120
+ const other = when.other ? `; also recorded ${when.other.at} (${when.other.basis}${when.other.anchor ? `, ${when.other.anchor}` : ''})` : '';
121
+ return `${main} (${when.basis}${when.anchor ? `, ${when.anchor}` : ''}${other})`;
122
+ }
123
+
124
+ function evidence(ref: EvidenceRef | null): string {
125
+ if (!ref) return 'Not recorded';
126
+ const line = ref.line ? `; original: ${inline(ref.line)}` : '';
127
+ return `${ref.label} [${ref.kind}: ${ref.id}]${line}`;
128
+ }
129
+
130
+ function patchEntry(patch: SemanticPatch): string {
131
+ return [
132
+ `## ${inline(patch.number)} · ${inline(patch.title)}`,
133
+ '',
134
+ `- **Workbench object:** \`${patch.id}\``,
135
+ `- **Status:** ${patch.status}${patch.partial ? ' · partial (the remainder stays current)' : ''}`,
136
+ `- **Occurred:** ${occurrence(patch.occurred)}`,
137
+ `- **Invalidated part:** ${block(patch.invalidated)}`,
138
+ `- **Replacement:** ${block(patch.replacedBy)}`,
139
+ `- **Affected objects:** ${patch.affects.length ? sortBy(patch.affects, (id) => id).map((id) => `\`${id}\``).join(', ') : 'None identified'}`,
140
+ `- **Affected in words:** ${block(patch.affectsText)}`,
141
+ `- **Must no longer pass as current:** ${block(patch.mustNotPassAsCurrent)}`,
142
+ `- **Old source:** ${evidence(patch.oldAnchor)}`,
143
+ `- **New source:** ${evidence(patch.newAnchor)}`,
144
+ `- **Replacing decision or commit:** ${evidence(patch.decision)}`,
145
+ `- **Explicit replacement record:** ${evidence(patch.candidate)}`,
146
+ '',
147
+ ].join('\n');
148
+ }
149
+
150
+ function ownerSource(s: Source): boolean {
151
+ return s.anchor.kind === 'session' && s.said?.by !== 'agent';
152
+ }
153
+
154
+ function ownerDecisions(store: ProjectStore): { item: ReferenceItem; sessions: Source[]; documents: Source[] }[] {
155
+ return store.reference.all().filter((item) => item.projectId === store.projectId && item.category === 'Decision').flatMap((item) => {
156
+ const sources = item.sourceIds.map((id) => store.sources.get(id)).filter((s): s is Source => s !== undefined);
157
+ const sessions = sources.filter(ownerSource);
158
+ if (!sessions.length || (item.attribution.author.kind !== 'owner' && !sessions.some((s) => s.said?.by === 'owner'))) return [];
159
+ const documents = sources.filter((s) => s.anchor.kind === 'file' || s.anchor.kind === 'revision');
160
+ return [{ item, sessions, documents }];
161
+ });
162
+ }
163
+
164
+ function numberProvenance(store: ProjectStore, objectId: string): string {
165
+ const reference = store.reference.get(objectId);
166
+ const thread = store.threads.get(objectId);
167
+ const patch = store.patches.get(objectId);
168
+ if (patch) return `${evidence(patch.candidate)}; numbered object \`${patch.id}\``;
169
+ const ids = reference?.sourceIds ?? thread?.inputs?.sourceIds ?? [];
170
+ const sources = ids.map((id) => store.sources.get(id)).filter((source): source is Source => source !== undefined);
171
+ return sources.length
172
+ ? sortBy(sources, (source) => source.id).map((source) => `${anchorLabel(source.anchor)} [source: ${source.id}]`).join('; ')
173
+ : `Workbench object \`${objectId}\`; the number record has no separate source anchor`;
174
+ }
175
+
176
+ /** Stable filenames and bytes for both the on-disk folder and its workbench rendering. */
177
+ export function renderProjectFolder(store: ProjectStore, project: Project): Readonly<Record<(typeof FILES)[number], string>> {
178
+ const patches = sortBy(store.patches.filter((p) => p.projectId === project.id && p.status !== 'Rejected'), (p) => `${p.occurred.at}\0${p.number}\0${p.id}`);
179
+ const numbers = sortBy(store.numbers.filter((n) => n.projectId === project.id && !(n.objectKind === 'patch' && store.patches.get(n.objectId)?.status === 'Rejected')), (n) => `${n.at}\0${n.number}\0${n.id}`);
180
+ const decisions = sortBy(ownerDecisions(store), ({ item, sessions }) => `${sessions.map((s) => s.anchor.kind === 'session' ? s.anchor.at ?? '' : '').sort()[0] ?? ''}\0${item.id}`);
181
+ const patchText = patches.map(patchEntry).join('\n');
182
+ const numberText = numbers.map((n) => [
183
+ `## ${inline(n.projectNumber ?? n.number)} · ${inline(n.objectKind)}`,
184
+ '',
185
+ `- **Workbench number object:** \`${n.id}\``,
186
+ `- **Object:** \`${n.objectId}\` (${n.objectKind})`,
187
+ `- **Keeper number:** \`${n.number}\`${n.projectNumber ? ' (alias)' : ' (current)'}`,
188
+ `- **Project number:** ${n.projectNumber ? `\`${n.projectNumber}\`` : 'Not assigned'}`,
189
+ `- **Occurred:** ${n.at}`,
190
+ `- **Source:** ${numberProvenance(store, n.objectId)}`,
191
+ '',
192
+ ].join('\n')).join('\n');
193
+ const decisionText = decisions.map(({ item, sessions, documents }) => {
194
+ const first = sortBy(sessions, (s) => `${s.anchor.kind === 'session' ? s.anchor.at ?? '\uffff' : '\uffff'}\0${s.id}`)[0]!;
195
+ const occurred = first.anchor.kind === 'session' ? first.anchor.at ?? 'Undated in session' : 'Undated in session';
196
+ const quote = item.quote ?? (first.said?.by === 'owner' ? first.excerpt.slice(first.said.wordsFrom) : null);
197
+ return [
198
+ `## ${inline(item.name || item.id)}`,
199
+ '',
200
+ `- **Workbench object:** \`${item.id}\``,
201
+ `- **Validity:** ${item.validity}`,
202
+ `- **Occurred:** ${occurred}`,
203
+ `- **Owner's words:** ${quote ? block(quote) : 'Original quote unavailable in this record'}`,
204
+ `- **Decision as recorded:** ${block(item.text)}`,
205
+ `- **Session source:** ${sortBy(sessions, (s) => s.id).map((s) => `${anchorLabel(s.anchor)} [source: ${s.id}]`).join('; ')}`,
206
+ `- **Also documented at:** ${documents.length ? sortBy(documents, (s) => s.id).map((s) => `${anchorLabel(s.anchor)} [source: ${s.id}]`).join('; ') : 'Not yet in a project document'}`,
207
+ '',
208
+ ].join('\n');
209
+ }).join('\n');
210
+ return {
211
+ 'README.md': `# ProjectKeeper records for ${inline(project.name)}\n\nThis folder is maintained by ProjectKeeper with the project owner's standing authorization. It contains the same identified records shown in the ProjectKeeper workbench; use the object IDs below to open the same entries there. Project documents outside this folder remain owned by the project.\n\n- [Semantic patches](semantic-patches.md): explicit changes to the meaning of project documents.\n- [Keeper numbers](keeper-numbers.md): IDs assigned where the project had none; project IDs take precedence.\n- [Owner decisions from sessions](owner-decisions.md): owner decisions first recorded in conversation, with links to later project documentation when available.\n`,
212
+ 'semantic-patches.md': `# Semantic patches\n\n${patchText || 'No non-rejected semantic patches are recorded.\n'}`,
213
+ 'keeper-numbers.md': `# Keeper numbers\n\n${numberText || 'No Keeper numbers are recorded.\n'}`,
214
+ 'owner-decisions.md': `# Owner decisions from sessions\n\n${decisionText || 'No owner decisions with a session source are recorded.\n'}`,
215
+ };
216
+ }
217
+
218
+ export interface ProjectFolderSyncResult {
219
+ readonly status: 'not-authorized' | 'unchanged' | 'written';
220
+ readonly path: string | null;
221
+ readonly changedFiles: readonly string[];
222
+ readonly commit: string | null;
223
+ readonly reason: string | null;
224
+ }
225
+
226
+ /** Unlike util/git.ts, these two operations must take git's index lock and write a commit. */
227
+ /**
228
+ * The project's own git hooks never run for the Keeper's commits (QC AW-2): a pre-commit or commit-msg hook can change
229
+ * files outside the folder, and a post-commit hook can push, which would break "only this folder, never pushed" (D89).
230
+ * `--no-verify` is not enough (prepare-commit-msg and post-commit still run), so each write points `core.hooksPath` at
231
+ * an empty directory of its own, for that one command; the repository's configuration is never written.
232
+ */
233
+ let noHooksDir: string | null = null;
234
+ const noHooks = (): string => (noHooksDir ??= mkdtempSync(join(tmpdir(), 'pk-no-hooks-')));
235
+
236
+ function gitWrite(cwd: string, args: readonly string[], env: NodeJS.ProcessEnv = {}): GitResult {
237
+ for (let attempt = 0; attempt < 4; attempt++) {
238
+ try {
239
+ const out = execFileSync('git', ['--no-pager', '-c', 'core.fsmonitor=false', '-c', `core.hooksPath=${noHooks()}`, ...LONG_PATHS, '-C', cwd, ...args], {
240
+ encoding: 'utf8', env: { ...process.env, GIT_OPTIONAL_LOCKS: '1', GIT_TERMINAL_PROMPT: '0', ...env },
241
+ timeout: 20_000, maxBuffer: 16_000_000, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
242
+ });
243
+ return { ok: true, out, err: '' };
244
+ } catch (error) {
245
+ const e = error as { stdout?: string; stderr?: string; message?: string };
246
+ const result = { ok: false, out: e.stdout ?? '', err: (e.stderr || e.message || String(error)).trim() };
247
+ if (!/index\.lock/i.test(result.err) || attempt === 3) return result;
248
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 150 * (attempt + 1));
249
+ }
250
+ }
251
+ return { ok: false, out: '', err: 'Git write retry exhausted' };
252
+ }
253
+
254
+ /** The committer of the Keeper's commit: the repository's own identity when git has one (nothing is passed), the Keeper otherwise. */
255
+ function committerEnv(repo: string): NodeJS.ProcessEnv {
256
+ return git(repo, ['var', 'GIT_COMMITTER_IDENT']).ok ? {} : { GIT_COMMITTER_NAME: KEEPER_NAME, GIT_COMMITTER_EMAIL: KEEPER_EMAIL };
257
+ }
258
+
259
+ function recordIssue(store: ProjectStore, authorizationId: string, message: string): void {
260
+ store.trace({ collection: 'authorizations', id: authorizationId, op: 'put', jobId: null, summary: `Project folder sync: ${message}` });
261
+ }
262
+
263
+ /** Write only the four managed files, then optionally make a commit whose pathspec is only this folder. */
264
+ export function syncProjectFolder(store: ProjectStore, project: Project): ProjectFolderSyncResult {
265
+ const authorization = sortBy(store.authorizations.filter((a) => a.projectFolder != null && !a.revokedAt), (a) => a.at).at(-1);
266
+ if (!authorization?.projectFolder) return { status: 'not-authorized', path: null, changedFiles: [], commit: null, reason: null };
267
+ const folder = projectFolderPath(project, authorization.projectFolder.path);
268
+ const rendered = renderProjectFolder(store, project);
269
+ const root = canonicalPath(project.locations[0]!);
270
+ if (!inside(root, folder)) throw new Error('Project folder escaped the project root');
271
+ mkdirSync(folder, { recursive: true });
272
+ if (!inside(root, realpathSync(folder))) throw new Error('Project folder escaped through a link');
273
+ const changedFiles: string[] = [];
274
+ for (const name of FILES) {
275
+ const target = join(folder, name);
276
+ const stat = statIfPresent(target);
277
+ if (stat && (stat.isSymbolicLink() || !stat.isFile())) throw new Error(`Unsafe project folder file: ${target}`);
278
+ if (stat && readFileSync(target, 'utf8') === rendered[name]) continue;
279
+ writeFileSync(target, rendered[name], 'utf8');
280
+ changedFiles.push(name);
281
+ }
282
+
283
+ let commit: string | null = null;
284
+ let reason: string | null = null;
285
+ if (authorization.projectFolder.commits) {
286
+ const repo = gitToplevel(root);
287
+ // A repository git cannot open because its path is too long is not "no repository": the reason says which it is.
288
+ const tooLong = repo ? null : gitPathLimit(root);
289
+ if (tooLong) reason = `The project folder's files were written without a commit. ${tooLong}`;
290
+ else if (!repo || !inside(repo, folder)) reason = 'The project folder is not in a git repository; files were written without a commit.';
291
+ else {
292
+ const pathspec = relative(repo, folder).split(sep).join('/');
293
+ const status = git(repo, ['status', '--porcelain', '--untracked-files=all', '--', pathspec]);
294
+ if (!status.ok) reason = `Could not inspect git status: ${status.err}`;
295
+ else if (!status.out.trim() && changedFiles.length) reason = 'Git did not see the written files; the folder may be ignored.';
296
+ else if (status.out.trim()) {
297
+ const added = gitWrite(repo, ['add', '--', pathspec]);
298
+ if (!added.ok) reason = `Git add failed: ${added.err}`;
299
+ else {
300
+ const staged = git(repo, ['diff', '--cached', '--name-only', '--', pathspec]);
301
+ if (!staged.ok) reason = `Could not inspect staged folder changes: ${staged.err}`;
302
+ else if (staged.out.trim()) {
303
+ const summary = changedFiles.length ? changedFiles.join(', ') : staged.out.trim().split(/\r?\n/).map((path) => basename(path)).join(', ');
304
+ const committed = gitWrite(repo, ['commit', `--author=${KEEPER_AUTHOR}`, '-m', `ProjectKeeper: update ${summary}`, '--', pathspec], committerEnv(repo));
305
+ if (!committed.ok) reason = `Git commit failed: ${committed.err}`;
306
+ else {
307
+ const head = git(repo, ['rev-parse', 'HEAD']);
308
+ commit = head.ok ? head.out.trim() : null;
309
+ if (!commit) reason = `Commit succeeded but its hash could not be read: ${head.err}`;
310
+ }
311
+ }
312
+ }
313
+ }
314
+ }
315
+ }
316
+ if (reason) recordIssue(store, authorization.id, reason);
317
+ if (changedFiles.length || commit) {
318
+ const at = changedFiles.length ? now() : authorization.projectFolder.lastWrite?.at ?? now();
319
+ store.authorizations.put({ ...authorization, projectFolder: { ...authorization.projectFolder, lastWrite: { at, commit } } }, {
320
+ jobId: null, summary: `ProjectKeeper project folder ${changedFiles.length ? 'written' : 'committed'}${commit ? ` at ${commit}` : ''}`,
321
+ });
322
+ for (const patch of store.patches.filter((p) => p.projectId === project.id && p.status !== 'Rejected')) {
323
+ if (patch.writtenToFolder?.path === folder && patch.writtenToFolder.commit === commit && !changedFiles.length) continue;
324
+ store.patches.put({ ...patch, writtenToFolder: { path: folder, commit, at } });
325
+ }
326
+ }
327
+ for (const patch of store.patches.filter((p) => p.projectId === project.id && p.status === 'Rejected' && p.writtenToFolder?.path === folder)) {
328
+ store.patches.put({ ...patch, writtenToFolder: null });
329
+ }
330
+ return {
331
+ status: changedFiles.length || commit ? 'written' : 'unchanged', path: folder, changedFiles, commit,
332
+ reason,
333
+ };
334
+ }