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
package/ui/placement.js
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
// Where every drawn object sits on the story map (D100; Spec §1.4 "落位", §6.3 "默认可见"; CKC-09 AC-2, AC-15). One
|
|
2
|
+
// model for the Graph and the List, so the two cannot disagree (as observedCells does for the folders, D55). Nothing
|
|
3
|
+
// here is a new store: the places are read off the relations the Keeper already writes — a work item's `serves` to a
|
|
4
|
+
// Plan and to an Area (or to what refines one: the node's `areaId`), a decision's or a requirement's `refines` to the
|
|
5
|
+
// Areas and Plans it acts on — and off the earlier generations the Keeper recorded. Placement quality is the Keeper's
|
|
6
|
+
// (CKC-23 AC-23); this draws whatever placement exists and counts what is not placed yet where it would sit.
|
|
7
|
+
//
|
|
8
|
+
// No DOM here: data in, places out, so the rules are tested with `node --test` (src/ui/placement.test.ts).
|
|
9
|
+
|
|
10
|
+
export const HIDDEN_VALIDITY = new Set(['Replaced', 'Deferred', 'Abandoned']);
|
|
11
|
+
export const INTENT_KINDS = ['Requirement', 'Design', 'Decision'];
|
|
12
|
+
const INTENT = new Set(INTENT_KINDS);
|
|
13
|
+
const RESULT = new Set(['Result', 'Review', 'Test', 'Session', 'Run']);
|
|
14
|
+
/** What a work item can implement: a requirement, a design, a contract (itself a work item). */
|
|
15
|
+
const CONTRACT = new Set(['Requirement', 'Design', 'Work item']);
|
|
16
|
+
/** The band of work in no plan (Spec §6.3 `Not in a plan`). */
|
|
17
|
+
export const NO_PLAN = 'none';
|
|
18
|
+
/** A band's cell: the band (a plan id, a generation id or NO_PLAN) and the column (an area id, or '' for no area). */
|
|
19
|
+
export const cellKey = (band, area) => `${band}|${area ?? ''}`;
|
|
20
|
+
const byName = (a, b) => String(a.label ?? '').localeCompare(String(b.label ?? ''), undefined, { numeric: true });
|
|
21
|
+
const isStruck = (n) => n?.validity === 'Replaced' || n?.validity === 'Abandoned' || n?.validity === 'Deferred';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The places of a project's objects. `data` is the graph payload (nodes, relations and, when the Keeper recorded them,
|
|
25
|
+
* generations); `showReplaced` draws replaced and deferred objects too (Spec §6.3 `Show replaced & deferred`).
|
|
26
|
+
*
|
|
27
|
+
* Returns:
|
|
28
|
+
* areas, plans the columns (areas by name, a cross-cutting foundation after every module: D101, Spec §6.3)
|
|
29
|
+
* and the current plans' bands (in the project's own order: the order
|
|
30
|
+
* the plans are listed in, which is the order its plan documents give them)
|
|
31
|
+
* generations the earlier generations, each with its items' destinations (Spec §2.12, D100: where its work
|
|
32
|
+
* went — carried on, moved into the current plan, dropped by which decision — never "abandoned")
|
|
33
|
+
* bands every band top to bottom: the generations, the current plans, `Not in a plan`
|
|
34
|
+
* place object id → { zone, band?, area?, areas?, by?, ring?, gen?, whole? }: a work item's `area` is the
|
|
35
|
+
* one it is drawn solid in, `areas` every area it serves (that one first), `by` the rule that chose
|
|
36
|
+
* it; `whole` the Keeper's reason when it serves its whole plan and no single area (CN)
|
|
37
|
+
* cells cellKey → work item ids, in the band's cell: a work item serving several areas is in each
|
|
38
|
+
* area's cell of its band, solid in its own and a dashed copy in the others (isCopyIn)
|
|
39
|
+
* areaWork area id → { total, shared, main }: the work in its column, how many shared, how many of those main here
|
|
40
|
+
* intent area id → the requirements, designs and decisions folded at the column's top
|
|
41
|
+
* ring { multi, whole, product, none } → ids: the cross-cutting ring (Spec §6.3: under the area row,
|
|
42
|
+
* above every band) — reaching several areas; on the whole product, with the Keeper's written
|
|
43
|
+
* reason (`wholeProductWhy`: placed, CM); only on the product without one (not placed, §1.4);
|
|
44
|
+
* on nothing yet
|
|
45
|
+
* exec plan id → the execution decisions placed on that plan (they refine the Plan)
|
|
46
|
+
* unplaced the counts of what is not placed yet, by kind and where it would sit
|
|
47
|
+
*/
|
|
48
|
+
export function placementOf(data, { showReplaced = false } = {}) {
|
|
49
|
+
const nodes = data?.nodes ?? [];
|
|
50
|
+
const relations = data?.relations ?? [];
|
|
51
|
+
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
52
|
+
const vis = (n) => Boolean(n) && (!HIDDEN_VALIDITY.has(n.validity) || showReplaced || n.recentChange);
|
|
53
|
+
// A cross-cutting foundation (D101; Spec §1.4, §6.3) is a column like any other, after every module's: it is the base
|
|
54
|
+
// the modules share, not a module users face. Modules by name, then the foundations by name.
|
|
55
|
+
const areas = nodes.filter((n) => n.category === 'Area' && vis(n)).sort((a, b) => (a.foundation ? 1 : 0) - (b.foundation ? 1 : 0) || byName(a, b));
|
|
56
|
+
const areaIds = new Set(areas.map((a) => a.id));
|
|
57
|
+
const out = new Map();
|
|
58
|
+
for (const r of relations) { if (!out.has(r.from)) out.set(r.from, []); out.get(r.from).push(r); }
|
|
59
|
+
const targets = (id, type, pred) => (out.get(id) ?? []).filter((r) => r.type === type).map((r) => byId.get(r.to)).filter((t) => t && (!pred || pred(t)));
|
|
60
|
+
|
|
61
|
+
// Earlier generations (Spec §2.12): their plans and work items, as the Keeper recorded them.
|
|
62
|
+
const gens = [...(data?.generations ?? [])].sort((a, b) => String(a.ended?.at ?? '').localeCompare(String(b.ended?.at ?? '')));
|
|
63
|
+
// CZ: an item a generation lists that was carried on under the same number (`carriedIds`, from the server) is current
|
|
64
|
+
// work: it stands in its current plan, never in the generation's band; the generation lists it as "carried on".
|
|
65
|
+
const carriedOn = new Set(gens.flatMap((g) => g.carriedIds ?? []));
|
|
66
|
+
const genOfPlan = new Map(), genOfWork = new Map();
|
|
67
|
+
for (const g of gens) { for (const p of g.planIds ?? []) genOfPlan.set(p, g.id); for (const w of g.workIds ?? []) if (!carriedOn.has(w)) genOfWork.set(w, g.id); }
|
|
68
|
+
// A current plan: not an earlier generation's, and not itself replaced or abandoned (then it is history, drawn in its
|
|
69
|
+
// generation's band or, with no generation recorded, not as a band of its own).
|
|
70
|
+
const plans = nodes.filter((n) => n.category === 'Plan' && !genOfPlan.has(n.id) && vis(n) && !(n.validity === 'Replaced' || n.validity === 'Abandoned'));
|
|
71
|
+
const planIds = new Set(plans.map((p) => p.id));
|
|
72
|
+
|
|
73
|
+
/** The area an object leads to: itself when it is one, else the area the server derived for it (`areaId`). */
|
|
74
|
+
const areaOfTarget = (t) => (t.category === 'Area' ? (areaIds.has(t.id) ? t.id : null) : areaIds.has(t.areaId) ? t.areaId : null);
|
|
75
|
+
const distinct = (list) => [...new Set(list.filter(Boolean))];
|
|
76
|
+
/** What a work item serves, in the order the Keeper wrote it (`servesOrder`, graph-view.ts), else the relations'. */
|
|
77
|
+
const servedInOrder = (n) => {
|
|
78
|
+
const rels = (out.get(n.id) ?? []).filter((r) => r.type === 'serves');
|
|
79
|
+
const order = Array.isArray(n.servesOrder) ? new Map(n.servesOrder.map((id, i) => [id, i])) : null;
|
|
80
|
+
const at = (r) => order?.get(r.to) ?? Number.MAX_SAFE_INTEGER;
|
|
81
|
+
return (order ? [...rels].sort((a, b) => at(a) - at(b)) : rels).map((r) => byId.get(r.to)).filter(Boolean);
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* The areas a work item serves and the one it is drawn solid in (owner, 2026-09-30, verbatim: 「实线框的规则是。 a.这项
|
|
85
|
+
* 工作实现的合同属于哪个模块,就放哪; b.没有合同的,看派工单或提示词写的是哪个模块; c.都没写,才用"写在最前的"。」;
|
|
86
|
+
* Spec §1.4):
|
|
87
|
+
* (a) the module of the contract it implements — what it `implements`, or the requirement, design or contract work
|
|
88
|
+
* item it `serves`, the first of them that leads to an area. A contract of several modules gives the one this
|
|
89
|
+
* work's ticket or prompt names among them, else the one the contract lists first;
|
|
90
|
+
* (b) with no contract, the module its dispatch ticket or prompt names, which the Keeper writes as its first `serves`
|
|
91
|
+
* to an Area;
|
|
92
|
+
* (c) with neither, the first one listed.
|
|
93
|
+
* `by` says which rule placed it: contract · named · first.
|
|
94
|
+
*/
|
|
95
|
+
const workAreas = (n, seen = new Set()) => {
|
|
96
|
+
seen.add(n.id);
|
|
97
|
+
const served = servedInOrder(n);
|
|
98
|
+
const servedAreas = distinct(served.map(areaOfTarget));
|
|
99
|
+
const named = served.filter((t) => t.category === 'Area').map(areaOfTarget).find(Boolean) ?? null;
|
|
100
|
+
// A contract's modules: a contract work item's own areas (its main one first); a requirement's or a design's area and
|
|
101
|
+
// the other areas it refines.
|
|
102
|
+
const modulesOf = (t) => (t.category === 'Work item' ? (seen.has(t.id) ? [] : workAreas(t, seen).areas) : distinct([areaOfTarget(t), ...targets(t.id, 'refines').map(areaOfTarget)]));
|
|
103
|
+
const modules = [...targets(n.id, 'implements'), ...served.filter((t) => CONTRACT.has(t.category))].map(modulesOf).find((l) => l.length) ?? [];
|
|
104
|
+
const contract = modules.length ? (named && modules.includes(named) ? named : modules[0]) : null;
|
|
105
|
+
const first = servedAreas[0] ?? (areaIds.has(n.areaId) ? n.areaId : null);
|
|
106
|
+
const area = contract ?? named ?? first;
|
|
107
|
+
return { area, areas: distinct([area, ...servedAreas]), by: contract ? 'contract' : named ? 'named' : area ? 'first' : null };
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const place = new Map();
|
|
111
|
+
const cells = new Map();
|
|
112
|
+
const intent = new Map(areas.map((a) => [a.id, []]));
|
|
113
|
+
const ring = { multi: [], whole: [], product: [], none: [] };
|
|
114
|
+
const exec = new Map(plans.map((p) => [p.id, []]));
|
|
115
|
+
const genExec = new Map(gens.map((g) => [g.id, []]));
|
|
116
|
+
const genPlans = new Map(gens.map((g) => [g.id, []]));
|
|
117
|
+
const also = new Map(); // work id → { areas: [...], plans: [...] } where it is listed besides its own cell
|
|
118
|
+
const addCell = (band, area, id) => { const k = cellKey(band, area); if (!cells.has(k)) cells.set(k, []); cells.get(k).push(id); };
|
|
119
|
+
|
|
120
|
+
for (const n of nodes) {
|
|
121
|
+
// An earlier generation's plan is history in its band, whatever its validity; anything else hidden is not placed.
|
|
122
|
+
if (!vis(n) && !(n.category === 'Plan' && genOfPlan.has(n.id))) continue;
|
|
123
|
+
const c = n.category;
|
|
124
|
+
if (c === "Owner's words" || n.group === "Owner's words") { place.set(n.id, { zone: 'words' }); continue; }
|
|
125
|
+
if (c === 'Product' || c === 'Goal') { place.set(n.id, { zone: c === 'Product' ? 'product' : 'goal' }); continue; }
|
|
126
|
+
if (c === 'Area') { if (areaIds.has(n.id)) place.set(n.id, { zone: 'area', area: n.id }); continue; }
|
|
127
|
+
if (c === 'Plan') {
|
|
128
|
+
if (genOfPlan.has(n.id)) { place.set(n.id, { zone: 'gen-plan', band: genOfPlan.get(n.id), gen: genOfPlan.get(n.id) }); genPlans.get(genOfPlan.get(n.id))?.push(n.id); }
|
|
129
|
+
else if (planIds.has(n.id)) place.set(n.id, { zone: 'head', band: n.id });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (c === 'Work item') {
|
|
133
|
+
if (!vis(n)) continue;
|
|
134
|
+
const { area, areas: inAreas, by } = workAreas(n);
|
|
135
|
+
const servedPlans = servedInOrder(n).filter((t) => t.category === 'Plan').map((t) => t.id);
|
|
136
|
+
// "写了几个区域或几个计划的,落在写在最前的那一个" (Spec §1.4): the first current plan it serves.
|
|
137
|
+
const plan = servedPlans.find((p) => planIds.has(p)) ?? null;
|
|
138
|
+
// An item an earlier generation planned that moved into a current plan stands in that plan; the generation's band
|
|
139
|
+
// counts it as "moved into" and does not hold it (otherwise the current plan's band would look empty).
|
|
140
|
+
const gen = plan || carriedOn.has(n.id) ? null : genOfWork.get(n.id) ?? servedPlans.map((p) => genOfPlan.get(p)).find(Boolean) ?? null;
|
|
141
|
+
const band = gen ?? plan ?? NO_PLAN;
|
|
142
|
+
// Work for its whole plan — all of the plan's modules, no single one — with the Keeper's written reason (CN; E152;
|
|
143
|
+
// Spec §1.4): placed, in its band's cross-cutting cell. It needs a plan, and counts only while it serves no area.
|
|
144
|
+
const whole = !area && band !== NO_PLAN && n.wholePlanWhy ? String(n.wholePlanWhy) : null;
|
|
145
|
+
// CQ (D104): a recorded reason the records lead nowhere ("written: no plan — why") places the item by that reason: it is
|
|
146
|
+
// shown in the detail and not counted as unplaced.
|
|
147
|
+
const noPlanWhy = band === NO_PLAN && n.noPlanWhy ? String(n.noPlanWhy) : null;
|
|
148
|
+
const noAreaWhy = !area && !whole && n.noAreaWhy ? String(n.noAreaWhy) : null;
|
|
149
|
+
// DB: the plans it serves that have no band of their own — not current (Proposed, Deferred, Replaced, Abandoned) or
|
|
150
|
+
// held by an earlier generation while this item was carried on. It stands in `Not in a plan`, with those plans named.
|
|
151
|
+
const plansNotDrawn = band === NO_PLAN ? distinct(servedPlans.filter((p) => !planIds.has(p))) : [];
|
|
152
|
+
place.set(n.id, { zone: gen ? 'gen' : 'cell', band, area, areas: inAreas, by, gen, plan: gen ? null : plan, ...(whole ? { whole } : {}), ...(noPlanWhy ? { noPlanWhy } : {}), ...(noAreaWhy ? { noAreaWhy } : {}), ...(plansNotDrawn.length ? { plansNotDrawn } : {}) });
|
|
153
|
+
// In every area it serves (owner, 2026-09-30: "appear in every column it serves"): its own cell first — the solid
|
|
154
|
+
// one — then a dashed copy in each other area's cell of the same band.
|
|
155
|
+
addCell(band, area, n.id);
|
|
156
|
+
for (const a of inAreas) if (a !== area) addCell(band, a, n.id);
|
|
157
|
+
const otherAreas = inAreas.filter((a) => a !== area);
|
|
158
|
+
const otherPlans = servedPlans.filter((p) => p !== plan && planIds.has(p));
|
|
159
|
+
if (otherAreas.length || otherPlans.length) also.set(n.id, { areas: otherAreas, plans: otherPlans });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (INTENT.has(c)) {
|
|
163
|
+
// An execution decision (the log of how a plan was carried out) refines the Plan whose execution it records.
|
|
164
|
+
const onPlans = targets(n.id, 'refines', (t) => t.category === 'Plan').map((t) => t.id);
|
|
165
|
+
const execPlan = onPlans.find((p) => planIds.has(p)) ?? null;
|
|
166
|
+
const execGen = onPlans.map((p) => genOfPlan.get(p)).find(Boolean) ?? null;
|
|
167
|
+
if (execPlan) { place.set(n.id, { zone: 'exec', band: execPlan }); exec.get(execPlan).push(n.id); continue; }
|
|
168
|
+
if (execGen) { place.set(n.id, { zone: 'exec', band: execGen, gen: execGen, plan: onPlans.find((p) => genOfPlan.get(p) === execGen) ?? null }); genExec.get(execGen).push(n.id); continue; }
|
|
169
|
+
const refined = targets(n.id, 'refines');
|
|
170
|
+
let reached = distinct(refined.map(areaOfTarget));
|
|
171
|
+
if (!reached.length && areaIds.has(n.areaId)) reached = [n.areaId];
|
|
172
|
+
if (reached.length === 1) { place.set(n.id, { zone: 'intent', area: reached[0] }); intent.get(reached[0]).push(n.id); continue; }
|
|
173
|
+
if (reached.length > 1) { place.set(n.id, { zone: 'ring', ring: 'multi', areas: reached }); ring.multi.push(n.id); continue; }
|
|
174
|
+
// Only on the product (or a goal) is not a place (Spec §1.4 "只挂在 Product 上、说不出落在哪里的,不算落位") — unless
|
|
175
|
+
// the Keeper wrote why it concerns the whole product (`wholeProductWhy`, CM; Spec §1.4 "不针对任何一个区域的…也在圈里,
|
|
176
|
+
// 写明为什么"): then it is placed, in the ring's `Whole product` group, with that reason.
|
|
177
|
+
const onProduct = refined.some((t) => t.category === 'Product' || t.category === 'Goal');
|
|
178
|
+
if (onProduct && n.wholeProductWhy) { place.set(n.id, { zone: 'ring', ring: 'whole', why: n.wholeProductWhy }); ring.whole.push(n.id); continue; }
|
|
179
|
+
place.set(n.id, { zone: 'ring', ring: onProduct ? 'product' : 'none' });
|
|
180
|
+
ring[onProduct ? 'product' : 'none'].push(n.id);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (RESULT.has(c)) place.set(n.id, { zone: 'result', work: n.parentId ?? null });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Earlier generations: where each of their items went (Spec §2.12; D100) ──
|
|
187
|
+
const inGen = (id) => genOfWork.has(id) || genOfPlan.has(id);
|
|
188
|
+
const incoming = new Map();
|
|
189
|
+
for (const r of relations) { if (!incoming.has(r.to)) incoming.set(r.to, []); incoming.get(r.to).push(r); }
|
|
190
|
+
const CARRY = new Set(['replaces', 'depends on', 'carries out', 'refines', 'serves', 'implements']);
|
|
191
|
+
// `replacedBy` is an id, or the successor's own number as the project writes it (e.g. "CKC-04"); a number is
|
|
192
|
+
// matched against the labels of objects still in force (an item carried on from one generation into the current plan is
|
|
193
|
+
// listed in that generation too, and still counts as where the earlier item went).
|
|
194
|
+
const successorOf = (ref) => {
|
|
195
|
+
if (!ref) return null;
|
|
196
|
+
const direct = byId.get(ref);
|
|
197
|
+
if (direct) return direct;
|
|
198
|
+
const key = String(ref).trim();
|
|
199
|
+
return nodes.find((m) => m.validity !== 'Replaced' && m.validity !== 'Abandoned' && (m.label === key || String(m.label ?? '').startsWith(`${key} `))) ?? null;
|
|
200
|
+
};
|
|
201
|
+
const generations = gens.map((g) => {
|
|
202
|
+
const items = (g.workIds ?? []).map((id) => {
|
|
203
|
+
const n = byId.get(id);
|
|
204
|
+
if (!n) return { id, destination: { kind: 'unrecorded', done: false } };
|
|
205
|
+
let destination;
|
|
206
|
+
// Replaced by a current object (not a decision) is carried on in it: the later work was built on it (owner,
|
|
207
|
+
// 2026-09-30: 「这两代不是完全废弃了」). Dropped is what a decision ended, or what was abandoned or deferred.
|
|
208
|
+
const successor = n.validity === 'Replaced' ? successorOf(n.replacedBy) : null;
|
|
209
|
+
// CZ: carried on under the same number — the current plan lists the same item (a deferred one among them).
|
|
210
|
+
if (carriedOn.has(id) && n.validity !== 'Replaced' && n.validity !== 'Abandoned') destination = { kind: 'carried', to: id, toLabel: n.label, how: 'under the same number', same: true, deferred: n.validity === 'Deferred' };
|
|
211
|
+
else if (successor && successor.id !== id && successor.validity !== 'Replaced' && successor.validity !== 'Abandoned' && successor.category !== 'Decision') destination = { kind: 'carried', to: successor.id, toLabel: successor.label, how: 'replaced by', deferred: successor.validity === 'Deferred' };
|
|
212
|
+
else if (n.validity === 'Deferred') destination = { kind: 'deferred' };
|
|
213
|
+
// Dropped needs something that ended it: abandoned, or replaced by a decision. Replaced with no successor recorded
|
|
214
|
+
// says only that the organizing has not found where it went yet.
|
|
215
|
+
else if (n.validity === 'Abandoned' || (n.validity === 'Replaced' && n.replacedBy)) destination = { kind: 'dropped', by: n.replacedBy ?? null, byLabel: byId.get(n.replacedBy)?.label ?? n.replacedBy ?? null, validity: n.validity };
|
|
216
|
+
else if (isStruck(n)) destination = { kind: 'unrecorded', done: n.progress === 'Done' };
|
|
217
|
+
else {
|
|
218
|
+
const moved = targets(id, 'serves', (t) => t.category === 'Plan' && planIds.has(t.id))[0];
|
|
219
|
+
const carrier = (incoming.get(id) ?? []).find((r) => CARRY.has(r.type) && byId.has(r.from) && !inGen(r.from) && !RESULT.has(byId.get(r.from).category));
|
|
220
|
+
if (moved) destination = { kind: 'moved', to: moved.id, toLabel: moved.label };
|
|
221
|
+
else if (carrier) destination = { kind: 'carried', to: carrier.from, toLabel: byId.get(carrier.from).label, how: carrier.type };
|
|
222
|
+
else destination = { kind: 'unrecorded', done: n.progress === 'Done' };
|
|
223
|
+
}
|
|
224
|
+
return { id, destination };
|
|
225
|
+
});
|
|
226
|
+
const count = (k) => items.filter((i) => i.destination.kind === k).length;
|
|
227
|
+
// In the band's one line a dropping decision is named by its number alone (its title is on the item itself).
|
|
228
|
+
const shortName = (label) => { const t = String(label ?? '').trim(); const m = /^([A-Za-z]{1,6}[-‑]?\d+[\w.-]*)/.exec(t); return m ? m[1] : t.length > 28 ? `${t.slice(0, 27)}…` : t; };
|
|
229
|
+
const droppedBy = distinct(items.filter((i) => i.destination.kind === 'dropped').map((i) => (i.destination.byLabel ? shortName(i.destination.byLabel) : i.destination.byLabel)));
|
|
230
|
+
const done = items.filter((i) => byId.get(i.id)?.progress === 'Done').length;
|
|
231
|
+
return {
|
|
232
|
+
id: g.id, name: g.name, started: g.started ?? null, ended: g.ended ?? null, endedBy: g.endedBy ?? null,
|
|
233
|
+
workIds: g.workIds ?? [], planIds: genPlans.get(g.id) ?? [], execIds: genExec.get(g.id) ?? [], items,
|
|
234
|
+
// A generation whose items the organizing did not record is `Not organized` (owner, 2026-09-30); its name, end
|
|
235
|
+
// and ending document still stand.
|
|
236
|
+
organized: items.length > 0,
|
|
237
|
+
destinations: { planned: items.length, done, carried: count('carried'), moved: count('moved'), deferred: count('deferred'), dropped: count('dropped'), droppedBy, unrecorded: count('unrecorded') },
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
const destinationOf = new Map(generations.flatMap((g) => g.items.map((i) => [i.id, i.destination])));
|
|
241
|
+
|
|
242
|
+
// ── What is not placed yet, counted where it would sit (Spec §1.4, §6.3) ──
|
|
243
|
+
// DB: one definition — the lists (`unplacedOf`) say which objects, the counts here are their sizes, and the program's
|
|
244
|
+
// counts for the Keeper (src/keeper/organize/round-open.ts) are read from the same lists.
|
|
245
|
+
const kindCount = (ids) => { const o = {}; for (const id of ids) { const c = byId.get(id)?.category; if (c) o[c] = (o[c] ?? 0) + 1; } return o; };
|
|
246
|
+
const lists = unplacedOf({ place, ring });
|
|
247
|
+
const unplaced = {
|
|
248
|
+
workNoPlan: lists.workNoPlan.length,
|
|
249
|
+
workNoArea: lists.workNoArea.length,
|
|
250
|
+
workWholePlan: lists.workWholePlan.length,
|
|
251
|
+
workWrittenNoPlan: lists.workWrittenNoPlan.length,
|
|
252
|
+
workWrittenNoArea: lists.workWrittenNoArea.length,
|
|
253
|
+
workNeither: lists.workNeither.length,
|
|
254
|
+
intentProductOnly: kindCount(lists.intentProductOnly),
|
|
255
|
+
intentNowhere: kindCount(lists.intentNowhere),
|
|
256
|
+
intent: lists.intentProductOnly.length + lists.intentNowhere.length,
|
|
257
|
+
};
|
|
258
|
+
const bands = [
|
|
259
|
+
...generations.map((g) => ({ key: g.id, kind: 'gen', gen: g })),
|
|
260
|
+
...plans.map((p) => ({ key: p.id, kind: 'plan', plan: p })),
|
|
261
|
+
{ key: NO_PLAN, kind: 'none' },
|
|
262
|
+
];
|
|
263
|
+
const bandOrder = new Map(bands.map((b, i) => [b.key, i]));
|
|
264
|
+
// Each area's current work, drawn or folded, for its own line (`AREA · 9 work items · 2 shared`): every work item in
|
|
265
|
+
// its column under the current plans or in no plan, solid or dashed, and how many of them are shared with another
|
|
266
|
+
// area (`main`: of those, the ones solid here). An earlier generation's items are counted on its band.
|
|
267
|
+
const areaWork = new Map(areas.map((a) => [a.id, { total: 0, shared: 0, main: 0 }]));
|
|
268
|
+
for (const p of place.values()) {
|
|
269
|
+
if (p.zone !== 'cell') continue;
|
|
270
|
+
for (const a of p.areas) { const c = areaWork.get(a); if (!c) continue; c.total++; if (p.areas.length > 1) { c.shared++; if (a === p.area) c.main++; } }
|
|
271
|
+
}
|
|
272
|
+
return { areas, plans, generations, bands, bandOrder, place, cells, intent, ring, exec, also, destinationOf, unplaced, areaWork, byId };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* What is not placed yet, as lists of ids (Spec §1.4, §6.3; DB) — the one definition of "unplaced" for the workbench and
|
|
277
|
+
* for the program's counts:
|
|
278
|
+
* workNoPlan current work in the `Not in a plan` band with no recorded reason: it serves no plan that has a band
|
|
279
|
+
* (`plansNotDrawn` on its place names the plans it does serve — not current, or an earlier generation's)
|
|
280
|
+
* workNoArea current work in no area, not whole-plan work, with no recorded reason
|
|
281
|
+
* workWholePlan work for its whole plan, with the Keeper's reason: placed
|
|
282
|
+
* workWrittenNoPlan work written as in no plan, with the recorded reason: placed by the reason
|
|
283
|
+
* workWrittenNoArea work written as in no module, with the recorded reason
|
|
284
|
+
* workNeither in no plan and in no area, with no reason for either
|
|
285
|
+
* intentProductOnly requirements, designs and decisions only on the product (or a goal) with no written reason
|
|
286
|
+
* intentNowhere requirements, designs and decisions on nothing
|
|
287
|
+
* An earlier generation's items are counted on its band, not here. CQ (D104): a recorded reason places the work by that reason.
|
|
288
|
+
*/
|
|
289
|
+
export function unplacedOf(M) {
|
|
290
|
+
const works = [...M.place.entries()].filter(([, p]) => p.zone === 'cell');
|
|
291
|
+
const ids = (pred) => works.filter(([, p]) => pred(p)).map(([id]) => id);
|
|
292
|
+
return {
|
|
293
|
+
workNoPlan: ids((p) => p.band === NO_PLAN && !p.noPlanWhy),
|
|
294
|
+
workNoArea: ids((p) => !p.area && !p.whole && !p.noAreaWhy),
|
|
295
|
+
workWholePlan: ids((p) => Boolean(p.whole)),
|
|
296
|
+
workWrittenNoPlan: ids((p) => p.band === NO_PLAN && Boolean(p.noPlanWhy)),
|
|
297
|
+
workWrittenNoArea: ids((p) => !p.area && !p.whole && Boolean(p.noAreaWhy)),
|
|
298
|
+
workNeither: ids((p) => p.band === NO_PLAN && !p.area && !p.noPlanWhy && !p.noAreaWhy),
|
|
299
|
+
intentProductOnly: [...M.ring.product],
|
|
300
|
+
intentNowhere: [...M.ring.none],
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Whether `id` in the column of `area` is a dashed copy: a work item standing there whose solid one is elsewhere. */
|
|
305
|
+
export const isCopyIn = (M, id, area) => { const p = M.place.get(id); return Boolean(p && area && p.area !== area && p.areas?.includes(area)); };
|
|
306
|
+
const shortArea = (M, id) => String(M.byId.get(id)?.label ?? id).split(' · ')[0];
|
|
307
|
+
/**
|
|
308
|
+
* How a shared work item is marked where it stands (owner, 2026-09-30): in the column of `area`, solid (its main one,
|
|
309
|
+
* saying where else it is) or dashed (a copy, saying where its main one is). Null for work that serves one area.
|
|
310
|
+
* The Graph writes `text` on the object's second line, the List on the row's tag.
|
|
311
|
+
*/
|
|
312
|
+
export function sharedMark(M, id, area) {
|
|
313
|
+
const p = M.place.get(id);
|
|
314
|
+
if (!p || (p.areas?.length ?? 0) < 2 || !p.areas.includes(area)) return null;
|
|
315
|
+
const others = p.areas.filter((a) => a !== p.area);
|
|
316
|
+
if (area === p.area) return { copy: false, main: p.area, others, by: p.by, text: `also in ${others.map((a) => shortArea(M, a)).join(', ')}` };
|
|
317
|
+
return { copy: true, main: p.area, others, by: p.by, text: `main in ${shortArea(M, p.area)}` };
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* The work rows of an area's block in the List (`null`: the cross-cutting block's work of no area): each work item in
|
|
321
|
+
* the area's cells under the current plans and in `Not in a plan` — an earlier generation's are in its band — with its
|
|
322
|
+
* marking there (sharedMark): solid, dashed, or none for work that serves one area.
|
|
323
|
+
*/
|
|
324
|
+
export function listRowsOf(M, area) {
|
|
325
|
+
const gens = new Set(M.generations.map((g) => g.id));
|
|
326
|
+
const rows = [];
|
|
327
|
+
for (const [key, ids] of M.cells) {
|
|
328
|
+
const cut = key.lastIndexOf('|');
|
|
329
|
+
if (key.slice(cut + 1) !== (area ?? '') || gens.has(key.slice(0, cut))) continue;
|
|
330
|
+
for (const id of ids) rows.push({ id, copy: isCopyIn(M, id, area), mark: area ? sharedMark(M, id, area) : null, whole: M.place.get(id)?.whole ?? null });
|
|
331
|
+
}
|
|
332
|
+
return rows;
|
|
333
|
+
}
|
|
334
|
+
/** Why a shared work item's main one stands where it does (the owner's rule a, b, c), in words for its tooltip. */
|
|
335
|
+
export const MAIN_BY = {
|
|
336
|
+
contract: 'the contract it implements belongs to this module',
|
|
337
|
+
named: 'no contract; its dispatch ticket or prompt names this module (the first area the Keeper wrote)',
|
|
338
|
+
first: 'no contract, and no ticket or prompt names a module: the first one listed',
|
|
339
|
+
};
|
|
340
|
+
/** The label a cross-cutting foundation's column head and List block head carry beside the project's name (D101). */
|
|
341
|
+
export const FOUNDATION_LABEL = 'Cross-cutting foundation';
|
|
342
|
+
/** The ring's group of items placed on the whole product, each with the Keeper's written reason (CM; Spec §1.4). */
|
|
343
|
+
export const WHOLE_LABEL = 'Whole product';
|
|
344
|
+
/** Work that serves its whole plan and no single module, with the Keeper's written reason (CN; Spec §1.4). */
|
|
345
|
+
export const WHOLE_PLAN_LABEL = 'Whole plan';
|
|
346
|
+
/** An area's count of work in words: `9 work items · 2 shared`. */
|
|
347
|
+
export function areaWorkLine(c) {
|
|
348
|
+
const t = c?.total ?? 0;
|
|
349
|
+
return `${t} work item${t === 1 ? '' : 's'}${c?.shared ? ` · ${c.shared} shared` : ''}`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Whether a work item in this place is placed yet: a plan and an area (Spec §1.4; CKC-23 AC-23). */
|
|
353
|
+
// CQ (D104): a recorded reason ("written: no plan — why", "written: no module — why") places the work by that reason.
|
|
354
|
+
export const isUnplacedWork = (p) => Boolean(p) && (p.zone === 'cell' || p.zone === 'gen') && ((p.band === NO_PLAN && !p.noPlanWhy) || (!p.area && !p.whole && !p.noAreaWhy));
|
|
355
|
+
|
|
356
|
+
/** What a generation the organizing recorded no items for says in their place (owner, 2026-09-30: 「显示未整理」). */
|
|
357
|
+
export const NOT_ORGANIZED = 'Not organized';
|
|
358
|
+
/** A generation's destinations in one line (Spec §2.12): what it planned and where its work went; never "abandoned". */
|
|
359
|
+
export function destinationLine(d) {
|
|
360
|
+
if (!d?.planned) return NOT_ORGANIZED;
|
|
361
|
+
const bits = [`${d.planned} planned`, `${d.done} done`];
|
|
362
|
+
if (d.carried) bits.push(`${d.carried} carried on`);
|
|
363
|
+
if (d.moved) bits.push(`${d.moved} moved into the current plan`);
|
|
364
|
+
if (d.deferred) bits.push(`${d.deferred} deferred`);
|
|
365
|
+
if (d.dropped) bits.push(`${d.dropped} dropped${d.droppedBy.length ? ` by ${d.droppedBy.join(', ')}` : ''}`);
|
|
366
|
+
if (d.unrecorded) bits.push(`${d.unrecorded} where it went not recorded yet`);
|
|
367
|
+
return bits.join(' · ');
|
|
368
|
+
}
|
|
369
|
+
/** One item's destination in words, for the object's second line and the List row. */
|
|
370
|
+
export function destinationText(dest) {
|
|
371
|
+
if (!dest) return '';
|
|
372
|
+
if (dest.kind === 'moved') return `→ moved into ${dest.toLabel}`;
|
|
373
|
+
if (dest.kind === 'carried') return dest.same ? `→ carried on under the same number${dest.deferred ? ' (deferred)' : ''}` : `→ carried on in ${dest.toLabel}${dest.deferred ? ' (deferred)' : ''}`;
|
|
374
|
+
if (dest.kind === 'deferred') return 'deferred';
|
|
375
|
+
if (dest.kind === 'dropped') return `dropped${dest.byLabel ? ` by ${dest.byLabel}` : ''}`;
|
|
376
|
+
return dest.done ? 'done · where it went not recorded yet' : 'where it went not recorded yet';
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The notes on objects that still need the owner (Spec §2.7, §6.2; D100): each object — a node, or a relation — with
|
|
381
|
+
* the count of its notes still in `Notes (attention)`; a note on a path counts on the path's top object (the server
|
|
382
|
+
* says which: `noteAttention`). `count` sums the marks, `objects` the objects they hang on, `ids` their ids. The drawer's
|
|
383
|
+
* count of notes (CF, strip-rows.js) follows the same rule from the overview (graph-view.ts `inAttention`).
|
|
384
|
+
*/
|
|
385
|
+
export function objectAttentionNotes(view) {
|
|
386
|
+
const nodes = view?.nodes ?? [];
|
|
387
|
+
const relations = view?.relations ?? [];
|
|
388
|
+
const label = new Map(nodes.map((n) => [n.id, n.label]));
|
|
389
|
+
const objects = [
|
|
390
|
+
...nodes.filter((n) => n.noteAttention > 0).map((n) => ({ id: n.id, kind: 'node', label: n.label, category: n.category, attention: n.noteAttention })),
|
|
391
|
+
...relations.filter((r) => r.noteAttention > 0).map((r) => ({ id: r.id, kind: 'relation', label: `${r.type}: ${label.get(r.from) ?? r.from} → ${label.get(r.to) ?? r.to}`, attention: r.noteAttention, ends: [r.from, r.to] })),
|
|
392
|
+
];
|
|
393
|
+
return { count: objects.reduce((s, o) => s + o.attention, 0), objects, ids: objects.map((o) => o.id) };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The object a `replacedBy` names: an id, or the successor's own number as the project writes it ("D60"), matched
|
|
398
|
+
* against the labels of objects still in force — the rule the generation bands use (successorOf above).
|
|
399
|
+
*/
|
|
400
|
+
export function replacementOf(M, ref) {
|
|
401
|
+
if (!ref) return null;
|
|
402
|
+
const direct = M.byId.get(ref);
|
|
403
|
+
if (direct) return direct;
|
|
404
|
+
const key = String(ref).trim();
|
|
405
|
+
for (const m of M.byId.values()) {
|
|
406
|
+
if (m.validity === 'Replaced' || m.validity === 'Abandoned') continue;
|
|
407
|
+
if (m.label === key || String(m.label ?? '').startsWith(`${key} `)) return m;
|
|
408
|
+
}
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Where an object sits, for the top of its popover (owner 2026-09-30, of a decision: 「他到底是什么,现在是不是current
|
|
414
|
+
* 看不出来」): a requirement, design or decision on its module, on several, on a plan (an execution decision), only on
|
|
415
|
+
* the product (not a place, Spec §1.4) or on nothing yet; a work item in its module and plan. `ids` are the objects it
|
|
416
|
+
* sits on, `note` what is missing. Null for what has no place to state (the product, a goal, an area, a result).
|
|
417
|
+
* Read from the same placement the Graph and the List draw, so the three cannot disagree.
|
|
418
|
+
*/
|
|
419
|
+
export function whereItSits(M, id) {
|
|
420
|
+
const p = M.place.get(id);
|
|
421
|
+
if (!p) return null;
|
|
422
|
+
const genName = (g) => M.generations.find((x) => x.id === g)?.name ?? g;
|
|
423
|
+
switch (p.zone) {
|
|
424
|
+
case 'intent': return { kind: 'module', lead: 'On the module', ids: [p.area], note: null };
|
|
425
|
+
case 'ring':
|
|
426
|
+
if (p.ring === 'multi') return { kind: 'modules', lead: `On ${p.areas.length} modules`, ids: p.areas, note: 'cross-cutting' };
|
|
427
|
+
if (p.ring === 'whole') return { kind: 'whole', lead: 'Whole product', ids: [], note: p.why };
|
|
428
|
+
if (p.ring === 'product') return { kind: 'product', lead: 'Product only', ids: [], note: 'not placed on a module or plan yet' };
|
|
429
|
+
return { kind: 'none', lead: 'Not placed yet', ids: [], note: 'on no module, plan or product' };
|
|
430
|
+
case 'exec':
|
|
431
|
+
return p.gen
|
|
432
|
+
? { kind: 'plan', lead: 'On a plan of an earlier generation', ids: p.plan ? [p.plan] : [], note: genName(p.gen) }
|
|
433
|
+
: { kind: 'plan', lead: 'On the plan', ids: [p.band], note: 'an execution decision' };
|
|
434
|
+
case 'cell': case 'gen': {
|
|
435
|
+
if (p.whole) return { kind: 'work', lead: `${WHOLE_PLAN_LABEL} of`, ids: [p.plan].filter(Boolean), note: p.gen ? `${p.whole} · in the earlier generation ${genName(p.gen)}` : p.whole };
|
|
436
|
+
// CQ (D104): a recorded reason reads "written: no plan — why" where "not in a plan yet" would stand.
|
|
437
|
+
const missing = [
|
|
438
|
+
!p.area ? (p.noAreaWhy ? `written: no module — ${p.noAreaWhy}` : 'no module yet') : null,
|
|
439
|
+
!p.plan ? (p.gen ? `in the earlier generation ${genName(p.gen)}` : p.noPlanWhy ? `written: no plan — ${p.noPlanWhy}` : 'not in a plan yet') : null,
|
|
440
|
+
].filter(Boolean);
|
|
441
|
+
return { kind: 'work', lead: 'In', ids: [p.area, p.plan].filter(Boolean), note: missing.join(' · ') || null };
|
|
442
|
+
}
|
|
443
|
+
default: return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Types for popover-place.js, so the node tests under src/ check against them. The browser never loads this file. */
|
|
2
|
+
export interface Rect { left: number; top: number; right: number; bottom: number }
|
|
3
|
+
export type Side = 'right' | 'left' | 'bottom' | 'top';
|
|
4
|
+
export interface Placement { left: number; top: number; width: number; height: number; side: Side; covers: boolean; overlaps: boolean }
|
|
5
|
+
export function placePopover(options: {
|
|
6
|
+
anchor: Rect;
|
|
7
|
+
size: { width: number; height: number };
|
|
8
|
+
bounds: Rect;
|
|
9
|
+
gap?: number;
|
|
10
|
+
margin?: number;
|
|
11
|
+
prefer?: readonly Side[];
|
|
12
|
+
keepSide?: Side | null;
|
|
13
|
+
/** Rectangles to keep off when that can be helped (the pill at the bottom right). */
|
|
14
|
+
avoid?: readonly Rect[];
|
|
15
|
+
}): Placement;
|
|
16
|
+
export function standBeside(ways: ReadonlyArray<() => Rect | null | undefined>): () => Rect | null;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Where the details popover goes (Spec §6.4). No DOM here: rectangles in, a position out, so the rule can be tested
|
|
2
|
+
// without a browser (src/ui/popover-place.test.ts). All rectangles are { left, top, right, bottom } in one coordinate
|
|
3
|
+
// space (the viewport, in practice).
|
|
4
|
+
//
|
|
5
|
+
// The rule: beside the object, on the first side that leaves the object uncovered — to its right, else to its left,
|
|
6
|
+
// else below, else above — and always wholly inside `bounds`, the main view's visible area, which is narrower while
|
|
7
|
+
// the Keeper conversation is docked. A popover that is already up keeps its side while that side still fits, so an
|
|
8
|
+
// update in place does not make it jump. When no side is free (the object fills the area) it takes the side where it
|
|
9
|
+
// covers the least of the object and reports `covers`.
|
|
10
|
+
//
|
|
11
|
+
// `avoid` names rectangles to keep off when that can be helped — the pill at the bottom right (CKC-09 AC-1). On the side
|
|
12
|
+
// it stands, the popover slides along the object (up or down beside it, left or right above or below it) by just enough
|
|
13
|
+
// to clear them; a side where no slide clears them gives way to a side that is clear. The rules above come first: when
|
|
14
|
+
// nothing is clear it still lies wholly inside the area and off its object, on the rectangle, and reports `overlaps`.
|
|
15
|
+
|
|
16
|
+
const SIDES = ['right', 'left', 'bottom', 'top'];
|
|
17
|
+
const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), Math.max(lo, hi));
|
|
18
|
+
const overlap = (a, b) => Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left)) * Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{ anchor: {left:number,top:number,right:number,bottom:number}, size: {width:number,height:number},
|
|
22
|
+
* bounds: {left:number,top:number,right:number,bottom:number}, gap?: number, margin?: number,
|
|
23
|
+
* prefer?: readonly string[], keepSide?: string|null, avoid?: ReadonlyArray<{left:number,top:number,right:number,bottom:number}> }} o
|
|
24
|
+
* @returns {{ left:number, top:number, width:number, height:number, side:string, covers:boolean, overlaps:boolean }}
|
|
25
|
+
*/
|
|
26
|
+
export function placePopover({ anchor, size, bounds, gap = 8, margin = 8, prefer = SIDES, keepSide = null, avoid = [] }) {
|
|
27
|
+
const area = { left: bounds.left + margin, top: bounds.top + margin, right: bounds.right - margin, bottom: bounds.bottom - margin };
|
|
28
|
+
if (area.right < area.left) area.right = area.left = (bounds.left + bounds.right) / 2;
|
|
29
|
+
if (area.bottom < area.top) area.bottom = area.top = (bounds.top + bounds.bottom) / 2;
|
|
30
|
+
// Larger than the area: cut to it, and the popover's own body scrolls.
|
|
31
|
+
const width = Math.min(size.width, area.right - area.left);
|
|
32
|
+
const height = Math.min(size.height, area.bottom - area.top);
|
|
33
|
+
// The part of the object that is in the area; an object that is wholly outside (scrolled away, under the panel)
|
|
34
|
+
// counts as the nearest point of the area's edge.
|
|
35
|
+
const a = {
|
|
36
|
+
left: clamp(anchor.left, bounds.left, bounds.right), right: clamp(anchor.right, bounds.left, bounds.right),
|
|
37
|
+
top: clamp(anchor.top, bounds.top, bounds.bottom), bottom: clamp(anchor.bottom, bounds.top, bounds.bottom),
|
|
38
|
+
};
|
|
39
|
+
const candidate = (side) => {
|
|
40
|
+
const free = side === 'right' ? area.right - (a.right + gap) : side === 'left' ? (a.left - gap) - area.left
|
|
41
|
+
: side === 'bottom' ? area.bottom - (a.bottom + gap) : (a.top - gap) - area.top;
|
|
42
|
+
const wants = side === 'right' ? { left: a.right + gap, top: a.top } : side === 'left' ? { left: a.left - gap - width, top: a.top }
|
|
43
|
+
: side === 'bottom' ? { left: a.left, top: a.bottom + gap } : { left: a.left, top: a.top - gap - height };
|
|
44
|
+
let left = clamp(wants.left, area.left, area.right - width);
|
|
45
|
+
let top = clamp(wants.top, area.top, area.bottom - height);
|
|
46
|
+
const fits = free >= (side === 'right' || side === 'left' ? width : height);
|
|
47
|
+
// Off the rectangles to keep off: slide along the object, by the least that clears every one of them.
|
|
48
|
+
const at = (l, t) => ({ left: l, top: t, right: l + width, bottom: t + height });
|
|
49
|
+
const onOne = (l, t) => obstacles.some((o) => overlap(at(l, t), o) > 0);
|
|
50
|
+
let blocked = onOne(left, top);
|
|
51
|
+
if (blocked) {
|
|
52
|
+
const upDown = side === 'right' || side === 'left';
|
|
53
|
+
const slides = obstacles.flatMap((o) => (upDown ? [o.top - gap - height, o.bottom + gap] : [o.left - gap - width, o.right + gap]))
|
|
54
|
+
.filter((v) => (upDown ? v >= area.top && v <= area.bottom - height && !onOne(left, v) : v >= area.left && v <= area.right - width && !onOne(v, top)))
|
|
55
|
+
.sort((x, y) => Math.abs(x - (upDown ? top : left)) - Math.abs(y - (upDown ? top : left)));
|
|
56
|
+
if (slides.length) { if (upDown) top = slides[0]; else left = slides[0]; blocked = false; }
|
|
57
|
+
}
|
|
58
|
+
return { side, left, top, fits, free, blocked, covered: overlap(at(left, top), a) };
|
|
59
|
+
};
|
|
60
|
+
const obstacles = (avoid ?? []).filter((o) => o && o.right > o.left && o.bottom > o.top);
|
|
61
|
+
const order = [...new Set([...(keepSide ? [keepSide] : []), ...prefer, ...SIDES])].filter((s) => SIDES.includes(s));
|
|
62
|
+
const all = order.map(candidate);
|
|
63
|
+
const chosen = all.find((c) => c.fits && !c.blocked) ?? all.find((c) => c.fits)
|
|
64
|
+
?? all.slice().sort((x, y) => x.covered - y.covered || Number(x.blocked) - Number(y.blocked) || y.free - x.free)[0];
|
|
65
|
+
return { left: Math.round(chosen.left), top: Math.round(chosen.top), width: Math.round(width), height: Math.round(height), side: chosen.side, covers: !chosen.fits && chosen.covered > 0, overlaps: chosen.blocked };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What the popover stands beside, asked again every time it is placed. `ways` are tried in order (the object itself
|
|
70
|
+
* first, lesser stand-ins after it); each answers a rectangle, or null when its thing is not on screen. The answer
|
|
71
|
+
* moves up the order when something better comes on screen, and never down it: when what the popover stood beside
|
|
72
|
+
* goes away — an answered note leaves the list it was picked from — the answer is null, the caller keeps the last
|
|
73
|
+
* place, and the popover does not jump under the owner's pointer to some other thing.
|
|
74
|
+
* @param {Array<() => (Rect | null | undefined)>} ways
|
|
75
|
+
* @returns {() => (Rect | null)}
|
|
76
|
+
*/
|
|
77
|
+
export function standBeside(ways) {
|
|
78
|
+
let stoodAt = ways.length;
|
|
79
|
+
return () => {
|
|
80
|
+
for (let i = 0; i < ways.length; i++) {
|
|
81
|
+
const rect = ways[i]();
|
|
82
|
+
if (!rect) continue;
|
|
83
|
+
if (i > stoodAt) return null;
|
|
84
|
+
stoodAt = i;
|
|
85
|
+
return rect;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
};
|
|
89
|
+
}
|