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,565 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules of change follow-up that the program keeps, not the model (Spec §1.8, §1.11, §2.1, §2.10, §5.4, §5.5;
|
|
3
|
+
* D56, D59). Everything here is deterministic and calls no model.
|
|
4
|
+
*
|
|
5
|
+
* Why these are rules and not prompt wording: on the 2026-09-18 assets, 42 matters were written as 160 change
|
|
6
|
+
* records, 233 objects were judged 5,237 times, 1,759 of 6,301 propagation judgements were about decisions (16 of
|
|
7
|
+
* them "has not followed") while the decisions that had really been superseded were not marked at all, and 38 of 85
|
|
8
|
+
* marks hung on a point-in-time record, on another mark, or on a note. A model that is merely asked to remember
|
|
9
|
+
* these will forget them again.
|
|
10
|
+
*/
|
|
11
|
+
import type {
|
|
12
|
+
ChangeItem, ChangeRecord, ClosedItem, ItemRef, LackedItem, NotJudgedEntry, ObjectJudgement, PropagationEntry, RoundResult,
|
|
13
|
+
} from '../model/types.ts';
|
|
14
|
+
import type { NotAChange, NotJudgedReason, Propagation } from '../model/vocab.ts';
|
|
15
|
+
import type { RoundNews } from '../model/k-types.ts';
|
|
16
|
+
import type { ProjectStore } from '../store/project-store.ts';
|
|
17
|
+
|
|
18
|
+
const now = () => new Date().toISOString();
|
|
19
|
+
|
|
20
|
+
// ───────────────────────── what a change record holds ─────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A record written before items existed reads as one item covering the whole record, so every rule below works the
|
|
24
|
+
* same on old and new records.
|
|
25
|
+
*/
|
|
26
|
+
export function itemsOf(change: ChangeRecord): readonly ChangeItem[] {
|
|
27
|
+
if (change.items && change.items.length) return change.items;
|
|
28
|
+
return [{
|
|
29
|
+
id: '', at: change.at, atSource: change.atSource, material: change.material, effect: change.effect,
|
|
30
|
+
title: change.title, summary: change.summary, before: change.before, after: change.after,
|
|
31
|
+
sourceIds: change.sourceIds, by: change.by, why: null, affects: change.affects,
|
|
32
|
+
}];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Every item of every record, in the order they happened. */
|
|
36
|
+
export function allItems(store: ProjectStore): { change: ChangeRecord; item: ChangeItem }[] {
|
|
37
|
+
return store.changes.all()
|
|
38
|
+
.flatMap((change) => itemsOf(change).map((item) => ({ change, item })))
|
|
39
|
+
.sort((a, b) => a.item.at.localeCompare(b.item.at));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const normalise = (s: string | null): string =>
|
|
43
|
+
(s ?? '').toLowerCase().replace(/\bv?\d+(\.\d+)+\b/g, '#').replace(/[\s`*_"'“”‘’,.;:!?()[\]{}\-—–]+/g, ' ').trim();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether an item is one of the things §1.8 says is not a change, and which one. Three of the seven are decided
|
|
47
|
+
* from the item itself; the other four — restating, re-reading, checking a dependency, re-reading after a
|
|
48
|
+
* compaction, recalling from memory — all mean the same net change was already recorded, so they are found by
|
|
49
|
+
* comparing the item with what is already in the assets.
|
|
50
|
+
*/
|
|
51
|
+
export function notAChange(store: ProjectStore, item: ChangeItem, recordId: string): NotAChange | null {
|
|
52
|
+
const before = normalise(item.before);
|
|
53
|
+
const after = normalise(item.after);
|
|
54
|
+
// Changed and changed back: the piece of work ends exactly where it started, so its net change is nothing.
|
|
55
|
+
if (item.before !== null && item.after !== null && item.before.trim() === item.after.trim()) return 'Changed and changed back';
|
|
56
|
+
// Wording, formatting or a version number that leaves the meaning alone: what differs is only spacing,
|
|
57
|
+
// punctuation, case or a version string.
|
|
58
|
+
if (before.length > 0 && before === after) return 'Wording, formatting or version number only';
|
|
59
|
+
// Already recorded: the same subject, effect and content as an item of an earlier record. Being met again is not
|
|
60
|
+
// a new change — it is the same one being restated, re-read, looked up as a dependency, or recalled.
|
|
61
|
+
const subject = [...item.affects].sort().join(',');
|
|
62
|
+
for (const { change, item: other } of allItems(store)) {
|
|
63
|
+
if (change.id === recordId) continue;
|
|
64
|
+
if (other.effect !== item.effect) continue;
|
|
65
|
+
if ([...other.affects].sort().join(',') !== subject) continue;
|
|
66
|
+
if (normalise(other.after) !== after || normalise(other.before) !== before) continue;
|
|
67
|
+
if (normalise(other.title) !== normalise(item.title) && normalise(other.summary) !== normalise(item.summary)) continue;
|
|
68
|
+
return 'Restating';
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Two items of the same piece of work that undo each other — the second puts back what the first replaced, on the
|
|
75
|
+
* same subject — are the "changed it and changed it back" of §1.8: the work's net change on that subject is
|
|
76
|
+
* nothing, so neither item survives. Returns the pairs, by index.
|
|
77
|
+
*/
|
|
78
|
+
export function undonePairs(items: readonly ChangeItem[]): [number, number][] {
|
|
79
|
+
const pairs: [number, number][] = [];
|
|
80
|
+
const taken = new Set<number>();
|
|
81
|
+
for (let i = 0; i < items.length; i++) {
|
|
82
|
+
if (taken.has(i)) continue;
|
|
83
|
+
const a = items[i]!;
|
|
84
|
+
for (let j = i + 1; j < items.length; j++) {
|
|
85
|
+
if (taken.has(j)) continue;
|
|
86
|
+
const b = items[j]!;
|
|
87
|
+
if ([...a.affects].sort().join(',') !== [...b.affects].sort().join(',')) continue;
|
|
88
|
+
if (normalise(a.before) !== normalise(b.after) || normalise(a.after) !== normalise(b.before)) continue;
|
|
89
|
+
if (normalise(a.after).length === 0 && normalise(a.before).length === 0) continue;
|
|
90
|
+
pairs.push([i, j]); taken.add(i); taken.add(j); break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return pairs;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const NOT_A_CHANGE_ADVICE: Readonly<Record<NotAChange, string>> = {
|
|
97
|
+
'Restating': 'the same net change is already recorded; add the material to that record’s sources instead of recording it again',
|
|
98
|
+
'Re-reading': 'reading a material again is not a change',
|
|
99
|
+
'Checking a dependency': 'looking a dependency up is not a change',
|
|
100
|
+
'Re-reading after compaction': 'reading the same material again after a compaction is not a change',
|
|
101
|
+
'Recalling from memory': 'bringing the same matter up again is not a change',
|
|
102
|
+
'Changed and changed back': 'the piece of work ends where it started, so it has no net change on this object',
|
|
103
|
+
'Wording, formatting or version number only': 'before and after differ only in wording, formatting or a version number, so the meaning did not change',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// ───────────────────────── who is judged, and who is not ─────────────────────────
|
|
107
|
+
|
|
108
|
+
const POINT_IN_TIME_NODES = new Set(['Session', 'Run', 'Review', 'Result', 'Change']);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A point-in-time record: something written down and then not maintained — a `Done` work item, a `Replaced`,
|
|
112
|
+
* `Abandoned` or `Removed` entry, a session, an execution or review report, a commit, a run result, a status statement. It says
|
|
113
|
+
* how things stood then, so a later change cannot leave it behind (§2.10). An item on hold is not one of these: it
|
|
114
|
+
* will be resumed from its text.
|
|
115
|
+
*/
|
|
116
|
+
export function isPointInTime(store: ProjectStore, id: string): boolean {
|
|
117
|
+
const node = store.nodes.get(id);
|
|
118
|
+
const refId = node ? node.refId : id;
|
|
119
|
+
if (store.changes.has(refId)) return true;
|
|
120
|
+
// What the material is used as decides this, and it is asked before the node's category. The graph has no `Code`
|
|
121
|
+
// category, so a file kept as code or as a design becomes a `Result` node (§1.5); reading the category first
|
|
122
|
+
// classed every maintained code area as a point-in-time record and took it out of propagation for good, against
|
|
123
|
+
// §2.10, which judges documents, plans, unfinished work, code areas and tests as usual.
|
|
124
|
+
const source = store.sources.get(refId);
|
|
125
|
+
if (source) return source.anchor.kind !== 'file' || source.usedAs === 'Status' || source.usedAs === 'QC' || source.usedAs === 'Run result';
|
|
126
|
+
if (node && POINT_IN_TIME_NODES.has(node.category)) return true;
|
|
127
|
+
// `Removed` (v2.8, D61) is no longer in the project's current version: nobody maintains it, so a later change cannot
|
|
128
|
+
// leave it behind, and a doubt that it still applies belongs on the current object that relies on it (§2.1).
|
|
129
|
+
const thread = store.threads.get(refId);
|
|
130
|
+
if (thread) return thread.progress === 'Done' || thread.validity === 'Replaced' || thread.validity === 'Abandoned' || thread.validity === 'Removed';
|
|
131
|
+
const ref = store.reference.get(refId);
|
|
132
|
+
if (ref) return ref.validity === 'Replaced' || ref.validity === 'Abandoned' || ref.validity === 'Removed';
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A decision entry: judged by the replacement check (§5.5), never by propagation. */
|
|
137
|
+
export function isDecision(store: ProjectStore, id: string): boolean {
|
|
138
|
+
const node = store.nodes.get(id);
|
|
139
|
+
const ref = store.reference.get(node ? node.refId : id);
|
|
140
|
+
return ref !== undefined && (ref.category === 'Decision' || ref.category === 'Boundary');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A mark or a note: a judgement about something else, never an object that falls behind or carries a mark. */
|
|
144
|
+
export function isJudgementRecord(store: ProjectStore, id: string): boolean {
|
|
145
|
+
return store.marks.has(id) || store.notes.has(id) || id.startsWith('mark_') || id.startsWith('note_');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Why an object is never judged, or null when it is judged as usual (documents, plans, unfinished work, code
|
|
149
|
+
* areas, tests). Point-in-time comes first: a `Replaced` decision is listed as a point-in-time record. */
|
|
150
|
+
export function notJudgedReason(store: ProjectStore, id: string): NotJudgedReason | null {
|
|
151
|
+
if (isJudgementRecord(store, id)) return 'Point-in-time record';
|
|
152
|
+
if (isPointInTime(store, id)) return 'Point-in-time record';
|
|
153
|
+
if (isDecision(store, id)) return 'Decision';
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const NOT_JUDGED_MESSAGE: Readonly<Record<NotJudgedReason, string>> = {
|
|
158
|
+
'Point-in-time record': 'A point-in-time record says how things stood when it was written, so a later change cannot leave it behind. It is listed in the change’s detail and does not count towards what is pending. Judge instead the current object that still uses the old content.',
|
|
159
|
+
'Decision': 'A decision records what was settled at one moment, so it is not judged for propagation. What it needs is the replacement check: whether a later decision superseded it, and which part. Use pk_check_decisions.',
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Whether the object exists at all in the assets. */
|
|
163
|
+
export function objectExists(store: ProjectStore, id: string): boolean {
|
|
164
|
+
return store.nodes.has(id) || store.reference.has(id) || store.threads.has(id) || store.areas.has(id);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The object's own last change, used to tell whether a settled item has to be judged again. */
|
|
168
|
+
export function objectUpdatedAt(store: ProjectStore, id: string): string {
|
|
169
|
+
const node = store.nodes.get(id);
|
|
170
|
+
const refId = node ? node.refId : id;
|
|
171
|
+
return store.threads.get(refId)?.updatedAt ?? store.reference.get(refId)?.updatedAt ?? store.areas.get(refId)?.updatedAt ?? node?.updatedAt ?? '';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ───────────────────────── downstream, by rule ─────────────────────────
|
|
175
|
+
|
|
176
|
+
const DOWNSTREAM_RELATIONS = ['serves', 'refines', 'implements', 'verifies', 'depends on'];
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The downstream of one item: from its subject, along the relations, by rule (§5.5). The subject itself is not
|
|
180
|
+
* downstream of its own item — an object never fails to follow its own revision — and objects that are never
|
|
181
|
+
* judged are left out, so they cannot reach the pending list at all. An object changed inside the same piece of
|
|
182
|
+
* work is still downstream: that it was touched is evidence, not a conclusion.
|
|
183
|
+
*/
|
|
184
|
+
export function downstreamOfItem(store: ProjectStore, item: Pick<ChangeItem, 'affects'>, depth = 2): string[] {
|
|
185
|
+
return rawDownstream(store, item, depth).filter((id) => notJudgedReason(store, id) === null);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** What one item of a record reached: the object, and the item that reached it. */
|
|
189
|
+
export interface Reached {
|
|
190
|
+
readonly nodeId: string;
|
|
191
|
+
readonly itemIds: readonly string[];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Every object the items of this record reached, each with the items that actually reached it, and the not-judged
|
|
196
|
+
* ones listed apart. The per-item mapping is what the round hands to a model: a piece of work with several
|
|
197
|
+
* unrelated items used to give every object it reached all of the record's items, so an object was asked about
|
|
198
|
+
* items that never came near it — inventing judgements, wrong `lacks` and wrong numbers.
|
|
199
|
+
*/
|
|
200
|
+
export function downstreamOfChange(store: ProjectStore, change: ChangeRecord): { judged: Reached[]; notJudged: NotJudgedEntry[] } {
|
|
201
|
+
const judged = new Map<string, Set<string>>();
|
|
202
|
+
const notJudged = new Map<string, NotJudgedReason>();
|
|
203
|
+
const at = now();
|
|
204
|
+
// An object another item of the same piece of work changed is still judged when it lies downstream of this item
|
|
205
|
+
// (D56 rule 2): that the work touched it is evidence, not a conclusion. Only the item's own subject is excluded,
|
|
206
|
+
// and `rawDownstream` does that.
|
|
207
|
+
for (const item of itemsOf(change)) {
|
|
208
|
+
// The walk is run once without the filter, so the change's detail can list what was left out and why.
|
|
209
|
+
for (const id of rawDownstream(store, item, 2)) {
|
|
210
|
+
const reason = notJudgedReason(store, id);
|
|
211
|
+
if (reason) { if (!notJudged.has(id)) notJudged.set(id, reason); continue; }
|
|
212
|
+
if (!judged.has(id)) judged.set(id, new Set());
|
|
213
|
+
judged.get(id)!.add(item.id);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
judged: [...judged].map(([nodeId, itemIds]) => ({ nodeId, itemIds: [...itemIds] })),
|
|
218
|
+
notJudged: [...notJudged].map(([nodeId, reason]) => ({ nodeId, reason, at })),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The walk without the not-judged filter, so the callers above can list what was left out and why. */
|
|
223
|
+
function rawDownstream(store: ProjectStore, item: Pick<ChangeItem, 'affects'>, depth = 2): string[] {
|
|
224
|
+
const subjects = new Set(item.affects);
|
|
225
|
+
const out = new Set<string>();
|
|
226
|
+
const relations = store.relations.all();
|
|
227
|
+
// `left` is how many edges may still be crossed from `id`. Stopping at `left < 0` crossed one edge more than
|
|
228
|
+
// the rule allows: from depth 2 the walk reached a third object, which was then judged against a change two
|
|
229
|
+
// steps removed from it.
|
|
230
|
+
const walk = (id: string, left: number) => {
|
|
231
|
+
if (left <= 0) return;
|
|
232
|
+
for (const r of relations) {
|
|
233
|
+
if (r.to !== id || !DOWNSTREAM_RELATIONS.includes(r.type)) continue;
|
|
234
|
+
if (subjects.has(r.from) || out.has(r.from)) continue;
|
|
235
|
+
out.add(r.from);
|
|
236
|
+
walk(r.from, left - 1);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
for (const s of subjects) walk(s, depth);
|
|
240
|
+
return [...out].filter((id) => objectExists(store, id));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Move every object that is never judged out of the change records' pending entries and into the record's own
|
|
245
|
+
* `notJudged` list, so it is shown in the change's detail and counted nowhere else. Returns how many it moved.
|
|
246
|
+
* Runs before each round: a decision or a `Done` work item may have become one since the entry was made.
|
|
247
|
+
*/
|
|
248
|
+
export function settleNotJudged(store: ProjectStore, at = now()): number {
|
|
249
|
+
let moved = 0;
|
|
250
|
+
for (const change of store.changes.all()) {
|
|
251
|
+
const keep: PropagationEntry[] = [];
|
|
252
|
+
const listed = new Map<string, NotJudgedEntry>((change.notJudged ?? []).map((e) => [e.nodeId, e]));
|
|
253
|
+
for (const p of change.propagation) {
|
|
254
|
+
const reason = notJudgedReason(store, p.nodeId);
|
|
255
|
+
if (!reason) { keep.push(p); continue; }
|
|
256
|
+
moved++;
|
|
257
|
+
listed.set(p.nodeId, { nodeId: p.nodeId, reason, at });
|
|
258
|
+
}
|
|
259
|
+
if (keep.length === change.propagation.length && listed.size === (change.notJudged ?? []).length) continue;
|
|
260
|
+
store.changes.put({ ...change, propagation: keep, notJudged: [...listed.values()], updatedAt: at });
|
|
261
|
+
}
|
|
262
|
+
return moved;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ───────────────────────── one judgement per object per round ─────────────────────────
|
|
266
|
+
|
|
267
|
+
export const roundIdOf = (n: number) => `round_${String(n).padStart(4, '0')}`;
|
|
268
|
+
|
|
269
|
+
/** The round now open, or null. A round is open from when it starts until its result is written. */
|
|
270
|
+
export function openRound(store: ProjectStore) {
|
|
271
|
+
return store.rounds.filter((r) => r.endedAt === null).sort((a, b) => b.number - a.number)[0] ?? null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Open the next round, or return the one already open. The main job of the round owns it (§3.3). */
|
|
275
|
+
export function startRound(store: ProjectStore, projectId: string, mainJobId: string | null = null, at = now()) {
|
|
276
|
+
const already = openRound(store);
|
|
277
|
+
if (already) return already;
|
|
278
|
+
const number = Math.max(0, ...store.rounds.all().map((r) => r.number)) + 1;
|
|
279
|
+
// Before a round begins, whatever has become a point-in-time record or a decision since the entries were made is
|
|
280
|
+
// moved out of what is pending, so no round ever hands one to a model (§2.10).
|
|
281
|
+
settleNotJudged(store, at);
|
|
282
|
+
const round = { id: roundIdOf(number), projectId, number, startedAt: at, endedAt: null, mainJobId, result: null };
|
|
283
|
+
store.rounds.put(round, { jobId: mainJobId, summary: `Follow up round ${number} started` });
|
|
284
|
+
return round;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A round id's number. Round ids are compared by this and never as strings: `roundIdOf` pads to four digits, so
|
|
289
|
+
* from `round_10000` on the dictionary order stops matching the numeric one and `round_9999 < round_10000` is
|
|
290
|
+
* false — a project that long-lived would silently carry the wrong round's judgement forward.
|
|
291
|
+
*/
|
|
292
|
+
export function roundNumberOf(roundId: string): number {
|
|
293
|
+
const n = Number.parseInt(roundId.replace(/^round_/, ''), 10);
|
|
294
|
+
return Number.isFinite(n) ? n : -1;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The object's judgement from the round before this one, if any. */
|
|
298
|
+
export function lastJudgement(store: ProjectStore, nodeId: string, beforeRoundId: string): ObjectJudgement | null {
|
|
299
|
+
const before = roundNumberOf(beforeRoundId);
|
|
300
|
+
return store.propagation.filter((j) => j.nodeId === nodeId && roundNumberOf(j.roundId) < before)
|
|
301
|
+
.sort((a, b) => roundNumberOf(b.roundId) - roundNumberOf(a.roundId))[0] ?? null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export const sameItem = (a: ItemRef, b: ItemRef) => a.changeId === b.changeId && a.itemId === b.itemId;
|
|
305
|
+
|
|
306
|
+
/** Where one item stands on an object after the rounds before this one. */
|
|
307
|
+
export type ItemStanding = { readonly kind: 'concluded' } | { readonly kind: 'lacked'; readonly lacked: LackedItem } | { readonly kind: 'open' };
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Where every item that ever reached an object stands, over all the rounds before `beforeRoundId` — not only the
|
|
311
|
+
* latest one. An item is concluded once a judgement followed it, closed it, or covered it with a state that settles
|
|
312
|
+
* it (`Updated`, `Reusable as is`, or `Still on old understanding` without naming it as lacked — the per-record
|
|
313
|
+
* entries already read those as followed); it is lacked while the latest judgement that names it lists it as lacked;
|
|
314
|
+
* it is open when only `Not yet checked` ever covered it. A concluded item stays concluded (§2.10).
|
|
315
|
+
*/
|
|
316
|
+
export function itemStandings(store: ProjectStore, nodeId: string, beforeRoundId: string): Map<string, ItemStanding> {
|
|
317
|
+
const before = roundNumberOf(beforeRoundId);
|
|
318
|
+
const judgements = store.propagation.filter((j) => j.nodeId === nodeId && roundNumberOf(j.roundId) < before)
|
|
319
|
+
.sort((a, b) => roundNumberOf(a.roundId) - roundNumberOf(b.roundId));
|
|
320
|
+
const standing = new Map<string, ItemStanding>();
|
|
321
|
+
for (const j of judgements) {
|
|
322
|
+
for (const ref of [...j.covers, ...j.lacks.filter((l) => !j.covers.some((c) => sameItem(c, l)))]) {
|
|
323
|
+
const key = itemKey(ref);
|
|
324
|
+
if (standing.get(key)?.kind === 'concluded') continue;
|
|
325
|
+
const lacked = j.lacks.find((l) => sameItem(l, ref));
|
|
326
|
+
if (lacked) standing.set(key, { kind: 'lacked', lacked });
|
|
327
|
+
else if (j.closed.some((c) => sameItem(c, ref)) || j.followed.some((f) => sameItem(f, ref)) || j.state !== 'Not yet checked') standing.set(key, { kind: 'concluded' });
|
|
328
|
+
else if (!standing.has(key)) standing.set(key, { kind: 'open' });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return standing;
|
|
332
|
+
}
|
|
333
|
+
/** The key `itemStandings` files one item of one record under. */
|
|
334
|
+
export const itemKey = (r: ItemRef) => `${r.changeId}\0${r.itemId}`;
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The items this round has to judge for one object (Spec v2.8 §2.10, §5.5; CKC-11 AC-12): the items that reached it
|
|
338
|
+
* and have no conclusion yet, together with what it still lacks. What it still lacks is judged again only alongside
|
|
339
|
+
* something new, or once the object itself has moved since its last judgement — "the previous round's outstanding
|
|
340
|
+
* items are not judged again while the object has not moved". An item that already has a conclusion is never judged
|
|
341
|
+
* again, however often the object is modified: the object's whole history is not walked again (E50, and the Product
|
|
342
|
+
* architect's reading written into §2.10 on 2026-09-21).
|
|
343
|
+
*/
|
|
344
|
+
export function itemsForObject(store: ProjectStore, nodeId: string, roundId: string): { covers: ItemRef[]; carried: LackedItem[] } {
|
|
345
|
+
const previous = lastJudgement(store, nodeId, roundId);
|
|
346
|
+
const movedSince = previous !== null && objectUpdatedAt(store, nodeId) > previous.objectUpdatedAt;
|
|
347
|
+
const standings = itemStandings(store, nodeId, roundId);
|
|
348
|
+
const covers: ItemRef[] = [];
|
|
349
|
+
for (const change of store.changes.all()) {
|
|
350
|
+
for (const p of change.propagation) {
|
|
351
|
+
if (p.nodeId !== nodeId) continue;
|
|
352
|
+
// Only the items that actually reached this object. An entry written before the seeding recorded which item
|
|
353
|
+
// arrived carries no `itemIds` and is read as the whole record, the way it was meant when it was written.
|
|
354
|
+
const reached = p.itemIds ? itemsOf(change).filter((i) => p.itemIds!.includes(i.id)) : itemsOf(change);
|
|
355
|
+
for (const item of reached) {
|
|
356
|
+
const ref = { changeId: change.id, itemId: item.id };
|
|
357
|
+
const standing = standings.get(itemKey(ref));
|
|
358
|
+
if (standing?.kind === 'concluded') continue;
|
|
359
|
+
if (standing?.kind === 'lacked' && !movedSince) continue;
|
|
360
|
+
if (!covers.some((c) => sameItem(c, ref))) covers.push(ref);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const carried = [...standings.values()].flatMap((s) => (s.kind === 'lacked' ? [s.lacked] : []));
|
|
365
|
+
return { covers, carried };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export interface JudgeInput {
|
|
369
|
+
readonly nodeId: string;
|
|
370
|
+
readonly state: Propagation;
|
|
371
|
+
readonly sourceOrReason: string;
|
|
372
|
+
readonly followed?: readonly ItemRef[];
|
|
373
|
+
readonly lacks?: readonly LackedItem[];
|
|
374
|
+
readonly closed?: readonly ClosedItem[];
|
|
375
|
+
readonly jobId?: string | null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Write one object's judgement for one round, and fan it out to the entries inside the change records so every
|
|
380
|
+
* existing view of a change shows the same conclusion. A second judgement of the same object in the same round
|
|
381
|
+
* replaces the first: one object, one state, one round.
|
|
382
|
+
*/
|
|
383
|
+
export function judgeObject(store: ProjectStore, projectId: string, roundId: string, input: JudgeInput, at = now()): ObjectJudgement {
|
|
384
|
+
const id = `${roundId}:${input.nodeId}`;
|
|
385
|
+
const existing = store.propagation.get(id);
|
|
386
|
+
const { covers, carried } = itemsForObject(store, input.nodeId, roundId);
|
|
387
|
+
// What the previous round left open is judged again together with what arrived since, never dropped. §5.5 ends
|
|
388
|
+
// an item in exactly three ways — the object followed it, a later change superseded it, or the owner or holder
|
|
389
|
+
// said it need not be handled — and taking only `covers` here was a fourth: an item still lacked last round
|
|
390
|
+
// disappeared without any of them the moment a new change reached the same object.
|
|
391
|
+
const carriedRefs: ItemRef[] = carried.filter((l) => store.changes.has(l.changeId)).map((l) => ({ changeId: l.changeId, itemId: l.itemId }));
|
|
392
|
+
const reached = [...covers, ...carriedRefs.filter((c) => !covers.some((x) => sameItem(x, c)))];
|
|
393
|
+
const all = existing ? [...reached, ...existing.covers.filter((c) => !reached.some((x) => sameItem(x, c)))] : reached;
|
|
394
|
+
const known = (r: ItemRef) => all.some((c) => sameItem(c, r));
|
|
395
|
+
// A caller that names only the record means all of its items: the judgement is about the whole piece of work.
|
|
396
|
+
const expand = <T extends ItemRef>(refs: readonly T[]): T[] => refs.flatMap((r) => {
|
|
397
|
+
if (r.itemId) return [r];
|
|
398
|
+
const change = store.changes.get(r.changeId);
|
|
399
|
+
return change ? itemsOf(change).map((i) => ({ ...r, itemId: i.id })) : [r];
|
|
400
|
+
});
|
|
401
|
+
// Where the object stood before this call: what this round has already concluded, or — on the round's first call
|
|
402
|
+
// — what the previous round left open.
|
|
403
|
+
const wasLacking: readonly LackedItem[] = existing ? existing.lacks : carried.filter(known);
|
|
404
|
+
const wasFollowed = existing?.followed ?? [];
|
|
405
|
+
const wasClosed = existing?.closed ?? [];
|
|
406
|
+
const take = <T extends ItemRef>(refs: readonly T[] | undefined): T[] => expand(refs ?? []).filter(known);
|
|
407
|
+
const saysLacks = take(input.lacks);
|
|
408
|
+
const saysClosed = take(input.closed).filter((c) => !saysLacks.some((l) => sameItem(l, c)));
|
|
409
|
+
const saysFollowed = take(input.followed).filter((f) => ![...saysLacks, ...saysClosed].some((x) => sameItem(x, f)));
|
|
410
|
+
const named = [...saysLacks, ...saysClosed, ...saysFollowed];
|
|
411
|
+
// An item this call names moves to the bucket the call put it in and leaves the others. A union kept the earlier
|
|
412
|
+
// conclusion instead, so a second look in the same round could add a lacked item but never take one back: a
|
|
413
|
+
// re-check that found the object had followed it after all left the object behind anyway.
|
|
414
|
+
const kept = <T extends ItemRef>(before: readonly T[]): T[] => before.filter((o) => !named.some((n) => sameItem(n, o)));
|
|
415
|
+
const lacks = [...saysLacks, ...kept(wasLacking)];
|
|
416
|
+
const closed = [...saysClosed, ...kept(wasClosed)].filter((c) => !lacks.some((l) => sameItem(l, c)));
|
|
417
|
+
const followed = [...saysFollowed, ...kept(wasFollowed)].filter((f) => ![...lacks, ...closed].some((x) => sameItem(x, f)));
|
|
418
|
+
// One object, one state: a second call in the same round adds to the one judgement instead of writing another,
|
|
419
|
+
// and anything the object still lacks decides the state however the call phrased it.
|
|
420
|
+
const state: Propagation = lacks.length > 0 ? 'Still on old understanding' : input.state;
|
|
421
|
+
const reason = existing && existing.sourceOrReason && existing.sourceOrReason !== input.sourceOrReason
|
|
422
|
+
? `${existing.sourceOrReason} · ${input.sourceOrReason}` : input.sourceOrReason;
|
|
423
|
+
const judgement: ObjectJudgement = {
|
|
424
|
+
id, projectId, nodeId: input.nodeId, roundId, state, sourceOrReason: reason,
|
|
425
|
+
covers: all, followed, lacks, closed,
|
|
426
|
+
objectUpdatedAt: objectUpdatedAt(store, input.nodeId), jobId: input.jobId ?? existing?.jobId ?? null, at,
|
|
427
|
+
};
|
|
428
|
+
store.propagation.put(judgement, { jobId: judgement.jobId, summary: `${state}: ${input.nodeId} (round ${roundId})` });
|
|
429
|
+
// The per-record entries follow from the one judgement; nothing else writes them, so a change's own detail and
|
|
430
|
+
// the object's state can never disagree.
|
|
431
|
+
const lacking = new Set(lacks.map((l) => l.changeId));
|
|
432
|
+
for (const change of store.changes.all()) {
|
|
433
|
+
if (!change.propagation.some((p) => p.nodeId === input.nodeId)) continue;
|
|
434
|
+
const items = all.filter((c) => c.changeId === change.id);
|
|
435
|
+
if (!items.length) continue;
|
|
436
|
+
const perChange: Propagation = state === 'Still on old understanding' && !lacking.has(change.id) ? 'Updated' : state;
|
|
437
|
+
// An entry's `itemIds` record what reached the object, not what this round judged, so they never shrink: a
|
|
438
|
+
// round that judges only a newly arrived item must not erase the older item that an earlier round settled, or
|
|
439
|
+
// the next round after the object moves would never ask about it again.
|
|
440
|
+
const before = change.propagation.find((p) => p.nodeId === input.nodeId);
|
|
441
|
+
const entry: PropagationEntry = {
|
|
442
|
+
nodeId: input.nodeId, state: perChange, sourceOrReason: reason, updatedAt: at, roundId,
|
|
443
|
+
itemIds: [...new Set([...(before?.itemIds ?? []), ...items.map((i) => i.itemId)])],
|
|
444
|
+
};
|
|
445
|
+
store.changes.put({ ...change, propagation: change.propagation.map((p) => (p.nodeId === input.nodeId ? entry : p)), updatedAt: at });
|
|
446
|
+
}
|
|
447
|
+
return judgement;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ───────────────────────── where a mark may hang ─────────────────────────
|
|
451
|
+
|
|
452
|
+
/** What a mark on a point-in-time record is about: the record's own time, or something that happened after it. */
|
|
453
|
+
export const MARK_ABOUT = ['At its own time', 'A later change'] as const;
|
|
454
|
+
export type MarkAbout = (typeof MARK_ABOUT)[number];
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Why this mark may not be written, or null when it may (Spec §1.11; CKC-02 AC-22). Three rules:
|
|
458
|
+
* a mark never hangs on another mark or on a note, because both are judgements about something else; a
|
|
459
|
+
* point-in-time record does not get `Suspected stale` or `Layer drift` because of something that happened later —
|
|
460
|
+
* that belongs on the current object that still uses the old content — though a doubt about whether the record was
|
|
461
|
+
* true at its own time may still hang on it; and a mark that names neither what differs nor what it was checked
|
|
462
|
+
* against is never shown to an agent (§7.1), so writing one only hides it.
|
|
463
|
+
*/
|
|
464
|
+
export function markRefusal(
|
|
465
|
+
store: ProjectStore, kind: string, targetId: string,
|
|
466
|
+
clue: string, clueSourceIds: readonly string[], about: MarkAbout | null,
|
|
467
|
+
): string | null {
|
|
468
|
+
if (isJudgementRecord(store, targetId)) {
|
|
469
|
+
return store.notes.has(targetId) || targetId.startsWith('note_')
|
|
470
|
+
? `A mark does not hang on a note. A note that has gone stale is updated by a re-look, or closed as Resolved or Withdrawn with pk_close_note. Hang the mark on the object the note is about.`
|
|
471
|
+
: `A mark does not hang on another mark. When the same doubt comes up again, update the clue of the mark that is already there (pk_write_mark with the same kind and targetId does exactly that).`;
|
|
472
|
+
}
|
|
473
|
+
if (clue.trim().length === 0 || clueSourceIds.length === 0) {
|
|
474
|
+
return `A mark that cannot be checked does not exist: the pack only prints a mark that says what differs and names the material it was checked against. Add clue: what differs, in one sentence${clueSourceIds.length === 0 ? ', and clueSourceIds: the source ids you checked it against' : ''}.`;
|
|
475
|
+
}
|
|
476
|
+
if ((kind === 'Suspected stale' || kind === 'Layer drift') && isPointInTime(store, targetId) && about !== 'At its own time') {
|
|
477
|
+
return `${kind} does not hang on a point-in-time record because of something that happened later: it records how things stood when it was written. Hang it on the current object that still uses the old content. If instead you doubt whether this record was true at its own time — the material says it is done and the evidence contradicts that — say so with about: "At its own time" and let the clue name the contradiction.`;
|
|
478
|
+
}
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ───────────────────────── notes and requests of one round ─────────────────────────
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* A current note already on this mount about the same matter, or null. The round writes one note per matter, not
|
|
486
|
+
* one per object: on the 2026-09-18 assets five mount points carried duplicate notes about the same thing.
|
|
487
|
+
*/
|
|
488
|
+
export function duplicateNote(store: ProjectStore, mountIds: readonly string[], changeIds: readonly string[], title: string, exceptId: string) {
|
|
489
|
+
const mounts = new Set(mountIds);
|
|
490
|
+
const changes = new Set(changeIds);
|
|
491
|
+
const key = normalise(title);
|
|
492
|
+
return store.notes.find((n) => {
|
|
493
|
+
if (n.id === exceptId || n.status !== 'Current') return false;
|
|
494
|
+
if (!n.mount.ids.some((id) => mounts.has(id))) return false;
|
|
495
|
+
const version = n.versions[n.versions.length - 1];
|
|
496
|
+
if (version && normalise(version.title) === key) return true;
|
|
497
|
+
return changes.size > 0 && (n.cameFrom?.changeIds ?? []).some((id) => changes.has(id));
|
|
498
|
+
}) ?? null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** The request this round already holds for that holder, or null: one holder gets one request per round (§5.4). */
|
|
502
|
+
export function requestForHolder(store: ProjectStore, holder: string, roundId: string | null) {
|
|
503
|
+
if (!roundId) return null;
|
|
504
|
+
return store.requests.find((r) => r.holder === holder && r.roundId === roundId && !r.handled) ?? null;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ───────────────────────── the round's one result ─────────────────────────
|
|
508
|
+
|
|
509
|
+
/** Every count the round's result carries, taken from the assets (D43). No model writes these. */
|
|
510
|
+
export function countRound(store: ProjectStore, roundId: string) {
|
|
511
|
+
const judgements = store.propagation.filter((j) => j.roundId === roundId);
|
|
512
|
+
const byState: Record<string, number> = {};
|
|
513
|
+
for (const j of judgements) byState[j.state] = (byState[j.state] ?? 0) + 1;
|
|
514
|
+
const behind = judgements.filter((j) => j.state === 'Still on old understanding');
|
|
515
|
+
const requests = store.requests.filter((r) => r.roundId === roundId);
|
|
516
|
+
// Judgements and requests carry the round they belong to. Notes, marks and reference items do not, so they are
|
|
517
|
+
// attributed by the round's own window: from when it started until it ended, and for the open round up to now.
|
|
518
|
+
// Counting everything from the round's start with no end let a closed round's result report what later rounds
|
|
519
|
+
// wrote, and taking the not-judged objects from every record of every round reported the project's history as
|
|
520
|
+
// this round's number — a round that added none still showed the historical total.
|
|
521
|
+
const round = store.rounds.get(roundId);
|
|
522
|
+
const from = round?.startedAt ?? '';
|
|
523
|
+
const to = round?.endedAt ?? '9999';
|
|
524
|
+
const inRound = (at: string) => at >= from && at <= to;
|
|
525
|
+
const notes = store.notes.filter((n) => n.cameFrom?.kind === 'Change follow-up' && inRound(n.updatedAt));
|
|
526
|
+
const notJudged = new Set(store.changes.all().flatMap((c) => (c.notJudged ?? []).filter((e) => inRound(e.at)).map((e) => e.nodeId)));
|
|
527
|
+
// What the new decisions superseded this round, counted both ways §5.5 allows: a decision replaced as a whole,
|
|
528
|
+
// and a decision one part of which was superseded while the rest stays in force. Counting only `Replaced` left
|
|
529
|
+
// every partial supersession out of the round result's account of what the new decisions replaced — and partial
|
|
530
|
+
// is the ordinary case, since a later decision usually changes one clause.
|
|
531
|
+
const replaced = store.reference.filter((r) => isDecision(store, r.id) && (
|
|
532
|
+
(r.validity === 'Replaced' && inRound(r.updatedAt)) || (r.supersededParts ?? []).some((s) => inRound(s.at))
|
|
533
|
+
));
|
|
534
|
+
const suspected = store.marks.filter((m) => m.kind === 'Suspected stale' && !m.closed && isDecision(store, m.targetId) && inRound(m.since));
|
|
535
|
+
return {
|
|
536
|
+
counts: {
|
|
537
|
+
objectsJudged: judgements.length, byState, behind: behind.length,
|
|
538
|
+
itemsLacked: behind.reduce((n, j) => n + j.lacks.length, 0), notJudged: notJudged.size,
|
|
539
|
+
requests: requests.length, notes: notes.length,
|
|
540
|
+
decisionsReplaced: replaced.length, decisionsSuspected: suspected.length,
|
|
541
|
+
},
|
|
542
|
+
behindJudgements: behind, requests, noteIds: notes.map((n) => n.id),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* A round's one result (§5.5): the account in words, and everything counted from the assets (D43). An object behind
|
|
548
|
+
* reaches its holder: the holder of the work item or reference item, else the one a request of this round names.
|
|
549
|
+
* `news`: what the round made new with its positions (organize/round-news.ts, §3.8; CKC-07 AC-27), counted into the
|
|
550
|
+
* result beside the propagation account.
|
|
551
|
+
*/
|
|
552
|
+
export function roundResultOf(store: ProjectStore, roundId: string, summary: string, at: string, news: RoundNews | null = null): RoundResult {
|
|
553
|
+
const counted = countRound(store, roundId);
|
|
554
|
+
const holderOf = (nodeId: string) => store.threads.get(nodeId)?.attribution.holder?.role ?? store.reference.get(nodeId)?.attribution.holder?.role ?? counted.requests.find((r) => r.impact.includes(nodeId))?.holder ?? null;
|
|
555
|
+
const fresh = (s: { detail: string | null }) => s.detail?.startsWith('new') === true;
|
|
556
|
+
return {
|
|
557
|
+
at, summary,
|
|
558
|
+
counts: news ? { ...counted.counts, news: { breakpoints: news.breakpoints.length, sendbacksNew: news.sendbacks.filter(fresh).length, sendbacksMoved: news.sendbacks.filter((s) => !fresh(s)).length, sixThings: news.sixThings.length, patches: news.patches.length, notes: news.notes.length } } : counted.counts,
|
|
559
|
+
behind: counted.behindJudgements.map((j) => ({ nodeId: j.nodeId, holder: holderOf(j.nodeId), lacks: j.lacks.length })),
|
|
560
|
+
byHolder: counted.requests.map((r) => ({ holder: r.holder, requestId: r.id, nodeIds: r.impact })),
|
|
561
|
+
noteIds: counted.noteIds,
|
|
562
|
+
unassigned: counted.behindJudgements.filter((j) => holderOf(j.nodeId) === null).map((j) => j.nodeId),
|
|
563
|
+
...(news ? { news } : {}),
|
|
564
|
+
};
|
|
565
|
+
}
|