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,86 @@
1
+ /**
2
+ * Candidates for third-party material and generated output that nobody ignored (Spec §1.1; CKC-04 AC-13, AC-17): the
3
+ * program offers them with what shows it — a usual build-output or dependency name, a cache tag, a license of its own, a
4
+ * link to somewhere outside the project — and the Keeper judges them (`pk_classify_scope`); the owner can correct it.
5
+ * Until the Keeper has judged, a candidate is treated as what it looks like, so a build cache is not organized and a
6
+ * library's release plan is not taken for the project's plan while the judgement is pending.
7
+ */
8
+ import { existsSync, readFileSync, readdirSync, statSync, type Dirent } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { git } from '../util/git.ts';
11
+ import { canonicalPath, isWithin, nameForm, normalizePath, relativeDisplay } from '../util/paths.ts';
12
+ import { LICENSE_FILE, NEVER_WALKED, classifyName } from './skip.ts';
13
+
14
+ export interface Candidate {
15
+ readonly path: string;
16
+ readonly relation: 'Generated' | 'Third-party material';
17
+ readonly kind: string;
18
+ readonly evidence: readonly string[];
19
+ }
20
+
21
+ export interface CandidateWalk {
22
+ /** The project's locations: a link that leads outside all of them brings in outside material. */
23
+ readonly locations: readonly string[];
24
+ /** Relative-path predicate from the project's ignore rules (null without version control): ignored content is listed apart. */
25
+ readonly ignored: ((rel: string) => boolean) | null;
26
+ /** Directories handled on their own (registered worktrees, nested repositories): not entered. */
27
+ readonly separate: readonly string[];
28
+ /** Whether git tracks files under `root`, to say how many of a candidate's files are tracked. */
29
+ readonly git: boolean;
30
+ }
31
+
32
+ /** A directory's entries, each under the name names are kept in (`nameForm`): the listing of a Mac may give another Unicode form than git does. */
33
+ const read = (dir: string): Dirent[] => { try { return readdirSync(dir, { withFileTypes: true }).map((e) => { e.name = nameForm(e.name); return e; }); } catch { return []; } };
34
+ const squash = (text: string) => text.replace(/\s+/g, ' ').trim().toLowerCase();
35
+
36
+ export function licenseIn(dir: string, entries: readonly Dirent[] = read(dir)): string | null {
37
+ return entries.find((e) => e.isFile() && LICENSE_FILE.test(e.name))?.name ?? null;
38
+ }
39
+ const licenseText = (dir: string, name: string | null) => { if (!name) return null; try { return squash(readFileSync(join(dir, name), 'utf8').slice(0, 20_000)); } catch { return null; } };
40
+
41
+ function tracked(root: string, rel: string): number | null {
42
+ const r = git(root, ['ls-files', '-z', '--', rel.split('\\').join('/')], 30_000);
43
+ return r.ok ? r.out.split('\0').filter(Boolean).length : null;
44
+ }
45
+
46
+ /** Walk `root` for candidates. A candidate is not entered; neither are ignored directories, links, or directories handled on their own. */
47
+ export function findCandidates(root: string, walk: CandidateWalk): Candidate[] {
48
+ const out: Candidate[] = [];
49
+ const own = licenseText(root, licenseIn(root));
50
+ const inProject = (p: string) => walk.locations.some((l) => isWithin(l, p));
51
+ const trackedNote = (full: string) => {
52
+ if (!walk.git) return [];
53
+ const n = tracked(root, relativeDisplay(root, full));
54
+ return n === null ? [] : [n === 0 ? 'none of its files are tracked in git' : `${n} of its files are tracked in git`];
55
+ };
56
+ const visit = (dir: string, depth: number) => {
57
+ if (depth > 40) return;
58
+ for (const e of read(dir)) {
59
+ if (NEVER_WALKED.has(e.name.toLowerCase())) continue;
60
+ const full = normalizePath(join(dir, e.name));
61
+ const rel = relativeDisplay(root, full);
62
+ if (walk.ignored?.(rel)) continue;
63
+ if (walk.separate.some((s) => isWithin(s, full))) continue;
64
+ if (e.isSymbolicLink()) {
65
+ // Never followed. A directory linked in from outside the project is outside material; one inside is read where it lives.
66
+ let target: string | null = null;
67
+ try { if (statSync(full).isDirectory()) target = canonicalPath(full); } catch { target = null; }
68
+ if (target && !inProject(target)) out.push({ path: full, relation: 'Third-party material', kind: 'linked in', evidence: [`${e.name} is a link to ${target}, outside the project`] });
69
+ continue;
70
+ }
71
+ if (!e.isDirectory()) continue;
72
+ const byName = classifyName(e.name);
73
+ if (byName) { out.push({ path: full, relation: byName.relation, kind: byName.kind, evidence: [byName.evidence, ...trackedNote(full)] }); continue; }
74
+ const entries = read(full);
75
+ if (entries.some((x) => x.isFile() && x.name === 'CACHEDIR.TAG')) { out.push({ path: full, relation: 'Generated', kind: 'cache', evidence: ['CACHEDIR.TAG marks it as a cache directory', ...trackedNote(full)] }); continue; }
76
+ const license = licenseIn(full, entries);
77
+ if (license && (own === null || licenseText(full, license) !== own)) {
78
+ out.push({ path: full, relation: 'Third-party material', kind: 'license of its own', evidence: [`its own license file ${rel}/${license}${own === null ? '' : ', different from the project’s'}`, ...trackedNote(full)] });
79
+ continue;
80
+ }
81
+ visit(full, depth + 1);
82
+ }
83
+ };
84
+ if (existsSync(root)) visit(root, 0);
85
+ return out;
86
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * What changes a scope item after the program has drawn it (Spec §1.1, §1.15, §3.9; CKC-04 AC-1, AC-14, AC-16, AC-17):
3
+ *
4
+ * - the Keeper's classification of a location (`pk_classify_scope`), and a question it put to the owner;
5
+ * - the project's material rules that name a directory or a branch: the listing shows the rule's own words and where
6
+ * they are written; `Recovery only` takes the location out of the current material (its content is `History only`);
7
+ * - the owner's word: a correction in the conversation, an answer to a scope question.
8
+ *
9
+ * Precedence follows the Spec: the project's rule over the Keeper's inference, the owner over both. A rule marks the
10
+ * places it names and what they hold; it never makes the program read anything more (D1): what a place is read for comes
11
+ * from what it is — the project's own material, a library, build output, a worktree, what the ignore rules or the owner
12
+ * leave out — and a directory only a rule names is read as the location around it is read. Pure: no disk and no git
13
+ * beyond what the caller passes in.
14
+ */
15
+ import type { ProjectRule, ScopeItem, ScopeJudgement, ScopeQuestion, ScopeRuleCover } from '../model/types.ts';
16
+ import type { ScopeRelation } from '../model/vocab.ts';
17
+ import { stableId } from '../model/ids.ts';
18
+ import { isWithin, pathKey, relativeDisplay, samePath } from '../util/paths.ts';
19
+ import { skippedSegment, treatmentOf } from './skip.ts';
20
+
21
+ /** The relations a classification can give: judgements about a location, not structural facts drawn from records. */
22
+ export const JUDGED_RELATIONS = ['Main project', 'Nested repository', 'Third-party material', 'Generated', 'Experiment', 'Excluded'] as const satisfies readonly ScopeRelation[];
23
+
24
+ export const ignoredQuestionId = (path: string) => stableId('scopeq', 'ignored-documents', pathKey(path));
25
+ export const keeperQuestionId = (path: string) => stableId('scopeq', 'keeper', pathKey(path));
26
+
27
+ /** Does the owner's answer take ignored material in? Only a clear yes does; anything else leaves it out, as the rules say. */
28
+ export function answerIncludes(text: string): boolean {
29
+ const t = text.trim().toLowerCase();
30
+ if (/^(no\b|not\b|don|leave|keep (it |them )?out|exclude|skip|不|别|否)/.test(t)) return false;
31
+ return /^(include|yes\b|y$|ok\b|take|read|纳入|包括|收|要|是)/.test(t);
32
+ }
33
+
34
+ export interface DecisionInput<T extends ScopeItem> {
35
+ readonly locations: readonly string[];
36
+ readonly rules: readonly ProjectRule[];
37
+ readonly judgements: readonly ScopeJudgement[];
38
+ /** Questions already asked, with the owner's answers. */
39
+ readonly questions: readonly ScopeQuestion[];
40
+ readonly sourceLabel: (sourceId: string) => string | null;
41
+ /** Resolve a rule's words for a location to a directory on disk, or null. */
42
+ readonly resolveDir: (words: string) => string | null;
43
+ /** A scope item for a location nothing listed yet. */
44
+ readonly newItem: (path: string, relation: ScopeRelation) => T;
45
+ }
46
+
47
+ const who = (j: ScopeJudgement) => (j.by === 'owner' ? 'the owner’s correction' : 'the Keeper');
48
+
49
+ function judgementReason(j: ScopeJudgement): string {
50
+ const shows = j.evidence.length ? ` — ${j.evidence.join('; ')}` : '';
51
+ const quote = j.ownerQuote ? ` — “${j.ownerQuote}”` : '';
52
+ return `${j.relation} (${who(j)}, ${j.basis}): ${j.reason}${shows}${quote}`;
53
+ }
54
+
55
+ /** The listing's line for a rule on a location; `part` names the folder inside it the rule is about, when it is not the whole. */
56
+ function coverSentence(rule: ProjectRule, labels: readonly string[], part: string | null = null): string {
57
+ const words = rule.excerpt ? `“${rule.excerpt}”` : rule.summary;
58
+ const where = labels.length ? ` (${labels.join('; ')})` : '';
59
+ const holds = part ? `what ${part} holds` : 'what it holds';
60
+ const then = rule.category === 'Recovery only' ? `; ${holds} is History only: kept for tracing history, never current material`
61
+ : rule.category === 'Reference only' ? `; ${part ? `the documents in ${part}` : 'its documents'} are Reference only`
62
+ : rule.category === 'Obsolete' ? `; ${holds} is superseded` : '';
63
+ return `${rule.category ?? 'Rule'}${part ? ` for ${part}, inside this location` : ''} — ${words}${where}${then}`;
64
+ }
65
+
66
+ const BRANCH_WORDS = /^(?:branch(?:es)?|分支)\s*[::]?\s*(.+)$/i;
67
+ const globRe = (pattern: string) => new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`, 'i');
68
+
69
+ export function applyDecisions<T extends ScopeItem>(start: readonly T[], input: DecisionInput<T>): { items: T[]; questions: ScopeQuestion[] } {
70
+ const items: T[] = [...start];
71
+ const questions: ScopeQuestion[] = [];
72
+ const find = (path: string) => items.findIndex((i) => i.category !== 'Session source' && samePath(i.path, path));
73
+ const set = (idx: number, patch: Partial<ScopeItem>) => { items[idx] = { ...items[idx]!, ...patch } as T; };
74
+ /** Add a clause to an item's reason once (decisions may be applied again to a scope that already carries them). */
75
+ const note = (idx: number, clause: string) => { if (!items[idx]!.reason.includes(clause)) set(idx, { reason: [items[idx]!.reason, clause].filter(Boolean).join(' · ') }); };
76
+ const locate = (path: string, relation: ScopeRelation) => {
77
+ const idx = find(path);
78
+ if (idx >= 0) return idx;
79
+ items.push(input.newItem(path, relation));
80
+ return items.length - 1;
81
+ };
82
+
83
+ const judge = (j: ScopeJudgement) => {
84
+ const idx = locate(j.path, j.relation);
85
+ const before = items[idx]!;
86
+ const applied = before.classification?.at === j.at && before.classification.by === j.by; // already carries this judgement
87
+ if (!applied) {
88
+ set(idx, {
89
+ relation: j.relation, reason: judgementReason(j),
90
+ reasonSourceIds: [...new Set([...j.sourceIds, ...before.reasonSourceIds])],
91
+ classification: { by: j.by, basis: j.basis, kind: before.relation === j.relation ? before.classification?.kind ?? null : null, evidence: j.evidence, sourceIds: j.sourceIds, ruleId: j.ruleId, jobId: j.jobId, at: j.at },
92
+ });
93
+ }
94
+ if (!j.question) return;
95
+ const asked = input.questions.find((q) => q.id === j.question!.id);
96
+ const q: ScopeQuestion = { ...j.question, answer: asked?.answer ?? null };
97
+ questions.push(q);
98
+ if (!q.answer) { note(idx, `asked the owner: ${q.question}`); return; }
99
+ const said = q.answer.text.trim();
100
+ const named = JUDGED_RELATIONS.find((r) => said.toLowerCase() === r.toLowerCase() || said.toLowerCase().startsWith(`${r.toLowerCase()}`));
101
+ if (named) {
102
+ set(idx, {
103
+ relation: named, reason: `${named} (the owner’s answer, Explicit): “${said}” — to “${q.question}”`,
104
+ classification: { by: 'owner', basis: 'Explicit', kind: null, evidence: [`the owner answered “${said}”`], sourceIds: q.answer.sourceId ? [q.answer.sourceId] : [], ruleId: null, jobId: null, at: q.answer.at },
105
+ });
106
+ } else {
107
+ note(idx, `the owner answered: “${said}”`);
108
+ }
109
+ };
110
+
111
+ // 1. The Keeper's classifications.
112
+ const byTime = [...input.judgements].sort((a, b) => a.at.localeCompare(b.at));
113
+ for (const j of byTime.filter((x) => x.by === 'keeper')) judge(j);
114
+
115
+ // 2. The project's material rules that name a directory or a branch (§1.15): over the Keeper's inference.
116
+ const labelsOf = (rule: ProjectRule) => [...new Set(rule.sourceIds.map((id) => input.sourceLabel(id)).filter((l): l is string => Boolean(l)))];
117
+ /**
118
+ * Put a rule on a location: its words and where they are written go on the location's line. `part` is the folder
119
+ * inside the location the rule names, when the rule's words go on the location around that folder (step 5).
120
+ *
121
+ * A Recovery only rule takes the location out of the current material, and what is read from it stays what it was,
122
+ * now History only (skip.ts `treatmentOf`). So it turns `Excluded` only a location read in full, or a worktree read
123
+ * for what it adds to the trunk; every other keeps what it is — a library still gives only its documents, build output
124
+ * and what is left out give nothing — and what the Keeper or the owner judged a location stays as they judged it.
125
+ */
126
+ const cover = (rule: ProjectRule, idx: number, target: string, part: string | null = null) => {
127
+ const item = items[idx]!;
128
+ if ((item.coveredBy ?? []).some((c) => c.ruleId === rule.id)) return;
129
+ const entry: ScopeRuleCover = { ruleId: rule.id, category: rule.category, summary: rule.summary, excerpt: rule.excerpt, sourceIds: rule.sourceIds, basis: rule.basis, target };
130
+ const read = treatmentOf(item);
131
+ const out = part === null && rule.category === 'Recovery only' && item.addedBy === 'keeper' && item.classification == null && (read === 'read' || read === 'changes');
132
+ set(idx, {
133
+ coveredBy: [...(item.coveredBy ?? []), entry],
134
+ relation: out ? 'Excluded' : item.relation,
135
+ reason: [coverSentence(rule, labelsOf(rule), part), item.reason].filter(Boolean).join(' · '),
136
+ reasonSourceIds: [...new Set([...rule.sourceIds, ...item.reasonSourceIds])],
137
+ });
138
+ };
139
+ /** Directories only a rule names: listed in step 5, once the owner's word on the locations around them is in too. */
140
+ const named: { rule: ProjectRule; raw: string; words: string; dir: string }[] = [];
141
+ for (const rule of input.rules) {
142
+ if (rule.group !== 'Material rules' || rule.validity !== 'Current') continue;
143
+ for (const raw of rule.appliesTo) {
144
+ const words = raw.trim().replace(/^[`'"“”]+|[`'"“”]+$/g, '');
145
+ const branch = BRANCH_WORDS.exec(words)?.[1]?.trim().replace(/^[`'"“”]+|[`'"“”]+$/g, '') ?? null;
146
+ const onBranch = (name: string) => items.map((i, idx) => ({ i, idx })).filter(({ i }) => i.worktree?.branch && globRe(name).test(i.worktree.branch));
147
+ if (branch) {
148
+ const hits = onBranch(branch);
149
+ if (hits.length) for (const h of hits) cover(rule, h.idx, raw);
150
+ else for (const [idx, i] of items.entries()) if (i.category === 'Repository' && i.relation === 'Main project') {
151
+ // A branch without a worktree lives only in the history: the repository's line says what the rule makes of it.
152
+ if ((i.coveredBy ?? []).some((c) => c.ruleId === rule.id)) continue;
153
+ set(idx, { coveredBy: [...(i.coveredBy ?? []), { ruleId: rule.id, category: rule.category, summary: rule.summary, excerpt: rule.excerpt, sourceIds: rule.sourceIds, basis: rule.basis, target: raw }], reason: `${i.reason} · branch ${branch}: ${coverSentence(rule, labelsOf(rule))}`, reasonSourceIds: [...new Set([...i.reasonSourceIds, ...rule.sourceIds])] });
154
+ }
155
+ continue;
156
+ }
157
+ const dir = input.resolveDir(words);
158
+ if (dir && !input.locations.some((l) => samePath(l, dir))) {
159
+ const idx = find(dir);
160
+ if (idx >= 0) cover(rule, idx, raw);
161
+ else named.push({ rule, raw, words, dir });
162
+ continue;
163
+ }
164
+ const hits = onBranch(words);
165
+ for (const h of hits) cover(rule, h.idx, raw);
166
+ }
167
+ }
168
+
169
+ // 3. The owner's corrections, over everything.
170
+ for (const j of byTime.filter((x) => x.by === 'owner')) judge(j);
171
+
172
+ // 4. The owner's answer about ignored documents.
173
+ for (const [idx, item] of items.entries()) {
174
+ if (!item.ignoredBy || item.addedBy !== 'keeper' || (item.classification && item.classification.by !== 'program')) continue;
175
+ const answer = input.questions.find((q) => q.id === ignoredQuestionId(item.path))?.answer;
176
+ if (!answer) continue;
177
+ if (answerIncludes(answer.text)) { set(idx, { relation: 'Main project' }); note(idx, `the owner chose to include it: “${answer.text.trim()}”`); }
178
+ else note(idx, `the owner chose to leave it out: “${answer.text.trim()}”`);
179
+ }
180
+
181
+ // 5. The directories only a rule names, outer ones first (D1). Each is read as the location around it is read, so the
182
+ // rule marks what it holds and the program reads nothing more for it.
183
+ const around = (path: string): number => {
184
+ let best = -1;
185
+ for (const [idx, i] of items.entries()) {
186
+ if (i.category === 'Session source' || samePath(i.path, path) || !isWithin(i.path, path)) continue;
187
+ if (best < 0 || pathKey(i.path).length > pathKey(items[best]!.path).length) best = idx;
188
+ }
189
+ return best;
190
+ };
191
+ /**
192
+ * Whether the directory gets a line of its own. Under a location nothing is read from, it may: it is not read either.
193
+ * Where a directory read on its own could not be read the way the location around it reads it — a worktree, read for
194
+ * what it adds to the trunk; a location kept for recovery; a folder below one no walk of it enters (.git, .worktrees,
195
+ * build output …) — the rule's words go on the location around it instead, naming the folder.
196
+ */
197
+ const ownLine = (outer: ScopeItem, dir: string): boolean => {
198
+ const read = treatmentOf(outer);
199
+ if (read === 'none') return true;
200
+ if (read === 'changes' || read === 'history') return false;
201
+ return skippedSegment(relativeDisplay(outer.path, dir)) === null;
202
+ };
203
+ /**
204
+ * A directory's own line, read as the location around it is read: its relation and its classification (a library's
205
+ * folder gives its documents; one under build output, or under what the ignore rules or the owner leave out, gives
206
+ * nothing). One of the project's own locations is the project, whatever its relation says (a project may itself be a
207
+ * copy of another repository), so a folder inside it is the project's own material. It is a plain directory: a
208
+ * repository only a rule names does not bring its own history in. With no location around it, only what the rule
209
+ * allows is read — what a Recovery only rule keeps is History only (Spec §1.2); the other rules make nothing readable.
210
+ */
211
+ const lineFor = (dir: string, outer: ScopeItem | null): T => {
212
+ const made = input.newItem(dir, 'Excluded');
213
+ if (!outer) return { ...made, category: 'Directory', relation: 'Excluded', classification: null, reason: 'no location in scope holds it: only what the rule allows is read' } as T;
214
+ const own = input.locations.some((l) => samePath(l, outer.path));
215
+ return {
216
+ ...made, category: 'Directory', relation: own ? 'Main project' : outer.relation, classification: outer.classification ?? null,
217
+ reason: `read as ${outer.path} around it is read (${outer.relation})`,
218
+ } as T;
219
+ };
220
+ for (const n of [...named].sort((a, b) => pathKey(a.dir).length - pathKey(b.dir).length)) {
221
+ const idx = find(n.dir);
222
+ if (idx >= 0) { cover(n.rule, idx, n.raw); continue; }
223
+ const outer = around(n.dir);
224
+ if (outer >= 0 && !ownLine(items[outer]!, n.dir)) { cover(n.rule, outer, n.raw, n.words); continue; }
225
+ items.push(lineFor(n.dir, outer >= 0 ? items[outer]! : null));
226
+ cover(n.rule, items.length - 1, n.raw);
227
+ }
228
+ return { items, questions };
229
+ }