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,431 @@
1
+ /**
2
+ * Merged work that carries no task number (Spec v3.0 §2.12, §1.17; the owner's rule of 2026-09-28): the branches merged
3
+ * and the direct commits whose messages carry none of the numbers the project gives its tasks, and that no process link
4
+ * ties to a work item yet. The program lists them with what the step judges each one from — its commits and when, its
5
+ * merges, the size of the change, what its messages name, whose files it changed, where the records name it — and the
6
+ * step applies the owner's rule: work that changes behaviour, a contract or the architecture, or is clearly worth
7
+ * tracking, becomes its own work item; a pure repair, a test-stability fix or a supporting fix a task needed folds into
8
+ * that task's work and is named in its results.
9
+ *
10
+ * Found in test-C-1: the Claude-subagent packages — the clerk tools, the process engine, the long-output fix, the
11
+ * cleanup, QC AY's packages — were merged into k-clerk with no number of their own, and the round made no work item for
12
+ * any of them; they showed only inside the contracts' work.
13
+ *
14
+ * Numbered is what the project numbered: the process engine ties the commit to a task (units.ts), its subject opens with
15
+ * a task number (`B0 foundation: …`), or a merge names the task where a merge names what it merges. A message that only
16
+ * mentions a number — a fix saying which QC findings it answers (`(QC AY B3, B13)`) — does not make the work that task's;
17
+ * the numbers it names are listed as what the work served. Commits that changed only documents and execution records
18
+ * (the project's own records, the document chain's versions) are counted apart: the document chain has its versions in
19
+ * the skeleton, and the records are the project's arrangements.
20
+ */
21
+ import type { Ledger } from '../ledger/index.ts';
22
+ import type { Project, WorkThread } from '../model/types.ts';
23
+ import type { ProjectStore } from '../store/project-store.ts';
24
+ import { within } from '../codemap/facts.ts';
25
+ import { langOf } from '../ledger/code.ts';
26
+ import { definitionsInSubject } from '../ledger/numbering.ts';
27
+ import { Facts, type CommitFact } from './facts.ts';
28
+ import type { Integration } from './integration.ts';
29
+ import { clip } from './text.ts';
30
+ import { baseNumber, buildUnits, isArrangementPath, mergeNamesIn, type UnitIndex } from './units.ts';
31
+
32
+ // ───────────────────────── what a piece of work is made of ─────────────────────────
33
+
34
+ export type FileKind = 'code' | 'test' | 'config' | 'doc' | 'record';
35
+
36
+ /** Languages of programs, as the ledger names them (ledger/code.ts); markup, data and prose are not among them. */
37
+ const PROGRAM = new Set(['typescript', 'javascript', 'dart', 'python', 'java', 'kotlin', 'swift', 'go', 'rust', 'csharp', 'c', 'cpp', 'css', 'html', 'shell', 'sql']);
38
+ /** The working folders of the agents that work on a project: their settings are not the project's product. */
39
+ const AGENT_DIR = /(?:^|\/)\.(?:claude|codex|cursor|pi|gemini|kimi|zcode|windsurf|aider|continue)\//i;
40
+
41
+ /**
42
+ * What a changed file is, by its path: the execution records (prompts, receipts, execution plans, the agents' own
43
+ * folders), tests, documents (prose, and whatever lies under docs/, design/ or mockups/), code (a program's language, by
44
+ * the ledger's table), or configuration (the rest: data files, build files, dotfiles).
45
+ */
46
+ export function fileKind(path: string): FileKind {
47
+ if (isArrangementPath(path) || AGENT_DIR.test(path)) return 'record';
48
+ if (/(?:^|\/)(?:tests?|__tests__|spec|specs)\//i.test(path) || /[._-](?:test|spec)\.[a-z0-9]+$/i.test(path) || /_test\.[a-z0-9]+$/i.test(path)) return 'test';
49
+ const lang = langOf(path);
50
+ if (lang === 'markdown' || lang === 'text' || /\.(?:mdx|rst|adoc)$/i.test(path) || /(?:^|\/)(?:docs?|design|mockups?)\//i.test(path)) return 'doc';
51
+ return lang && PROGRAM.has(lang) ? 'code' : 'config';
52
+ }
53
+ /** Code, tests and configuration are the work; documents and records are not. */
54
+ const isWork = (k: FileKind): boolean => k === 'code' || k === 'test' || k === 'config';
55
+
56
+ export interface SizePart { readonly files: number; readonly added: number; readonly deleted: number }
57
+ export interface Size extends SizePart { readonly byKind: Readonly<Partial<Record<FileKind, SizePart>>> }
58
+
59
+ export interface TiedWork {
60
+ readonly workId: string;
61
+ readonly label: string;
62
+ readonly stepKind: string;
63
+ readonly confirmed: boolean;
64
+ /** The work item's results name the branch, its merge or one of its commits (the owner's rule: a folded fix is listed there). */
65
+ readonly namedInResults: boolean;
66
+ }
67
+
68
+ export interface UnnumberedPiece {
69
+ readonly repo: string;
70
+ readonly kind: 'branch' | 'direct';
71
+ /** The branch a merge names, or whose tip it merged; null for direct commits and for a merge that names none. */
72
+ readonly branch: string | null;
73
+ /** The merges that integrated it, oldest first (a branch merged several times as it went is one piece). */
74
+ readonly merges: readonly Integration[];
75
+ /** Its merges into another branch that only kept that branch up to date (`Merge k-clerk into k-ledger`). */
76
+ readonly syncs: readonly Integration[];
77
+ /** The trunk's merges that brought its merges into the trunk, when they are not the trunk's own. */
78
+ readonly trunkMerges: readonly CommitFact[];
79
+ /** Its own commits that carry no task number (merges left out), oldest first. */
80
+ readonly commits: readonly CommitFact[];
81
+ /** Commits of the same branch that do carry one, with the task. */
82
+ readonly numbered: readonly { readonly commit: CommitFact; readonly tasks: readonly string[] }[];
83
+ readonly files: readonly { readonly path: string; readonly kind: FileKind }[];
84
+ readonly size: Size;
85
+ /** The project's numbers its messages name, most named first. */
86
+ readonly names: readonly { readonly num: string; readonly count: number }[];
87
+ /** Work items whose numbers its messages name. */
88
+ readonly workNamed: readonly { readonly workId: string; readonly label: string }[];
89
+ /** Numbers its messages name that exactly one work item's own document defines (a contract's acceptance criterion). */
90
+ readonly definedIn: readonly { readonly num: string; readonly work: string }[];
91
+ /** Numbered work whose own commits changed the same files. */
92
+ readonly workByFiles: readonly { readonly workId: string; readonly label: string; readonly files: number }[];
93
+ readonly territories: readonly { readonly id: string; readonly name: string; readonly area: string | null; readonly files: number }[];
94
+ /** Top directories of its files outside the code territories. */
95
+ readonly dirs: readonly { readonly dir: string; readonly files: number }[];
96
+ /** Documents that name the branch (the project's records of it: a dispatch, a receipt, a decision). */
97
+ readonly records: readonly { readonly id: string; readonly label: string; readonly snippet: string }[];
98
+ /** Work items a process link already ties it to. */
99
+ readonly tied: readonly TiedWork[];
100
+ /** Its latest commit or merge, in ms. */
101
+ readonly lastMs: number;
102
+ /** It changed code, tests or configuration (else only documents and execution records). */
103
+ readonly work: boolean;
104
+ }
105
+
106
+ const short = (h: string): string => h.slice(0, 7);
107
+ const at = (c: CommitFact): string => c.occurred.at.replace('T', ' ').slice(0, 16);
108
+ const threadLabel = (t: WorkThread): string => `${t.ids[0] && !t.title.startsWith(t.ids[0]) ? `${t.ids[0]} ` : ''}${t.title}`;
109
+ const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
110
+
111
+ /** The commit a link's ledger ref names (`commit:<hash>`, full or short). */
112
+ function linkedHash(ref: string, facts: Facts): string | null {
113
+ const m = /^commit:([0-9a-f]{7,40})$/i.exec(ref.trim());
114
+ return m ? facts.commitByPrefix(m[1]!)?.hash ?? null : null;
115
+ }
116
+
117
+ /**
118
+ * The numbers the project gives its tasks: its work items' numbers (and the Keeper's numbers for work), what its
119
+ * execution arrangements are about, and the rows of its execution plans and prompt index — ContextKeeper's batches B0–B9
120
+ * were rows of its first execution plan before the two-letter numbers began.
121
+ */
122
+ export function taskNumbers(store: ProjectStore, facts: Facts, index: UnitIndex): Set<string> {
123
+ const out = new Set<string>([...index.units.keys()].map(baseNumber));
124
+ for (const t of store.threads.all()) for (const n of t.ids) out.add(n);
125
+ for (const k of store.numbers.all()) if (k.objectKind === 'work') for (const n of [k.number, k.projectNumber]) if (n) out.add(n);
126
+ for (const a of facts.arrangements) {
127
+ if (a.ident) out.add(a.ident);
128
+ for (const r of a.data.rows ?? []) if (/^[A-Z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*$/.test(r.id.trim())) out.add(r.id.trim());
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /** The pieces of merged work no task number claims (see the module comment), oldest first. */
134
+ export function unnumberedWork(store: ProjectStore, facts: Facts, index: UnitIndex = buildUnits(store, facts)): UnnumberedPiece[] {
135
+ const db = facts.ledger.db;
136
+ const integrations = index.integrations;
137
+ const tasks = taskNumbers(store, facts, index);
138
+ const numberedMerges = new Set([...index.units.values()].flatMap((u) => u.merges.map((m) => m.hash)));
139
+ /** The tasks a commit carries: the process engine's, the task number its subject opens with, or the one a merge names as merged. */
140
+ const tasksOf = (c: CommitFact): string[] => {
141
+ const engine = index.commitUnits.get(c.hash) ?? [];
142
+ if (engine.length) return [...engine];
143
+ const opens = definitionsInSubject(c.subject).map((d) => d.num).filter((n) => tasks.has(n));
144
+ if (opens.length) return opens;
145
+ return c.merge ? mergeNamesIn(c.subject).filter((n) => tasks.has(n)) : [];
146
+ };
147
+ const threadByNum = new Map<string, WorkThread>();
148
+ for (const t of store.threads.all()) for (const n of t.ids) if (!threadByNum.has(n)) threadByNum.set(n, t);
149
+ for (const k of store.numbers.all()) { const t = k.objectKind === 'work' ? store.threads.get(k.objectId) : undefined; if (t) for (const n of [k.number, k.projectNumber]) if (n && !threadByNum.has(n)) threadByNum.set(n, t); }
150
+ const commitFiles = db.prepare('SELECT path, added, deleted FROM commit_files WHERE repo = ? AND hash = ?');
151
+ const filesOf = new Map<string, { path: string; added: number | null; deleted: number | null }[]>();
152
+ const changed = (c: CommitFact) => {
153
+ if (!filesOf.has(c.hash)) filesOf.set(c.hash, commitFiles.all(c.repo, c.hash) as { path: string; added: number | null; deleted: number | null }[]);
154
+ return filesOf.get(c.hash)!;
155
+ };
156
+ /** A commit that changed only documents and execution records. */
157
+ const recordOnly = (c: CommitFact) => changed(c).length > 0 && !changed(c).some((f) => isWork(fileKind(f.path)));
158
+
159
+ // ── the branches: the commits each integrating merge brought in as the innermost one, by the branch it names ──
160
+ const groups = new Map<string, { repo: string; branch: string | null; merges: Integration[]; commits: CommitFact[] }>();
161
+ const keyOf = (m: Integration) => `${m.merge.repo}\x1f${m.source ?? `merge:${m.merge.hash}`}`;
162
+ for (const m of integrations.merges) {
163
+ const g = groups.get(keyOf(m)) ?? { repo: m.merge.repo, branch: m.source, merges: [], commits: [] };
164
+ g.merges.push(m);
165
+ groups.set(keyOf(m), g);
166
+ }
167
+ for (const c of facts.commits.values()) {
168
+ if (c.merge || !c.onTrunk) continue;
169
+ const m = integrations.innermost.get(c.hash);
170
+ if (m) groups.get(keyOf(m))!.commits.push(c);
171
+ }
172
+ // A merge into a branch that is itself merged elsewhere only kept that branch up to date.
173
+ const merged = new Set(integrations.merges.map((m) => `${m.merge.repo}\x1f${m.source ?? ''}`));
174
+ const isSync = (m: Integration) => m.target !== null && merged.has(`${m.merge.repo}\x1f${m.target}`);
175
+
176
+ // ── direct commits: on the trunk and brought in by no integrating merge, in runs between numbered commits and merges ──
177
+ const runs: { repo: string; commits: CommitFact[] }[] = [];
178
+ let recordsLeftOut = 0;
179
+ for (const [repo, list] of facts.commitsByRepo) {
180
+ let run: CommitFact[] = [];
181
+ const close = () => { if (run.length) runs.push({ repo, commits: run }); run = []; };
182
+ for (const c of list) {
183
+ if (!c.onTrunk) continue;
184
+ if (c.merge) { if (c.fpTrunk) close(); continue; }
185
+ if (integrations.innermost.has(c.hash)) continue;
186
+ if (tasksOf(c).length) { close(); continue; }
187
+ if (recordOnly(c)) { recordsLeftOut++; continue; }
188
+ run.push(c);
189
+ }
190
+ close();
191
+ }
192
+
193
+ // ── the ties a round already made ──
194
+ const linksByHash = new Map<string, TiedWork[]>();
195
+ for (const l of store.links.all()) {
196
+ const h = linkedHash(l.ledgerRef, facts);
197
+ const t = store.threads.get(l.workId);
198
+ if (!h || !t) continue;
199
+ linksByHash.set(h, [...(linksByHash.get(h) ?? []), { workId: t.id, label: threadLabel(t), stepKind: l.stepKind, confirmed: l.confirmed, namedInResults: false }]);
200
+ }
201
+
202
+ const numsOfCommit = db.prepare("SELECT num FROM nums WHERE kind = 'commit' AND commit_hash = ?");
203
+ const defsOf = db.prepare("SELECT DISTINCT path FROM nums WHERE num = ? AND place = 'definition' AND kind = 'doc' AND path IS NOT NULL");
204
+ const touching = db.prepare('SELECT DISTINCT hash FROM commit_files WHERE repo = ? AND (path = ? OR old_path = ?)');
205
+ const territories = store.territories.all();
206
+ const repoPath = (id: string) => facts.ledger.repos().find((r) => r.id === id)?.path;
207
+ const areaName = (id: string | null) => (id ? store.reference.get(id)?.name ?? null : null);
208
+
209
+ const describe = (repo: string, kind: UnnumberedPiece['kind'], branch: string | null, allMerges: Integration[], allCommits: CommitFact[]): UnnumberedPiece | null => {
210
+ const merges = allMerges.filter((m) => !isSync(m)).sort((a, b) => a.merge.ms - b.merge.ms);
211
+ const syncs = allMerges.filter(isSync).sort((a, b) => a.merge.ms - b.merge.ms);
212
+ // A merge that names the task it merged makes the whole branch that task's.
213
+ if (merges.some((m) => tasksOf(m.merge).length)) return null;
214
+ const sorted = [...allCommits].sort((a, b) => a.ms - b.ms);
215
+ const numbered = sorted.map((c) => ({ commit: c, tasks: tasksOf(c) })).filter((x) => x.tasks.length);
216
+ const own = sorted.filter((c) => !tasksOf(c).length);
217
+ if (own.length === 0) return null;
218
+ // The size: what its merges brought into their line when they brought only this piece's commits; else the commits.
219
+ const mine = new Set(own.map((c) => c.hash));
220
+ const netByMerge = kind === 'branch' && merges.length > 0 && !numbered.length && !syncs.length
221
+ && merges.every((m) => [...m.brought].every((h) => facts.commits.get(h)?.merge || mine.has(h)));
222
+ const changes = new Map<string, { kind: FileKind; added: number; deleted: number }>();
223
+ for (const c of netByMerge ? merges.map((m) => m.merge) : own) {
224
+ for (const f of changed(c)) {
225
+ const e = changes.get(f.path) ?? { kind: fileKind(f.path), added: 0, deleted: 0 };
226
+ e.added += f.added ?? 0;
227
+ e.deleted += f.deleted ?? 0;
228
+ changes.set(f.path, e);
229
+ }
230
+ }
231
+ const byKind: Partial<Record<FileKind, { files: number; added: number; deleted: number }>> = {};
232
+ for (const e of changes.values()) {
233
+ const k = (byKind[e.kind] ??= { files: 0, added: 0, deleted: 0 });
234
+ k.files++; k.added += e.added; k.deleted += e.deleted;
235
+ }
236
+ const size: Size = { files: changes.size, added: [...changes.values()].reduce((n, e) => n + e.added, 0), deleted: [...changes.values()].reduce((n, e) => n + e.deleted, 0), byKind };
237
+ const files = [...changes].map(([path, e]) => ({ path, kind: e.kind })).sort((a, b) => a.path.localeCompare(b.path));
238
+ // What its messages name.
239
+ const counts = new Map<string, number>();
240
+ for (const c of [...own, ...merges.map((m) => m.merge)]) for (const r of numsOfCommit.all(c.hash) as { num: string }[]) counts.set(r.num, (counts.get(r.num) ?? 0) + 1);
241
+ const names = [...counts].map(([num, count]) => ({ num, count })).sort((a, b) => b.count - a.count || a.num.localeCompare(b.num));
242
+ const workNamed = [...new Map(names.flatMap((x) => { const t = threadByNum.get(x.num); return t ? [[t.id, { workId: t.id, label: threadLabel(t) }] as const] : []; })).values()];
243
+ const definedIn = names.filter((x) => !threadByNum.has(x.num)).flatMap((x) => {
244
+ const work = new Set<string>();
245
+ for (const r of defsOf.all(x.num) as { path: string }[]) {
246
+ const base = r.path.split('/').pop() ?? r.path;
247
+ for (const [num, t] of threadByNum) if (new RegExp(`(?:^|[^A-Za-z0-9])${escapeRe(num)}(?![A-Za-z0-9])`).test(base)) work.add(threadLabel(t));
248
+ }
249
+ return work.size === 1 ? [{ num: x.num, work: [...work][0]! }] : [];
250
+ });
251
+ // Numbered work whose own commits changed the same files.
252
+ const shared = new Map<string, { t: WorkThread; files: Set<string> }>();
253
+ for (const f of files) {
254
+ for (const r of touching.all(repo, f.path, f.path) as { hash: string }[]) {
255
+ if (mine.has(r.hash)) continue;
256
+ for (const u of index.commitUnits.get(r.hash) ?? []) {
257
+ const tid = index.threadOf.get(u);
258
+ const t = tid ? store.threads.get(tid) : undefined;
259
+ if (!t) continue;
260
+ const e = shared.get(t.id) ?? { t, files: new Set<string>() };
261
+ e.files.add(f.path);
262
+ shared.set(t.id, e);
263
+ }
264
+ }
265
+ }
266
+ const workByFiles = [...shared.values()].map((e) => ({ workId: e.t.id, label: threadLabel(e.t), files: e.files.size })).sort((a, b) => b.files - a.files || a.label.localeCompare(b.label));
267
+ const ofRepo = territories.filter((t) => t.repo === repo || t.repo === repoPath(repo));
268
+ const inTerritory = ofRepo.map((t) => ({ id: t.id, name: t.name, area: areaName(t.areaId), files: files.filter((f) => within(t.paths, f.path)).length }))
269
+ .filter((t) => t.files > 0).sort((a, b) => b.files - a.files);
270
+ const dirCount = new Map<string, number>();
271
+ for (const f of files) {
272
+ if (ofRepo.some((t) => within(t.paths, f.path))) continue;
273
+ const parts = f.path.split('/');
274
+ const dir = parts.length > 2 ? parts.slice(0, 2).join('/') : parts.length === 2 ? parts[0]! : '(root)';
275
+ dirCount.set(dir, (dirCount.get(dir) ?? 0) + 1);
276
+ }
277
+ const dirs = [...dirCount].map(([dir, n]) => ({ dir, files: n })).sort((a, b) => b.files - a.files || a.dir.localeCompare(b.dir));
278
+ // The records that name the branch.
279
+ let records: UnnumberedPiece['records'] = [];
280
+ if (branch && branch.length >= 3) {
281
+ // The branch's own name, not a longer one it begins (`k-clerk` in `k-clerk-tools`).
282
+ const whole = new RegExp(`(?<![\\w/.-])${escapeRe(branch)}(?![\\w-])`, 'i');
283
+ const hits = facts.ledger.word(branch, { kinds: ['doc', 'loose'], limit: 500 });
284
+ if (typeof hits !== 'string') records = hits.rows.filter((h) => whole.test(h.snippet)).map((h) => ({ id: h.id, label: h.label, snippet: h.snippet }));
285
+ }
286
+ // What already ties it to a work item — a link on one of its commits, or on a merge that brought in nothing else (a
287
+ // link on the trunk's merge of a whole integration branch is about that landing, not this piece) — and whether that
288
+ // work's results name it.
289
+ const ofBranch = new Set(sorted.map((c) => c.hash));
290
+ const ownMerges = merges.filter((m) => [...m.brought].every((h) => facts.commits.get(h)?.merge || ofBranch.has(h)));
291
+ const tiedTo = new Set([...mine, ...ownMerges.map((m) => m.merge.hash)]);
292
+ const tiedBy = new Map<string, TiedWork>();
293
+ for (const h of tiedTo) for (const t of linksByHash.get(h) ?? []) if (!tiedBy.has(`${t.workId}\x1f${t.stepKind}`)) tiedBy.set(`${t.workId}\x1f${t.stepKind}`, t);
294
+ const tied = [...tiedBy.values()].map((t) => {
295
+ const w = store.threads.get(t.workId);
296
+ const text = `${w?.results ?? ''}\n${w?.changed ?? ''}`;
297
+ const named = (branch !== null && text.includes(branch)) || [...tiedTo].some((h) => new RegExp(`(?<![0-9a-f])${h.slice(0, 7)}`, 'i').test(text));
298
+ return { ...t, namedInResults: named };
299
+ });
300
+ const lastMs = Math.max(...own.map((c) => c.ms), ...merges.map((m) => m.merge.ms));
301
+ const trunkMerges = [...new Map(merges.map((m) => integrations.trunkMergeOf(m.merge.hash)).filter((t): t is CommitFact => t !== null && !merges.some((m) => m.merge.hash === t.hash)).map((t) => [t.hash, t])).values()];
302
+ const work = files.some((f) => isWork(f.kind));
303
+ return { repo, kind, branch, merges, syncs, trunkMerges, commits: own, numbered, files, size, names, workNamed, definedIn, workByFiles, territories: inTerritory, dirs, records, tied, lastMs, work };
304
+ };
305
+
306
+ const out: UnnumberedPiece[] = [];
307
+ for (const g of groups.values()) { const p = describe(g.repo, 'branch', g.branch, g.merges, g.commits); if (p) out.push(p); }
308
+ for (const r of runs) { const p = describe(r.repo, 'direct', null, [], r.commits); if (p) out.push(p); }
309
+ const sorted = out.sort((a, b) => a.lastMs - b.lastMs);
310
+ return Object.assign(sorted, { recordsLeftOut });
311
+ }
312
+
313
+ // ───────────────────────── the block a step is given ─────────────────────────
314
+
315
+ const n = (x: number): string => x.toLocaleString('en-US');
316
+ const sizeText = (s: SizePart): string => `${n(s.files)} file${s.files === 1 ? '' : 's'}, +${n(s.added)} −${n(s.deleted)}`;
317
+ const KIND_LABEL: Record<FileKind, string> = { code: 'code', test: 'tests', config: 'configuration', doc: 'documents', record: 'execution records' };
318
+
319
+ function repoLabel(ledger: Ledger, repo: string): string {
320
+ const r = ledger.repos().find((x) => x.id === repo);
321
+ return r ? (r.path.split(/[\\/]/).filter(Boolean).pop() ?? r.path) : repo;
322
+ }
323
+
324
+ function pieceTitle(ledger: Ledger, p: UnnumberedPiece): string {
325
+ const where = repoLabel(ledger, p.repo);
326
+ if (p.kind === 'direct') {
327
+ const first = p.commits[0]!;
328
+ const last = p.commits[p.commits.length - 1]!;
329
+ return `direct commits on the trunk · ${where} · ${p.commits.length === 1 ? short(first.hash) : `${short(first.hash)}…${short(last.hash)}`}`;
330
+ }
331
+ const first = p.merges[0] ?? p.syncs[0];
332
+ return `${p.branch ? `branch ${p.branch}` : `merge ${first ? short(first.merge.hash) : '?'} (it names no branch)`} · ${where}`;
333
+ }
334
+
335
+ /** One piece as the step reads it. */
336
+ export function pieceLines(ledger: Ledger, p: UnnumberedPiece, i: number): string[] {
337
+ const lines: string[] = [`${i}. ${pieceTitle(ledger, p)}`];
338
+ if (p.merges.length) lines.push(` merged: ${p.merges.map((m) => `${short(m.merge.hash)} ${at(m.merge)} “${clip(m.merge.subject, 140)}”${m.target ? ` (into ${m.target})` : ''}`).join('; ')}`);
339
+ if (p.trunkMerges.length) lines.push(` reached the trunk with: ${p.trunkMerges.map((t) => `${short(t.hash)} ${at(t)} “${clip(t.subject, 120)}”`).join('; ')}`);
340
+ if (p.syncs.length) lines.push(` also merged into ${[...new Set(p.syncs.map((m) => m.target))].join(', ')} to keep ${p.syncs.length === 1 ? 'it' : 'them'} up to date: ${p.syncs.map((m) => short(m.merge.hash)).join(', ')}`);
341
+ const kinds = (Object.keys(KIND_LABEL) as FileKind[]).filter((k) => p.size.byKind[k]);
342
+ lines.push(` size: ${sizeText(p.size)}${kinds.length > 1 ? ` (${kinds.map((k) => `${KIND_LABEL[k]} ${sizeText(p.size.byKind[k]!)}`).join(' · ')})` : kinds.length === 1 ? ` (${KIND_LABEL[kinds[0]!]})` : ''}`);
343
+ lines.push(` commits (${p.commits.length}, ${at(p.commits[0]!)} → ${at(p.commits[p.commits.length - 1]!)}):`);
344
+ for (const c of p.commits) lines.push(` - ${short(c.hash)} ${at(c)} ${clip(c.subject, 160)}`);
345
+ if (p.numbered.length) lines.push(` on the same branch, carrying a task number: ${p.numbered.map((x) => `${short(x.commit.hash)} (${x.tasks.join(', ')})`).join(', ')}`);
346
+ if (p.names.length) lines.push(` numbers its messages name: ${p.names.map((x) => `${x.num}${x.count > 1 ? ` ×${x.count}` : ''}`).join(', ')}`);
347
+ if (p.workNamed.length) lines.push(` work items among them: ${p.workNamed.map((w) => `${w.label} (${w.workId})`).join('; ')}`);
348
+ if (p.definedIn.length) lines.push(` defined in one work item's own document: ${p.definedIn.map((d) => `${d.num} → ${d.work}`).join('; ')}`);
349
+ if (p.workByFiles.length) lines.push(` numbered work whose own commits changed the same files: ${p.workByFiles.map((w) => `${w.label} (${w.workId}) ${w.files} file${w.files === 1 ? '' : 's'}`).join('; ')}`);
350
+ if (p.territories.length) lines.push(` code territories: ${p.territories.map((t) => `${t.name}${t.area ? ` (serves ${t.area})` : ''} ${t.files} file${t.files === 1 ? '' : 's'}`).join('; ')}`);
351
+ if (p.dirs.length) lines.push(` ${p.territories.length ? 'outside the territories' : 'directories'}: ${p.dirs.map((d) => `${d.dir} ${d.files}`).join(', ')}`);
352
+ if (p.records.length) {
353
+ lines.push(` records that name ${p.branch} (${p.records.length}; the first line naming it in each):`);
354
+ for (const r of p.records) lines.push(` · ${r.id}: “${clip(r.snippet.replace(/\s+/g, ' ').trim(), 160)}”`);
355
+ }
356
+ if (p.tied.length) lines.push(` tied by a process link to: ${p.tied.map((t) => `${t.label} (${t.workId}) as ${t.stepKind}${t.confirmed ? ', confirmed' : ''}${t.namedInResults ? '' : ' — its results do not name it yet'}`).join('; ')}`);
357
+ return lines;
358
+ }
359
+
360
+ /** One line for a piece listed without its details (the ledger has them). */
361
+ function pieceLine(ledger: Ledger, p: UnnumberedPiece): string {
362
+ const span = ` (${at(p.commits[0]!)} → ${at(p.commits[p.commits.length - 1]!)})`;
363
+ const names = p.names.slice(0, 8).map((x) => x.num).join(', ');
364
+ return `- ${pieceTitle(ledger, p)} · ${p.commits.length} commit${p.commits.length === 1 ? '' : 's'}${span} · ${sizeText(p.size)}${p.merges.length ? ` · merged ${p.merges.map((m) => short(m.merge.hash)).join(', ')}` : ''}${names ? ` · names ${names}${p.names.length > 8 ? ' …' : ''}` : ''}`;
365
+ }
366
+
367
+ export interface UnnumberedBlockOptions {
368
+ /** A Follow up: what was merged since this time is listed in full; what was merged before and is still tied to nothing, one line each. */
369
+ readonly since?: string | null;
370
+ /** The ledger's facts and the units, when the caller already read them for another block of the same step. */
371
+ readonly analysis?: (() => { readonly facts: Facts; readonly index: UnitIndex } | null) | null;
372
+ }
373
+
374
+ /**
375
+ * The block for the skeleton (and a Follow up's cross-check): each piece of merged work with no task number, with what
376
+ * the step judges it from. The owner's rule is in the method text; the program only lists. Work of an earlier generation
377
+ * (merged before a generation the round recorded ended) is history: one line each, to put under its generation when it
378
+ * matters, never as current work (D82).
379
+ */
380
+ export function unnumberedWorkBlock(store: ProjectStore, project: Project, ledger: Ledger | null, opts: UnnumberedBlockOptions = {}): string {
381
+ const head = "=== Work merged without a task number (the program's list from the ledger: merged branches and direct commits whose messages carry none of the project's task numbers and that no process link ties to a work item; apply the owner's rule to each)";
382
+ if (!ledger) return `${head}\nThe ledger is not available, so no merged work was listed.`;
383
+ void project;
384
+ let pieces: UnnumberedPiece[] & { recordsLeftOut?: number };
385
+ try {
386
+ const shared = opts.analysis?.() ?? null;
387
+ const facts = shared?.facts ?? new Facts(ledger);
388
+ pieces = unnumberedWork(store, facts, shared?.index ?? buildUnits(store, facts));
389
+ } catch (e) { return `${head}\nThe ledger could not be read for it: ${(e as Error).message}`; }
390
+ const sinceMs = opts.since ? Date.parse(opts.since) || 0 : 0;
391
+ const generations = store.generations.all().map((g) => ({ g, ms: Date.parse(g.ended.at) || 0 })).filter((x) => x.ms > 0).sort((a, b) => a.ms - b.ms);
392
+ const generationOf = (p: UnnumberedPiece) => generations.find((x) => p.lastMs <= x.ms)?.g ?? null;
393
+ const work = pieces.filter((p) => p.work);
394
+ const open = work.filter((p) => !p.tied.length);
395
+ const history = open.filter((p) => generationOf(p) !== null);
396
+ const current = open.filter((p) => generationOf(p) === null);
397
+ const recent = sinceMs ? current.filter((p) => p.lastMs >= sinceMs) : current;
398
+ const earlier = sinceMs ? current.filter((p) => p.lastMs < sinceMs) : [];
399
+ const tied = work.filter((p) => p.tied.length);
400
+ const docsOnly = pieces.filter((p) => !p.work && !p.tied.length);
401
+ const lines: string[] = [head];
402
+ lines.push([
403
+ `${work.length} piece${work.length === 1 ? '' : 's'} changed code, tests or configuration:`,
404
+ `${recent.length} of the current generation tied to no work item${sinceMs ? `, merged since ${opts.since!.slice(0, 16).replace('T', ' ')}` : ''} (in full below)`,
405
+ ...(earlier.length ? [`${earlier.length} merged before that and still tied to none`] : []),
406
+ ...(history.length ? [`${history.length} of an earlier generation`] : []),
407
+ `${tied.length} already tied to a work item by a process link.`,
408
+ `Left out: ${docsOnly.length} merged branch${docsOnly.length === 1 ? '' : 'es'} and ${pieces.recordsLeftOut ?? 0} direct commit${pieces.recordsLeftOut === 1 ? '' : 's'} that changed only documents and execution records.`,
409
+ ].join(' · '));
410
+ recent.forEach((p, i) => lines.push(...pieceLines(ledger, p, i + 1)));
411
+ if (earlier.length) {
412
+ lines.push(`Merged before the last round and still tied to no work item (${earlier.length}; pk_ledger_commit or pk_ledger_commits for any of them in full):`);
413
+ for (const p of earlier) lines.push(pieceLine(ledger, p));
414
+ }
415
+ for (const x of generations) {
416
+ const of = history.filter((p) => generationOf(p)?.id === x.g.id);
417
+ if (!of.length) continue;
418
+ lines.push(`Of the earlier generation “${x.g.name}” (ended ${x.g.ended.at.slice(0, 16).replace('T', ' ')}; history — pk_ledger_commit for any of them in full) (${of.length}):`);
419
+ for (const p of of) lines.push(pieceLine(ledger, p));
420
+ }
421
+ if (tied.length) {
422
+ lines.push(`Already tied to a work item (${tied.length}):`);
423
+ for (const p of tied) lines.push(`- ${pieceTitle(ledger, p)} → ${p.tied.map((t) => `${t.label} (${t.workId}) as ${t.stepKind}${t.namedInResults ? '' : ' — its results do not name it yet'}`).join('; ')}`);
424
+ }
425
+ if (docsOnly.length) {
426
+ lines.push(`Merged branches that changed only documents and execution records (${docsOnly.length}):`);
427
+ for (const p of docsOnly) lines.push(pieceLine(ledger, p));
428
+ }
429
+ if (!work.length && !docsOnly.length) lines.push('None: every merged branch and direct commit carries a task number or is tied to a work item.');
430
+ return lines.join('\n');
431
+ }