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,746 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Breakpoints (Spec v3.0 §2.12 断点): where the next step of the flow should be there and has no trace. An observation on
|
|
3
|
+
* an object, not a ticket: later evidence puts it out by itself; the owner's `No action needed` stops it lighting up and
|
|
4
|
+
* stays; whether the next step is due follows the project's own rules (§1.15 `expects`; D72: ProjectKeeper adds no steps
|
|
5
|
+
* to a project and asks nobody to write more for it).
|
|
6
|
+
*
|
|
7
|
+
* | kind | on | lit when | six (§2.13) | needs |
|
|
8
|
+
* | --- | --- | --- | --- | --- |
|
|
9
|
+
* | `Not planned` | owner's words, requirements | nothing downstream reaches a plan item or a work item; not deferred or abandoned | 3 | `Written plan` |
|
|
10
|
+
* | `Not started` | planned work | its batch has started; no dispatch, branch or commit of its own | — | — |
|
|
11
|
+
* | `Not merged` | work with commits that says it is done | its commits are not on the trunk; not written as an experiment or abandoned | — | — |
|
|
12
|
+
* | `No trace of done` | work reported done | no commit and no product of its own in the ledger | — | — |
|
|
13
|
+
* | `Not checked` | work the rules say is checked | no independent check at all | — | `Independent check` |
|
|
14
|
+
* | `Findings open` | a check's findings | no fix, send-back or disposition afterwards | 5 | — |
|
|
15
|
+
* | `Passed with open items` | a check that passed | its report leaves items for later that nothing followed up | 5 | — |
|
|
16
|
+
* | `Fix not re-checked` | a fix the rules say is checked again | no check after the fix | 5 | `Re-check after fix` |
|
|
17
|
+
* | `No plan` | work merged into the trunk | traces to no plan item, owner's words or decision | 4 | `Written plan` |
|
|
18
|
+
* | `Downstream behind` | current downstream of a superseded version | a current object still cites what was superseded | 1 | — (Inferred) |
|
|
19
|
+
* | `Not carried out` | a decision with a date or a precondition | due, and no later trace | 2 | — (Inferred) |
|
|
20
|
+
*
|
|
21
|
+
* `basis` is `Explicit` when the ledger's ids alone show it, `Inferred` when it rests on a judged link or a judged
|
|
22
|
+
* relation, for the round's steps to confirm or refute (§3.3). The ids are stable per kind and object.
|
|
23
|
+
*
|
|
24
|
+
* D99 (§2.12 没有痕迹,要读过才算; CKC-24 AC-7, CKC-23 AC-22): "lit when" above is when the program computes a candidate.
|
|
25
|
+
* A candidate lights only after a lane looked for the missing step and did not find it (pk_record_looked, `looked`) and
|
|
26
|
+
* the round's independent spot-check confirmed it (pk_confirm, `checked`); a First usable round lights nothing.
|
|
27
|
+
* `breakpoint-candidates.ts` holds the lighting rule, the spot-check's targets and the start-up fix.
|
|
28
|
+
*/
|
|
29
|
+
import type { Breakpoint, BreakpointKind, EvidenceRef, Occurred, SixThing } from '../model/k-types.ts';
|
|
30
|
+
import type { ProjectRule, ReferenceItem, WorkThread } from '../model/types.ts';
|
|
31
|
+
import type { ProjectStore } from '../store/project-store.ts';
|
|
32
|
+
import { stableId } from '../model/ids.ts';
|
|
33
|
+
import { datesOnLine, msOf } from '../ledger/time.ts';
|
|
34
|
+
import { replacedNumbers } from '../ledger/lines.ts';
|
|
35
|
+
import { itemRanges } from '../ledger/item-range.ts';
|
|
36
|
+
|
|
37
|
+
import type { Analysis } from './analysis.ts';
|
|
38
|
+
import { msOfOccurred } from './facts.ts';
|
|
39
|
+
import { batchOf, workOfPlan } from './placement.ts';
|
|
40
|
+
import { clip, standsAlone } from './text.ts';
|
|
41
|
+
import { earlierGenerationOf, earlierWork } from '../keeper/organize/carried-on.ts';
|
|
42
|
+
import { startsSendBack, type WorkProcess } from './work.ts';
|
|
43
|
+
|
|
44
|
+
/** §2.12's table: the six things each kind is material for. */
|
|
45
|
+
export const SIX_OF: Readonly<Record<BreakpointKind, SixThing | null>> = {
|
|
46
|
+
'Not planned': 3, 'Not started': null, 'Not merged': null, 'No trace of done': null, 'Not checked': null, 'Findings open': 5,
|
|
47
|
+
'Passed with open items': 5, 'Fix not re-checked': 5, 'No plan': 4, 'Downstream behind': 1, 'Not carried out': 2,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const BREAKPOINT_KINDS: readonly BreakpointKind[] = Object.keys(SIX_OF) as BreakpointKind[];
|
|
51
|
+
|
|
52
|
+
export interface Candidate {
|
|
53
|
+
readonly kind: BreakpointKind;
|
|
54
|
+
readonly targetId: string;
|
|
55
|
+
readonly why: string;
|
|
56
|
+
readonly evidence: readonly EvidenceRef[];
|
|
57
|
+
readonly basis: 'Explicit' | 'Inferred';
|
|
58
|
+
readonly since: Occurred;
|
|
59
|
+
readonly familyBasis?: Breakpoint['familyBasis'];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What the computation found for one kind and object: lit, or out with the evidence that the step did happen. */
|
|
63
|
+
export type Finding = { readonly lit: Candidate } | { readonly out: readonly EvidenceRef[]; readonly kind: BreakpointKind; readonly targetId: string };
|
|
64
|
+
|
|
65
|
+
export const breakpointId = (projectId: string, kind: BreakpointKind, targetId: string): string => stableId('bp', projectId, kind, targetId);
|
|
66
|
+
const keyOf = (kind: BreakpointKind, targetId: string): string => `${kind}\x1f${targetId}`;
|
|
67
|
+
|
|
68
|
+
// ───────────────────────── evidence helpers ─────────────────────────
|
|
69
|
+
|
|
70
|
+
function objectRef(store: ProjectStore, id: string, line: string | null = null, occurred: Occurred | null = null): EvidenceRef {
|
|
71
|
+
const t = store.threads.get(id);
|
|
72
|
+
const r = t ? undefined : store.reference.get(id);
|
|
73
|
+
const rule = t || r ? undefined : store.rules.get(id);
|
|
74
|
+
const label = t ? `${t.ids[0] ? `${t.ids[0]} ` : ''}${t.title}` : r ? `${r.category}: ${r.name}` : rule ? `Rule: ${rule.summary}` : id;
|
|
75
|
+
return { kind: 'object', id, label, line: line ?? (r?.quote ? clip(r.quote, 200) : rule?.excerpt ? clip(rule.excerpt, 200) : null), occurred };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const undated = (at: string, anchor: string | null): Occurred => ({ at, basis: 'First observed', anchor, undated: true });
|
|
79
|
+
|
|
80
|
+
/** When a reference item's material happened: its first source's time (a session message, a commit, the document version). */
|
|
81
|
+
function itemOccurred(a: Analysis, item: ReferenceItem): Occurred {
|
|
82
|
+
const { store, facts } = a;
|
|
83
|
+
for (const sid of item.sourceIds) {
|
|
84
|
+
const s = store.sources.get(sid);
|
|
85
|
+
if (!s) continue;
|
|
86
|
+
const an = s.anchor;
|
|
87
|
+
if (an.kind === 'session' && an.at) return { at: an.at, basis: 'Session', anchor: sid };
|
|
88
|
+
if (an.kind === 'commit') { const c = facts.commitByPrefix(an.commit); if (c) return c.occurred; }
|
|
89
|
+
if (an.kind === 'revision') { const c = facts.commitByPrefix(an.commit); if (c) return c.occurred; }
|
|
90
|
+
if (an.kind === 'file') {
|
|
91
|
+
const at = facts.relPathOf(an.path);
|
|
92
|
+
if (!at) continue;
|
|
93
|
+
const versions = (facts.docsByPath.get(at.path) ?? []).filter((d) => d.repo === at.repo);
|
|
94
|
+
const section = an.headingPath.join(' › ');
|
|
95
|
+
const first = versions.find((d) => section && (d.added.includes(section) || d.changed.includes(section))) ?? versions[0];
|
|
96
|
+
if (first) return facts.entry(first.id)?.occurred ?? { at: new Date(first.ms).toISOString(), basis: 'Commit', anchor: first.id };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return undated(item.asOf || item.updatedAt, item.id);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ───────────────────────── which work the breakpoints about work look at ─────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Current work of the current generation: struck work and earlier generations light nothing (their end closed them). CZ:
|
|
106
|
+
* an item an earlier generation lists that was carried on under the same number is current work, and may light.
|
|
107
|
+
*/
|
|
108
|
+
function liveWork(a: Analysis): WorkThread[] {
|
|
109
|
+
const gen = earlierWork(a.store);
|
|
110
|
+
return a.store.threads.filter((t) => t.validity === 'Current' && !gen.has(t.id));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The work item a check's findings hang on: the check's own work item, else the work it checks, else the chain's root. */
|
|
114
|
+
function checkTarget(a: Analysis, checkUnit: string, root: WorkThread): string {
|
|
115
|
+
const own = a.index.threadOf.get(checkUnit);
|
|
116
|
+
if (own && a.store.threads.get(own)?.validity === 'Current') return own;
|
|
117
|
+
const u = a.index.units.get(checkUnit);
|
|
118
|
+
for (const r of u?.relations ?? []) { const t = a.index.threadOf.get(r.num); if (t) return t; }
|
|
119
|
+
return root.id;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ───────────────────────── the eleven kinds ─────────────────────────
|
|
123
|
+
|
|
124
|
+
export function computeFindings(a: Analysis): Finding[] {
|
|
125
|
+
const out: Finding[] = [];
|
|
126
|
+
const work = liveWork(a);
|
|
127
|
+
const processes = new Map(work.map((t) => [t.id, a.process(t.id)!]));
|
|
128
|
+
out.push(...notPlanned(a));
|
|
129
|
+
out.push(...notStarted(a, work, processes));
|
|
130
|
+
for (const t of work) out.push(...aboutWork(a, t, processes.get(t.id)!));
|
|
131
|
+
out.push(...findingsOpen(a, work, processes));
|
|
132
|
+
out.push(...passedWithOpenItems(a, work, processes));
|
|
133
|
+
out.push(...fixNotRechecked(a, work, processes));
|
|
134
|
+
out.push(...downstreamBehind(a));
|
|
135
|
+
out.push(...notCarriedOut(a));
|
|
136
|
+
// One result per kind and object: a lit one wins over an out one computed from another chain.
|
|
137
|
+
const merged = new Map<string, Finding>();
|
|
138
|
+
for (const f of out) {
|
|
139
|
+
const k = 'lit' in f ? keyOf(f.lit.kind, f.lit.targetId) : keyOf(f.kind, f.targetId);
|
|
140
|
+
const prev = merged.get(k);
|
|
141
|
+
if (!prev) { merged.set(k, f); continue; }
|
|
142
|
+
if ('lit' in f && 'lit' in prev) merged.set(k, { lit: { ...prev.lit, evidence: dedupe([...prev.lit.evidence, ...f.lit.evidence]) } });
|
|
143
|
+
else if ('lit' in f) merged.set(k, f);
|
|
144
|
+
}
|
|
145
|
+
return [...merged.values()];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const dedupe = (xs: readonly EvidenceRef[]): EvidenceRef[] => [...new Map(xs.map((e) => [`${e.kind}\x1f${e.id}\x1f${e.line ?? ''}`, e])).values()];
|
|
149
|
+
|
|
150
|
+
/** `Not planned`: owner's words and requirements nothing downstream brings to a plan item or a piece of work. */
|
|
151
|
+
function notPlanned(a: Analysis): Finding[] {
|
|
152
|
+
const { store } = a;
|
|
153
|
+
const below = downstreamIndex(store);
|
|
154
|
+
const rules = store.rules.filter((r) => r.validity === 'Current' && r.group === 'How work is organized' && (r.expects ?? []).includes('Written plan'));
|
|
155
|
+
const items = store.reference.filter((r) => r.category === "Owner's words" || r.category === 'Requirement');
|
|
156
|
+
const out: Finding[] = [];
|
|
157
|
+
for (const item of items) {
|
|
158
|
+
const rule = rules.find((r) => ruleCoversItem(r, item));
|
|
159
|
+
if (!rule || item.validity !== 'Current') {
|
|
160
|
+
out.push({ out: [objectRef(store, rule ? item.id : (rules[0]?.id ?? item.id))], kind: 'Not planned', targetId: item.id });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const reached = downstreamPlanOrWork(store, below, item.id);
|
|
164
|
+
if (reached) { out.push({ out: [objectRef(store, reached)], kind: 'Not planned', targetId: item.id }); continue; }
|
|
165
|
+
out.push({ lit: {
|
|
166
|
+
kind: 'Not planned', targetId: item.id, basis: 'Inferred', since: itemOccurred(a, item),
|
|
167
|
+
why: `${item.category === "Owner's words" ? 'The owner\'s words' : 'The requirement'} “${clip(item.name, 60)}” lead to no plan item and no work item, and nothing defers or abandons ${item.category === "Owner's words" ? 'them' : 'it'}`,
|
|
168
|
+
evidence: [objectRef(store, item.id), ...item.sourceIds.slice(0, 1).map((s) => ({ kind: 'source' as const, id: s, label: store.sources.get(s)?.title ?? s, line: null, occurred: null })), objectRef(store, rule.id)],
|
|
169
|
+
} });
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function ruleCoversItem(rule: ProjectRule, item: ReferenceItem): boolean {
|
|
175
|
+
if (rule.appliesTo.length === 0) return true;
|
|
176
|
+
const text = `${item.name} ${item.ids.join(' ')} ${item.category}`.toLowerCase();
|
|
177
|
+
return rule.appliesTo.some((e) => e.trim() && (item.id === e || text.includes(e.trim().toLowerCase())));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** What lies directly downstream of each object: what refines it, serves it, implements or carries it out. */
|
|
181
|
+
function downstreamIndex(store: ProjectStore): Map<string, string[]> {
|
|
182
|
+
const below = new Map<string, string[]>();
|
|
183
|
+
const add = (up: string, down: string) => below.set(up, [...(below.get(up) ?? []), down]);
|
|
184
|
+
for (const r of store.reference.all()) for (const up of r.refines) add(up, r.id);
|
|
185
|
+
for (const t of store.threads.all()) for (const s of t.serves) add(s.referenceId, t.id);
|
|
186
|
+
for (const rel of store.relations.all()) if (['serves', 'implements', 'refines', 'carries out', 'verifies'].includes(rel.type)) add(rel.to, rel.from);
|
|
187
|
+
return below;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The first plan item or piece of work reached going downstream. */
|
|
191
|
+
function downstreamPlanOrWork(store: ProjectStore, below: ReadonlyMap<string, readonly string[]>, fromId: string): string | null {
|
|
192
|
+
const seen = new Set<string>([fromId]);
|
|
193
|
+
const queue = [fromId];
|
|
194
|
+
while (queue.length) {
|
|
195
|
+
const id = queue.shift()!;
|
|
196
|
+
for (const n of below.get(id) ?? []) {
|
|
197
|
+
if (seen.has(n)) continue;
|
|
198
|
+
seen.add(n);
|
|
199
|
+
const t = store.threads.get(n);
|
|
200
|
+
if (t && t.validity !== 'Abandoned' && t.validity !== 'Removed') return n;
|
|
201
|
+
const r = store.reference.get(n);
|
|
202
|
+
if (r?.category === 'Plan' && r.validity !== 'Abandoned') return n;
|
|
203
|
+
queue.push(n);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** `Not started`: planned work whose batch has started while it has no dispatch, branch or commit of its own. */
|
|
210
|
+
function notStarted(a: Analysis, work: readonly WorkThread[], processes: ReadonlyMap<string, WorkProcess>): Finding[] {
|
|
211
|
+
const { store, facts, index } = a;
|
|
212
|
+
const out: Finding[] = [];
|
|
213
|
+
const live = new Set(work.map((t) => t.id));
|
|
214
|
+
for (const plan of store.reference.filter((r) => r.category === 'Plan')) {
|
|
215
|
+
const runs = workOfPlan(store, plan.id).filter((t) => live.has(t.id)).map((t) => {
|
|
216
|
+
const p = processes.get(t.id) ?? a.process(t.id)!;
|
|
217
|
+
const units = p.own.map((n) => index.units.get(n)!).filter(Boolean);
|
|
218
|
+
return { t, p, batch: units.map(batchOf).find((b) => b !== null) ?? null };
|
|
219
|
+
});
|
|
220
|
+
for (const r of runs) {
|
|
221
|
+
if (!r.batch) continue;
|
|
222
|
+
const started = r.p.delivery.dispatched !== null || r.p.delivery.commits.length > 0 || r.p.delivery.branches.length > 0 || r.p.delivery.receipts.length > 0;
|
|
223
|
+
if (started) { out.push({ out: [r.p.delivery.dispatched ? facts.ref(r.p.delivery.dispatched.entry.id) : r.p.delivery.commits[0] ? facts.ref(r.p.delivery.commits[0].id) : objectRef(store, r.t.id)], kind: 'Not started', targetId: r.t.id }); continue; }
|
|
224
|
+
if (r.t.progress === 'Done') continue;
|
|
225
|
+
const mates = runs.filter((m) => m !== r && m.batch?.batch === r.batch!.batch && (m.p.delivery.dispatched || m.p.delivery.commits.length));
|
|
226
|
+
const first = mates.map((m) => m.p.delivery.dispatched ? { ms: m.p.delivery.dispatched.ms, occurred: m.p.delivery.dispatched.occurred, ev: facts.ref(m.p.delivery.dispatched.entry.id), t: m.t } : { ms: m.p.delivery.commits[0]!.ms, occurred: m.p.delivery.commits[0]!.occurred, ev: facts.ref(m.p.delivery.commits[0]!.id), t: m.t }).sort((x, y) => x.ms - y.ms)[0];
|
|
227
|
+
if (!first) continue;
|
|
228
|
+
out.push({ lit: {
|
|
229
|
+
kind: 'Not started', targetId: r.t.id, basis: 'Inferred', since: first.occurred,
|
|
230
|
+
why: `Batch ${r.batch.batch} of ${plan.name} started (${first.t.ids[0] ?? first.t.title}); ${r.t.ids[0] ?? r.t.title} has no dispatch, branch or commit`,
|
|
231
|
+
evidence: [first.ev, objectRef(store, plan.id), ...(r.p.delivery.planned ? [r.p.delivery.planned.evidence] : [])],
|
|
232
|
+
} });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** `Not merged`, `No trace of done`, `Not checked`, `No plan`: the breakpoints about one piece of work's own steps. */
|
|
239
|
+
function aboutWork(a: Analysis, t: WorkThread, p: WorkProcess): Finding[] {
|
|
240
|
+
const { store, facts, index } = a;
|
|
241
|
+
const out: Finding[] = [];
|
|
242
|
+
const d = p.delivery;
|
|
243
|
+
const units = p.own.map((n) => index.units.get(n)!).filter(Boolean);
|
|
244
|
+
const isCheckOrFix = units.length > 0 && units.every((u) => u.nature !== 'work');
|
|
245
|
+
const done = d.reportedDone ?? (t.progress === 'Done' ? { occurred: undated(t.asOf || t.updatedAt, t.id), ms: 0, evidence: null } : null);
|
|
246
|
+
|
|
247
|
+
// Not merged: delivered code that never reached the trunk, and the work says it is done.
|
|
248
|
+
if (d.codeCommits.length && d.unmerged && (done || d.receipts.length) && !d.experiment) {
|
|
249
|
+
const last = d.codeCommits[d.codeCommits.length - 1]!;
|
|
250
|
+
const since = done && done.ms > last.ms ? done.occurred : last.occurred;
|
|
251
|
+
out.push({ lit: {
|
|
252
|
+
kind: 'Not merged', targetId: t.id, basis: 'Explicit', since,
|
|
253
|
+
why: `${t.ids[0] ?? t.title}'s ${d.codeCommits.length} commit${d.codeCommits.length === 1 ? '' : 's'}${d.branches[0] ? ` on ${d.branches[0]}` : ''} never reached the trunk${done ? ', and it says it is done' : ''}`,
|
|
254
|
+
evidence: [facts.ref(last.id), ...(done?.evidence ? [done.evidence] : [])],
|
|
255
|
+
} });
|
|
256
|
+
} else if (d.codeCommits.length) {
|
|
257
|
+
const merged = d.merges[d.merges.length - 1] ?? d.codeCommits.find((c) => c.onTrunk);
|
|
258
|
+
out.push({ out: [merged ? facts.ref(merged.id) : d.experiment ?? objectRef(store, t.id)], kind: 'Not merged', targetId: t.id });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// No trace of done: it says it is done, and the ledger has no commit or product of its own.
|
|
262
|
+
const tiedByLink = p.linked.some((l) => l.stepKind === 'Delivered' || l.stepKind === 'Fix' || l.stepKind === 'Merged');
|
|
263
|
+
const product = d.codeCommits.length > 0 || d.commits.length > 0 || d.receipts.length > 0 || p.checks.some((c) => c.unit && p.own.includes(c.unit)) || tiedByLink;
|
|
264
|
+
if (done && !product && (units.length > 0 || p.linked.length > 0)) {
|
|
265
|
+
out.push({ lit: {
|
|
266
|
+
kind: 'No trace of done', targetId: t.id, basis: units.length ? 'Explicit' : 'Inferred', since: done.occurred,
|
|
267
|
+
why: `${t.ids[0] ?? t.title} is reported done, and the ledger has no commit, receipt or report of its own`,
|
|
268
|
+
evidence: [...(done.evidence ? [done.evidence] : [objectRef(store, t.id)]), ...(d.missingReceipt ? [objectRef(store, t.id, `named receipt never committed: ${d.missingReceipt}`)] : [])],
|
|
269
|
+
} });
|
|
270
|
+
} else if (product) {
|
|
271
|
+
const ev = d.codeCommits[0] ? facts.ref(d.codeCommits[0].id) : d.receipts[0] ? facts.ref(d.receipts[0].id) : p.linked[0]?.evidence ?? objectRef(store, t.id);
|
|
272
|
+
out.push({ out: [ev], kind: 'No trace of done', targetId: t.id });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Not checked: the rules say this work is checked independently, it was delivered, and no independent check exists.
|
|
276
|
+
const checkRule = isCheckOrFix ? null : a.expecting(t, 'Independent check');
|
|
277
|
+
const delivered = d.deliveredAt !== null || done !== null;
|
|
278
|
+
const independent = p.checks.filter((c) => c.by === 'Independent QC').sort((x, y) => y.ms - x.ms)[0] ?? null;
|
|
279
|
+
const linkedCheck = p.linked.find((l) => l.stepKind === 'QC' || l.stepKind === 'Review' || l.stepKind === 'Walkthrough') ?? null;
|
|
280
|
+
if (checkRule && delivered && !independent && !linkedCheck) {
|
|
281
|
+
out.push({ lit: {
|
|
282
|
+
kind: 'Not checked', targetId: t.id, basis: checkRule.basis === 'Explicit' ? 'Explicit' : 'Inferred', since: d.deliveredAt?.occurred ?? done!.occurred,
|
|
283
|
+
why: `The project's rule asks for an independent check of ${t.ids[0] ?? t.title}; it was delivered and nothing checked it${p.latestCheck?.by === 'Self-reported' ? ` (its own report says ${p.latestCheck.verdict}: a self-report is not a check)` : ''}`,
|
|
284
|
+
evidence: [objectRef(store, checkRule.id), ...(d.codeCommits.length ? [facts.ref(d.codeCommits[d.codeCommits.length - 1]!.id)] : done?.evidence ? [done.evidence] : [])],
|
|
285
|
+
} });
|
|
286
|
+
} else {
|
|
287
|
+
out.push({ out: [independent?.verdict ? facts.ref(independent.verdict.id, independent.verdict.text) : linkedCheck?.evidence ?? objectRef(store, checkRule?.id ?? t.id)], kind: 'Not checked', targetId: t.id });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// No plan: merged work that traces to no plan item, owner's words or decision (only where the rules expect a written plan).
|
|
291
|
+
const planRule = a.expecting(t, 'Written plan');
|
|
292
|
+
if (planRule && p.execution === 'Merged' && !isCheckOrFix) {
|
|
293
|
+
const traced = tracedToIntent(a, t, p);
|
|
294
|
+
if (!traced) {
|
|
295
|
+
const first = d.codeCommits[0] ?? d.commits[0];
|
|
296
|
+
out.push({ lit: {
|
|
297
|
+
kind: 'No plan', targetId: t.id, basis: 'Inferred', since: first?.occurred ?? d.deliveredAt?.occurred ?? undated(t.asOf || t.updatedAt, t.id),
|
|
298
|
+
why: `${t.ids[0] ?? t.title} was merged into the trunk and traces to no plan item, owner's words or decision`,
|
|
299
|
+
evidence: [...(first ? [facts.ref(first.id)] : []), objectRef(store, t.id), objectRef(store, planRule.id)],
|
|
300
|
+
} });
|
|
301
|
+
} else out.push({ out: [traced], kind: 'No plan', targetId: t.id });
|
|
302
|
+
} else out.push({ out: [objectRef(store, planRule?.id ?? t.id)], kind: 'No plan', targetId: t.id });
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** What a piece of work traces up to: a plan item, owner's words or a decision (the round's relations), or a plan document naming it. */
|
|
307
|
+
function tracedToIntent(a: Analysis, t: WorkThread, p: WorkProcess): EvidenceRef | null {
|
|
308
|
+
const { store, facts, index } = a;
|
|
309
|
+
const seen = new Set<string>([t.id]);
|
|
310
|
+
const queue = [...t.serves.map((s) => s.referenceId), ...store.relations.filter((r) => r.from === t.id).map((r) => r.to)];
|
|
311
|
+
while (queue.length) {
|
|
312
|
+
const id = queue.shift()!;
|
|
313
|
+
if (seen.has(id)) continue;
|
|
314
|
+
seen.add(id);
|
|
315
|
+
const r = store.reference.get(id);
|
|
316
|
+
if (!r) continue;
|
|
317
|
+
if (r.category === 'Plan' || r.category === "Owner's words" || r.category === 'Decision' || r.category === 'Requirement') return objectRef(store, r.id);
|
|
318
|
+
queue.push(...r.refines);
|
|
319
|
+
}
|
|
320
|
+
const planPaths = new Set([...store.layers.filter((l) => l.layer === 'Plan' || l.layer === 'Task contract').map((l) => l.path.replace(/\\/g, '/')), ...facts.arrangements.filter((x) => x.kind === 'plan').map((x) => x.path)]);
|
|
321
|
+
for (const n of p.own) {
|
|
322
|
+
const hit = (index.nums.get(n) ?? []).find((r) => r.kind === 'doc' && r.path && [...planPaths].some((pp) => r.path === pp || r.path!.endsWith(pp) || pp.endsWith(r.path!)));
|
|
323
|
+
if (hit) return facts.ref(hit.id, hit.context);
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** `Findings open`: a check's findings that nothing fixed, sent back or disposed of afterwards. */
|
|
329
|
+
function findingsOpen(a: Analysis, work: readonly WorkThread[], processes: ReadonlyMap<string, WorkProcess>): Finding[] {
|
|
330
|
+
const { facts } = a;
|
|
331
|
+
const byTarget = new Map<string, { open: Map<string, WorkProcess['findings'][number]>; handled: Map<string, WorkProcess['findings'][number]>; root: WorkThread }>();
|
|
332
|
+
for (const t of work) {
|
|
333
|
+
for (const f of processes.get(t.id)!.findings) {
|
|
334
|
+
const target = checkTarget(a, f.check.unit, t);
|
|
335
|
+
const e = byTarget.get(target) ?? { open: new Map(), handled: new Map(), root: t };
|
|
336
|
+
(f.handled ? e.handled : e.open).set(f.finding.id, f);
|
|
337
|
+
byTarget.set(target, e);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const out: Finding[] = [];
|
|
341
|
+
for (const [target, e] of byTarget) {
|
|
342
|
+
const open = [...e.open.values()].filter((f) => !e.handled.has(f.finding.id));
|
|
343
|
+
if (open.length === 0) { out.push({ out: dedupe([...e.handled.values()].map((f) => f.handled!.evidence)).slice(0, 8), kind: 'Findings open', targetId: target }); continue; }
|
|
344
|
+
const checks = [...new Set(open.map((f) => f.check.unit))];
|
|
345
|
+
const first = open.sort((x, y) => x.finding.ms - y.finding.ms)[0]!;
|
|
346
|
+
out.push({ lit: {
|
|
347
|
+
kind: 'Findings open', targetId: target, basis: 'Explicit', since: first.finding.occurred,
|
|
348
|
+
why: `${open.length} finding${open.length === 1 ? '' : 's'} of ${checks.join(', ')} (${open.map((f) => f.num).join(', ')}) ${open.length === 1 ? 'has' : 'have'} no fix, send-back or disposition afterwards`,
|
|
349
|
+
evidence: dedupe([...open.map((f) => f.check.verdict).filter((v): v is NonNullable<typeof v> => v !== null).map((v) => facts.ref(v.id, v.text)), ...open.map((f) => facts.ref(f.finding.id, f.finding.text))]),
|
|
350
|
+
} });
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** `Passed with open items`: a passing report that leaves items for later which nothing followed up. */
|
|
356
|
+
function passedWithOpenItems(a: Analysis, work: readonly WorkThread[], processes: ReadonlyMap<string, WorkProcess>): Finding[] {
|
|
357
|
+
const { facts } = a;
|
|
358
|
+
const byTarget = new Map<string, { open: WorkProcess['openItems'][number][]; handled: WorkProcess['openItems'][number][] }>();
|
|
359
|
+
for (const t of work) {
|
|
360
|
+
for (const i of processes.get(t.id)!.openItems) {
|
|
361
|
+
const target = checkTarget(a, i.check.unit, t);
|
|
362
|
+
const e = byTarget.get(target) ?? { open: [], handled: [] };
|
|
363
|
+
if (i.handled) e.handled.push(i);
|
|
364
|
+
else if (!e.open.some((x) => x.check.reportPath === i.check.reportPath && x.item.line === i.item.line)) e.open.push(i);
|
|
365
|
+
byTarget.set(target, e);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const out: Finding[] = [];
|
|
369
|
+
for (const [target, e] of byTarget) {
|
|
370
|
+
if (e.open.length === 0) { out.push({ out: dedupe(e.handled.map((i) => i.handled!.evidence)).slice(0, 8), kind: 'Passed with open items', targetId: target }); continue; }
|
|
371
|
+
const c = e.open[0]!.check;
|
|
372
|
+
const text = c.report ? facts.textOf(c.report.id) : null;
|
|
373
|
+
const deferLine = text ? text.split(/\r?\n/).find((l) => /不单开|不另开|照实记下|之后再|以后再|留给|留到|defer|later|follow[- ]?up/i.test(l) && !/^\s*#/.test(l)) : undefined;
|
|
374
|
+
const itemRef = (i: WorkProcess['openItems'][number]): EvidenceRef => ({ kind: 'file', id: i.check.reportPath, label: `${i.check.reportPath}:${i.item.line}`, line: i.item.text, occurred: i.check.report?.occurred ?? i.check.occurred });
|
|
375
|
+
out.push({ lit: {
|
|
376
|
+
kind: 'Passed with open items', targetId: target, basis: c.fromCandidates ? 'Inferred' : 'Explicit', since: c.firstReport?.occurred ?? c.occurred,
|
|
377
|
+
why: `${c.unit}'s report says ${c.verdictWord ?? 'pass'} and leaves ${e.open.length} item${e.open.length === 1 ? '' : 's'} for later that nothing followed up`,
|
|
378
|
+
evidence: dedupe([...(c.verdict ? [facts.ref(c.verdict.id, c.verdict.text)] : []), ...e.open.map(itemRef), ...(deferLine ? [{ kind: 'file' as const, id: c.reportPath, label: c.reportPath, line: deferLine.trim(), occurred: c.report?.occurred ?? null }] : [])]),
|
|
379
|
+
} });
|
|
380
|
+
}
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** `Fix not re-checked`: where the rules ask a fix after a failure to be checked again, and no check came after it. */
|
|
385
|
+
function fixNotRechecked(a: Analysis, work: readonly WorkThread[], processes: ReadonlyMap<string, WorkProcess>): Finding[] {
|
|
386
|
+
const { store, facts, index } = a;
|
|
387
|
+
const out: Finding[] = [];
|
|
388
|
+
for (const t of work) {
|
|
389
|
+
const p = processes.get(t.id)!;
|
|
390
|
+
if (p.fixes.length === 0) continue;
|
|
391
|
+
const rule = a.expecting(t, 'Re-check after fix');
|
|
392
|
+
for (const fix of p.fixes.filter((f) => f.delivered)) {
|
|
393
|
+
const target = index.threadOf.get(fix.unit) && store.threads.get(index.threadOf.get(fix.unit)!)?.validity === 'Current' ? index.threadOf.get(fix.unit)! : t.id;
|
|
394
|
+
// A failure that sent the work back: a check's failing verdict (not one still in progress) or the host's review.
|
|
395
|
+
const failure = [...p.checks.filter(startsSendBack).map((c) => ({ ms: c.ms, ev: facts.ref(c.verdict!.id, c.verdict!.text) })), ...p.reviews.map((r) => ({ ms: r.ms, ev: facts.ref(r.entry.id, `status: ${r.status}`) }))]
|
|
396
|
+
.filter((x) => x.ms <= (fix.dispatched?.ms ?? fix.ms)).sort((x, y) => y.ms - x.ms)[0];
|
|
397
|
+
const recheck = p.checks.filter((c) => c.by === 'Independent QC' && c.ms > fix.ms).sort((x, y) => x.ms - y.ms)[0];
|
|
398
|
+
if (!rule || !failure) { out.push({ out: [objectRef(store, rule?.id ?? t.id)], kind: 'Fix not re-checked', targetId: target }); continue; }
|
|
399
|
+
if (recheck) { out.push({ out: [facts.ref(recheck.verdict?.id ?? recheck.report!.id, recheck.verdict?.text ?? null)], kind: 'Fix not re-checked', targetId: target }); continue; }
|
|
400
|
+
out.push({ lit: {
|
|
401
|
+
kind: 'Fix not re-checked', targetId: target, basis: rule.basis === 'Explicit' ? 'Explicit' : 'Inferred', since: fix.occurred,
|
|
402
|
+
why: `${fix.unit} fixed ${fix.targets.join(', ') || (t.ids[0] ?? t.title)} after a failure, and no check came after the fix`,
|
|
403
|
+
evidence: [failure.ev, ...fix.evidence.slice(0, 2), objectRef(store, rule.id)],
|
|
404
|
+
} });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return out;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* `Downstream behind` (the program's half, Inferred): a semantic patch withdrew something and an object it affects has
|
|
412
|
+
* not changed since; a line of the ledger says a numbered entry was superseded and a current object still cites it.
|
|
413
|
+
*/
|
|
414
|
+
function downstreamBehind(a: Analysis): Finding[] {
|
|
415
|
+
const { store, facts } = a;
|
|
416
|
+
const out: Finding[] = [];
|
|
417
|
+
const current = (id: string) => (store.reference.get(id)?.validity ?? store.threads.get(id)?.validity) === 'Current';
|
|
418
|
+
// 1. Semantic patches: the objects they name as affected, whose document has no version since.
|
|
419
|
+
for (const patch of store.patches.filter((x) => x.status !== 'Rejected')) {
|
|
420
|
+
const at = msOfOccurred(patch.occurred);
|
|
421
|
+
for (const id of patch.affects) {
|
|
422
|
+
if (!current(id)) continue;
|
|
423
|
+
const paths = objectPaths(a, id);
|
|
424
|
+
if (paths.length === 0) continue;
|
|
425
|
+
const newer = paths.flatMap((pp) => facts.docsByPath.get(pp) ?? []).filter((d) => d.ms > at).sort((x, y) => x.ms - y.ms)[0];
|
|
426
|
+
if (newer) { out.push({ out: [facts.ref(newer.id)], kind: 'Downstream behind', targetId: id }); continue; }
|
|
427
|
+
out.push({ lit: {
|
|
428
|
+
kind: 'Downstream behind', targetId: id, basis: 'Inferred', since: patch.occurred,
|
|
429
|
+
why: `${patch.number} withdrew “${clip(patch.invalidated, 70)}”; ${objectRef(store, id).label} is affected and its document has not changed since`,
|
|
430
|
+
evidence: [patch.candidate, ...(patch.decision ? [patch.decision] : []), objectRef(store, id)],
|
|
431
|
+
} });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// 2. The ledger's explicit supersessions of a numbered entry, and current lines that still cite the number.
|
|
435
|
+
// CM (E151): a dated record is a note of its time, not a supersession or a citation in force — a decision log's
|
|
436
|
+
// entries (a decision's 影响 or 改到的文档 note), reports and receipts, what the layer map marks not current. On the gated
|
|
437
|
+
// run all 38 Downstream-behind candidates came from such notes, and none lit. A decision log supersedes in the entry of
|
|
438
|
+
// the decision that does it (「D53 取代 D40」 under D53) or of the one it supersedes, never in another entry's background
|
|
439
|
+
// or in its discussion.
|
|
440
|
+
const dated = datedRecords(a);
|
|
441
|
+
const reps = new Map<string, { at: number; ev: EvidenceRef; num: string; familyBasis: NonNullable<Breakpoint['familyBasis']> }>();
|
|
442
|
+
for (const s of facts.supersessions) {
|
|
443
|
+
if ((s.source !== 'content' && s.source !== 'loose') || !s.path || s.line === null || !s.current) continue;
|
|
444
|
+
const at = dated.rel(s.path);
|
|
445
|
+
if (dated.isDated(at)) continue;
|
|
446
|
+
if (dated.isLog(at)) {
|
|
447
|
+
const entry = dated.entryAt(at, s.line);
|
|
448
|
+
const replacers: string[] = (s.replacement ?? '').toUpperCase().match(/[A-Z]{1,6}-?[A-Z]?\d{1,4}/g) ?? [];
|
|
449
|
+
// The entry of the decision that supersedes, or of the one superseded (「D1 …(superseded by D2)」 under D1).
|
|
450
|
+
if (!entry || ![...replacers, ...replacedNumbers(s.replaced).map((n) => n.toUpperCase())].includes(entry.toUpperCase())) continue;
|
|
451
|
+
}
|
|
452
|
+
for (const num of replacedNumbers(s.replaced)) {
|
|
453
|
+
if (!reps.has(num)) reps.set(num, { at: s.ms, ev: facts.ref(s.id, s.text), num,
|
|
454
|
+
familyBasis: { lineId: s.id, syntax: s.syntax ?? s.pattern, replaced: s.replaced! } });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (reps.size) {
|
|
458
|
+
const rows = facts.numsOf(reps.keys());
|
|
459
|
+
const objectsByPath = pathObjects(a);
|
|
460
|
+
for (const [num, r] of reps) {
|
|
461
|
+
for (const m of rows.get(num) ?? []) {
|
|
462
|
+
if (m.kind !== 'doc' || !m.current || !m.path || m.line === null || m.place === 'definition') continue;
|
|
463
|
+
if (dated.isLog(m.path) || dated.isDated(m.path)) continue;
|
|
464
|
+
if (/supersed|replac|取代|作废|废弃|撤回|obsolete|deprecated|不再|历史|history|→|->/i.test(m.context)) continue;
|
|
465
|
+
if (!standsAlone(m.context, num)) continue;
|
|
466
|
+
for (const o of objectsByPath.get(m.path) ?? []) {
|
|
467
|
+
if (m.line < o.lineStart || m.line > o.lineEnd || !current(o.id)) continue;
|
|
468
|
+
out.push({ lit: {
|
|
469
|
+
kind: 'Downstream behind', targetId: o.id, basis: 'Inferred', since: m.occurred,
|
|
470
|
+
why: `${num} is written as superseded; ${objectRef(store, o.id).label} still cites it`,
|
|
471
|
+
evidence: [r.ev, facts.ref(m.id, m.context), objectRef(store, o.id)],
|
|
472
|
+
familyBasis: r.familyBasis,
|
|
473
|
+
} });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return out;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Where the project keeps dated records (CM, E151): its decision logs (the layer map's `Decision record`, or by name when
|
|
483
|
+
* the layer map does not say), its reports and receipts (`QC and receipts`, the ledger's receipts), and what the layer map
|
|
484
|
+
* marks not current (archives); and, in a decision log, the entry a line stands in (the last number defined at or above it).
|
|
485
|
+
*/
|
|
486
|
+
function datedRecords(a: Analysis): { rel: (path: string) => string; isLog: (path: string) => boolean; isDated: (path: string) => boolean; entryAt: (path: string, line: number) => string | null } {
|
|
487
|
+
const { store, facts } = a;
|
|
488
|
+
const slash = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
489
|
+
const layers = store.layers.all().map((l) => ({ path: slash(l.path), layer: l.layer, current: l.current }));
|
|
490
|
+
const layerOf = (path: string) => layers.filter((l) => path === l.path || path.startsWith(`${l.path}/`)).sort((x, y) => y.path.length - x.path.length)[0] ?? null;
|
|
491
|
+
const rel = (path: string) => (/^[A-Za-z]:|^[\\/]/.test(path) ? facts.relPathOf(path)?.path ?? slash(path) : slash(path));
|
|
492
|
+
const isLog = (path: string) => { const l = layerOf(path); return l ? l.layer === 'Decision record' : /(^|\/)(DECISIONS?|ADR|decision[-_ ]?log)[^/]*\.md$/i.test(path); };
|
|
493
|
+
const isDated = (path: string) => { const l = layerOf(path); return Boolean(l && (l.layer === 'QC and receipts' || !l.current)) || (facts.arrangementsByPath.get(path) ?? []).some((x) => x.kind === 'receipt'); };
|
|
494
|
+
const defs = new Map<string, { num: string; line: number }[]>();
|
|
495
|
+
const entryAt = (path: string, line: number): string | null => {
|
|
496
|
+
let list = defs.get(path);
|
|
497
|
+
if (!list) {
|
|
498
|
+
try {
|
|
499
|
+
list = (facts.ledger.db.prepare("SELECT num, line FROM nums WHERE path = ? AND place = 'definition' AND kind = 'doc' AND current = 1 AND line IS NOT NULL ORDER BY line").all(path) as { num: string; line: number }[]);
|
|
500
|
+
} catch { list = []; }
|
|
501
|
+
defs.set(path, list);
|
|
502
|
+
}
|
|
503
|
+
let found: string | null = null;
|
|
504
|
+
for (const d of list) { if (d.line > line) break; found = d.num; }
|
|
505
|
+
return found;
|
|
506
|
+
};
|
|
507
|
+
return { rel, isLog, isDated, entryAt };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Repository-relative paths an object's sources are in. */
|
|
511
|
+
function objectPaths(a: Analysis, id: string): string[] {
|
|
512
|
+
const { store, facts } = a;
|
|
513
|
+
const ref = store.reference.get(id);
|
|
514
|
+
const thread = ref ? undefined : store.threads.get(id);
|
|
515
|
+
const sourceIds = ref?.sourceIds ?? thread?.factRecordIds.flatMap((f) => store.facts.get(f)?.aboutSourceIds ?? []) ?? [];
|
|
516
|
+
const out = new Set<string>();
|
|
517
|
+
for (const sid of sourceIds) {
|
|
518
|
+
const an = store.sources.get(sid)?.anchor;
|
|
519
|
+
if (an?.kind !== 'file') continue;
|
|
520
|
+
const at = facts.relPathOf(an.path);
|
|
521
|
+
if (at) out.add(at.path);
|
|
522
|
+
}
|
|
523
|
+
return [...out];
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** The current objects each document's sections are the source of, with their line ranges. */
|
|
527
|
+
function pathObjects(a: Analysis): Map<string, { id: string; lineStart: number; lineEnd: number }[]> {
|
|
528
|
+
const { store, facts } = a;
|
|
529
|
+
const out = new Map<string, { id: string; lineStart: number; lineEnd: number }[]>();
|
|
530
|
+
const add = (id: string, ids: readonly string[], sids: readonly string[]) => {
|
|
531
|
+
for (const sid of sids) {
|
|
532
|
+
const an = store.sources.get(sid)?.anchor;
|
|
533
|
+
if (an?.kind !== 'file') continue;
|
|
534
|
+
const at = facts.relPathOf(an.path);
|
|
535
|
+
if (at) for (const range of itemRanges(facts.ledger.db, at.repo, at.path, ids, { from: an.lineStart, to: an.lineEnd }))
|
|
536
|
+
out.set(at.path, [...(out.get(at.path) ?? []), { id, lineStart: range.from, lineEnd: range.to }]);
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
for (const r of store.reference.all()) if (r.validity === 'Current' && r.category !== "Owner's words") add(r.id, r.ids, r.sourceIds);
|
|
540
|
+
return out;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** `Not carried out` (the program's half, Inferred): a decision asking for something by a date or after a precondition, now due, with no later trace. */
|
|
544
|
+
function notCarriedOut(a: Analysis): Finding[] {
|
|
545
|
+
const { store, facts, index } = a;
|
|
546
|
+
const out: Finding[] = [];
|
|
547
|
+
const latest = facts.latestMs;
|
|
548
|
+
for (const d of store.reference.filter((r) => r.category === 'Decision')) {
|
|
549
|
+
if (d.validity !== 'Current' || d.carryOut?.status === 'Carried out') {
|
|
550
|
+
out.push({ out: [objectRef(store, d.id, d.carryOut?.status === 'Carried out' ? `Carried out${d.carryOut.remaining ? `; left: ${d.carryOut.remaining}` : ''}` : null)], kind: 'Not carried out', targetId: d.id });
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
const text = `${d.quote ?? ''}\n${d.text}\n${d.name}`;
|
|
554
|
+
if (!/要|必须|应该|应当|需要|将|删|改|做|完成|落实|上线|迁移|随|before|by |must|should|will|to be|deadline|截止|之前|以前|前/i.test(text)) continue;
|
|
555
|
+
let due: { at: string; ms: number; how: string; ev: EvidenceRef | null } | null = null;
|
|
556
|
+
// A date the decision names that has passed (by the latest commit the ledger holds).
|
|
557
|
+
for (const line of text.split(/\r?\n/)) {
|
|
558
|
+
for (const x of datesOnLine(line, null)) {
|
|
559
|
+
const ms = msOf(x.at) ?? 0;
|
|
560
|
+
if (ms && ms < latest && (!due || ms < due.ms)) due = { at: x.at, ms, how: `the date it names (${x.at}) has passed`, ev: null };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
// A precondition naming a unit of work that has since merged (`AK 合入后`, `after AK`, `随 AK`).
|
|
564
|
+
for (const m of text.matchAll(/(?:after|once|when|随|等|在)\s*([A-Z]{1,6}-?\d*)\s*(?:合入|完成|之后|以后|后|merged|lands|is done)?|([A-Z]{1,6}-?\d*)\s*(?:合入|完成)(?:之后|以后|后)/g)) {
|
|
565
|
+
const n = m[1] ?? m[2];
|
|
566
|
+
if (!n) continue;
|
|
567
|
+
const u = index.units.get(n);
|
|
568
|
+
const merge = u?.merges[0];
|
|
569
|
+
if (merge && merge.ms < latest && (!due || merge.ms < due.ms)) due = { at: merge.occurred.at, ms: merge.ms, how: `its precondition happened (${n} merged ${merge.short})`, ev: facts.ref(merge.id) };
|
|
570
|
+
}
|
|
571
|
+
if (!due) continue;
|
|
572
|
+
// A later trace: work carrying it out, or a commit or document naming the decision's own number after it was due.
|
|
573
|
+
const trace = laterTrace(a, d, due.ms);
|
|
574
|
+
if (trace) { out.push({ out: [trace], kind: 'Not carried out', targetId: d.id }); continue; }
|
|
575
|
+
out.push({ lit: {
|
|
576
|
+
kind: 'Not carried out', targetId: d.id, basis: 'Inferred', since: due.ev?.occurred ?? { at: due.at, basis: 'Written in text', anchor: d.id },
|
|
577
|
+
why: `The decision “${clip(d.name, 60)}” is due — ${due.how} — and nothing later carries it out`,
|
|
578
|
+
evidence: [objectRef(store, d.id), ...(due.ev ? [due.ev] : [])],
|
|
579
|
+
} });
|
|
580
|
+
}
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** A later trace of a decision: work that carries it out and is done, or a commit or document naming its number after it was due. */
|
|
585
|
+
function laterTrace(a: Analysis, d: ReferenceItem, dueMs: number): EvidenceRef | null {
|
|
586
|
+
const { store, facts } = a;
|
|
587
|
+
const carrier = store.relations.filter((r) => r.to === d.id && r.type === 'carries out').map((r) => store.threads.get(r.from)).find((t) => t?.progress === 'Done');
|
|
588
|
+
if (carrier) return objectRef(store, carrier.id, `carries out ${d.name}; progress Done`);
|
|
589
|
+
if (d.carryOut?.workIds.some((w) => store.threads.get(w)?.progress === 'Done')) return objectRef(store, d.carryOut.workIds.find((w) => store.threads.get(w)?.progress === 'Done')!);
|
|
590
|
+
const rows = facts.numsOf(d.ids);
|
|
591
|
+
for (const n of d.ids) {
|
|
592
|
+
const later = (rows.get(n) ?? []).filter((r) => r.ms > dueMs && r.place !== 'definition').sort((x, y) => x.ms - y.ms)[0];
|
|
593
|
+
if (later) return facts.ref(later.id, later.context);
|
|
594
|
+
}
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// ───────────────────────── reconciling with what the workbench already holds ─────────────────────────
|
|
599
|
+
|
|
600
|
+
export interface ReconcileResult {
|
|
601
|
+
/** Lit after this computation: only those the independent check lit before and that still hold (D99). */
|
|
602
|
+
readonly lit: number;
|
|
603
|
+
readonly litByKind: Readonly<Partial<Record<BreakpointKind, number>>>;
|
|
604
|
+
/** Always 0 since D99: the program never lights; the spot-check does (pk_confirm). Kept for the step's note. */
|
|
605
|
+
readonly newlyLit: number;
|
|
606
|
+
/** Candidates after this computation: leads for the lanes to read, not findings (Spec §2.12, D99). */
|
|
607
|
+
readonly candidates: number;
|
|
608
|
+
readonly candidatesByKind: Readonly<Partial<Record<BreakpointKind, number>>>;
|
|
609
|
+
readonly putOut: number;
|
|
610
|
+
readonly keptOut: number;
|
|
611
|
+
readonly written: number;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** A breakpoint waiting to be read and checked: computed, neither lit nor out (D99). */
|
|
615
|
+
export const isCandidate = (b: Breakpoint): boolean => !b.lit && !b.out && !b.ownerResponse;
|
|
616
|
+
|
|
617
|
+
const evidenceKey = (x: { readonly evidence: readonly EvidenceRef[] }): string => x.evidence.map((e) => `${e.id}\x1f${e.line ?? ''}`).join('\x1e');
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Write the findings on the workbench (Spec §2.12, D99: 没有痕迹,要读过才算). What the program computes is a candidate,
|
|
621
|
+
* a lead for the lanes to read; the program never lights one. A candidate keeps where a lane looked (`looked`) and the
|
|
622
|
+
* independent check (`checked`) while it holds on the same evidence; one the spot-check lit (pk_confirm) stays lit while
|
|
623
|
+
* it still holds, so a Follow up keeps it. What no longer holds goes out with its evidence, candidate or lit. The owner's
|
|
624
|
+
* `No action needed` is kept (never lit again), and so is a model's refutation (`out.by: 'model'`, pk_confirm
|
|
625
|
+
* `confirmed: false`: not a candidate again on the same evidence). Records are written only when something changed.
|
|
626
|
+
*/
|
|
627
|
+
export function reconcile(store: ProjectStore, projectId: string, findings: readonly Finding[], roundId: string | null, jobId: string | null): ReconcileResult {
|
|
628
|
+
const now = new Date().toISOString();
|
|
629
|
+
const trace = (summary: string) => ({ jobId, summary });
|
|
630
|
+
const seen = new Set<string>();
|
|
631
|
+
let putOut = 0; let keptOut = 0; let written = 0;
|
|
632
|
+
const litByKind: Partial<Record<BreakpointKind, number>> = {};
|
|
633
|
+
const candidatesByKind: Partial<Record<BreakpointKind, number>> = {};
|
|
634
|
+
const put = (b: Breakpoint, summary: string) => { store.breakpoints.put(b, trace(summary)); written += 1; };
|
|
635
|
+
const basisKey = (b: NonNullable<Breakpoint['familyBasis']>) => `${b.lineId}\x1f${b.syntax}\x1f${b.replaced}`;
|
|
636
|
+
type Refutation = NonNullable<Breakpoint['familyRefutations']>[number];
|
|
637
|
+
const refutedFamilies = new Map<string, Refutation>();
|
|
638
|
+
for (const b of store.breakpoints.filter((x) => x.projectId === projectId)) {
|
|
639
|
+
for (const r of b.familyRefutations ?? []) refutedFamilies.set(basisKey(r.basis), r);
|
|
640
|
+
if (b.out?.by !== 'model') continue;
|
|
641
|
+
const decision = b.out.decision ?? { breakpointId: b.id, at: b.out.at, roundId: null };
|
|
642
|
+
if (b.familyBasis && !refutedFamilies.has(basisKey(b.familyBasis)))
|
|
643
|
+
refutedFamilies.set(basisKey(b.familyBasis), { basis: b.familyBasis, decision, evidence: b.out.evidence });
|
|
644
|
+
else if (!b.familyBasis) {
|
|
645
|
+
// A judgement recorded before this field existed can still be carried to siblings on the same ledger line.
|
|
646
|
+
const lineId = b.evidence.find((e) => e.id.startsWith('sup:'))?.id;
|
|
647
|
+
for (const f of findings) if ('lit' in f && f.lit.familyBasis && f.lit.familyBasis.lineId === lineId)
|
|
648
|
+
refutedFamilies.set(basisKey(f.lit.familyBasis), { basis: f.lit.familyBasis, decision, evidence: b.out.evidence });
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
// One object has one breakpoint id. If several lines support it, an unrefuted line keeps it lit regardless of order.
|
|
652
|
+
const byId = new Map<string, Finding[]>();
|
|
653
|
+
for (const f of findings) {
|
|
654
|
+
const c = 'lit' in f ? f.lit : f;
|
|
655
|
+
const id = breakpointId(projectId, c.kind, c.targetId);
|
|
656
|
+
byId.set(id, [...(byId.get(id) ?? []), f]);
|
|
657
|
+
}
|
|
658
|
+
const selected: Finding[] = [...byId.values()].map((group): Finding => {
|
|
659
|
+
const candidates = group.filter((f): f is { readonly lit: Candidate } => 'lit' in f);
|
|
660
|
+
return candidates.find((f) => !f.lit.familyBasis || !refutedFamilies.has(basisKey(f.lit.familyBasis))) ?? candidates[0] ?? group[0]!;
|
|
661
|
+
});
|
|
662
|
+
for (const f of selected) {
|
|
663
|
+
if ('lit' in f) {
|
|
664
|
+
const c = f.lit;
|
|
665
|
+
const id = breakpointId(projectId, c.kind, c.targetId);
|
|
666
|
+
seen.add(id);
|
|
667
|
+
const existing = store.breakpoints.get(id);
|
|
668
|
+
if (existing?.ownerResponse) {
|
|
669
|
+
// The owner said no action is needed: it stays unlit; the answer stays.
|
|
670
|
+
if (existing.lit || !existing.out) put({ ...existing, lit: false, out: existing.out ?? { at: existing.ownerResponse.at, by: 'owner', evidence: [] }, updatedAt: now }, `Breakpoint ${c.kind} on ${c.targetId}: stays out, the owner answered No action needed`);
|
|
671
|
+
keptOut += 1;
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
const precedent = c.familyBasis ? refutedFamilies.get(basisKey(c.familyBasis)) : undefined;
|
|
675
|
+
if (precedent) {
|
|
676
|
+
const decision = precedent.decision;
|
|
677
|
+
const familyRefutations = [...(existing?.familyRefutations ?? []).filter((r) => basisKey(r.basis) !== basisKey(precedent.basis)), precedent];
|
|
678
|
+
const next: Breakpoint = {
|
|
679
|
+
id, projectId, kind: c.kind, targetId: c.targetId, why: c.why, evidence: c.evidence, basis: c.basis, since: c.since,
|
|
680
|
+
lit: false, out: { at: decision.at, by: 'model', evidence: precedent.evidence, decision }, ownerResponse: null,
|
|
681
|
+
confirmedInRoundId: null, sixThing: existing?.sixThing ?? SIX_OF[c.kind], sendBackId: existing?.sendBackId ?? null,
|
|
682
|
+
roundId: existing?.roundId ?? roundId, updatedAt: existing?.updatedAt ?? now, familyBasis: c.familyBasis, familyRefutations,
|
|
683
|
+
};
|
|
684
|
+
if (existing && !existing.out) putOut += 1;
|
|
685
|
+
keptOut += 1;
|
|
686
|
+
if (!existing || changed(existing, next) || existing.out?.decision?.breakpointId !== decision.breakpointId)
|
|
687
|
+
put({ ...next, updatedAt: now }, `Breakpoint ${c.kind} on ${c.targetId} stays out by ${decision.breakpointId} at ${decision.at}`);
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
if (!c.familyBasis && existing && !existing.lit && existing.out?.by === 'model') {
|
|
691
|
+
// A model step refuted it: light it again only on a fact it has not seen — neither what lit it nor what refuted it.
|
|
692
|
+
const seenIds = new Set([...existing.evidence, ...existing.out.evidence].map((e) => e.id));
|
|
693
|
+
if (c.evidence.every((e) => seenIds.has(e.id))) { keptOut += 1; continue; }
|
|
694
|
+
}
|
|
695
|
+
// D99: the program computes a candidate and never lights it. One the independent check lit (it has `checked`) and
|
|
696
|
+
// that still holds stays lit; one lit before D99 without a check goes back to being a candidate.
|
|
697
|
+
const staysLit = existing?.lit === true && !existing.out && !!existing.checked;
|
|
698
|
+
const same = !!existing && evidenceKey(existing) === evidenceKey(c);
|
|
699
|
+
const next: Breakpoint = {
|
|
700
|
+
id, projectId, kind: c.kind, targetId: c.targetId, why: c.why, evidence: c.evidence, basis: c.basis, since: c.since, lit: staysLit, out: null, ownerResponse: null,
|
|
701
|
+
looked: staysLit || same ? existing?.looked ?? null : null,
|
|
702
|
+
checked: staysLit || same ? existing?.checked ?? null : null,
|
|
703
|
+
confirmedInRoundId: staysLit ? existing!.confirmedInRoundId : null, sixThing: existing?.sixThing ?? SIX_OF[c.kind], sendBackId: existing?.sendBackId ?? null,
|
|
704
|
+
roundId: existing && !existing.out ? existing.roundId : roundId, updatedAt: existing?.updatedAt ?? now, familyBasis: c.familyBasis,
|
|
705
|
+
familyRefutations: existing?.familyRefutations,
|
|
706
|
+
};
|
|
707
|
+
if (staysLit) litByKind[c.kind] = (litByKind[c.kind] ?? 0) + 1;
|
|
708
|
+
else candidatesByKind[c.kind] = (candidatesByKind[c.kind] ?? 0) + 1;
|
|
709
|
+
if (!existing || changed(existing, next)) put({ ...next, updatedAt: now }, `Breakpoint ${c.kind} on ${c.targetId} ${staysLit ? 'stays lit' : 'is a candidate'} (program): ${clip(c.why, 120)}`);
|
|
710
|
+
} else {
|
|
711
|
+
const id = breakpointId(projectId, f.kind, f.targetId);
|
|
712
|
+
seen.add(id);
|
|
713
|
+
const existing = store.breakpoints.get(id);
|
|
714
|
+
// Lit or still a candidate: evidence that the step did happen puts it out. What is out keeps its treatment.
|
|
715
|
+
if (!existing || existing.out) continue;
|
|
716
|
+
put({ ...existing, lit: false, out: { at: now, by: existing.ownerResponse ? 'owner' : 'evidence', evidence: f.out }, updatedAt: now }, `Breakpoint ${f.kind} on ${f.targetId} out (program): ${f.out.map((e) => e.label).join('; ').slice(0, 160)}`);
|
|
717
|
+
putOut += 1;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// Lit breakpoints and candidates nothing computed any more (the object went, was struck, or fell into an earlier generation).
|
|
721
|
+
for (const b of store.breakpoints.filter((x) => x.projectId === projectId && !x.out && !seen.has(x.id))) {
|
|
722
|
+
const t = store.threads.get(b.targetId);
|
|
723
|
+
const r = t ? undefined : store.reference.get(b.targetId);
|
|
724
|
+
const gen = earlierGenerationOf(store, b.targetId);
|
|
725
|
+
const o = t ?? r;
|
|
726
|
+
const ev: EvidenceRef = gen ? gen.endedBy
|
|
727
|
+
: o && o.validity !== 'Current' ? objectRef(store, b.targetId, `validity ${o.validity}${o.replacedBy ? `, replaced by ${o.replacedBy}` : ''}`)
|
|
728
|
+
: o ? objectRef(store, b.targetId, `the ledger no longer shows what lit ${b.kind} here (its material changed or went)`)
|
|
729
|
+
: { kind: 'object', id: b.targetId, label: `${b.targetId} is no longer on the workbench`, line: null, occurred: null };
|
|
730
|
+
put({ ...b, lit: false, out: { at: now, by: 'evidence', evidence: [ev] }, updatedAt: now }, `Breakpoint ${b.kind} on ${b.targetId} out (program): ${ev.label}`);
|
|
731
|
+
putOut += 1;
|
|
732
|
+
}
|
|
733
|
+
const count = (by: Partial<Record<BreakpointKind, number>>) => Object.values(by).reduce((n, x) => n + (x ?? 0), 0);
|
|
734
|
+
return { lit: count(litByKind), litByKind, newlyLit: 0, candidates: count(candidatesByKind), candidatesByKind, putOut, keptOut, written };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function changed(a: Breakpoint, b: Breakpoint): boolean {
|
|
738
|
+
return a.lit !== b.lit || a.why !== b.why || a.basis !== b.basis || a.since.at !== b.since.at || evidenceKey(a) !== evidenceKey(b) ||
|
|
739
|
+
JSON.stringify(a.looked ?? null) !== JSON.stringify(b.looked ?? null) || JSON.stringify(a.checked ?? null) !== JSON.stringify(b.checked ?? null) ||
|
|
740
|
+
a.confirmedInRoundId !== b.confirmedInRoundId ||
|
|
741
|
+
a.out?.by !== b.out?.by || a.out?.at !== b.out?.at || a.sixThing !== b.sixThing ||
|
|
742
|
+
(a.familyBasis ? basisKeyChanged(a.familyBasis) : '') !== (b.familyBasis ? basisKeyChanged(b.familyBasis) : '') ||
|
|
743
|
+
JSON.stringify(a.familyRefutations ?? []) !== JSON.stringify(b.familyRefutations ?? []);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
const basisKeyChanged = (b: NonNullable<Breakpoint['familyBasis']>): string => `${b.lineId}\x1f${b.syntax}\x1f${b.replaced}`;
|