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,662 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evidence the Keeper cites, resolved by the program (Spec v3.0 §1.16, §1.18, §2.11; D77, D83). The model never writes
|
|
3
|
+
* a date, a count, a label of evidence or a quoted line the program cannot check: it names what it points at — a ledger
|
|
4
|
+
* entry, a source, a commit, a file or an object of the assets, and at most one line of it — and the program checks
|
|
5
|
+
* that it is there, reads its label, finds the line as the original has it and reads when it happened.
|
|
6
|
+
*
|
|
7
|
+
* When it happened (`Occurred`, §2.11) comes from the material, never from the model:
|
|
8
|
+
* - a commit: its author time (`Commit`);
|
|
9
|
+
* - a session message: the message's time (`Session`); a session source cited without a line, its first message's;
|
|
10
|
+
* - a document: the commit of the version cited — the commit a cited line first appeared in, or the last commit that
|
|
11
|
+
* touched the file up to the version read (`Commit`); a file with no commit behind it, its file time (`File time`,
|
|
12
|
+
* a weak basis: copying and moving change it);
|
|
13
|
+
* - what gives no time at all is `Undated · first seen <when the program first saw it>` (`First observed`, `undated`).
|
|
14
|
+
* Precision is the source's: a date stays a date; a clock time is stored as its instant in UTC (model/time.ts). A date
|
|
15
|
+
* written in the text that disagrees with the commit (`other`) is left to the ledger, which reads the text.
|
|
16
|
+
*
|
|
17
|
+
* Everything here only reads: the assets, the files of the project's scope, and git with read-only commands.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync, statSync, type Stats } from 'node:fs';
|
|
20
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
21
|
+
import type { EvidenceRef, Occurred, OccurredBasis } from '../model/k-types.ts';
|
|
22
|
+
import type { Project, Source } from '../model/types.ts';
|
|
23
|
+
import { normalizeMaterialTime } from '../model/time.ts';
|
|
24
|
+
import type { ProjectStore } from '../store/project-store.ts';
|
|
25
|
+
import { anchorLabel } from '../sources/anchor.ts';
|
|
26
|
+
import { MAX_REVISION_BYTES } from '../sources/history.ts';
|
|
27
|
+
import { parseClaudeSession, parseCodexSession, type ParsedSession } from '../sources/sessions/read.ts';
|
|
28
|
+
import { gitCommitMeta, gitRead, gitResolveCommit, gitTreeEntry } from '../util/git.ts';
|
|
29
|
+
import { isWithin, nameForm, normalizePath, pathKey, samePath } from '../util/paths.ts';
|
|
30
|
+
import { resolveMergedId } from './merge.ts';
|
|
31
|
+
import { checkVerbatim, findWords } from './verbatim.ts';
|
|
32
|
+
|
|
33
|
+
export const EVIDENCE_KINDS = ['ledger', 'source', 'commit', 'file', 'object'] as const;
|
|
34
|
+
export type EvidenceKind = (typeof EVIDENCE_KINDS)[number];
|
|
35
|
+
|
|
36
|
+
/** What a tool call names as evidence. Everything else about it is read by the program. */
|
|
37
|
+
export interface EvidenceInput {
|
|
38
|
+
readonly kind: EvidenceKind;
|
|
39
|
+
/** Ledger entry id, source id, commit hash (full or short), repository-relative path, or object id. */
|
|
40
|
+
readonly id: string;
|
|
41
|
+
/** One line of it, as the original has it (checked; stored as the original has it). */
|
|
42
|
+
readonly line?: string;
|
|
43
|
+
/** For a file or a commit: which repository of the scope (its scope item id or path); default the main one. */
|
|
44
|
+
readonly repo?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One entry of the ledger (§1.16), as the ledger answers for it. */
|
|
48
|
+
export interface LedgerEntry { readonly label: string; readonly occurred: Occurred; readonly text?: string }
|
|
49
|
+
/** The ledger is built in its own package; the tools reach it through this hook when a build has one. */
|
|
50
|
+
export interface LedgerHook { resolve(id: string): LedgerEntry | null }
|
|
51
|
+
|
|
52
|
+
export interface EvidenceContext {
|
|
53
|
+
readonly store: ProjectStore;
|
|
54
|
+
readonly project: Project;
|
|
55
|
+
readonly ledger?: LedgerHook | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What a caller may not hand in as evidence: the program reads these from what the evidence names. */
|
|
59
|
+
const PROGRAM_READS = ['label', 'occurred', 'at', 'date', 'time', 'when'] as const;
|
|
60
|
+
|
|
61
|
+
const str = (v: unknown): string => (typeof v === 'string' ? v : '');
|
|
62
|
+
const now = () => new Date().toISOString();
|
|
63
|
+
function snippet(s: string, max = 120): string {
|
|
64
|
+
const cps = [...s.replace(/\s+/g, ' ').trim()];
|
|
65
|
+
return cps.length <= max ? cps.join('') : `${cps.slice(0, max).join('')}…`;
|
|
66
|
+
}
|
|
67
|
+
const HOW_A_LINE_IS_READ = 'A cited line is checked against the original: case, spacing, punctuation, quotation marks and Markdown marks make no difference, but every word has to be there, in that order. Copy the line from the original instead of rewording it.';
|
|
68
|
+
|
|
69
|
+
// ───────────────────────── when it happened ─────────────────────────
|
|
70
|
+
|
|
71
|
+
function dated(at: string, basis: OccurredBasis, anchor: string | null): Occurred | null {
|
|
72
|
+
const time = normalizeMaterialTime(at);
|
|
73
|
+
return time ? { at: time, basis, anchor } : null;
|
|
74
|
+
}
|
|
75
|
+
function undatedAt(firstSeen: string | null | undefined, anchor: string | null): Occurred {
|
|
76
|
+
const seen = firstSeen && !Number.isNaN(Date.parse(firstSeen)) ? (normalizeMaterialTime(firstSeen) ?? firstSeen) : now();
|
|
77
|
+
return { at: seen, basis: 'First observed', anchor, undated: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function instant(o: Occurred): number | null {
|
|
81
|
+
const ms = Date.parse(o.at);
|
|
82
|
+
return Number.isNaN(ms) ? null : ms;
|
|
83
|
+
}
|
|
84
|
+
/** Oldest first, by the instant each time names; on a tie a dated time goes before an undated one. */
|
|
85
|
+
export function compareOccurred(a: Occurred, b: Occurred): number {
|
|
86
|
+
const x = instant(a);
|
|
87
|
+
const y = instant(b);
|
|
88
|
+
const byTime = x !== null && y !== null ? x - y : a.at.localeCompare(b.at);
|
|
89
|
+
return byTime !== 0 ? byTime : Number(a.undated === true) - Number(b.undated === true);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The earliest of these times. An undated time is when the program first saw something, not when it happened, so it is
|
|
93
|
+
* the answer only when nothing is dated.
|
|
94
|
+
*/
|
|
95
|
+
export function earliestOccurred(list: readonly (Occurred | null | undefined)[]): Occurred | null {
|
|
96
|
+
const all = list.filter((o): o is Occurred => o !== null && o !== undefined);
|
|
97
|
+
const pool = all.some((o) => o.undated !== true) ? all.filter((o) => o.undated !== true) : all;
|
|
98
|
+
return [...pool].sort(compareOccurred)[0] ?? null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ───────────────────────── the project's locations ─────────────────────────
|
|
102
|
+
|
|
103
|
+
export interface ProjectRoot {
|
|
104
|
+
/** The scope item's id (or `location-<n>` for a location given with no scope yet). */
|
|
105
|
+
readonly id: string;
|
|
106
|
+
readonly path: string;
|
|
107
|
+
readonly git: boolean;
|
|
108
|
+
readonly main: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The repositories and directories of the scope a path can be in, the main repository first. */
|
|
112
|
+
export function projectRoots(project: Project): ProjectRoot[] {
|
|
113
|
+
const roots: ProjectRoot[] = project.scope
|
|
114
|
+
.filter((i) => i.category !== 'Session source' && i.relation !== 'Excluded' && i.relation !== 'Session source' && !i.missing)
|
|
115
|
+
.map((i) => ({
|
|
116
|
+
id: i.id, path: i.path, main: i.relation === 'Main project',
|
|
117
|
+
git: i.versionControl === 'git' || ((i.category === 'Repository' || i.category === 'Worktree') && i.versionControl !== 'none'),
|
|
118
|
+
}));
|
|
119
|
+
if (roots.length === 0) {
|
|
120
|
+
for (const [n, loc] of project.locations.entries()) roots.push({ id: `location-${n}`, path: loc, git: existsSync(join(loc, '.git')), main: n === 0 });
|
|
121
|
+
}
|
|
122
|
+
const rank = (r: ProjectRoot) => (r.main && r.git ? 0 : r.git ? 1 : r.main ? 2 : 3);
|
|
123
|
+
return roots.map((r, i) => ({ r, i })).sort((a, b) => rank(a.r) - rank(b.r) || a.i - b.i).map((x) => x.r);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The repository a piece of evidence is in when it is not the project's first one (`EvidenceRef.repo`): its path. What
|
|
128
|
+
* is in the first repository names none — a path or a commit is the project's main repository's unless it says otherwise.
|
|
129
|
+
*/
|
|
130
|
+
export function otherRepository(project: Project, rootPath: string): string | null {
|
|
131
|
+
const first = projectRoots(project)[0];
|
|
132
|
+
return first && samePath(first.path, rootPath) ? null : rootPath;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface ProjectPath { readonly root: ProjectRoot; readonly rel: string }
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Which location and path a call means: `repo` names a scope item by id or path; an absolute path picks the location
|
|
139
|
+
* that holds it; otherwise the main repository. The path is relative to that location, with forward slashes; a path
|
|
140
|
+
* that leaves it is refused.
|
|
141
|
+
*/
|
|
142
|
+
export function resolvePath(project: Project, input: { readonly path: string; readonly repo?: string | null }): ProjectPath | string {
|
|
143
|
+
const roots = projectRoots(project);
|
|
144
|
+
if (roots.length === 0) return 'This project has no location in its scope, so no path can be found in it.';
|
|
145
|
+
const raw = (input.path ?? '').trim();
|
|
146
|
+
const repoArg = (input.repo ?? '').trim();
|
|
147
|
+
let root: ProjectRoot | undefined;
|
|
148
|
+
if (repoArg) {
|
|
149
|
+
root = roots.find((r) => r.id === repoArg) ?? roots.find((r) => samePath(r.path, repoArg));
|
|
150
|
+
if (!root) return `${repoArg} is not a repository or directory of this project’s scope. Use one of: ${roots.map((r) => `${r.id} (${r.path})`).join(', ')}.`;
|
|
151
|
+
} else if (raw && isAbsolute(raw)) {
|
|
152
|
+
root = roots.filter((r) => isWithin(r.path, raw)).sort((a, b) => b.path.length - a.path.length)[0];
|
|
153
|
+
if (!root) return `${raw} is not inside this project’s scope. Give the path relative to the repository root.`;
|
|
154
|
+
} else {
|
|
155
|
+
root = roots[0]!;
|
|
156
|
+
}
|
|
157
|
+
let rel = raw;
|
|
158
|
+
if (raw && isAbsolute(raw)) {
|
|
159
|
+
if (!isWithin(root.path, raw)) return `${raw} is not inside ${root.path}. Give the path relative to the repository root.`;
|
|
160
|
+
rel = relative(normalizePath(root.path), normalizePath(raw));
|
|
161
|
+
}
|
|
162
|
+
// In the form names are kept in: a path copied from a directory listing on a Mac is in another one than git's.
|
|
163
|
+
rel = nameForm(rel).split('\\').join('/').replace(/^\.\/+/, '').replace(/\/+$/, '');
|
|
164
|
+
if (rel === '.') rel = '';
|
|
165
|
+
if (rel.split('/').some((seg) => seg === '..') || /^[A-Za-z]:/.test(rel) || rel.startsWith('/')) return `${raw} leaves ${root.path}; give a path inside it.`;
|
|
166
|
+
return { root, rel };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ───────────────────────── read-only git, as evidence needs it ─────────────────────────
|
|
170
|
+
|
|
171
|
+
export interface CommitFacts { readonly hash: string; readonly at: string; readonly subject: string; readonly parents: readonly string[] }
|
|
172
|
+
const FORMAT = '--format=%x1e%H%x1f%aI%x1f%P%x1f%s';
|
|
173
|
+
function commitsOf(out: string): CommitFacts[] {
|
|
174
|
+
return out.split('\x1e').map((b) => b.trim()).filter(Boolean).map((b) => {
|
|
175
|
+
const [hash, at, parents, subject] = b.split(/\r?\n/)[0]!.split('\x1f');
|
|
176
|
+
return { hash: hash ?? '', at: at ?? '', parents: (parents ?? '').split(' ').filter(Boolean), subject: subject ?? '' };
|
|
177
|
+
}).filter((c) => /^[0-9a-f]{40}$/.test(c.hash));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const factsCache = new Map<string, CommitFacts | null>();
|
|
181
|
+
/** Author time, subject and parents of one commit (a full hash: what it says never changes). */
|
|
182
|
+
export function commitFacts(root: string, hash: string): CommitFacts | null {
|
|
183
|
+
const key = `${pathKey(root)}|${hash}`;
|
|
184
|
+
if (!factsCache.has(key)) {
|
|
185
|
+
const meta = gitCommitMeta(root, hash);
|
|
186
|
+
factsCache.set(key, meta ? { hash: meta.hash, at: meta.at, subject: meta.subject, parents: meta.parents } : null);
|
|
187
|
+
}
|
|
188
|
+
return factsCache.get(key) ?? null;
|
|
189
|
+
}
|
|
190
|
+
function commitMessage(root: string, hash: string): string | null {
|
|
191
|
+
const r = gitRead(root, ['log', '-1', '--format=%B', hash, '--'], { timeoutMs: 8000 });
|
|
192
|
+
return r.ok ? r.out : null;
|
|
193
|
+
}
|
|
194
|
+
/** History read up to a full hash never changes, so it is read once per process; HEAD and `--all` are read each time. */
|
|
195
|
+
const historyCache = new Map<string, CommitFacts | null>();
|
|
196
|
+
function cachedLog(root: string, upTo: string, key: readonly string[], read: () => CommitFacts | null): CommitFacts | null {
|
|
197
|
+
if (!/^[0-9a-f]{40}$/.test(upTo)) return read();
|
|
198
|
+
const k = [pathKey(root), upTo, ...key].join('\x1f');
|
|
199
|
+
if (!historyCache.has(k)) historyCache.set(k, read());
|
|
200
|
+
return historyCache.get(k) ?? null;
|
|
201
|
+
}
|
|
202
|
+
/** The newest commit reachable from `from` (a ref, or `--all`) that touched the path. */
|
|
203
|
+
function lastTouching(root: string, rel: string, from: string): CommitFacts | null {
|
|
204
|
+
return cachedLog(root, from, ['last', rel], () => {
|
|
205
|
+
const r = gitRead(root, ['log', '-1', FORMAT, from, '--', rel], { timeoutMs: 20_000 });
|
|
206
|
+
return r.ok ? commitsOf(r.out)[0] ?? null : null;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/** The commit these exact words first appeared in, in the history of the path up to `upTo` (following renames). */
|
|
210
|
+
function firstWithText(root: string, rel: string, text: string, upTo: string): CommitFacts | null {
|
|
211
|
+
if (!text.trim()) return null;
|
|
212
|
+
return cachedLog(root, upTo, ['first', rel, text], () => {
|
|
213
|
+
const r = gitRead(root, ['log', '--follow', FORMAT, `-S${text}`, upTo, '--', rel], { timeoutMs: 30_000 });
|
|
214
|
+
if (!r.ok) return null;
|
|
215
|
+
const all = commitsOf(r.out);
|
|
216
|
+
return all[all.length - 1] ?? null;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const inTree = (root: string, commit: string, rel: string): boolean => gitTreeEntry(root, commit, rel) !== null;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A path that is not in the working tree, as history last had it: the commit whose tree holds its last version (for a
|
|
223
|
+
* deleted file, the parent of the commit that deleted it) and when that version was made. HEAD's history first, then
|
|
224
|
+
* every branch.
|
|
225
|
+
*/
|
|
226
|
+
function lastVersionInHistory(root: string, rel: string): { treeish: string; made: CommitFacts } | null {
|
|
227
|
+
for (const from of ['HEAD', '--all']) {
|
|
228
|
+
const newest = lastTouching(root, rel, from);
|
|
229
|
+
if (!newest) continue;
|
|
230
|
+
if (inTree(root, newest.hash, rel)) return { treeish: newest.hash, made: newest };
|
|
231
|
+
for (const parent of newest.parents) {
|
|
232
|
+
if (!inTree(root, parent, rel)) continue;
|
|
233
|
+
return { treeish: parent, made: lastTouching(root, rel, parent) ?? commitFacts(root, parent) ?? newest };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Whether a path of a location exists in its working tree now, or only in its history. */
|
|
240
|
+
export function pathPresence(root: ProjectRoot, rel: string): 'now' | 'history' | null {
|
|
241
|
+
if (existsSync(join(root.path, ...rel.split('/')))) return 'now';
|
|
242
|
+
return root.git && rel && lastTouching(root.path, rel, '--all') ? 'history' : null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The git roots of the scope a commit can be in: the one named, or all of them, the main repository first. */
|
|
246
|
+
function gitRoots(project: Project, repo: string): ProjectRoot[] | string {
|
|
247
|
+
const all = projectRoots(project).filter((r) => r.git);
|
|
248
|
+
if (!repo) return all;
|
|
249
|
+
const named = all.find((r) => r.id === repo) ?? all.find((r) => samePath(r.path, repo));
|
|
250
|
+
return named ? [named] : `${repo} is not a git repository of this project’s scope. Use one of: ${all.map((r) => `${r.id} (${r.path})`).join(', ') || 'none — the scope has no git repository'}.`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ───────────────────────── sessions ─────────────────────────
|
|
254
|
+
|
|
255
|
+
const sessionCache = new Map<string, { size: number; mtimeMs: number; parsed: ParsedSession }>();
|
|
256
|
+
/** A Claude Code or Codex log, parsed once per version of the file; null when it cannot be read. */
|
|
257
|
+
export function parsedSession(host: string, file: string): ParsedSession | null {
|
|
258
|
+
if ((host !== 'claude' && host !== 'codex') || !file) return null;
|
|
259
|
+
let st;
|
|
260
|
+
try { st = statSync(file); } catch { return null; }
|
|
261
|
+
const key = `${host}:${pathKey(file)}`;
|
|
262
|
+
const hit = sessionCache.get(key);
|
|
263
|
+
if (hit && hit.size === st.size && hit.mtimeMs === st.mtimeMs) return hit.parsed;
|
|
264
|
+
try {
|
|
265
|
+
const parsed = host === 'codex' ? parseCodexSession(file) : parseClaudeSession(file);
|
|
266
|
+
sessionCache.set(key, { size: st.size, mtimeMs: st.mtimeMs, parsed });
|
|
267
|
+
return parsed;
|
|
268
|
+
} catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** A header of the transcript a session source holds (sources/sessions/read.ts `renderMessage`). */
|
|
274
|
+
const HEADER = /^\[(\d+)\] (?:OWNER|AGENT|SUBAGENT TASK|SUBAGENT|HOST)\b[^\n]*$/gm;
|
|
275
|
+
/** The message of a session transcript that holds the text at `offset`: its position, and the minute its header shows. */
|
|
276
|
+
function messageAtOffset(transcript: string, offset: number): { index: number; minute: string | null } | null {
|
|
277
|
+
let found: RegExpMatchArray | null = null;
|
|
278
|
+
for (const m of transcript.matchAll(HEADER)) {
|
|
279
|
+
if (m.index! > offset) break;
|
|
280
|
+
found = m;
|
|
281
|
+
}
|
|
282
|
+
if (!found) return null;
|
|
283
|
+
const clock = /(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})\s*$/.exec(found[0]);
|
|
284
|
+
return { index: Number(found[1]), minute: clock ? `${clock[1]}T${clock[2]}Z` : null };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ───────────────────────── when a source happened ─────────────────────────
|
|
288
|
+
|
|
289
|
+
/** A cited line, where the source's excerpt has it. */
|
|
290
|
+
interface CitedLine { readonly text: string; readonly start: number }
|
|
291
|
+
|
|
292
|
+
/** Marks that open what follows (brackets, opening quotes, emphasis), and marks that close what went before. */
|
|
293
|
+
const OPENS = /[\p{Ps}\p{Pi}*_`~"'“‘「『《〈]/u;
|
|
294
|
+
const CLOSES = /[\p{Pe}\p{Pf}*_`~"'”’」』》〉.。!!??…]/u;
|
|
295
|
+
/**
|
|
296
|
+
* The words the caller cited, as the original has them, with the marks that enclose or end them (the full stop after
|
|
297
|
+
* a sentence, the `**` around a number, the brackets around a clause) — never a word more, and never the mark that opens
|
|
298
|
+
* the next clause: text with no spaces between words (Chinese) would otherwise run on.
|
|
299
|
+
*/
|
|
300
|
+
function asCited(raw: string, words: string): CitedLine | null {
|
|
301
|
+
const found = findWords(words, raw);
|
|
302
|
+
if (!found) return null;
|
|
303
|
+
const t = raw.normalize('NFC');
|
|
304
|
+
let start = found.start;
|
|
305
|
+
let end = found.start + found.text.length;
|
|
306
|
+
while (start > 0 && OPENS.test(t[start - 1]!)) start--;
|
|
307
|
+
while (end < t.length && CLOSES.test(t[end]!)) end++;
|
|
308
|
+
return { text: t.slice(start, end), start };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The single line of a cited stretch that git can search the history for (a pickaxe works on text, not on lines). */
|
|
312
|
+
function searchable(line: string): string {
|
|
313
|
+
const lines = line.split(/\r?\n/).map((l) => l.replace(/\r$/, '')).filter((l) => l.trim());
|
|
314
|
+
return lines.sort((a, b) => b.trim().length - a.trim().length)[0] ?? '';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* When the material of a source happened (§2.11): a session message's time, a commit's author time, the commit of the
|
|
319
|
+
* version of a document — the commit a cited line first appeared in when one is cited — or the file's time when no
|
|
320
|
+
* commit is behind it; undated, with when it was first read, when the source gives no time.
|
|
321
|
+
*/
|
|
322
|
+
export function sourceOccurred(ctx: EvidenceContext, source: Source, line: CitedLine | null = null): Occurred {
|
|
323
|
+
const a = source.anchor;
|
|
324
|
+
const seen = undatedAt(source.version.readAt, source.id);
|
|
325
|
+
switch (a.kind) {
|
|
326
|
+
case 'session': {
|
|
327
|
+
if (line && a.host !== 'pi') {
|
|
328
|
+
const message = messageAtOffset(source.excerpt.normalize('NFC'), line.start);
|
|
329
|
+
if (message) {
|
|
330
|
+
const exact = parsedSession(a.host, a.file)?.messages.find((m) => m.index === message.index)?.at ?? null;
|
|
331
|
+
const at = exact ?? message.minute;
|
|
332
|
+
const time = at ? dated(at, 'Session', source.id) : null;
|
|
333
|
+
if (time) return time;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return (a.at ? dated(a.at, 'Session', source.id) : null) ?? seen;
|
|
337
|
+
}
|
|
338
|
+
case 'commit': {
|
|
339
|
+
const at = a.at ?? commitFacts(a.repo, a.commit)?.at ?? null;
|
|
340
|
+
return (at ? dated(at, 'Commit', a.commit) : null) ?? seen;
|
|
341
|
+
}
|
|
342
|
+
case 'revision': {
|
|
343
|
+
const found = line ? firstWithText(a.repo, a.path, searchable(line.text), a.commit) : null;
|
|
344
|
+
const made = found ?? lastTouching(a.repo, a.path, a.commit) ?? commitFacts(a.repo, a.commit);
|
|
345
|
+
return (made ? dated(made.at, 'Commit', made.hash) : null) ?? seen;
|
|
346
|
+
}
|
|
347
|
+
case 'file': {
|
|
348
|
+
const place = resolvePath(ctx.project, { path: a.path });
|
|
349
|
+
if (typeof place !== 'string' && place.root.git && place.rel && source.version.commit) {
|
|
350
|
+
const read = source.version.commit;
|
|
351
|
+
const upTo = /^[0-9a-f]{40}$/.test(read) ? read : gitResolveCommit(place.root.path, read);
|
|
352
|
+
if (upTo) {
|
|
353
|
+
const found = line ? firstWithText(place.root.path, place.rel, searchable(line.text), upTo) : null;
|
|
354
|
+
const made = found ?? lastTouching(place.root.path, place.rel, upTo);
|
|
355
|
+
const time = made ? dated(made.at, 'Commit', made.hash) : null;
|
|
356
|
+
if (time) return time;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// CM (E151; CK): a file read while it had changes not yet committed records no commit, and its time used to be the
|
|
360
|
+
// file's own — the day it was last saved — so a decision of mid-September read as "started" on the day of the run,
|
|
361
|
+
// after what ended it, and four pk_write_generation calls were refused. The words themselves are in the history: the
|
|
362
|
+
// commit the cited line first appeared in, or, with no line cited, the one the section's own heading did.
|
|
363
|
+
if (typeof place !== 'string' && place.root.git && place.rel && !source.version.commit) {
|
|
364
|
+
const head = gitResolveCommit(place.root.path, 'HEAD');
|
|
365
|
+
const words = line ? searchable(line.text) : a.headingPath.length ? searchable(source.excerpt.split(/\r?\n/).find((l) => l.trim()) ?? '') : '';
|
|
366
|
+
const found = head && words ? firstWithText(place.root.path, place.rel, words, head) : null;
|
|
367
|
+
const time = found ? dated(found.at, 'Commit', found.hash) : null;
|
|
368
|
+
if (time) return time;
|
|
369
|
+
}
|
|
370
|
+
return fileTime(a.path, source.id) ?? seen;
|
|
371
|
+
}
|
|
372
|
+
case 'command': return dated(a.ranAt, 'First observed', source.id) ?? seen;
|
|
373
|
+
case 'status': return dated(a.at, 'First observed', source.id) ?? seen;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function fileTime(abs: string, anchor: string | null): Occurred | null {
|
|
378
|
+
try {
|
|
379
|
+
const st = statSync(abs);
|
|
380
|
+
return st.isFile() ? { at: st.mtime.toISOString(), basis: 'File time', anchor } : null;
|
|
381
|
+
} catch {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ───────────────────────── objects of the assets ─────────────────────────
|
|
387
|
+
|
|
388
|
+
interface FoundObject {
|
|
389
|
+
readonly id: string;
|
|
390
|
+
readonly label: string;
|
|
391
|
+
/** Its own time, when it carries one the program gave it (a breakpoint's, a send-back's, a patch's). */
|
|
392
|
+
readonly occurred?: Occurred | null;
|
|
393
|
+
/** Otherwise: the sources it rests on, whose times the program reads. */
|
|
394
|
+
readonly sourceIds?: readonly string[];
|
|
395
|
+
/** When the program first recorded it, for an object nothing dates. */
|
|
396
|
+
readonly firstSeen?: string | null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const latestNoteTitle = (versions: readonly { title: string }[]): string => versions[versions.length - 1]?.title ?? '';
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* An object of the assets by its id — or by the number the Keeper gave it (a patch's `SP-n`, a Keeper number `K-n`) —
|
|
403
|
+
* with its name and what dates it; null when it names nothing.
|
|
404
|
+
*/
|
|
405
|
+
export function findObject(store: ProjectStore, rawId: string): FoundObject | null {
|
|
406
|
+
const id = rawId.trim();
|
|
407
|
+
if (!id) return null;
|
|
408
|
+
const merged = resolveMergedId(store, id);
|
|
409
|
+
const thread = store.threads.get(merged);
|
|
410
|
+
if (thread) {
|
|
411
|
+
const facts = thread.factRecordIds.flatMap((f) => store.facts.get(f)?.aboutSourceIds ?? []);
|
|
412
|
+
return { id: thread.id, label: thread.ids.length && !thread.title.includes(thread.ids[0]!) ? `${thread.ids[0]} ${thread.title}` : thread.title, sourceIds: [...facts, ...thread.executionFacts.flatMap((s) => s.sourceIds), ...thread.qcFacts.flatMap((s) => s.sourceIds)], firstSeen: thread.asOf };
|
|
413
|
+
}
|
|
414
|
+
const ref = store.reference.get(id);
|
|
415
|
+
if (ref) return { id, label: ref.name, sourceIds: ref.sourceIds, firstSeen: ref.asOf };
|
|
416
|
+
const node = store.nodes.get(id);
|
|
417
|
+
if (node) return { id, label: node.label, sourceIds: node.sourceIds, firstSeen: node.createdAt || node.updatedAt };
|
|
418
|
+
const area = store.areas.get(id);
|
|
419
|
+
if (area) return { id, label: `Area understanding: ${store.reference.get(area.referenceId)?.name ?? area.referenceId}`, sourceIds: store.reference.get(area.referenceId)?.sourceIds ?? [], firstSeen: area.asOf };
|
|
420
|
+
const fact = store.facts.get(id);
|
|
421
|
+
if (fact) return { id, label: fact.title, sourceIds: fact.aboutSourceIds, firstSeen: fact.asOf };
|
|
422
|
+
const relation = store.relations.get(id);
|
|
423
|
+
if (relation) {
|
|
424
|
+
const name = (x: string) => store.reference.get(x)?.name ?? store.threads.get(x)?.title ?? store.nodes.get(x)?.label ?? x;
|
|
425
|
+
return { id, label: `${relation.type}: ${name(relation.from)} → ${name(relation.to)}`, sourceIds: relation.evidence.sourceIds, firstSeen: relation.updatedAt };
|
|
426
|
+
}
|
|
427
|
+
const change = store.changes.get(id);
|
|
428
|
+
if (change) return { id, label: change.title, sourceIds: change.sourceIds, firstSeen: change.updatedAt };
|
|
429
|
+
const mark = store.marks.get(id);
|
|
430
|
+
if (mark) return { id, label: `${mark.kind}: ${mark.clue}`, sourceIds: mark.clueSourceIds, firstSeen: mark.since };
|
|
431
|
+
const note = store.notes.get(id);
|
|
432
|
+
if (note) return { id, label: latestNoteTitle(note.versions), sourceIds: note.versions.flatMap((v) => v.body.facts.flatMap((f) => f.sourceIds)), firstSeen: note.versions[0]?.at ?? note.updatedAt };
|
|
433
|
+
const rule = store.rules.get(id);
|
|
434
|
+
if (rule) return { id, label: rule.summary, sourceIds: rule.sourceIds, firstSeen: rule.asOf };
|
|
435
|
+
const breakpoint = store.breakpoints.get(id);
|
|
436
|
+
if (breakpoint) return { id, label: `${breakpoint.kind}: ${breakpoint.why}`, occurred: breakpoint.since };
|
|
437
|
+
const sendBack = store.sendbacks.get(id);
|
|
438
|
+
if (sendBack) return { id, label: `Send-back to ${sendBack.to}: ${sendBack.what}`, occurred: sendBack.occurred };
|
|
439
|
+
const patch = store.patches.get(id) ?? store.patches.find((p) => p.number === id);
|
|
440
|
+
if (patch) return { id: patch.id, label: `${patch.number} ${patch.title}`, occurred: patch.occurred };
|
|
441
|
+
const generation = store.generations.get(id);
|
|
442
|
+
if (generation) return { id, label: generation.name, occurred: generation.ended };
|
|
443
|
+
const territory = store.territories.get(id);
|
|
444
|
+
if (territory) return { id, label: territory.name, firstSeen: territory.updatedAt };
|
|
445
|
+
const layer = store.layers.get(id);
|
|
446
|
+
if (layer) return { id, label: `${layer.path} (${layer.layer})`, firstSeen: layer.updatedAt };
|
|
447
|
+
const doc = store.roundDocs.get(id);
|
|
448
|
+
if (doc) return { id, label: doc.title, firstSeen: doc.at };
|
|
449
|
+
const link = store.links.get(id);
|
|
450
|
+
if (link) return { id, label: `${link.stepKind}: ${store.threads.get(link.workId)?.title ?? link.workId}`, firstSeen: link.at };
|
|
451
|
+
const draft = store.drafts.get(id);
|
|
452
|
+
if (draft) return { id, label: `Session draft: ${draft.session.host} session ${draft.session.sessionId.slice(0, 8)}`, occurred: draft.session.startedAt ? dated(draft.session.startedAt, 'Session', draft.id) : null, firstSeen: draft.at };
|
|
453
|
+
const number = store.numbers.get(id) ?? store.numbers.find((n) => n.number === id || n.projectNumber === id);
|
|
454
|
+
if (number) {
|
|
455
|
+
const of = number.objectId !== id ? findObject(store, number.objectId) : null;
|
|
456
|
+
return of ?? { id: number.id, label: number.number, firstSeen: number.at };
|
|
457
|
+
}
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ───────────────────────── resolving one piece of evidence ─────────────────────────
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The evidence a call names, checked and labelled by the program, with when it happened; or why it cannot be, as the
|
|
465
|
+
* caller is told it (`label` says which argument it is, e.g. `evidence[2]`).
|
|
466
|
+
*/
|
|
467
|
+
export function resolveEvidence(ctx: EvidenceContext, raw: unknown, label = 'evidence'): EvidenceRef | string {
|
|
468
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
469
|
+
return `${label} is not evidence: give { kind, id }, kind one of ${EVIDENCE_KINDS.join(' | ')}, and the line when it is one line${typeof raw === 'string' ? `. “${snippet(raw, 60)}” written out is not evidence — a date, a name or a summary is read by the program from what the evidence names` : ''}.`;
|
|
470
|
+
}
|
|
471
|
+
const input = raw as Record<string, unknown>;
|
|
472
|
+
const set = PROGRAM_READS.filter((k) => input[k] !== undefined);
|
|
473
|
+
if (set.length) {
|
|
474
|
+
return `${label} gives ${set.join(' and ')}, which the program reads from what the evidence names (the model writes no date and no label of evidence). Give only kind, id, the line when it is one line, and repo for a file or a commit in another repository.`;
|
|
475
|
+
}
|
|
476
|
+
const kind = str(input.kind);
|
|
477
|
+
if (!(EVIDENCE_KINDS as readonly string[]).includes(kind)) return `${label}.kind must be one of ${EVIDENCE_KINDS.join(', ')}${kind ? `; “${kind}” is not one` : ''}.`;
|
|
478
|
+
const id = str(input.id).trim();
|
|
479
|
+
if (!id) return `${label}.id is empty: name the ${kind === 'ledger' ? 'ledger entry' : kind === 'file' ? 'repository-relative path' : kind === 'commit' ? 'commit hash' : kind}.`;
|
|
480
|
+
if (input.line !== undefined && typeof input.line !== 'string') return `${label}.line must be the text of the line, as the original has it.`;
|
|
481
|
+
const line = str(input.line).trim() ? str(input.line) : null;
|
|
482
|
+
const repo = str(input.repo).trim();
|
|
483
|
+
switch (kind as EvidenceKind) {
|
|
484
|
+
case 'ledger': return ledgerEvidence(ctx, id, line, label);
|
|
485
|
+
case 'source': return sourceEvidence(ctx, id, line, label);
|
|
486
|
+
case 'commit': return commitEvidence(ctx, id, line, repo, label);
|
|
487
|
+
case 'file': return fileEvidence(ctx, id, line, repo, label);
|
|
488
|
+
case 'object': return objectEvidence(ctx, id, line, label);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Several pieces of evidence; the first that cannot be resolved refuses them all. */
|
|
493
|
+
export function resolveEvidenceList(ctx: EvidenceContext, raw: unknown, label = 'evidence'): EvidenceRef[] | string {
|
|
494
|
+
if (raw === undefined || raw === null) return [];
|
|
495
|
+
if (!Array.isArray(raw)) return `${label} must be a list of evidence: [{ kind, id, line? }].`;
|
|
496
|
+
const out: EvidenceRef[] = [];
|
|
497
|
+
for (const [i, item] of raw.entries()) {
|
|
498
|
+
const ref = resolveEvidence(ctx, item, `${label}[${i}]`);
|
|
499
|
+
if (typeof ref === 'string') return ref;
|
|
500
|
+
if (!out.some((r) => evidenceKey(r) === evidenceKey(ref))) out.push(ref);
|
|
501
|
+
}
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** When what the evidence names happened (§2.11), read by the program; or why the evidence cannot be resolved. */
|
|
506
|
+
export function occurredOf(ctx: EvidenceContext, raw: unknown, label = 'evidence'): Occurred | string {
|
|
507
|
+
const ref = resolveEvidence(ctx, raw, label);
|
|
508
|
+
return typeof ref === 'string' ? ref : ref.occurred ?? undatedAt(null, ref.id);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* The same piece of evidence twice is one: kind, id and line — and the repository, for one outside the first (a key of
|
|
513
|
+
* the first repository's evidence is what it always was, so ids built from it stay the same).
|
|
514
|
+
*/
|
|
515
|
+
export const evidenceKey = (r: EvidenceRef): string => `${r.kind}\x1f${r.id}\x1f${r.line ?? ''}${r.repo ? `\x1f${pathKey(r.repo)}` : ''}`;
|
|
516
|
+
|
|
517
|
+
function lineRefusal(line: string, where: string, hint: string | null): string {
|
|
518
|
+
return `The line “${snippet(line)}” is not in ${where}, so nothing was written. ${HOW_A_LINE_IS_READ}${hint ? ` (${hint}.)` : ''}`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function ledgerEvidence(ctx: EvidenceContext, id: string, line: string | null, label: string): EvidenceRef | string {
|
|
522
|
+
if (!ctx.ledger) return `${label} names the ledger entry ${id}, and the ledger is not available in this build, so it cannot be checked. Cite what the entry is about instead — the source, the commit, or the file and its line — and the program reads its time from there.`;
|
|
523
|
+
const entry = ctx.ledger.resolve(id);
|
|
524
|
+
if (!entry) return `${label}: ${id} is not an entry of the ledger.`;
|
|
525
|
+
let cited: string | null = null;
|
|
526
|
+
if (line) {
|
|
527
|
+
if (!entry.text) return `${label}: the ledger entry ${id} (${entry.label}) holds no text to find a line in; leave line out, or cite the source the line stands in.`;
|
|
528
|
+
const found = asCited(entry.text, line);
|
|
529
|
+
if (!found) return `${label}: ${lineRefusal(line, `the ledger entry ${id} (${entry.label})`, null)}`;
|
|
530
|
+
cited = found.text;
|
|
531
|
+
}
|
|
532
|
+
return { kind: 'ledger', id, label: entry.label, line: cited, occurred: entry.occurred };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function sourceEvidence(ctx: EvidenceContext, id: string, line: string | null, label: string): EvidenceRef | string {
|
|
536
|
+
const source = ctx.store.sources.get(id);
|
|
537
|
+
if (!source) return `${label}: ${id} is not a source of this project. pk_list_sources lists them; a file can also be cited by its path (kind file) and a commit by its hash (kind commit).`;
|
|
538
|
+
let cited: CitedLine | null = null;
|
|
539
|
+
if (line) {
|
|
540
|
+
const found = asCited(source.excerpt, line);
|
|
541
|
+
if (!found) {
|
|
542
|
+
const hint = checkVerbatim(ctx.store, line, [id]).missing[0]?.hint ?? null;
|
|
543
|
+
return `${label}: ${lineRefusal(line, `${id} (${anchorLabel(source.anchor)})`, hint)}`;
|
|
544
|
+
}
|
|
545
|
+
cited = found;
|
|
546
|
+
}
|
|
547
|
+
return { kind: 'source', id, label: anchorLabel(source.anchor), line: cited?.text ?? null, occurred: sourceOccurred(ctx, source, cited) };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function commitEvidence(ctx: EvidenceContext, id: string, line: string | null, repo: string, label: string): EvidenceRef | string {
|
|
551
|
+
const hash = id.toLowerCase();
|
|
552
|
+
if (!/^[0-9a-f]{4,40}$/.test(hash)) return `${label}: “${id}” is not a commit hash. Name a commit by its hash, full or short: a branch, a tag or HEAD moves, and evidence has to keep pointing at the same commit.`;
|
|
553
|
+
const roots = gitRoots(ctx.project, repo);
|
|
554
|
+
if (typeof roots === 'string') return `${label}: ${roots}`;
|
|
555
|
+
if (roots.length === 0) return `${label}: this project’s scope has no git repository, so ${id} cannot name a commit.`;
|
|
556
|
+
const hits = new Map<string, string>();
|
|
557
|
+
for (const r of roots) {
|
|
558
|
+
const full = gitResolveCommit(r.path, hash);
|
|
559
|
+
if (full && !hits.has(full)) hits.set(full, r.path);
|
|
560
|
+
}
|
|
561
|
+
if (hits.size === 0) return `${label}: ${id} names no commit in ${roots.map((r) => r.path).join(', ')}. pk_history_log lists the commits that touched a path.`;
|
|
562
|
+
if (hits.size > 1) return `${label}: ${id} is short for more than one commit (${[...hits.keys()].map((h) => h.slice(0, 12)).join(', ')}). Give more of the hash, or repo.`;
|
|
563
|
+
const [full, root] = [...hits.entries()][0]!;
|
|
564
|
+
const facts = commitFacts(root, full);
|
|
565
|
+
if (!facts) return `${label}: git could not read commit ${full.slice(0, 12)} in ${root}.`;
|
|
566
|
+
let cited: string | null = null;
|
|
567
|
+
if (line) {
|
|
568
|
+
const message = commitMessage(root, full) ?? facts.subject;
|
|
569
|
+
const found = asCited(message, line);
|
|
570
|
+
if (!found) return `${label}: ${lineRefusal(line, `the message of commit ${full.slice(0, 7)}`, null)}`;
|
|
571
|
+
cited = found.text;
|
|
572
|
+
}
|
|
573
|
+
// The label names another repository too: several views print the label alone.
|
|
574
|
+
const other = otherRepository(ctx.project, root);
|
|
575
|
+
return {
|
|
576
|
+
kind: 'commit', id: full, label: `${full.slice(0, 7)} ${facts.subject}${other ? ` (in ${other})` : ''}`, line: cited,
|
|
577
|
+
occurred: dated(facts.at, 'Commit', full) ?? undatedAt(null, full), ...(other ? { repo: other } : {}),
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function fileEvidence(ctx: EvidenceContext, id: string, line: string | null, repo: string, label: string): EvidenceRef | string {
|
|
582
|
+
const place = resolvePath(ctx.project, { path: id, repo });
|
|
583
|
+
if (typeof place === 'string') return `${label}: ${place}`;
|
|
584
|
+
const { root, rel } = place;
|
|
585
|
+
if (!rel) return `${label}: give the path of a file inside ${root.path}, relative to it.`;
|
|
586
|
+
const abs = join(root.path, ...rel.split('/'));
|
|
587
|
+
// A path is repository-relative: in another repository than the first, the evidence says which (and so does its label,
|
|
588
|
+
// since several views print the label alone).
|
|
589
|
+
const other = otherRepository(ctx.project, root.path);
|
|
590
|
+
const elsewhere = other ? ` (in ${other})` : '';
|
|
591
|
+
const where = other ? { repo: other } : {};
|
|
592
|
+
let st: Stats | null = null;
|
|
593
|
+
try { st = statSync(abs); } catch { st = null; }
|
|
594
|
+
if (st) {
|
|
595
|
+
if (st.isDirectory()) {
|
|
596
|
+
if (line) return `${label}: ${rel} is a directory; a line is cited from a file.`;
|
|
597
|
+
const made = root.git ? lastTouching(root.path, rel, 'HEAD') : null;
|
|
598
|
+
return { kind: 'file', id: rel, label: `${rel}/${elsewhere}`, line: null, occurred: (made ? dated(made.at, 'Commit', made.hash) : null) ?? undatedAt(null, rel), ...where };
|
|
599
|
+
}
|
|
600
|
+
let cited: string | null = null;
|
|
601
|
+
if (line) {
|
|
602
|
+
if (st.size > MAX_REVISION_BYTES) return `${label}: ${rel} is ${st.size} bytes, more than the ${MAX_REVISION_BYTES} a line is looked for in; cite the source of the section instead.`;
|
|
603
|
+
const text = readFileSync(abs, 'utf8');
|
|
604
|
+
if (text.slice(0, 8000).includes('\0')) return `${label}: ${rel} is not a text file, so no line can be cited from it.`;
|
|
605
|
+
const found = asCited(text, line);
|
|
606
|
+
if (!found) return `${label}: ${lineRefusal(line, `${rel} as it is now${elsewhere}`, 'a line that only an earlier version has is cited from that version: read it with pk_history_read and cite the source it gives')}`;
|
|
607
|
+
cited = found.text;
|
|
608
|
+
}
|
|
609
|
+
return { kind: 'file', id: rel, label: `${rel}${elsewhere}`, line: cited, occurred: currentFileOccurred(root, rel, abs, cited), ...where };
|
|
610
|
+
}
|
|
611
|
+
if (!root.git) return `${label}: ${rel} does not exist in ${root.path}.`;
|
|
612
|
+
const last = lastVersionInHistory(root.path, rel);
|
|
613
|
+
if (!last) return `${label}: ${rel} is not in ${root.path} now, and no commit in its history ever touched that path.`;
|
|
614
|
+
let cited: string | null = null;
|
|
615
|
+
let made = last.made;
|
|
616
|
+
if (line) {
|
|
617
|
+
const entry = gitTreeEntry(root.path, last.treeish, rel);
|
|
618
|
+
if (!entry || entry.type !== 'blob') return `${label}: ${rel} was a directory; a line is cited from a file.`;
|
|
619
|
+
const r = gitRead(root.path, ['show', `${last.treeish}:${rel}`], { timeoutMs: 20_000 });
|
|
620
|
+
if (!r.ok || r.out.slice(0, 8000).includes('\0')) return `${label}: the last version of ${rel} could not be read as text.`;
|
|
621
|
+
const found = asCited(r.out, line);
|
|
622
|
+
if (!found) return `${label}: ${lineRefusal(line, `${rel} as its last version had it (${last.treeish.slice(0, 7)})`, 'an earlier version is read with pk_history_read and cited by the source it gives')}`;
|
|
623
|
+
cited = found.text;
|
|
624
|
+
made = firstWithText(root.path, rel, searchable(cited), last.treeish) ?? made;
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
kind: 'file', id: rel, label: `${rel} @ ${last.treeish.slice(0, 7)} (version history)${elsewhere}`, line: cited,
|
|
628
|
+
occurred: dated(made.at, 'Commit', made.hash) ?? undatedAt(null, rel), ...where,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* When the file as it is now happened: the commit a cited line first appeared in, or — the file being what HEAD has — the
|
|
634
|
+
* last commit that touched it; a file or line not committed has only its file time.
|
|
635
|
+
*/
|
|
636
|
+
function currentFileOccurred(root: ProjectRoot, rel: string, abs: string, line: string | null): Occurred {
|
|
637
|
+
if (root.git) {
|
|
638
|
+
const found = line ? firstWithText(root.path, rel, searchable(line), 'HEAD') : null;
|
|
639
|
+
if (found) return dated(found.at, 'Commit', found.hash) ?? undatedAt(null, rel);
|
|
640
|
+
if (!line) {
|
|
641
|
+
const head = gitTreeEntry(root.path, 'HEAD', rel);
|
|
642
|
+
const working = head ? gitRead(root.path, ['hash-object', `--path=${rel}`, '--', abs], { timeoutMs: 8000 }).out.trim() : '';
|
|
643
|
+
if (head && working === head.id) {
|
|
644
|
+
const made = lastTouching(root.path, rel, 'HEAD');
|
|
645
|
+
const time = made ? dated(made.at, 'Commit', made.hash) : null;
|
|
646
|
+
if (time) return time;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return fileTime(abs, rel) ?? undatedAt(null, rel);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function objectEvidence(ctx: EvidenceContext, id: string, line: string | null, label: string): EvidenceRef | string {
|
|
654
|
+
if (ctx.store.sources.has(id)) return `${label}: ${id} is a source: cite it with kind "source" — its label and time are read from the source.`;
|
|
655
|
+
const found = findObject(ctx.store, id);
|
|
656
|
+
if (!found) return `${label}: ${id} is not an object of this project’s assets (a graph node, reference item, work item, note, mark, rule, breakpoint, send-back, semantic patch, code territory, generation …).`;
|
|
657
|
+
if (line) return `${label}: a line quotes original material, and ${found.id} (${snippet(found.label, 60)}) is the Keeper’s own record. Cite the source, file, commit or ledger entry the line stands in.`;
|
|
658
|
+
const occurred = found.occurred
|
|
659
|
+
?? earliestOccurred((found.sourceIds ?? []).flatMap((s) => { const src = ctx.store.sources.get(s); return src ? [sourceOccurred(ctx, src)] : []; }))
|
|
660
|
+
?? undatedAt(found.firstSeen ?? null, found.id);
|
|
661
|
+
return { kind: 'object', id: found.id, label: found.label, line: null, occurred };
|
|
662
|
+
}
|