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,821 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placement the program can read off the project's own writing (CM, E151; CL's findings; D101; Spec §1.4 落位):
|
|
3
|
+
*
|
|
4
|
+
* - **An Area by its written name.** A contract's Module column writes 「贯穿」 and the Area is named 「贯穿 · Keeper runtime
|
|
5
|
+
* 与项目资产」: the short form names it (`areaNames`, `areaByWrittenName`). On the gated run the value never matched the
|
|
6
|
+
* Area's full name, and CKC-03 and CKC-20 sat in CK-M5.
|
|
7
|
+
* - **What names an Area is placed on it.** Its requirement group (the heading it stands under), the Spec chapter tagged
|
|
8
|
+
* with it, the contracts whose row writes it, the tickets implementing those contracts, the decisions whose own entry
|
|
9
|
+
* names those (`itemsNaming`). When an Area is created — a foundation column is often created mid-round — everything
|
|
10
|
+
* that names it is placed again in the same pass (`placeAgain`), and `pk_round_state` lists what names an Area but sits
|
|
11
|
+
* elsewhere (`misplaced`).
|
|
12
|
+
* - **Where a decision placed only on the product traces to.** Its own entry (a contract, a Spec section, a module, an
|
|
13
|
+
* R-item, a plan id, an Area's name) and the current documents that cite its number — the PRD group or Spec chapter
|
|
14
|
+
* citing it maps to its Area, the contract to its Module (`tracePlacement`). D28 and D71 name nothing themselves; the
|
|
15
|
+
* documents cite them.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here judges: a placement the documents write is copied, a trace is a suggestion with what it rests on.
|
|
18
|
+
*
|
|
19
|
+
* CQ (D104): the numbers a trace follows are the project's own — the ledger's recognised numbering families, the numbers
|
|
20
|
+
* the ledger finds defined, and the ids the workbench's items carry (`AB`, `#18`, `K`) — never a fixed shape of one
|
|
21
|
+
* project (`numberMatcher`). The rows of an execution arrangement the layer map marks not current still count as the
|
|
22
|
+
* task index of their time (`lineIndex.dated` spares a table row); its decision entries stay dated.
|
|
23
|
+
*/
|
|
24
|
+
import type { Ledger } from '../../ledger/index.ts';
|
|
25
|
+
import { definitionsInPath, definitionsInText, familyOf, mentionMatcher, type Rule } from '../../ledger/numbering.ts';
|
|
26
|
+
import { rangesIn } from '../../ledger/ranges.ts';
|
|
27
|
+
import type { GraphRelation, ReferenceItem, Source, WorkThread } from '../../model/types.ts';
|
|
28
|
+
import type { ProjectStore, TraceInfo } from '../../store/project-store.ts';
|
|
29
|
+
import { primaryIdentifier } from '../tools.ts';
|
|
30
|
+
import { citedAfter, laterThan, writtenBy, type Chronology } from './placement-time.ts';
|
|
31
|
+
|
|
32
|
+
const GONE = new Set(['Replaced', 'Removed', 'Abandoned']);
|
|
33
|
+
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
34
|
+
const squash = (s: string) => s.replace(/\s+/g, ' ').trim();
|
|
35
|
+
const slash = (p: string) => p.replace(/\\/g, '/');
|
|
36
|
+
|
|
37
|
+
// ───────────────────────── an Area by its written name ─────────────────────────
|
|
38
|
+
|
|
39
|
+
/** What separates an Area's short name from the rest of its name (`贯穿 · Keeper runtime`, `CK-M1:看懂项目全貌`). */
|
|
40
|
+
export const NAME_SEP = /\s*[·•::—–|]\s*|\s+-\s+/;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The names an Area answers to: its full name, its ids, and its short form — what stands before the first separator of
|
|
44
|
+
* its name — when that is at least two characters. Longest first.
|
|
45
|
+
*/
|
|
46
|
+
export function areaNames(area: Pick<ReferenceItem, 'name' | 'ids'>): string[] {
|
|
47
|
+
const full = squash(area.name);
|
|
48
|
+
const short = squash(full.split(NAME_SEP)[0] ?? '');
|
|
49
|
+
const out = [full, ...area.ids.map((i) => i.trim()), ...(short.length >= 2 && short !== full ? [short] : [])].filter((n) => n.length >= 2);
|
|
50
|
+
return [...new Set(out)].sort((a, b) => b.length - a.length);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether a text names the Area as a tag or a heading: one of its names standing alone between separators or brackets. */
|
|
54
|
+
export function namesAreaIn(text: string, names: readonly string[]): boolean {
|
|
55
|
+
const t = squash(text);
|
|
56
|
+
if (!t) return false;
|
|
57
|
+
return names.some((n) => new RegExp(`(?:^|[\\s((\\[【「《·•::、,,;;/|—–-])${esc(n)}(?:$|[\\s))\\]】」》·•::、,,;;/|—–-])`, 'i').test(t));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether a name carries the Area as a tag: a bracket at its end, or anywhere, that holds only Area names
|
|
62
|
+
* (`Spec §8 · Keeper runtime(贯穿)`, `整理与判断(CK-M2、CK-M3)`). A name that merely mentions an Area in a sentence is not tagged.
|
|
63
|
+
*/
|
|
64
|
+
export function taggedWith(name: string, names: readonly string[]): boolean {
|
|
65
|
+
const lower = names.map((n) => n.toLowerCase());
|
|
66
|
+
for (const m of name.matchAll(/[((\[【]([^(())\[\]【】]{1,60})[))\]】]/g)) {
|
|
67
|
+
const parts = m[1]!.split(/[,,、;;/]+/).map((x) => squash(x).toLowerCase()).filter(Boolean);
|
|
68
|
+
if (parts.length && parts.length <= 6 && parts.some((x) => lower.includes(x)) && parts.every((x) => x.length <= 40)) return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The Area a written value names (a Module column's cell, a dispatch ticket's field): by id or project id, by its whole
|
|
75
|
+
* name, or by its short form (D101: 「合同 Module 一栏写它的简称也算写明」). Null when none or several answer.
|
|
76
|
+
*/
|
|
77
|
+
export function areaByWrittenName(areas: readonly ReferenceItem[], value: string): ReferenceItem | null {
|
|
78
|
+
const v = squash(value);
|
|
79
|
+
if (!v) return null;
|
|
80
|
+
const exact = areas.filter((a) => a.id === v || a.ids.some((i) => i.toUpperCase() === v.toUpperCase()) || squash(a.name).toLowerCase() === v.toLowerCase());
|
|
81
|
+
if (exact.length === 1) return exact[0]!;
|
|
82
|
+
const short = areas.filter((a) => areaNames(a).some((n) => n.toLowerCase() === v.toLowerCase()));
|
|
83
|
+
return short.length === 1 ? short[0]! : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ───────────────────────── where an item leads ─────────────────────────
|
|
87
|
+
|
|
88
|
+
export interface Reach {
|
|
89
|
+
/** The Areas and Plans a reference item reaches through what it refines (itself included), by id. */
|
|
90
|
+
readonly ofReference: (id: string) => { readonly areas: string[]; readonly plans: string[]; readonly product: boolean };
|
|
91
|
+
/** The Areas and Plans a work item reaches through what it serves, implements or refines; its main Area first. */
|
|
92
|
+
readonly ofThread: (t: WorkThread) => { readonly areas: string[]; readonly plans: string[] };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** How the workbench's relations lead to Areas and Plans, read once. */
|
|
96
|
+
export function reachOf(store: ProjectStore, relations: readonly GraphRelation[] = store.relations.all()): Reach {
|
|
97
|
+
const refinesOf = new Map<string, string[]>();
|
|
98
|
+
for (const r of relations) if (r.type === 'refines') refinesOf.set(r.from, [...(refinesOf.get(r.from) ?? []), r.to]);
|
|
99
|
+
const cache = new Map<string, { areas: string[]; plans: string[]; product: boolean }>();
|
|
100
|
+
const ofReference = (id: string) => {
|
|
101
|
+
const hit = cache.get(id);
|
|
102
|
+
if (hit) return hit;
|
|
103
|
+
const areas: string[] = [];
|
|
104
|
+
const plans: string[] = [];
|
|
105
|
+
let product = false;
|
|
106
|
+
const seen = new Set<string>();
|
|
107
|
+
const walk = (at: string, depth: number) => {
|
|
108
|
+
if (seen.has(at) || depth > 10) return;
|
|
109
|
+
seen.add(at);
|
|
110
|
+
const item = store.reference.get(at);
|
|
111
|
+
if (!item) return;
|
|
112
|
+
if (item.category === 'Area') { if (!areas.includes(at)) areas.push(at); return; }
|
|
113
|
+
if (item.category === 'Plan') { if (!plans.includes(at)) plans.push(at); return; }
|
|
114
|
+
if (item.category === 'Product' || item.category === 'Goal') product = true;
|
|
115
|
+
for (const up of [...item.refines, ...(refinesOf.get(at) ?? [])]) walk(up, depth + 1);
|
|
116
|
+
};
|
|
117
|
+
walk(id, 0);
|
|
118
|
+
const out = { areas, plans, product };
|
|
119
|
+
cache.set(id, out);
|
|
120
|
+
return out;
|
|
121
|
+
};
|
|
122
|
+
const upOf = new Map<string, string[]>();
|
|
123
|
+
for (const r of relations) if (r.type === 'serves' || r.type === 'implements' || r.type === 'refines') upOf.set(r.from, [...(upOf.get(r.from) ?? []), r.to]);
|
|
124
|
+
const ofThread = (t: WorkThread) => {
|
|
125
|
+
const areas: string[] = [];
|
|
126
|
+
const plans: string[] = [];
|
|
127
|
+
for (const id of [...t.serves.map((s) => s.referenceId), ...(upOf.get(t.id) ?? [])]) {
|
|
128
|
+
const r = ofReference(id);
|
|
129
|
+
for (const a of r.areas) if (!areas.includes(a)) areas.push(a);
|
|
130
|
+
for (const p of r.plans) if (!plans.includes(p)) plans.push(p);
|
|
131
|
+
}
|
|
132
|
+
return { areas, plans };
|
|
133
|
+
};
|
|
134
|
+
return { ofReference, ofThread };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ───────────────────────── what names an Area ─────────────────────────
|
|
138
|
+
|
|
139
|
+
export interface Naming {
|
|
140
|
+
readonly id: string;
|
|
141
|
+
readonly kind: 'reference' | 'thread';
|
|
142
|
+
readonly category: string;
|
|
143
|
+
readonly name: string;
|
|
144
|
+
/** How it names the Area, in words the main agent can check: the heading, the cell, the contract it implements. */
|
|
145
|
+
readonly how: string;
|
|
146
|
+
/** Whether it reaches the Area already, and whether the Area is (or would be) its main one — the first it serves. */
|
|
147
|
+
readonly placed: boolean;
|
|
148
|
+
readonly main: boolean;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const threadName = (t: WorkThread): string => (t.ids[0] && !t.title.includes(t.ids[0]) ? `${t.ids[0]} ${t.title}` : t.title);
|
|
152
|
+
const refName = (r: ReferenceItem): string => (r.ids[0] && !r.name.startsWith(r.ids[0]) ? `${r.ids[0]} ${r.name}` : r.name);
|
|
153
|
+
|
|
154
|
+
/** The cells of a table row as the fill-in tools keep them in an item's text (`Header: value · Header: value`). */
|
|
155
|
+
function rowCells(text: string): { header: string; value: string }[] {
|
|
156
|
+
return text.split(/\s+·\s+/).flatMap((seg) => { const m = /^([^::]{1,40})[::]\s*(.+)$/.exec(seg.trim()); return m ? [{ header: m[1]!.trim(), value: m[2]!.trim() }] : []; });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The Areas a row's cells name, in the order written (a cell may list several: `CK-M1、CK-M4`). */
|
|
160
|
+
function areasOfRow(text: string, areas: readonly ReferenceItem[]): ReferenceItem[] {
|
|
161
|
+
const out: ReferenceItem[] = [];
|
|
162
|
+
for (const cell of rowCells(text)) {
|
|
163
|
+
for (const part of [cell.value, ...cell.value.split(/[,,、;;/]+/)].map(squash)) {
|
|
164
|
+
const a = areaByWrittenName(areas, part);
|
|
165
|
+
if (a && !out.includes(a)) out.push(a);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The file anchors of an item's sources. */
|
|
172
|
+
function fileAnchors(store: ProjectStore, sourceIds: readonly string[]): Extract<Source['anchor'], { kind: 'file' }>[] {
|
|
173
|
+
return sourceIds.flatMap((s) => { const a = store.sources.get(s)?.anchor; return a?.kind === 'file' ? [a] : []; });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Every item that names the Area in the project's own writing (see the module comment), with whether it sits on it
|
|
178
|
+
* already. Decisions come last: they are placed by what their own entry names, once those are known to name the Area.
|
|
179
|
+
*/
|
|
180
|
+
export function itemsNaming(store: ProjectStore, area: ReferenceItem, opts: { readonly reach?: Reach; readonly entries?: Map<string, string[]>; readonly index?: TraceIndex } = {}): Naming[] {
|
|
181
|
+
const names = areaNames(area);
|
|
182
|
+
const areas = store.reference.filter((r) => r.category === 'Area' && !GONE.has(r.validity));
|
|
183
|
+
const relations = store.relations.all();
|
|
184
|
+
const reach = opts.reach ?? reachOf(store, relations);
|
|
185
|
+
const out = new Map<string, Naming>();
|
|
186
|
+
const named = new Set<string>(); // reference ids and thread ids that name the Area
|
|
187
|
+
const mainOf = new Map<string, boolean>();
|
|
188
|
+
|
|
189
|
+
// 1 · requirements and designs: the group heading they stand under, a tag in their name, the Area their row writes.
|
|
190
|
+
for (const r of store.reference.filter((x) => (x.category === 'Requirement' || x.category === 'Design') && !GONE.has(x.validity))) {
|
|
191
|
+
let how: string | null = null;
|
|
192
|
+
let main = true;
|
|
193
|
+
const heading = fileAnchors(store, r.sourceIds).flatMap((a) => a.headingPath).find((h) => namesAreaIn(h, names));
|
|
194
|
+
const cells = areasOfRow(r.text, areas);
|
|
195
|
+
if (cells.includes(area)) { how = `its row writes ${rowCells(r.text).find((c) => [c.value, ...c.value.split(/[,,、;;/]+/)].some((p) => areaByWrittenName(areas, squash(p)) === area))?.header ?? 'the area'}: ${area.name.split(NAME_SEP)[0]}`; main = cells[0] === area; }
|
|
196
|
+
else if (taggedWith(r.name, names)) how = `its name is tagged with ${names[names.length - 1]}`;
|
|
197
|
+
else if (heading) how = `it stands under the heading “${squash(heading)}”`;
|
|
198
|
+
if (!how) continue;
|
|
199
|
+
named.add(r.id);
|
|
200
|
+
mainOf.set(r.id, main);
|
|
201
|
+
out.set(r.id, { id: r.id, kind: 'reference', category: r.category, name: refName(r), how, placed: reach.ofReference(r.id).areas.includes(area.id), main });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 2 · contracts: the work item of the same row (it serves the row's reference item, or carries its number).
|
|
205
|
+
const threads = store.threads.filter((t) => !GONE.has(t.validity));
|
|
206
|
+
for (const t of threads) {
|
|
207
|
+
const row = [...t.serves.map((s) => s.referenceId), ...store.reference.filter((r) => r.category === 'Requirement' && r.ids.some((i) => t.ids.some((x) => x.toUpperCase() === i.toUpperCase()))).map((r) => r.id)].find((id) => named.has(id) && areasOfRow(store.reference.get(id)?.text ?? '', areas).includes(area));
|
|
208
|
+
if (!row) continue;
|
|
209
|
+
const main = mainOf.get(row) ?? true;
|
|
210
|
+
named.add(t.id);
|
|
211
|
+
mainOf.set(t.id, main);
|
|
212
|
+
const at = reach.ofThread(t).areas;
|
|
213
|
+
out.set(t.id, { id: t.id, kind: 'thread', category: 'Work item', name: threadName(t), how: out.get(row)!.how.replace(/^its row/, 'its contract row'), placed: at.includes(area.id) && (!main || at[0] === area.id), main });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 3 · tickets: they implement a contract that names the Area. Main when every contract they implement does.
|
|
217
|
+
const contractOf = (to: string): string => {
|
|
218
|
+
if (store.threads.has(to)) return to;
|
|
219
|
+
const r = store.reference.get(to);
|
|
220
|
+
const n = (r?.ids ?? []).map((i) => i.toUpperCase());
|
|
221
|
+
return n.length ? store.threads.find((x) => x.ids.some((i) => n.includes(i.toUpperCase())))?.id ?? to : to;
|
|
222
|
+
};
|
|
223
|
+
const implementsOf = new Map<string, string[]>();
|
|
224
|
+
for (const r of relations) if (r.type === 'implements' && store.threads.has(r.from)) implementsOf.set(r.from, [...(implementsOf.get(r.from) ?? []), contractOf(r.to)]);
|
|
225
|
+
for (const t of threads) {
|
|
226
|
+
if (named.has(t.id)) continue;
|
|
227
|
+
const contracts = implementsOf.get(t.id) ?? [];
|
|
228
|
+
const hit = contracts.filter((c) => named.has(c) && mainOf.get(c) !== false);
|
|
229
|
+
if (!hit.length) continue;
|
|
230
|
+
const main = hit.length === contracts.length;
|
|
231
|
+
const at = reach.ofThread(t).areas;
|
|
232
|
+
const label = hit.map((c) => store.threads.get(c)?.ids[0] ?? store.reference.get(c)?.ids[0] ?? c).join(', ');
|
|
233
|
+
out.set(t.id, { id: t.id, kind: 'thread', category: 'Work item', name: threadName(t), how: `it implements ${label}`, placed: at.includes(area.id) && (!main || at[0] === area.id), main });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 4 · decisions and boundaries: their own entry names only items of this Area (a contract, a Spec section, an R-item).
|
|
237
|
+
const naming = new Set([...named, ...[...out.keys()]]);
|
|
238
|
+
const trace = opts.index ?? traceIndex(store, reach);
|
|
239
|
+
for (const d of store.reference.filter((x) => (x.category === 'Decision' || x.category === 'Boundary') && !GONE.has(x.validity))) {
|
|
240
|
+
// What an entry names does not depend on the Area asked about: read once when several Areas are asked in a row.
|
|
241
|
+
let own = opts.entries?.get(d.id);
|
|
242
|
+
if (!own) { own = entryNames(store, d, trace); opts.entries?.set(d.id, own); }
|
|
243
|
+
if (!own.length) continue;
|
|
244
|
+
const targets = own.flatMap((n) => trace.resolve(n));
|
|
245
|
+
if (!targets.length) continue;
|
|
246
|
+
const onArea = targets.filter((id) => naming.has(id) || id === area.id);
|
|
247
|
+
if (!onArea.length) continue;
|
|
248
|
+
// Its entry names other Areas' items too: it reaches several areas, which is the model's to weigh (the trace suggests).
|
|
249
|
+
const elsewhere = targets.filter((id) => !onArea.includes(id)).some((id) => (store.threads.has(id) ? reach.ofThread(store.threads.get(id)!).areas : reach.ofReference(id).areas).some((a) => a !== area.id));
|
|
250
|
+
if (elsewhere) continue;
|
|
251
|
+
const r = reach.ofReference(d.id);
|
|
252
|
+
out.set(d.id, { id: d.id, kind: 'reference', category: d.category, name: refName(d), how: `its entry names ${own.filter((n) => trace.resolve(n).some((id) => onArea.includes(id))).join(', ')}`, placed: r.areas.includes(area.id), main: true });
|
|
253
|
+
}
|
|
254
|
+
return [...out.values()];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Place again what names the Area and does not sit on it (D101: 「这一列是在途中才建的,建好后把点名它的再落一遍」): a
|
|
259
|
+
* requirement, a design or a decision refines it too; a contract or a ticket serves it, first when it is its main Area. What
|
|
260
|
+
* an item already refines or serves stays — a contract in two modules stays in both, the Area written first leading.
|
|
261
|
+
* Decisions already placed on an Area or a Plan are left where the Keeper judged them.
|
|
262
|
+
*/
|
|
263
|
+
export function placeAgain(store: ProjectStore, area: ReferenceItem, trace: (summary: string) => TraceInfo): Naming[] {
|
|
264
|
+
const reach = reachOf(store);
|
|
265
|
+
const moved: Naming[] = [];
|
|
266
|
+
const at = new Date().toISOString();
|
|
267
|
+
for (const n of itemsNaming(store, area, { reach })) {
|
|
268
|
+
if (n.placed) continue;
|
|
269
|
+
if (n.kind === 'reference') {
|
|
270
|
+
const r = store.reference.get(n.id);
|
|
271
|
+
if (!r || r.refines.includes(area.id)) continue;
|
|
272
|
+
if ((r.category === 'Decision' || r.category === 'Boundary') && (reach.ofReference(r.id).areas.length || reach.ofReference(r.id).plans.length)) continue;
|
|
273
|
+
store.reference.put({ ...r, refines: [...r.refines, area.id], updatedAt: at }, trace(`${refName(r)} placed on ${area.name}: ${n.how}`));
|
|
274
|
+
} else {
|
|
275
|
+
const t = store.threads.get(n.id);
|
|
276
|
+
if (!t) continue;
|
|
277
|
+
const rest = t.serves.filter((s) => s.referenceId !== area.id);
|
|
278
|
+
// CQ (D104): a placement the program writes is Inferred; the main agent or a lane confirms or moves it.
|
|
279
|
+
const mine = t.serves.find((s) => s.referenceId === area.id) ?? { referenceId: area.id, claim: n.how, basis: 'Inferred' as const };
|
|
280
|
+
const firstArea = rest.findIndex((s) => store.reference.get(s.referenceId)?.category === 'Area');
|
|
281
|
+
// Main: before every other Area it serves. Otherwise after the Areas it already serves, before its plans.
|
|
282
|
+
const lastArea = rest.reduce((k, s, i) => (store.reference.get(s.referenceId)?.category === 'Area' ? i : k), -1);
|
|
283
|
+
const serves = n.main ? [...rest.slice(0, Math.max(0, firstArea)), mine, ...rest.slice(Math.max(0, firstArea))] : [...rest.slice(0, lastArea + 1), mine, ...rest.slice(lastArea + 1)];
|
|
284
|
+
store.threads.put({ ...t, serves: firstArea < 0 && n.main ? [mine, ...rest] : serves, updatedAt: at }, trace(`${threadName(t)} placed on ${area.name}${n.main ? ' (its main area)' : ''}: ${n.how}`));
|
|
285
|
+
}
|
|
286
|
+
moved.push(n);
|
|
287
|
+
}
|
|
288
|
+
return moved;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Across every Area: what names it in the project's own writing and sits elsewhere (`pk_round_state` open `misplaced`). */
|
|
292
|
+
export function misplacedItems(store: ProjectStore): (Naming & { readonly area: string; readonly sits: string })[] {
|
|
293
|
+
const reach = reachOf(store);
|
|
294
|
+
const nameOf = (id: string) => store.reference.get(id)?.name.split(NAME_SEP)[0] ?? id;
|
|
295
|
+
const out: (Naming & { area: string; sits: string })[] = [];
|
|
296
|
+
const entries = new Map<string, string[]>();
|
|
297
|
+
const index = traceIndex(store, reach);
|
|
298
|
+
for (const area of store.reference.filter((r) => r.category === 'Area' && !GONE.has(r.validity))) {
|
|
299
|
+
for (const n of itemsNaming(store, area, { reach, entries, index })) {
|
|
300
|
+
if (n.placed) continue;
|
|
301
|
+
const r = n.kind === 'thread' ? reach.ofThread(store.threads.get(n.id)!) : reach.ofReference(n.id);
|
|
302
|
+
if (n.kind === 'reference' && (n.category === 'Decision' || n.category === 'Boundary') && (r.areas.length || r.plans.length)) continue;
|
|
303
|
+
out.push({ ...n, area: area.name, sits: r.areas.length ? r.areas.map(nameOf).join(', ') : 'product' in r && r.product ? 'the product only' : 'no area' });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ───────────────────────── where a decision traces to ─────────────────────────
|
|
310
|
+
|
|
311
|
+
/** A section reference in an entry (`Spec §8.1`, `SPEC.md §3`, `§2.4`): the document's word, when written, and the section number. */
|
|
312
|
+
const SECTION_RE = /(?:\b([A-Za-z][A-Za-z0-9_-]{1,15})(?:\.md)?\s*)?§\s*(\d+(?:\.\d+)*)/g;
|
|
313
|
+
/** The generic shapes of a project number: the fallback where no ledger says which families the project has. */
|
|
314
|
+
const GENERIC_NUM_RE = /(?<![A-Za-z0-9_#-])(?:[A-Z]{1,6}-[A-Z]?\d{1,4}|[A-Z]\d{1,4}|#\d{1,5})(?![A-Za-z0-9_])/g;
|
|
315
|
+
|
|
316
|
+
/** The project's own numbers in a text, as `numberMatcher` reads them: upper case, each once. */
|
|
317
|
+
export type NumberMatcher = (text: string) => string[];
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* CQ (D104): the project's own numbers, read off the ledger and the workbench, never a fixed shape of one project:
|
|
321
|
+
* - the ledger's recognised numbering families (`num_rules`: letter-digits, prefix-hyphen, and the two-letter ids it found
|
|
322
|
+
* defined — an index's `AB`, a prompt's `AP`), through `mentionMatcher`;
|
|
323
|
+
* - a token of a generic shape the ledger finds defined somewhere (a family too small to be a rule);
|
|
324
|
+
* - the ids the workbench's items carry (`#18`, `CK-M2`, a plan's `M1`), as whole tokens, when two characters or more
|
|
325
|
+
* (a one-letter plan id is read by the plan words of `traceIndex`).
|
|
326
|
+
* Without a ledger the generic shapes stand in for the families.
|
|
327
|
+
*/
|
|
328
|
+
export function numberMatcher(store: ProjectStore, ledger: Ledger | null): NumberMatcher {
|
|
329
|
+
let rules: Rule[] = [];
|
|
330
|
+
if (ledger) {
|
|
331
|
+
try {
|
|
332
|
+
const rows = ledger.db.prepare('SELECT rule, shape FROM num_rules').all() as { rule: string; shape: Rule['shape'] }[];
|
|
333
|
+
const pairs = rows.some((r) => r.shape === 'two-letters') ? (ledger.db.prepare("SELECT DISTINCT num FROM nums WHERE rule = 'two letters' AND place = 'definition'").all() as { num: string }[]).map((r) => r.num) : [];
|
|
334
|
+
rules = rows.map((r) => ({ family: r.rule, shape: r.shape, defined: new Set(r.shape === 'two-letters' ? pairs : []) }));
|
|
335
|
+
} catch { rules = []; }
|
|
336
|
+
}
|
|
337
|
+
const viaRules = rules.length ? mentionMatcher(rules) : null;
|
|
338
|
+
const ids = new Set<string>();
|
|
339
|
+
for (const t of store.threads.all()) for (const i of t.ids) if (i.trim().length >= 2 && i.trim().length <= 24) ids.add(i.trim());
|
|
340
|
+
for (const r of store.reference.all()) if (r.category !== "Owner's words") for (const i of r.ids) if (i.trim().length >= 2 && i.trim().length <= 24) ids.add(i.trim());
|
|
341
|
+
const idRe = ids.size ? new RegExp(`(?<![\\p{L}\\p{N}_#-])(?:${[...ids].sort((a, b) => b.length - a.length).map(esc).join('|')})(?![\\p{L}\\p{N}_])`, 'gu') : null;
|
|
342
|
+
const defined = new Map<string, boolean>();
|
|
343
|
+
const isDefined = (tokens: string[]): Set<string> => {
|
|
344
|
+
const ask = tokens.filter((t) => !defined.has(t));
|
|
345
|
+
if (ask.length && ledger) { try { const found = ledger.definedNumbers(ask); for (const t of ask) defined.set(t, found.has(t)); } catch { for (const t of ask) defined.set(t, false); } }
|
|
346
|
+
return new Set(tokens.filter((t) => defined.get(t) === true));
|
|
347
|
+
};
|
|
348
|
+
return (text: string): string[] => {
|
|
349
|
+
const out = new Set<string>();
|
|
350
|
+
if (viaRules) for (const m of viaRules(text)) if (m.confidence === 'stated') out.add(m.num.toUpperCase());
|
|
351
|
+
if (idRe) for (const m of text.matchAll(idRe)) out.add(m[0].toUpperCase());
|
|
352
|
+
const generic = [...new Set([...text.matchAll(GENERIC_NUM_RE)].map((m) => m[0].toUpperCase()))].filter((n) => !out.has(n) && !/^V\d/.test(n));
|
|
353
|
+
if (generic.length) {
|
|
354
|
+
if (!ledger) for (const n of generic) out.add(n);
|
|
355
|
+
else for (const n of isDefined(generic.filter((g) => familyOf(g) !== null))) out.add(n);
|
|
356
|
+
}
|
|
357
|
+
return [...out];
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export interface TraceIndex {
|
|
362
|
+
/** The Plans' ids, the Areas' names, the project's numbers and the section references, as a matcher over an entry's text. */
|
|
363
|
+
readonly named: (text: string) => string[];
|
|
364
|
+
/** The items a written name stands for: the contract's work item and requirement, the section's design, the Area, the Plan. */
|
|
365
|
+
readonly resolve: (name: string) => string[];
|
|
366
|
+
/** The project's numbers in a text (`numberMatcher`). */
|
|
367
|
+
readonly numbers: NumberMatcher;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** The words a plan's one-token id follows when an entry means the plan by it (`增量 K`, `plan K`): a bilingual word list, not one project's shape. */
|
|
371
|
+
const PLAN_WORDS = '增量|计划|阶段|批次|里程碑|迭代|increment|plan|phase|batch|milestone|sprint|iteration|stage';
|
|
372
|
+
|
|
373
|
+
/** The project's own names a trace can lead through (plan ids like K and P1–P4, Area names, its numbers; CM, CL's findings §3; CQ). */
|
|
374
|
+
export function traceIndex(store: ProjectStore, _reach?: Reach, opts: { readonly ledger?: Ledger | null; readonly numbers?: NumberMatcher } = {}): TraceIndex {
|
|
375
|
+
const live = store.reference.filter((r) => !GONE.has(r.validity));
|
|
376
|
+
const plans = live.filter((r) => r.category === 'Plan');
|
|
377
|
+
const areas = live.filter((r) => r.category === 'Area');
|
|
378
|
+
const planNames = new Map<string, string>();
|
|
379
|
+
for (const p of plans) for (const n of [...p.ids, primaryIdentifier(p.ids, p.name) ?? '', squash(p.name.split(NAME_SEP)[0] ?? '')]) if (n && n.length <= 16) planNames.set(n.toUpperCase(), p.id);
|
|
380
|
+
const areaName = new Map<string, string>();
|
|
381
|
+
for (const a of areas) for (const n of areaNames(a)) areaName.set(n.toLowerCase(), a.id);
|
|
382
|
+
// A plan's short id (K, P1) counts where the entry writes it as one: after a plan word, or with digits.
|
|
383
|
+
const planRe = planNames.size ? new RegExp(`(?:(?:${PLAN_WORDS})\\s*)(${[...planNames.keys()].map(esc).join('|')})(?![\\p{L}\\p{N}_-])|(?<![\\p{L}\\p{N}_-])(${[...planNames.keys()].filter((k) => /\d/.test(k)).map(esc).join('|') || '\\b\\B'})(?![\\p{L}\\p{N}_-])`, 'giu') : null;
|
|
384
|
+
const numbers = opts.numbers ?? numberMatcher(store, opts.ledger ?? null);
|
|
385
|
+
const named = (text: string): string[] => {
|
|
386
|
+
const out = new Set<string>();
|
|
387
|
+
for (const n of numbers(text)) out.add(n);
|
|
388
|
+
for (const m of text.matchAll(SECTION_RE)) out.add(`${m[1] ? `${m[1].toUpperCase()} ` : ''}§${m[2]}`);
|
|
389
|
+
if (planRe) for (const m of text.matchAll(planRe)) out.add((m[1] ?? m[2])!.toUpperCase());
|
|
390
|
+
for (const [n] of areaName) if (namesAreaIn(text, [n])) out.add(areas.find((a) => a.id === areaName.get(n))!.name.split(NAME_SEP)[0]!.trim());
|
|
391
|
+
return [...out];
|
|
392
|
+
};
|
|
393
|
+
const resolve = (name: string): string[] => {
|
|
394
|
+
const up = name.toUpperCase();
|
|
395
|
+
const plan = planNames.get(up);
|
|
396
|
+
if (plan) return [plan];
|
|
397
|
+
const area = areaName.get(name.toLowerCase());
|
|
398
|
+
if (area) return [area];
|
|
399
|
+
const section = /^(?:([A-Z0-9_-]+) )?§(\d+(?:\.\d+)*)$/.exec(up);
|
|
400
|
+
if (section) {
|
|
401
|
+
// The design of that section: by its name (`Spec §8 · …`, `§8.1 …`), else of its chapter; the document's word, when
|
|
402
|
+
// written, keeps to the designs read from a file of that name.
|
|
403
|
+
const word = section[1]?.toLowerCase() ?? null;
|
|
404
|
+
const designs = live.filter((r) => r.category === 'Design');
|
|
405
|
+
const by = (num: string) => designs.filter((d) => new RegExp(`§\\s*${esc(num)}(?![\\d.]*\\d)`).test(d.name) || fileAnchors(store, d.sourceIds).some((a) => a.headingPath.some((h) => new RegExp(`^${esc(num)}(?:\\s|[.·、::])`).test(h.trim()))));
|
|
406
|
+
const narrow = (ds: ReferenceItem[]) => { if (!word || ds.length <= 1) return ds; const of = ds.filter((d) => fileAnchors(store, d.sourceIds).some((a) => slash(a.path).toLowerCase().includes(word))); return of.length ? of : ds; };
|
|
407
|
+
const exact = narrow(by(section[2]!));
|
|
408
|
+
return (exact.length ? exact : narrow(by(section[2]!.split('.')[0]!))).map((d) => d.id);
|
|
409
|
+
}
|
|
410
|
+
const refs = live.filter((r) => r.category !== 'Decision' && r.category !== 'Boundary' && r.ids.some((i) => i.toUpperCase() === up)).map((r) => r.id);
|
|
411
|
+
const threads = store.threads.filter((t) => !GONE.has(t.validity) && t.ids.some((i) => i.toUpperCase() === up)).map((t) => t.id);
|
|
412
|
+
return [...threads, ...refs];
|
|
413
|
+
};
|
|
414
|
+
return { named, resolve, numbers };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The names in an item's own entry of its source that trace it: the lines from the line that defines its number to the
|
|
419
|
+
* next entry of another number, in the section its source holds (a source often holds a whole section of many entries);
|
|
420
|
+
* without a number, or when the source does not hold its definition, the item's own text.
|
|
421
|
+
*/
|
|
422
|
+
export function entryNames(store: ProjectStore, item: ReferenceItem, index: TraceIndex = traceIndex(store)): string[] {
|
|
423
|
+
const num = item.ids[0]?.toUpperCase() ?? null;
|
|
424
|
+
const texts: string[] = [];
|
|
425
|
+
for (const sid of item.sourceIds) {
|
|
426
|
+
const src = store.sources.get(sid);
|
|
427
|
+
if (!src) continue;
|
|
428
|
+
const lines = src.excerpt.split(/\r?\n/);
|
|
429
|
+
const defs = definitionsInText(src.excerpt).filter((d) => d.line !== null);
|
|
430
|
+
const own = num ? defs.filter((d) => d.num === num) : [];
|
|
431
|
+
if (!own.length) continue;
|
|
432
|
+
for (const o of own) {
|
|
433
|
+
const next = defs.find((d) => d.line! > o.line! && d.num !== num);
|
|
434
|
+
texts.push(lines.slice(o.line! - 1, next ? next.line! - 1 : lines.length).join('\n'));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (!texts.length) texts.push(item.text);
|
|
438
|
+
const found = new Set<string>();
|
|
439
|
+
for (const t of texts) for (const n of index.named(t)) if (n.toUpperCase() !== num) found.add(n);
|
|
440
|
+
return [...found];
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export interface Trace {
|
|
444
|
+
/** What its own entry names. */
|
|
445
|
+
readonly names: readonly string[];
|
|
446
|
+
/** The current documents that cite its number, each as `path:line → the item there (its areas)`; up to `CITED_SHOWN`. */
|
|
447
|
+
readonly cited: readonly string[];
|
|
448
|
+
readonly citations: number;
|
|
449
|
+
/** The Areas and Plans the names and the citing items lead to, most cited first, each with how often. */
|
|
450
|
+
readonly suggest: readonly string[];
|
|
451
|
+
/** CQ: the same tally by id — the Area or Plan, and how many names and citations lead to it — most led to first. */
|
|
452
|
+
readonly leads: readonly { readonly id: string; readonly count: number }[];
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const CITED_SHOWN = 6;
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Where a decision or boundary traces to (see the module comment): forward through its own entry, and back through the
|
|
459
|
+
* current documents that cite its number — a line of the PRD or the Spec maps to the requirement or design it stands in
|
|
460
|
+
* and that item's Area; a line of a contract to the contract's Module. Dated records (decision logs, reports, archives)
|
|
461
|
+
* are not citations in force.
|
|
462
|
+
*/
|
|
463
|
+
export function tracePlacement(store: ProjectStore, ledger: Ledger | null, item: ReferenceItem, shared: { readonly reach?: Reach; readonly index?: TraceIndex; readonly lines?: LineIndex; readonly time?: Chronology; readonly dropped?: string[] } = {}): Trace {
|
|
464
|
+
const reach = shared.reach ?? reachOf(store);
|
|
465
|
+
const index = shared.index ?? traceIndex(store, reach);
|
|
466
|
+
const time = shared.time ?? null;
|
|
467
|
+
const drop = (note: string) => { if (shared.dropped && !shared.dropped.includes(note)) shared.dropped.push(note); };
|
|
468
|
+
const label = item.ids[0] ?? squash(item.name).slice(0, 30);
|
|
469
|
+
// CR: a name its entry writes counts only when what it names existed at the entry's date.
|
|
470
|
+
const own = time?.own(item.id) ?? null;
|
|
471
|
+
const ownEntry = own ? entryTextOf(store, item) : '';
|
|
472
|
+
const existed = (n: string, id: string): boolean => {
|
|
473
|
+
const first = own ? time!.first(id) : null;
|
|
474
|
+
if (!citedAfter(first, own, ownEntry, n)) return true;
|
|
475
|
+
drop(`${label}'s entry names ${n}, first written ${first}, after ${label}'s entry of ${own}`);
|
|
476
|
+
return false;
|
|
477
|
+
};
|
|
478
|
+
const names = entryNames(store, item, index);
|
|
479
|
+
const tally = new Map<string, number>();
|
|
480
|
+
const bump = (ids: readonly string[]) => { for (const id of new Set(ids)) tally.set(id, (tally.get(id) ?? 0) + 1); };
|
|
481
|
+
const placesOf = (id: string): string[] => {
|
|
482
|
+
const t = store.threads.get(id);
|
|
483
|
+
if (t) { const r = reach.ofThread(t); return [...r.areas.slice(0, 1), ...(r.areas.length ? [] : r.plans.slice(0, 1))]; }
|
|
484
|
+
const r = store.reference.get(id);
|
|
485
|
+
if (!r) return [];
|
|
486
|
+
if (r.category === 'Area' || r.category === 'Plan') return [id];
|
|
487
|
+
const x = reach.ofReference(id);
|
|
488
|
+
return x.areas.length ? x.areas : x.plans;
|
|
489
|
+
};
|
|
490
|
+
for (const n of names) bump(index.resolve(n).filter((id) => existed(n, id)).flatMap(placesOf));
|
|
491
|
+
const cited: string[] = [];
|
|
492
|
+
let citations = 0;
|
|
493
|
+
const num = item.ids[0]?.toUpperCase();
|
|
494
|
+
if (ledger && num) {
|
|
495
|
+
const lines = shared.lines ?? lineIndex(store);
|
|
496
|
+
let rows: { path: string; line: number; context: string; occurred_at: string; occurred_basis: string; other_at: string | null }[] = [];
|
|
497
|
+
try { rows = ledger.db.prepare("SELECT path, line, context, occurred_at, occurred_basis, other_at FROM nums WHERE num = ? AND kind = 'doc' AND current = 1 AND place != 'definition' AND path IS NOT NULL AND line IS NOT NULL ORDER BY path, line").all(num) as typeof rows; } catch { rows = []; }
|
|
498
|
+
// CR: a line citing it counts only when it existed when that line was written.
|
|
499
|
+
const first = time?.first(item.id) ?? null;
|
|
500
|
+
for (const row of rows) {
|
|
501
|
+
if (lines.dated(row.path, row.context)) continue;
|
|
502
|
+
// The line as it stands was written by its document's latest version (else the ledger's row says by when).
|
|
503
|
+
const written = time?.line(row.path, row.line) ?? writtenBy(row);
|
|
504
|
+
if (first && written && laterThan(first, written)) { drop(`${row.path}:${row.line} names ${label}, first written ${first}, after that line of ${written}`); continue; }
|
|
505
|
+
const at = lines.itemsAt(row.path, row.line).filter((id) => id !== item.id);
|
|
506
|
+
// A section's source often holds a whole table: the row that cites it is the item whose own number stands on the line.
|
|
507
|
+
const onLine = at.filter((id) => { const n = store.threads.get(id)?.ids[0] ?? store.reference.get(id)?.ids[0]; return n ? new RegExp(`(?<![A-Za-z0-9_-])${esc(n)}(?![A-Za-z0-9_])`).test(row.context) : false; });
|
|
508
|
+
const here = onLine.length ? onLine : at;
|
|
509
|
+
const places = [...new Set(here.flatMap(placesOf))];
|
|
510
|
+
// A line that leads to three places or more (a table of everything, a principles section) says nothing about where.
|
|
511
|
+
if (!places.length || places.length > 2) continue;
|
|
512
|
+
citations += 1;
|
|
513
|
+
bump(places);
|
|
514
|
+
if (cited.length < CITED_SHOWN) cited.push(`${row.path}:${row.line} → ${here.slice(0, 2).map((id) => store.threads.get(id)?.ids[0] ?? store.reference.get(id)?.ids[0] ?? squash(store.reference.get(id)?.name ?? id).slice(0, 30)).join(', ')} (${places.map((p) => store.reference.get(p)?.name.split(NAME_SEP)[0]?.trim() ?? p).join(', ')})`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const sorted = [...tally].sort((a, b) => b[1] - a[1]);
|
|
518
|
+
const suggest = sorted.map(([id, n]) => `${store.reference.get(id)?.name.split(NAME_SEP)[0]?.trim() ?? id}${store.reference.get(id)?.category === 'Plan' ? ' (plan)' : ''} ×${n}`);
|
|
519
|
+
return { names, cited, citations, suggest, leads: sorted.map(([id, count]) => ({ id, count })) };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export interface LineIndex {
|
|
523
|
+
/** The items standing at a line of a current document: a contract file's own contract, else the items whose narrowest source holds the line. */
|
|
524
|
+
readonly itemsAt: (path: string, line: number) => string[];
|
|
525
|
+
/**
|
|
526
|
+
* A dated record: a decision log, a report or receipt, an archive (what the layer map says, or by name). CQ (D104): a
|
|
527
|
+
* table row of an execution arrangement the layer map marks not current is not dated — the rows are the task index of
|
|
528
|
+
* their time and still place the work they list; give the line (`context`) to have it read that way. Its decision
|
|
529
|
+
* entries, and everything else in it, stay dated.
|
|
530
|
+
*/
|
|
531
|
+
readonly dated: (path: string, context?: string) => boolean;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Which items stand where in the project's documents, read once from the sources (paths as the ledger writes them). */
|
|
535
|
+
export function lineIndex(store: ProjectStore): LineIndex {
|
|
536
|
+
const roots = [...new Set(store.sources.all().flatMap((s) => (s.anchor.kind === 'file' ? [s.anchor.path] : [])))];
|
|
537
|
+
const layers = store.layers.all().map((l) => ({ path: slash(l.path).replace(/\/+$/, ''), repo: slash(l.repo ?? '').replace(/\/+$/, ''), layer: l.layer, current: l.current }));
|
|
538
|
+
const layerOf = (rel: string) => layers.filter((l) => rel === l.path || rel.startsWith(`${l.path}/`)).sort((a, b) => b.path.length - a.path.length)[0] ?? null;
|
|
539
|
+
const dated = (rel: string, context?: string) => {
|
|
540
|
+
const l = layerOf(rel);
|
|
541
|
+
if (!l) return /(^|\/)(DECISIONS?|ADR|decision[-_ ]?log)[^/]*\.md$/i.test(rel) || /(^|\/)archive(\/|$)/i.test(rel);
|
|
542
|
+
if (l.layer === 'Decision record' || l.layer === 'QC and receipts') return true;
|
|
543
|
+
if (l.current) return false;
|
|
544
|
+
return !(l.layer === 'Execution arrangement' && context !== undefined && /^\s*\|/.test(context));
|
|
545
|
+
};
|
|
546
|
+
// The sources of each file, keyed by the end of its path (the ledger's paths are repository-relative).
|
|
547
|
+
const byFile = new Map<string, { id: string; from: number; to: number }[]>();
|
|
548
|
+
for (const s of store.sources.all()) {
|
|
549
|
+
if (s.anchor.kind !== 'file' || s.availability === 'No longer available') continue;
|
|
550
|
+
const key = slash(s.anchor.path).toLowerCase();
|
|
551
|
+
byFile.set(key, [...(byFile.get(key) ?? []), { id: s.id, from: s.anchor.lineStart, to: s.anchor.lineEnd }]);
|
|
552
|
+
}
|
|
553
|
+
const fileOf = new Map<string, string | null>();
|
|
554
|
+
const sourcesOf = (rel: string) => {
|
|
555
|
+
if (!fileOf.has(rel)) fileOf.set(rel, [...byFile.keys()].find((k) => k.endsWith(`/${rel.toLowerCase()}`)) ?? null);
|
|
556
|
+
const key = fileOf.get(rel);
|
|
557
|
+
return key ? byFile.get(key)! : [];
|
|
558
|
+
};
|
|
559
|
+
const bySource = new Map<string, string[]>();
|
|
560
|
+
for (const r of store.reference.all()) if (!GONE.has(r.validity) && r.category !== "Owner's words") for (const sid of r.sourceIds) bySource.set(sid, [...(bySource.get(sid) ?? []), r.id]);
|
|
561
|
+
void roots;
|
|
562
|
+
const itemsAt = (rel: string, line: number): string[] => {
|
|
563
|
+
// A contract's own file: the contract it defines.
|
|
564
|
+
const nums = definitionsInPath(rel).map((d) => d.num.toUpperCase());
|
|
565
|
+
if (nums.length) {
|
|
566
|
+
const threads = store.threads.filter((t) => !GONE.has(t.validity) && t.ids.some((i) => nums.includes(i.toUpperCase()))).map((t) => t.id);
|
|
567
|
+
if (threads.length) return threads;
|
|
568
|
+
}
|
|
569
|
+
const holding = sourcesOf(rel).filter((s) => s.from <= line && line <= s.to).sort((a, b) => (a.to - a.from) - (b.to - b.from));
|
|
570
|
+
for (const s of holding) {
|
|
571
|
+
const items = bySource.get(s.id) ?? [];
|
|
572
|
+
if (items.length) return items;
|
|
573
|
+
}
|
|
574
|
+
return [];
|
|
575
|
+
};
|
|
576
|
+
return { itemsAt, dated };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ───────────────────────── work for its whole plan; a plan through decisions (CN, E152) ─────────────────────────
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Two things the program reads off the project's own writing for a work item that is not placed (CN; E152, from the CM
|
|
583
|
+
* run, where four work items stayed "unplaced"):
|
|
584
|
+
*
|
|
585
|
+
* - **Work for its whole plan.** A milestone QC "(CKC-01~CKC-12)" and an isolated reading of the whole graph are in a
|
|
586
|
+
* plan and belong to no single module. When a range the ticket writes — in its title or what it does, in its own
|
|
587
|
+
* prompt, or the contracts it implements — spans `WHOLE_PLAN_MODULES` modules or more, the program suggests recording
|
|
588
|
+
* it as serving its whole plan (`pk_write_thread` `wholePlanWhy`).
|
|
589
|
+
* - **A plan through decisions.** Two audits "交付已按 D57 归档" had no plan, though the decision they carry out is one
|
|
590
|
+
* plan's trial arrangement. For a work item in no plan the program follows the decisions it carries out, cites, or is
|
|
591
|
+
* cited by to the plan they lead to (`planThroughDecisions`).
|
|
592
|
+
*
|
|
593
|
+
* Both are suggestions with what they rest on; nothing is placed by them.
|
|
594
|
+
*/
|
|
595
|
+
export const WHOLE_PLAN_MODULES = 3;
|
|
596
|
+
|
|
597
|
+
/** What a work item says of itself: its title and what it does. */
|
|
598
|
+
const ownWords = (t: WorkThread): string => [t.title, t.doing].filter(Boolean).join('\n');
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* The lines of a ticket's own writing in the ledger: every line of the document that is its own (the file's name and
|
|
602
|
+
* its front matter, heading or bold entry both define the number: a prompt `AC-….md` with `id: "AC"`), and the row that
|
|
603
|
+
* defines it in a shared index. Each line as the ledger keeps it (its context), current versions only.
|
|
604
|
+
*/
|
|
605
|
+
function ticketLines(ledger: Ledger | null, nums: readonly string[]): { own: string[]; rows: string[] } {
|
|
606
|
+
const own: string[] = [];
|
|
607
|
+
const rows: string[] = [];
|
|
608
|
+
if (!ledger) return { own, rows };
|
|
609
|
+
for (const num of nums) {
|
|
610
|
+
let defs: { repo: string | null; path: string | null; context: string }[] = [];
|
|
611
|
+
try { defs = ledger.db.prepare("SELECT repo, path, context FROM nums WHERE num = ? AND place = 'definition' AND kind = 'doc' AND current = 1 AND path IS NOT NULL").all(num) as typeof defs; } catch { defs = []; }
|
|
612
|
+
for (const d of defs) {
|
|
613
|
+
const base = d.path!.split('/').pop() ?? '';
|
|
614
|
+
if (definitionsInPath(base).some((x) => x.num.toUpperCase() === num.toUpperCase())) {
|
|
615
|
+
let lines: { context: string }[] = [];
|
|
616
|
+
try { lines = ledger.db.prepare("SELECT DISTINCT line, context FROM nums WHERE repo IS ? AND path = ? AND kind = 'doc' AND current = 1 ORDER BY line").all(d.repo, d.path) as typeof lines; } catch { lines = []; }
|
|
617
|
+
for (const l of lines) if (!own.includes(l.context)) own.push(l.context);
|
|
618
|
+
} else if (!rows.includes(d.context)) rows.push(d.context);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return { own, rows };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export interface WholePlanSuggestion {
|
|
625
|
+
/** The range as written (`CKC-01~CKC-12`), or the contracts it implements. */
|
|
626
|
+
readonly range: string;
|
|
627
|
+
/** Where the range stands: the work item's own words, its ticket's own document, or its `implements` relations. */
|
|
628
|
+
readonly from: 'its title' | 'its ticket' | 'the contracts it implements';
|
|
629
|
+
/** The modules the range's items sit in, by short name, in the order met. */
|
|
630
|
+
readonly modules: readonly string[];
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Whether a work item's own writing spans `WHOLE_PLAN_MODULES` modules or more (see above); null when it does not.
|
|
635
|
+
* A module is the main Area of each item the range names (a contract's, a requirement's).
|
|
636
|
+
*/
|
|
637
|
+
export function wholePlanSuggestion(store: ProjectStore, ledger: Ledger | null, t: WorkThread, shared: { readonly reach?: Reach; readonly index?: TraceIndex } = {}): WholePlanSuggestion | null {
|
|
638
|
+
const reach = shared.reach ?? reachOf(store);
|
|
639
|
+
const index = shared.index ?? traceIndex(store, reach);
|
|
640
|
+
const short = (id: string) => store.reference.get(id)?.name.split(NAME_SEP)[0]?.trim() ?? id;
|
|
641
|
+
const moduleOf = (id: string): string | null => {
|
|
642
|
+
const thread = store.threads.get(id);
|
|
643
|
+
if (thread) return thread.id === t.id ? null : reach.ofThread(thread).areas[0] ?? null;
|
|
644
|
+
const r = store.reference.get(id);
|
|
645
|
+
if (!r) return null;
|
|
646
|
+
return r.category === 'Area' ? r.id : reach.ofReference(id).areas[0] ?? null;
|
|
647
|
+
};
|
|
648
|
+
const modulesOf = (ids: readonly string[]): string[] => [...new Set(ids.map(moduleOf).filter((a): a is string => a !== null))];
|
|
649
|
+
const spans = (text: string, from: WholePlanSuggestion['from']): WholePlanSuggestion | null => {
|
|
650
|
+
for (const r of rangesIn(text)) {
|
|
651
|
+
const modules = modulesOf(r.members.flatMap((n) => index.resolve(n)));
|
|
652
|
+
if (modules.length >= WHOLE_PLAN_MODULES) return { range: r.written, from, modules: modules.map(short) };
|
|
653
|
+
}
|
|
654
|
+
return null;
|
|
655
|
+
};
|
|
656
|
+
const own = spans(ownWords(t), 'its title');
|
|
657
|
+
if (own) return own;
|
|
658
|
+
const lines = ticketLines(ledger, t.ids);
|
|
659
|
+
for (const line of [...lines.rows, ...lines.own]) { const hit = spans(line, 'its ticket'); if (hit) return hit; }
|
|
660
|
+
const contracts = store.relations.filter((r) => r.type === 'implements' && r.from === t.id).map((r) => r.to);
|
|
661
|
+
const modules = modulesOf(contracts);
|
|
662
|
+
if (modules.length >= WHOLE_PLAN_MODULES) {
|
|
663
|
+
return { range: contracts.map((c) => store.threads.get(c)?.ids[0] ?? store.reference.get(c)?.ids[0] ?? c).join(', '), from: 'the contracts it implements', modules: modules.map(short) };
|
|
664
|
+
}
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export interface PlanSuggestion {
|
|
669
|
+
readonly planId: string;
|
|
670
|
+
/** The plan by its short name (`P1`). */
|
|
671
|
+
readonly plan: string;
|
|
672
|
+
/** The decisions that lead to it, each with how the work item meets it: `D57 (cites)`, `E40 (cited by)`. */
|
|
673
|
+
readonly through: readonly string[];
|
|
674
|
+
readonly weight: number;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** How much each way of meeting a decision counts: what the work item carries out, what it cites, what cites it. */
|
|
678
|
+
const THROUGH_WEIGHT = { 'carries out': 3, cites: 2, 'cited by': 1 } as const;
|
|
679
|
+
type Through = keyof typeof THROUGH_WEIGHT;
|
|
680
|
+
|
|
681
|
+
/** The lines of an item's own entry in its sources (as `entryNames` cuts them); its own text when no source holds them. */
|
|
682
|
+
export function entryTextOf(store: ProjectStore, item: ReferenceItem): string {
|
|
683
|
+
const num = item.ids[0]?.toUpperCase() ?? null;
|
|
684
|
+
const texts: string[] = [];
|
|
685
|
+
for (const sid of item.sourceIds) {
|
|
686
|
+
const src = store.sources.get(sid);
|
|
687
|
+
if (!src) continue;
|
|
688
|
+
const lines = src.excerpt.split(/\r?\n/);
|
|
689
|
+
const defs = definitionsInText(src.excerpt).filter((d) => d.line !== null);
|
|
690
|
+
const own = num ? defs.filter((d) => d.num === num) : [];
|
|
691
|
+
for (const o of own) {
|
|
692
|
+
const next = defs.find((d) => d.line! > o.line! && d.num !== num);
|
|
693
|
+
texts.push(lines.slice(o.line! - 1, next ? next.line! - 1 : lines.length).join('\n'));
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return texts.length ? texts.join('\n') : item.text;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* The plans the decisions a work item meets lead to, heaviest first (see above). A decision leads to the plan it sits on
|
|
701
|
+
* (it refines the Plan, or what it refines does); else to the plans of what its own entry names (a contract, a
|
|
702
|
+
* requirement); else to the plans of the decisions its entry cites, one step further (D57 「按 D56 的新口径」, D56's entry
|
|
703
|
+
* names the contracts of one plan). Decisions are met three ways: the work item carries one out (a relation it wrote),
|
|
704
|
+
* cites one (in its title, what it does, or its row in the index), or is cited by one (a decision's entry names its
|
|
705
|
+
* number; the ledger's lines, read where a decision stands).
|
|
706
|
+
*/
|
|
707
|
+
export function planThroughDecisions(store: ProjectStore, ledger: Ledger | null, t: WorkThread, shared: { readonly reach?: Reach; readonly index?: TraceIndex; readonly lines?: LineIndex; readonly time?: Chronology; readonly dropped?: string[] } = {}): PlanSuggestion[] {
|
|
708
|
+
const reach = shared.reach ?? reachOf(store);
|
|
709
|
+
const index = shared.index ?? traceIndex(store, reach, { ledger });
|
|
710
|
+
// CR: with the project's chronology, a citation counts only when what it names existed at the citing record's date, and
|
|
711
|
+
// an execution decision leads to no plan first written after it.
|
|
712
|
+
const time = shared.time ?? null;
|
|
713
|
+
const drop = (note: string) => { if (shared.dropped && !shared.dropped.includes(note)) shared.dropped.push(note); };
|
|
714
|
+
const tName = t.ids[0] ?? squash(t.title).slice(0, 30);
|
|
715
|
+
const after = (cited: string | null, citing: string | null): boolean => !!cited && !!citing && laterThan(cited, citing);
|
|
716
|
+
// CQ (D104): the numbers met are the project's own (`numberMatcher`), not a fixed shape.
|
|
717
|
+
const numbersOf = index.numbers;
|
|
718
|
+
const isDecision = (r: ReferenceItem | undefined): r is ReferenceItem => !!r && (r.category === 'Decision' || r.category === 'Boundary') && !GONE.has(r.validity);
|
|
719
|
+
const decisions = store.reference.filter((r) => isDecision(r));
|
|
720
|
+
const byNumber = new Map<string, ReferenceItem>();
|
|
721
|
+
for (const d of decisions) for (const i of d.ids) if (!byNumber.has(i.toUpperCase())) byNumber.set(i.toUpperCase(), d);
|
|
722
|
+
const own = new Set(t.ids.map((i) => i.toUpperCase()));
|
|
723
|
+
const entries = new Map<string, string>();
|
|
724
|
+
const entryOf = (d: ReferenceItem): string => { let e = entries.get(d.id); if (e === undefined) { e = entryTextOf(store, d); entries.set(d.id, e); } return e; };
|
|
725
|
+
|
|
726
|
+
// The decisions it meets, and how (the strongest way wins).
|
|
727
|
+
const met = new Map<string, Through>();
|
|
728
|
+
const meet = (id: string, how: Through) => { const was = met.get(id); if (!was || THROUGH_WEIGHT[how] > THROUGH_WEIGHT[was]) met.set(id, how); };
|
|
729
|
+
for (const id of [...t.serves.map((s) => s.referenceId), ...store.relations.filter((r) => r.from === t.id && (r.type === 'carries out' || r.type === 'serves' || r.type === 'implements' || r.type === 'refines' || r.type === 'depends on')).map((r) => r.to)]) {
|
|
730
|
+
if (isDecision(store.reference.get(id))) meet(id, 'carries out');
|
|
731
|
+
}
|
|
732
|
+
const ticket = ticketLines(ledger, t.ids);
|
|
733
|
+
const records = time?.current(t.id) ?? null;
|
|
734
|
+
for (const n of numbersOf([ownWords(t), t.results, ...ticket.rows].join('\n'))) {
|
|
735
|
+
const d = byNumber.get(n);
|
|
736
|
+
if (!d) continue;
|
|
737
|
+
const first = time?.first(d.id) ?? null;
|
|
738
|
+
if (after(first, records)) { drop(`${tName} names ${n}, first written ${first}, after its records of ${records}`); continue; }
|
|
739
|
+
meet(d.id, 'cites');
|
|
740
|
+
}
|
|
741
|
+
// A decision that names this work item before it was first written does not cite it: the token is something else.
|
|
742
|
+
const tFirst = time?.first(t.id) ?? null;
|
|
743
|
+
const citedBy = (d: ReferenceItem): boolean => {
|
|
744
|
+
const own = tFirst ? time!.own(d.id) : null;
|
|
745
|
+
if (!citedAfter(tFirst, own, entryOf(d), tName)) return true;
|
|
746
|
+
drop(`${d.ids[0] ?? squash(d.name).slice(0, 30)}'s entry names ${tName}, first written ${tFirst}, after that entry of ${own}`);
|
|
747
|
+
return false;
|
|
748
|
+
};
|
|
749
|
+
if (own.size) {
|
|
750
|
+
const naming = new RegExp(`(?<![A-Za-z0-9_-])(?:${[...own].map(esc).join('|')})(?![A-Za-z0-9_])`);
|
|
751
|
+
let viaLedger = false;
|
|
752
|
+
if (ledger) {
|
|
753
|
+
const lines = shared.lines ?? lineIndex(store);
|
|
754
|
+
for (const num of own) {
|
|
755
|
+
let rows: { path: string; line: number }[] = [];
|
|
756
|
+
try { rows = ledger.db.prepare("SELECT path, line FROM nums WHERE num = ? AND kind = 'doc' AND current = 1 AND place != 'definition' AND path IS NOT NULL AND line IS NOT NULL").all(num) as typeof rows; } catch { rows = []; }
|
|
757
|
+
// A source often holds a section of many entries: of the decisions standing there, the one whose own entry names the number.
|
|
758
|
+
for (const row of rows) for (const id of lines.itemsAt(row.path, row.line)) { const d = store.reference.get(id); if (isDecision(d) && naming.test(entryOf(d))) { viaLedger = true; if (citedBy(d)) meet(id, 'cited by'); } }
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
// Without the ledger's lines (or when they place none): a decision whose own entry names the number.
|
|
762
|
+
if (!viaLedger) for (const d of decisions) if (naming.test(entryOf(d)) && citedBy(d)) meet(d.id, 'cited by');
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Where each decision leads.
|
|
766
|
+
const plansOfItem = (id: string): string[] => {
|
|
767
|
+
const thread = store.threads.get(id);
|
|
768
|
+
if (thread) return thread.id === t.id ? [] : reach.ofThread(thread).plans;
|
|
769
|
+
const r = store.reference.get(id);
|
|
770
|
+
if (!r) return [];
|
|
771
|
+
return r.category === 'Plan' ? [r.id] : reach.ofReference(id).plans;
|
|
772
|
+
};
|
|
773
|
+
const leads = (d: ReferenceItem, depth: number, seen: Set<string>): string[] => {
|
|
774
|
+
if (seen.has(d.id)) return [];
|
|
775
|
+
seen.add(d.id);
|
|
776
|
+
const label = d.ids[0] ?? squash(d.name).slice(0, 30);
|
|
777
|
+
const own = time?.own(d.id) ?? null;
|
|
778
|
+
const existed = (n: string, id: string): boolean => {
|
|
779
|
+
const first = own ? time!.first(id) : null;
|
|
780
|
+
if (!citedAfter(first, own, entryOf(d), n)) return true;
|
|
781
|
+
drop(`${label}'s entry names ${n}, first written ${first}, after ${label}'s entry of ${own}`);
|
|
782
|
+
return false;
|
|
783
|
+
};
|
|
784
|
+
// An execution decision executed a plan that existed at its date.
|
|
785
|
+
const execution = time?.executionEntry(d) ?? false;
|
|
786
|
+
const inTime = (plans: readonly string[], how: string): string[] => plans.filter((p) => {
|
|
787
|
+
const first = execution && own ? time!.first(p) : null;
|
|
788
|
+
if (!after(first, own)) return true;
|
|
789
|
+
drop(`${label} leads to ${store.reference.get(p)?.name.split(NAME_SEP)[0]?.trim() ?? p} ${how}; that plan was first written ${first}, after ${label}'s entry of ${own}`);
|
|
790
|
+
return false;
|
|
791
|
+
});
|
|
792
|
+
const direct = reach.ofReference(d.id).plans;
|
|
793
|
+
if (direct.length) return inTime(direct, 'by where it is placed');
|
|
794
|
+
const entry = entryOf(d);
|
|
795
|
+
const tally = new Map<string, number>();
|
|
796
|
+
for (const n of entryNames(store, d, index)) for (const p of new Set(index.resolve(n).filter((id) => existed(n, id)).flatMap(plansOfItem))) tally.set(p, (tally.get(p) ?? 0) + 1);
|
|
797
|
+
if (tally.size) { const top = inTime([...tally].sort((a, b) => b[1] - a[1]).map(([p]) => p).slice(0, 1), 'through what its entry names'); if (top.length) return top; }
|
|
798
|
+
if (depth >= 2) return [];
|
|
799
|
+
for (const n of numbersOf(entry)) {
|
|
800
|
+
const next = byNumber.get(n);
|
|
801
|
+
if (!next || next.id === d.id || !existed(n, next.id)) continue;
|
|
802
|
+
const via = inTime(leads(next, depth + 1, seen), `through ${n}`);
|
|
803
|
+
if (via.length) return via;
|
|
804
|
+
}
|
|
805
|
+
return [];
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
const out = new Map<string, { weight: number; through: string[] }>();
|
|
809
|
+
for (const [id, how] of met) {
|
|
810
|
+
const d = store.reference.get(id)!;
|
|
811
|
+
for (const p of leads(d, 0, new Set())) {
|
|
812
|
+
const at = out.get(p) ?? { weight: 0, through: [] };
|
|
813
|
+
at.weight += THROUGH_WEIGHT[how];
|
|
814
|
+
at.through.push(`${d.ids[0] ?? squash(d.name).slice(0, 30)} (${how})`);
|
|
815
|
+
out.set(p, at);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
const rank = (s: string) => (s.endsWith('(carries out)') ? 0 : s.endsWith('(cites)') ? 1 : 2);
|
|
819
|
+
return [...out].map(([planId, v]) => ({ planId, plan: store.reference.get(planId)?.name.split(NAME_SEP)[0]?.trim() ?? planId, through: v.through.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b, undefined, { numeric: true })), weight: v.weight }))
|
|
820
|
+
.sort((a, b) => b.weight - a.weight || a.plan.localeCompare(b.plan, undefined, { numeric: true }));
|
|
821
|
+
}
|