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,231 @@
1
+ /**
2
+ * Old content from version history (Spec §1.2 `History only`, §3.1; CKC-03 AC-24; subagent/DECISIONS.md E64, E65).
3
+ *
4
+ * The Keeper can list the history of a path, read a file as it was at any commit — a deleted file, an old version of a
5
+ * plan, an old file of a worktree branch already merged — and list the documents main's history deleted. What it reads
6
+ * becomes a source whose `Used as` is `History only`, anchored at repository, commit and path. Deletion means "no
7
+ * longer needed" (D61), so that source has three uses only: marking what current material has outdated, cross-checking
8
+ * current documents, and producing the history when it is traced. It forms no current node, enters no context, and is
9
+ * never material waiting to be organized (the checks live with the writes and the coverage; `isHistoryOnly` is their
10
+ * one test).
11
+ *
12
+ * Only read-only git runs here, against the repositories in the project's scope.
13
+ */
14
+ import { existsSync } from 'node:fs';
15
+ import { isAbsolute, join, relative } from 'node:path';
16
+ import type { Project, ScopeItem, Source } from '../model/types.ts';
17
+ import { fingerprint } from '../model/ids.ts';
18
+ import type { ProjectStore } from '../store/project-store.ts';
19
+ import {
20
+ gitCommitMeta, gitDeletedPaths, gitHashWorkingFile, gitPathHistory, gitResolveCommit, gitShow, gitTrackedPaths,
21
+ gitTreeEntry, gitUncommittedDeletions, isSafeRef, type CommitMeta,
22
+ } from '../util/git.ts';
23
+ import { isWithin, nameForm, normalizePath, samePath } from '../util/paths.ts';
24
+ import { extractIds, redactCredentials, revisionSourceId } from './anchor.ts';
25
+
26
+ /** Material that exists only in history: read from version history, or judged `History only` (a recovery-only place). */
27
+ export function isHistoryOnly(source: Source): boolean {
28
+ return source.anchor.kind === 'revision' || source.usedAs === 'History only';
29
+ }
30
+
31
+ /** Same bound as a project file read into sources (sources/files.ts). */
32
+ export const MAX_REVISION_BYTES = 2_000_000;
33
+
34
+ /** Documents: what the intent material of a project is written in (design, plan, decision, review). */
35
+ const DOC_RE = /\.(md|markdown|mdx|txt|text|rst|adoc|asciidoc|org|textile|wiki|html?)$/i;
36
+ export const isDocumentPath = (path: string): boolean => DOC_RE.test(path);
37
+
38
+ export interface HistoryTarget {
39
+ readonly item: ScopeItem;
40
+ /** Where git runs: the scope item's own checkout. */
41
+ readonly root: string;
42
+ /** The repository whose history it is: a worktree of the main repository shares the main repository's. */
43
+ readonly repo: string;
44
+ /** Relative to the root, forward slashes; '' for the root itself. */
45
+ readonly rel: string;
46
+ }
47
+
48
+ const repoItems = (project: Project): ScopeItem[] =>
49
+ project.scope.filter((i) => (i.category === 'Repository' || i.category === 'Worktree') && i.relation !== 'Excluded' && !i.missing);
50
+
51
+ /**
52
+ * Which repository and path a call means: `repo` names a scope item by id or path; an absolute path picks the
53
+ * repository that holds it; otherwise the main repository. A path that leaves the repository is refused.
54
+ */
55
+ export function resolveTarget(project: Project, input: { readonly path?: string; readonly repo?: string }): HistoryTarget | string {
56
+ const repos = repoItems(project);
57
+ if (repos.length === 0) return 'This project has no repository in its scope, so there is no version history to read.';
58
+ const raw = (input.path ?? '').trim();
59
+ let item: ScopeItem | undefined;
60
+ const repoArg = (input.repo ?? '').trim();
61
+ if (repoArg) {
62
+ item = repos.find((i) => i.id === repoArg) ?? repos.find((i) => samePath(i.path, repoArg));
63
+ if (!item) return `${repoArg} is not a repository or worktree in this project’s scope. Use one of: ${repos.map((i) => `${i.id} (${i.path})`).join(', ')}.`;
64
+ } else if (raw && isAbsolute(raw)) {
65
+ item = repos.filter((i) => isWithin(i.path, raw)).sort((a, b) => b.path.length - a.path.length)[0];
66
+ if (!item) return `${raw} is not inside a repository of this project’s scope.`;
67
+ } else {
68
+ item = repos.find((i) => i.relation === 'Main project' && i.category === 'Repository') ?? repos.find((i) => i.category === 'Repository') ?? repos[0]!;
69
+ }
70
+ const root = item.path;
71
+ let rel = raw;
72
+ if (raw && isAbsolute(raw)) {
73
+ if (!isWithin(root, raw)) return `${raw} is not inside ${root}.`;
74
+ rel = relative(normalizePath(root), normalizePath(raw));
75
+ }
76
+ // In the form names are kept in: a path copied from a directory listing on a Mac is in another one than git's.
77
+ rel = nameForm(rel).split('\\').join('/').replace(/^\.\/+/, '').replace(/\/+$/, '');
78
+ if (rel === '.') rel = '';
79
+ if (rel.split('/').some((seg) => seg === '..') || /^[A-Za-z]:/.test(rel) || rel.startsWith('/')) return `${raw} leaves the repository; give a path inside ${root}.`;
80
+ const repo = item.category === 'Worktree' && item.relation === 'Worktree of main repo' && item.worktreeOf ? item.worktreeOf : root;
81
+ return { item, root, repo, rel };
82
+ }
83
+
84
+ export type Change = 'Added' | 'Modified' | 'Deleted' | 'Renamed' | 'Copied' | 'Type changed';
85
+ const CHANGE: Record<string, Change> = { A: 'Added', M: 'Modified', D: 'Deleted', R: 'Renamed', C: 'Copied', T: 'Type changed' };
86
+
87
+ export interface HistoryRow {
88
+ readonly commit: string;
89
+ readonly at: string;
90
+ readonly author: string;
91
+ readonly subject: string;
92
+ readonly change: Change;
93
+ readonly path: string;
94
+ readonly from?: string;
95
+ readonly to?: string;
96
+ /** The commit whose tree holds this version: the commit itself, or for a deletion the last commit that had it. */
97
+ readonly readAt: string | null;
98
+ }
99
+
100
+ /**
101
+ * Every commit that touched a path (for a directory: anything under it), newest first, a page at a time: `limit` commits
102
+ * (default 100) after the first `offset`. Paging reaches the whole history (D77: no recent-N cap).
103
+ */
104
+ export function pathHistory(project: Project, input: { readonly path: string; readonly repo?: string; readonly limit?: number; readonly offset?: number }): { target: HistoryTarget; rows: HistoryRow[]; more: boolean; offset: number; next: number | null } | string {
105
+ const target = resolveTarget(project, input);
106
+ if (typeof target === 'string') return target;
107
+ if (!target.rel) return 'Give a path inside the repository (a file or a directory).';
108
+ const limit = Math.max(1, Math.floor(Number(input.limit) || 100));
109
+ const offset = Math.max(0, Math.floor(Number(input.offset) || 0));
110
+ const commits = gitPathHistory(target.root, target.rel, limit + 1, 'HEAD', offset);
111
+ if (!commits) return `git could not read the history of ${target.rel} in ${target.root}.`;
112
+ const rows: HistoryRow[] = [];
113
+ for (const c of commits.slice(0, limit)) {
114
+ for (const ch of c.changes) {
115
+ const renamedAway = (ch.status === 'R' || ch.status === 'C') && ch.from !== null && (ch.from === target.rel || ch.from.startsWith(`${target.rel}/`)) && !(ch.path === target.rel || ch.path.startsWith(`${target.rel}/`));
116
+ rows.push({
117
+ commit: c.hash, at: c.at, author: c.author, subject: c.subject, change: CHANGE[ch.status]!,
118
+ path: renamedAway ? ch.from! : ch.path,
119
+ ...(ch.status === 'R' || ch.status === 'C' ? (renamedAway ? { to: ch.path } : { from: ch.from! }) : {}),
120
+ readAt: ch.status === 'D' || renamedAway ? (c.parents[0] ?? null) : c.hash,
121
+ });
122
+ }
123
+ }
124
+ const more = commits.length > limit;
125
+ return { target, rows, more, offset, next: more ? offset + limit : null };
126
+ }
127
+
128
+ export type RevisionRead =
129
+ | { readonly kind: 'history'; readonly source: Source; readonly target: HistoryTarget; readonly meta: CommitMeta | null; readonly text: string; readonly nowAtPath: 'Changed since' | 'Not at this path now' }
130
+ | { readonly kind: 'current'; readonly target: HistoryTarget; readonly commit: string; readonly file: string; readonly sourceIds: readonly string[] };
131
+
132
+ /**
133
+ * A file as it was at a commit. When it is exactly what the project has at that path now, it is current material and
134
+ * nothing is recorded as history; otherwise it becomes (or refreshes) the `History only` source of that repository,
135
+ * commit and path. The refusals say what to do instead: a deleted file is read at the last commit that had it.
136
+ */
137
+ export function readRevision(store: ProjectStore, project: Project, input: { readonly path: string; readonly commit: string; readonly repo?: string }): RevisionRead | string {
138
+ const target = resolveTarget(project, input);
139
+ if (typeof target === 'string') return target;
140
+ if (!target.rel) return 'Give the path of a file inside the repository.';
141
+ const ref = (input.commit ?? '').trim();
142
+ if (!isSafeRef(ref)) return `“${ref}” is not a commit: give a commit hash, a branch or tag name, or HEAD (optionally with ~n or ^); anything shaped like an option is refused, because history is read with read-only commands only.`;
143
+ const commit = gitResolveCommit(target.root, ref);
144
+ if (!commit) return `${ref} names no commit in ${target.root}. pk_history_log lists the commits that touched ${target.rel}.`;
145
+ const entry = gitTreeEntry(target.root, commit, target.rel);
146
+ if (!entry) {
147
+ const touched = gitPathHistory(target.root, target.rel, 1, commit)?.[0];
148
+ const gone = touched?.changes.some((c) => c.status === 'D' || (c.status === 'R' && c.from === target.rel));
149
+ if (touched && gone && touched.parents[0]) {
150
+ return `${target.rel} is not in ${commit.slice(0, 10)}: it was deleted (or moved away) in ${touched.hash.slice(0, 10)} (“${touched.subject}”), and its last version is at ${touched.parents[0].slice(0, 10)}. Read it there: commit "${touched.parents[0]}".`;
151
+ }
152
+ if (touched) return `${target.rel} is not a file in ${commit.slice(0, 10)}; the last commit up to it that touched the path is ${touched.hash.slice(0, 10)} (“${touched.subject}”). pk_history_log lists its versions.`;
153
+ return `${target.rel} is not in ${commit.slice(0, 10)}, and no commit up to it ever touched that path.`;
154
+ }
155
+ if (entry.type !== 'blob') return `${target.rel} is a ${entry.type === 'tree' ? 'directory' : 'submodule'} at ${commit.slice(0, 10)}; read one file at a time (pk_history_log lists what changed under a directory).`;
156
+ if (entry.size !== null && entry.size > MAX_REVISION_BYTES) return `${target.rel} is ${entry.size} bytes at ${commit.slice(0, 10)}, larger than the ${MAX_REVISION_BYTES} bytes a source holds.`;
157
+ const file = join(target.root, ...target.rel.split('/'));
158
+ if (existsSync(file) && gitHashWorkingFile(target.root, file, target.rel) === entry.id) {
159
+ const sourceIds = store.sources.filter((s) => s.anchor.kind === 'file' && samePath(s.anchor.path, file) && s.availability === null).map((s) => s.id);
160
+ return { kind: 'current', target, commit, file, sourceIds };
161
+ }
162
+ const text = gitShow(target.root, commit, target.rel);
163
+ if (text === null) return `git could not read ${target.rel} at ${commit.slice(0, 10)}.`;
164
+ if (text.slice(0, 8000).includes('\0')) return `${target.rel} at ${commit.slice(0, 10)} is not a text file.`;
165
+ const meta = gitCommitMeta(target.root, commit);
166
+ const red = redactCredentials(text);
167
+ const id = revisionSourceId(target.repo, commit, target.rel);
168
+ const previous = store.sources.get(id);
169
+ const name = target.rel.split('/').pop()!;
170
+ const source: Source = {
171
+ id, projectId: project.id, title: `${name} @ ${commit.slice(0, 8)}${meta ? ` · ${meta.at.slice(0, 10)}` : ''}`,
172
+ anchor: { kind: 'revision', repo: target.repo, commit, path: target.rel }, ids: extractIds(text),
173
+ version: { fingerprint: fingerprint(text), readAt: previous?.version.readAt ?? new Date().toISOString(), commit }, excerpt: red.text,
174
+ usedAs: 'History only', usedAsBy: 'keeper', usedAsByRuleId: null, availability: null, movedTo: null,
175
+ scopeItemId: target.item.id, hasCredential: red.found, bytes: Buffer.byteLength(text),
176
+ };
177
+ return { kind: 'history', source, target, meta, text: red.text, nowAtPath: existsSync(file) ? 'Changed since' : 'Not at this path now' };
178
+ }
179
+
180
+ export interface DeletedDocument {
181
+ readonly path: string;
182
+ readonly deletedIn: { readonly commit: string; readonly at: string; readonly author: string; readonly subject: string } | 'uncommitted';
183
+ readonly lastPresentIn: string | null;
184
+ }
185
+ export interface DeletedDirectory {
186
+ readonly dir: string;
187
+ readonly count: number;
188
+ readonly lastDeletedAt: string | null;
189
+ readonly files: readonly DeletedDocument[];
190
+ }
191
+
192
+ /**
193
+ * What main's history deleted and the current version does not have again, grouped by directory, each with the commit
194
+ * that deleted it and the last commit it appears in. Documents only unless `all`; a rename is not a deletion; a
195
+ * deletion in the working tree not committed yet is listed as such.
196
+ */
197
+ export function deletedDocuments(project: Project, input: { readonly repo?: string; readonly dir?: string; readonly all?: boolean }): { target: HistoryTarget; head: string | null; directories: DeletedDirectory[]; total: number } | string {
198
+ const target = resolveTarget(project, { repo: input.repo, path: input.dir });
199
+ if (typeof target === 'string') return target;
200
+ const head = gitResolveCommit(target.root, 'HEAD');
201
+ if (!head) return `${target.root} has no commit yet.`;
202
+ const deleted = gitDeletedPaths(target.root, 'HEAD', target.rel || null);
203
+ if (!deleted) return `git could not read the deletions in ${target.root}.`;
204
+ const present = gitTrackedPaths(target.root, 'HEAD', target.rel || null) ?? new Set<string>();
205
+ const docs: DeletedDocument[] = [];
206
+ const keep = (path: string) => (input.all === true || isDocumentPath(path)) && !present.has(path) && !existsSync(join(target.root, ...path.split('/')));
207
+ for (const d of deleted) {
208
+ if (!keep(d.path)) continue;
209
+ docs.push({ path: d.path, deletedIn: { commit: d.deletedIn.hash, at: d.deletedIn.at, author: d.deletedIn.author, subject: d.deletedIn.subject }, lastPresentIn: d.deletedIn.parents[0] ?? null });
210
+ }
211
+ const listed = new Set(docs.map((d) => d.path));
212
+ for (const path of gitUncommittedDeletions(target.root)) {
213
+ if (listed.has(path) || !(input.all === true || isDocumentPath(path))) continue;
214
+ if (target.rel && !(path === target.rel || path.startsWith(`${target.rel}/`))) continue;
215
+ docs.push({ path, deletedIn: 'uncommitted', lastPresentIn: head });
216
+ }
217
+ const byDir = new Map<string, DeletedDocument[]>();
218
+ for (const d of docs) {
219
+ const dir = d.path.includes('/') ? d.path.slice(0, d.path.lastIndexOf('/')) : '.';
220
+ const list = byDir.get(dir) ?? [];
221
+ list.push(d);
222
+ byDir.set(dir, list);
223
+ }
224
+ const atOf = (d: DeletedDocument) => (d.deletedIn === 'uncommitted' ? '9999' : d.deletedIn.at);
225
+ const directories = [...byDir].map(([dir, files]) => {
226
+ const sorted = [...files].sort((a, b) => atOf(b).localeCompare(atOf(a)) || a.path.localeCompare(b.path));
227
+ const dated = sorted.map((f) => (f.deletedIn === 'uncommitted' ? null : f.deletedIn.at)).filter((x): x is string => x !== null);
228
+ return { dir, count: files.length, lastDeletedAt: dated[0] ?? null, files: sorted };
229
+ }).sort((a, b) => b.count - a.count || a.dir.localeCompare(b.dir));
230
+ return { target, head, directories, total: docs.length };
231
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Worker-thread entry for `scanFilesAsync`: reads every file in scope off the main thread so the
3
+ * workbench keeps answering while a large project is taken in.
4
+ */
5
+ import { parentPort, workerData } from 'node:worker_threads';
6
+ import type { ScopeItem } from '../model/types.ts';
7
+ import { scanFiles } from './files.ts';
8
+
9
+ const data = workerData as { projectId: string; scope: readonly ScopeItem[] };
10
+ parentPort!.postMessage(scanFiles(data.projectId, data.scope));
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Locate native session logs that belong to a set of working directories.
3
+ *
4
+ * Claude Code: `~/.claude/projects/<encoded cwd>/<sessionId>.jsonl` (the directory name is
5
+ * the encoding of the cwd; sub-agent transcripts live in a same-named subdirectory).
6
+ * Codex: `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`; the first line (`session_meta`) carries `cwd`. More Codex
7
+ * homes (a second account kept in `~/.codex-2`, say) are named in `PROJECTKEEPER_CODEX_HOMES`.
8
+ *
9
+ * Reading is by peeking at the head of each file; results are cached per file identity so a
10
+ * rescan only touches new or changed files. Nothing here writes anywhere.
11
+ *
12
+ * Both hosts record the working directory in the spelling the agent ran under — through a junction or a link, a
13
+ * `subst` drive, an 8.3 short name, another case — while the directories asked for are in the file system's own
14
+ * (util/paths.ts `canonicalPath`). A log belongs to a directory asked for when the two are one directory by the file
15
+ * system's judgement (`directoryKey`), not only when they are one text. For Claude Code that means looking past the
16
+ * folder named after the directory: the head of one log in each other folder is read once for the directory it
17
+ * records, and nothing else of it is kept.
18
+ */
19
+ import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { basename, delimiter, join, resolve } from 'node:path';
22
+ import type { SessionHost } from '../../model/vocab.ts';
23
+ import { claudeProjectDirName, directoryKey, normalizePath, pathKey } from '../../util/paths.ts';
24
+
25
+ export interface LocatedSession {
26
+ readonly host: SessionHost;
27
+ readonly file: string;
28
+ /** The working directory as the log records it, in the spelling the agent ran under; the directory asked for when a Claude Code log names none. */
29
+ readonly cwd: string | null;
30
+ /** The one of the directories asked for that `cwd` is — the same text, or another spelling of that directory; null when it is none of them. */
31
+ readonly matchedCwd: string | null;
32
+ readonly sessionId: string;
33
+ readonly bytes: number;
34
+ readonly mtimeMs: number;
35
+ readonly isSubagent: boolean;
36
+ readonly home: string; // which host home it came from
37
+ }
38
+
39
+ const PEEK_BYTES = 8192;
40
+ const peekCache = new Map<string, { size: number; mtimeMs: number; cwd: string | null; sessionId: string | null; sub: boolean }>();
41
+ export interface CodexSessionHeader {
42
+ readonly cwd: string;
43
+ readonly sessionId: string | null;
44
+ readonly sub: boolean;
45
+ }
46
+ const codexHeaderCache = new Map<string, { size: number; mtimeMs: number; header: CodexSessionHeader | null }>();
47
+
48
+ function peek(file: string): { cwd: string | null; sessionId: string | null; sub: boolean } {
49
+ const st = statSync(file);
50
+ const hit = peekCache.get(file);
51
+ if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) return hit;
52
+ let fd: number | null = null;
53
+ let head = '';
54
+ try {
55
+ fd = openSync(file, 'r');
56
+ const buf = Buffer.alloc(PEEK_BYTES);
57
+ const n = readSync(fd, buf, 0, PEEK_BYTES, 0);
58
+ head = buf.subarray(0, n).toString('utf8');
59
+ } catch {
60
+ head = '';
61
+ } finally {
62
+ if (fd !== null) closeSync(fd);
63
+ }
64
+ const cwdMatch = /"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(head);
65
+ const idMatch = /"session_id"\s*:\s*"([^"]+)"/.exec(head) ?? /"sessionId"\s*:\s*"([^"]+)"/.exec(head);
66
+ let cwd: string | null = null;
67
+ try { cwd = cwdMatch?.[1] ? (JSON.parse(`"${cwdMatch[1]}"`) as string) : null; } catch { cwd = null; }
68
+ const sub = /"thread_source"\s*:\s*"subagent"/.test(head) || /"isSidechain"\s*:\s*true/.test(head);
69
+ const entry = { size: st.size, mtimeMs: st.mtimeMs, cwd, sessionId: idMatch?.[1] ?? null, sub };
70
+ peekCache.set(file, entry);
71
+ return entry;
72
+ }
73
+
74
+ /**
75
+ * Read only a Codex log's first record. Ownership comes from the `session_meta` header; a `cwd` appearing later in an
76
+ * unrelated session's body is never considered. The fixed-size peek also keeps live watching from opening unrelated
77
+ * conversation content merely because some other project changed it.
78
+ */
79
+ export function readCodexSessionHeader(file: string): CodexSessionHeader | null {
80
+ let st;
81
+ try { st = statSync(file); } catch { return null; }
82
+ if (!st.isFile()) return null;
83
+ const hit = codexHeaderCache.get(file);
84
+ if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) return hit.header;
85
+
86
+ let fd: number | null = null;
87
+ let head = '';
88
+ try {
89
+ fd = openSync(file, 'r');
90
+ const buf = Buffer.alloc(PEEK_BYTES);
91
+ const n = readSync(fd, buf, 0, PEEK_BYTES, 0);
92
+ head = buf.subarray(0, n).toString('utf8');
93
+ } catch {
94
+ head = '';
95
+ } finally {
96
+ if (fd !== null) closeSync(fd);
97
+ }
98
+
99
+ // Even when a very large session_meta line exceeds PEEK_BYTES, its type, id and cwd are at the front. When the
100
+ // first line is short, cut before the body so a later cwd cannot be mistaken for the header's.
101
+ const end = head.search(/[\r\n]/);
102
+ const first = end >= 0 ? head.slice(0, end) : head;
103
+ let header: CodexSessionHeader | null = null;
104
+ if (/"type"\s*:\s*"session_meta"/.test(first)) {
105
+ const cwdMatch = /"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(first);
106
+ const idMatch = /"id"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/.exec(first)
107
+ ?? /"session_id"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/.exec(first);
108
+ let cwd: string | null = null;
109
+ let sessionId: string | null = null;
110
+ try { cwd = cwdMatch?.[1] ? (JSON.parse(`"${cwdMatch[1]}"`) as string) : null; } catch { cwd = null; }
111
+ try { sessionId = idMatch?.[1] ? (JSON.parse(`"${idMatch[1]}"`) as string) : null; } catch { sessionId = null; }
112
+ if (cwd) header = { cwd, sessionId, sub: /"thread_source"\s*:\s*"subagent"/.test(first) };
113
+ }
114
+ codexHeaderCache.set(file, { size: st.size, mtimeMs: st.mtimeMs, header });
115
+ return header;
116
+ }
117
+
118
+ /**
119
+ * For a set of directories asked for: which of them a directory a log records is, or null. The text is compared first
120
+ * (case and slashes as the system allows); a directory spelled otherwise is asked of the file system, once. Most of
121
+ * the directories an agent's history records are gone (worktrees, mostly), and one that is gone is rarely worth
122
+ * asking about: it cannot be another spelling of a directory that is there, and of one that is gone too only under the
123
+ * same last name, which is compared as text either way.
124
+ */
125
+ export function cwdMatcher(cwds: readonly string[]): (recorded: string | null) => string | null {
126
+ const byText = new Map<string, string>();
127
+ for (const cwd of cwds) if (!byText.has(pathKey(cwd))) byText.set(pathKey(cwd), normalizePath(cwd));
128
+ let byDirectory: Map<string, string> | null = null;
129
+ const goneNames = new Set<string>();
130
+ const asked = new Map<string, string | null>();
131
+ return (recorded) => {
132
+ if (!recorded) return null;
133
+ const key = pathKey(recorded);
134
+ const text = byText.get(key);
135
+ if (text !== undefined) return text;
136
+ if (asked.has(key)) return asked.get(key)!;
137
+ if (!byDirectory) {
138
+ byDirectory = new Map();
139
+ for (const [cwdKey, cwd] of byText) {
140
+ if (!byDirectory.has(directoryKey(cwd))) byDirectory.set(directoryKey(cwd), cwd);
141
+ if (!existsSync(cwd)) goneNames.add(basename(cwdKey));
142
+ }
143
+ }
144
+ const worthAsking = existsSync(recorded) || goneNames.has(basename(key));
145
+ const hit = worthAsking ? byDirectory.get(directoryKey(recorded)) ?? null : null;
146
+ asked.set(key, hit);
147
+ return hit;
148
+ };
149
+ }
150
+
151
+ export function claudeProjectsRoot(home = homedir()): string {
152
+ return join(home, '.claude', 'projects');
153
+ }
154
+
155
+ /**
156
+ * The Codex homes under a user home: `.codex`, then every directory named in `PROJECTKEEPER_CODEX_HOMES` (separated like
157
+ * `PATH`; a name is taken under the user home, so a frozen copy of a home is read the same way; an absolute path as it is).
158
+ */
159
+ export function codexHomes(home = homedir(), extra = process.env.PROJECTKEEPER_CODEX_HOMES ?? ''): string[] {
160
+ const named = extra.split(delimiter).map((d) => d.trim()).filter((d) => d !== '');
161
+ return [...new Set([join(home, '.codex'), ...named.map((d) => resolve(home, d))])];
162
+ }
163
+
164
+ export function codexSessionRoots(home = homedir()): string[] {
165
+ return codexHomes(home).map((d) => join(d, 'sessions')).filter((p) => existsSync(p));
166
+ }
167
+
168
+ /** How many logs of a Claude Code folder are asked which directory the folder is for, before it is passed over. */
169
+ const FOLDER_PEEKS = 8;
170
+ /** The directory each Claude Code folder is for, once one of its logs has said: a folder is named after it, so it stays. */
171
+ const folderCwdCache = new Map<string, string>();
172
+
173
+ /**
174
+ * The directory a Claude Code folder holds the sessions of, in the spelling its logs record. The folder's name is that
175
+ * directory's, encoded, so a log that records a directory with another name does not speak for the folder.
176
+ */
177
+ function folderCwd(folder: string, name: string): string | null {
178
+ const known = folderCwdCache.get(folder);
179
+ if (known !== undefined) return known;
180
+ let entries: string[] = [];
181
+ try { entries = readdirSync(folder); } catch { return null; }
182
+ for (const entry of entries.filter((e) => e.endsWith('.jsonl')).slice(0, FOLDER_PEEKS)) {
183
+ let cwd: string | null = null;
184
+ try { cwd = peek(join(folder, entry)).cwd; } catch { continue; }
185
+ if (cwd && claudeProjectDirName(cwd).toLowerCase() === name.toLowerCase()) { folderCwdCache.set(folder, cwd); return cwd; }
186
+ }
187
+ return null;
188
+ }
189
+
190
+ /**
191
+ * The folders under a home's `.claude/projects` that hold the sessions of the given working directories, each with
192
+ * the directory it is for: the folder named after the directory, and any folder named after another spelling of it.
193
+ */
194
+ export function claudeSessionFolders(cwds: readonly string[], home = homedir()): { readonly dir: string; readonly cwd: string }[] {
195
+ const root = claudeProjectsRoot(home);
196
+ let names: string[] = [];
197
+ try { names = readdirSync(root); } catch { return []; }
198
+ const named = new Map<string, string>();
199
+ for (const cwd of cwds) named.set(claudeProjectDirName(cwd).toLowerCase(), normalizePath(cwd));
200
+ const matched = cwdMatcher(cwds);
201
+ const out: { dir: string; cwd: string }[] = [];
202
+ for (const name of names) {
203
+ const dir = join(root, name);
204
+ const cwd = named.get(name.toLowerCase()) ?? matched(folderCwd(dir, name));
205
+ if (cwd) out.push({ dir, cwd });
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /** Claude Code sessions for the given working directories (the directory itself, under any spelling; not what lies in it). */
211
+ export function locateClaudeSessions(cwds: readonly string[], home = homedir()): LocatedSession[] {
212
+ const root = claudeProjectsRoot(home);
213
+ const matched = cwdMatcher(cwds);
214
+ const out: LocatedSession[] = [];
215
+ for (const { dir, cwd } of claudeSessionFolders(cwds, home)) {
216
+ let entries: string[] = [];
217
+ try { entries = readdirSync(dir); } catch { continue; }
218
+ for (const name of entries) {
219
+ if (!name.endsWith('.jsonl')) continue;
220
+ const file = join(dir, name);
221
+ let st;
222
+ try { st = statSync(file); } catch { continue; }
223
+ if (!st.isFile()) continue;
224
+ const info = peek(file);
225
+ out.push({
226
+ host: 'claude', file, cwd: info.cwd ?? cwd, matchedCwd: info.cwd ? matched(info.cwd) : cwd, sessionId: name.slice(0, -'.jsonl'.length),
227
+ bytes: st.size, mtimeMs: st.mtimeMs, isSubagent: info.sub, home: root,
228
+ });
229
+ }
230
+ }
231
+ return out.sort((a, b) => a.mtimeMs - b.mtimeMs);
232
+ }
233
+
234
+ function walkJsonl(dir: string, depth: number, into: string[]): void {
235
+ if (depth > 4) return;
236
+ let entries: string[] = [];
237
+ try { entries = readdirSync(dir); } catch { return; }
238
+ for (const name of entries) {
239
+ const full = join(dir, name);
240
+ let st;
241
+ try { st = statSync(full); } catch { continue; }
242
+ if (st.isDirectory()) walkJsonl(full, depth + 1, into);
243
+ else if (name.endsWith('.jsonl')) into.push(full);
244
+ }
245
+ }
246
+
247
+ /** Codex sessions (every home) whose recorded cwd is one of the given directories, under any spelling. */
248
+ export function locateCodexSessions(cwds: readonly string[], home = homedir()): LocatedSession[] {
249
+ const matched = cwdMatcher(cwds);
250
+ const out: LocatedSession[] = [];
251
+ for (const root of codexSessionRoots(home)) {
252
+ const files: string[] = [];
253
+ walkJsonl(root, 0, files);
254
+ for (const file of files) {
255
+ const info = readCodexSessionHeader(file);
256
+ const matchedCwd = info ? matched(info.cwd) : null;
257
+ if (!info || !matchedCwd) continue;
258
+ let st;
259
+ try { st = statSync(file); } catch { continue; }
260
+ const stem = file.split(/[\\/]/).pop()!.replace(/\.jsonl$/, '');
261
+ out.push({
262
+ host: 'codex', file, cwd: normalizePath(info.cwd), matchedCwd,
263
+ sessionId: info.sessionId ?? stem.replace(/^rollout-\d{4}-\d{2}-\d{2}T[\d-]+-/, ''),
264
+ bytes: st.size, mtimeMs: st.mtimeMs, isSubagent: info.sub, home: root,
265
+ });
266
+ }
267
+ }
268
+ return out.sort((a, b) => a.mtimeMs - b.mtimeMs);
269
+ }
270
+
271
+ export function locateSessions(cwds: readonly string[], home = homedir()): LocatedSession[] {
272
+ return [...locateClaudeSessions(cwds, home), ...locateCodexSessions(cwds, home)];
273
+ }
274
+
275
+ /** Locate each cwd in its selected native home; a frozen copy never falls back to the live host's logs. */
276
+ export function locateSessionsForHomes(cwds: readonly string[], homeFor: (host: 'claude' | 'codex', cwd: string) => string): LocatedSession[] {
277
+ const out: LocatedSession[] = [];
278
+ for (const host of ['claude', 'codex'] as const) {
279
+ const groups = new Map<string, { home: string; cwds: string[] }>();
280
+ for (const cwd of cwds) {
281
+ const home = normalizePath(homeFor(host, cwd));
282
+ const key = pathKey(home);
283
+ const group = groups.get(key) ?? { home, cwds: [] };
284
+ group.cwds.push(cwd);
285
+ groups.set(key, group);
286
+ }
287
+ for (const group of groups.values()) out.push(...(host === 'claude'
288
+ ? locateClaudeSessions(group.cwds, group.home)
289
+ : locateCodexSessions(group.cwds, group.home)));
290
+ }
291
+ return out;
292
+ }