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,1058 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context for execution agents (Spec §7.1–§7.4, §7.8; CKC-12). Assembly is deterministic and
|
|
3
|
+
* reads only the assets (E6): the same input gives the same content, whatever channel delivers
|
|
4
|
+
* it. Sections appear only when they have content; the delivery grading of D15 decides where a
|
|
5
|
+
* thing goes (body, Do not revive, Not in current scope, Pending owner decisions, Freshness).
|
|
6
|
+
*
|
|
7
|
+
* Two rules from D59 run through the whole file:
|
|
8
|
+
* - The normal state is not written; an anomaly is written only where it can be checked. `Up to date`,
|
|
9
|
+
* `Updated`, `Reusable as is`, `Holds`, `Not assessed` and "no marks" stay in the assets, to be fetched
|
|
10
|
+
* by id (§7.10). What is written — an entry mark, `Still on old understanding`, `Questioned` — carries
|
|
11
|
+
* its source and says what differs, on the line of the object it belongs to, not in a list at the end.
|
|
12
|
+
* - The start pack follows the graph: product → goals → plans → areas → work items, told with the
|
|
13
|
+
* explanations the assets already hold, whole. What goes in goes in whole; what does not fit this pack
|
|
14
|
+
* is named by name and id, never by half a sentence (AC-31).
|
|
15
|
+
*
|
|
16
|
+
* History (D82; Spec §7.1; CKC-12 AC-35): what exists only in history and objects `Removed` from the current version are
|
|
17
|
+
* never current content; where they bear on the work they are given as lineage, marked as history, saying when and what
|
|
18
|
+
* replaced them or why they went (lineage.ts). The project's ledger, when it has one, gives the rest of that lineage.
|
|
19
|
+
*/
|
|
20
|
+
import type { AreaUnderstanding, ChangeItem, ChangeRecord, ContextPackage, ContextRequest, EntryMark, Note, ObjectJudgement, PendingWait, Project, ProjectRule, ReferenceItem, Source, Statement, WorkThread } from '../model/types.ts';
|
|
21
|
+
import type { ProjectStore } from '../store/project-store.ts';
|
|
22
|
+
import type { Ledger } from '../ledger/index.ts';
|
|
23
|
+
import { historyChanges, ledgerLineage, removalOf, type HistoryChange } from './lineage.ts';
|
|
24
|
+
import { anchorLabel } from '../sources/anchor.ts';
|
|
25
|
+
import { newId } from '../model/ids.ts';
|
|
26
|
+
import { OWNER_WORDS } from '../model/vocab.ts';
|
|
27
|
+
import { reportedBy } from '../model/time.ts';
|
|
28
|
+
import { areaOf } from '../keeper/organize/graph.ts';
|
|
29
|
+
import { itemsOf, notJudgedReason, roundNumberOf } from '../keeper/adjustment.ts';
|
|
30
|
+
import { pathKey } from '../util/paths.ts';
|
|
31
|
+
import { OPEN_CURRENT, commitFacts, fileLocation, isCodeSource, locationText, shortLocationText } from './code-source.ts';
|
|
32
|
+
import { dayOf, decidedBy, traceOwnerWords } from './owner-words.ts';
|
|
33
|
+
import { ruleBearsOnWork, rulesLines, rulesOpening, type RuleRowHelp, type WorkFacts } from './project-rules.ts';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
import { scheduleOf } from '../keeper/organize/schedule.ts';
|
|
36
|
+
import { failuresOf as realFailures } from '../intake/skipped.ts';
|
|
37
|
+
|
|
38
|
+
type Emphasis = 'full' | 'brief' | 'skip';
|
|
39
|
+
/**
|
|
40
|
+
* What each kind of work puts first (Spec §7.1). Every kind that does or plans the work — and product discussion —
|
|
41
|
+
* gets the owner's words in full (D66); an investigation lists them, and fetches them by id.
|
|
42
|
+
*/
|
|
43
|
+
const KIND_EMPHASIS: Record<string, Record<string, Emphasis>> = {
|
|
44
|
+
'Implement': { ownerWords: 'full', serves: 'full', relation: 'full', done: 'full', results: 'full', failures: 'full', open: 'full', code: 'full', notes: 'brief', reference: 'brief' },
|
|
45
|
+
'Review': { ownerWords: 'full', serves: 'full', relation: 'brief', done: 'full', results: 'brief', failures: 'full', open: 'full', code: 'brief', notes: 'full', reference: 'brief', reviewChanges: 'full' },
|
|
46
|
+
'Plan': { ownerWords: 'full', serves: 'full', relation: 'full', done: 'brief', results: 'brief', failures: 'brief', open: 'full', code: 'skip', notes: 'brief', reference: 'full', dependencies: 'full' },
|
|
47
|
+
'Discuss product': { ownerWords: 'full', serves: 'full', relation: 'brief', done: 'brief', results: 'brief', failures: 'skip', open: 'brief', code: 'skip', notes: 'full', reference: 'full' },
|
|
48
|
+
'Investigate': { ownerWords: 'brief', serves: 'brief', relation: 'brief', done: 'brief', results: 'full', failures: 'full', open: 'full', code: 'full', notes: 'brief', reference: 'brief', recentChanges: 'full' },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The sources a pack cites, numbered in the order they first appear. A source that exists only in history is cited like
|
|
53
|
+
* any other, and `Sources` says it is history (D82; Spec §1.2, §7.1): it backs the lineage and the marks it helped to set,
|
|
54
|
+
* never a current requirement.
|
|
55
|
+
*/
|
|
56
|
+
class Cites {
|
|
57
|
+
private readonly order: string[] = [];
|
|
58
|
+
private readonly index = new Map<string, number>();
|
|
59
|
+
private readonly store: ProjectStore;
|
|
60
|
+
constructor(store: ProjectStore) { this.store = store; }
|
|
61
|
+
ref(ids: readonly string[]): string {
|
|
62
|
+
const nums: number[] = [];
|
|
63
|
+
for (const id of ids) {
|
|
64
|
+
const s = this.store.sources.get(id);
|
|
65
|
+
if (!s) continue;
|
|
66
|
+
let n = this.index.get(id);
|
|
67
|
+
if (!n) { this.order.push(id); n = this.order.length; this.index.set(id, n); }
|
|
68
|
+
nums.push(n);
|
|
69
|
+
}
|
|
70
|
+
return nums.length ? ` [${nums.join(',')}]` : '';
|
|
71
|
+
}
|
|
72
|
+
list(): string[] { return this.order; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What goes into a pack goes in whole (D59; CKC-12 AC-31): `text` keeps every word. `oneLine` keeps every word
|
|
77
|
+
* too and only folds the line breaks away, for content that sits on a row of the map. `indent` puts a whole
|
|
78
|
+
* explanation under the row it belongs to. Nothing here shortens; a thing that does not belong in this pack is
|
|
79
|
+
* named by name and id instead (2026-09-18: reference explanations cut to a hundred characters made PRD §4.2 and
|
|
80
|
+
* PLAN §6 unreadable).
|
|
81
|
+
*/
|
|
82
|
+
const text = (s: string | null | undefined) => (s ?? '').trim();
|
|
83
|
+
const oneLine = (s: string | null | undefined) => text(s).replace(/\s*\n+\s*/g, ' ');
|
|
84
|
+
const indent = (s: string | null | undefined, pad: string) => text(s).split('\n').map((l) => l.trim()).filter((l) => l.length).map((l) => `${pad}${l}`).join('\n');
|
|
85
|
+
const latest = (n: Note) => n.versions[n.versions.length - 1]!;
|
|
86
|
+
/** A work item's name with its own number in front, unless the title already starts with it. */
|
|
87
|
+
const titled = (t: WorkThread) => (t.ids.length && !t.title.startsWith(t.ids[0]!) ? `${t.ids[0]} ${t.title}` : t.title);
|
|
88
|
+
const nameOf = (store: ProjectStore, id: string) => store.reference.get(id)?.name ?? store.threads.get(id)?.title ?? store.changes.get(id)?.title ?? (store.sources.get(id) ? anchorLabel(store.sources.get(id)!.anchor) : id);
|
|
89
|
+
|
|
90
|
+
export interface Scope { readonly kind: ContextRequest['scope']['kind']; readonly ids: readonly string[] }
|
|
91
|
+
|
|
92
|
+
interface ScopeSet { kind: Scope['kind']; areaIds: Set<string>; threads: WorkThread[]; references: ReferenceItem[]; label: string; focusThread: WorkThread | null }
|
|
93
|
+
|
|
94
|
+
/** The product overview and the goals say what the product is and who it is for; both belong in `Purpose` (§7.3). */
|
|
95
|
+
const PURPOSE_CATEGORIES = new Set(['Product', 'Goal']);
|
|
96
|
+
/** A change that set or changed the direction (§1.12, §7.3 item 7): a decision, what the owner said, the product's direction,
|
|
97
|
+
* or anything replaced, given up, put off or removed. */
|
|
98
|
+
const DIRECTION_MATERIALS: ReadonlySet<string> = new Set(['Decision', 'Owner statement', 'Product direction']);
|
|
99
|
+
const DIRECTION_EFFECTS: ReadonlySet<string> = new Set(['Replaced', 'Abandoned', 'Deferred', 'Removed']);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* What a pack is about. An object that was `Removed` — its material deleted from the project's current version — is no
|
|
103
|
+
* current content of any pack (Spec §2.1, §7.1; CKC-12 AC-35): not the scope, not an area, not a work item in it. Where it
|
|
104
|
+
* bears on the work it appears as lineage instead.
|
|
105
|
+
*/
|
|
106
|
+
function resolveScope(store: ProjectStore, project: Project, scope: Scope): ScopeSet {
|
|
107
|
+
const live = (x: { readonly validity: string } | undefined) => x !== undefined && x.validity !== 'Removed';
|
|
108
|
+
const allThreads = store.threads.all().filter(live);
|
|
109
|
+
const refs = store.reference.all().filter(live);
|
|
110
|
+
const liveArea = (id: string | null): id is string => id !== null && live(store.reference.get(id));
|
|
111
|
+
if (scope.kind === 'work' && scope.ids[0] && live(store.threads.get(scope.ids[0]))) {
|
|
112
|
+
const t = store.threads.get(scope.ids[0])!;
|
|
113
|
+
const areaIds = new Set(t.serves.map((s) => areaOf(store, s.referenceId)).filter(liveArea));
|
|
114
|
+
const refIds = new Set([...t.serves.map((s) => s.referenceId), ...areaIds]);
|
|
115
|
+
return { kind: 'work', areaIds, threads: [t], references: refs.filter((r) => refIds.has(r.id) || PURPOSE_CATEGORIES.has(r.category)), label: titled(t), focusThread: t };
|
|
116
|
+
}
|
|
117
|
+
if (scope.kind === 'area' && scope.ids[0] && live(store.reference.get(scope.ids[0]))) {
|
|
118
|
+
const area = store.reference.get(scope.ids[0])!;
|
|
119
|
+
const threads = allThreads.filter((t) => t.serves.some((s) => areaOf(store, s.referenceId) === area.id));
|
|
120
|
+
return { kind: 'area', areaIds: new Set([area.id]), threads, references: refs.filter((r) => r.id === area.id || areaOf(store, r.id) === area.id || PURPOSE_CATEGORIES.has(r.category)), label: `Area: ${area.name}`, focusThread: null };
|
|
121
|
+
}
|
|
122
|
+
if (scope.kind === 'path' && scope.ids.length) {
|
|
123
|
+
const ids = new Set(scope.ids);
|
|
124
|
+
const threads = allThreads.filter((t) => ids.has(t.id));
|
|
125
|
+
const areaIds = new Set<string>([...scope.ids.filter((id) => store.reference.get(id)?.category === 'Area' && liveArea(id)), ...threads.map((t) => t.serves.map((s) => areaOf(store, s.referenceId))).flat().filter(liveArea)]);
|
|
126
|
+
return { kind: 'path', areaIds, threads, references: refs.filter((r) => ids.has(r.id) || areaIds.has(r.id) || PURPOSE_CATEGORIES.has(r.category)), label: `Path: ${scope.ids.map((id) => nameOf(store, id)).join(' → ')}`, focusThread: threads.length === 1 ? threads[0]! : null };
|
|
127
|
+
}
|
|
128
|
+
return { kind: 'project', areaIds: new Set(refs.filter((r) => r.category === 'Area' && r.validity === 'Current').map((r) => r.id)), threads: allThreads, references: refs, label: project.name, focusThread: null };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const DEFAULT_REQUEST: ContextRequest = { scope: { kind: 'project', ids: [] }, purpose: 'Start', kind: 'Implement', recipient: 'Incoming agent', lastSessionAt: null } as ContextRequest;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* What an option actually changes, measured by assembling both packs and comparing them section by section
|
|
135
|
+
* (D59; CKC-12 AC-21). The agent is told the difference instead of fetching all nine packs to compare them
|
|
136
|
+
* itself; where a project has no difference, it is told that too (2026-09-18: 4 of 9 options were all but
|
|
137
|
+
* identical to the default and nothing said so).
|
|
138
|
+
*/
|
|
139
|
+
type Sections = Map<string, string[]>;
|
|
140
|
+
/**
|
|
141
|
+
* A pack by its sections. The header always names the kind and the recipient that were asked for (§7.1), so it is
|
|
142
|
+
* never the difference an agent is choosing between; what is compared is the sections under it.
|
|
143
|
+
*/
|
|
144
|
+
function packSections(md: string): Sections {
|
|
145
|
+
const map: Sections = new Map();
|
|
146
|
+
let title: string | null = null;
|
|
147
|
+
for (const line of md.split('\n')) {
|
|
148
|
+
if (line.startsWith('## ')) { title = line.slice(3).trim(); map.set(title, []); } else if (title !== null && line.trim().length) map.get(title)!.push(line);
|
|
149
|
+
}
|
|
150
|
+
return map;
|
|
151
|
+
}
|
|
152
|
+
function packDifference(A: Sections, B: Sections): string {
|
|
153
|
+
const size = (m: Sections) => [...m.values()].flat().join('\n').length;
|
|
154
|
+
const added = [...B.keys()].filter((k) => !A.has(k));
|
|
155
|
+
const dropped = [...A.keys()].filter((k) => !B.has(k));
|
|
156
|
+
const differs = [...B.keys()].filter((k) => A.has(k) && A.get(k)!.join('\n') !== B.get(k)!.join('\n'));
|
|
157
|
+
if (!added.length && !dropped.length && !differs.length) return 'the same pack as the default, apart from the header naming this kind and recipient';
|
|
158
|
+
const parts: string[] = [];
|
|
159
|
+
if (added.length) parts.push(`adds ${added.map((k) => `\`${k}\``).join(', ')}`);
|
|
160
|
+
if (dropped.length) parts.push(`drops ${dropped.map((k) => `\`${k}\``).join(', ')}`);
|
|
161
|
+
for (const k of differs) parts.push(`\`${k}\` differs (${B.get(k)!.length} lines against ${A.get(k)!.length})`);
|
|
162
|
+
const delta = size(B) - size(A);
|
|
163
|
+
parts.push(`${Math.abs(delta)} characters ${delta >= 0 ? 'more' : 'less'} in all`);
|
|
164
|
+
return parts.join('; ');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function contextOptions(store: ProjectStore, project: Project, ledger: Ledger | null = null) {
|
|
168
|
+
const areas = store.reference.filter((r) => r.category === 'Area' && r.validity === 'Current').map((r) => ({ kind: 'area', id: r.id, label: r.name }));
|
|
169
|
+
const work = store.threads.filter((t) => t.validity === 'Current').sort((a, b) => (a.progress === 'In progress' ? 0 : 1) - (b.progress === 'In progress' ? 0 : 1) || a.title.localeCompare(b.title)).map((t) => ({ kind: 'work', id: t.id, label: titled(t), progress: t.progress }));
|
|
170
|
+
const kinds = ['Implement', 'Review', 'Plan', 'Discuss product', 'Investigate'];
|
|
171
|
+
const recipients = ['Incoming agent', ...project.roles];
|
|
172
|
+
// A kind changes a work pack most and a start pack least, so both are measured, on this project's own assets. The
|
|
173
|
+
// packs are assembled once each and kept, because every option is compared against the same two.
|
|
174
|
+
const sample = work[0] ?? null;
|
|
175
|
+
const assembled = new Map<string, Sections>();
|
|
176
|
+
const sectionsFor = (purpose: 'Start' | 'Work', kind: string, recipient: string): Sections => {
|
|
177
|
+
const key = `${purpose}|${kind}|${recipient}`;
|
|
178
|
+
let s = assembled.get(key);
|
|
179
|
+
if (!s) {
|
|
180
|
+
const request = { ...DEFAULT_REQUEST, kind, recipient, ...(purpose === 'Work' && sample ? { scope: { kind: 'work', ids: [sample.id] }, purpose } : {}) } as ContextRequest;
|
|
181
|
+
s = packSections(assembleContext(store, project, request, 'Idle', {}, ledger).markdown);
|
|
182
|
+
assembled.set(key, s);
|
|
183
|
+
}
|
|
184
|
+
return s;
|
|
185
|
+
};
|
|
186
|
+
const describe = (kind: string, recipient: string): string => {
|
|
187
|
+
const startPart = packDifference(sectionsFor('Start', 'Implement', 'Incoming agent'), sectionsFor('Start', kind, recipient));
|
|
188
|
+
if (!sample) return `start pack: ${startPart}`;
|
|
189
|
+
const workPart = packDifference(sectionsFor('Work', 'Implement', 'Incoming agent'), sectionsFor('Work', kind, recipient));
|
|
190
|
+
return `start pack: ${startPart} · work pack (measured on ${sample.label}): ${workPart}`;
|
|
191
|
+
};
|
|
192
|
+
return {
|
|
193
|
+
forWork: [{ kind: 'project', id: '', label: 'Whole project' }, ...areas, ...work],
|
|
194
|
+
purposes: ['Start', 'Work'], kinds, recipients,
|
|
195
|
+
// Each option says how its pack differs from the default (`Start` · `Implement` · `Incoming agent`, whole project).
|
|
196
|
+
kindDifferences: kinds.map((k) => ({ name: k, difference: k === 'Implement' ? 'the default pack' : describe(k, 'Incoming agent') })),
|
|
197
|
+
recipientDifferences: recipients.map((r) => ({ name: r, difference: r === 'Incoming agent' ? 'the default pack' : describe('Implement', r) })),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* `Version check` (§7.3 item 7): does the version the agent's task cites match the current material?
|
|
203
|
+
* Deterministic: version markers are read from the work, the reference items it names and their sources.
|
|
204
|
+
*/
|
|
205
|
+
function versionCheck(store: ProjectStore, thread: WorkThread | null, references: readonly ReferenceItem[], taskVersion: string, cites: { ref(ids: readonly string[]): string }): string[] {
|
|
206
|
+
const VERSION_RE = /\bv?(\d+(?:\.\d+)+)\b/i;
|
|
207
|
+
const cited = VERSION_RE.exec(taskVersion)?.[1] ?? null;
|
|
208
|
+
const idsInTask: string[] = taskVersion.match(/\b[A-Za-z]{2,}-\d+\b/g) ?? [];
|
|
209
|
+
type Candidate = { label: string; text: string; sourceIds: readonly string[]; ids: readonly string[] };
|
|
210
|
+
const candidates: Candidate[] = [];
|
|
211
|
+
if (thread) {
|
|
212
|
+
candidates.push({ label: thread.title, text: `${thread.title} ${thread.doing} ${thread.changed} ${thread.results}`, sourceIds: thread.factRecordIds.flatMap((f) => store.facts.get(f)?.aboutSourceIds ?? []).slice(0, 4), ids: thread.ids });
|
|
213
|
+
// A code file is not read for a version: the agent opens the current code itself (D65); what exists only in
|
|
214
|
+
// history is not current material (D61).
|
|
215
|
+
for (const s of thread.factRecordIds.flatMap((f) => store.facts.get(f)?.aboutSourceIds ?? []).map((id) => store.sources.get(id)).filter((x): x is Source => x !== undefined && !isCodeSource(x) && x.usedAs !== 'History only').slice(0, 12)) candidates.push({ label: s.title, text: s.excerpt.slice(0, 2000), sourceIds: [s.id], ids: s.ids });
|
|
216
|
+
}
|
|
217
|
+
// A replaced or abandoned item is not the current version of anything (it is in `Do not revive`).
|
|
218
|
+
for (const r of references.filter((x) => x.validity === 'Current')) candidates.push({ label: r.name, text: `${r.name} ${r.text} ${r.quote ?? ''}`, sourceIds: r.sourceIds, ids: r.ids });
|
|
219
|
+
const relevant = candidates.filter((c) => idsInTask.length === 0 || c.ids.some((id) => idsInTask.includes(id)) || idsInTask.some((id) => c.text.includes(id)));
|
|
220
|
+
const lines = [`- Your task cites: ${taskVersion}`];
|
|
221
|
+
let found = 0;
|
|
222
|
+
for (const c of relevant) {
|
|
223
|
+
if (found >= 4) break;
|
|
224
|
+
const m = VERSION_RE.exec(c.text);
|
|
225
|
+
if (!m) continue;
|
|
226
|
+
found++;
|
|
227
|
+
const verdict = cited ? (m[1] === cited ? ' — same as your task' : ` — differs from your task's v${cited}; work from the current version and say so`) : '';
|
|
228
|
+
lines.push(`- Current material for ${c.label}: v${m[1]}${verdict}${cites.ref(c.sourceIds)}`);
|
|
229
|
+
}
|
|
230
|
+
if (found === 0) lines.push('- Not yet established: no version marker found in the current material for this work. To know it, open the sources below or ask the Keeper.');
|
|
231
|
+
return lines;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* How an execution agent reaches the project by itself (Spec §7.10): the same lines in `Explore further`, in `pk help`,
|
|
236
|
+
* and in the Keeper view's `Execution-agent access`, so what the pack says can be done as written.
|
|
237
|
+
*/
|
|
238
|
+
export interface UsageWhere {
|
|
239
|
+
/** The port the workbench serves on, when it is not the one the command line would try by default. */
|
|
240
|
+
readonly port?: number | null;
|
|
241
|
+
}
|
|
242
|
+
export function agentUsage(projectId: string, where: UsageWhere = {}): string[] {
|
|
243
|
+
const port = where.port ? ` --port ${where.port}` : '';
|
|
244
|
+
const cli = fileURLToPath(new URL('../cli.ts', import.meta.url));
|
|
245
|
+
const lines = [
|
|
246
|
+
`\`pk\` is the ProjectKeeper command; where it is not installed, run \`node ${cli}\` in its place. It talks to the running workbench${port ? ` (this one serves on port ${where.port}, hence \`--port ${where.port}\` below)` : ''}.`,
|
|
247
|
+
`A context pack: \`pk context --project ${projectId}\` gives the start-of-work map; add \`--work <id>\` for one work item or area (its work context), \`--purpose Start|Work\`, \`--kind Implement|Review|Plan|"Discuss product"|Investigate\`, \`--recipient <role>\`, and \`--since <time of your last pack>\` for the changes since then.`,
|
|
248
|
+
`Anything this pack names by id: \`pk get --project ${projectId} <id>\` — a work item or area gives its context pack, a source (\`src_…\`) the original with line numbers and whether it changed since it was read (a code file gives where it is and the version read: open the current version and read it there), a note (\`note_…\`) the full note, a project rule (\`rule_…\`) the rule in full with where it is written, anything else its own content in full with its neighbours named by id. Only ids of this project are read; no paths.`,
|
|
249
|
+
`What you can ask for: \`pk options --project ${projectId}\` lists the areas, work items and recognised roles with their ids, and the purposes, kinds and recipients, each saying how its pack differs from the default one.`,
|
|
250
|
+
`Ask the Keeper: \`pk ask --project ${projectId} "<question>"\`. What is organized comes at once; what needs investigating returns the known part now and a job id (\`pk ask --project ${projectId} --fetch <jobId>\`); \`--thread <id>\` keeps asking in the same thread. Only asking needs the Keeper; packs, originals and options are read from the saved assets even when it is stopped, paused or out of quota.`,
|
|
251
|
+
'Original records (sessions, long logs, raw files) are not included in a pack; the sources below name them by id, and `pk get` reads them.',
|
|
252
|
+
];
|
|
253
|
+
return port ? lines.map((l) => l.split(`--project ${projectId}`).join(`--project ${projectId}${port}`)) : lines;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* `ledger`: the project's ledger (§1.16), when it has one; the work context's `How it got here` takes the rest of the
|
|
258
|
+
* work's lineage from it. Without it the lineage has what the assets record.
|
|
259
|
+
*/
|
|
260
|
+
export function assembleContext(store: ProjectStore, project: Project, request: ContextRequest, keeperStatus: string, where: UsageWhere = {}, ledger: Ledger | null = null): ContextPackage {
|
|
261
|
+
const cites = new Cites(store);
|
|
262
|
+
const scope = resolveScope(store, project, request.scope);
|
|
263
|
+
const em = KIND_EMPHASIS[request.kind] ?? KIND_EMPHASIS['Implement']!;
|
|
264
|
+
const isOrchestrator = /orchestrat|编排/i.test(request.recipient);
|
|
265
|
+
const roleRecipient = request.recipient !== 'Incoming agent' ? request.recipient : null;
|
|
266
|
+
const coverage = store.coverage.scopes.find((s) => s.id === 'project');
|
|
267
|
+
const asOf = coverage?.asOf ?? null;
|
|
268
|
+
const commit = coverage?.commit ?? null;
|
|
269
|
+
const out: string[] = [];
|
|
270
|
+
const section = (title: string, lines: string[]) => { const body = lines.filter((l) => l.trim().length); if (body.length) { out.push(`## ${title}`, ...body, ''); } };
|
|
271
|
+
/**
|
|
272
|
+
* What exists only in history, and objects `Removed` from the project's current version, are never current content of
|
|
273
|
+
* any section (Spec §1.2, §2.1, §7.1; CKC-12 AC-35). Where they bear on the work they are given as lineage, marked as
|
|
274
|
+
* history, with when and what replaced them or why they went (`How it got here`); where a current object still rests
|
|
275
|
+
* on a removed one, that object's own row says so. What was replaced or abandoned still goes to `Do not revive`.
|
|
276
|
+
*/
|
|
277
|
+
const removed = (id: string) => store.reference.get(id)?.validity === 'Removed' || store.threads.get(id)?.validity === 'Removed' || store.nodes.get(id)?.validity === 'Removed';
|
|
278
|
+
const historyOnly = (id: string) => store.sources.get(id)?.usedAs === 'History only';
|
|
279
|
+
const gone = (id: string) => removed(id) || historyOnly(id);
|
|
280
|
+
/** When a removed object left the current version and what took it out, as the change records give it. */
|
|
281
|
+
const removedWhen = (id: string, withDay: boolean): string => {
|
|
282
|
+
const r = removalOf(store, id);
|
|
283
|
+
if (!r) return 'removed from the current version (the records do not say when)';
|
|
284
|
+
return `removed from the current version${withDay ? ` on ${dayOf(r.at) ?? r.at}` : ''} (${r.effect}: ${oneLine(r.title)}${text(r.why) ? ` — ${oneLine(r.why)}` : ''}; \`${r.changeId}\`)${cites.ref(r.sourceIds)}`;
|
|
285
|
+
};
|
|
286
|
+
/** The removed objects one object rests on, each with how it rests on it. */
|
|
287
|
+
const removedUnder = (id: string): { id: string; how: string }[] => {
|
|
288
|
+
const t = store.threads.get(id);
|
|
289
|
+
const r = store.reference.get(id);
|
|
290
|
+
const on = [
|
|
291
|
+
...(t ? [...t.serves.map((s) => ({ id: s.referenceId, how: 'serves' })), ...t.dependsOn.map((d) => ({ id: d.threadId, how: 'depends on' }))] : []),
|
|
292
|
+
...(r ? r.refines.map((x) => ({ id: x, how: 'refines' })) : []),
|
|
293
|
+
...store.relations.filter((x) => x.from === id && (x.type === 'depends on' || x.type === 'refines' || x.type === 'serves' || x.type === 'carries out')).map((x) => ({ id: x.to, how: x.type })),
|
|
294
|
+
];
|
|
295
|
+
return on.filter((x, i) => removed(x.id) && on.findIndex((y) => y.id === x.id) === i);
|
|
296
|
+
};
|
|
297
|
+
const restsOnRemovedText = (x: string) => `rests on “${nameOf(store, x)}” (\`${x}\`) — history: ${removedWhen(x, true)}`;
|
|
298
|
+
const restsOnRemoved = (id: string): string[] => removedUnder(id).map((x) => restsOnRemovedText(x.id));
|
|
299
|
+
/**
|
|
300
|
+
* Two records that disagree are both written and neither is picked (Spec §2.4, D43): one task number on two current
|
|
301
|
+
* work items whose progress differs. Merging them is the organizing round's work (§1.4); until then both stand.
|
|
302
|
+
*/
|
|
303
|
+
const twinsOf = (t: WorkThread) => t.validity !== 'Current' ? [] : store.threads.filter((x) => x.id !== t.id && x.validity === 'Current' && x.progress !== t.progress && x.ids.some((n) => t.ids.some((m) => m.toUpperCase() === n.toUpperCase())));
|
|
304
|
+
const twinText = (t: WorkThread): string | null => {
|
|
305
|
+
const tw = twinsOf(t);
|
|
306
|
+
if (!tw.length) return null;
|
|
307
|
+
const number = t.ids.find((m) => tw.some((x) => x.ids.some((n) => n.toUpperCase() === m.toUpperCase()))) ?? t.ids[0]!;
|
|
308
|
+
return `${number} is also ${tw.map((x) => `${titled(x)} (\`${x.id}\`), ${x.progress}`).join('; ')}`;
|
|
309
|
+
};
|
|
310
|
+
const twinNotes = (t: WorkThread): string[] => { const s = twinText(t); return s ? [`the records disagree: ${s}`] : []; };
|
|
311
|
+
const marksOn = new Map<string, EntryMark[]>();
|
|
312
|
+
for (const m of store.marks.filter((x) => !x.closed && !gone(x.targetId))) { if (!marksOn.has(m.targetId)) marksOn.set(m.targetId, []); marksOn.get(m.targetId)!.push(m); }
|
|
313
|
+
/** A role set this rule or decision in the owner's place, and it is in force (§1.9; CKC-12 AC-37). */
|
|
314
|
+
const withoutOwner = (id: string) => marksOn.get(id)?.find((m) => m.kind === 'Decided without owner');
|
|
315
|
+
/** The marks this pack actually writes, so the map's opening can say how many of the open marks are here. */
|
|
316
|
+
const shownMarkIds = new Set<string>();
|
|
317
|
+
const undocumented = (id: string) => marksOn.get(id)?.find((m) => m.kind === 'Undocumented decision');
|
|
318
|
+
/**
|
|
319
|
+
* An anomaly is written into a pack only where the agent reading it can check the same thing: the mark says what
|
|
320
|
+
* differs (its clue) and names the material it was checked against. A mark with neither stays in the assets and is
|
|
321
|
+
* fetched by id. Basis: the 2026-09-18 trial, where 94 mark lines followed the map, some of them judging a
|
|
322
|
+
* point-in-time record stale and some hanging off another mark (D58, D59).
|
|
323
|
+
*/
|
|
324
|
+
const checked = (m: EntryMark) => text(m.clue).length > 0 && m.clueSourceIds.length > 0;
|
|
325
|
+
/**
|
|
326
|
+
* §2.10: what is never judged for propagation never appears as `Still on old understanding` (AC-32): a point-in-time
|
|
327
|
+
* record — a `Done` work item, a `Replaced`, `Abandoned` or `Removed` entry, a session, commit, run result or review —
|
|
328
|
+
* which records how things stood then; a decision, which is checked for what replaced it instead (§5.5); and a mark or a
|
|
329
|
+
* note, which judge something else. It is the rule the judging tools apply (adjustment.ts `notJudgedReason`): the copy
|
|
330
|
+
* this file kept had drifted from it — it took every source and every `Result` node for a point-in-time record, so a
|
|
331
|
+
* maintained document or code area judged behind never showed as behind (QC AH #14).
|
|
332
|
+
*/
|
|
333
|
+
const pointInTime = (id: string): boolean => notJudgedReason(store, id) !== null;
|
|
334
|
+
/**
|
|
335
|
+
* A mark is a judgement about something else, so it never hangs on another mark or on a note (Spec §1.11; CKC-02
|
|
336
|
+
* AC-22; AC-32). A mark on a point-in-time record is left alone: a doubt about whether that record was true when it
|
|
337
|
+
* was written is legitimate, and only the Keeper that wrote the mark knows which of the two it is (§2.6).
|
|
338
|
+
*/
|
|
339
|
+
const onAnotherJudgement = (id: string) => id.startsWith('mark_') || id.startsWith('note_') || store.notes.has(id) || store.marks.has(id);
|
|
340
|
+
/** The owner's words whole, their line breaks kept (D66: word for word, never cut). */
|
|
341
|
+
const quoteBlock = (q: string, pad: string) => `${pad}“${text(q).split('\n').map((l, i) => (i === 0 ? l : `${pad}${l}`)).join('\n')}”`;
|
|
342
|
+
/** When and where the owner said what an item holds: the day of the session it cites, and the citation. */
|
|
343
|
+
const saidOnDay = (w: ReferenceItem) => { for (const id of w.sourceIds) { const a = store.sources.get(id)?.anchor; if (a?.kind === 'session' && a.at) return dayOf(a.at); } return null; };
|
|
344
|
+
/** The owner's words an item of `Purpose` rests on (it `refines` them): what the product and its goals are, as the owner said it. */
|
|
345
|
+
const ownerWordsUnder = (r: ReferenceItem) => r.refines.map((id) => store.reference.get(id)).filter((w): w is ReferenceItem => w !== undefined && w.category === OWNER_WORDS && w.validity === 'Current' && text(w.quote).length > 0);
|
|
346
|
+
/** A decision's carry-out on its own row (Spec §2.2; CKC-12 AC-38): carried out is not a to-do; partly carried out says what is left. */
|
|
347
|
+
const carryOutNotes = (r: ReferenceItem): string[] => {
|
|
348
|
+
const c = r.carryOut;
|
|
349
|
+
if (!c || c.status === 'Not carried out yet') return [];
|
|
350
|
+
const works = c.workIds.map((id) => store.threads.get(id)).filter((t): t is WorkThread => t !== undefined && !gone(t.id));
|
|
351
|
+
const named = works.map((t) => `${titled(t)} (\`${t.id}\`)`).join(', ');
|
|
352
|
+
const notes = [c.status === 'Carried out' ? `carried out${named ? ` by ${named}` : ''}` : `partly carried out${named ? ` by ${named}` : ''}; still left: ${oneLine(c.remaining) || 'not recorded'}`];
|
|
353
|
+
// Two records disagree (Spec §2.4): the decision says it was carried out, the work that carried it out says it has not started.
|
|
354
|
+
if (c.status === 'Carried out' && works.length && works.every((t) => t.progress === 'Planned')) notes.push(`the records disagree: it is recorded as carried out by ${named}, which ${works.length === 1 ? 'is' : 'are'} Planned`);
|
|
355
|
+
return notes;
|
|
356
|
+
};
|
|
357
|
+
const withoutOwnerNote = (id: string): string[] => { const m = withoutOwner(id); return m && checked(m) ? [`set by ${m.decidedBy?.who ?? 'a role'} without the owner — see \`Pending owner decisions\``] : []; };
|
|
358
|
+
/** `Update pending` on a product reference item where the pack names it (§1.11, §7.1); noted so `Freshness` says how to read it. */
|
|
359
|
+
let refPendingWritten = false;
|
|
360
|
+
const refPending = (r: ReferenceItem): string[] => {
|
|
361
|
+
if (!(r.pendingSourceIds ?? []).length) return [];
|
|
362
|
+
refPendingWritten = true;
|
|
363
|
+
return [pendingMark(r.pendingSourceIds!, r.waitsFor)];
|
|
364
|
+
};
|
|
365
|
+
/** A reference item with its whole explanation and the owner's own words, both entire (AC-31). */
|
|
366
|
+
const refLine = (r: ReferenceItem) => {
|
|
367
|
+
const notes = [...refPending(r), ...carryOutNotes(r), ...withoutOwnerNote(r.id), ...restsOnRemoved(r.id)];
|
|
368
|
+
const head = `- **${r.name}**${r.ids.length ? ` (${r.ids.join(', ')})` : ''} · \`${r.id}\`${r.basis === 'Inferred' ? ' · Inferred' : ''}${r.validity === 'Proposed' ? ' · Proposed' : ''}${undocumented(r.id) ? ' — not yet written into the project documents; basis is the owner’s words' : ''}${cites.ref(r.sourceIds)}${notes.length ? ` — ${notes.join('; ')}` : ''}`;
|
|
369
|
+
const words = PURPOSE_CATEGORIES.has(r.category) ? ownerWordsUnder(r).map((w) => { const on = saidOnDay(w); return ` Owner’s words (\`${w.id}\`${on ? `, said ${on}` : ''})${cites.ref(w.sourceIds)}:\n${quoteBlock(w.quote!, ' ')}`; }) : [];
|
|
370
|
+
return [head, indent(r.text, ' '), r.quote ? indent(`Owner’s words: “${text(r.quote)}”`, ' ') : '', ...words].filter((l) => l.length).join('\n');
|
|
371
|
+
};
|
|
372
|
+
const current = (v: string) => v === 'Current';
|
|
373
|
+
// When waiting material will be organized (Spec §3.8): the owner's daily Follow up, or after each stretch of work,
|
|
374
|
+
// or, while the takeover runs, as it goes.
|
|
375
|
+
const takingOver = store.coverage.takeover ? store.coverage.takeover.stage !== 'Daily' : false;
|
|
376
|
+
const whenOrganized = takingOver ? 'as the takeover goes on' : scheduleOf(project).frequency === 'Continuous' ? 'after the current stretch of work' : 'at the next Follow up (on the owner’s schedule, or earlier when the owner starts one)';
|
|
377
|
+
// What an entry waits for (Spec §7.1: `Update pending` names the changes it waits for): each change as the coverage
|
|
378
|
+
// lists it, the id to read it by, and how it reaches the entry. A mark written before the changes were named gives the
|
|
379
|
+
// documents only, by document: a changed file shows up as many section sources, and naming each section made one
|
|
380
|
+
// area's mark list 411 of them. One id per document is enough to read it.
|
|
381
|
+
const pendingMark = (sourceIds: readonly string[], waits?: readonly PendingWait[]) => {
|
|
382
|
+
if (waits?.length) {
|
|
383
|
+
const named = waits.slice(0, 4).map((w) => `${w.label} (\`${w.sourceIds[0] ?? w.ref}\`${w.sourceIds.length > 1 ? `, ${w.sourceIds.length} parts` : ''}${w.reasons.length ? ` — ${w.reasons.map((r) => r.detail).join('; ')}` : ''})`);
|
|
384
|
+
return `Update pending: ${named.join(', ')}${waits.length > 4 ? ` and ${waits.length - 4} more change${waits.length - 4 === 1 ? '' : 's'}` : ''}`;
|
|
385
|
+
}
|
|
386
|
+
const docs = new Map<string, string[]>();
|
|
387
|
+
for (const id of sourceIds) {
|
|
388
|
+
const a = store.sources.get(id)?.anchor;
|
|
389
|
+
const doc = !a ? id : a.kind === 'file' ? a.path.split(/[\\/]/).pop()! : a.kind === 'session' ? `${a.host} session ${a.sessionId.slice(0, 8)}` : a.kind === 'commit' ? `commit ${a.commit.slice(0, 8)}` : a.kind;
|
|
390
|
+
if (!docs.has(doc)) docs.set(doc, []);
|
|
391
|
+
docs.get(doc)!.push(id);
|
|
392
|
+
}
|
|
393
|
+
const named = [...docs].slice(0, 4).map(([doc, ids]) => `${doc} (\`${ids[0]}\`${ids.length > 1 ? `, ${ids.length} parts` : ''})`);
|
|
394
|
+
return `Update pending: ${named.join(', ')}${docs.size > 4 ? ` and ${docs.size - 4} more document${docs.size - 4 === 1 ? '' : 's'}` : ''}`;
|
|
395
|
+
};
|
|
396
|
+
// Short names on the map: an area or work item by its own number when it has one; a title that already starts with
|
|
397
|
+
// that number is not prefixed again.
|
|
398
|
+
const shortName = (id: string) => store.reference.get(id)?.ids[0] ?? store.threads.get(id)?.ids[0] ?? nameOf(store, id);
|
|
399
|
+
const workTitle = titled;
|
|
400
|
+
/** Marks on one object, each saying what differs and naming what it was checked against (D59). */
|
|
401
|
+
const markNotes = (id: string) => (marksOn.get(id) ?? []).filter((m) => m.kind !== 'Undocumented decision' && checked(m) && !onAnotherJudgement(m.targetId)).map((m) => { shownMarkIds.add(m.id); return `${m.kind} (\`${m.id}\`): ${oneLine(m.clue)}${cites.ref(m.clueSourceIds)}`; });
|
|
402
|
+
// Which objects have not followed which change, and what they lack: written on the object's own row when it is on
|
|
403
|
+
// the map, indexed in `Freshness` when it is not (Spec §7.1; CKC-12 AC-3, AC-32). A point-in-time record never
|
|
404
|
+
// appears here, and an entry that names neither what differs nor a source cannot be checked, so it is not written.
|
|
405
|
+
const behindOn = new Map<string, { title: string; id: string; what: string; sourceIds: readonly string[] }[]>();
|
|
406
|
+
// The round's own judgement says which items of which record the object still lacks (D56), so it is read first — the
|
|
407
|
+
// latest one only. Each round judges what reached the object together with what it still lacked, so its judgement
|
|
408
|
+
// carries every lack still open (§2.10, §5.5): an object whose latest judgement is not `Still on old understanding`
|
|
409
|
+
// lacks nothing now, whatever an earlier round said. Reading every round kept an object that had followed since
|
|
410
|
+
// on the map as behind. Rounds are ordered by number, never as strings (`roundNumberOf`); an object keeps the place
|
|
411
|
+
// its first judgement gave it. The per-change entries the program mirrors judgements into cover objects no round has
|
|
412
|
+
// judged yet.
|
|
413
|
+
const latestJudgement = new Map<string, ObjectJudgement>();
|
|
414
|
+
for (const j of store.propagation.all().sort((a, b) => roundNumberOf(a.roundId) - roundNumberOf(b.roundId))) latestJudgement.set(j.nodeId, j);
|
|
415
|
+
const judged = new Set<string>();
|
|
416
|
+
for (const j of latestJudgement.values()) {
|
|
417
|
+
if (pointInTime(j.nodeId)) continue;
|
|
418
|
+
judged.add(j.nodeId);
|
|
419
|
+
if (j.state !== 'Still on old understanding') continue;
|
|
420
|
+
for (const l of j.lacks) {
|
|
421
|
+
const c = store.changes.get(l.changeId);
|
|
422
|
+
if (!c) continue;
|
|
423
|
+
const item = l.itemId ? itemsOf(c).find((x) => x.id === l.itemId) : undefined;
|
|
424
|
+
if (!text(l.what) && c.sourceIds.length === 0) continue;
|
|
425
|
+
if (!behindOn.has(j.nodeId)) behindOn.set(j.nodeId, []);
|
|
426
|
+
behindOn.get(j.nodeId)!.push({ title: item ? `${c.work ? `${c.work.label} · ` : ''}${item.title}` : c.title, id: c.id, what: oneLine(l.what), sourceIds: item ? item.sourceIds : c.sourceIds });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
for (const c of store.changes.all()) for (const p of c.propagation) {
|
|
430
|
+
if (p.state !== 'Still on old understanding' || pointInTime(p.nodeId) || judged.has(p.nodeId)) continue;
|
|
431
|
+
if (!text(p.sourceOrReason) && c.sourceIds.length === 0) continue;
|
|
432
|
+
if (!behindOn.has(p.nodeId)) behindOn.set(p.nodeId, []);
|
|
433
|
+
behindOn.get(p.nodeId)!.push({ title: c.title, id: c.id, what: oneLine(p.sourceOrReason), sourceIds: c.sourceIds });
|
|
434
|
+
}
|
|
435
|
+
// One entry per object, however many changes it is behind: the object is what the row is about, and the twenty
|
|
436
|
+
// change records a single work item can lag behind would otherwise repeat the same opening twenty times.
|
|
437
|
+
const behindNotes = (id: string) => {
|
|
438
|
+
const cs = behindOn.get(id) ?? [];
|
|
439
|
+
return cs.length ? [`Still on old understanding: has not followed ${cs.map((b) => `“${oneLine(b.title)}” (\`${b.id}\`)${b.what ? ` — ${b.what}` : ''}${cites.ref(b.sourceIds)}`).join('; ')}`] : [];
|
|
440
|
+
};
|
|
441
|
+
// How to check a mark now, and when waiting material is organized: the same for every mark, so said once, above the
|
|
442
|
+
// map; each mark carries the id that `pk get` reads (Spec §7.1, §7.3; CKC-12 AC-24).
|
|
443
|
+
const markLegend = `How to read the marks: each names an id — \`pk get <id>\` reads it now (the changed document for \`Update pending\`, the clue for the others), or ask the Keeper; material that is \`Update pending\` is organized ${whenOrganized}.`;
|
|
444
|
+
|
|
445
|
+
// Header
|
|
446
|
+
out.push(`# Context for ${request.recipient} · ${request.purpose === 'Work' && scope.focusThread ? `Work: ${scope.label}` : `Project: ${project.name}${scope.kind !== 'project' ? ` · ${scope.label}` : ''}`} · Kind: ${request.kind}`);
|
|
447
|
+
out.push(`As of ${asOf ?? 'unknown'}. If the work, kind or recipient above is wrong, say so; the Keeper takes the correction.`, '');
|
|
448
|
+
const asked = request.scope.ids[0];
|
|
449
|
+
if (asked && (request.scope.kind === 'work' || request.scope.kind === 'area') && removed(asked)) out.push(`The ${request.scope.kind === 'work' ? 'work item' : 'area'} asked for, “${nameOf(store, asked)}” (\`${asked}\`), is history: it was ${removedWhen(asked, true)}. It has no context of its own; what follows is the start-of-work map.`, '');
|
|
450
|
+
|
|
451
|
+
// The project's rules (Spec §1.15; CKC-21): in the start pack all of them, in a work pack those that bear on it.
|
|
452
|
+
const rules = store.rules.all();
|
|
453
|
+
const ruleHelp: RuleRowHelp = { cite: (ids) => cites.ref(ids), withoutOwner: (id) => { const m = withoutOwner(id); return m && checked(m) ? m : undefined; } };
|
|
454
|
+
/** A note that waits for the owner's decision (§2.7): `For your decision`, and the owner has not settled it. */
|
|
455
|
+
const waitsForOwner = (n: Note) => n.status === 'Current' && latest(n).ask === 'For your decision' && !['Decided', 'Delegated', 'No action needed'].includes(n.ownerResponse ?? '');
|
|
456
|
+
|
|
457
|
+
const purposeItems = scope.references.filter((r) => PURPOSE_CATEGORIES.has(r.category) && current(r.validity)).sort((a, b) => (a.category === 'Product' ? 0 : 1) - (b.category === 'Product' ? 0 : 1));
|
|
458
|
+
// The decisions that set the current direction, most recent first; the map's opening says how many there are in all.
|
|
459
|
+
const decisions = store.reference.filter((r) => (r.category === 'Decision' || r.category === 'Boundary') && current(r.validity)).sort((a, b) => (b.asOf ?? '').localeCompare(a.asOf ?? ''));
|
|
460
|
+
const plans = store.reference.filter((r) => r.category === 'Plan' && current(r.validity));
|
|
461
|
+
const areas = [...scope.areaIds].map((id) => store.reference.get(id)).filter((r): r is ReferenceItem => r !== undefined);
|
|
462
|
+
const inProgress = scope.threads.filter((t) => current(t.validity) && t.progress === 'In progress');
|
|
463
|
+
|
|
464
|
+
const inScopeIds = new Set([...scope.references.map((r) => r.id), ...scope.threads.map((t) => t.id)]);
|
|
465
|
+
const wholeProject = scope.kind === 'project';
|
|
466
|
+
const onMapIds = new Set<string>();
|
|
467
|
+
if (request.purpose === 'Start' || !scope.focusThread) {
|
|
468
|
+
section('Purpose', purposeItems.map((g) => refLine(g)));
|
|
469
|
+
const decisionsShown = decisions.slice(0, em.reference === 'full' ? 40 : 12);
|
|
470
|
+
// What does not fit is named by name and id, never left out in silence (Spec §7.1; CKC-12 AC-31; QC AH #12).
|
|
471
|
+
const decisionsLeft = decisions.slice(decisionsShown.length);
|
|
472
|
+
section('Current direction', [
|
|
473
|
+
...decisionsShown.map((d) => refLine(d)),
|
|
474
|
+
decisionsLeft.length ? `- Also in force, older — each in full with \`pk get <id>\`: ${decisionsLeft.map((d) => `${d.name} (\`${d.id}\`)`).join('; ')}` : '',
|
|
475
|
+
inProgress.length ? `- In progress now: ${inProgress.map((t) => `${workTitle(t)} (\`${t.id}\`)`).join('; ')}` : '',
|
|
476
|
+
roleRecipient ? `- For ${roleRecipient}: the items below that name this role, and the notes and requests addressed to it, come first.` : '',
|
|
477
|
+
]);
|
|
478
|
+
// `How this project works` (Spec §7.3 item 3; CKC-21 AC-9): a fixed section, right after the current direction.
|
|
479
|
+
section('How this project works', rulesLines(rules, ruleHelp, rulesOpening(rules, ruleHelp, rules.filter((r) => r.validity === 'Current' && r.category === 'Obsolete').length)));
|
|
480
|
+
|
|
481
|
+
const PROGRESS_ORDER = ['In progress', 'Planned', 'On hold', 'Done'];
|
|
482
|
+
const distribution = (threads: readonly WorkThread[]) => {
|
|
483
|
+
const n = new Map<string, number>();
|
|
484
|
+
for (const t of threads) n.set(t.progress, (n.get(t.progress) ?? 0) + 1);
|
|
485
|
+
return [...n].sort((a, b) => PROGRESS_ORDER.indexOf(a[0]) - PROGRESS_ORDER.indexOf(b[0])).map(([k, v]) => `${k} ${v}`).join(' · ') || 'none';
|
|
486
|
+
};
|
|
487
|
+
const areasOfThread = (t: WorkThread) => [...new Set(t.serves.map((x) => areaOf(store, x.referenceId)).filter((x): x is string => x !== null && !removed(x)))];
|
|
488
|
+
const inScopeThreads = scope.threads.filter((t) => current(t.validity));
|
|
489
|
+
const PROGRESS_RANK = (t: WorkThread) => PROGRESS_ORDER.indexOf(t.progress);
|
|
490
|
+
|
|
491
|
+
// `Plan` (Spec §7.3 item 3; CKC-12 AC-18): the plans and milestones in the project's own order, each with how far
|
|
492
|
+
// its work has got and what it waits on, told with the plan's own explanation, whole. A project with no plan
|
|
493
|
+
// items has no such section.
|
|
494
|
+
const planThreads = (p: ReferenceItem) => inScopeThreads.filter((t) => t.serves.some((s) => s.referenceId === p.id));
|
|
495
|
+
const planOrder = (p: ReferenceItem) => `${p.ids[0] ?? p.name}`;
|
|
496
|
+
const milestonesOf = (p: ReferenceItem) => plans.filter((x) => x.refines.includes(p.id));
|
|
497
|
+
const topPlans = plans.filter((p) => !plans.some((x) => p.refines.includes(x.id))).sort((a, b) => planOrder(a).localeCompare(planOrder(b), undefined, { numeric: true }));
|
|
498
|
+
const planLines: string[] = [];
|
|
499
|
+
const planRow = (p: ReferenceItem, pad: string) => {
|
|
500
|
+
const mine = planThreads(p);
|
|
501
|
+
const done = mine.every((t) => t.progress === 'Done');
|
|
502
|
+
const state = p.progress ?? (mine.length === 0 ? null : mine.some((t) => t.progress === 'In progress') ? 'In progress' : done ? 'Done' : 'Planned');
|
|
503
|
+
// What a plan waits on, from the assets (Spec §7.3 item 4, `Waits on`): the work its own items depend on that is
|
|
504
|
+
// not finished yet — never work that was removed — and, inside the plan, which later item waits on which; and the
|
|
505
|
+
// owner's decisions its work is waiting for.
|
|
506
|
+
const unfinished = (t: WorkThread | undefined): t is WorkThread => t !== undefined && !gone(t.id) && t.progress !== 'Done';
|
|
507
|
+
const waits = [...new Set(mine.flatMap((t) => t.dependsOn.map((d) => d.threadId)))].map((id) => store.threads.get(id)).filter((t): t is WorkThread => unfinished(t) && !mine.some((x) => x.id === t.id));
|
|
508
|
+
const inside = mine.filter((t) => t.progress !== 'Done').flatMap((t) => t.dependsOn.map((d) => store.threads.get(d.threadId)).filter((d): d is WorkThread => unfinished(d) && mine.some((x) => x.id === d.id)).map((d) => `${workTitle(t)} (\`${t.id}\`, ${t.progress}) waits on ${workTitle(d)} (\`${d.id}\`, ${d.progress})`));
|
|
509
|
+
const forOwner = store.notes.filter((n) => waitsForOwner(n) && n.mount.ids.some((id) => id === p.id || mine.some((t) => t.id === id)));
|
|
510
|
+
// Two records disagree (Spec §2.4): the plan's own record of how far it is, against its work items' records.
|
|
511
|
+
const disagree = p.progress && mine.length && ((p.progress === 'Done' && !done) || (p.progress === 'Planned' && mine.some((t) => t.progress !== 'Planned')))
|
|
512
|
+
? [`the records disagree: the plan is recorded as ${p.progress}, and its work items are ${distribution(mine)}`] : [];
|
|
513
|
+
const marks = [...refPending(p), ...markNotes(p.id), ...behindNotes(p.id), ...restsOnRemoved(p.id), ...disagree];
|
|
514
|
+
planLines.push(`${pad}- **${p.name}**${p.ids.length ? ` (${p.ids.join(', ')})` : ''} · \`${p.id}\`${state ? ` · ${state}` : ''}${mine.length ? ` · work: ${distribution(mine)}` : ''}${p.basis === 'Inferred' ? ' · Inferred' : ''}${cites.ref(p.sourceIds)}${marks.length ? ` — ${marks.join('; ')}` : ''}`);
|
|
515
|
+
if (text(p.text)) planLines.push(indent(p.text, `${pad} `));
|
|
516
|
+
const running = mine.filter((t) => t.progress === 'In progress');
|
|
517
|
+
if (running.length) planLines.push(`${pad} In progress here: ${running.map((t) => `${workTitle(t)} (\`${t.id}\`)`).join('; ')}.`);
|
|
518
|
+
if (waits.length) planLines.push(`${pad} Waits on: ${waits.map((t) => `${workTitle(t)} (\`${t.id}\`, ${t.progress})`).join('; ')}.`);
|
|
519
|
+
if (inside.length) planLines.push(`${pad} Inside this plan: ${inside.join('; ')}.`);
|
|
520
|
+
if (forOwner.length) planLines.push(`${pad} Waits on the owner: ${forOwner.map((n) => `the decision on “${oneLine(latest(n).title)}” (\`${n.id}\`)`).join('; ')}.`);
|
|
521
|
+
for (const ms of milestonesOf(p).sort((a, b) => planOrder(a).localeCompare(planOrder(b), undefined, { numeric: true }))) planRow(ms, `${pad} `);
|
|
522
|
+
};
|
|
523
|
+
for (const p of topPlans) planRow(p, '');
|
|
524
|
+
section('Plan', planLines);
|
|
525
|
+
|
|
526
|
+
// `Relevant work` is a map (D52; Spec §7.3; CKC-12 AC-18): one line per area with its area understanding, and the
|
|
527
|
+
// work of the areas in focus one line each, from fixed fields of the assets. What the work is doing, its results
|
|
528
|
+
// and its problems are in the work context, fetched by id. A work item serving several areas is listed once.
|
|
529
|
+
const relevant: string[] = [];
|
|
530
|
+
const planView = isOrchestrator || request.kind === 'Plan';
|
|
531
|
+
const focusAreas = new Set([...inProgress.flatMap(areasOfThread), ...(scope.kind !== 'project' ? scope.areaIds : [])]);
|
|
532
|
+
const paused = project.organizingPaused === true;
|
|
533
|
+
const organizingRefs = new Set((coverage?.organizing ?? []).map((m) => m.ref));
|
|
534
|
+
// The normal state (`Up to date`) is not written; only a scope that is behind says so (D59).
|
|
535
|
+
const coverageOf = (pendingIds: readonly string[]) => {
|
|
536
|
+
if (!pendingIds.length) return null;
|
|
537
|
+
if (paused) return 'Organizing paused';
|
|
538
|
+
return pendingIds.some((id) => { const a = store.sources.get(id)?.anchor; return a?.kind === 'file' && organizingRefs.has(a.path); }) ? 'Organizing' : 'Changes pending';
|
|
539
|
+
};
|
|
540
|
+
const workRow = (t: WorkThread) => {
|
|
541
|
+
const areasHere = areasOfThread(t);
|
|
542
|
+
const holder = t.attribution.holder?.role ?? (t.attribution.author.kind === 'role' ? t.attribution.author.name : null);
|
|
543
|
+
const parts = [`**${workTitle(t)}**`, `\`${t.id}\``, t.progress];
|
|
544
|
+
if (t.acceptance) parts.push(t.acceptance);
|
|
545
|
+
if (holder) parts.push(`held by ${holder}`);
|
|
546
|
+
if (areasHere.length > 1) parts.push(`serves ${areasHere.map(shortName).join(', ')}`);
|
|
547
|
+
if (planView) {
|
|
548
|
+
// The product effect each piece of work serves, with the claim, for whoever orchestrates (Spec §7.3, D11).
|
|
549
|
+
const effects = t.serves.map((x) => `${shortName(x.referenceId)}${x.claim ? ` — ${oneLine(x.claim)}` : ''}`);
|
|
550
|
+
parts.push(`effect: ${effects.length ? effects.join('; ') : 'not yet established'}`);
|
|
551
|
+
if (t.dependsOn.length) parts.push(`depends on ${t.dependsOn.map((d) => `${shortName(d.threadId)} (\`${d.threadId}\`)`).join(', ')}`);
|
|
552
|
+
}
|
|
553
|
+
const marks = [...(t.pendingSourceIds.length ? [pendingMark(t.pendingSourceIds, t.waitsFor)] : []), ...markNotes(t.id), ...behindNotes(t.id), ...(undocumented(t.id) ? ['rests on a decision not yet written into the project documents'] : []), ...restsOnRemoved(t.id), ...twinNotes(t)];
|
|
554
|
+
return ` - ${parts.join(' · ')}${marks.length ? ` — ${marks.join('; ')}` : ''}`;
|
|
555
|
+
};
|
|
556
|
+
const listedUnder = new Map<string, string>();
|
|
557
|
+
const onMap = new Set<string>();
|
|
558
|
+
const mapArea = (a: ReferenceItem | null, threads: readonly WorkThread[]) => {
|
|
559
|
+
const u: AreaUnderstanding | undefined = a ? store.areas.find((x) => x.referenceId === a.id) : undefined;
|
|
560
|
+
const goalsServed = a ? a.refines.map((id) => store.reference.get(id)).filter((r): r is ReferenceItem => r?.category === 'Goal').map((r) => r.name) : [];
|
|
561
|
+
const pendingIds = [...new Set([...(u?.pendingSourceIds ?? []), ...(a?.pendingSourceIds ?? []), ...threads.flatMap((t) => t.pendingSourceIds)])];
|
|
562
|
+
const cover = coverageOf(pendingIds);
|
|
563
|
+
// The area's own item and its understanding wait together: one mark, each change named once.
|
|
564
|
+
const areaIds = [...new Set([...(u?.pendingSourceIds ?? []), ...(a?.pendingSourceIds ?? [])])];
|
|
565
|
+
const areaWaits = [...new Map([...(u?.waitsFor ?? []), ...(a?.waitsFor ?? [])].map((w) => [w.ref, w])).values()];
|
|
566
|
+
const areaMarks = a ? [...(areaIds.length ? [pendingMark(areaIds, areaWaits)] : []), ...markNotes(a.id), ...behindNotes(a.id), ...restsOnRemoved(a.id)] : [];
|
|
567
|
+
relevant.push(`- **${a ? a.name : 'Not under any area'}**${a ? ` · \`${a.id}\`` : ''}${goalsServed.length ? ` · serves ${goalsServed.join('; ')}` : ''} · work: ${distribution(threads)}${cover ? ` · ${cover}` : ''}${a?.basis === 'Inferred' ? ' · Inferred' : ''}${areaMarks.length ? ` — ${areaMarks.join('; ')}` : ''}`);
|
|
568
|
+
// Every area carries its area understanding — what it does now and what is still missing — as the assets write
|
|
569
|
+
// it, whole (Spec §7.3; CKC-12 AC-18). Before D59 the map carried none of it: 26,000 characters of area
|
|
570
|
+
// explanation never reached the agent (D58).
|
|
571
|
+
if (u && text(u.effectNow)) relevant.push(indent(`Now: ${u.effectNow}`, ' '));
|
|
572
|
+
if (u && text(u.gaps)) relevant.push(indent(`Still missing: ${u.gaps}`, ' '));
|
|
573
|
+
if (a) onMap.add(a.id);
|
|
574
|
+
const expanded = a ? focusAreas.has(a.id) : threads.some((t) => t.progress === 'In progress');
|
|
575
|
+
if (!expanded) {
|
|
576
|
+
if (a && threads.length) relevant.push(indent(`Its work is not listed here; \`pk get ${a.id}\` gives this area's context with every work item in it.`, ' '));
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
const elsewhere: WorkThread[] = [];
|
|
580
|
+
for (const t of threads) {
|
|
581
|
+
if (listedUnder.has(t.id)) { elsewhere.push(t); continue; }
|
|
582
|
+
listedUnder.set(t.id, a ? shortName(a.id) : 'Not under any area');
|
|
583
|
+
onMap.add(t.id);
|
|
584
|
+
relevant.push(workRow(t));
|
|
585
|
+
}
|
|
586
|
+
if (elsewhere.length) relevant.push(` - Also in this area, listed above: ${elsewhere.map((t) => `${workTitle(t)} (\`${t.id}\`, under ${listedUnder.get(t.id)})`).join('; ')}`);
|
|
587
|
+
};
|
|
588
|
+
for (const a of areas) mapArea(a, inScopeThreads.filter((t) => areasOfThread(t).includes(a.id)).sort((x, y) => PROGRESS_RANK(x) - PROGRESS_RANK(y)));
|
|
589
|
+
const unplaced = inScopeThreads.filter((t) => areasOfThread(t).length === 0 || (areas.length > 0 && !areasOfThread(t).some((id) => scope.areaIds.has(id)) && scope.kind === 'project'));
|
|
590
|
+
if (unplaced.length) mapArea(null, unplaced.sort((x, y) => PROGRESS_RANK(x) - PROGRESS_RANK(y)));
|
|
591
|
+
for (const id of onMap) onMapIds.add(id);
|
|
592
|
+
if (relevant.some((l) => / — .*(Update pending|Suspected stale|Layer drift|Scope question)/.test(l))) relevant.unshift(`- ${markLegend}`);
|
|
593
|
+
// The map says first what the assets hold and how much of it is here, so an agent knows whether this pack is
|
|
594
|
+
// complete without fetching every object to find out (D59; 2026-09-18: a subagent fetched the lot and built its
|
|
595
|
+
// own index, 46 minutes).
|
|
596
|
+
const allAreas = store.reference.filter((r) => r.category === 'Area' && current(r.validity)).length;
|
|
597
|
+
const allWork = store.threads.filter((t) => current(t.validity)).length;
|
|
598
|
+
// Marks on what was removed, or on history, are not counted: a mark judges current content, and those are history (AC-35).
|
|
599
|
+
const inScopeMarks = store.marks.filter((m) => !m.closed && m.kind !== 'Undocumented decision' && !gone(m.targetId) && (wholeProject || inScopeIds.has(m.targetId)));
|
|
600
|
+
// Every mark that says what differs and names what it was checked against is written somewhere in this pack: on
|
|
601
|
+
// the row of its object, in `Do not revive` for a disposal, in `Pending owner decisions` for a rule a role set in
|
|
602
|
+
// the owner's place, or in `Freshness` when its object is not on this map.
|
|
603
|
+
const writtenMarks = inScopeMarks.filter((m) => checked(m) && !onAnotherJudgement(m.targetId));
|
|
604
|
+
const inForce = rules.filter((r) => r.validity === 'Current');
|
|
605
|
+
const obsoleteRules = inForce.filter((r) => r.category === 'Obsolete').length;
|
|
606
|
+
const replacedRules = rules.filter((r) => r.validity === 'Replaced').length;
|
|
607
|
+
const allWords = store.reference.filter((r) => r.category === OWNER_WORDS && r.validity === 'Current');
|
|
608
|
+
const wordsInPurpose = new Set(purposeItems.flatMap((r) => ownerWordsUnder(r).map((w) => w.id)));
|
|
609
|
+
const inventory = [
|
|
610
|
+
'This pack against what the assets hold:',
|
|
611
|
+
`- Areas: ${areas.length} of ${allAreas} listed below, each with its area understanding.`,
|
|
612
|
+
`- Work items: ${listedUnder.size} of ${allWork} listed — the work of the areas in focus. \`pk get <area id>\` gives an area's context with every work item in it; \`pk options\` lists them all with their ids.`,
|
|
613
|
+
plans.length ? `- Plans and milestones: ${planLines.length ? plans.length : 0} of ${plans.length} in \`Plan\` above.` : '',
|
|
614
|
+
decisions.length ? `- Decisions and boundaries: ${decisionsShown.length} of ${decisions.length} in \`Current direction\` above, most recent first${decisionsShown.length < decisions.length ? '; the others are named at its end, each by id for `pk get`' : ''}.` : '',
|
|
615
|
+
allWords.length ? `- Owner’s words: ${wordsInPurpose.size} of ${allWords.length} in \`Purpose\` above — what the product and its goals rest on; the others come with the work they bear on, first in its work context (\`pk context --work <id>\`).` : '',
|
|
616
|
+
inForce.length || replacedRules ? `- Project rules: ${inForce.length - obsoleteRules} in \`How this project works\` above${obsoleteRules ? `, ${obsoleteRules} obsolete in \`Do not revive\`` : ''}${replacedRules ? `; ${replacedRules} replaced by newer rules and not given` : ''}.` : '',
|
|
617
|
+
`- Entry marks: ${writtenMarks.length} of ${inScopeMarks.length} open marks are written into this pack — ${shownMarkIds.size} on the row of the object they belong to, the rest in \`Freshness\`, \`Do not revive\` or \`Pending owner decisions\`. The others say neither what differs nor what they were checked against, so they stay in the assets (\`pk get <mark id>\` reads one).`,
|
|
618
|
+
`- Normal states are not written: a row with no mark carries nothing the Keeper has found wrong with it.${asOf ? ` All of this is as of ${asOf}.` : ''}`,
|
|
619
|
+
].filter((l) => l.length);
|
|
620
|
+
relevant.unshift(...inventory);
|
|
621
|
+
section('Relevant work', relevant);
|
|
622
|
+
const who = inProgress.filter((t) => t.attribution.holder || (t.attribution.author.kind === 'role' && t.attribution.author.name)).map((t) => `- ${workTitle(t)} (\`${t.id}\`): ${t.attribution.holder?.role ?? t.attribution.author.name}${t.attribution.holder?.window ? ` (${t.attribution.holder.window})` : ''}`);
|
|
623
|
+
section('Who is doing what', who);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* What of a change record is a change in force: not an item written only from material that exists in history — that
|
|
628
|
+
* is lineage, given in `How it got here` where it bears on the work (CKC-12 AC-35). An item about what was removed is a
|
|
629
|
+
* change like any other (the deletion happened); what it removed is named as removed where the item lists what it
|
|
630
|
+
* affects.
|
|
631
|
+
*/
|
|
632
|
+
const fromHistoryOnly = (it: ChangeItem) => { const sources = it.sourceIds.filter((id) => store.sources.has(id)); return sources.length > 0 && sources.every(historyOnly); };
|
|
633
|
+
const visibleItems = (c: ChangeRecord): ChangeItem[] => itemsOf(c).filter((it) => !fromHistoryOnly(it));
|
|
634
|
+
/** The change items this pack gives as history in `How it got here`: they are not listed again as changes in force. */
|
|
635
|
+
const inLineage = new Set<string>();
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* `How it got here` (Spec §2.11, §7.1; CKC-12 AC-35): how the work got here, oldest first — the ledger's lineage of the
|
|
639
|
+
* object itself, and from the assets what on its way is history now: the removed objects it rests on, and the changes
|
|
640
|
+
* that reached it and were since superseded, written only from history, or overtaken. Every step that is history says
|
|
641
|
+
* so, with when and what replaced it or why it went; none of it is current content.
|
|
642
|
+
*/
|
|
643
|
+
const lineageLines = (focusId: string, onTheWay: ReadonlySet<string>, removedHere: readonly { id: string; clause: string }[]): string[] => {
|
|
644
|
+
const steps: { at: string | null; line: string }[] = [];
|
|
645
|
+
const lin = ledgerLineage(ledger, store, focusId);
|
|
646
|
+
for (const s of lin.steps) {
|
|
647
|
+
const when = s.occurred.undated ? `Undated · first seen ${dayOf(s.occurred.at) ?? s.occurred.at}` : dayOf(s.occurred.at) ?? s.occurred.at;
|
|
648
|
+
steps.push({ at: s.occurred.at, line: `- ${when} · ${s.history ? 'History · ' : ''}${s.kind} · ${oneLine(s.title)}` });
|
|
649
|
+
}
|
|
650
|
+
for (const x of removedHere) {
|
|
651
|
+
const r = removalOf(store, x.id);
|
|
652
|
+
steps.push({ at: r?.at ?? null, line: `- ${r ? dayOf(r.at) ?? r.at : 'Undated'} · History · Removed · “${nameOf(store, x.id)}” (\`${x.id}\`), ${x.clause}: ${removedWhen(x.id, false)}` });
|
|
653
|
+
}
|
|
654
|
+
for (const h of historyChanges(store, onTheWay)) {
|
|
655
|
+
steps.push({ at: h.item.at, line: historyChangeLine(h) });
|
|
656
|
+
inLineage.add(`${h.change.id}|${h.item.id}`);
|
|
657
|
+
}
|
|
658
|
+
if (!steps.length && !lin.error) return [];
|
|
659
|
+
steps.sort((a, b) => (a.at === null ? 1 : 0) - (b.at === null ? 1 : 0) || (a.at ?? '').localeCompare(b.at ?? ''));
|
|
660
|
+
return [
|
|
661
|
+
`Oldest first. A step marked History is not current: it lies only in history — an old version, a deleted document, a side branch, material kept for recovery — or it was removed from the current version or superseded; it says when, and what replaced it or why it went.${ledger ? '' : ' The project has no ledger yet, so the steps are what the assets record.'}`,
|
|
662
|
+
...steps.map((s) => s.line),
|
|
663
|
+
...(lin.error ? [`- The ledger could not be read for this lineage (${oneLine(lin.error)}); the steps above are what the assets record.`] : []),
|
|
664
|
+
];
|
|
665
|
+
};
|
|
666
|
+
/** One change on the way that is history now, with why and, when superseded, by what. */
|
|
667
|
+
const historyChangeLine = (h: HistoryChange): string => {
|
|
668
|
+
const it = h.item;
|
|
669
|
+
const head = `- ${dayOf(it.at) ?? 'Undated'} · History · ${it.effect} · ${oneLine(it.title)} (\`${h.change.id}\`, ${decidedBy(it.by, undefined).phrase})`;
|
|
670
|
+
const why = h.why;
|
|
671
|
+
if (why.kind === 'superseded') {
|
|
672
|
+
const by = why.byChangeId ? store.changes.get(why.byChangeId) : undefined;
|
|
673
|
+
const byItem = by ? itemsOf(by).find((x) => x.affects.some((id) => it.affects.includes(id))) ?? itemsOf(by)[0] : undefined;
|
|
674
|
+
return `${head} — superseded${by ? ` on ${dayOf(byItem?.at ?? by.at) ?? by.at} by “${oneLine(byItem?.title ?? by.title)}” (\`${by.id}\`)` : ' by a later change'}${text(why.reason) ? `: ${oneLine(why.reason)}` : ''}${cites.ref(it.sourceIds)}`;
|
|
675
|
+
}
|
|
676
|
+
if (why.kind === 'history only') return `${head} — recorded only from material that exists in history${cites.ref(it.sourceIds)}`;
|
|
677
|
+
const states = it.affects.map((id) => `${nameOf(store, id)} (\`${id}\`) ${(store.reference.get(id)?.validity ?? store.threads.get(id)?.validity ?? store.nodes.get(id)?.validity ?? '').toLowerCase()}`);
|
|
678
|
+
return `${head} — what it set down has since been replaced, given up or removed: ${states.join('; ')}${cites.ref(it.sourceIds)}`;
|
|
679
|
+
};
|
|
680
|
+
// An area's context (Spec §7.4: the area first, then its work) carries the area's lineage: the area item's own, and
|
|
681
|
+
// what is history among the requirements, designs and work in it — the removed ones among them included.
|
|
682
|
+
if (request.purpose === 'Work' && !scope.focusThread && scope.kind === 'area') {
|
|
683
|
+
const areaId = [...scope.areaIds][0];
|
|
684
|
+
if (areaId) {
|
|
685
|
+
const inArea = new Set<string>([areaId, ...scope.references.filter((r) => !PURPOSE_CATEGORIES.has(r.category)).map((r) => r.id), ...scope.threads.map((x) => x.id)]);
|
|
686
|
+
const partOf = [
|
|
687
|
+
...store.reference.filter((r) => removed(r.id) && (r.refines.some((id) => inArea.has(id)) || areaOf(store, r.id) === areaId)).map((r) => ({ id: r.id, clause: `which was part of ${nameOf(store, areaId)}` })),
|
|
688
|
+
...store.threads.filter((x) => removed(x.id) && x.serves.some((s) => inArea.has(s.referenceId))).map((x) => ({ id: x.id, clause: `which served ${x.serves.filter((s) => inArea.has(s.referenceId)).map((s) => nameOf(store, s.referenceId)).join(', ')}` })),
|
|
689
|
+
...[...inArea].flatMap((id) => removedUnder(id).map((x) => ({ id: x.id, clause: `which ${nameOf(store, id)} (\`${id}\`) ${x.how}` }))),
|
|
690
|
+
].filter((x, i, all) => all.findIndex((y) => y.id === x.id) === i);
|
|
691
|
+
section('How it got here', lineageLines(areaId, inArea, partOf));
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/** A document by its place in the project: `docs/PLAN.md › P2` (who wrote what a work item rests on, Spec §7.4 item 1). */
|
|
695
|
+
const docName = (s: Source) => s.anchor.kind === 'file' ? `${fileLocation(project, s).file}${s.anchor.headingPath.length ? ` › ${s.anchor.headingPath.join(' › ')}` : ''}` : s.anchor.kind === 'session' ? `a ${s.anchor.host} session${s.anchor.at ? ` on ${dayOf(s.anchor.at)}` : ''}` : anchorLabel(s.anchor);
|
|
696
|
+
const writtenBy = (r: ReferenceItem) => r.attribution?.author?.kind === 'owner' ? 'the owner' : r.attribution?.author?.name ?? r.attribution?.holder?.role ?? (r.attribution?.author?.kind === 'agent' ? r.attribution.author.host ?? 'an agent' : 'an author the records do not name');
|
|
697
|
+
/** Where a source is, for `Code entry and recent changes`: a code file by repository, file, lines and commit (D65). */
|
|
698
|
+
const locate = (s: Source) => {
|
|
699
|
+
if (s.anchor.kind !== 'file') return anchorLabel(s.anchor);
|
|
700
|
+
const loc = fileLocation(project, s);
|
|
701
|
+
return isCodeSource(s) ? locationText(loc) : `${loc.repo ? `${loc.repo} · ` : ''}${docName(s)}, ${loc.lines}`;
|
|
702
|
+
};
|
|
703
|
+
const codeSourceOf = (ids: readonly string[]) => ids.map((id) => store.sources.get(id)).find((x): x is Source => x !== undefined && isCodeSource(x));
|
|
704
|
+
|
|
705
|
+
// A pack for one work item or one area carries only the project-wide entries that bear on it; the rest are in the
|
|
706
|
+
// start pack (Spec §7.4 item 11; CKC-12 AC-33). What bears on a work item is its own scope and everything on its way
|
|
707
|
+
// up to the owner's words: the decisions it carries out, the rules that apply to it, what its adjustments changed.
|
|
708
|
+
const scopeWords = wholeProject ? [] : [...new Set([...scope.threads.flatMap((t) => [t.id, ...t.ids, t.title]), ...[...scope.areaIds].flatMap((id) => { const r = store.reference.get(id); return r ? [r.id, ...r.ids, r.name] : [id]; })])].filter((w) => w.length > 2);
|
|
709
|
+
/** Does a project-wide entry bear on this scope? It names one of the objects in it, or it is mounted on one. */
|
|
710
|
+
const relatedText = (s: string) => scopeWords.some((w) => s.includes(w));
|
|
711
|
+
const relatedIds = new Set(inScopeIds);
|
|
712
|
+
let workFacts: WorkFacts | null = null;
|
|
713
|
+
|
|
714
|
+
if (request.purpose === 'Work' && scope.focusThread) {
|
|
715
|
+
const t = scope.focusThread;
|
|
716
|
+
|
|
717
|
+
// ── `Owner's words` (D66; Spec §7.1, §7.4 item 1; CKC-12 AC-4, AC-40–AC-42) ──
|
|
718
|
+
// What the owner said that this work traces up to, word for word, then what was adjusted on the way and by whom.
|
|
719
|
+
// Where nothing traces to the owner, the section says so and names the documents the work rests on, and who wrote
|
|
720
|
+
// them, so the agent knows it is following others' accounts.
|
|
721
|
+
const trace = traceOwnerWords(store, t, { scopeWords, checked });
|
|
722
|
+
for (const id of trace.related) relatedIds.add(id);
|
|
723
|
+
const full = em.ownerWords !== 'brief';
|
|
724
|
+
const ow: string[] = [];
|
|
725
|
+
if (trace.words.length && full) {
|
|
726
|
+
ow.push('What the owner said that this work traces up to, word for word:');
|
|
727
|
+
for (const w of trace.words) {
|
|
728
|
+
const label = w.item.category === OWNER_WORDS ? (w.item.ids.length ? `${w.item.ids.join(', ')} · ` : '') : `${w.item.name} · `;
|
|
729
|
+
ow.push(`- ${label}\`${w.item.id}\` · the owner${w.saidOn ? `, ${w.saidOn}` : ''}${cites.ref(w.item.sourceIds)}:\n${quoteBlock(w.quote, ' ')}${w.item.answers ? `\n In answer to: ${quoteBlock(w.item.answers, ' ').trimStart()}` : ''}`);
|
|
730
|
+
}
|
|
731
|
+
} else if (trace.words.length) {
|
|
732
|
+
ow.push('What the owner said that this work traces up to; the words themselves: `pk get <id>`:');
|
|
733
|
+
for (const w of trace.words) ow.push(`- ${w.item.name} (\`${w.item.id}\`${w.saidOn ? `, said ${w.saidOn}` : ''})`);
|
|
734
|
+
} else {
|
|
735
|
+
const docs = trace.path.map((r) => {
|
|
736
|
+
const where = [...new Set(r.sourceIds.map((id) => store.sources.get(id)).filter((s): s is Source => s !== undefined && !historyOnly(s.id)).map(docName))];
|
|
737
|
+
return `${r.name} (\`${r.id}\`)${where.length ? ` from ${where.join(', ')}` : ''}, written by ${writtenBy(r)}${cites.ref(r.sourceIds)}`;
|
|
738
|
+
});
|
|
739
|
+
ow.push(`Not traced to the owner’s words: this work rests on ${docs.length ? docs.join('; ') : 'no product description in the assets'}. What it follows was written by others, not said by the owner.`);
|
|
740
|
+
}
|
|
741
|
+
for (const m of trace.drift) {
|
|
742
|
+
shownMarkIds.add(m.id);
|
|
743
|
+
ow.push(`- Layer drift (\`${m.id}\`) on ${nameOf(store, m.targetId)} (\`${m.targetId}\`): ${oneLine(m.clue)}${cites.ref(m.clueSourceIds)} — what this work serves departs from the owner’s words here.`);
|
|
744
|
+
}
|
|
745
|
+
if (trace.adjustments.length) {
|
|
746
|
+
ow.push(full ? 'Adjustments along the way, oldest first:' : 'Adjustments along the way, oldest first; each by id with `pk get`:');
|
|
747
|
+
for (const a of trace.adjustments) {
|
|
748
|
+
const id = a.changeId ?? a.refId;
|
|
749
|
+
if (!full) { ow.push(`- ${a.on ?? 'undated'} · ${a.title} (\`${id}\`) — ${a.decidedBy}`); continue; }
|
|
750
|
+
const what = [a.material, a.effect].filter((x) => x).join(' · ');
|
|
751
|
+
ow.push(`- ${a.on ?? 'undated'}${what ? ` · ${what}` : ''}: ${a.title} (\`${id}\`) — ${a.decidedBy} — why: ${a.why ?? 'the material gives no reason'}${cites.ref(a.sourceIds)}`);
|
|
752
|
+
if (a.summary && a.summary !== a.title) ow.push(indent(a.summary, ' '));
|
|
753
|
+
if (a.before || a.after) ow.push(` Before: ${oneLine(a.before) || '—'} → after: ${oneLine(a.after) || '—'}`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
section("Owner's words", ow);
|
|
757
|
+
|
|
758
|
+
// ── `Serves`: the Keeper's own summary of the effect, after the owner's words ──
|
|
759
|
+
const servesLines = t.serves.map((s) => {
|
|
760
|
+
if (removed(s.referenceId)) return `- It ${restsOnRemovedText(s.referenceId)}`;
|
|
761
|
+
const r = store.reference.get(s.referenceId);
|
|
762
|
+
const pending = r ? refPending(r) : [];
|
|
763
|
+
return r ? `- ${r.name} (\`${r.id}\`${r.category !== 'Area' ? ` · ${r.category}` : ''}): ${oneLine(s.claim)}${s.basis === 'Inferred' ? ' · Inferred' : ''}${cites.ref(r.sourceIds)}${pending.length ? ` — ${pending.join('; ')}` : ''}` : '';
|
|
764
|
+
});
|
|
765
|
+
if (t.serves.length === 0) servesLines.push('- Not yet established: which product effect this work serves; no material names it. To know it, ask the owner or check the plan that introduced it.');
|
|
766
|
+
const whyNow = store.changes.filter((c) => visibleItems(c).length > 0 && (c.affects.includes(t.id) || t.serves.some((s) => !removed(s.referenceId) && c.affects.includes(s.referenceId)))).sort((a, b) => b.at.localeCompare(a.at))[0];
|
|
767
|
+
if (whyNow) servesLines.push(`- Why now: ${whyNow.effect} — ${oneLine(whyNow.summary)}${cites.ref(whyNow.sourceIds)}`);
|
|
768
|
+
section('Serves', em.serves === 'skip' ? [] : servesLines);
|
|
769
|
+
const rel: string[] = [];
|
|
770
|
+
for (const s of t.serves) {
|
|
771
|
+
if (removed(s.referenceId)) { rel.push(`- **${t.title}** ${restsOnRemovedText(s.referenceId)}`); continue; }
|
|
772
|
+
const found = areaOf(store, s.referenceId);
|
|
773
|
+
const a = found && !removed(found) ? found : null;
|
|
774
|
+
const areaGoals = a ? (store.reference.get(a)?.refines ?? []).map((id) => store.reference.get(id)).filter((r): r is ReferenceItem => r?.category === 'Goal' && !removed(r.id)) : [];
|
|
775
|
+
rel.push(`- ${areaGoals.length ? `${areaGoals.map((g) => g.name).join(' / ')} → ` : ''}${a && a !== s.referenceId ? `${nameOf(store, a)} → ` : ''}${nameOf(store, s.referenceId)} → **${t.title}**`);
|
|
776
|
+
}
|
|
777
|
+
for (const d of t.dependsOn) rel.push(removed(d.threadId) ? `- ${t.title} ${restsOnRemovedText(d.threadId)}` : `- ${t.title} depends on ${nameOf(store, d.threadId)} (\`${d.threadId}\`): ${oneLine(d.claim)}${d.basis === 'Inferred' ? ' · Inferred' : ''}`);
|
|
778
|
+
// Only a questioned assessment is written; `Holds` and `Not assessed` are the normal states and stay in the assets (D59).
|
|
779
|
+
for (const r of store.relations.filter((x) => x.to === t.id && !gone(x.from) && (x.type === 'implements' || x.type === 'verifies' || x.type === 'produced'))) rel.push(`- ${nameOf(store, r.from)} ${r.type} ${t.title}${r.assessment === 'Questioned' ? ' · Questioned' : ''}: ${oneLine(r.evidence.factsSoFar || r.claim)}${cites.ref(r.evidence.sourceIds)}`);
|
|
780
|
+
// Shared only where an area is: what has no area (a plan, a goal) is not a common area of two work items.
|
|
781
|
+
const areasServed = new Set(t.serves.map((y) => areaOf(store, y.referenceId)).filter((a): a is string => a !== null && !removed(a)));
|
|
782
|
+
const shared = store.threads.filter((x) => x.id !== t.id && !gone(x.id) && x.serves.length > 1 && x.serves.some((s) => { const a = areaOf(store, s.referenceId); return a !== null && areasServed.has(a); }));
|
|
783
|
+
for (const x of shared.slice(0, 5)) rel.push(`- Shared foundation: ${x.title} (\`${x.id}\`) serves ${x.serves.filter((s) => !removed(s.referenceId)).map((s) => nameOf(store, s.referenceId)).join(', ')} (${x.progress})`);
|
|
784
|
+
// Where a kind asks for the brief form, the lines that are left out are counted, not dropped in silence: an agent
|
|
785
|
+
// has to be able to tell whether what it holds is all of it (D59).
|
|
786
|
+
const capped = (lines: string[], n: number, what: string) => (lines.length > n ? [...lines.slice(0, n), `- ${lines.length - n} further ${what} are in the assets; \`pk get ${t.id}\` with \`--kind Implement\` gives them all.`] : lines);
|
|
787
|
+
section('Relation map', em.relation === 'skip' ? [] : em.relation === 'brief' ? capped(rel, 4, 'relations') : rel);
|
|
788
|
+
// What is on its way — the same objects `Adjustments along the way` reads — and the removed objects this work or
|
|
789
|
+
// anything on its way rests on.
|
|
790
|
+
const onTheWay = new Set<string>([t.id, ...trace.path.map((r) => r.id), ...trace.words.map((w) => w.item.id), ...trace.replacedWords.map((r) => r.id)]);
|
|
791
|
+
const removedHere = [
|
|
792
|
+
...removedUnder(t.id).map((x) => ({ id: x.id, clause: `which this work ${x.how}` })),
|
|
793
|
+
...trace.path.flatMap((r) => removedUnder(r.id).map((x) => ({ id: x.id, clause: `which ${r.name} (\`${r.id}\`) ${x.how}` }))),
|
|
794
|
+
].filter((x, i, all) => all.findIndex((y) => y.id === x.id) === i);
|
|
795
|
+
section('How it got here', lineageLines(t.id, onTheWay, removedHere));
|
|
796
|
+
const boundaries = store.reference.filter((r) => r.category === 'Boundary' && current(r.validity));
|
|
797
|
+
// The work context is where the detail belongs (D52), and what goes in goes in whole (AC-31): a cut `Done means`
|
|
798
|
+
// hid exactly the open conditions from an agent in the 2026-09-18 trial (D16, D31: no length caps).
|
|
799
|
+
section('Done means', em.done === 'skip' || !t.doneMeans ? [] : [`- ${oneLine(t.doneMeans)}`]);
|
|
800
|
+
// Owner acceptance is its own fact (D41): work that is finished but not accepted still belongs in the context,
|
|
801
|
+
// said plainly, so whoever takes over knows the result exists and knows the owner has not signed it off (CKC-12 AC-19).
|
|
802
|
+
section('Owner acceptance', em.done === 'skip' || !t.acceptance ? [] : [`- ${t.acceptance}${t.acceptanceMeans ? `: ${oneLine(t.acceptanceMeans)}` : ''}`]);
|
|
803
|
+
section('Not included', em.done === 'skip' ? [] : boundaries.map((b) => refLine(b)));
|
|
804
|
+
// A decision this work carries out is a result, not a to-do (Spec §2.2; CKC-12 AC-38): carried out says so; partly
|
|
805
|
+
// carried out says what is left; one not carried out yet is still open.
|
|
806
|
+
const carries = store.reference.filter((r) => !gone(r.id) && r.carryOut?.workIds.includes(t.id) === true);
|
|
807
|
+
const carried = carries.filter((r) => r.carryOut!.status !== 'Not carried out yet').map((r) => {
|
|
808
|
+
const c = r.carryOut!;
|
|
809
|
+
const state = c.status === 'Carried out' ? 'carried out' : `partly carried out; still left: ${oneLine(c.remaining) || 'not recorded'}`;
|
|
810
|
+
const disagree = c.status === 'Carried out' && t.progress === 'Planned' ? ' — the records disagree: this work is Planned' : '';
|
|
811
|
+
return `- Carries out ${r.name} (\`${r.id}\`): ${state}${disagree}${cites.ref(c.evidenceSourceIds)}`;
|
|
812
|
+
});
|
|
813
|
+
section('Existing results', em.results === 'skip' ? [] : [t.results ? `- ${oneLine(t.results)}${t.acceptance === 'Not yet accepted' ? ' (Not yet accepted by the owner)' : ''}` : '', ...carried]);
|
|
814
|
+
|
|
815
|
+
// ── What was reported, and what checking the code found (D63; Spec §2.4; CKC-12 AC-36) ──
|
|
816
|
+
// A claim is said as reported — who and when — never as a fact. Where reading the code showed otherwise, the claim's
|
|
817
|
+
// own row says what the code does. The check is tied to the claim it answers: the relation quotes the claim, or the
|
|
818
|
+
// claim is the only one resting on the material the check names.
|
|
819
|
+
const stmts = [...t.executionFacts, ...t.qcFacts];
|
|
820
|
+
const ownSources = new Set(stmts.flatMap((s) => s.sourceIds));
|
|
821
|
+
const contradictions = store.relations.filter((r) => r.type === 'contradicts' && !gone(r.from) && !gone(r.to) && [r.from, r.to].some((id) => id === t.id || t.factRecordIds.includes(id) || ownSources.has(id)));
|
|
822
|
+
const claims = stmts.filter((s) => s.type === 'Claimed');
|
|
823
|
+
const squash = (s: string) => s.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
824
|
+
const answers = (r: (typeof contradictions)[number], s: Statement) => {
|
|
825
|
+
if (squash(s.text).length && squash(r.claim).includes(squash(s.text))) return true;
|
|
826
|
+
const named = [...r.evidence.sourceIds, r.from, r.to].filter((id) => s.sourceIds.includes(id));
|
|
827
|
+
return named.length > 0 && claims.filter((x) => x.sourceIds.some((id) => named.includes(id))).length === 1;
|
|
828
|
+
};
|
|
829
|
+
const heldAgainst = new Map<Statement, typeof contradictions>();
|
|
830
|
+
for (const r of contradictions) for (const s of claims) if (answers(r, s)) heldAgainst.set(s, [...(heldAgainst.get(s) ?? []), r]);
|
|
831
|
+
const answered = new Set([...heldAgainst.values()].flat().map((r) => r.id));
|
|
832
|
+
const reported = (s: Statement) => (s.claimedBy ? `${reportedBy(s.claimedBy, 'UTC')}${s.claimedBy.untrustedRuleId ? ` (a source the project marks untrusted, \`${s.claimedBy.untrustedRuleId}\`)` : ''}` : 'Reported (who and when are not recorded)');
|
|
833
|
+
const doesNotHold = (s: Statement) => (heldAgainst.get(s) ?? []).map((r) => ` — does not hold in the code: ${oneLine(r.claim)}${cites.ref(r.evidence.sourceIds)}`).join('');
|
|
834
|
+
const known = stmts.map((s) => {
|
|
835
|
+
if (s.type === 'Claimed') return `- ${reported(s)}: ${oneLine(s.text)}${cites.ref(s.sourceIds)}${doesNotHold(s)}`;
|
|
836
|
+
const code = s.type === 'Observed' ? codeSourceOf(s.sourceIds) : undefined;
|
|
837
|
+
if (code) return `- Observed in the code (${shortLocationText(fileLocation(project, code))}): ${oneLine(s.text)}${cites.ref(s.sourceIds)}`;
|
|
838
|
+
return `- ${s.type}: ${oneLine(s.text)}${cites.ref(s.sourceIds)}`;
|
|
839
|
+
});
|
|
840
|
+
for (const r of contradictions.filter((x) => !answered.has(x.id))) known.push(`- Checked against the code, and what was reported does not hold: ${oneLine(r.claim)}${cites.ref(r.evidence.sourceIds)}`);
|
|
841
|
+
if (t.changed && em.failures === 'full') known.unshift(`- Changed on the way: ${oneLine(t.changed)}`);
|
|
842
|
+
section('Known results & failures', em.failures === 'skip' ? [] : em.failures === 'full' ? known : capped(known, 6, 'recorded results and failures'));
|
|
843
|
+
const twin = twinText(t);
|
|
844
|
+
const openLines = [
|
|
845
|
+
t.unresolved ? `- ${oneLine(t.unresolved)}` : '',
|
|
846
|
+
...t.factRecordIds.flatMap((f) => (store.facts.get(f)?.openQuestions ?? []).map((q) => `- ${oneLine(q)}`)),
|
|
847
|
+
...carries.filter((r) => r.carryOut!.status === 'Not carried out yet').map((r) => `- ${r.name} (\`${r.id}\`) asks for something this work carries out; not carried out yet.`),
|
|
848
|
+
...(twin ? [`- The records disagree on this work: ${twin}; this one says ${t.progress}.`] : []),
|
|
849
|
+
];
|
|
850
|
+
section('Open problems', em.open === 'skip' ? [] : em.open === 'full' ? openLines : capped(openLines.filter((l) => l.length), 5, 'open problems'));
|
|
851
|
+
if (request.taskVersion) section('Version check', versionCheck(store, t, scope.references, request.taskVersion, cites));
|
|
852
|
+
|
|
853
|
+
// ── `Code entry and recent changes` (D65; Spec §7.1, §7.4 item 9; CKC-12 AC-4, AC-43) ──
|
|
854
|
+
// Where the code is, what changed in it lately and why, and what checking the reports about it found. No code: the
|
|
855
|
+
// agent opens the current version itself.
|
|
856
|
+
const where: string[] = [];
|
|
857
|
+
const listed = new Set<string>();
|
|
858
|
+
for (const r of store.relations.filter((x) => x.to === t.id && (x.type === 'implements' || x.type === 'verifies') && !gone(x.from))) {
|
|
859
|
+
const s = store.sources.get(r.from);
|
|
860
|
+
if (!s || listed.has(s.id)) continue;
|
|
861
|
+
listed.add(s.id);
|
|
862
|
+
where.push(`- ${locate(s)} — ${r.type} this work${text(r.claim) ? `: ${oneLine(r.claim)}` : ''}${cites.ref([s.id])}`);
|
|
863
|
+
}
|
|
864
|
+
const readToCheck = [...contradictions.flatMap((r) => [r.from, r.to, ...r.evidence.sourceIds]), ...stmts.filter((s) => s.type === 'Observed').flatMap((s) => s.sourceIds)]
|
|
865
|
+
.map((id) => store.sources.get(id)).filter((s): s is Source => s !== undefined && isCodeSource(s) && !gone(s.id));
|
|
866
|
+
for (const s of readToCheck) { if (listed.has(s.id)) continue; listed.add(s.id); where.push(`- ${locate(s)} — read to check what was reported about this work${cites.ref([s.id])}`); }
|
|
867
|
+
const changedHere = store.changes.all().flatMap((c) => visibleItems(c).filter((it) => it.affects.includes(t.id)).map((it) => ({ c, it })));
|
|
868
|
+
const commitIds = [...new Set([...changedHere.flatMap(({ it }) => it.sourceIds), ...ownSources, ...t.factRecordIds.flatMap((f) => store.facts.get(f)?.aboutSourceIds ?? []), ...store.relations.filter((x) => x.to === t.id && x.type === 'implements').map((x) => x.from)])]
|
|
869
|
+
.filter((id) => store.sources.get(id)?.anchor.kind === 'commit' && !gone(id));
|
|
870
|
+
const commitLines = commitIds.map((id) => store.sources.get(id)!).map((s) => ({ s, f: commitFacts(s), at: commitFacts(s).at ?? s.version.readAt }))
|
|
871
|
+
.sort((a, b) => b.at.localeCompare(a.at))
|
|
872
|
+
.map(({ s, f }) => `- commit ${s.anchor.kind === 'commit' ? s.anchor.commit.slice(0, 10) : s.id}${f.at ? ` · ${dayOf(f.at)}` : ''} · ${oneLine(f.subject)}${f.files.length ? ` — files: ${f.files.join(', ')}` : ''}${cites.ref([s.id])}`);
|
|
873
|
+
const codeChanges = changedHere.filter(({ it }) => it.material === 'Code change').sort((a, b) => b.it.at.localeCompare(a.it.at))
|
|
874
|
+
.map(({ c, it }) => `- ${dayOf(it.at) ?? 'undated'} · ${oneLine(it.title)} (\`${c.id}\`) — why: ${text(it.why) || 'the material gives no reason'}${cites.ref(it.sourceIds)}`);
|
|
875
|
+
const checks: string[] = [];
|
|
876
|
+
for (const s of claims) for (const r of heldAgainst.get(s) ?? []) checks.push(`- ${reported(s)}: “${oneLine(s.text)}” — does not hold in the code: ${oneLine(r.claim)}${cites.ref(r.evidence.sourceIds)}`);
|
|
877
|
+
for (const r of contradictions.filter((x) => !answered.has(x.id))) checks.push(`- Checked against the code, and what was reported does not hold: ${oneLine(r.claim)}${cites.ref(r.evidence.sourceIds)}`);
|
|
878
|
+
for (const s of stmts.filter((x) => x.type === 'Observed')) { const code = codeSourceOf(s.sourceIds); if (code) checks.push(`- Checked in the code: ${oneLine(s.text)} (${shortLocationText(fileLocation(project, code))})${cites.ref(s.sourceIds)}`); }
|
|
879
|
+
const unchecked = claims.filter((s) => !heldAgainst.has(s)).length;
|
|
880
|
+
if (unchecked) checks.push(`- ${unchecked} more report${unchecked === 1 ? '' : 's'} about this work ${unchecked === 1 ? 'is' : 'are'} given as reported in \`Known results & failures\`; nothing records ${unchecked === 1 ? 'it' : 'them'} being checked against the code.`);
|
|
881
|
+
section('Code entry and recent changes', em.code === 'skip' ? [] : [
|
|
882
|
+
...(where.length ? [`Where the code is — ${OPEN_CURRENT}`, ...where] : []),
|
|
883
|
+
...(em.code === 'full' && (commitLines.length || codeChanges.length) ? ['Recent changes, latest first:', ...commitLines, ...codeChanges] : []),
|
|
884
|
+
...(checks.length ? ['What was reported about the code, and what checking it found:', ...checks] : []),
|
|
885
|
+
]);
|
|
886
|
+
|
|
887
|
+
// ── `How this project works` for this work (Spec §7.4 item 10; CKC-21 AC-10; CKC-12 AC-39) ──
|
|
888
|
+
const restsOn = [...ownSources, ...t.factRecordIds.flatMap((f) => store.facts.get(f)?.aboutSourceIds ?? []), ...store.relations.filter((x) => x.to === t.id).map((x) => x.from), ...t.pendingSourceIds, ...trace.path.flatMap((r) => r.sourceIds)];
|
|
889
|
+
const holder = t.attribution.holder?.role ?? (t.attribution.author.kind === 'role' ? t.attribution.author.name : null);
|
|
890
|
+
workFacts = {
|
|
891
|
+
words: [...scopeWords, ...trace.path.flatMap((r) => [r.name, ...r.ids])],
|
|
892
|
+
roles: [holder, request.recipient].filter((x): x is string => typeof x === 'string' && x.length > 0),
|
|
893
|
+
kind: request.kind,
|
|
894
|
+
paths: [...new Set(restsOn.map((id) => store.sources.get(id)?.anchor).filter((a) => a?.kind === 'file').map((a) => pathKey((a as { path: string }).path).replace(/\\/g, '/').toLowerCase()))],
|
|
895
|
+
decidedBy: new Set([t.validityByRuleId, t.progressByRuleId, ...trace.path.map((r) => r.validityByRuleId), ...restsOn.map((id) => store.sources.get(id)?.usedAsByRuleId), ...stmts.map((s) => s.claimedBy?.untrustedRuleId)].filter((x): x is string => typeof x === 'string' && x.length > 0)),
|
|
896
|
+
knownRoles: project.roles,
|
|
897
|
+
workNumbers: new Set(store.threads.all().flatMap((x) => x.ids.map((n) => n.toUpperCase()))),
|
|
898
|
+
};
|
|
899
|
+
const facts = workFacts;
|
|
900
|
+
const bearing = rules.filter((r) => ruleBearsOnWork(r, facts));
|
|
901
|
+
for (const r of bearing) relatedIds.add(r.id);
|
|
902
|
+
section('How this project works', rulesLines(bearing, ruleHelp, 'The project’s rules that bear on this work; all of them are in the start pack’s `How this project works`, each in full with `pk get <rule id>`.'));
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Changes since last session / recent (§7.3 item 6, §7.8). Each item says what changed and why, as the material
|
|
906
|
+
// states it, whole (D59): before D59 the summary was cut at 200 characters and the reason fell off the end.
|
|
907
|
+
const since = request.lastSessionAt;
|
|
908
|
+
// Outside the start pack only what bears on this work or scope is listed, the rest of the project's changes are in
|
|
909
|
+
// the start pack (Spec §7.4 item 11; §7.1: Review takes the changes since the last review, Investigate the recent
|
|
910
|
+
// related ones); an item a later change superseded for it is no longer a change in force (§7.1 grading).
|
|
911
|
+
const supersededHere = new Set(wholeProject ? [] : store.propagation.filter((j) => relatedIds.has(j.nodeId)).flatMap((j) => j.closed.filter((x) => x.close === 'Superseded by a later change').map((x) => `${x.changeId}|${x.itemId}`)));
|
|
912
|
+
const shownItems = (c: ChangeRecord): ChangeItem[] => visibleItems(c).filter((it) => wholeProject || (it.affects.some((id) => relatedIds.has(id)) && !supersededHere.has(`${c.id}|${it.id}`) && !inLineage.has(`${c.id}|${it.id}`)));
|
|
913
|
+
const changes = store.changes.all().filter((c) => shownItems(c).length > 0).sort((a, b) => a.at.localeCompare(b.at));
|
|
914
|
+
// Without a last-session reference point, the most recent meaningful changes, mainly the decisions that changed the
|
|
915
|
+
// direction and the replacements (Spec §1.12, §7.3 item 7; QC AH #12): the latest five of those first, the latest other
|
|
916
|
+
// changes after them up to five, all in time order. It used to be the latest five whatever they were.
|
|
917
|
+
const directional = (c: ChangeRecord) => shownItems(c).some((it) => DIRECTION_MATERIALS.has(it.material) || DIRECTION_EFFECTS.has(it.effect));
|
|
918
|
+
const latestFirst = [...changes].reverse();
|
|
919
|
+
const picked = [...latestFirst.filter(directional), ...latestFirst.filter((c) => !directional(c))].slice(0, 5);
|
|
920
|
+
const recent = since ? changes.filter((c) => c.updatedAt > since) : changes.filter((c) => picked.includes(c));
|
|
921
|
+
const isAsset = (id: string) => store.reference.has(id) || store.threads.has(id) || store.nodes.has(id);
|
|
922
|
+
/** An object a change names; one since removed from the current version is named as removed (AC-35). */
|
|
923
|
+
const named = (id: string) => `${nameOf(store, id)} (\`${id}\`${removed(id) ? ', removed from the current version' : ''})`;
|
|
924
|
+
/** One entry per piece of work, its net changes listed under it (D56; Spec §1.8). Each one says what changed and
|
|
925
|
+
* why, as the material states it. A record written before pieces of work existed keeps the old single-line shape,
|
|
926
|
+
* so nothing gains a layer it does not need. */
|
|
927
|
+
const changeLine = (c: ChangeRecord) => {
|
|
928
|
+
const notJudged = (c.notJudged ?? []).filter((n) => isAsset(n.nodeId));
|
|
929
|
+
// What a piece of work only recorded and never maintained — a finished work item, a session, a commit — is named
|
|
930
|
+
// here and judged nowhere (D56), so the reader sees it without it turning into something to chase.
|
|
931
|
+
const tail = notJudged.length ? [` Recorded at the time, not judged: ${notJudged.map((n) => named(n.nodeId)).join('; ')}`] : [];
|
|
932
|
+
if (!c.work) {
|
|
933
|
+
const affects = c.affects.filter(isAsset);
|
|
934
|
+
return [
|
|
935
|
+
`- ${c.at.slice(0, 10)} · ${c.effect} · ${c.material} · **${oneLine(c.title)}** (\`${c.id}\`)${c.segment ? ` · from ${oneLine(c.segment.name)}` : ''}${cites.ref(c.sourceIds)}`,
|
|
936
|
+
indent(c.summary, ' '),
|
|
937
|
+
c.before || c.after ? ` Before: ${oneLine(c.before) || '—'} → after: ${oneLine(c.after) || '—'}` : '',
|
|
938
|
+
affects.length ? ` Affects: ${affects.map(named).join('; ')}` : '',
|
|
939
|
+
...tail,
|
|
940
|
+
].filter((l) => l.length).join('\n');
|
|
941
|
+
}
|
|
942
|
+
const items = shownItems(c);
|
|
943
|
+
return [
|
|
944
|
+
`- ${c.at.slice(0, 10)} · ${c.work.kind}: **${oneLine(c.work.label)}** (\`${c.id}\`)${c.work.openEnded ? ' · still going when this round began' : ''}${items.length > 1 ? ` · ${items.length} changes` : ''}${cites.ref(items.flatMap((it) => it.sourceIds))}`,
|
|
945
|
+
...items.flatMap((it) => {
|
|
946
|
+
const affects = it.affects.filter(isAsset);
|
|
947
|
+
return [
|
|
948
|
+
` - ${it.effect} · ${it.material} · **${oneLine(it.title)}**${cites.ref(it.sourceIds)}`,
|
|
949
|
+
indent(it.summary, ' '),
|
|
950
|
+
it.before || it.after ? ` Before: ${oneLine(it.before) || '—'} → after: ${oneLine(it.after) || '—'}` : '',
|
|
951
|
+
it.why ? ` Why: ${oneLine(it.why)}` : '',
|
|
952
|
+
affects.length ? ` Affects: ${affects.map(named).join('; ')}` : '',
|
|
953
|
+
].filter((l) => l.length);
|
|
954
|
+
}),
|
|
955
|
+
...tail,
|
|
956
|
+
].join('\n');
|
|
957
|
+
};
|
|
958
|
+
if (request.taskVersion && !(request.purpose === 'Work' && scope.focusThread)) section('Version check', versionCheck(store, scope.focusThread ?? null, scope.references, request.taskVersion, cites));
|
|
959
|
+
// The section keeps its fixed name (§7.3); without a last-session reference point it says so and lists the most recent meaningful changes.
|
|
960
|
+
if (request.purpose === 'Start' || em.recentChanges === 'full' || em.reviewChanges === 'full') section('Changes since last session', recent.length ? [...(since ? [] : ['- No last-session reference point was given; the most recent meaningful changes follow in time order — chiefly the decisions and replacements that changed the direction, then the latest others.']), ...recent.map(changeLine)] : []);
|
|
961
|
+
|
|
962
|
+
// D15 grading: Do not revive / Not in current scope / Pending owner decisions (Spec §7.1).
|
|
963
|
+
const bears = (id: string) => wholeProject || relatedIds.has(id);
|
|
964
|
+
/** What the project's own `Obsolete` rule withdraws is `Do not revive` with the rule as its source (§1.15, §7.1). */
|
|
965
|
+
const byObsoleteRule = (ruleId: string | null | undefined) => { const rule = ruleId ? store.rules.get(ruleId) : undefined; return rule?.category === 'Obsolete' ? rule : undefined; };
|
|
966
|
+
const retiredRefs = store.reference.filter((r) => (r.validity === 'Replaced' || r.validity === 'Abandoned') && (bears(r.id) || (r.replacedBy ? bears(r.replacedBy) : false)));
|
|
967
|
+
const retiredThreads = store.threads.filter((t) => (t.validity === 'Replaced' || t.validity === 'Abandoned') && (bears(t.id) || (t.replacedBy ? bears(t.replacedBy) : false)));
|
|
968
|
+
const withdrawn = (ruleId: string | null | undefined) => { const rule = byObsoleteRule(ruleId); return rule ? ` — withdrawn by the project’s rule \`${rule.id}\`` : ''; };
|
|
969
|
+
const retired = [...retiredRefs.map((r) => `- ${r.name} (\`${r.id}\`): ${r.validity === 'Replaced' ? `replaced by ${r.replacedBy ? `${nameOf(store, r.replacedBy)} (\`${r.replacedBy}\`)` : 'newer content'}` : 'abandoned'}${withdrawn(r.validityByRuleId)}${text(r.text) ? ` — ${oneLine(r.text)}` : ''}${cites.ref([...r.sourceIds, ...(byObsoleteRule(r.validityByRuleId)?.sourceIds ?? [])])}`),
|
|
970
|
+
...retiredThreads.map((t) => `- ${t.title} (\`${t.id}\`): ${t.validity === 'Replaced' ? `replaced by ${t.replacedBy ? `${nameOf(store, t.replacedBy)} (\`${t.replacedBy}\`)` : 'newer work'}` : 'abandoned'}${withdrawn(t.validityByRuleId)}`)];
|
|
971
|
+
const withdrawnHere = new Set([...retiredRefs.map((r) => r.validityByRuleId), ...retiredThreads.map((t) => t.validityByRuleId)].filter((x): x is string => typeof x === 'string'));
|
|
972
|
+
const facts = workFacts;
|
|
973
|
+
const obsolete = rules.filter((r) => r.validity === 'Current' && r.category === 'Obsolete' && (wholeProject || withdrawnHere.has(r.id) || (facts !== null && ruleBearsOnWork({ ...r, category: 'Other' }, facts))))
|
|
974
|
+
.map((r) => `- ${oneLine(r.summary)} (\`${r.id}\`) — the project’s own rule marks this obsolete${r.appliesTo.length ? `; it covers ${r.appliesTo.join(', ')}` : ''}${cites.ref(r.sourceIds)}`);
|
|
975
|
+
const disposal = store.marks.filter((m) => m.kind === 'Disposal' && !m.closed && !gone(m.targetId) && bears(m.targetId)).map((m) => `- Disposal: ${nameOf(store, m.targetId)} (\`${m.targetId}\`) — ${oneLine(m.clue)}${cites.ref(m.clueSourceIds)}`);
|
|
976
|
+
section('Do not revive', [...retired, ...obsolete, ...disposal]);
|
|
977
|
+
const deferred = [...store.reference.filter((r) => r.validity === 'Deferred' && bears(r.id)).map((r) => `- ${r.name} (\`${r.id}\`): ${oneLine(r.text)}${cites.ref(r.sourceIds)}`), ...store.threads.filter((t) => t.validity === 'Deferred' && bears(t.id)).map((t) => `- ${t.title} (\`${t.id}\`): ${oneLine(t.doing)}`)];
|
|
978
|
+
section('Not in current scope', deferred);
|
|
979
|
+
const noteInScope = (n: Note) => wholeProject || n.mount.ids.some((id) => relatedIds.has(id)) || (n.mount.kind === 'project' && relatedText(`${latest(n).title} ${latest(n).preview}`));
|
|
980
|
+
const pendingNotes = store.notes.filter((n) => waitsForOwner(n) && noteInScope(n));
|
|
981
|
+
const proposed = store.reference.filter((r) => r.validity === 'Proposed' && bears(r.id));
|
|
982
|
+
const proposedRules = rules.filter((r) => r.validity === 'Proposed' && bears(r.id));
|
|
983
|
+
// A rule or decision a role set in the owner's place, in force now (§1.9; CKC-12 AC-37): who set it and when, and why
|
|
984
|
+
// it is the owner's to decide. The agent follows it meanwhile, knowing it is not the owner's decision.
|
|
985
|
+
const inForceNow = (id: string) => (store.reference.get(id)?.validity ?? store.rules.get(id)?.validity) === 'Current';
|
|
986
|
+
const setWithoutOwner = store.marks.filter((m) => m.kind === 'Decided without owner' && !m.closed && checked(m) && !gone(m.targetId) && inForceNow(m.targetId) && bears(m.targetId)).map((m) => {
|
|
987
|
+
const rule = store.rules.get(m.targetId);
|
|
988
|
+
return `- Decided without owner, in force now: ${rule ? oneLine(rule.summary) : nameOf(store, m.targetId)} (\`${m.targetId}\`) — set by ${m.decidedBy?.who ?? 'a role the records do not name'}${m.decidedBy?.at ? ` on ${dayOf(m.decidedBy.at)}` : ''}; the owner decides because: ${oneLine(m.clue)} (\`${m.id}\`)${cites.ref(m.clueSourceIds)}`;
|
|
989
|
+
});
|
|
990
|
+
section('Pending owner decisions', [
|
|
991
|
+
...setWithoutOwner,
|
|
992
|
+
...proposed.map((r) => `- Proposed: ${r.name} (\`${r.id}\`) — ${oneLine(r.text)}${cites.ref(r.sourceIds)}`),
|
|
993
|
+
...proposedRules.map((r) => `- Proposed rule: ${oneLine(r.summary)} (\`${r.id}\`)${cites.ref(r.sourceIds)}`),
|
|
994
|
+
...pendingNotes.map((n) => `- For the owner’s decision: ${latest(n).title} (\`${n.id}\`) — ${oneLine(latest(n).preview)}`),
|
|
995
|
+
]);
|
|
996
|
+
|
|
997
|
+
// Notes for you: the gist of each note whole, with its id; the full note is fetched by id (§7.3 item 9).
|
|
998
|
+
const notes = store.notes.filter((n) => n.status === 'Current' && noteInScope(n));
|
|
999
|
+
const noteShown = notes.slice(0, em.notes === 'full' ? 12 : 5);
|
|
1000
|
+
const noteLines = noteShown.map((n) => { const v = latest(n); return `- ${v.title} (\`${n.id}\` · ${v.ask}): ${oneLine(v.preview)}${v.body.keepAdjust && em.notes === 'full' ? ` Keep/adjust: ${oneLine(v.body.keepAdjust)}` : ''}${cites.ref(v.body.facts.flatMap((f) => f.sourceIds))}`; });
|
|
1001
|
+
if (noteShown.length < notes.length) noteLines.push(`- ${notes.length - noteShown.length} further current note${notes.length - noteShown.length === 1 ? '' : 's'} in scope are not listed here: ${notes.slice(noteShown.length).map((n) => `${latest(n).title} (\`${n.id}\`)`).join('; ')}.`);
|
|
1002
|
+
if (roleRecipient) for (const r of store.requests.filter((x) => !x.handled && x.holder.toLowerCase() === roleRecipient.toLowerCase())) noteLines.push(`- Modification request for ${r.holder}: ${oneLine(r.what)} — why: ${oneLine(r.why)}${r.impact.length ? ` — affects ${r.impact.filter((id) => !gone(id)).map((id) => `${nameOf(store, id)} (\`${id}\`)`).join(', ')}` : ''}${cites.ref(r.basisSourceIds)}`);
|
|
1003
|
+
section('Notes for you', noteLines);
|
|
1004
|
+
// An orchestrator's product purpose of each piece of work is on its map row (Spec §7.3); a work context has no map.
|
|
1005
|
+
if (isOrchestrator && request.purpose === 'Work' && scope.focusThread) {
|
|
1006
|
+
const contracts = store.threads.filter((t) => current(t.validity) && t.ids.length > 0).map((t) => `- ${t.ids[0]} ${t.title} (\`${t.id}\`): ${t.serves.filter((s) => !removed(s.referenceId)).map((s) => `${nameOf(store, s.referenceId)} — ${oneLine(s.claim)}`).join('; ') || 'purpose not yet established'}`);
|
|
1007
|
+
section('Product purpose of each piece of work in hand', contracts);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// Freshness
|
|
1011
|
+
const fresh: string[] = [];
|
|
1012
|
+
// The repository's head is not what the content covers: commits read but not yet organized are named (trial,
|
|
1013
|
+
// 2026-09-18: a header naming the latest commit made an agent take that commit's work as already reflected).
|
|
1014
|
+
const commitsSince = (coverage?.pending ?? []).filter((m) => m.kind === 'commit').map((m) => m.since).sort()[0] ?? null;
|
|
1015
|
+
const unorganizedCommits = commitsSince ? store.sources.filter((x) => x.anchor.kind === 'commit' && x.usedAs === null && x.version.readAt >= commitsSince).sort((a, b) => a.version.readAt.localeCompare(b.version.readAt)) : [];
|
|
1016
|
+
// "organized" is said only about a head this project has actually read. A home that does not watch the project sees
|
|
1017
|
+
// no pending commits, and the head then went unmentioned as if it were covered (cold-start trial, 2026-09-18: the
|
|
1018
|
+
// pack covered material up to 08:03Z and still called the 15:03Z head organized).
|
|
1019
|
+
const headSource = commit ? store.sources.find((x) => x.anchor.kind === 'commit' && (x.anchor.commit.startsWith(commit) || commit.startsWith(x.anchor.commit))) : undefined;
|
|
1020
|
+
const headState = !commit ? '' : headSource === undefined ? ', not read into the assets yet' : headSource.usedAs === null ? ', read but not organized yet' : ', organized';
|
|
1021
|
+
// The coverage label is written only when the scope is behind; `Up to date` is the normal state and stays in the assets (D59).
|
|
1022
|
+
const coverageLabel = coverage && coverage.coverage !== 'Up to date' ? ` Coverage: ${coverage.coverage}.` : '';
|
|
1023
|
+
fresh.push(`- As of ${asOf ?? 'unknown'}.${coverageLabel}${commit ? ` The repository is at commit ${commit.slice(0, 10)}${headState}.` : ''}`);
|
|
1024
|
+
if (unorganizedCommits.length) fresh.push(`- Commits read but not yet organized, so not reflected above: ${unorganizedCommits.map((x) => `${oneLine(x.title)} (\`${x.id}\`)`).join('; ')}.`);
|
|
1025
|
+
// The total and its parts come from the same list, so they add up (D43; CKC-12 AC-28). They used to come from two
|
|
1026
|
+
// records — the project scope's pending list and a store-wide tally — and the parts could exceed the total.
|
|
1027
|
+
const pendingList = coverage?.pending ?? [];
|
|
1028
|
+
const byKind = new Map<string, number>();
|
|
1029
|
+
for (const m of pendingList) byKind.set(m.kind, (byKind.get(m.kind) ?? 0) + 1);
|
|
1030
|
+
// A file too large to read is skipped, not failed (D105): not counted here, also in a home that still lists it as failed.
|
|
1031
|
+
const failedCount = coverage ? realFailures([coverage]).length : 0;
|
|
1032
|
+
fresh.push(`- Unprocessed changes, counted when this pack was generated: ${pendingList.length}${byKind.size ? ` (${[...byKind].map(([k, v]) => `${k} ${v}`).join(', ')})` : ''}${failedCount ? `; ${failedCount} material(s) failed to organize` : ''}.`);
|
|
1033
|
+
// Spec §7.1 / CKC-13 AC-22: which history is not organized, at what depth, and how to reach it.
|
|
1034
|
+
const tk = store.coverage.takeover;
|
|
1035
|
+
if (tk && tk.historyNotOrganized > 0) fresh.push(`- Takeover depth: ${tk.depth}. ${tk.historyNotOrganized} older material(s) are not organized into fact records (${tk.levels.filter((l) => l.level !== 'Read in full' && l.materials > 0).map((l) => `${l.level}: ${l.materials}`).join(', ')}). When your work touches that history, ask the Keeper to read it (Explore further) or search the sources; it is indexed, not summarised.`);
|
|
1036
|
+
fresh.push(`- Observed: ${project.scope.filter((i) => i.relation !== 'Excluded').map((i) => `${i.category.toLowerCase()} ${i.path}`).join('; ')}.`);
|
|
1037
|
+
fresh.push(`- Keeper status when generated: ${keeperStatus}.`);
|
|
1038
|
+
// A mark on a row of the map is written in that row, and one on the way to the owner's words in `Owner's words`; the
|
|
1039
|
+
// rest are listed here by name, id and kind, as an index (Spec §7.1, §7.3). Each says what differs and how to check it now.
|
|
1040
|
+
const staleMarks = store.marks.filter((m) => !m.closed && (m.kind === 'Suspected stale' || m.kind === 'Layer drift' || m.kind === 'Scope question') && !gone(m.targetId) && bears(m.targetId) && !onMapIds.has(m.targetId) && !shownMarkIds.has(m.id) && checked(m) && !onAnotherJudgement(m.targetId));
|
|
1041
|
+
const behindOffMap = [...behindOn.entries()].filter(([nodeId]) => !gone(nodeId) && bears(nodeId) && !onMapIds.has(nodeId));
|
|
1042
|
+
if (staleMarks.length || refPendingWritten || scope.threads.some((x) => x.pendingSourceIds.length && !onMapIds.has(x.id))) fresh.push(`- ${markLegend}`);
|
|
1043
|
+
for (const m of staleMarks) fresh.push(`- ${m.kind} (\`${m.id}\`) on ${nameOf(store, m.targetId)} (\`${m.targetId}\`): ${oneLine(m.clue)}${cites.ref(m.clueSourceIds)}`);
|
|
1044
|
+
// Still on old understanding (§7.1; D56): which object has not followed which change, and what it lacks.
|
|
1045
|
+
for (const [nodeId, cs] of behindOffMap) fresh.push(`- Still on old understanding: ${nameOf(store, nodeId)} (\`${nodeId}\`) has not followed ${cs.map((c) => `“${oneLine(c.title)}” (\`${c.id}\`)${c.what ? ` — ${c.what}` : ''}${cites.ref(c.sourceIds)}`).join(', ')}.`);
|
|
1046
|
+
for (const t of scope.threads.filter((x) => x.pendingSourceIds.length && !onMapIds.has(x.id))) fresh.push(`- ${t.title} (\`${t.id}\`): ${pendingMark(t.pendingSourceIds, t.waitsFor)}.`);
|
|
1047
|
+
section('Freshness', fresh);
|
|
1048
|
+
|
|
1049
|
+
section('Explore further', agentUsage(project.id, where).map((l) => `- ${l}`));
|
|
1050
|
+
|
|
1051
|
+
// A source that exists only in history, or that is no longer in the current version, says so where it is listed (AC-35).
|
|
1052
|
+
const historyLabel = (s: Source) => s.usedAs === 'History only' ? ' — history only, not the current version' : s.availability === 'No longer available' ? ' — history: no longer in the current version' : '';
|
|
1053
|
+
const provenance = cites.list().map((id, i) => { const s = store.sources.get(id)!; return `[${i + 1}] \`${id}\` ${s.title} — ${anchorLabel(s.anchor)}${historyLabel(s)}${s.hasCredential ? ' (contains a credential; not reproduced)' : ''}`; });
|
|
1054
|
+
section('Sources', provenance);
|
|
1055
|
+
|
|
1056
|
+
const markdown = out.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
|
|
1057
|
+
return { id: newId('ctx'), projectId: project.id, request, asOf: asOf ?? 'unknown', commit, keeperStatus, markdown, citedSourceIds: cites.list(), generatedAt: new Date().toISOString(), deliveries: [] };
|
|
1058
|
+
}
|