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,194 @@
1
+ /**
2
+ * The Keeper's word on the project scope (Spec §1.1, §3.9, §6.7; CKC-04 AC-13, AC-16, AC-17; CKC-02 AC-25).
3
+ *
4
+ * The program offers candidates for third-party material and generated output, counts what the ignore rules leave out
5
+ * and measures worktrees against the trunk; what a location *is* when the records cannot tell is the Keeper's
6
+ * judgement, recorded here with a reason, what shows it and a basis, and corrected by the owner in the conversation.
7
+ * A question only the owner can settle goes into the scope questions Project scope already shows (§3.9).
8
+ *
9
+ * These tools write assets only; what is read from a location follows at the next scope pass and intake.
10
+ */
11
+ import { existsSync, statSync } from 'node:fs';
12
+ import { isAbsolute, join } from 'node:path';
13
+ import { Type } from 'typebox';
14
+ import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
15
+ import type { ScopeItem, ScopeJudgement, ScopeQuestion, Source } from '../model/types.ts';
16
+ import type { ScopeRelation } from '../model/vocab.ts';
17
+ import { BASIS, isOneOf } from '../model/vocab.ts';
18
+ import { stableId } from '../model/ids.ts';
19
+ import { anchorLabel, redactCredentials } from '../sources/anchor.ts';
20
+ import { canonicalPath, isWithin, normalizePath, pathKey, samePath } from '../util/paths.ts';
21
+ import { JUDGED_RELATIONS, applyDecisions, keeperQuestionId } from '../scope/decisions.ts';
22
+ import { isDocumentPath, treatmentOf } from '../scope/skip.ts';
23
+ import { ruleUseRefusal } from './rules.ts';
24
+ import type { ToolContext } from './tools.ts';
25
+
26
+ const text = (v: unknown): string => (typeof v === 'string' ? v : '');
27
+ const arr = <T>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []);
28
+ const redact = (s: string) => redactCredentials(s).text;
29
+ const now = () => new Date().toISOString();
30
+ const ok = (value: unknown) => ({ content: [{ type: 'text' as const, text: JSON.stringify(value, null, 1) }], details: {} });
31
+ const fail = (message: string) => ({ content: [{ type: 'text' as const, text: `ERROR: ${message}` }], details: {}, isError: true });
32
+
33
+ /** The id discovery gives the item at this location, so a source's marker names the same item Project scope lists. */
34
+ function itemIdAt(scope: readonly ScopeItem[], path: string): string {
35
+ const listed = scope.find((i) => i.category !== 'Session source' && samePath(i.path, path));
36
+ if (listed) return listed.id;
37
+ return stableId('scope', existsSync(join(path, '.git')) ? 'repo' : 'dir', pathKey(path));
38
+ }
39
+
40
+ /**
41
+ * Bring the `Used as` of the sources already read under `path` in line with its classification (§1.1): the documents
42
+ * of third-party material are `Reference only`; when the classification changes, what it set goes with it. What the
43
+ * owner judged one by one stays, and so does what the Keeper judged one by one.
44
+ */
45
+ export function syncScopeUsedAs(store: ToolContext['store'], path: string, scopeItemId: string, relation: ScopeRelation, by: 'keeper' | 'owner', trace: { jobId: string | null; summary: string }): { referenceOnly: number; reset: number } {
46
+ let referenceOnly = 0;
47
+ let reset = 0;
48
+ const thirdParty = relation === 'Third-party material';
49
+ for (const s of store.sources.all()) {
50
+ if (s.anchor.kind !== 'file' || !isWithin(path, s.anchor.path)) continue;
51
+ const marker = s.usedAsByScopeItemId ?? null;
52
+ if (thirdParty) {
53
+ if (!isDocumentPath(s.anchor.path) || (s.usedAs === 'Reference only' && marker === scopeItemId)) continue;
54
+ if (s.usedAs !== null && marker === null) continue; // judged one by one, by the owner or the Keeper: that stands
55
+ store.sources.put({ ...s, usedAs: 'Reference only', usedAsBy: by, usedAsByScopeItemId: scopeItemId } as Source, { jobId: trace.jobId, basisSourceIds: [s.id], summary: `Used as Reference only: ${trace.summary}` });
56
+ referenceOnly += 1;
57
+ } else if (marker === scopeItemId) {
58
+ store.sources.put({ ...s, usedAs: null, usedAsBy: null, usedAsByScopeItemId: null } as Source, { jobId: trace.jobId, basisSourceIds: [s.id], summary: `Used as no longer set by the scope: ${trace.summary}` });
59
+ reset += 1;
60
+ }
61
+ }
62
+ return { referenceOnly, reset };
63
+ }
64
+
65
+ /** After the scope is drawn: every third-party location's documents are Reference only, and a location no longer third-party takes back what it set. */
66
+ export function syncAllScopeUsedAs(store: ToolContext['store'], scope: readonly ScopeItem[]): void {
67
+ const reading = scope.filter((i) => treatmentOf(i) === 'documents');
68
+ for (const item of reading) syncScopeUsedAs(store, item.path, item.id, 'Third-party material', item.classification?.by === 'owner' ? 'owner' : 'keeper', { jobId: null, summary: `${item.path}: Third-party material` });
69
+ const ids = new Set(reading.map((i) => i.id));
70
+ for (const s of store.sources.all()) {
71
+ if (!s.usedAsByScopeItemId || ids.has(s.usedAsByScopeItemId)) continue;
72
+ store.sources.put({ ...s, usedAs: null, usedAsBy: null, usedAsByScopeItemId: null } as Source, { jobId: null, basisSourceIds: [s.id], summary: 'Used as no longer set by the scope: its location is not third-party material any more' });
73
+ }
74
+ }
75
+
76
+ export function scopeTools(ctx: ToolContext): ToolDefinition[] {
77
+ const { store, project } = ctx;
78
+ const saved = (collection: string, id: string, label: string) => ctx.onSaved?.(collection, id, label);
79
+ const locations = project.locations.map((l) => canonicalPath(l));
80
+ const resolve = (raw: string): string | null => {
81
+ const p = raw.trim().replace(/^[`'"“”]+|[`'"“”]+$/g, '');
82
+ if (!p) return null;
83
+ if (isAbsolute(p)) return canonicalPath(p);
84
+ for (const l of locations) { const full = normalizePath(join(l, p.replace(/[\\/]+$/, ''))); if (existsSync(full)) return full; }
85
+ return locations[0] ? normalizePath(join(locations[0], p.replace(/[\\/]+$/, ''))) : null;
86
+ };
87
+ const inProject = (path: string) => locations.some((l) => isWithin(l, path)) || project.scope.some((i) => i.category !== 'Session source' && isWithin(i.path, path));
88
+ const current = () => applyDecisions<ScopeItem>(project.scope.map((i) => ({ ...i })), {
89
+ locations, rules: store.rules.all(), judgements: store.scopeJudgements.all(), questions: project.scopeQuestions,
90
+ sourceLabel: (id) => { const s = store.sources.get(id); return s ? anchorLabel(s.anchor) : null; },
91
+ resolveDir: (words) => { const full = resolve(words); try { return full && inProject(full) && statSync(full).isDirectory() ? full : null; } catch { return null; } },
92
+ newItem: (path, relation) => ({ id: itemIdAt(project.scope, path), path, category: existsSync(join(path, '.git')) ? 'Repository' : 'Directory', relation, reason: '', reasonSourceIds: [], sessionHost: null, readOnly: false, copyOf: null, worktreeOf: null, versionControl: 'unknown', missing: null, addedBy: 'keeper' }),
93
+ });
94
+
95
+ return [
96
+ defineTool({
97
+ name: 'pk_classify_scope', label: 'Classify a scope location',
98
+ description: `Record what a location in the project is, when the records alone cannot tell: ${JUDGED_RELATIONS.join(', ')}. Third-party material is what the project uses but did not write — SDKs, libraries, templates distributed with it; its documents become Reference only (citable, never this project's requirement, plan or work) and its code and history are not organized. Generated is build output, caches, exports: not organized. Main project (or Nested repository for a repository of its own) takes a candidate back as the project's own material; Experiment is for what the project calls an experiment; Excluded leaves a location out. Project scope lists the program's candidates ("candidate, not yet judged by the Keeper") — see pk_scope_items — and you can classify any other directory inside the project. Give the reason in your words, what shows it (sourceIds of material that says it; evidence for what shows it when no source does: a marker file, an upstream remote, file headers), and the basis: Explicit only when the project itself says so (a document, or the project's rule — ruleId), otherwise Inferred. When you cannot tell and it would change the result — for example an unmerged worktree you cannot tie to any work — record your best reading and put the question to the owner with askOwner. In the owner's conversation, ownerCorrection records the owner correcting it; the owner's word then stands over yours.`,
99
+ parameters: Type.Object({
100
+ path: Type.String({ description: 'the directory: absolute, or relative to the project root' }),
101
+ relation: Type.String({ description: JUDGED_RELATIONS.join(' | ') }),
102
+ reason: Type.String({ description: 'why, in your words; Project scope shows it' }),
103
+ sourceIds: Type.Optional(Type.Array(Type.String(), { description: 'sources that show it: the directory’s README or LICENSE, a project document naming it' })),
104
+ evidence: Type.Optional(Type.Array(Type.String(), { description: 'what shows it when no source does: a marker file, an upstream remote, a link target, generated-file headers' })),
105
+ basis: Type.Optional(Type.String({ description: 'Explicit (the project says so) | Inferred (default: you conclude it)' })),
106
+ ruleId: Type.Optional(Type.String({ description: 'the project’s rule that settles it' })),
107
+ askOwner: Type.Optional(Type.Object({ question: Type.String(), whyItMatters: Type.String(), clues: Type.Optional(Type.Array(Type.String())), options: Type.Array(Type.String()) }, { description: 'a question only the owner can settle; name the relations as options when the answer is one of them' })),
108
+ ownerCorrection: Type.Optional(Type.Object({ quote: Type.String({ description: 'the owner’s words' }) })),
109
+ }),
110
+ execute: async (_id, p) => {
111
+ const path = resolve(text(p.path));
112
+ if (!path) return fail('path: the directory to classify, absolute or relative to the project root.');
113
+ if (!inProject(path)) return fail(`${text(p.path)} is not in this project (its locations: ${locations.join(', ')}; or a listed scope item). Classify a location inside the project.`);
114
+ if (locations.some((l) => samePath(l, path))) return fail(`${path} is one of the project’s own locations, given by the owner; what the project is changes in Project scope, by the owner. Classify a location inside it.`);
115
+ if (!existsSync(path) && !project.scope.some((i) => samePath(i.path, path))) return fail(`${path} is not on disk.`);
116
+ if (!isOneOf(JUDGED_RELATIONS, p.relation)) return fail(`relation must be one of ${JUDGED_RELATIONS.join(', ')}. Third-party material and Generated are listed apart from the project's own material.`);
117
+ const relation = p.relation;
118
+ const reason = redact(text(p.reason)).trim();
119
+ if (!reason) return fail('Give the reason in your words (reason); Project scope shows it beside the location.');
120
+ if (relation === 'Nested repository' && !existsSync(join(path, '.git'))) return fail(`${path} is not a git repository of its own (no .git), so it cannot be a Nested repository; its own material is Main project.`);
121
+ const correction = p.ownerCorrection;
122
+ if (correction !== undefined && !ctx.ownerSourceId) return fail('Only the owner’s message in the conversation can correct a classification. Outside it, record your own reading; if the owner should settle it, use askOwner.');
123
+ const quote = correction ? redact(text(correction.quote)).trim() : '';
124
+ if (correction && !quote) return fail('ownerCorrection.quote: the owner’s words.');
125
+ const by = correction ? 'owner' as const : 'keeper' as const;
126
+ const unknown = arr<string>(p.sourceIds).filter((s) => !store.sources.has(s));
127
+ if (unknown.length) return fail(`sourceIds names no source in the assets: ${unknown.join(', ')}`);
128
+ const ruleId = text(p.ruleId).trim() || null;
129
+ if (ruleId) { const refusal = ruleUseRefusal(store, ruleId, 'Validity'); if (refusal) return fail(refusal); }
130
+ const sourceIds = [...new Set([...arr<string>(p.sourceIds), ...(ruleId ? store.rules.get(ruleId)!.sourceIds : []), ...(correction ? [ctx.ownerSourceId!] : [])])].filter((s) => store.sources.has(s));
131
+ const evidence = arr<string>(p.evidence).map((e) => redact(text(e)).trim()).filter(Boolean);
132
+ const basisGiven = correction || ruleId ? 'Explicit' : (text(p.basis) || 'Inferred');
133
+ if (!isOneOf(BASIS, basisGiven)) return fail('basis must be Explicit (the project itself says so) or Inferred (you conclude it)');
134
+ if (basisGiven === 'Explicit' && !correction && !ruleId && arr<string>(p.sourceIds).length === 0) {
135
+ return fail('Explicit is for what the project itself says: cite the material that says it (sourceIds) or the project’s rule (ruleId). What you conclude from how the location looks is Inferred.');
136
+ }
137
+ if (basisGiven === 'Inferred' && sourceIds.length === 0 && evidence.length === 0) {
138
+ return fail('Say what shows it (the reason comes with its source): sourceIds of material that shows it, or evidence — a marker file, an upstream remote, a link target, file headers — when no source does.');
139
+ }
140
+ const id = stableId('scopej', project.id, pathKey(path));
141
+ const previous = store.scopeJudgements.get(id);
142
+ if (previous?.by === 'owner' && !correction) {
143
+ return fail(`The owner corrected this location on ${previous.at.slice(0, 10)}${previous.ownerQuote ? `: “${previous.ownerQuote}”` : ''} (${previous.relation}). The owner’s word stands; if the material says otherwise, write a note to the owner.`);
144
+ }
145
+ let question: ScopeJudgement['question'] = null;
146
+ if (p.askOwner !== undefined) {
147
+ if (correction) return fail('askOwner is for a question you put to the owner; the owner’s correction already settles it.');
148
+ const q = p.askOwner;
149
+ const options = arr<string>(q.options).map((o) => text(o).trim()).filter(Boolean);
150
+ if (!text(q.question).trim() || !text(q.whyItMatters).trim() || options.length < 2) return fail('askOwner needs the question, whyItMatters (what changes with the answer) and at least two options.');
151
+ question = { id: keeperQuestionId(path), question: redact(text(q.question)).trim(), whyItMatters: redact(text(q.whyItMatters)).trim(), clues: arr<string>(q.clues).map((c) => redact(text(c)).trim()).filter(Boolean), options };
152
+ }
153
+ const at = now();
154
+ const judgement: ScopeJudgement = {
155
+ id, projectId: project.id, path, relation, reason, sourceIds, evidence, basis: basisGiven, ruleId, by, ownerQuote: quote || null, question,
156
+ previous: previous ? { relation: previous.relation, reason: previous.reason, by: previous.by, at: previous.at } : null, jobId: ctx.jobId, at,
157
+ };
158
+ const summary = `${path}: ${relation} (${by}, ${basisGiven})`;
159
+ store.scopeJudgements.put(judgement, { jobId: ctx.jobId, basisSourceIds: sourceIds, summary: `Scope classification — ${summary}` });
160
+ const synced = syncScopeUsedAs(store, path, itemIdAt(project.scope, path), relation, by, { jobId: ctx.jobId, summary });
161
+ saved('scopeJudgements', id, `Scope: ${relation} — ${path}`);
162
+ return ok({
163
+ path, relation, by, basis: basisGiven, ...(question ? { askedOwner: question.question } : {}),
164
+ documentsNowReferenceOnly: synced.referenceOnly, usedAsReset: synced.reset,
165
+ note: 'Project scope shows it once the scope is drawn again, in a few seconds; what is read from the location follows.',
166
+ });
167
+ },
168
+ }),
169
+ defineTool({
170
+ name: 'pk_scope_items', label: 'Read the project scope',
171
+ description: 'The project scope with what the records say about each location: which: candidates — third-party material and generated output the program offered and you have not judged yet, with its evidence; worktrees — each registered worktree measured against the trunk (merged or not, commits the trunk lacks, files skipped as the same or older, files taken); ignored — what the project’s ignore rules leave out, by directory, with the rule and how many documents; rules — locations a project rule covers; judged — what you or the owner classified; all (default). Also the scope questions put to the owner, with their answers.',
172
+ parameters: Type.Object({ which: Type.Optional(Type.String({ description: 'all | candidates | worktrees | ignored | rules | judged' })) }),
173
+ execute: async (_id, p) => {
174
+ const which = text(p.which) || 'all';
175
+ const view = current();
176
+ const pick = (i: ScopeItem) => which === 'all' ? true
177
+ : which === 'candidates' ? i.classification?.by === 'program'
178
+ : which === 'worktrees' ? i.worktree != null
179
+ : which === 'ignored' ? i.ignoredBy != null
180
+ : which === 'rules' ? (i.coveredBy ?? []).length > 0
181
+ : which === 'judged' ? i.classification != null && i.classification.by !== 'program' : false;
182
+ const items = view.items.filter((i) => i.category !== 'Session source' && pick(i)).map((i) => ({
183
+ id: i.id, path: i.path, category: i.category, relation: i.relation, reason: i.reason, read: treatmentOf(i),
184
+ ...(i.classification ? { classification: i.classification } : {}),
185
+ ...(i.worktree ? { worktree: i.worktree } : {}),
186
+ ...(i.ignoredBy ? { ignoredBy: i.ignoredBy } : {}),
187
+ ...((i.coveredBy ?? []).length ? { coveredBy: i.coveredBy } : {}),
188
+ }));
189
+ const questions: ScopeQuestion[] = [...project.scopeQuestions, ...view.questions.filter((q) => !project.scopeQuestions.some((x) => x.id === q.id))];
190
+ return ok({ items, questions: questions.map((q) => ({ id: q.id, question: q.question, options: q.options, answer: q.answer?.text ?? null })) });
191
+ },
192
+ }),
193
+ ];
194
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * What `Copy for agent` puts on the clipboard for a send-back (Spec §1.18, §7.10; CKC-24 AC-9; D79): where the problem
3
+ * is, the evidence — numbers, commits, files and lines, the report's own line word for word — where it should go back
4
+ * to, and the CLI command that shows the same send-back. The owner pastes it to an execution agent or an orchestrator;
5
+ * the agent runs the command and sees the same record the owner sees.
6
+ *
7
+ * Built only from the record and its resolved evidence: nothing here reads the assets, and nothing the model wrote
8
+ * becomes a date or a label (§2.11). Credential values never leave in it (§3.1).
9
+ */
10
+ import type { EvidenceRef, Occurred, SendBack } from '../model/k-types.ts';
11
+ import { materialDate } from '../model/time.ts';
12
+ import { redactCredentials } from '../sources/anchor.ts';
13
+
14
+ /** The command an execution agent runs to see the same send-back (§7.10): the CLI's `pk get <id>`. */
15
+ export function cliCommandFor(projectId: string, id: string): string {
16
+ const quote = (v: string) => (/^[A-Za-z0-9._:-]+$/.test(v) ? v : `"${v.replace(/"/g, '\\"')}"`);
17
+ return `pk get ${quote(id)} --project ${quote(projectId)}`;
18
+ }
19
+
20
+ const BASIS_WORDS: Record<Occurred['basis'], string> = {
21
+ Commit: 'committed',
22
+ Session: 'said',
23
+ 'Written in text': 'dated in the text',
24
+ 'File time': 'file time',
25
+ 'First observed': 'first seen',
26
+ };
27
+
28
+ /** When a piece of evidence happened, as the text says it: "committed 2026-09-12", "undated · first seen 2026-09-26". */
29
+ export function occurredText(o: Occurred | null | undefined): string | null {
30
+ if (!o) return null;
31
+ const main = o.undated ? `undated · first seen ${materialDate(o.at)}` : `${BASIS_WORDS[o.basis] ?? o.basis} ${materialDate(o.at)}`;
32
+ return o.other ? `${main} · ${BASIS_WORDS[o.other.basis] ?? o.other.basis} ${materialDate(o.other.at)}` : main;
33
+ }
34
+
35
+ const KIND_WORDS: Record<EvidenceRef['kind'], string> = { commit: 'Commit', file: 'File', source: 'Source', ledger: 'Ledger', object: 'Record' };
36
+
37
+ /** One piece of evidence: what it is (and in which repository, outside the first), when it happened, the original line. */
38
+ function evidenceLine(e: EvidenceRef): string {
39
+ const named = e.kind === 'object' ? `${e.label} (${e.id})` : e.label;
40
+ const what = e.repo && !named.includes(e.repo) ? `${named} in ${e.repo}` : named;
41
+ const when = occurredText(e.occurred);
42
+ const head = `- ${KIND_WORDS[e.kind]} ${what}${when ? ` (${when})` : ''}`;
43
+ if (!e.line) return head;
44
+ const lines = e.line.split(/\r?\n/);
45
+ // One line stays on the entry; a cited stretch of several lines keeps its lines, each quoted.
46
+ return lines.length === 1 ? `${head}: “${e.line}”` : `${head}:\n${lines.map((l) => ` > ${l}`).join('\n')}`;
47
+ }
48
+
49
+ const FROM_WORDS: Record<SendBack['from']['kind'], string> = {
50
+ breakpoint: 'a breakpoint in the process',
51
+ verdict: 'a QC, review or walkthrough verdict',
52
+ 'owner-judgement': 'one of the things the owner judges',
53
+ 'code-anomaly': 'a code territory anomaly',
54
+ };
55
+
56
+ export interface CopyForAgentOptions {
57
+ /** The object it hangs on, by name and the project's number (or the Keeper's), when the caller knows them. */
58
+ readonly target?: { readonly label: string; readonly number?: string | null } | null;
59
+ }
60
+
61
+ /**
62
+ * The text `Copy for agent` puts on the clipboard. `resolvedEvidence` is the send-back's evidence as it resolves now
63
+ * (normally its own `evidence`).
64
+ */
65
+ export function copyForAgentText(projectId: string, sendBack: SendBack, resolvedEvidence: readonly EvidenceRef[] = sendBack.evidence, options: CopyForAgentOptions = {}): string {
66
+ const target = options.target;
67
+ const on = target ? `${target.number ? `${target.number} ` : ''}${target.label} (${sendBack.targetId})` : sendBack.targetId;
68
+ const back = sendBack.to === 'Work'
69
+ ? `Send it back to Work — reopen the work or open new work: ${sendBack.suggestion}`
70
+ : `Send it back to Plan — change the plan or the document: ${sendBack.suggestion}`;
71
+ const since = occurredText(sendBack.occurred);
72
+ const lines = [
73
+ `Send-back ${sendBack.id} · ${sendBack.stage} · to ${sendBack.to} (project ${projectId})`,
74
+ `Where the problem is: ${sendBack.what}`,
75
+ `On: ${on}`,
76
+ `Found as: ${FROM_WORDS[sendBack.from.kind]} (${sendBack.from.id})${since ? ` · happened: ${since}` : ''}`,
77
+ 'Evidence:',
78
+ ...(resolvedEvidence.length ? resolvedEvidence.map(evidenceLine) : ['- none recorded']),
79
+ back,
80
+ `See the same send-back: ${cliCommandFor(projectId, sendBack.id)}`,
81
+ ];
82
+ return redactCredentials(lines.join('\n')).text;
83
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Where one job's time went (Spec §3.10, D81; CKC-23 AC-10, CKC-03 AC-19): model generation, tool execution, queueing,
3
+ * parsing and retries, and what cannot be told apart.
4
+ *
5
+ * pi's session events carry no times, so the runtime stamps them as they arrive. An assistant message carries the moment
6
+ * its request was sent (`timestamp`); its end is when `message_end` arrives, so generation includes building the request,
7
+ * the network, the provider's queue, time to first token and streaming — pi does not separate them. Parallel tool calls
8
+ * of one turn count once, as the span from the first start to the last end.
9
+ *
10
+ * Parsing and retrying, counted the way the test-B-1 baseline counted them (E106), so the two runs compare:
11
+ * - a request that ended in an error or was aborted, and the wait before pi sent it again (`auto_retry_start`);
12
+ * - the turn right after a turn whose tool calls were all refused (bad or incomplete arguments, an unknown tool),
13
+ * when it sends a tool call again: it re-sends them. The refused turn itself stays generation, and so does a next
14
+ * turn that only answers (QC AX-1: a final answer after a refusal was counted as a re-send).
15
+ */
16
+ import { createHash } from 'node:crypto';
17
+ import type { StepTiming } from '../model/k-types.ts';
18
+
19
+ /** A tool result that says the call never ran: its arguments failed validation or did not arrive complete (pi's
20
+ * "was not executed" — cut off at the output limit, or not complete JSON), or no tool has that name. A pk_* write
21
+ * tool's own refusal of a call that leaves out what a new item needs says "Invalid arguments" (`incompleteCall`). */
22
+ const REFUSED = /validation failed|invalid (tool )?arguments|must have required property|unknown tool|tool .{0,80} not found|no tool named|was not executed/i;
23
+ /** pi's refusal of a call whose arguments did not arrive whole (cut off at the output limit, or not complete JSON). */
24
+ const CUT_OFF = /was not executed/i;
25
+
26
+ /** The call behind this tool result never ran. */
27
+ export function isRefusal(isError: boolean, resultText: string): boolean {
28
+ return isError && REFUSED.test(resultText);
29
+ }
30
+
31
+ /**
32
+ * How a pk_* write tool refuses a call that would create an item but leaves out what a new item needs (a work item's
33
+ * title and progress, a round document's markdown …). Those fields used to be required by the tools' schemas, so pi
34
+ * refused such a call before it ran; they are optional now, because an update gives only what it changes, and the tool
35
+ * refuses the incomplete create itself, before it writes anything. It throws, so pi records the result as an error,
36
+ * and its words begin "Invalid arguments", so the timer and the refusal guard read it as the refusal of a call that
37
+ * never ran — as they read pi's own refusal of the same call before. `message` says what is missing and how to update.
38
+ */
39
+ export function incompleteCall(message: string): Error {
40
+ return new Error(`Invalid arguments: ${message}`);
41
+ }
42
+
43
+ /** The message sends at least one tool call. */
44
+ function sendsToolCall(message: { readonly stopReason?: string; readonly content?: readonly { readonly type: string }[] }): boolean {
45
+ return message.stopReason === 'toolUse' || (message.content ?? []).some((c) => c.type === 'toolCall');
46
+ }
47
+
48
+ export class StepTimer {
49
+ private generationMs = 0;
50
+ private toolMs = 0;
51
+ private parseRetryMs = 0;
52
+ private openTools = 0;
53
+ private toolSpanStart = 0;
54
+ private turnCalls = 0;
55
+ private turnRefused = 0;
56
+ private reissuePending = false;
57
+
58
+ /** An assistant message ended at `at`; its `timestamp` is when its request was sent. */
59
+ assistantEnded(message: { readonly timestamp?: number | string; readonly stopReason?: string; readonly content?: readonly { readonly type: string }[] }, at: number): void {
60
+ const sent = typeof message.timestamp === 'number' ? message.timestamp : typeof message.timestamp === 'string' ? Date.parse(message.timestamp) : Number.NaN;
61
+ const ms = Number.isFinite(sent) ? Math.max(0, at - sent) : 0;
62
+ const resends = this.reissuePending && sendsToolCall(message);
63
+ if (message.stopReason === 'error' || message.stopReason === 'aborted' || resends) this.parseRetryMs += ms;
64
+ else this.generationMs += ms;
65
+ this.reissuePending = false;
66
+ this.turnCalls = 0;
67
+ this.turnRefused = 0;
68
+ }
69
+
70
+ toolStarted(at: number): void {
71
+ if (this.openTools === 0) this.toolSpanStart = at;
72
+ this.openTools += 1;
73
+ this.turnCalls += 1;
74
+ }
75
+
76
+ toolEnded(at: number, isError: boolean, resultText: string): void {
77
+ if (isRefusal(isError, resultText)) this.turnRefused += 1;
78
+ this.openTools = Math.max(0, this.openTools - 1);
79
+ if (this.openTools === 0) {
80
+ this.toolMs += Math.max(0, at - this.toolSpanStart);
81
+ if (this.turnCalls > 0 && this.turnRefused === this.turnCalls) this.reissuePending = true;
82
+ }
83
+ }
84
+
85
+ /** pi waits before re-sending a failed request (`auto_retry_start`). */
86
+ retryWait(delayMs: number): void {
87
+ if (Number.isFinite(delayMs) && delayMs > 0) this.parseRetryMs += delayMs;
88
+ }
89
+
90
+ /** The job's timing, given when it was queued, started and ended (ISO times or epoch milliseconds). */
91
+ finish(queuedAt: string | number, startedAt: string | number, endedAt: string | number): StepTiming {
92
+ const t = (v: string | number) => (typeof v === 'number' ? v : Date.parse(v));
93
+ const wallMs = Math.max(0, t(endedAt) - t(startedAt));
94
+ const queueMs = Math.max(0, t(startedAt) - t(queuedAt));
95
+ const known = this.generationMs + this.toolMs + this.parseRetryMs;
96
+ return {
97
+ wallMs, queueMs,
98
+ generationMs: Math.round(this.generationMs), toolMs: Math.round(this.toolMs), parseRetryMs: Math.round(this.parseRetryMs),
99
+ otherMs: Math.max(0, Math.round(wallMs - known)),
100
+ };
101
+ }
102
+ }
103
+
104
+ /**
105
+ * How many turns in a row the same call may be refused the same way before the step is ended. pi's tool loop has no
106
+ * bound of its own: a model that keeps re-sending a refused call goes on until the step's time limit (30 minutes for
107
+ * automatic work, none for the owner's). QC AX-1 observed exactly that — a call re-sent with the same missing field
108
+ * turn after turn, ended only by the test double's own six-turn safety valve. Eight is generous: a model that fixes its
109
+ * call within a few tries, or moves on to other calls, never reaches it.
110
+ */
111
+ export const REPEATED_REFUSAL_LIMIT = 8;
112
+
113
+ /** A call refused the same way in `turns` turns in a row; `alongside`: how many other calls reached the limit with it. */
114
+ export interface RepeatedRefusal { readonly tool: string; readonly error: string; readonly turns: number; readonly alongside: number }
115
+
116
+ /** One tool call of a turn as the guard reads it: the tool, the arguments the model sent, and how the call ended. */
117
+ export interface TurnCall { readonly tool: string; readonly args: unknown; readonly isError: boolean; readonly resultText: string }
118
+
119
+ /** One turn: how the reply stopped, and every tool call it sent with its result. */
120
+ export interface GuardTurn { readonly stopReason?: string; readonly calls: readonly TurnCall[] }
121
+
122
+ /** A refusal's error, without the arguments pi echoes after a validation error, whitespace collapsed. */
123
+ function refusalError(resultText: string): string {
124
+ return (resultText.split(/\n\s*\nReceived arguments:/)[0] ?? resultText).replace(/\s+/g, ' ').trim();
125
+ }
126
+
127
+ /** The arguments with their keys in one order, so the same call sent twice reads the same however its keys came. */
128
+ function stableJson(value: unknown): string {
129
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
130
+ if (value !== null && typeof value === 'object') {
131
+ const o = value as Record<string, unknown>;
132
+ return `{${Object.keys(o).sort().filter((k) => o[k] !== undefined).map((k) => `${JSON.stringify(k)}:${stableJson(o[k])}`).join(',')}}`;
133
+ }
134
+ return JSON.stringify(value) ?? 'null';
135
+ }
136
+
137
+ /** Which refused call this is: its tool, its error, and — when they arrived whole — its arguments. */
138
+ function refusedCallKey(call: TurnCall, error: string): string {
139
+ const refusal = `${call.tool}\u0000${error.replace(/\d+/g, '#')}`;
140
+ if (CUT_OFF.test(call.resultText)) return refusal;
141
+ return `${refusal}\u0000${createHash('sha256').update(stableJson(call.args)).digest('hex')}`;
142
+ }
143
+
144
+ /**
145
+ * The guard against a model that re-sends a refused call turn after turn (QC AX-1). It reads each turn whole — the
146
+ * reply and the result of every call it sent — at the turn's end. The rule:
147
+ * - A refused call is itself: its tool, its arguments and its error (pi's echo of the arguments left off, numbers in
148
+ * the error ignored). A call whose arguments did not arrive whole (pi's "was not executed": cut off at the output
149
+ * limit, or not complete JSON) is its tool and its error alone: what arrived is not the call the model sent, and it
150
+ * differs from one cut to the next, so a call re-sent cut off at another length is the same refused call.
151
+ * - A turn in which every call was refused counts once for each distinct call in it: one refused in the turn before
152
+ * too goes on counting, any other starts at one. The same call twice in one turn counts once.
153
+ * - A turn in which any call ran, or that sent none, starts every count again: the model is doing something else.
154
+ * A request that failed or was aborted is no turn of the model's and changes no count.
155
+ * - The step ends when one call reaches REPEATED_REFUSAL_LIMIT turns.
156
+ * So calls to different targets refused for the same reason in one turn are that many calls counted once each, not
157
+ * one call refused that many times: test-D-1's skeleton ended Failed on ten parallel updates of ten work items, refused
158
+ * for one missing field, when the count was kept per result and keyed on the tool and the error alone. The same call,
159
+ * or the same batch, re-sent turn after turn still ends the step at the limit.
160
+ */
161
+ export class RefusalStreak {
162
+ private counts = new Map<string, { readonly tool: string; readonly error: string; readonly turns: number }>();
163
+
164
+ /** A turn has ended: its reply's stop reason, and each call it sent with its result. */
165
+ turnEnded(turn: GuardTurn): void {
166
+ if (turn.stopReason === 'error' || turn.stopReason === 'aborted') return;
167
+ const refused = turn.calls.filter((c) => isRefusal(c.isError, c.resultText));
168
+ const next = new Map<string, { readonly tool: string; readonly error: string; readonly turns: number }>();
169
+ if (refused.length > 0 && refused.length === turn.calls.length) {
170
+ for (const call of refused) {
171
+ const error = refusalError(call.resultText);
172
+ const key = refusedCallKey(call, error);
173
+ if (!next.has(key)) next.set(key, { tool: call.tool, error, turns: (this.counts.get(key)?.turns ?? 0) + 1 });
174
+ }
175
+ }
176
+ this.counts = next;
177
+ }
178
+
179
+ /** The refused call that has now come back `REPEATED_REFUSAL_LIMIT` or more turns in a row, if there is one. */
180
+ get reached(): RepeatedRefusal | null {
181
+ const over = [...this.counts.values()].filter((c) => c.turns >= REPEATED_REFUSAL_LIMIT);
182
+ return over.length ? { ...over[0]!, alongside: over.length - 1 } : null;
183
+ }
184
+ }
185
+
186
+ /** pi's view of a finished turn, as far as the guard reads it (`finishTurn`'s argument). */
187
+ interface PiTurn {
188
+ readonly message: { readonly stopReason?: string; readonly content?: readonly unknown[] };
189
+ readonly toolResults?: readonly { readonly toolCallId?: string; readonly isError?: boolean; readonly content?: readonly { readonly type: string; readonly text?: string }[] }[];
190
+ }
191
+
192
+ /** The guard's reading of a turn pi finished: each tool call of the reply, matched to its result by the call's id. */
193
+ export function guardTurn(turn: PiTurn): GuardTurn {
194
+ const results = new Map((turn.toolResults ?? []).map((r) => [r.toolCallId, r]));
195
+ const calls = (turn.message.content ?? [])
196
+ .filter((c): c is { type: 'toolCall'; id: string; name: string; arguments?: unknown } => (c as { type?: unknown } | null)?.type === 'toolCall')
197
+ .map((c) => {
198
+ const result = results.get(c.id);
199
+ return { tool: c.name, args: c.arguments ?? {}, isError: result?.isError === true, resultText: result?.content?.find((x) => x.type === 'text')?.text ?? '' };
200
+ });
201
+ return { stopReason: turn.message.stopReason, calls };
202
+ }
203
+
204
+ /** Every pi built-in tool, on in every step (D87: 「自带的几个命令全部打开,grep find」; CKC-03 AC-25). */
205
+ export function keeperBuiltinTools(platform: string = process.platform): string[] {
206
+ return ['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', ...(platform === 'win32' ? ['powershell'] : [])];
207
+ }