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,610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pk import`: the ProjectKeeper table taken into a home (template/FILLING-IN.md, "How it is drawn"). The table is
|
|
3
|
+
* checked first, as `pk check` checks it, and a table with any problem is refused before anything is opened or written.
|
|
4
|
+
* A table that holds replaces the picture the Keeper wrote for the project — the product reference, the work items,
|
|
5
|
+
* the relations, the generations, the process links, the code territories and the notes — with the table's content;
|
|
6
|
+
* what intake read (sources) and the ledger stay. The graph is then derived from it as after any round (graph.ts), so
|
|
7
|
+
* the Graph, the List and the Code view draw the table (D107: the filler judges, the program draws).
|
|
8
|
+
*
|
|
9
|
+
* `mapTable` is the mapping alone: table in, store records out, given how each cited source resolves. `resolveSources`
|
|
10
|
+
* reads the cited lines, commits and session messages at the table's `asOf`; `importTable` does the rest.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { basename, join } from 'node:path';
|
|
14
|
+
import type { CodeTerritory, EvidenceRef, Generation, Occurred, ProcessLink, StepKind } from '../model/k-types.ts';
|
|
15
|
+
import type {
|
|
16
|
+
Attribution, FactRecord, GraphRelation, JobInputs, Note, NoteFact, Project, ReferenceItem, ServesClaim, Source, Statement, WorkThread,
|
|
17
|
+
} from '../model/types.ts';
|
|
18
|
+
import type { Basis, NoteAsk, Progress, SessionHost, Validity } from '../model/vocab.ts';
|
|
19
|
+
import { fingerprint, stableId } from '../model/ids.ts';
|
|
20
|
+
import { extractIds, makeFileSource, makeSessionSource, redactCredentials } from '../sources/anchor.ts';
|
|
21
|
+
import { git } from '../util/git.ts';
|
|
22
|
+
import { canonicalPath, normalizePath, pathKey, samePath } from '../util/paths.ts';
|
|
23
|
+
import type { ProjectStore } from '../store/project-store.ts';
|
|
24
|
+
import type { App } from '../server/app.ts';
|
|
25
|
+
import { withTableProject } from './project-home.ts';
|
|
26
|
+
import { checkTableFile, fileAt, owningRepo, resolveRepos, walkSources, type Repo, type SessionMessage, type TableProblem, type TableSource } from './check.ts';
|
|
27
|
+
|
|
28
|
+
// ───────────────────────── the table, as the schema shapes it ─────────────────────────
|
|
29
|
+
|
|
30
|
+
type Status = 'Current' | 'Proposed' | 'Deferred' | 'Replaced' | 'Abandoned';
|
|
31
|
+
interface Entry { readonly id: string; readonly name: string; readonly summary: string; readonly status: Status; readonly replacedBy?: string; readonly why?: string; readonly sources: readonly TableSource[] }
|
|
32
|
+
interface OwnerWant {
|
|
33
|
+
readonly id: string; readonly want: string; readonly kind: 'Product' | 'Way of working';
|
|
34
|
+
readonly status: 'Current' | 'Replaced' | 'Open' | 'Not raised again'; readonly replacedBy?: string;
|
|
35
|
+
readonly said: readonly TableSource[]; readonly approvedProposal?: { readonly part: string; readonly source: TableSource };
|
|
36
|
+
readonly changed?: readonly { readonly before: string; readonly after: string; readonly source: TableSource }[];
|
|
37
|
+
readonly reflectedIn?: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
type Place = { readonly area: string } | { readonly areas: readonly string[] } | { readonly plan: string } | { readonly wholeProduct: string };
|
|
40
|
+
interface Intent extends Entry { readonly boundary?: boolean; readonly place: Place; readonly inferred?: boolean; readonly partlyReplaced?: readonly { readonly by: string; readonly part: string }[] }
|
|
41
|
+
interface Area extends Entry { readonly goals?: readonly string[]; readonly foundation?: boolean }
|
|
42
|
+
interface Plan extends Entry { readonly ended?: string }
|
|
43
|
+
type Became = { readonly carriedOnBy: string } | { readonly movedTo: string } | { readonly deferred: string } | { readonly dropped: string } | { readonly finished: string };
|
|
44
|
+
interface Step { readonly kind: StepKind; readonly at?: string; readonly verdict?: string; readonly source: TableSource }
|
|
45
|
+
interface Work extends Entry {
|
|
46
|
+
readonly plan: string; readonly area?: string; readonly alsoAreas?: readonly string[]; readonly wholePlan?: string;
|
|
47
|
+
readonly implements?: readonly string[]; readonly dependsOn?: readonly string[]; readonly progress: Progress;
|
|
48
|
+
readonly accepted?: TableSource; readonly open?: string; readonly became?: Became; readonly steps?: readonly Step[];
|
|
49
|
+
}
|
|
50
|
+
interface Territory {
|
|
51
|
+
readonly id: string; readonly name: string; readonly summary: string; readonly area: string; readonly alsoAreas?: readonly string[];
|
|
52
|
+
readonly paths: readonly string[];
|
|
53
|
+
readonly anomalies?: readonly { readonly kind: CodeTerritory['anomalies'][number]['kind']; readonly text: string; readonly sources?: readonly TableSource[] }[];
|
|
54
|
+
}
|
|
55
|
+
interface TableNote {
|
|
56
|
+
readonly id: string; readonly title: string; readonly kind: 'Anomaly' | 'Owner and project differ' | 'Question'; readonly ask: NoteAsk;
|
|
57
|
+
readonly by: "Owner's words" | 'Project' | 'Main agent'; readonly on: readonly string[]; readonly text: string;
|
|
58
|
+
readonly options?: readonly { readonly choice: string; readonly consequence: string }[]; readonly sources: readonly TableSource[];
|
|
59
|
+
}
|
|
60
|
+
export interface Table {
|
|
61
|
+
readonly format: 'projectkeeper.table/1';
|
|
62
|
+
readonly project: { readonly name: string; readonly asOf: { readonly date: string; readonly repos: readonly { readonly path: string; readonly commit: string }[] }; readonly filledBy: { readonly agent: string; readonly at: string; readonly read?: string } };
|
|
63
|
+
readonly ownerWants: readonly OwnerWant[];
|
|
64
|
+
readonly product: { readonly id: string; readonly name: string; readonly summary: string; readonly forWhom: string; readonly sources: readonly TableSource[] };
|
|
65
|
+
readonly goals: readonly Entry[];
|
|
66
|
+
readonly areas: readonly Area[];
|
|
67
|
+
readonly requirements: readonly Intent[];
|
|
68
|
+
readonly designs: readonly Intent[];
|
|
69
|
+
readonly decisions: readonly Intent[];
|
|
70
|
+
readonly plans: readonly Plan[];
|
|
71
|
+
readonly work: readonly Work[];
|
|
72
|
+
readonly code: readonly Territory[];
|
|
73
|
+
readonly notes: readonly TableNote[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ───────────────────────── sources ─────────────────────────
|
|
77
|
+
|
|
78
|
+
const slash = (p: string): string => p.split('\\').join('/').replace(/^\.\/?/, '').replace(/\/$/, '');
|
|
79
|
+
const squash = (text: string): string => text.replace(/\s+/g, ' ').trim();
|
|
80
|
+
|
|
81
|
+
/** One key per cited place, whatever quote it carries: the same lines cited twice are one source. */
|
|
82
|
+
export function sourceKey(s: TableSource): string {
|
|
83
|
+
if (s.file !== undefined) return `file:${slash(s.file)}#${s.lines ?? ''}`;
|
|
84
|
+
if (s.commit !== undefined) return `commit:${slash(s.repo ?? '.')}@${s.commit}`;
|
|
85
|
+
return `session:${s.session ?? ''}#${s.message ?? ''}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The lines a file source cites, 1-based and inclusive: its `lines`, else the whole file (a final newline is no line). */
|
|
89
|
+
function citedRange(text: string, lines: string | undefined): { from: number; to: number; all: string[] } {
|
|
90
|
+
const all = text.split(/\r?\n/);
|
|
91
|
+
if (all.length > 1 && all[all.length - 1] === '') all.pop();
|
|
92
|
+
if (lines === undefined) return { from: 1, to: Math.max(1, all.length), all };
|
|
93
|
+
const [from, to = from] = lines.split('-').map(Number) as [number, number?];
|
|
94
|
+
return { from, to, all };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The session material's name for a session, `host:id`, as the store's anchor has it. */
|
|
98
|
+
function sessionParts(session: string): { host: SessionHost; id: string } {
|
|
99
|
+
const at = session.indexOf(':');
|
|
100
|
+
// The material names every session with its host (claude:…, codex:…); a host the store has no word for is kept as given.
|
|
101
|
+
return at > 0 ? { host: session.slice(0, at) as SessionHost, id: session.slice(at + 1) } : { host: 'claude', id: session };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface ResolvedSources {
|
|
105
|
+
/** The store source each cited place stands for, by `sourceKey`. */
|
|
106
|
+
readonly byKey: ReadonlyMap<string, Source>;
|
|
107
|
+
/** The sources the import writes: the ones intake had not read as they are cited. */
|
|
108
|
+
readonly fresh: readonly Source[];
|
|
109
|
+
/** How many cited places were found already read by intake, unchanged. */
|
|
110
|
+
readonly reused: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Every source the table cites, as a store source: a file's cited lines as they stood at the `asOf` commit (an intake
|
|
115
|
+
* source of exactly those lines with the same text is used as it is), a commit by its full hash (intake's commit source
|
|
116
|
+
* when it read it), a session message with its text from the session material. Reads git; writes nothing.
|
|
117
|
+
*/
|
|
118
|
+
export function resolveSources(table: Table, options: {
|
|
119
|
+
readonly projectId: string; readonly projectDir: string; readonly repos: readonly Repo[]; readonly existing: readonly Source[];
|
|
120
|
+
readonly sessions: readonly SessionMessage[] | null; readonly scopeItemId: string; readonly at: string;
|
|
121
|
+
}): ResolvedSources {
|
|
122
|
+
const { projectId, projectDir, repos, existing } = options;
|
|
123
|
+
const byKey = new Map<string, Source>();
|
|
124
|
+
const fresh: Source[] = [];
|
|
125
|
+
let reused = 0;
|
|
126
|
+
const cache = new Map<string, string | null>();
|
|
127
|
+
const sessionText = new Map((options.sessions ?? []).map((m) => [`${m.session}#${m.message}`, m.text]));
|
|
128
|
+
const keep = (key: string, source: Source, isNew: boolean) => { byKey.set(key, source); if (isNew) fresh.push(source); else reused++; };
|
|
129
|
+
walkSources(table, '', (_at, s) => {
|
|
130
|
+
const key = sourceKey(s);
|
|
131
|
+
if (byKey.has(key)) return;
|
|
132
|
+
if (s.file !== undefined) {
|
|
133
|
+
const text = fileAt(s.file, repos, projectDir, cache);
|
|
134
|
+
if (text === null) throw new Error(`${s.file} is not in the project at its asOf commit`); // the check said it is
|
|
135
|
+
const path = normalizePath(join(projectDir, slash(s.file)));
|
|
136
|
+
const { from, to, all } = citedRange(text, s.lines);
|
|
137
|
+
const cited = all.slice(from - 1, to).join('\n');
|
|
138
|
+
const sameFile = existing.filter((x) => x.anchor.kind === 'file' && samePath(x.anchor.path, path));
|
|
139
|
+
const same = sameFile.find((x) => x.anchor.kind === 'file' && x.anchor.lineStart === from && x.anchor.lineEnd === to && !x.availability && squash(x.excerpt) === squash(cited));
|
|
140
|
+
if (same) { keep(key, same, false); return; }
|
|
141
|
+
// Shown under the section intake read the first cited line in, when it read one; judged as intake's source of it was.
|
|
142
|
+
const section = sameFile.find((x) => x.anchor.kind === 'file' && x.anchor.lineStart <= from && from <= x.anchor.lineEnd);
|
|
143
|
+
const headingPath = section?.anchor.kind === 'file' ? section.anchor.headingPath : [];
|
|
144
|
+
const owner = owningRepo(s.file, repos);
|
|
145
|
+
const made = makeFileSource({
|
|
146
|
+
projectId, path, headingPath, lineStart: from, lineEnd: to, excerpt: cited, fileText: text,
|
|
147
|
+
scopeItemId: section?.scopeItemId ?? sameFile[0]?.scopeItemId ?? options.scopeItemId, ids: extractIds(cited),
|
|
148
|
+
commit: owner?.repo.commit ?? null, readAt: options.at,
|
|
149
|
+
title: `${headingPath.length ? headingPath[headingPath.length - 1] : basename(path)} · L${from}–L${to}`,
|
|
150
|
+
});
|
|
151
|
+
// Its own id: intake's id for a section is the path and the heading, and these are the table's lines of it.
|
|
152
|
+
keep(key, { ...made, id: stableId('src', 'table', pathKey(path), from, to), usedAs: section?.usedAs ?? null, usedAsBy: section?.usedAsBy ?? null }, true);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (s.commit !== undefined) {
|
|
156
|
+
const repo = repos.find((r) => r.path === slash(s.repo ?? '.'));
|
|
157
|
+
const dir = canonicalPath(repo?.dir ?? join(projectDir, s.repo ?? '.'));
|
|
158
|
+
const full = git(dir, ['rev-parse', '--verify', `${s.commit}^{commit}`]).out.trim() || s.commit;
|
|
159
|
+
const id = stableId('src', 'commit', pathKey(dir), full); // anchor.ts commitSourceId: intake's own id for it
|
|
160
|
+
const read = existing.find((x) => x.id === id);
|
|
161
|
+
if (read) { keep(key, read, false); return; }
|
|
162
|
+
const [subject = '', author = '', when = ''] = git(dir, ['show', '-s', '--format=%s%n%an%n%aI', full]).out.split('\n');
|
|
163
|
+
const files = git(dir, ['diff-tree', '--no-commit-id', '--name-only', '-r', '--root', full]).out.split('\n').filter(Boolean);
|
|
164
|
+
const excerpt = `${subject}\n\nauthor: ${author}\nat: ${when}\nfiles:\n${files.map((f) => ` ${f}`).join('\n')}`;
|
|
165
|
+
const red = redactCredentials(excerpt);
|
|
166
|
+
keep(key, {
|
|
167
|
+
id, projectId, title: `${full.slice(0, 8)} ${subject.slice(0, 80)}`, anchor: { kind: 'commit', repo: dir, commit: full, ...(when ? { at: when } : {}) },
|
|
168
|
+
ids: extractIds(subject), version: { fingerprint: fingerprint(excerpt), readAt: options.at, commit: full }, excerpt: red.text,
|
|
169
|
+
usedAs: null, usedAsBy: null, availability: null, movedTo: null, scopeItemId: options.scopeItemId, hasCredential: red.found, bytes: Buffer.byteLength(excerpt),
|
|
170
|
+
}, true);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const session = s.session ?? '';
|
|
174
|
+
const message = s.message ?? 0;
|
|
175
|
+
const { host, id } = sessionParts(session);
|
|
176
|
+
const text = sessionText.get(`${session}#${message}`) ?? '';
|
|
177
|
+
const made = makeSessionSource({
|
|
178
|
+
projectId, host, sessionId: id, file: '', cwd: projectDir, messageStart: message, messageEnd: message, at: null,
|
|
179
|
+
excerpt: text, title: `${host} session ${id.slice(0, 8)} · message ${message}`, scopeItemId: options.scopeItemId, readAt: options.at,
|
|
180
|
+
});
|
|
181
|
+
// The material numbers a session's messages its own way, not by their place in the native log intake segments by:
|
|
182
|
+
// the table's message gets an id of its own so it never stands in for an intake segment.
|
|
183
|
+
keep(key, { ...made, id: stableId('src', 'table', 'session', session, message), usedAs: 'Session', usedAsBy: null }, true);
|
|
184
|
+
});
|
|
185
|
+
return { byKey, fresh, reused };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ───────────────────────── the mapping ─────────────────────────
|
|
189
|
+
|
|
190
|
+
export interface MapContext {
|
|
191
|
+
readonly projectId: string;
|
|
192
|
+
readonly projectDir: string;
|
|
193
|
+
/** The table's repositories (check.ts `resolveRepos`), for the code territories and the steps' evidence. */
|
|
194
|
+
readonly repos: readonly Repo[];
|
|
195
|
+
/** The store source a cited place resolves to (`resolveSources`). */
|
|
196
|
+
readonly source: (s: TableSource) => Source;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface MappedTable {
|
|
200
|
+
readonly reference: readonly ReferenceItem[];
|
|
201
|
+
readonly threads: readonly WorkThread[];
|
|
202
|
+
readonly facts: readonly FactRecord[];
|
|
203
|
+
readonly relations: readonly GraphRelation[];
|
|
204
|
+
readonly generations: readonly Generation[];
|
|
205
|
+
readonly links: readonly ProcessLink[];
|
|
206
|
+
readonly territories: readonly CodeTerritory[];
|
|
207
|
+
readonly notes: readonly Note[];
|
|
208
|
+
/** Table id → store id, for every entry of the table. */
|
|
209
|
+
readonly ids: ReadonlyMap<string, string>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const JOB = 'table-import';
|
|
213
|
+
const language = (text: string): string => (/[\u3400-\u9fff]/.test(text) ? 'zh' : 'en');
|
|
214
|
+
/** `A1 · Capture` as the Keeper names items (the fixture's `'A1 · Capture 快速收藏'`); a name that starts with its id keeps it once. */
|
|
215
|
+
const named = (id: string, name: string): string => (name.trim().startsWith(id) ? name.trim() : `${id} · ${name.trim()}`);
|
|
216
|
+
const withWhy = (text: string, why: string | undefined): string => (why ? `${text}\n\n${why}` : text);
|
|
217
|
+
const uniq = <T,>(list: readonly T[]): T[] => [...new Set(list)];
|
|
218
|
+
|
|
219
|
+
/** The table's store ids: stable per project and table id, prefixed by what the store keeps them as. */
|
|
220
|
+
export function storeIdsOf(table: Table, projectId: string): Map<string, string> {
|
|
221
|
+
const ids = new Map<string, string>();
|
|
222
|
+
const ref = (id: string) => ids.set(id, stableId('ref', projectId, 'table', id));
|
|
223
|
+
ref(table.product.id);
|
|
224
|
+
for (const list of [table.ownerWants, table.goals, table.areas, table.requirements, table.designs, table.decisions, table.plans]) for (const e of list) ref(e.id);
|
|
225
|
+
for (const w of table.work) ids.set(w.id, stableId('thread', projectId, 'table', w.id));
|
|
226
|
+
for (const t of table.code) ids.set(t.id, stableId('terr', projectId, 'table', t.id));
|
|
227
|
+
for (const n of table.notes) ids.set(n.id, stableId('note', projectId, 'table', n.id));
|
|
228
|
+
return ids;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The table mapped onto the store's records. Pure: every source is resolved by `ctx.source`. */
|
|
232
|
+
export function mapTable(table: Table, ctx: MapContext): MappedTable {
|
|
233
|
+
const { projectId } = ctx;
|
|
234
|
+
const at = table.project.filledBy.at;
|
|
235
|
+
const ids = storeIdsOf(table, projectId);
|
|
236
|
+
const id = (tableId: string): string => ids.get(tableId)!;
|
|
237
|
+
const sids = (list: readonly (TableSource | undefined)[]): string[] => uniq(list.filter((s): s is TableSource => s !== undefined).map((s) => ctx.source(s).id));
|
|
238
|
+
const inputs = (sourceIds: readonly string[]): JobInputs => ({ jobId: JOB, sourceIds, factRecordIds: [], threadIds: [], areaIds: [], referenceIds: [], noteIds: [], location: ctx.projectDir });
|
|
239
|
+
// Whose the content is. The owner's words are the owner's own statements (author owner, identity Decision: tools.ts
|
|
240
|
+
// pk_write_reference); the rest is the project's material as the filler read it — who wrote that material the table
|
|
241
|
+
// does not say — and what no document states is the filler's own reading.
|
|
242
|
+
const byOwner: Attribution = { author: { kind: 'owner', name: null, window: null, host: null, model: null }, holder: null, identity: 'Decision' };
|
|
243
|
+
const byMaterial: Attribution = { author: { kind: 'unknown', name: null, window: null, host: null, model: null }, holder: null, identity: 'Artifact' };
|
|
244
|
+
const byFiller: Attribution = { author: { kind: 'agent', name: table.project.filledBy.agent, window: null, host: null, model: null }, holder: null, identity: 'Interpretation' };
|
|
245
|
+
|
|
246
|
+
// ── what carries each owner want out: the reference items refine it, the work items serve it ──
|
|
247
|
+
const reflectingRefs = new Map<string, string[]>();
|
|
248
|
+
const reflectingWork = new Map<string, ServesClaim[]>();
|
|
249
|
+
const workIds = new Set(table.work.map((w) => w.id));
|
|
250
|
+
const territoryIds = new Set(table.code.map((t) => t.id));
|
|
251
|
+
for (const w of table.ownerWants) for (const r of w.reflectedIn ?? []) {
|
|
252
|
+
if (territoryIds.has(r)) continue; // a territory is no node of the graph: nothing to draw the link on
|
|
253
|
+
if (workIds.has(r)) reflectingWork.set(r, [...(reflectingWork.get(r) ?? []), { referenceId: id(w.id), claim: `carries out the owner's want ${w.id}`, basis: 'Explicit' }]);
|
|
254
|
+
else reflectingRefs.set(r, [...(reflectingRefs.get(r) ?? []), id(w.id)]);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const reference: ReferenceItem[] = [];
|
|
258
|
+
const item = (tableId: string, category: ReferenceItem['category'], name: string, text: string, sourceIds: readonly string[], over: Partial<ReferenceItem> = {}) => {
|
|
259
|
+
reference.push({
|
|
260
|
+
id: id(tableId), projectId, category, name, ids: [tableId], text, quote: null, basis: 'Explicit', validity: 'Current', progress: null,
|
|
261
|
+
attribution: byMaterial, sourceIds, replacedBy: null, inputs: null, asOf: at, updatedAt: at, ...over,
|
|
262
|
+
refines: uniq([...(over.refines ?? []), ...(reflectingRefs.get(tableId) ?? [])]),
|
|
263
|
+
});
|
|
264
|
+
};
|
|
265
|
+
const productId = id(table.product.id);
|
|
266
|
+
|
|
267
|
+
// ── the owner's words (D63): the want in a sentence, the owner's own words as the quote, where they were said ──
|
|
268
|
+
const WANT_VALIDITY: Record<OwnerWant['status'], Validity> = { Current: 'Current', Replaced: 'Replaced', Open: 'Proposed', 'Not raised again': 'Unjudged' };
|
|
269
|
+
for (const w of table.ownerWants) {
|
|
270
|
+
const turns = (w.changed ?? []).map((c) => `Earlier: ${c.before} → ${c.after}`);
|
|
271
|
+
item(w.id, "Owner's words", w.want, [w.want, ...turns].join('\n\n'), sids([...w.said, w.approvedProposal?.source, ...(w.changed ?? []).map((c) => c.source)]), {
|
|
272
|
+
quote: w.said.find((s) => s.quote)?.quote ?? w.want, attribution: byOwner, validity: WANT_VALIDITY[w.status],
|
|
273
|
+
replacedBy: w.replacedBy ? id(w.replacedBy) : null, ...(w.approvedProposal ? { answers: w.approvedProposal.part } : {}),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
const p = table.product;
|
|
277
|
+
item(p.id, 'Product', named(p.id, p.name), `${p.summary}\n\nFor: ${p.forWhom}`, sids(p.sources));
|
|
278
|
+
const entry = (e: Entry) => ({ validity: e.status as Validity, replacedBy: e.replacedBy ? id(e.replacedBy) : null });
|
|
279
|
+
for (const g of table.goals) item(g.id, 'Goal', named(g.id, g.name), withWhy(g.summary, g.why), sids(g.sources), { ...entry(g), refines: [productId] });
|
|
280
|
+
for (const a of table.areas) {
|
|
281
|
+
item(a.id, 'Area', named(a.id, a.name), withWhy(a.summary, a.why), sids(a.sources), {
|
|
282
|
+
...entry(a), refines: a.goals?.length ? a.goals.map(id) : [productId], ...(a.foundation ? { foundation: true } : {}),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
const intents: readonly (readonly [readonly Intent[], 'Requirement' | 'Design' | 'Decision'])[] = [[table.requirements, 'Requirement'], [table.designs, 'Design'], [table.decisions, 'Decision']];
|
|
286
|
+
for (const [list, category] of intents) {
|
|
287
|
+
for (const e of list) {
|
|
288
|
+
const place = e.place as Partial<{ area: string; areas: string[]; plan: string; wholeProduct: string }>;
|
|
289
|
+
// Each place is what the story map reads (ui/placement.js): an area's column, several areas' ring, the plan whose
|
|
290
|
+
// execution it records, or the whole product with the written reason (CM).
|
|
291
|
+
const refines = place.area ? [id(place.area)] : place.areas ? place.areas.map(id) : place.plan ? [id(place.plan)] : [productId];
|
|
292
|
+
// A part a later entry overturned stays on the item for the program (supersededParts, §5.5) and is said in its text,
|
|
293
|
+
// which is where the popover shows it; the item itself stays in force.
|
|
294
|
+
const parts = e.partlyReplaced ?? [];
|
|
295
|
+
const text = [withWhy(e.summary, e.why), ...parts.map((p) => `No longer holds: ${p.part} (${p.by})`)].join('\n\n');
|
|
296
|
+
item(e.id, category === 'Decision' && e.boundary ? 'Boundary' : category, named(e.id, e.name), text, sids(e.sources), {
|
|
297
|
+
...entry(e), refines, basis: e.inferred ? 'Inferred' : 'Explicit', attribution: e.inferred ? byFiller : byMaterial,
|
|
298
|
+
...(parts.length ? { supersededParts: parts.map((p) => ({ part: p.part, byId: id(p.by), basis: 'Explicit' as const, at: table.project.filledBy.at })) } : {}),
|
|
299
|
+
...(place.wholeProduct ? { wholeProductWhy: place.wholeProduct } : {}),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Plans: a plan's progress is its work's, so none is written here.
|
|
304
|
+
for (const pl of table.plans) item(pl.id, 'Plan', named(pl.id, pl.name), withWhy(pl.summary, pl.why), sids(pl.sources), { ...entry(pl), refines: [productId] });
|
|
305
|
+
|
|
306
|
+
// ── work items: their place by `serves` (area first, then the other areas, then the plan), their sources in a fact record ──
|
|
307
|
+
const threads: WorkThread[] = [];
|
|
308
|
+
const facts: FactRecord[] = [];
|
|
309
|
+
const relations: GraphRelation[] = [];
|
|
310
|
+
const links: ProcessLink[] = [];
|
|
311
|
+
const intentKind = new Map<string, string>();
|
|
312
|
+
for (const [list, category] of intents) for (const e of list) intentKind.set(e.id, category === 'Decision' ? 'Decision' : category);
|
|
313
|
+
for (const w of table.work) {
|
|
314
|
+
const tid = id(w.id);
|
|
315
|
+
const label = named(w.id, w.name);
|
|
316
|
+
const sourceIds = sids([...w.sources, w.accepted]);
|
|
317
|
+
const factId = stableId('fact', projectId, 'table', w.id);
|
|
318
|
+
const statements: Statement[] = [{ id: `${factId}_summary`, type: 'Observed', text: w.summary, sourceIds: sids(w.sources) }];
|
|
319
|
+
if (w.open) statements.push({ id: `${factId}_open`, type: 'Open', text: w.open, sourceIds: sids(w.sources) });
|
|
320
|
+
if (w.accepted) statements.push({ id: `${factId}_accepted`, type: 'Observed', text: 'The owner accepted it.', sourceIds: sids([w.accepted]) });
|
|
321
|
+
facts.push({
|
|
322
|
+
id: factId, projectId, title: `${label} · from the table`, aboutSourceIds: sourceIds, statements, decisions: [], changes: [],
|
|
323
|
+
openQuestions: w.open ? [w.open] : [], executionFacts: [], language: language(`${w.name} ${w.summary}`), inputs: inputs(sourceIds), asOf: at, updatedAt: at, pendingSourceIds: [],
|
|
324
|
+
});
|
|
325
|
+
const serves: ServesClaim[] = [
|
|
326
|
+
...(w.area ? [{ referenceId: id(w.area), claim: `the table puts ${w.id} in ${w.area}`, basis: 'Explicit' as Basis }] : []),
|
|
327
|
+
...(w.alsoAreas ?? []).map((a) => ({ referenceId: id(a), claim: `the table also puts ${w.id} in ${a}`, basis: 'Explicit' as Basis })),
|
|
328
|
+
{ referenceId: id(w.plan), claim: `the table plans ${w.id} in ${w.plan}`, basis: 'Explicit' },
|
|
329
|
+
];
|
|
330
|
+
let validity: Validity = w.status;
|
|
331
|
+
let replacedBy: string | null = w.replacedBy ? id(w.replacedBy) : null;
|
|
332
|
+
let progress: Progress = w.progress;
|
|
333
|
+
const changed: string[] = w.why ? [w.why] : [];
|
|
334
|
+
// What became of an earlier generation's work (Spec §2.12, D100), the way the band reads where it went
|
|
335
|
+
// (ui/placement.js): carried on by a current item it was replaced by; moved into a current plan it now serves too;
|
|
336
|
+
// deferred; dropped (abandoned); finished under the earlier plan.
|
|
337
|
+
const b = w.became as Partial<{ carriedOnBy: string; movedTo: string; deferred: string; dropped: string; finished: string }> | undefined;
|
|
338
|
+
if (b?.carriedOnBy) { validity = 'Replaced'; replacedBy = id(b.carriedOnBy); changed.push(`Carried on by ${b.carriedOnBy}.`); }
|
|
339
|
+
if (b?.movedTo) { serves.push({ referenceId: id(b.movedTo), claim: `moved from ${w.plan} into ${b.movedTo} unchanged`, basis: 'Explicit' }); changed.push(`Moved into ${b.movedTo}.`); }
|
|
340
|
+
if (b?.deferred) { validity = 'Deferred'; changed.push(`Deferred: ${b.deferred}`); }
|
|
341
|
+
if (b?.dropped) { validity = 'Abandoned'; changed.push(`Dropped: ${b.dropped}`); }
|
|
342
|
+
if (b?.finished) { progress = 'Done'; changed.push(`Finished: ${b.finished}`); }
|
|
343
|
+
threads.push({
|
|
344
|
+
id: tid, projectId, title: w.name, ids: [w.id], doing: w.summary, changed: changed.join(' '), results: '', unresolved: w.open ?? '',
|
|
345
|
+
...(w.accepted ? { acceptance: 'Accepted' as const } : {}),
|
|
346
|
+
executionFacts: [], qcFacts: [], factRecordIds: [factId], serves: [...serves, ...(reflectingWork.get(w.id) ?? [])],
|
|
347
|
+
dependsOn: (w.dependsOn ?? []).map((d) => ({ threadId: id(d), claim: `the table says ${w.id} depends on ${d}`, basis: 'Explicit' as Basis })),
|
|
348
|
+
progress, validity, replacedBy, attribution: byMaterial, inputs: inputs(sourceIds), asOf: at, updatedAt: at, pendingSourceIds: [],
|
|
349
|
+
...(w.wholePlan ? { wholePlanWhy: w.wholePlan } : {}),
|
|
350
|
+
});
|
|
351
|
+
// What it carries out: a requirement or design it implements (a contract, ui/placement.js), a decision it carries out.
|
|
352
|
+
for (const target of w.implements ?? []) {
|
|
353
|
+
const type: GraphRelation['type'] = intentKind.get(target) === 'Decision' ? 'carries out' : 'implements';
|
|
354
|
+
relations.push({
|
|
355
|
+
id: stableId('rel', type, tid, id(target)), projectId, type, from: tid, to: id(target), claim: `${w.id} ${type} ${target} (the table)`, basis: 'Explicit',
|
|
356
|
+
evidence: { sourceIds: sids(w.sources), factRecordIds: [factId], factsSoFar: '' }, assessment: 'Not assessed', assessedAt: null, assessedInJobId: null, updatedAt: at,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
// A step the program cannot find by the work's number, with its evidence (process/work.ts takes a confirmed link as a step).
|
|
360
|
+
(w.steps ?? []).forEach((s, i) => {
|
|
361
|
+
const ev = evidenceOf(ctx, s.source, s.at ? { at: s.at, basis: 'Written in text', anchor: sourceKey(s.source) } : null, s.verdict ?? s.source.quote ?? null);
|
|
362
|
+
links.push({
|
|
363
|
+
id: stableId('link', projectId, 'table', w.id, i), projectId, workId: tid, ledgerRef: ledgerRefOf(ctx, s.source, ev), evidence: ev, stepKind: s.kind,
|
|
364
|
+
why: `${s.kind} of ${w.id}, as the table records it${s.verdict ? `: ${s.verdict}` : ''}`, basis: 'Inferred', confirmed: true, roundId: null, jobId: null, at: s.at ?? at,
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ── earlier generations: one per replaced plan, holding the work planned in it (Spec §2.12, D85) ──
|
|
370
|
+
const generations: Generation[] = table.plans.filter((pl) => pl.status === 'Replaced').map((pl) => {
|
|
371
|
+
const first = pl.sources[0]!;
|
|
372
|
+
const src = ctx.source(first);
|
|
373
|
+
return {
|
|
374
|
+
id: stableId('gen', projectId, 'table', pl.id), projectId, name: named(pl.id, pl.name), started: null,
|
|
375
|
+
ended: pl.ended ? { at: pl.ended, basis: 'Written in text', anchor: src.id } : { at, basis: 'First observed', anchor: null, undated: true },
|
|
376
|
+
endedBy: { kind: 'source', id: src.id, label: pl.replacedBy ? `${pl.id} replaced by ${pl.replacedBy}` : src.title, line: pl.why ?? first.quote ?? null },
|
|
377
|
+
// The plan itself, by its id: a document of it would draw every plan the same file holds into this band (generations.ts).
|
|
378
|
+
planRefs: [{ kind: 'object', id: id(pl.id), label: named(pl.id, pl.name) }],
|
|
379
|
+
workIds: table.work.filter((w) => w.plan === pl.id).map((w) => id(w.id)), roundId: null, updatedAt: at,
|
|
380
|
+
};
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ── code territories: the judged half (§1.19); paths by the repository that holds them ──
|
|
384
|
+
const territories: CodeTerritory[] = [];
|
|
385
|
+
const notesOnTerritory = new Map<string, string[]>();
|
|
386
|
+
for (const n of table.notes) for (const o of n.on) if (territoryIds.has(o)) notesOnTerritory.set(o, [...(notesOnTerritory.get(o) ?? []), id(n.id)]);
|
|
387
|
+
for (const t of table.code) {
|
|
388
|
+
const groups = new Map<string, { dir: string; paths: string[] }>();
|
|
389
|
+
for (const path of t.paths) {
|
|
390
|
+
const owner = owningRepo(path, ctx.repos);
|
|
391
|
+
const dir = owner ? owner.repo.dir : ctx.projectDir;
|
|
392
|
+
const inner = owner ? owner.inner : slash(path);
|
|
393
|
+
const key = pathKey(dir);
|
|
394
|
+
if (!groups.has(key)) groups.set(key, { dir, paths: [] });
|
|
395
|
+
groups.get(key)!.paths.push(inner || '.');
|
|
396
|
+
}
|
|
397
|
+
const anomalies = (t.anomalies ?? []).map((a) => ({
|
|
398
|
+
kind: a.kind, text: a.text, evidence: (a.sources ?? []).map((s) => evidenceOf(ctx, s, null, s.quote ?? null)), basis: 'Explicit' as Basis,
|
|
399
|
+
sendBackId: null, noteIds: notesOnTerritory.get(t.id) ?? [], sixThing: a.kind === 'Looks residual, is live' ? 6 as const : null,
|
|
400
|
+
}));
|
|
401
|
+
// A territory whose paths lie in two repositories is one territory per repository: a territory's paths are its repository's.
|
|
402
|
+
[...groups.values()].forEach((g, i) => territories.push({
|
|
403
|
+
id: i === 0 ? id(t.id) : stableId('terr', projectId, 'table', t.id, i), projectId, name: t.name, summary: t.summary, repo: canonicalPath(g.dir), paths: g.paths,
|
|
404
|
+
kind: 'area', areaId: id(t.area), alsoServes: (t.alsoAreas ?? []).map(id), anomalies, roundId: null, jobId: null, updatedAt: at,
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ── notes: on each object they name (the ❓ there), with how they came about ──
|
|
409
|
+
const notes: Note[] = table.notes.map((n) => {
|
|
410
|
+
const facts: NoteFact[] = n.sources.map((s) => ({ text: s.quote ?? labelOf(ctx.source(s)), sourceIds: [ctx.source(s).id], inferred: false }));
|
|
411
|
+
return {
|
|
412
|
+
id: id(n.id), projectId, mount: { kind: 'node', ids: n.on.map(id) }, status: 'Current', ownerResponse: null,
|
|
413
|
+
versions: [{
|
|
414
|
+
version: 1, at, title: n.title, preview: n.text, ask: n.ask, judgementRecordId: '', reason: 'Taken in from the ProjectKeeper table',
|
|
415
|
+
body: { ...(n.options?.length ? { options: n.options.map((o) => ({ option: o.choice, then: o.consequence })) } : {}), currentView: n.text, whyItMatters: null, facts, otherExplanations: null, keepAdjust: null, whatWouldSettleIt: null },
|
|
416
|
+
}],
|
|
417
|
+
discussion: [], followUps: [], author: { agent: table.project.filledBy.agent, model: null }, resolvedReason: null, withdrawnReason: null, delegatedTo: null,
|
|
418
|
+
// Spec §4.1: a note written while taking the project over. The table's kind and the step that wrote it ride on the job kind.
|
|
419
|
+
cameFrom: { kind: 'Takeover', jobKind: `ProjectKeeper table · ${n.kind} · by ${n.by}`, jobId: null, changeIds: [] },
|
|
420
|
+
language: language(`${n.title} ${n.text}`), updatedAt: at,
|
|
421
|
+
};
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
return { reference, threads, facts, relations, generations, links, territories, notes, ids };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* The table's notes, with what the owner did on the notes already there.
|
|
429
|
+
* - Same id: the response, discussion, follow-ups, delegation and a status the owner set (Resolved, Withdrawn) are kept,
|
|
430
|
+
* and the versions go on — the table's text is a new version only when it differs from the last one.
|
|
431
|
+
* - A note the table no longer has is kept as Withdrawn when the owner had answered or discussed it, so nothing the
|
|
432
|
+
* owner wrote is lost; one the owner never touched simply goes with the table.
|
|
433
|
+
*/
|
|
434
|
+
export function carryOwnerSide(fresh: readonly Note[], before: readonly Note[], at: string): Note[] {
|
|
435
|
+
const old = new Map(before.map((n) => [n.id, n]));
|
|
436
|
+
const touched = (n: Note) => n.ownerResponse !== null || n.discussion.length > 0 || n.followUps.length > 0 || n.delegatedTo !== null || n.status !== 'Current';
|
|
437
|
+
const out: Note[] = fresh.map((n) => {
|
|
438
|
+
const prev = old.get(n.id);
|
|
439
|
+
if (!prev) return n;
|
|
440
|
+
const last = prev.versions[prev.versions.length - 1];
|
|
441
|
+
const next = n.versions[n.versions.length - 1]!;
|
|
442
|
+
const same = last && last.title === next.title && last.ask === next.ask && JSON.stringify(last.body) === JSON.stringify(next.body);
|
|
443
|
+
const versions = same ? prev.versions : [...prev.versions, { ...next, version: (last?.version ?? 0) + 1, reason: 'Changed in the ProjectKeeper table' }];
|
|
444
|
+
return {
|
|
445
|
+
...n, versions,
|
|
446
|
+
status: prev.status, resolvedReason: prev.resolvedReason, withdrawnReason: prev.withdrawnReason,
|
|
447
|
+
ownerResponse: prev.ownerResponse, discussion: prev.discussion, followUps: prev.followUps, delegatedTo: prev.delegatedTo,
|
|
448
|
+
updatedAt: same ? prev.updatedAt : at,
|
|
449
|
+
};
|
|
450
|
+
});
|
|
451
|
+
const kept = new Set(fresh.map((n) => n.id));
|
|
452
|
+
for (const prev of before) {
|
|
453
|
+
if (kept.has(prev.id) || !touched(prev)) continue;
|
|
454
|
+
out.push(prev.status === 'Current' ? { ...prev, status: 'Withdrawn', withdrawnReason: 'No longer in the ProjectKeeper table', updatedAt: at } : prev);
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const labelOf = (s: Source): string => (s.anchor.kind === 'file' ? `${basename(s.anchor.path)} L${s.anchor.lineStart}–L${s.anchor.lineEnd}` : s.title);
|
|
460
|
+
|
|
461
|
+
/** A cited place as the workbench names evidence: a file by its repository-relative path, a commit, a session's source. */
|
|
462
|
+
function evidenceOf(ctx: MapContext, s: TableSource, occurred: Occurred | null, line: string | null): EvidenceRef {
|
|
463
|
+
const src = ctx.source(s);
|
|
464
|
+
if (s.file !== undefined) {
|
|
465
|
+
const owner = owningRepo(s.file, ctx.repos);
|
|
466
|
+
return { kind: 'file', id: owner ? owner.inner : slash(s.file), label: `${slash(s.file)}${s.lines ? ` lines ${s.lines}` : ''}`, line, occurred, repo: canonicalPath(owner?.repo.dir ?? ctx.projectDir) };
|
|
467
|
+
}
|
|
468
|
+
if (s.commit !== undefined && src.anchor.kind === 'commit') return { kind: 'commit', id: src.anchor.commit, label: src.title, line, occurred, repo: src.anchor.repo };
|
|
469
|
+
return { kind: 'source', id: src.id, label: src.title, line, occurred };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** The ledger's id for the entry a step rests on, as the ledger writes it (`file:<path>`, `commit:<12>`); a session's source id. */
|
|
473
|
+
function ledgerRefOf(ctx: MapContext, s: TableSource, ev: EvidenceRef): string {
|
|
474
|
+
if (ev.kind === 'file') return `file:${ev.id}`;
|
|
475
|
+
if (ev.kind === 'commit') return `commit:${ev.id.slice(0, 12)}`;
|
|
476
|
+
return ctx.source(s).id;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ───────────────────────── the import ─────────────────────────
|
|
480
|
+
|
|
481
|
+
/** The collections that hold the Keeper's picture of a project; intake's sources, the ledger and the rest stay. */
|
|
482
|
+
const PICTURE = ['reference', 'threads', 'facts', 'areas', 'relations', 'nodes', 'generations', 'links', 'territories', 'notes', 'breakpoints', 'sendbacks'] as const;
|
|
483
|
+
|
|
484
|
+
/** What one taking-in wrote: what it removed, the sources it cited, the records it wrote, the graph it derived. */
|
|
485
|
+
export interface ImportCounts {
|
|
486
|
+
readonly removed: Readonly<Record<string, number>>;
|
|
487
|
+
readonly sources: { readonly cited: number; readonly reused: number; readonly written: number };
|
|
488
|
+
readonly reference: Readonly<Record<string, number>>;
|
|
489
|
+
readonly threads: number; readonly facts: number; readonly relations: number; readonly generations: number;
|
|
490
|
+
readonly links: number; readonly territories: number; readonly notes: number;
|
|
491
|
+
readonly graph: { readonly nodes: number; readonly relations: number };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export interface ImportResult {
|
|
495
|
+
readonly problems: readonly TableProblem[];
|
|
496
|
+
readonly projectId?: string;
|
|
497
|
+
readonly projectName?: string;
|
|
498
|
+
readonly home?: string;
|
|
499
|
+
readonly added?: boolean;
|
|
500
|
+
readonly counts?: ImportCounts;
|
|
501
|
+
readonly warnings?: readonly string[];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export interface ImportOptions {
|
|
505
|
+
readonly tableFile: string;
|
|
506
|
+
/** The project folder; by default the folder holding `projectkeeper/`. */
|
|
507
|
+
readonly projectDir?: string;
|
|
508
|
+
/** The ProjectKeeper home; by default the user's (store/paths.ts). */
|
|
509
|
+
readonly home?: string;
|
|
510
|
+
/** The project's name when it is added; by default the table's. */
|
|
511
|
+
readonly name?: string;
|
|
512
|
+
readonly sessions?: readonly SessionMessage[] | null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Check the table, and when it holds take it into the home: the project found by its folder or added and read in, the
|
|
517
|
+
* ledger brought up to date, the Keeper's picture replaced by the table's (`applyTable`), the graph derived. Nothing is
|
|
518
|
+
* opened or written for a table with a problem.
|
|
519
|
+
*/
|
|
520
|
+
export async function importTable(options: ImportOptions): Promise<ImportResult> {
|
|
521
|
+
const projectDir = canonicalPath(options.projectDir ?? join(options.tableFile, '..', '..'));
|
|
522
|
+
const sessions = options.sessions ?? null;
|
|
523
|
+
const problems = checkTableFile(options.tableFile, { projectDir, sessions });
|
|
524
|
+
if (problems.length) return { problems };
|
|
525
|
+
const table = JSON.parse(readFileSync(options.tableFile, 'utf8')) as Table;
|
|
526
|
+
|
|
527
|
+
// Loaded only now: a refused table opens nothing.
|
|
528
|
+
const { writeClerkCoverage } = await import('../keeper/organize/service.ts');
|
|
529
|
+
return withTableProject({ projectDir, home: options.home, name: options.name ?? table.project.name }, async ({ app, project, home, added, warnings }) => {
|
|
530
|
+
const applied = await applyTable(app, project.id, table, { tableFile: options.tableFile, projectDir, sessions });
|
|
531
|
+
// The top bar and the opening view read the coverage state ("Not organized yet" sends a project to its Takeover page).
|
|
532
|
+
writeClerkCoverage(app, project.id);
|
|
533
|
+
await app.store(project.id).flush();
|
|
534
|
+
return { problems: [], projectId: applied.project.id, projectName: applied.project.name, home, added, warnings, counts: applied.counts };
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export interface ApplyOptions {
|
|
539
|
+
readonly tableFile: string;
|
|
540
|
+
/** The project folder the table's paths are relative to. */
|
|
541
|
+
readonly projectDir: string;
|
|
542
|
+
/** The session material the table's session sources were checked against. */
|
|
543
|
+
readonly sessions: readonly SessionMessage[] | null;
|
|
544
|
+
/** What the store's trace says the records came from; by default the table file. */
|
|
545
|
+
readonly summary?: string;
|
|
546
|
+
/** The job that takes the table in (a table follow-up, D107), for the store's trace; none for `pk import`. */
|
|
547
|
+
readonly jobId?: string | null;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* The store-writing part of taking a checked table in, on an App and a project already open: the Keeper's picture
|
|
552
|
+
* replaced by the table's, the graph derived, and the takeover marked done by the table (`takenOverByTable`). `pk
|
|
553
|
+
* import` runs it in a home it opens for the purpose; the table follow-up's write runs it in the running workbench
|
|
554
|
+
* (D107). The table must have passed `checkShape` and `checkFacts` with the same session material. The coverage is the
|
|
555
|
+
* caller's to write, once it has recorded whatever else it records.
|
|
556
|
+
*/
|
|
557
|
+
export async function applyTable(app: App, projectId: string, table: Table, options: ApplyOptions): Promise<{ readonly project: Project; readonly counts: ImportCounts }> {
|
|
558
|
+
const { deriveGraph } = await import('../keeper/organize/graph.ts');
|
|
559
|
+
const { projectDir, sessions } = options;
|
|
560
|
+
let project = app.project(projectId);
|
|
561
|
+
const store = app.store(projectId);
|
|
562
|
+
const at = new Date().toISOString();
|
|
563
|
+
const repos = resolveRepos(table as unknown as Record<string, unknown>, projectDir, () => undefined);
|
|
564
|
+
const mainItem = project.scope.find((i) => samePath(i.path, projectDir)) ?? project.scope[0];
|
|
565
|
+
const resolved = resolveSources(table, { projectId: project.id, projectDir, repos, existing: store.sources.all(), sessions, scopeItemId: mainItem?.id ?? 'scope_table', at });
|
|
566
|
+
const mapped = mapTable(table, { projectId: project.id, projectDir, repos, source: (s) => resolved.byKey.get(sourceKey(s))! });
|
|
567
|
+
|
|
568
|
+
const jobId = options.jobId ?? null;
|
|
569
|
+
const trace = { jobId, summary: options.summary ?? `Taken in from the ProjectKeeper table ${options.tableFile}` };
|
|
570
|
+
const removed: Record<string, number> = {};
|
|
571
|
+
// What the owner did with a note is the owner's, not the table's: taken in again (every follow-up, D106), a note keeps
|
|
572
|
+
// its response, discussion, follow-ups and handling, and a changed text becomes its next version.
|
|
573
|
+
const notes = carryOwnerSide(mapped.notes, store.notes.all(), at);
|
|
574
|
+
for (const name of PICTURE) {
|
|
575
|
+
const collection = store.collection(name);
|
|
576
|
+
const before = collection.all();
|
|
577
|
+
for (const x of before) collection.remove(x.id, { jobId, summary: 'Replaced by the ProjectKeeper table' });
|
|
578
|
+
if (before.length) removed[name] = before.length;
|
|
579
|
+
}
|
|
580
|
+
store.sources.putMany(resolved.fresh, trace);
|
|
581
|
+
store.reference.putMany(mapped.reference, trace);
|
|
582
|
+
store.threads.putMany(mapped.threads, trace);
|
|
583
|
+
store.facts.putMany(mapped.facts, trace);
|
|
584
|
+
store.relations.putMany(mapped.relations, trace);
|
|
585
|
+
store.generations.putMany(mapped.generations, trace);
|
|
586
|
+
store.links.putMany(mapped.links, trace);
|
|
587
|
+
store.territories.putMany(mapped.territories, trace);
|
|
588
|
+
store.notes.putMany(notes, trace);
|
|
589
|
+
// Nodes and the relations the items' claims state, as after any round (scripts/seed-ui-fixture.ts).
|
|
590
|
+
const graph = deriveGraph(store, project);
|
|
591
|
+
// D107: taking the table in is the takeover; the workbench then opens on the picture, not on the Takeover page.
|
|
592
|
+
const filled = table.project.filledBy;
|
|
593
|
+
project = app.updateProject({ ...app.project(project.id), takenOverByTable: { at: filled.at, by: filled.agent, file: options.tableFile } });
|
|
594
|
+
const reference: Record<string, number> = {};
|
|
595
|
+
for (const r of mapped.reference) reference[r.category] = (reference[r.category] ?? 0) + 1;
|
|
596
|
+
return {
|
|
597
|
+
project,
|
|
598
|
+
counts: {
|
|
599
|
+
removed, sources: { cited: resolved.byKey.size, reused: resolved.reused, written: resolved.fresh.length }, reference,
|
|
600
|
+
threads: mapped.threads.length, facts: mapped.facts.length, relations: mapped.relations.length, generations: mapped.generations.length,
|
|
601
|
+
links: mapped.links.length, territories: mapped.territories.length, notes: mapped.notes.length,
|
|
602
|
+
graph: { nodes: graph.nodes, relations: graph.relations },
|
|
603
|
+
},
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** For callers that only hold the store (tests): the picture collections' sizes. */
|
|
608
|
+
export function pictureSizes(store: ProjectStore): Record<string, number> {
|
|
609
|
+
return Object.fromEntries(PICTURE.map((name) => [name, store.collection(name).size]));
|
|
610
|
+
}
|