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.
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/README.zh-CN.md +285 -0
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/bin/pk.mjs +6 -0
- package/bin/postinstall.mjs +5 -0
- package/bin/strip-types.mjs +28 -0
- package/package.json +68 -4
- package/patches/README.md +111 -0
- package/patches/pi-ai-0.85.0.ts +122 -0
- package/patches/pi-ai-0.87.1.ts +122 -0
- package/scripts/apply-pi-streaming-patch.ts +86 -0
- package/skills/clerk/coverage/SKILL.md +35 -0
- package/skills/clerk/cross-check/SKILL.md +118 -0
- package/skills/clerk/deepen-dispatch/SKILL.md +69 -0
- package/skills/clerk/lane-follow-up/SKILL.md +94 -0
- package/skills/clerk/lane-plan/SKILL.md +107 -0
- package/skills/clerk/lane-slot/SKILL.md +113 -0
- package/skills/clerk/lane-topic/SKILL.md +116 -0
- package/skills/clerk/orientation/SKILL.md +160 -0
- package/skills/clerk/reconcile/SKILL.md +77 -0
- package/skills/clerk/skeleton-dispatch/SKILL.md +43 -0
- package/skills/clerk/spot-check/SKILL.md +52 -0
- package/skills/clerk/synthesis/SKILL.md +65 -0
- package/skills/table-follow-up/SKILL.md +42 -0
- package/src/cli.ts +319 -0
- package/src/codemap/README.md +13 -0
- package/src/codemap/engines.ts +102 -0
- package/src/codemap/facts.ts +504 -0
- package/src/context/agent-entry.ts +232 -0
- package/src/context/ask.ts +97 -0
- package/src/context/assemble.ts +1058 -0
- package/src/context/code-source.ts +103 -0
- package/src/context/k-briefs.ts +114 -0
- package/src/context/lineage.ts +100 -0
- package/src/context/object-brief.ts +300 -0
- package/src/context/owner-words.ts +191 -0
- package/src/context/pack-fixture.ts +342 -0
- package/src/context/project-rules.ts +160 -0
- package/src/intake/intake.ts +392 -0
- package/src/intake/material-rules.ts +166 -0
- package/src/intake/removal.ts +316 -0
- package/src/intake/skipped.ts +72 -0
- package/src/keeper/adjustment.ts +565 -0
- package/src/keeper/bounds/boundary.ts +509 -0
- package/src/keeper/bounds/command.ts +1665 -0
- package/src/keeper/bounds/paths.ts +164 -0
- package/src/keeper/bounds/reads.ts +718 -0
- package/src/keeper/bounds/shell-write-guard.ts +521 -0
- package/src/keeper/capabilities.ts +136 -0
- package/src/keeper/clerk-steps.ts +151 -0
- package/src/keeper/clerk-tools.ts +1424 -0
- package/src/keeper/conversation.ts +253 -0
- package/src/keeper/evidence.ts +662 -0
- package/src/keeper/fake-provider.ts +132 -0
- package/src/keeper/held.ts +15 -0
- package/src/keeper/history-tools.ts +163 -0
- package/src/keeper/jobs/answering.ts +53 -0
- package/src/keeper/key-check.ts +105 -0
- package/src/keeper/ledger-tools.ts +295 -0
- package/src/keeper/link-check.ts +185 -0
- package/src/keeper/merge.ts +260 -0
- package/src/keeper/note-origin.ts +56 -0
- package/src/keeper/numbers-check.ts +102 -0
- package/src/keeper/open-in-pi.ts +57 -0
- package/src/keeper/organize/absence.ts +266 -0
- package/src/keeper/organize/carried-on.ts +214 -0
- package/src/keeper/organize/clerk-coverage.ts +293 -0
- package/src/keeper/organize/clerk-prompts.ts +132 -0
- package/src/keeper/organize/clerk.ts +1395 -0
- package/src/keeper/organize/coverage-gate.ts +19 -0
- package/src/keeper/organize/coverage-tools.ts +765 -0
- package/src/keeper/organize/entries.ts +153 -0
- package/src/keeper/organize/entry-gate.ts +289 -0
- package/src/keeper/organize/fill-tools.ts +718 -0
- package/src/keeper/organize/follow-up.ts +280 -0
- package/src/keeper/organize/generation-check.ts +504 -0
- package/src/keeper/organize/graph.ts +230 -0
- package/src/keeper/organize/lane-tools.ts +289 -0
- package/src/keeper/organize/looked-tools.ts +54 -0
- package/src/keeper/organize/materials.ts +243 -0
- package/src/keeper/organize/owner-lines.ts +493 -0
- package/src/keeper/organize/place-tools.ts +216 -0
- package/src/keeper/organize/placement-inference.ts +1045 -0
- package/src/keeper/organize/placement-time.ts +320 -0
- package/src/keeper/organize/placing.ts +821 -0
- package/src/keeper/organize/positioning.ts +33 -0
- package/src/keeper/organize/readiness.ts +47 -0
- package/src/keeper/organize/reading.ts +401 -0
- package/src/keeper/organize/round-blocks.ts +65 -0
- package/src/keeper/organize/round-news.ts +164 -0
- package/src/keeper/organize/round-open.ts +586 -0
- package/src/keeper/organize/schedule-state.ts +39 -0
- package/src/keeper/organize/schedule.ts +137 -0
- package/src/keeper/organize/service.ts +409 -0
- package/src/keeper/organize/skills.ts +116 -0
- package/src/keeper/organize/slots.ts +85 -0
- package/src/keeper/organize/stage-gate.ts +175 -0
- package/src/keeper/organize/stage-tools.ts +471 -0
- package/src/keeper/organize/standing-notes.ts +141 -0
- package/src/keeper/organize/table-follow-up.ts +330 -0
- package/src/keeper/organize/takeover-clerk.ts +832 -0
- package/src/keeper/organize/takeover-state.ts +103 -0
- package/src/keeper/organize/takeover.ts +216 -0
- package/src/keeper/organize/update-pending.ts +535 -0
- package/src/keeper/organize/workbench-placement.ts +92 -0
- package/src/keeper/own-keys.ts +114 -0
- package/src/keeper/owner-text.ts +165 -0
- package/src/keeper/project-folder.ts +334 -0
- package/src/keeper/prompts.ts +101 -0
- package/src/keeper/references.ts +177 -0
- package/src/keeper/relook/compose.ts +120 -0
- package/src/keeper/relook/prompts.ts +36 -0
- package/src/keeper/roles.ts +259 -0
- package/src/keeper/round-tools.ts +227 -0
- package/src/keeper/route.ts +148 -0
- package/src/keeper/rules.ts +97 -0
- package/src/keeper/runtime.ts +1858 -0
- package/src/keeper/scope-tools.ts +194 -0
- package/src/keeper/sendback-text.ts +83 -0
- package/src/keeper/step-timing.ts +207 -0
- package/src/keeper/testing/clerk-script.ts +212 -0
- package/src/keeper/testing/streaming-arguments.ts +57 -0
- package/src/keeper/tools.ts +2340 -0
- package/src/keeper/utterance-tools.ts +64 -0
- package/src/keeper/verbatim.ts +397 -0
- package/src/ledger/adapters.ts +186 -0
- package/src/ledger/arrangements.ts +257 -0
- package/src/ledger/blame.ts +58 -0
- package/src/ledger/code-engine-child.ts +69 -0
- package/src/ledger/code-engine.ts +548 -0
- package/src/ledger/code.ts +607 -0
- package/src/ledger/docs.ts +294 -0
- package/src/ledger/git-read.ts +321 -0
- package/src/ledger/index.ts +1850 -0
- package/src/ledger/item-range.ts +16 -0
- package/src/ledger/lines.ts +328 -0
- package/src/ledger/numbering.ts +269 -0
- package/src/ledger/ranges.ts +74 -0
- package/src/ledger/rebuild-worker.ts +15 -0
- package/src/ledger/rebuild.ts +387 -0
- package/src/ledger/repo-scan.ts +181 -0
- package/src/ledger/schema.ts +450 -0
- package/src/ledger/sessions.ts +134 -0
- package/src/ledger/text-scan.ts +466 -0
- package/src/ledger/time.ts +185 -0
- package/src/ledger/ts-symbols.ts +180 -0
- package/src/ledger/views.ts +377 -0
- package/src/mcp/server.ts +127 -0
- package/src/mcp/tools.ts +187 -0
- package/src/model/ids.ts +23 -0
- package/src/model/k-types.ts +912 -0
- package/src/model/time.ts +85 -0
- package/src/model/types.ts +1382 -0
- package/src/model/views-k.ts +707 -0
- package/src/model/vocab.ts +269 -0
- package/src/process/analysis.ts +79 -0
- package/src/process/breakpoint-candidates.ts +383 -0
- package/src/process/breakpoints.ts +746 -0
- package/src/process/claim-check.ts +295 -0
- package/src/process/delivery-links.ts +52 -0
- package/src/process/facts.ts +487 -0
- package/src/process/generations.ts +138 -0
- package/src/process/index.ts +243 -0
- package/src/process/integration.ts +128 -0
- package/src/process/placement.ts +107 -0
- package/src/process/plan.ts +276 -0
- package/src/process/sendbacks.ts +244 -0
- package/src/process/text.ts +185 -0
- package/src/process/units.ts +543 -0
- package/src/process/unnumbered.ts +431 -0
- package/src/process/work.ts +884 -0
- package/src/scope/candidates.ts +86 -0
- package/src/scope/decisions.ts +229 -0
- package/src/scope/discover.ts +741 -0
- package/src/scope/ignore.ts +126 -0
- package/src/scope/ignored-place.ts +93 -0
- package/src/scope/skip.ts +161 -0
- package/src/scope/toolchain.ts +180 -0
- package/src/scope/worktree.ts +187 -0
- package/src/server/api.ts +549 -0
- package/src/server/app.ts +595 -0
- package/src/server/drafts-api.ts +170 -0
- package/src/server/folders-api.ts +243 -0
- package/src/server/graph-view.ts +639 -0
- package/src/server/http.ts +224 -0
- package/src/server/k-agent-api.ts +56 -0
- package/src/server/k-rounds-d99.ts +91 -0
- package/src/server/k-views.ts +405 -0
- package/src/server/keeper-page.ts +446 -0
- package/src/server/keys-api.ts +239 -0
- package/src/server/project-folder-api.ts +104 -0
- package/src/server/vendor.ts +14 -0
- package/src/server/workbench-content.ts +561 -0
- package/src/sources/anchor.ts +129 -0
- package/src/sources/files.ts +267 -0
- package/src/sources/gitobs.ts +41 -0
- package/src/sources/history.ts +231 -0
- package/src/sources/scan-worker.ts +10 -0
- package/src/sources/sessions/locate.ts +292 -0
- package/src/sources/sessions/read.ts +346 -0
- package/src/sources/sessions/scope.ts +42 -0
- package/src/sources/sessions/utterances.ts +223 -0
- package/src/sources/watch.ts +215 -0
- package/src/store/json-file.ts +50 -0
- package/src/store/paths.ts +39 -0
- package/src/store/project-store.ts +295 -0
- package/src/store/versions.ts +172 -0
- package/src/store/workspace.ts +216 -0
- package/src/table/check.ts +333 -0
- package/src/table/follow-up-tools.ts +30 -0
- package/src/table/follow-up.ts +344 -0
- package/src/table/import.ts +610 -0
- package/src/table/project-home.ts +61 -0
- package/src/table/sessions.ts +168 -0
- package/src/table/takeover.ts +483 -0
- package/src/util/git-check.ts +29 -0
- package/src/util/git.ts +411 -0
- package/src/util/own-ts.ts +15 -0
- package/src/util/paths.ts +225 -0
- package/template/FILLING-IN.md +148 -0
- package/template/examples/papertrail.json +747 -0
- package/template/projectkeeper.schema.json +1001 -0
- package/ui/app.js +1135 -0
- package/ui/failures.js +82 -0
- package/ui/flyout.js +120 -0
- package/ui/folder-chooser.js +264 -0
- package/ui/glossary.js +156 -0
- package/ui/graph-fit.js +157 -0
- package/ui/graph-material.js +61 -0
- package/ui/graph-palette.js +109 -0
- package/ui/graph-tools.js +62 -0
- package/ui/graph.js +1712 -0
- package/ui/index.html +31 -0
- package/ui/k/code-view.js +230 -0
- package/ui/k/draft-view.js +93 -0
- package/ui/k/k-views.css +224 -0
- package/ui/k/round-tree.js +450 -0
- package/ui/k/scope-k.js +159 -0
- package/ui/k-data.js +118 -0
- package/ui/k-fold.js +106 -0
- package/ui/k-process.css +302 -0
- package/ui/k-process.js +934 -0
- package/ui/keeper-activity.js +175 -0
- package/ui/keeper-view.js +801 -0
- package/ui/keeper.css +101 -0
- package/ui/key-order.js +62 -0
- package/ui/list-fold.js +67 -0
- package/ui/markdown.d.ts +21 -0
- package/ui/markdown.js +472 -0
- package/ui/placement.d.ts +92 -0
- package/ui/placement.js +445 -0
- package/ui/popover-place.d.ts +16 -0
- package/ui/popover-place.js +89 -0
- package/ui/popover.js +198 -0
- package/ui/project-folder.js +37 -0
- package/ui/shell-state.js +40 -0
- package/ui/strip-rows.js +171 -0
- package/ui/styles.css +492 -0
- package/ui/theme-pick.js +36 -0
- package/ui/theme.js +74 -0
- package/ui/themes/LICENSE.md +13 -0
- package/ui/themes/README.md +44 -0
- package/ui/themes/TOKENS.md +291 -0
- package/ui/themes/a1/board.css +1557 -0
- package/ui/themes/a1/pk.css +392 -0
- package/ui/themes/b1/board.css +1163 -0
- package/ui/themes/b1/pk.css +218 -0
- package/ui/themes/d2/board.css +518 -0
- package/ui/themes/d2/pk.css +243 -0
- package/ui/themes/guard.css +73 -0
- package/ui/themes/i1/board.css +776 -0
- package/ui/themes/i1/pk.css +223 -0
- package/ui/themes/manifest.json +13 -0
- package/ui/views.js +2796 -0
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The coverage check of a deepening (D99; Spec §3.3 "每份材料都有交代", §3.7; CKC-23 AC-20). Coverage is a check after
|
|
3
|
+
* the lanes, not the unit work is handed out by: once the lanes the main agent sent have ended, the program holds what
|
|
4
|
+
* they read against the plan and lists what no lane touched; the main agent accounts for it — not needed, or read in
|
|
5
|
+
* part, with why — or sends a follow-up lane, whose reads settle what it read; what it did not read is listed again once
|
|
6
|
+
* it has ended. The cross-check of a `Full` deepening starts only when every planned material is read whole or
|
|
7
|
+
* accounted for (`coverageSettled`, coverage-gate.ts).
|
|
8
|
+
*
|
|
9
|
+
* The plan (Spec §3.3: 计划):
|
|
10
|
+
* - what the organizing plan says to read closely (reading.ts `readCloselyOf`), as the plan names it — its own targets
|
|
11
|
+
* decide, as they do for the rest of the organizing;
|
|
12
|
+
* - what this round's lane briefs name (`namedInBrief`, resolved through the ledger the way the plan's entries are);
|
|
13
|
+
* - a brief or plan entry that names a kind of material and nothing the ledger can count (a lane named for the owner's
|
|
14
|
+
* meaning, the document chain, the process and checks, or the code, `sweepKindsOf`) plans that whole category: the
|
|
15
|
+
* ledger's current documents, its code files, every commit, every session — each category once, however many lanes
|
|
16
|
+
* name it;
|
|
17
|
+
* - what the project's rules settle is left out (§1.15: the places its material rules keep for reference, recovery or
|
|
18
|
+
* as obsolete, the organizing plan's own rule entries, the files a rule set `Reference only` or `History only`).
|
|
19
|
+
* Material is counted by file, commit and session: a document is one material, and its older versions are never
|
|
20
|
+
* separate items — history is looked up by question (§3.3 按问题深挖).
|
|
21
|
+
*
|
|
22
|
+
* What was read is the program's record, never the lanes' own account (Spec §3.3: 读了什么由程序记): the reads the
|
|
23
|
+
* runtime recorded on the round's lane jobs, everything they sent, and the main job (`tallyReads`). A document counts
|
|
24
|
+
* as read whole when the file as it stands was read whole — a deleted one when its last version was; a code file when
|
|
25
|
+
* it was read whole; a commit when it was read (its message and every file it changed); a session when every message
|
|
26
|
+
* was shown. The clerk's own skills folder is not the project's material and does not count.
|
|
27
|
+
*
|
|
28
|
+
* Items are grouped by category and top directory (a nested repository's folder and the directory under it), commits by
|
|
29
|
+
* repository and sessions by host, so the main agent can account for a group in one call.
|
|
30
|
+
*
|
|
31
|
+
* What a listed group holds (DA; E156, the flash run: 637 materials in 17 groups written off in 80 seconds with "no open
|
|
32
|
+
* item points here", no lane sent, 35 reports unread). For each listed group the program counts what its materials hold
|
|
33
|
+
* that nothing on the workbench carries (`materialHolds`), with a few examples:
|
|
34
|
+
* - verdict lines: the ledger's stated verdicts in its documents (the detection the process and the QC facts use) that
|
|
35
|
+
* no link and no fact of a work item cites;
|
|
36
|
+
* - numbers: the numbers its documents define as their own entries (entries.ts) that no item carries and no account
|
|
37
|
+
* names (entry-gate.ts);
|
|
38
|
+
* - the owner's lines not looked at yet (owner-lines.ts) that its documents quote, or its sessions hold.
|
|
39
|
+
* A group that holds any of these is not accounted for as a group: the main agent sends a lane for it, or accounts for
|
|
40
|
+
* each holding material by its key, with its own reason. A group that holds nothing is accounted for as before — a
|
|
41
|
+
* project without reports, a decision log or sessions has nothing held, and nothing is refused. Code files and commits
|
|
42
|
+
* hold none of the three: the ledger reads verdicts and entry definitions in documents, and on copies of the CQ and
|
|
43
|
+
* flash runs neither kind of group held anything a count could mean. The same measurement set the narrowing above — the
|
|
44
|
+
* ledger's candidate verdict words, its test counts and its findings lit up on prose, dates and table cells, and a
|
|
45
|
+
* contract's acceptance rows are the contract's points.
|
|
46
|
+
*/
|
|
47
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
48
|
+
import { dirname, isAbsolute, join, relative } from 'node:path';
|
|
49
|
+
import { Type } from 'typebox';
|
|
50
|
+
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
|
51
|
+
import type { ClerkRound, ClerkStage, GroupHolds, HoldCount, MaterialAccount, ReadingMaterial, RoundCoverage } from '../../model/k-types.ts';
|
|
52
|
+
import type { KeeperJob, OrganizingPlanContent, Project } from '../../model/types.ts';
|
|
53
|
+
import type { ProjectStore } from '../../store/project-store.ts';
|
|
54
|
+
import { Ledger } from '../../ledger/index.ts';
|
|
55
|
+
import { definitionsInPath, familyOf } from '../../ledger/numbering.ts';
|
|
56
|
+
import { readJson } from '../../store/json-file.ts';
|
|
57
|
+
import { workspaceFile } from '../../store/paths.ts';
|
|
58
|
+
import { isWithin, pathKey } from '../../util/paths.ts';
|
|
59
|
+
import { STAGE_WRITERS } from '../clerk-steps.ts';
|
|
60
|
+
import { planEntryFor } from '../rules.ts';
|
|
61
|
+
import type { ToolContext } from '../tools.ts';
|
|
62
|
+
import { DEEPENING_PATHS, sweepKindsOf, type SweepKind } from './clerk-prompts.ts';
|
|
63
|
+
import { tallyReads, type ReadTally } from './clerk-coverage.ts';
|
|
64
|
+
import { accountedEntries, carriedNumbers } from './entry-gate.ts';
|
|
65
|
+
import { entryDefinitions, headingLines } from './entries.ts';
|
|
66
|
+
import { DISTINCT_WORDS, fold, lineKey, uncitedOwnerLines } from './owner-lines.ts';
|
|
67
|
+
import { isClerkSkillPath } from './skills.ts';
|
|
68
|
+
import { settledAwayMaterials } from './materials.ts';
|
|
69
|
+
import { ORGANIZING_PLAN_ID, readCloselyNow, readCloselyOf, readsOf, settlingDirs, underAny, withDescendants, type NamedMaterials } from './reading.ts';
|
|
70
|
+
|
|
71
|
+
// ───────────────────────── documents, one material each ─────────────────────────
|
|
72
|
+
|
|
73
|
+
/** One version of a document as the ledger has it. */
|
|
74
|
+
interface VersionRow { readonly key: string; readonly repo: string; readonly path: string; readonly commit: string; readonly bytes: number; readonly lines: number; readonly atMs: number; readonly current: boolean }
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The version that stands for each document (Spec §3.3: 一份文档的各个旧版本不单列): the one the file as it stands has,
|
|
78
|
+
* else its latest — for a deleted document, its last. One entry per document, in the order first met.
|
|
79
|
+
*/
|
|
80
|
+
export function standingVersions(ledger: Ledger, versions: readonly { readonly key: string }[], rows: readonly VersionRow[] = ledger.versionsByKey(versions.map((x) => x.key))): VersionRow[] {
|
|
81
|
+
const byDoc = new Map<string, VersionRow>();
|
|
82
|
+
for (const v of rows) {
|
|
83
|
+
const doc = `${v.repo}:${v.path}`;
|
|
84
|
+
const had = byDoc.get(doc);
|
|
85
|
+
if (!had || (v.current && !had.current) || (v.current === had.current && v.atMs > had.atMs)) byDoc.set(doc, v);
|
|
86
|
+
}
|
|
87
|
+
return [...byDoc.values()];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `standingVersions` as the `{ key, path }` lists the depth question counts with (takeover-clerk.ts). */
|
|
91
|
+
export function oneVersionPerDocument(ledger: Ledger, versions: readonly { readonly key: string; readonly path: string }[]): { key: string; path: string }[] {
|
|
92
|
+
if (!versions.length) return [];
|
|
93
|
+
const rows = ledger.versionsByKey(versions.map((v) => v.key));
|
|
94
|
+
const standing = standingVersions(ledger, versions, rows);
|
|
95
|
+
// A version the ledger no longer has rows for is kept as it was named: nothing is dropped silently.
|
|
96
|
+
const known = new Set(rows.map((v) => v.key));
|
|
97
|
+
const kept = new Map<string, { key: string; path: string }>();
|
|
98
|
+
for (const v of standing) kept.set(`${v.repo}:${v.path}`, { key: v.key, path: v.path });
|
|
99
|
+
const unknown = versions.filter((v) => !known.has(v.key));
|
|
100
|
+
return [...kept.values(), ...new Map(unknown.map((v) => [v.path, { key: v.key, path: v.path }])).values()];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const isFile = (path: string): boolean => { try { return statSync(path).isFile(); } catch { return false; } };
|
|
104
|
+
|
|
105
|
+
/** The ledger's documents that are in the project now (on disk), one per document, with the version that stands for each. */
|
|
106
|
+
export function currentDocuments(ledger: Ledger): { readonly row: VersionRow; readonly file: string }[] {
|
|
107
|
+
const roots = new Map(ledger.repos().map((r) => [r.id, r.path]));
|
|
108
|
+
return standingVersions(ledger, ledger.allVersions()).flatMap((row) => {
|
|
109
|
+
const root = roots.get(row.repo);
|
|
110
|
+
const file = root ? join(root, ...row.path.split('/')) : null;
|
|
111
|
+
return file && isFile(file) ? [{ row, file }] : [];
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ───────────────────────── the items the check holds against what was read ─────────────────────────
|
|
116
|
+
|
|
117
|
+
export type CoverageCategory = 'documents' | 'code files' | 'commits' | 'sessions';
|
|
118
|
+
const CATEGORY_ORDER: readonly CoverageCategory[] = ['documents', 'code files', 'commits', 'sessions'];
|
|
119
|
+
/** The category a kind of question plans whole when its brief names nothing countable. */
|
|
120
|
+
const KIND_CATEGORY: Readonly<Record<SweepKind, CoverageCategory>> = {
|
|
121
|
+
[DEEPENING_PATHS[0]]: 'sessions', [DEEPENING_PATHS[1]]: 'documents', [DEEPENING_PATHS[2]]: 'commits', [DEEPENING_PATHS[3]]: 'code files',
|
|
122
|
+
} as Record<SweepKind, CoverageCategory>;
|
|
123
|
+
|
|
124
|
+
/** One planned material: what the main agent sees, and what its reads are held against (reading.ts `readsOf`). */
|
|
125
|
+
export interface CoverageItem {
|
|
126
|
+
/** `doc:<path>`, `file:<path>` (paths as the project names them), `commit:<hash>`, or the ledger's session id. */
|
|
127
|
+
readonly key: string;
|
|
128
|
+
readonly category: CoverageCategory;
|
|
129
|
+
readonly label: string;
|
|
130
|
+
/** Its top directory (a nested repository's folder with the directory under it), a commit's repository, a session's host. */
|
|
131
|
+
readonly dir: string;
|
|
132
|
+
readonly bytes: number;
|
|
133
|
+
readonly material: ReadingMaterial;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One group of listed items, as `pk_coverage_check` returns it. */
|
|
137
|
+
export interface CoverageGroup {
|
|
138
|
+
readonly group: string;
|
|
139
|
+
readonly category: CoverageCategory;
|
|
140
|
+
readonly dir: string;
|
|
141
|
+
readonly count: number;
|
|
142
|
+
readonly bytes: number;
|
|
143
|
+
readonly keys: readonly string[];
|
|
144
|
+
/** Added to the W0 shape: nothing of these was read (`none`), or only a part (`part`: account with outcome part and why). */
|
|
145
|
+
readonly read: 'none' | 'part';
|
|
146
|
+
/** Added: how many keys are not shown (the first 20 are, unless the group was asked for). */
|
|
147
|
+
readonly more?: number;
|
|
148
|
+
/** DA: what the group holds that nothing carries; absent when it holds nothing. */
|
|
149
|
+
readonly holds?: GroupHolds;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The coverage of a round as it stands now. */
|
|
153
|
+
export interface CoverageState {
|
|
154
|
+
/** Every planned material, each once. */
|
|
155
|
+
readonly items: ReadonlyMap<string, CoverageItem>;
|
|
156
|
+
/** How far each was read, by the program's record. */
|
|
157
|
+
readonly read: ReadonlyMap<string, 'whole' | 'part' | 'none'>;
|
|
158
|
+
/** The main agent's accounts (and any the program wrote). */
|
|
159
|
+
readonly accounted: readonly MaterialAccount[];
|
|
160
|
+
/** What is settled by an account. */
|
|
161
|
+
readonly accountedKeys: ReadonlySet<string>;
|
|
162
|
+
/**
|
|
163
|
+
* What is neither read whole nor accounted for: what the check lists (Spec §3.3). A follow-up lane settles what it
|
|
164
|
+
* reads; what it was sent for and did not read is listed again once it has ended, for the main agent to account for.
|
|
165
|
+
*/
|
|
166
|
+
readonly open: readonly CoverageItem[];
|
|
167
|
+
readonly settled: boolean;
|
|
168
|
+
/** Words the plan used that the ledger has nothing for: said, not dropped. */
|
|
169
|
+
readonly unresolved: readonly string[];
|
|
170
|
+
/**
|
|
171
|
+
* CM: the reports and prompts no lane read whole that a ticket or a decision entry of the workbench already cites, each
|
|
172
|
+
* with what cites it. The program accounts for them (they are among `accounted`, by the program), so a follow-up lane
|
|
173
|
+
* reads only uncited material.
|
|
174
|
+
*/
|
|
175
|
+
readonly cited: ReadonlyMap<string, string>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** How the program's account of cited reports and prompts begins (it is recomputed at every check, never doubled). */
|
|
179
|
+
export const CITED_WHY = 'Cited already: ';
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The reports and prompts among `items` that the workbench already cites (CM, E151; CK fix 13): a work item carries the
|
|
183
|
+
* number the file is named for (its prompt, its report, its run's result — the program ties them to the ticket's process),
|
|
184
|
+
* a decision entry names the file, or a link or a fact cites a source of it. On the gated run the coverage stage took 20
|
|
185
|
+
* minutes against 2, and a follow-up lane read 57 such files for $3.90 and wrote nothing.
|
|
186
|
+
*/
|
|
187
|
+
export function citedMaterials(store: ProjectStore, ledger: Ledger, items: readonly CoverageItem[]): Map<string, string> {
|
|
188
|
+
const out = new Map<string, string>();
|
|
189
|
+
const docs = items.filter((i) => i.category === 'documents' && i.material.file);
|
|
190
|
+
if (!docs.length) return out;
|
|
191
|
+
const roots = ledger.repos().map((r) => r.path.replace(/\\/g, '/').replace(/\/+$/, ''));
|
|
192
|
+
const relOf = (abs: string): string => { const n = abs.replace(/\\/g, '/'); const root = roots.find((r) => n.toLowerCase().startsWith(`${r.toLowerCase()}/`)); return root ? n.slice(root.length + 1) : n; };
|
|
193
|
+
const receipts = store.layers.filter((l) => l.layer === 'QC and receipts').map((l) => l.path.replace(/\\/g, '/').replace(/\/+$/, ''));
|
|
194
|
+
const ticketOf = new Map<string, string>();
|
|
195
|
+
for (const t of store.threads.all()) if (t.validity !== 'Removed') for (const i of t.ids) ticketOf.set(i.toUpperCase(), t.ids[0] ?? t.title);
|
|
196
|
+
const decisions = store.reference.filter((r) => r.category === 'Decision' && r.validity !== 'Removed');
|
|
197
|
+
const entryText = decisions.map((d) => d.text).join('\n').toLowerCase();
|
|
198
|
+
const citedFiles = new Set<string>();
|
|
199
|
+
const cite = (sourceId: string) => { const a = store.sources.get(sourceId)?.anchor; if (a?.kind === 'file') citedFiles.add(pathKey(a.path)); };
|
|
200
|
+
for (const l of store.links.all()) { if (l.evidence?.kind === 'source') cite(l.evidence.id); }
|
|
201
|
+
for (const t of store.threads.all()) { for (const f of t.factRecordIds) for (const sid of store.facts.get(f)?.aboutSourceIds ?? []) cite(sid); for (const st of [...t.executionFacts, ...t.qcFacts]) for (const sid of st.sourceIds) cite(sid); }
|
|
202
|
+
for (const item of docs) {
|
|
203
|
+
const rel = relOf(item.material.file!);
|
|
204
|
+
const kinds = ledger.arrangementKinds(rel);
|
|
205
|
+
if (!kinds.length && !receipts.some((r) => rel === r || rel.startsWith(`${r}/`))) continue;
|
|
206
|
+
const owner = definitionsInPath(rel).map((d) => ticketOf.get(d.num.toUpperCase())).find(Boolean) ?? kinds.map((k) => (k.ident ? ticketOf.get(k.ident.toUpperCase()) : undefined)).find(Boolean);
|
|
207
|
+
if (owner) { out.set(item.key, `the work item ${owner}`); continue; }
|
|
208
|
+
const base = rel.split('/').pop()!.toLowerCase();
|
|
209
|
+
if (base.length >= 8 && entryText.includes(base)) {
|
|
210
|
+
const d = decisions.find((x) => x.text.toLowerCase().includes(base));
|
|
211
|
+
out.set(item.key, `the decision entry ${d?.ids[0] ?? d?.name.slice(0, 40) ?? ''}`.trim());
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (citedFiles.has(pathKey(item.material.file!))) out.set(item.key, 'a link or a fact of a work item');
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The part-read variant of a group's id. */
|
|
220
|
+
const PART = ' (read in part)';
|
|
221
|
+
export const groupIdOf = (item: CoverageItem, read: 'none' | 'part' | 'whole' | undefined): string => `${item.category}:${item.dir}${read === 'part' ? PART : ''}`;
|
|
222
|
+
|
|
223
|
+
/** A tally without the clerk's own skill files (skills.ts `isClerkSkillPath`, resolved from the running install; `tallyReads` leaves them out already). */
|
|
224
|
+
function withoutSkills(tally: ReadTally): ReadTally {
|
|
225
|
+
for (const key of [...tally.files.keys()]) if (isClerkSkillPath(key)) tally.files.delete(key);
|
|
226
|
+
for (const key of [...tally.versions.keys()]) if (isClerkSkillPath(key)) tally.versions.delete(key);
|
|
227
|
+
return tally;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Where things are: the project's folder, the repositories and scope items the ledger keys files by. */
|
|
231
|
+
class Places {
|
|
232
|
+
readonly root: string;
|
|
233
|
+
private readonly rootOf: Map<string, string>;
|
|
234
|
+
private readonly roots: string[];
|
|
235
|
+
constructor(ledger: Ledger, project: Project) {
|
|
236
|
+
const repos = ledger.repos();
|
|
237
|
+
this.rootOf = new Map<string, string>([...project.scope.map((i) => [i.id, i.path] as const), ...repos.map((r) => [r.id, r.path] as const)]);
|
|
238
|
+
this.roots = [...this.rootOf.keys()].sort((a, b) => b.length - a.length);
|
|
239
|
+
this.root = project.locations[0] ?? repos[0]?.path ?? '';
|
|
240
|
+
}
|
|
241
|
+
abs(repo: string, rel: string): string | null { const r = this.rootOf.get(repo); return r ? join(r, ...rel.split('/')) : null; }
|
|
242
|
+
/** A code file key's repository and path. */
|
|
243
|
+
split(key: string): { repo: string; rel: string } | null { const repo = this.roots.find((id) => key.startsWith(`${id}:`)); return repo ? { repo, rel: key.slice(repo.length + 1) } : null; }
|
|
244
|
+
/** A path as the project names it: relative to its folder, with forward slashes; absolute when outside it. */
|
|
245
|
+
shown(abs: string): string {
|
|
246
|
+
const rel = this.root ? relative(this.root, abs) : abs;
|
|
247
|
+
if (this.root && rel === '') return '.';
|
|
248
|
+
return (rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel : abs).split('\\').join('/');
|
|
249
|
+
}
|
|
250
|
+
/** A repository's folder as the project names it: '' for the project's own. */
|
|
251
|
+
repoShown(repo: string): string { const r = this.rootOf.get(repo); if (!r) return repo; const s = this.shown(r); return s === '.' || s === '' ? '' : s; }
|
|
252
|
+
/** The top directory of a file: its repository's folder (when nested) and the first directory under it; '.' at a root. */
|
|
253
|
+
dirOf(repo: string, rel: string): string {
|
|
254
|
+
const top = rel.includes('/') ? rel.slice(0, rel.indexOf('/')) : '.';
|
|
255
|
+
const folder = this.repoShown(repo);
|
|
256
|
+
return folder ? (top === '.' ? folder : `${folder}/${top}`) : top;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** What the project's rules settle (§1.15): by place — theirs and the organizing plan's — and by how a rule set a file used. */
|
|
261
|
+
function settlement(store: ProjectStore, project: Project, plan: OrganizingPlanContent | null | undefined): (repoRel: string, shown: string, abs: string | null) => boolean {
|
|
262
|
+
const dirs = settlingDirs(store);
|
|
263
|
+
const inForce = (ruleId: string) => store.rules.get(ruleId)?.validity === 'Current';
|
|
264
|
+
const away = new Set(settledAwayMaterials(store, project, new Set()).map((m) => pathKey(m.key.slice('file:'.length))));
|
|
265
|
+
return (repoRel, shown, abs) => {
|
|
266
|
+
if (underAny(dirs, repoRel) || underAny(dirs, shown)) return true;
|
|
267
|
+
if (plan) for (const p of [repoRel, shown]) { const e = planEntryFor(plan, p); if (e && inForce(e.ruleId)) return true; }
|
|
268
|
+
return abs !== null && away.has(pathKey(abs));
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Builds the items of the plan: resolves the ledger's names into items, each once. */
|
|
273
|
+
class Planner {
|
|
274
|
+
readonly items = new Map<string, CoverageItem>();
|
|
275
|
+
private readonly places: Places;
|
|
276
|
+
private readonly settled: ReturnType<typeof settlement>;
|
|
277
|
+
/** What the project's scope leaves out (relation Excluded: the Keeper's own project folder among them, CKC-26): never the project's material. */
|
|
278
|
+
private readonly excluded: readonly string[];
|
|
279
|
+
private readonly ledger: Ledger;
|
|
280
|
+
private readonly store: ProjectStore;
|
|
281
|
+
constructor(ledger: Ledger, store: ProjectStore, project: Project, plan: OrganizingPlanContent | null | undefined) {
|
|
282
|
+
this.ledger = ledger;
|
|
283
|
+
this.store = store;
|
|
284
|
+
this.places = new Places(ledger, project);
|
|
285
|
+
this.settled = settlement(store, project, plan);
|
|
286
|
+
this.excluded = project.scope.filter((i) => i.relation === 'Excluded').map((i) => i.path);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private outOfScope(file: string | null): boolean {
|
|
290
|
+
return file !== null && this.excluded.some((x) => isWithin(x, file));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Materials as the ledger names them, as items; `byRule`: leave out what the rules settle. Returns their keys. */
|
|
294
|
+
add(named: NamedMaterials, byRule: boolean): string[] {
|
|
295
|
+
const keys: string[] = [];
|
|
296
|
+
const put = (item: CoverageItem) => { if (!this.items.has(item.key)) this.items.set(item.key, item); keys.push(item.key); };
|
|
297
|
+
for (const v of standingVersions(this.ledger, named.versions)) {
|
|
298
|
+
const file = this.places.abs(v.repo, v.path);
|
|
299
|
+
const shown = file ? this.places.shown(file) : v.path;
|
|
300
|
+
if (this.outOfScope(file) || (byRule && this.settled(v.path, shown, file))) continue;
|
|
301
|
+
const exists = file !== null && isFile(file);
|
|
302
|
+
put({
|
|
303
|
+
key: `doc:${shown}`, category: 'documents', label: exists ? shown : `${shown} (deleted; its last version is at ${v.commit.slice(0, 7)})`, dir: this.places.dirOf(v.repo, v.path), bytes: v.bytes,
|
|
304
|
+
material: { key: `doc:${shown}`, category: 'document versions', label: shown, group: '', bytes: v.bytes, how: '', file, rev: v.commit, current: exists, lines: v.lines || null },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
for (const f of this.ledger.codeFilesByKey(named.codeFiles.map((c) => c.key))) {
|
|
308
|
+
const file = this.places.abs(f.repo, f.path);
|
|
309
|
+
const shown = file ? this.places.shown(file) : f.path;
|
|
310
|
+
if (this.outOfScope(file) || (byRule && this.settled(f.path, shown, file))) continue;
|
|
311
|
+
put({
|
|
312
|
+
key: `file:${shown}`, category: 'code files', label: shown, dir: this.places.dirOf(f.repo, f.path), bytes: f.bytes,
|
|
313
|
+
material: { key: `file:${shown}`, category: 'code files', label: shown, group: '', bytes: f.bytes, how: '', file, lines: f.lines || null },
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
for (const c of this.ledger.commitsByHash(named.commits)) {
|
|
317
|
+
const label = `${c.hash.slice(0, 7)} ${c.subject.slice(0, 100)}`;
|
|
318
|
+
put({ key: `commit:${c.hash}`, category: 'commits', label, dir: this.places.repoShown(c.repo) || '.', bytes: c.bytes, material: { key: `commit:${c.hash}`, category: 'commits', label, group: '', bytes: c.bytes, how: '', rev: c.hash } });
|
|
319
|
+
}
|
|
320
|
+
for (const s of this.ledger.sessionsByKey(named.sessions)) {
|
|
321
|
+
const label = `${s.host} session ${s.sessionId.slice(0, 8)} (${s.messages} messages${s.startedAt ? `, from ${s.startedAt.slice(0, 10)}` : ''})`;
|
|
322
|
+
const bytes = s.textBytes + s.messages * 150;
|
|
323
|
+
put({ key: s.key, category: 'sessions', label, dir: s.host, bytes, material: { key: s.key, category: 'sessions', label, group: '', bytes, how: '', session: s.key, messages: s.messages, file: s.file } });
|
|
324
|
+
}
|
|
325
|
+
return keys;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** A whole category, as the ledger has it now (Spec §3.3: 只写了一类、没点名的,按账本里这一类的全部算), once per category. */
|
|
329
|
+
private readonly wholes = new Map<CoverageCategory, string[]>();
|
|
330
|
+
whole(category: CoverageCategory): string[] {
|
|
331
|
+
let keys = this.wholes.get(category);
|
|
332
|
+
if (!keys) {
|
|
333
|
+
const l = this.ledger;
|
|
334
|
+
const named: NamedMaterials = category === 'documents' ? { versions: currentDocuments(l).map((d) => ({ key: d.row.key, path: d.row.path })), codeFiles: [], commits: [], sessions: [] }
|
|
335
|
+
: category === 'code files' ? { versions: [], codeFiles: l.allCodeFiles(), commits: [], sessions: [] }
|
|
336
|
+
: category === 'commits' ? { versions: [], codeFiles: [], commits: l.allCommits(), sessions: [] }
|
|
337
|
+
: { versions: [], codeFiles: [], commits: [], sessions: l.allSessions() };
|
|
338
|
+
keys = this.add(named, true);
|
|
339
|
+
this.wholes.set(category, keys);
|
|
340
|
+
}
|
|
341
|
+
return keys;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** What a text names (a brief, a plan entry) — resolved the way the organizing plan's entries are — or the categories its name says. */
|
|
345
|
+
named(text: string, names: readonly string[], plan: OrganizingPlanContent | null | undefined): { keys: string[]; unresolved: string[] } {
|
|
346
|
+
const found = readCloselyOf(this.ledger, this.store, { byRule: plan?.byRule ?? [], readClosely: [{ what: text, targets: [], why: '' }], focus: [], order: [] });
|
|
347
|
+
const keys = this.add(found, true);
|
|
348
|
+
const counted = found.versions.length + found.codeFiles.length + found.commits.length + found.sessions.length > 0;
|
|
349
|
+
if (counted) return { keys, unresolved: found.unresolved.filter((u) => u !== text) };
|
|
350
|
+
const kinds = new Set(names.flatMap((n) => sweepKindsOf(n)));
|
|
351
|
+
return { keys: [...kinds].flatMap((k) => this.whole(KIND_CATEGORY[k])), unresolved: [] };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The jobs whose reads count: the round's main job and its lanes, and everything they sent. */
|
|
356
|
+
function readingJobs(store: ProjectStore, round: ClerkRound): KeeperJob[] {
|
|
357
|
+
const ids = new Set((round.lanes ?? []).map((l) => l.jobId));
|
|
358
|
+
const jobs = store.jobs.filter((j) => ids.has(j.id) || (j.step?.roundId === round.id && (j.step.kind === 'main' || j.step.kind === 'lane')));
|
|
359
|
+
return withDescendants(store, jobs);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The coverage of a round as it stands (Spec §3.3 每份材料都有交代): every planned material, what the round's jobs read of
|
|
364
|
+
* it, what is accounted for, and what the check lists. Without a ledger nothing can be planned, and nothing is listed.
|
|
365
|
+
*/
|
|
366
|
+
export function coverageOf(store: ProjectStore, round: ClerkRound, ledger: Ledger | null, project: Project): CoverageState {
|
|
367
|
+
// The main agent's accounts; the program's account of cited material is worked out afresh below.
|
|
368
|
+
const given = (round.coverage?.accounted ?? []).filter((a) => !(a.by === 'program' && a.why.startsWith(CITED_WHY)));
|
|
369
|
+
let accounted: readonly MaterialAccount[] = given;
|
|
370
|
+
if (!ledger) return { items: new Map(), read: new Map(), accounted, accountedKeys: new Set(), open: [], settled: true, unresolved: [], cited: new Map() };
|
|
371
|
+
const plan = store.plans.get(ORGANIZING_PLAN_ID);
|
|
372
|
+
const planner = new Planner(ledger, store, project, plan);
|
|
373
|
+
const unresolved: string[] = [];
|
|
374
|
+
// What the organizing plan says to read closely: its targets decide, as they do for the rest of the organizing.
|
|
375
|
+
const closely = readCloselyNow(ledger, store, plan);
|
|
376
|
+
planner.add(closely, false);
|
|
377
|
+
for (const u of closely.unresolved) {
|
|
378
|
+
const kinds = sweepKindsOf(u);
|
|
379
|
+
if (kinds.length) for (const k of kinds) planner.whole(KIND_CATEGORY[k]); else unresolved.push(u);
|
|
380
|
+
}
|
|
381
|
+
// What each lane's brief names, or the category its name says.
|
|
382
|
+
for (const lane of round.lanes ?? []) {
|
|
383
|
+
const brief = store.roundDocs.get(lane.briefDocId);
|
|
384
|
+
const r = planner.named(brief?.markdown ?? '', [lane.name, brief?.title ?? '', brief?.path ?? ''], plan);
|
|
385
|
+
unresolved.push(...r.unresolved);
|
|
386
|
+
}
|
|
387
|
+
const items = planner.items;
|
|
388
|
+
// What was read, by the program's record.
|
|
389
|
+
const materials = [...items.values()].map((i) => i.material);
|
|
390
|
+
const reads = readsOf(materials, withoutSkills(tallyReads(readingJobs(store, round), project)));
|
|
391
|
+
const read = new Map<string, 'whole' | 'part' | 'none'>([...items.keys()].map((k) => [k, reads.get(k)?.outcome ?? 'none']));
|
|
392
|
+
// What is accounted for: by its keys, or — an account that names only a group — by the group it names.
|
|
393
|
+
const accountedKeys = new Set<string>();
|
|
394
|
+
for (const a of accounted) {
|
|
395
|
+
if (a.keys?.length) { for (const k of a.keys) accountedKeys.add(k); continue; }
|
|
396
|
+
if (a.group) for (const item of items.values()) if (inGroup(item, read.get(item.key), a.group)) accountedKeys.add(item.key);
|
|
397
|
+
}
|
|
398
|
+
// CM: the reports and prompts a ticket or a decision entry already cites are the program's to account for.
|
|
399
|
+
let cited = new Map<string, string>();
|
|
400
|
+
try { cited = citedMaterials(store, ledger, [...items.values()].filter((i) => read.get(i.key) !== 'whole' && !accountedKeys.has(i.key))); } catch { cited = new Map(); }
|
|
401
|
+
if (cited.size) {
|
|
402
|
+
const by = new Map<string, number>();
|
|
403
|
+
for (const what of cited.values()) { const k = what.startsWith('the work item') ? 'a work item carrying its number' : what.startsWith('the decision entry') ? 'a decision entry naming it' : what; by.set(k, (by.get(k) ?? 0) + 1); }
|
|
404
|
+
accounted = [...given, { keys: [...cited.keys()], outcome: 'part', why: `${CITED_WHY}${cited.size} report${cited.size === 1 ? '' : 's'} and prompt${cited.size === 1 ? '' : 's'} the workbench cites (${[...by].map(([k, n]) => `${n} by ${k}`).join(', ')}); a follow-up lane reads the uncited material only.`, by: 'program', at: new Date().toISOString() }];
|
|
405
|
+
for (const k of cited.keys()) accountedKeys.add(k);
|
|
406
|
+
}
|
|
407
|
+
const open = [...items.values()].filter((i) => read.get(i.key) !== 'whole' && !accountedKeys.has(i.key));
|
|
408
|
+
return { items, read, accounted, accountedKeys, open, settled: open.length === 0, unresolved: [...new Set(unresolved)], cited };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Whether an item is in a group as the main agent names it: its group's id exactly, or its category (all its groups).
|
|
413
|
+
* DA: `documents:subagent` is the group nothing was read of; its part-read twin is `documents:subagent (read in part)`,
|
|
414
|
+
* a group of its own with its own account (on the flash run the unread group's "not needed" swallowed the twin).
|
|
415
|
+
*/
|
|
416
|
+
function inGroup(item: CoverageItem, read: 'none' | 'part' | 'whole' | undefined, group: string): boolean {
|
|
417
|
+
const g = group.trim();
|
|
418
|
+
return g === groupIdOf(item, read) || g === item.category;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The listed items as groups, by category, then directory; the first `limit` keys of each. */
|
|
422
|
+
export function coverageGroups(state: CoverageState, limit: number | null = 20): CoverageGroup[] {
|
|
423
|
+
const groups = new Map<string, { category: CoverageCategory; dir: string; read: 'none' | 'part'; keys: string[]; bytes: number }>();
|
|
424
|
+
for (const item of state.open) {
|
|
425
|
+
const read = state.read.get(item.key) === 'part' ? 'part' : 'none';
|
|
426
|
+
const id = groupIdOf(item, read);
|
|
427
|
+
const g = groups.get(id) ?? { category: item.category, dir: item.dir, read, keys: [], bytes: 0 };
|
|
428
|
+
g.keys.push(item.key);
|
|
429
|
+
g.bytes += item.bytes;
|
|
430
|
+
groups.set(id, g);
|
|
431
|
+
}
|
|
432
|
+
return [...groups].sort(([a, x], [b, y]) => CATEGORY_ORDER.indexOf(x.category) - CATEGORY_ORDER.indexOf(y.category) || a.localeCompare(b)).map(([group, g]) => {
|
|
433
|
+
const shown = limit === null ? g.keys : g.keys.slice(0, limit);
|
|
434
|
+
return { group, category: g.category, dir: g.dir, count: g.keys.length, bytes: g.bytes, keys: shown, read: g.read, ...(shown.length < g.keys.length ? { more: g.keys.length - shown.length } : {}) };
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ───────────────────────── what a listed material holds that nothing carries (DA) ─────────────────────────
|
|
439
|
+
|
|
440
|
+
/** What one listed material holds that nothing on the workbench carries; each entry is one line, number or owner's line, as an example names it. */
|
|
441
|
+
export interface MaterialHolds {
|
|
442
|
+
readonly verdictLines: readonly string[];
|
|
443
|
+
readonly numbers: readonly string[];
|
|
444
|
+
readonly ownerQuotes: readonly string[];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const holdsAny = (h: MaterialHolds | undefined): h is MaterialHolds => !!h && h.verdictLines.length + h.numbers.length + h.ownerQuotes.length > 0;
|
|
448
|
+
const clipTo = (s: string, n: number): string => { const flat = s.trim().replace(/\s+/g, ' '); return flat.length > n ? `${flat.slice(0, n - 1)}…` : flat; };
|
|
449
|
+
const slashed = (p: string): string => p.split('\\').join('/');
|
|
450
|
+
|
|
451
|
+
/** The files a link or a fact of a work item cites: a verdict line in one of them is linked. */
|
|
452
|
+
function linkedFiles(store: ProjectStore, ledger: Ledger): Set<string> {
|
|
453
|
+
const out = new Set<string>();
|
|
454
|
+
const cite = (sourceId: string) => { const a = store.sources.get(sourceId)?.anchor; if (a?.kind === 'file') out.add(pathKey(a.path)); };
|
|
455
|
+
for (const l of store.links.all()) {
|
|
456
|
+
if (l.evidence?.kind === 'source') cite(l.evidence.id);
|
|
457
|
+
try { const place = ledger.entryPlace(l.ledgerRef); if (place && 'file' in place) out.add(pathKey(place.file)); } catch { /* a ref the ledger no longer resolves links nothing */ }
|
|
458
|
+
}
|
|
459
|
+
for (const t of store.threads.all()) {
|
|
460
|
+
for (const f of t.factRecordIds) for (const sid of store.facts.get(f)?.aboutSourceIds ?? []) cite(sid);
|
|
461
|
+
for (const st of [...t.executionFacts, ...t.qcFacts]) for (const sid of st.sourceIds) cite(sid);
|
|
462
|
+
}
|
|
463
|
+
return out;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Quoted words in a document: between corner or curly quotation marks, or a blockquote line. */
|
|
467
|
+
const QUOTED = /「([^「」\n]{2,600})」|『([^『』\n]{2,600})』|“([^“”\n]{2,600})”|^\s{0,3}>\s?(.{2,600})$/gmu;
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* What each of `items` holds that nothing carries (see the module comment). Documents: the stated verdict lines no link
|
|
471
|
+
* or fact cites; the numbers it defines as its own entries that no item carries and no account names — in a document
|
|
472
|
+
* that is itself a numbered entry (its path, front matter or title heading defines a number), bold lines and table rows
|
|
473
|
+
* of another family are that entry's points, not entries; the owner's lines not looked at yet whose words it quotes.
|
|
474
|
+
* Sessions: their owner's lines not looked at yet. Code files and commits hold none. Only materials that hold something
|
|
475
|
+
* are in the result.
|
|
476
|
+
*/
|
|
477
|
+
export function materialHolds(store: ProjectStore, ledger: Ledger, items: readonly CoverageItem[]): Map<string, MaterialHolds> {
|
|
478
|
+
const out = new Map<string, MaterialHolds>();
|
|
479
|
+
const docs = items.filter((i) => i.category === 'documents' && i.material.file);
|
|
480
|
+
const sessions = items.filter((i) => i.category === 'sessions');
|
|
481
|
+
if (!docs.length && !sessions.length) return out;
|
|
482
|
+
const repos = ledger.repos().map((r) => ({ id: r.id, root: slashed(r.path).replace(/\/+$/, '').toLowerCase() })).sort((a, b) => b.root.length - a.root.length);
|
|
483
|
+
const placeOf = (abs: string): { repo: string; rel: string } | null => {
|
|
484
|
+
const n = slashed(abs);
|
|
485
|
+
const repo = repos.find((r) => n.toLowerCase().startsWith(`${r.root}/`));
|
|
486
|
+
return repo ? { repo: repo.id, rel: n.slice(repo.root.length + 1) } : null;
|
|
487
|
+
};
|
|
488
|
+
// The owner's lines not looked at yet, by their words (a line too short to tell apart is not looked for in a document).
|
|
489
|
+
let open: ReturnType<typeof uncitedOwnerLines>['lines'] = [];
|
|
490
|
+
try { open = uncitedOwnerLines(store, ledger).lines; } catch { open = []; }
|
|
491
|
+
const quotable = open.map((l) => ({ l, words: fold(l.text) })).filter((x) => x.words.length >= DISTINCT_WORDS);
|
|
492
|
+
const linked = docs.length ? linkedFiles(store, ledger) : new Set<string>();
|
|
493
|
+
const carried = docs.length ? carriedNumbers(store) : new Set<string>();
|
|
494
|
+
const accounts = docs.length ? accountedEntries(store) : new Map<string, Map<string, unknown>>();
|
|
495
|
+
const family = (num: string): string => familyOf(num)?.family ?? num;
|
|
496
|
+
for (const item of docs) {
|
|
497
|
+
const abs = item.material.file!;
|
|
498
|
+
const place = placeOf(abs);
|
|
499
|
+
if (!place) continue;
|
|
500
|
+
const verdictLines: string[] = [];
|
|
501
|
+
const numbers: string[] = [];
|
|
502
|
+
const ownerQuotes: string[] = [];
|
|
503
|
+
// Verdict lines: stated verdicts of the document as it stands, when no link or fact cites the document.
|
|
504
|
+
if (!linked.has(pathKey(abs))) {
|
|
505
|
+
const page = ledger.verdicts({ repo: place.repo, path: place.rel, kind: 'verdict', confidence: 'stated', currentOnly: true, limit: 500 });
|
|
506
|
+
if (typeof page !== 'string') for (const v of page.rows) if (v.path === place.rel) verdictLines.push(`${item.label}:${v.line ?? '?'} — ${clipTo(v.text, 100)}`);
|
|
507
|
+
}
|
|
508
|
+
// Numbers: its own entries nothing carries.
|
|
509
|
+
const all = ledger.definitionsIn(place.repo, place.rel).filter((d) => d.path === place.rel);
|
|
510
|
+
const entryDefs = all.filter((d) => d.position === 'heading' || d.position === 'bold entry' || d.position === 'table first column');
|
|
511
|
+
let text: string | null = null;
|
|
512
|
+
try { text = ledger.currentText(abs); } catch { text = null; }
|
|
513
|
+
if (text === null) { try { text = readFileSync(abs, 'utf8'); } catch { text = null; } }
|
|
514
|
+
const lines = text === null ? null : text.split(/\r?\n/);
|
|
515
|
+
if (entryDefs.length) {
|
|
516
|
+
const own = entryDefinitions(entryDefs.map((d) => ({ ...d, position: d.position as 'heading' })), lines);
|
|
517
|
+
const first = lines ? headingLines(lines)[0] : undefined;
|
|
518
|
+
const docOwn = new Set<string>([
|
|
519
|
+
...definitionsInPath(place.rel).map((d) => d.family),
|
|
520
|
+
...all.filter((d) => d.position === 'front matter').map((d) => family(d.num)),
|
|
521
|
+
...(first ? entryDefs.filter((d) => d.position === 'heading' && d.line === first.line).map((d) => family(d.num)) : []),
|
|
522
|
+
]);
|
|
523
|
+
const entries = docOwn.size ? own.filter((d) => d.position === 'heading' || docOwn.has(family(d.num))) : own;
|
|
524
|
+
const accounted = accounts.get(place.rel);
|
|
525
|
+
for (const d of entries) if (!numbers.includes(d.num) && !carried.has(d.num.toUpperCase()) && !accounted?.has(d.num)) numbers.push(d.num);
|
|
526
|
+
}
|
|
527
|
+
// The owner's lines not looked at yet that it quotes.
|
|
528
|
+
if (quotable.length && text) {
|
|
529
|
+
const seen = new Set<string>();
|
|
530
|
+
for (const m of text.matchAll(QUOTED)) {
|
|
531
|
+
// A blockquote line may carry its own quotation marks: the example shows the words once.
|
|
532
|
+
const said = (m[1] ?? m[2] ?? m[3] ?? m[4] ?? '').trim().replace(/^[「『“"]+|[」』”"]+$/g, '');
|
|
533
|
+
const span = fold(said);
|
|
534
|
+
if (span.length < DISTINCT_WORDS) continue;
|
|
535
|
+
const hit = quotable.find((x) => x.words.includes(span));
|
|
536
|
+
if (!hit || seen.has(lineKey(hit.l.draftId, hit.l.ref))) continue;
|
|
537
|
+
seen.add(lineKey(hit.l.draftId, hit.l.ref));
|
|
538
|
+
ownerQuotes.push(`${item.label}: 「${clipTo(said, 80)}」 (the owner, ${hit.l.at.slice(0, 10) || 'time unknown'}; line ${lineKey(hit.l.draftId, hit.l.ref)})`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (verdictLines.length + numbers.length + ownerQuotes.length) out.set(item.key, { verdictLines, numbers, ownerQuotes });
|
|
542
|
+
}
|
|
543
|
+
if (sessions.length && open.length) {
|
|
544
|
+
const drafts = new Map(store.drafts.all().map((d) => [d.id, d]));
|
|
545
|
+
for (const item of sessions) {
|
|
546
|
+
const file = item.material.file ? pathKey(item.material.file) : null;
|
|
547
|
+
const own = open.filter((l) => { const d = drafts.get(l.draftId); return !!d && file !== null && !!d.session.file && pathKey(d.session.file) === file; });
|
|
548
|
+
if (own.length) out.set(item.key, { verdictLines: [], numbers: [], ownerQuotes: own.map((l) => `${item.label}: 「${clipTo(l.text, 80)}」 (line ${lineKey(l.draftId, l.ref)})`) });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return out;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** How many examples of each kind a group shows. */
|
|
555
|
+
const HOLD_EXAMPLES = 3;
|
|
556
|
+
|
|
557
|
+
/** What a material holds, in one line: for a refusal, and for the account that names it. */
|
|
558
|
+
export function holdsLine(h: MaterialHolds): string {
|
|
559
|
+
const parts: string[] = [];
|
|
560
|
+
if (h.verdictLines.length) parts.push(`${h.verdictLines.length} verdict line${h.verdictLines.length === 1 ? '' : 's'} no work item links`);
|
|
561
|
+
if (h.numbers.length) parts.push(`${h.numbers.length} number${h.numbers.length === 1 ? '' : 's'} no item carries (${h.numbers.slice(0, 6).join(', ')}${h.numbers.length > 6 ? ', …' : ''})`);
|
|
562
|
+
if (h.ownerQuotes.length) parts.push(`${h.ownerQuotes.length} owner's line${h.ownerQuotes.length === 1 ? '' : 's'} not looked at yet`);
|
|
563
|
+
return parts.join('; ');
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** The holds of the listed groups, by group id: the three counts, a few examples of each, and the materials that hold them. */
|
|
567
|
+
export function groupHoldsOf(state: CoverageState, holds: ReadonlyMap<string, MaterialHolds>): Map<string, GroupHolds> {
|
|
568
|
+
const acc = new Map<string, { verdicts: string[]; numbers: number; numberDocs: string[]; quotes: string[]; materials: string[] }>();
|
|
569
|
+
for (const item of state.open) {
|
|
570
|
+
const h = holds.get(item.key);
|
|
571
|
+
if (!holdsAny(h)) continue;
|
|
572
|
+
const id = groupIdOf(item, state.read.get(item.key));
|
|
573
|
+
const g = acc.get(id) ?? { verdicts: [], numbers: 0, numberDocs: [], quotes: [], materials: [] };
|
|
574
|
+
g.verdicts.push(...h.verdictLines);
|
|
575
|
+
if (h.numbers.length) { g.numbers += h.numbers.length; g.numberDocs.push(`${item.label}: ${h.numbers.slice(0, 8).join(', ')}${h.numbers.length > 8 ? `, and ${h.numbers.length - 8} more` : ''}`); }
|
|
576
|
+
g.quotes.push(...h.ownerQuotes);
|
|
577
|
+
g.materials.push(item.key);
|
|
578
|
+
acc.set(id, g);
|
|
579
|
+
}
|
|
580
|
+
const count = (n: number, examples: readonly string[]): HoldCount | undefined => (n ? { count: n, examples: examples.slice(0, HOLD_EXAMPLES) } : undefined);
|
|
581
|
+
return new Map([...acc].map(([id, g]): [string, GroupHolds] => {
|
|
582
|
+
const verdictLines = count(g.verdicts.length, g.verdicts);
|
|
583
|
+
const numbers = count(g.numbers, g.numberDocs);
|
|
584
|
+
const ownerQuotes = count(g.quotes.length, g.quotes);
|
|
585
|
+
return [id, { ...(verdictLines ? { verdictLines } : {}), ...(numbers ? { numbers } : {}), ...(ownerQuotes ? { ownerQuotes } : {}), materials: g.materials }];
|
|
586
|
+
}));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ───────────────────────── the project and the ledger, when only the store is at hand ─────────────────────────
|
|
590
|
+
|
|
591
|
+
/** The project a store belongs to, from the workspace next to it; null when the store is not in a home. */
|
|
592
|
+
export function projectOfStore(store: ProjectStore): Project | null {
|
|
593
|
+
const home = dirname(dirname(store.dir));
|
|
594
|
+
const data = readJson<{ projects?: Project[] }>(workspaceFile(home), {});
|
|
595
|
+
return data.projects?.find((p) => p.id === store.projectId) ?? null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** A project that knows only where the ledger's repositories are: enough to place the material and count the reads. */
|
|
599
|
+
const bareProject = (store: ProjectStore, ledger: Ledger | null): Project => ({ id: store.projectId, locations: ledger?.repos().slice(0, 1).map((r) => r.path) ?? [], scope: [] }) as unknown as Project;
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Whether the round's coverage is settled (the gate into a `Full` deepening's cross-check, E148): every planned material
|
|
603
|
+
* read whole or accounted for (a follow-up lane settles only what it reads). `opts`: the project and ledger when the caller has
|
|
604
|
+
* them; else the project is read from the workspace beside the store and the ledger opened from the store's folder.
|
|
605
|
+
* Without a ledger nothing is planned, so nothing holds the gate.
|
|
606
|
+
*/
|
|
607
|
+
export function coverageSettledNow(store: ProjectStore, round: ClerkRound, opts: { readonly project?: Project | null; readonly ledger?: Ledger | null } = {}): boolean {
|
|
608
|
+
const own = opts.ledger === undefined ? Ledger.openDir(store.dir) : null;
|
|
609
|
+
const ledger = opts.ledger ?? own;
|
|
610
|
+
try {
|
|
611
|
+
const project = opts.project ?? projectOfStore(store) ?? bareProject(store, ledger);
|
|
612
|
+
return coverageOf(store, round, ledger, project).settled;
|
|
613
|
+
} finally {
|
|
614
|
+
own?.close();
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ───────────────────────── the tools ─────────────────────────
|
|
619
|
+
|
|
620
|
+
const text = (v: unknown): string => (typeof v === 'string' ? v : '');
|
|
621
|
+
const ok = (value: unknown) => ({ content: [{ type: 'text' as const, text: JSON.stringify(value, null, 1) }], details: {} });
|
|
622
|
+
const fail = (message: string) => ({ content: [{ type: 'text' as const, text: `ERROR: ${message}` }], details: {}, isError: true });
|
|
623
|
+
const now = () => new Date().toISOString();
|
|
624
|
+
|
|
625
|
+
/** The stages whose writers include a tool (clerk-steps.ts `STAGE_WRITERS`). */
|
|
626
|
+
const stagesOf = (tool: string): ClerkStage[] => (Object.keys(STAGE_WRITERS) as ClerkStage[]).filter((s) => STAGE_WRITERS[s].includes(tool));
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* The round's coverage record with what the check found now: whether it is settled (`open`: what is neither read whole
|
|
630
|
+
* nor accounted for), the accounts, and what no lane read whole (`untouched`, the first 20 keys of each group) — the
|
|
631
|
+
* accounts standing beside it, not taking it away — which the Keeper view shows (Spec §6.9).
|
|
632
|
+
*/
|
|
633
|
+
function recordCoverage(store: ProjectStore, round: ClerkRound, state: CoverageState, open: readonly CoverageItem[], accounted: readonly MaterialAccount[], jobId: string, summary: string, holds?: ReadonlyMap<string, GroupHolds>): void {
|
|
634
|
+
// DA: what each group holds, as this check counted it; an account between checks keeps what the last check recorded.
|
|
635
|
+
const before = new Map((round.coverage?.untouched ?? []).flatMap((g) => (g.holds ? [[g.group, g.holds] as const] : [])));
|
|
636
|
+
const untouched = coverageGroups({ ...state, open: [...state.items.values()].filter((i) => state.read.get(i.key) !== 'whole') })
|
|
637
|
+
.map((g) => { const h = holds ? holds.get(g.group) : before.get(g.group); return h ? { ...g, holds: h } : g; });
|
|
638
|
+
const coverage: RoundCoverage = { at: now(), accounted, settled: open.length === 0, untouched };
|
|
639
|
+
store.clerkRounds.put({ ...round, coverage, updatedAt: coverage.at }, { jobId, summary, basisSourceIds: [] });
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* `pk_coverage_check` and `pk_account_material` (D99; W0 contract §6). A job that is no part of a round has no coverage;
|
|
644
|
+
* only the round's main agent accounts, in the stages whose writers list it.
|
|
645
|
+
*/
|
|
646
|
+
export function coverageTools(ctx: ToolContext): ToolDefinition[] {
|
|
647
|
+
const { store, project } = ctx;
|
|
648
|
+
const roundOf = (): ClerkRound | string => {
|
|
649
|
+
const id = ctx.step?.roundId;
|
|
650
|
+
if (!id) return 'this job is no part of a round of the clerk method, so there is no coverage to check.';
|
|
651
|
+
return store.clerkRounds.get(id) ?? `round ${id} is not in the assets.`;
|
|
652
|
+
};
|
|
653
|
+
const withState = <T>(fn: (round: ClerkRound, state: CoverageState, ledger: Ledger | null) => T): T | ReturnType<typeof fail> => {
|
|
654
|
+
const round = roundOf();
|
|
655
|
+
if (typeof round === 'string') return fail(round);
|
|
656
|
+
const ledger = Ledger.openDir(store.dir);
|
|
657
|
+
try {
|
|
658
|
+
return fn(round, coverageOf(store, round, ledger, project), ledger);
|
|
659
|
+
} catch (error) {
|
|
660
|
+
return fail(`the coverage could not be computed: ${(error as Error).message}`);
|
|
661
|
+
} finally {
|
|
662
|
+
ledger?.close();
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
return [
|
|
666
|
+
defineTool({
|
|
667
|
+
name: 'pk_coverage_check', label: 'What no lane touched',
|
|
668
|
+
description: 'The coverage check (every material the round planned to read gets an outcome): after your lanes have ended, what of the plan no lane read in full — what the organizing plan says to read closely, what your lane briefs name, and the whole category where a brief names only a kind of material — grouped by category and top directory. The program counts what was read from the lanes\' recorded reads (and yours), not from their reports. A document is one material: its older versions are never listed. What the project\'s rules settle is not listed. `read: part` groups were read only in part. `holds` on a group is what its materials hold that nothing on the workbench carries — verdict lines no work item links, numbers no item carries, the owner\'s lines not looked at yet — as counts with a few examples and the materials that hold them. For each group: account for it with pk_account_material (not needed, or read in part, with why), or send a follow-up lane for it (pk_send_lanes, kind follow-up, its brief naming the group\'s files), then check again: what a follow-up lane did not read is listed again. A group with `holds` is not accounted for as a group: a lane reads the materials it names, or you account for each of them by its key, with its own reason. The cross-check of a Full deepening waits until settled is true. group: give one group\'s id to see all its keys (otherwise the first 20 of each).',
|
|
669
|
+
parameters: Type.Object({ group: Type.Optional(Type.String({ description: 'one group id as listed, to see all its keys' })) }),
|
|
670
|
+
execute: async (_id, p) => withState((round, state, ledger) => {
|
|
671
|
+
const only = text(p.group).trim();
|
|
672
|
+
// DA: what each listed group holds that nothing carries; a count that cannot be computed is left out, never guessed.
|
|
673
|
+
let held = new Map<string, GroupHolds>();
|
|
674
|
+
try { if (ledger) held = groupHoldsOf(state, materialHolds(store, ledger, state.open)); } catch { held = new Map(); }
|
|
675
|
+
const groups = coverageGroups(state, only ? null : 20).filter((g) => !only || g.group === only || g.category === only)
|
|
676
|
+
.map((g) => { const h = held.get(g.group); return h ? { ...g, holds: only ? h : { ...h, materials: h.materials.slice(0, 20) } } : g; });
|
|
677
|
+
if (only && !groups.length) return fail(`no listed group is ${only}; the listed groups are: ${coverageGroups(state).map((g) => g.group).join(', ') || 'none — everything is settled'}.`);
|
|
678
|
+
// Recorded at every check, so the Keeper view shows what this check listed (Spec §6.9).
|
|
679
|
+
recordCoverage(store, round, state, state.open, state.accounted, ctx.jobId, `Coverage check: ${state.open.length} of ${state.items.size} planned materials listed`, held);
|
|
680
|
+
const whole = [...state.read.values()].filter((r) => r === 'whole').length;
|
|
681
|
+
const citedBy = [...state.cited].slice(0, 8).map(([key, what]) => `${key.replace(/^doc:/, '')} — ${what}`);
|
|
682
|
+
return ok({
|
|
683
|
+
untouched: groups, accounted: state.accounted.map((a) => (a.by === 'program' && (a.keys?.length ?? 0) > 12 ? { ...a, keys: [...a.keys!.slice(0, 12), `… ${a.keys!.length - 12} more`] } : a)), settled: state.settled,
|
|
684
|
+
...(state.cited.size ? { cited: { count: state.cited.size, note: 'Reports and prompts no lane read whole that a work item or a decision entry already cites: the program accounted for them, so they are not listed. A follow-up lane reads the listed (uncited) material only.', examples: citedBy } } : {}),
|
|
685
|
+
totals: { planned: state.items.size, readWhole: whole, accounted: state.accountedKeys.size, listed: state.open.length },
|
|
686
|
+
...(state.unresolved.length ? { unresolved: state.unresolved } : {}),
|
|
687
|
+
});
|
|
688
|
+
}),
|
|
689
|
+
}),
|
|
690
|
+
defineTool({
|
|
691
|
+
name: 'pk_account_material',
|
|
692
|
+
label: 'Account for material no lane read',
|
|
693
|
+
description: 'Account for listed material (pk_coverage_check) that no lane read in full: outcome "not needed" — it need not be read for this round\'s questions, and why — or "part" — only a part of it was read (a `read: part` group), and why that is enough. Give keys (as listed) or one group (its id exactly as listed — the "(read in part)" group is a group of its own — or a category: its unread groups for "not needed", its part-read groups for "part"). A group that holds something nothing carries (`holds` in pk_coverage_check) is not accounted for as a group: send a follow-up lane for the materials that hold it, or account for each of them by its key with its own reason — one key in keys, or each: [{ key, why }] for several in one call; the rest of the group is then accounted for as a group. The account is recorded on the round, in your words, and shows in the Keeper view. Account honestly: what should be read, send a follow-up lane for instead.',
|
|
694
|
+
parameters: Type.Object({
|
|
695
|
+
keys: Type.Optional(Type.Array(Type.String())),
|
|
696
|
+
group: Type.Optional(Type.String({ description: 'a group id exactly as pk_coverage_check lists it, or a category (documents, code files, commits, sessions)' })),
|
|
697
|
+
each: Type.Optional(Type.Array(Type.Object({ key: Type.String(), why: Type.String() }), { description: 'materials accounted for one by one, each with its own reason: for materials that hold something nothing carries' })),
|
|
698
|
+
outcome: Type.String({ description: 'not needed | part' }),
|
|
699
|
+
why: Type.Optional(Type.String({ description: 'the reason for the keys or the group; each carries its own' })),
|
|
700
|
+
}),
|
|
701
|
+
execute: async (_id, p) => withState((round, state, ledger) => {
|
|
702
|
+
const allowed = stagesOf('pk_account_material');
|
|
703
|
+
if (ctx.step?.kind !== 'main') return fail('pk_account_material records the main agent\'s account of the coverage; this job is not the round\'s main agent, so nothing was recorded.');
|
|
704
|
+
if (round.stage && !allowed.includes(round.stage)) return fail(`pk_account_material is written in the ${allowed.join(', ')} stage; the round is in ${round.stage}, so nothing was recorded. Move there with pk_stage first.`);
|
|
705
|
+
const outcome = text(p.outcome).trim();
|
|
706
|
+
if (outcome !== 'not needed' && outcome !== 'part') return fail('outcome must be "not needed" or "part".');
|
|
707
|
+
const each = (Array.isArray(p.each) ? p.each as unknown[] : []).map((e) => ({ key: text((e as Record<string, unknown>)?.key).trim(), why: text((e as Record<string, unknown>)?.why).trim() })).filter((e) => e.key);
|
|
708
|
+
const keys = Array.isArray(p.keys) ? (p.keys as unknown[]).map(text).map((k) => k.trim()).filter(Boolean) : [];
|
|
709
|
+
const group = text(p.group).trim();
|
|
710
|
+
if (each.length && (keys.length || group)) return fail('give each alone — one reason per material — or keys or a group with one why. Nothing was recorded.');
|
|
711
|
+
const why = text(p.why).trim();
|
|
712
|
+
if (!each.length && !why) return fail('why is empty: say why this material need not be read, or why the part read is enough.');
|
|
713
|
+
if (!each.length && !keys.length && !group) return fail('give keys or a group, as pk_coverage_check lists them, or each.');
|
|
714
|
+
const noReason = each.filter((e) => !e.why).map((e) => e.key);
|
|
715
|
+
if (noReason.length) return fail(`each material has its own reason; none was given for ${noReason.slice(0, 12).join(', ')}. Nothing was recorded.`);
|
|
716
|
+
const open = new Map(state.open.map((i) => [i.key, i]));
|
|
717
|
+
const chosen = new Set<string>();
|
|
718
|
+
const settledAlready: string[] = [];
|
|
719
|
+
const unknown: string[] = [];
|
|
720
|
+
for (const k of each.length ? each.map((e) => e.key) : keys) { if (open.has(k)) chosen.add(k); else if (state.items.has(k)) settledAlready.push(k); else unknown.push(k); }
|
|
721
|
+
// A category stands for its groups of the outcome given: what was read in part is accounted for as "part" (DA).
|
|
722
|
+
const isCategory = (CATEGORY_ORDER as readonly string[]).includes(group);
|
|
723
|
+
let otherTwin = 0;
|
|
724
|
+
if (group) {
|
|
725
|
+
const before = chosen.size;
|
|
726
|
+
for (const item of state.open) {
|
|
727
|
+
if (!inGroup(item, state.read.get(item.key), group)) continue;
|
|
728
|
+
if (isCategory && (state.read.get(item.key) === 'part') !== (outcome === 'part')) { otherTwin++; continue; }
|
|
729
|
+
chosen.add(item.key);
|
|
730
|
+
}
|
|
731
|
+
if (chosen.size === before && !otherTwin) unknown.push(group);
|
|
732
|
+
}
|
|
733
|
+
if (!chosen.size) {
|
|
734
|
+
return fail(`nothing listed matches${unknown.length ? ` (${unknown.join(', ')})` : ''}${settledAlready.length ? `; already read or accounted for: ${settledAlready.join(', ')}` : ''}${otherTwin ? `; the ${otherTwin} listed material${otherTwin === 1 ? '' : 's'} of ${group} ${outcome === 'part' ? 'were not read at all: account for them as not needed' : 'were read in part: account for them with outcome part'}` : ''}. Listed groups: ${coverageGroups(state).map((g) => g.group).join(', ') || 'none — everything is settled'}.`);
|
|
735
|
+
}
|
|
736
|
+
if (outcome === 'part') {
|
|
737
|
+
const unread = [...chosen].filter((k) => state.read.get(k) !== 'part');
|
|
738
|
+
if (unread.length) return fail(`outcome part is for material read in part, and no lane read any of ${unread.slice(0, 20).join(', ')}${unread.length > 20 ? ` and ${unread.length - 20} more` : ''}: account for it as not needed, with why, or send a follow-up lane for it. Nothing was recorded.`);
|
|
739
|
+
}
|
|
740
|
+
// DA: what the chosen materials hold that nothing carries. A group that holds something is not written off whole,
|
|
741
|
+
// and a material that holds something is accounted for by its own reason.
|
|
742
|
+
let holds = new Map<string, MaterialHolds>();
|
|
743
|
+
try { if (ledger) holds = materialHolds(store, ledger, [...chosen].map((k) => open.get(k)!)); } catch { holds = new Map(); }
|
|
744
|
+
const holding = [...chosen].filter((k) => holds.has(k));
|
|
745
|
+
if (holding.length && (group || (!each.length && chosen.size > 1))) {
|
|
746
|
+
const total = { verdicts: 0, numbers: 0, quotes: 0 };
|
|
747
|
+
for (const k of holding) { const h = holds.get(k)!; total.verdicts += h.verdictLines.length; total.numbers += h.numbers.length; total.quotes += h.ownerQuotes.length; }
|
|
748
|
+
const what = [total.verdicts ? `${total.verdicts} verdict line${total.verdicts === 1 ? '' : 's'} no work item links` : '', total.numbers ? `${total.numbers} number${total.numbers === 1 ? '' : 's'} no item carries` : '', total.quotes ? `${total.quotes} owner's line${total.quotes === 1 ? '' : 's'} not looked at yet` : ''].filter(Boolean).join(', ');
|
|
749
|
+
const named = holding.slice(0, 20).map((k) => `- ${k}: ${holdsLine(holds.get(k)!)}`).join('\n');
|
|
750
|
+
return fail(`${group ? `${group} is not accounted for as a group` : 'These materials are not accounted for with one reason'}: ${holding.length} of the ${chosen.size} material${chosen.size === 1 ? '' : 's'} hold${holding.length === 1 ? 's' : ''} what nothing on the workbench carries — ${what}:\n${named}${holding.length > 20 ? `\n- and ${holding.length - 20} more (pk_coverage_check with the group lists them all)` : ''}\nSend a follow-up lane for them (pk_send_lanes, kind follow-up, its brief naming them), or account for each by its key with its own reason: pk_account_material { each: [{ key, why }], outcome }. The rest${group ? ' of the group' : ''} is then accounted for as before. Nothing was recorded.`);
|
|
751
|
+
}
|
|
752
|
+
const at = now();
|
|
753
|
+
const reasonOf = new Map(each.map((e) => [e.key, e.why]));
|
|
754
|
+
const written: MaterialAccount[] = each.length
|
|
755
|
+
? [...chosen].map((k) => ({ keys: [k], group: null, outcome, why: reasonOf.get(k)!, by: 'main' as const, at, ...(holds.has(k) ? { holds: holdsLine(holds.get(k)!) } : {}) }))
|
|
756
|
+
: [{ keys: [...chosen], group: group || null, outcome, why, by: 'main', at, ...(holding.length === 1 ? { holds: holdsLine(holds.get(holding[0]!)!) } : {}) }];
|
|
757
|
+
const accounted = [...state.accounted, ...written];
|
|
758
|
+
const rest = state.open.filter((i) => !chosen.has(i.key));
|
|
759
|
+
const left = rest.length;
|
|
760
|
+
recordCoverage(store, round, state, rest, accounted, ctx.jobId, `Coverage: ${chosen.size} material${chosen.size === 1 ? '' : 's'} accounted for as ${outcome}`);
|
|
761
|
+
return ok({ accounted: chosen.size, settled: left === 0, left, ...(holding.length ? { held: holding.length } : {}), ...(otherTwin ? { leftListed: `${otherTwin} material${otherTwin === 1 ? '' : 's'} of ${group} ${outcome === 'part' ? 'not read at all (account for them as not needed)' : 'read in part (account for them with outcome part)'}` } : {}), ...(unknown.length ? { unknown } : {}), ...(settledAlready.length ? { alreadySettled: settledAlready } : {}) });
|
|
762
|
+
}),
|
|
763
|
+
}),
|
|
764
|
+
];
|
|
765
|
+
}
|