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,2340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pk_* tools: how the Keeper reads and writes ProjectKeeper assets from inside a pi session.
|
|
3
|
+
* Every write records its basis (source ids) and the job it happened in (Spec §3.10 留痕).
|
|
4
|
+
* These tools never touch project files.
|
|
5
|
+
*/
|
|
6
|
+
import type { ProcessExpectation } from '../model/vocab.ts';
|
|
7
|
+
import type { ClerkStage, StepTiming } from '../model/k-types.ts';
|
|
8
|
+
import type { LaneRunner } from './organize/lane-tools.ts';
|
|
9
|
+
import { Type } from 'typebox';
|
|
10
|
+
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
|
11
|
+
import type {
|
|
12
|
+
AreaUnderstanding, Attribution, ChangeItem, ChangeRecord, ClaimedBy, ClosedItem, EntryMark, FactRecord, FollowUpRound, GraphNode, GraphRelation, ItemRef, JobInputs, LackedItem, Note, NoteBody, ObjectJudgement, OrganizingPlan, OrganizingPlanContent, ProjectRule, ReferenceItem, Source, Statement, WorkSegment, WorkThread, KeeperJob } from '../model/types.ts';
|
|
13
|
+
import {
|
|
14
|
+
ACCEPTANCE, ASSESSMENT, BASIS, CARRY_OUT, CHANGE_EFFECT, CHANGE_MATERIAL, IDENTITY, ITEM_CLOSE, MARK_KIND, MATERIAL_RULE, NODE_CATEGORY, PROCESS_EXPECTATION,
|
|
15
|
+
NOTE_ASK, OWNER_WORDS, PROGRESS, PROPAGATION, REFERENCE_CATEGORY, RELATION_TYPE, RULE_GROUP, STATEMENT_TYPE, USED_AS, VALIDITY,
|
|
16
|
+
WORK_SEGMENT_KIND, isOneOf, type MaterialRule,
|
|
17
|
+
} from '../model/vocab.ts';
|
|
18
|
+
import { newId, stableId } from '../model/ids.ts';
|
|
19
|
+
import { normalizeMaterialTime } from '../model/time.ts';
|
|
20
|
+
import type { ProjectStore } from '../store/project-store.ts';
|
|
21
|
+
import { cameFromFor } from './note-origin.ts';
|
|
22
|
+
import { laneNamesOf, noteFormRefusal, ownerTextRefusal } from './owner-text.ts';
|
|
23
|
+
import {
|
|
24
|
+
MARK_ABOUT, NOT_A_CHANGE_ADVICE, NOT_JUDGED_MESSAGE, duplicateNote, isDecision, itemsForObject,
|
|
25
|
+
itemsOf, judgeObject, lastJudgement, markRefusal, notAChange, notJudgedReason, objectExists, openRound, requestForHolder, roundNumberOf,
|
|
26
|
+
roundResultOf, sameItem, settleNotJudged, startRound, undonePairs, type MarkAbout,
|
|
27
|
+
} from './adjustment.ts';
|
|
28
|
+
import { mergeOf, mergeRefusal, mergeWorkItems, resolveMergedId } from './merge.ts';
|
|
29
|
+
import { findReferences, formedFrom } from './references.ts';
|
|
30
|
+
import { replacementCircle, ruleUseRefusal } from './rules.ts';
|
|
31
|
+
import { historyTools, relationEndpointHistoryRefusal, ruleHistoryRefusal, usedAsHistoryRefusal, workItemHistoryRefusal } from './history-tools.ts';
|
|
32
|
+
import { ownerUtteranceTools } from './utterance-tools.ts';
|
|
33
|
+
import { anchorLabel, redactCredentials } from '../sources/anchor.ts';
|
|
34
|
+
import type { Project } from '../model/types.ts';
|
|
35
|
+
import { clerkFollowUpRecordOf, isTakeoverWork, judgesFollowUp, roundKindOf, takeoverRoundOf } from './roles.ts';
|
|
36
|
+
import { scopeTools } from './scope-tools.ts';
|
|
37
|
+
import { worktreeOrigin } from '../scope/worktree.ts';
|
|
38
|
+
import { checkVerbatim, inferredExcerptWarning, ownerConfirmationRefusal, ownerWordsRefusal, referenceQuoteRefusal, ruleExcerptRefusal, sameWords } from './verbatim.ts';
|
|
39
|
+
import { incompleteCall } from './step-timing.ts';
|
|
40
|
+
import { isNumberShaped, numberOnlyRefusal, otherObjectOf, undefinedNumbers, withNumbering } from './numbers-check.ts';
|
|
41
|
+
import { isNumberOnly } from './organize/entries.ts';
|
|
42
|
+
import { NAME_SEP, placeAgain, reachOf } from './organize/placing.ts';
|
|
43
|
+
import { Ledger } from '../ledger/index.ts';
|
|
44
|
+
import { NO_PLAN } from '../../ui/placement.js';
|
|
45
|
+
import { workbenchPlacement } from './organize/workbench-placement.ts';
|
|
46
|
+
import { inferenceContext, noAreaWhyRefusal, noPlanWhyRefusal, noteOverride, planLayerPresent, programTargetsOf, type InferenceContext } from './organize/placement-inference.ts';
|
|
47
|
+
import { AS_FAR_AS, absenceClaims, absenceRefusal, lookedLine, lookedRecord, parseLooked, presentOf, stampAsFarAs } from './organize/absence.ts';
|
|
48
|
+
import { plansOf } from '../process/placement.ts';
|
|
49
|
+
import { successorOf } from './organize/generation-check.ts';
|
|
50
|
+
import { earlierWork } from './organize/carried-on.ts';
|
|
51
|
+
import { ownerWordsContextRefusal } from './organize/owner-lines.ts';
|
|
52
|
+
|
|
53
|
+
/** Why a takeover job gets no Follow up round (Spec §5.5, §3.7). */
|
|
54
|
+
const TAKEOVER_NO_ROUND = 'This job is part of taking the project over, and propagation is judged in the Follow up rounds after the takeover, so it opens no Follow up round and joins none. During the takeover, record what the material shows on the objects themselves — progress, results, validity, marks — and run the replacement check along the timeline with pk_check_decisions.';
|
|
55
|
+
const TAKEOVER_UNDERWAY = (stage: string) => `The project is still being taken over (stage: ${stage}), and propagation is judged in the Follow up rounds that start once the takeover is done, so no round is opened now. Record what you found on the object itself — progress, results, validity, a mark with its clue — and it is judged in the first Follow up round.`;
|
|
56
|
+
const NOT_THE_ROUNDS_MAIN_JOB = 'A Follow up round’s judgements are written by that round’s cross-check and its one result is counted when the round closes (before the clerk method, by the round’s own main job), so this job neither opens a round nor judges or writes a result in the one that is open. Record what the owner said or what you found on the object itself — the work item, the reference item, a mark with its clue — citing the source, and the next Follow up round judges it; the owner can start that round now with Follow up.';
|
|
57
|
+
const UNKNOWN_ROUND_JOB = (id: string) => `Keeper job ${id || '(missing id)'} is not registered in this project, so it cannot open or join a Follow up round or write a propagation judgement.`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The context the tools read at call time. A conversation session keeps one tool set across
|
|
61
|
+
* turns, so the runtime updates the per-job fields (jobId, onSaved …) on this object each turn.
|
|
62
|
+
*/
|
|
63
|
+
export interface ToolContext {
|
|
64
|
+
readonly store: ProjectStore;
|
|
65
|
+
readonly project: Project;
|
|
66
|
+
jobId: string;
|
|
67
|
+
jobKind: string;
|
|
68
|
+
readonly model: string | null;
|
|
69
|
+
/** Called by pk_investigate; provided by the runtime (nested focused session). */
|
|
70
|
+
investigate?: (question: string, hints: string[]) => Promise<{ conclusion: string; sourceIds: string[]; jobId: string }>;
|
|
71
|
+
onSaved?: (collection: string, id: string, label: string) => void;
|
|
72
|
+
/** Materials this job organizes: a fact record about one of them gets a stable id per material and covers all its sources. */
|
|
73
|
+
materials?: readonly { readonly key: string; readonly sourceIds: readonly string[] }[];
|
|
74
|
+
/** The judgement record of a product re-look: notes, assessments and investigations attach to it. */
|
|
75
|
+
judgementId?: string | null;
|
|
76
|
+
/** The Follow up round this job belongs to (§5.5). Absent: the first judgement of the job opens one. */
|
|
77
|
+
roundId?: string | null;
|
|
78
|
+
/** In a conversation: the source id of the owner's current message, and how to turn the turn into a delegated request (§1.14). */
|
|
79
|
+
ownerSourceId?: string | null;
|
|
80
|
+
beginRequest?: (input: { label: string; quote: string; scope: string; authorizationId: string | null }) => string;
|
|
81
|
+
/** Investigation results are kept with the job so the conversation can show them structured. */
|
|
82
|
+
onInvestigationResult?: (result: unknown) => void;
|
|
83
|
+
requestRelook?: (scope: { kind: 'project' | 'area' | 'thread'; id: string | null }, reason: string) => void;
|
|
84
|
+
/** The step of a clerk-method round this job is (Spec §3.3); null for any other job. */
|
|
85
|
+
readonly step?: KeeperJob['step'] | null;
|
|
86
|
+
/** D99: the job's time so far, from its step timer (§3.10): `pk_stage` records each stage's share of it. */
|
|
87
|
+
timingSnapshot?: () => StepTiming | null;
|
|
88
|
+
/** D99: the runtime's way to run the main job's lanes (lane-tools.ts `pk_send_lanes`). */
|
|
89
|
+
lanes?: LaneRunner;
|
|
90
|
+
/** D99: what the planner does as the main agent enters a stage (stage-tools.ts `pk_stage`): recompute the breakpoint
|
|
91
|
+
* candidates, and the program's lists for that stage. */
|
|
92
|
+
stageEntered?: (roundId: string, stage: ClerkStage) => Promise<{ readonly note: string | null }> | { readonly note: string | null };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const text = (v: unknown, fallback = ''): string => (typeof v === 'string' ? v : fallback);
|
|
96
|
+
const arr = <T>(v: unknown): T[] => (Array.isArray(v) ? (v as T[]) : []);
|
|
97
|
+
const now = () => new Date().toISOString();
|
|
98
|
+
|
|
99
|
+
function ok(value: unknown) {
|
|
100
|
+
const body = typeof value === 'string' ? value : JSON.stringify(value, null, 1);
|
|
101
|
+
return { content: [{ type: 'text' as const, text: body.length > 120_000 ? `${body.slice(0, 120_000)}\n…(truncated)` : body }], details: {} };
|
|
102
|
+
}
|
|
103
|
+
function fail(message: string) {
|
|
104
|
+
return { content: [{ type: 'text' as const, text: `ERROR: ${message}` }], details: {}, isError: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ───────────────────────── create or update: an update gives only what it changes ─────────────────────────
|
|
108
|
+
//
|
|
109
|
+
// Every writer that creates or updates an item takes an update as a call naming an item that exists — its id, or the
|
|
110
|
+
// project's own number or key the writer matches on — and changes only what the call gives: what it leaves out keeps
|
|
111
|
+
// its value. The fields only a new item needs are optional in the schema, and the writer asks for them itself when the
|
|
112
|
+
// call creates (test-D-1: ten updates that gave only what they changed were refused for the title and progress the
|
|
113
|
+
// schema required, and the job ended).
|
|
114
|
+
|
|
115
|
+
/** Left out of the call. An update keeps its value; a new item needs what its writer names. */
|
|
116
|
+
export const absent = (v: unknown): boolean => v === undefined || v === null;
|
|
117
|
+
/** Given with a value: present and, for text, not blank (a writer's text fields left empty keep their value). */
|
|
118
|
+
export const has = (v: unknown): boolean => !absent(v) && !(typeof v === 'string' && v.trim() === '');
|
|
119
|
+
const listed = (items: readonly string[]): string => (items.length <= 1 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The refusal of a call that would create an item but leaves out what a new one needs: why it creates one, exactly what
|
|
123
|
+
* is missing, and how to update the one meant instead. Nothing is written. It is thrown (step-timing.ts `incompleteCall`),
|
|
124
|
+
* so the timer and the refusal guard read it as the refusal of a call that never ran, as they read pi's refusal of the
|
|
125
|
+
* same call when the schema still required those fields.
|
|
126
|
+
*/
|
|
127
|
+
export function createNeeds(item: string, why: string, missing: readonly string[], toUpdate: string): Error {
|
|
128
|
+
return incompleteCall(`${why}, so this call would create ${item}, and a new one needs ${listed(missing)}, which the call leaves out. Nothing was written. ${toUpdate} An update gives only what it changes: what it leaves out keeps its value.`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function keeperAttribution(ctx: ToolContext, identity: Attribution['identity'] = 'Interpretation'): Attribution {
|
|
132
|
+
return { author: { kind: 'agent', name: 'Keeper', window: null, host: 'pi', model: ctx.model }, holder: null, identity };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function statements(ctx: ToolContext, raw: unknown, label: string): Statement[] | string {
|
|
136
|
+
const out: Statement[] = [];
|
|
137
|
+
for (const [i, s] of arr<Record<string, unknown>>(raw).entries()) {
|
|
138
|
+
if (!isOneOf(STATEMENT_TYPE, s.type)) return `${label}[${i}].type must be one of ${STATEMENT_TYPE.join(', ')}`;
|
|
139
|
+
const sourceIds = arr<string>(s.sourceIds).filter((id) => typeof id === 'string');
|
|
140
|
+
const missing = sourceIds.filter((id) => !ctx.store.sources.has(id));
|
|
141
|
+
if (missing.length) return `${label}[${i}] cites unknown source ids: ${missing.join(', ')}`;
|
|
142
|
+
if (s.type === 'Observed' && sourceIds.length === 0) return `${label}[${i}] is Observed but cites no source`;
|
|
143
|
+
// §1.15 Untrusted: what comes from a source the project marks untrusted stays a claim and never supports Observed.
|
|
144
|
+
const untrusted = text(s.untrustedRuleId).trim();
|
|
145
|
+
if (untrusted && s.type !== 'Claimed') {
|
|
146
|
+
return `${label}[${i}] names an Untrusted rule but is ${s.type}: a statement from a source the project marks untrusted stays Claimed and never supports Observed. Write it as Claimed, with who claimed it and when.`;
|
|
147
|
+
}
|
|
148
|
+
if (untrusted) {
|
|
149
|
+
const refusal = ruleUseRefusal(ctx.store, untrusted, 'Untrusted claim');
|
|
150
|
+
if (refusal) return `${label}[${i}].untrustedRuleId must name an Untrusted rule in force: ${refusal}`;
|
|
151
|
+
}
|
|
152
|
+
// §2.4, D63: a claim says whose it is and when — which report, role or agent, on which day.
|
|
153
|
+
let claimedBy: ClaimedBy | null = null;
|
|
154
|
+
if (s.type === 'Claimed') {
|
|
155
|
+
const who = text(s.claimedBy).trim();
|
|
156
|
+
const when = text(s.claimedAt).trim();
|
|
157
|
+
if (!who || !when) {
|
|
158
|
+
return `${label}[${i}] is Claimed but does not say who claimed it and when (which report, role or agent, on which day; an agent’s report is its claim, never a fact). Add claimedBy — for example "Worker agent, batch 3 receipt" — and claimedAt — the date or time the material gives, for example 2026-09-17.`;
|
|
159
|
+
}
|
|
160
|
+
const at = normalizeMaterialTime(when);
|
|
161
|
+
if (!at) return `${label}[${i}].claimedAt must be the date or time the claim was made, as the material gives it: a date (2026-09-17), which stays a date, or an ISO time. “${when}” is neither.`;
|
|
162
|
+
claimedBy = { who, at, untrustedRuleId: untrusted || null };
|
|
163
|
+
}
|
|
164
|
+
out.push({ id: newId('st'), type: s.type, text: text(s.text), sourceIds, ...(claimedBy ? { claimedBy } : {}) });
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const redact = (s: string): string => redactCredentials(s).text;
|
|
170
|
+
const PLAN_ID = 'organizing-plan';
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Why an object may not become `Removed`, or null (Spec §2.1, §2.6; D61; CKC-02 AC-5). `Removed` is for what was built
|
|
174
|
+
* before its material was deleted from the project's current version, and the deletion is what shows it: a source
|
|
175
|
+
* that is `No longer available`. A move changes no validity; a move into a place the project keeps for recovery is
|
|
176
|
+
* judged by the project's rule for it (`byRuleId`, checked by the caller).
|
|
177
|
+
*/
|
|
178
|
+
function removedRefusal(store: ProjectStore, sourceIds: readonly string[], byRuleId: string): string | null {
|
|
179
|
+
if (byRuleId) return null;
|
|
180
|
+
const sources = [...new Set(sourceIds)].map((id) => store.sources.get(id)).filter((s): s is Source => s !== undefined);
|
|
181
|
+
if (sources.some((s) => s.availability === 'No longer available')) return null;
|
|
182
|
+
if (sources.length > 0 && sources.every((s) => s.availability === 'Moved')) {
|
|
183
|
+
return `Its sources were moved, not deleted: a move does not change validity. The source shows Moved with its new place. When the project says the place it moved to is kept for recovery only, judge it by that rule (validityByRuleId, a Recovery only rule).`;
|
|
184
|
+
}
|
|
185
|
+
return `Removed is for an object whose material was deleted from the project’s current version, and none of the sources it rests on is No longer available${sources.length ? '' : ' — it rests on no source at all'}. While its material is still there, it is not removed; when you doubt it still applies, say so with Suspected stale.`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Why `Decided without owner` may not hang on this object, or null (Spec §1.9, §1.11; CKC-05 AC-15). It marks a rule or
|
|
190
|
+
* decision in force that a role set in the owner's place: a product reference item or one of the project's rules,
|
|
191
|
+
* `Current`, and not the owner's own.
|
|
192
|
+
*/
|
|
193
|
+
function decidedWithoutOwnerRefusal(store: ProjectStore, id: string): string | null {
|
|
194
|
+
const ref = store.reference.get(id);
|
|
195
|
+
const rule = store.rules.get(id);
|
|
196
|
+
if (!ref && !rule) return `Decided without owner hangs on the rule or decision a role set in the owner’s place — a product reference item or one of the project’s rules; ${id} is neither.`;
|
|
197
|
+
if (ref && (ref.attribution.identity === 'Decision' || ref.attribution.author.kind === 'owner' || ref.category === OWNER_WORDS)) {
|
|
198
|
+
return `${id} (“${ref.name}”) is the owner’s own decision (the owner said or approved it), so it was not decided without the owner. The mark is for a rule a role set in the owner’s place.`;
|
|
199
|
+
}
|
|
200
|
+
if (rule?.ownerConfirmation) return `${id} (“${rule.summary}”) is a rule the owner confirmed (${rule.ownerConfirmation.sourceId}), so it is not decided without the owner.`;
|
|
201
|
+
const validity = ref?.validity ?? rule!.validity;
|
|
202
|
+
if (validity !== 'Current') {
|
|
203
|
+
return `Decided without owner is for a rule that is in force now (it is Current and applies); ${id} is ${validity}. A rule that no longer applies needs no owner decision; one that was replaced may have a successor in force to check.`;
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function inputsOf(ctx: ToolContext, raw: unknown, location: string): JobInputs {
|
|
209
|
+
const r = (raw ?? {}) as Record<string, unknown>;
|
|
210
|
+
return {
|
|
211
|
+
jobId: ctx.jobId, sourceIds: arr<string>(r.sourceIds), factRecordIds: arr<string>(r.factRecordIds), threadIds: arr<string>(r.threadIds),
|
|
212
|
+
areaIds: arr<string>(r.areaIds), referenceIds: arr<string>(r.referenceIds), noteIds: arr<string>(r.noteIds), location,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const StrArr = Type.Array(Type.String());
|
|
217
|
+
const StatementSchema = Type.Object({
|
|
218
|
+
type: Type.String({ description: 'Observed | Claimed | Inferred | Open' }), text: Type.String(), sourceIds: Type.Array(Type.String(), { description: 'source ids this statement rests on' }),
|
|
219
|
+
claimedBy: Type.Optional(Type.String({ description: 'Claimed only (required): whose claim it is — the report, role or agent, e.g. "Worker agent, batch 3 receipt"' })),
|
|
220
|
+
claimedAt: Type.Optional(Type.String({ description: 'Claimed only (required): when it was claimed, as the material gives it — a date (2026-09-17) stays a date; an ISO time when the material gives the clock' })),
|
|
221
|
+
untrustedRuleId: Type.Optional(Type.String({ description: 'Claimed only: the project’s Untrusted rule that covers this source, when one does' })),
|
|
222
|
+
});
|
|
223
|
+
const InputsSchema = Type.Optional(Type.Object({ sourceIds: Type.Optional(StrArr), factRecordIds: Type.Optional(StrArr), threadIds: Type.Optional(StrArr), areaIds: Type.Optional(StrArr), referenceIds: Type.Optional(StrArr), noteIds: Type.Optional(StrArr) }, { description: 'what this job actually received as input (for the record)' }));
|
|
224
|
+
|
|
225
|
+
/** Where a reference item hangs in the graph: the first Area, Goal or Product reached through `refines` (itself included). */
|
|
226
|
+
function placement(store: ProjectStore, id: string, depth = 0, seen = new Set<string>()): 'Area' | 'Goal' | 'Product' | null {
|
|
227
|
+
const item = store.reference.get(id);
|
|
228
|
+
if (!item || depth > 8 || seen.has(id)) return null;
|
|
229
|
+
seen.add(id);
|
|
230
|
+
if (item.category === 'Area' || item.category === 'Goal' || item.category === 'Product') return item.category;
|
|
231
|
+
for (const up of item.refines) { const p = placement(store, up, depth + 1, seen); if (p) return p; }
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
/** Soft feedback the Keeper sees in the tool result: an item that hangs from nothing shows up as Unplaced (§1.5, §6.3). */
|
|
235
|
+
function referenceWarning(store: ProjectStore, item: ReferenceItem): string | null {
|
|
236
|
+
const hasAreas = store.reference.find((r) => r.category === 'Area' && r.validity === 'Current') !== undefined;
|
|
237
|
+
const hasGoals = store.reference.find((r) => r.category === 'Goal' && r.validity === 'Current') !== undefined;
|
|
238
|
+
const hasProduct = store.reference.find((r) => r.category === 'Product' && r.validity === 'Current') !== undefined;
|
|
239
|
+
// The owner's words are the top layer (§1.3): the other layers refine them, and they refine nothing.
|
|
240
|
+
if (item.category === 'Product' || item.category === OWNER_WORDS) return null;
|
|
241
|
+
if (item.category === 'Goal') return hasProduct && !item.refines.some((r) => store.reference.get(r)?.category === 'Product') ? 'This Goal does not refine the Product item; add refines: [the Product id].' : null;
|
|
242
|
+
if (item.category === 'Area') return hasGoals && !item.refines.some((r) => store.reference.get(r)?.category === 'Goal') ? 'This Area refines no Goal; add refines: [the Goal ids whose effect this area delivers].' : null;
|
|
243
|
+
if (placement(store, item.id)) return null;
|
|
244
|
+
if (item.wholeProductWhy?.trim()) return null;
|
|
245
|
+
if (!hasAreas && !hasGoals && !hasProduct) return null;
|
|
246
|
+
return `Not placed: this ${item.category} refines nothing that leads to an Area, Goal or Product, so it shows as Unplaced. Add refines: the Area(s) it belongs to (documents usually name their Module or feature), or the Product together with wholeProductWhy when it covers the whole product (on the Product with no reason it still counts as not placed). Refining only another document does not place it unless that document is placed.`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Soft feedback for a relation whose endpoints are not the ones the vocabulary defines (round 1, 2026-09-17: a QC
|
|
251
|
+
* report was recorded as one work item verifying another, and work items served a contract without serving an area).
|
|
252
|
+
* The relation is still written: the material may be unusual. The Keeper sees what the definition expects.
|
|
253
|
+
*/
|
|
254
|
+
function relationWarning(store: ProjectStore, type: string, fromId: string, toId: string): string | null {
|
|
255
|
+
const catOf = (id: string): string =>
|
|
256
|
+
store.reference.get(id)?.category ?? (store.threads.has(id) ? 'Work item' : store.sources.has(id) ? 'source' : store.facts.has(id) ? 'fact record' : store.changes.has(id) ? 'Change' : store.areas.has(id) ? 'area understanding' : 'other');
|
|
257
|
+
const from = catOf(fromId), to = catOf(toId);
|
|
258
|
+
if (type === 'verifies' && !['source', 'Test', 'Review'].includes(from)) {
|
|
259
|
+
return `verifies runs from a Test or a Review (or the source id of the report or test itself) to what it checks; this one starts at a ${from}. When a piece of work checked another, write the check as a Review or Test item and relate that.`;
|
|
260
|
+
}
|
|
261
|
+
if (from === 'Work item' && to === 'Work item' && ['implements', 'serves'].includes(type)) {
|
|
262
|
+
return `A work item that carries out another work item is an execution batch, and an execution batch is not a work item of its own: write it with pk_write_thread carriesOut, which records what it did as the progress of the work items it carries out, and keep the batch’s plan as a Plan reference item.`;
|
|
263
|
+
}
|
|
264
|
+
if (type === 'implements' && !['source', 'Result'].includes(from)) {
|
|
265
|
+
return `implements runs from a Result (or the source id of the code) to the Work item or Requirement it realises; this one starts at a ${from}.`;
|
|
266
|
+
}
|
|
267
|
+
if (type === 'carries out' && from !== 'Work item') {
|
|
268
|
+
return `carries out runs from the piece of work that carries a decision out (a Work item) to the decision; this one starts at a ${from}. pk_record_carry_out records the carry-out together with these relations.`;
|
|
269
|
+
}
|
|
270
|
+
if (type === 'serves' && from === 'Work item' && !['Area', 'Goal'].includes(to)) {
|
|
271
|
+
const placed = store.relations.find((r) => r.type === 'serves' && r.from === fromId && ['Area', 'Goal'].includes(catOf(r.to))) !== undefined;
|
|
272
|
+
if (!placed) return `A work item serves first the Area (or Goal) whose effect it delivers — that is what places it in the List and the graph — and then the ${to} it fulfils. This work item serves no Area yet; add that relation too.`;
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Which assets a change touches, worked out from the material it was read in (Product architect, 2026-09-17, after
|
|
279
|
+
* D43): the code knows which sources the change cites and which items were formed from those sources, so it fills
|
|
280
|
+
* `affects` when the model left it empty. 34 of this project's 129 change records had none, and a change that names
|
|
281
|
+
* nothing can neither be followed up nor put a star on anything.
|
|
282
|
+
*/
|
|
283
|
+
export function affectsFromSources(store: ProjectStore, sourceIds: readonly string[]): string[] {
|
|
284
|
+
const formed = formedFrom(store, sourceIds);
|
|
285
|
+
return [...new Set([...formed.referenceIds, ...formed.threadIds])];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The piece of work a change record covers (§1.8, D56), read from the call, or the reason it cannot be read, or
|
|
290
|
+
* null when the caller named none (a record written before pieces of work were recorded).
|
|
291
|
+
*/
|
|
292
|
+
function workSegmentOf(p: Record<string, unknown>): WorkSegment | string | null {
|
|
293
|
+
const kind = text(p.workKind);
|
|
294
|
+
const label = text(p.workLabel);
|
|
295
|
+
const sessionId = text(p.workSessionId);
|
|
296
|
+
if (!kind && !label && !sessionId) return null;
|
|
297
|
+
if (!isOneOf(WORK_SEGMENT_KIND, kind)) return `workKind must be one of ${WORK_SEGMENT_KIND.join(', ')}: a piece of work is one session, one execution, or — when the changes have no session — the stretch of time they were grouped into`;
|
|
298
|
+
// §1.8: the precision the material gives is kept — a stretch of history the material dates by day stays a date.
|
|
299
|
+
const startedAt = normalizeMaterialTime(text(p.workStartedAt));
|
|
300
|
+
const endedAt = normalizeMaterialTime(text(p.workEndedAt));
|
|
301
|
+
if (!startedAt) return 'workStartedAt must be the time this piece of work began: an ISO time, or the date when the material gives only the day';
|
|
302
|
+
if (!endedAt) return 'workEndedAt must be the time this piece of work ended, or its last pause when it was still running: an ISO time, or the date when the material gives only the day';
|
|
303
|
+
if (kind === 'Session' && !sessionId && !label) return 'a Session piece of work names its session: give workSessionId, or workLabel when only the host and time are known';
|
|
304
|
+
return {
|
|
305
|
+
kind, label: label || sessionId || `${kind} ${startedAt}`, sessionId: sessionId || null,
|
|
306
|
+
startedAt, endedAt,
|
|
307
|
+
openEnded: p.workOpenEnded === true,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Why this mount may not be written on a new note, or null (owner 2026-09-22: four notes were written with the
|
|
313
|
+
* project id inside a project mount's ids, and the panel showed a dead "On <id>" link). `project` hangs on the
|
|
314
|
+
* whole project and takes no ids; the other kinds take ids of what is in the assets — the same lookup the panel
|
|
315
|
+
* uses to name a mount (`mountView` in server/graph-view.ts).
|
|
316
|
+
*/
|
|
317
|
+
export function noteMountRefusal(store: ProjectStore, kind: string, ids: readonly string[]): string | null {
|
|
318
|
+
if (kind === 'project') {
|
|
319
|
+
return ids.length === 0 ? null : `A note on the whole project (mountKind project) hangs on the project itself and takes no mount ids — these do not belong here: ${ids.join(', ')}. Leave mountIds empty; to hang the note on an object, use mountKind node or relation with that object's id.`;
|
|
320
|
+
}
|
|
321
|
+
if (kind === 'relation') {
|
|
322
|
+
const missing = ids.filter((id) => !store.relations.has(id));
|
|
323
|
+
return missing.length === 0 ? null : `mountKind relation takes relation ids; these are not relations in the assets: ${missing.join(', ')}. Name each mounted relation by its relation id.`;
|
|
324
|
+
}
|
|
325
|
+
const missing = ids.filter((id) => !store.nodes.has(id) && !store.reference.has(id) && !store.threads.has(id));
|
|
326
|
+
if (missing.length === 0) return null;
|
|
327
|
+
// A code territory is not on the graph, so a note hung on it alone would hang on nothing the workbench can show.
|
|
328
|
+
const territories = missing.filter((id) => store.territories.has(id));
|
|
329
|
+
if (territories.length) {
|
|
330
|
+
return `mountKind ${kind} takes graph objects (graph nodes, reference items, work items), and ${territories.join(', ')} ${territories.length === 1 ? 'is a code territory' : 'are code territories'}, which the graph does not draw. Hang the note on the area the territory serves (or the work that built it), and name the anomaly it is about in codeAnomalies: the anomaly in Code opens the note.${missing.length > territories.length ? ` These name nothing in the assets: ${missing.filter((id) => !territories.includes(id)).join(', ')}.` : ''}`;
|
|
331
|
+
}
|
|
332
|
+
return `mountKind ${kind} takes the ids of objects in the assets (graph nodes, reference items, work items); these name nothing in the assets: ${missing.join(', ')}.`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The code-territory anomalies a note names (`codeAnomalies` of pk_write_note), resolved to the territory and the index of
|
|
337
|
+
* each, or why one cannot be: an unknown territory, or an index the territory does not have. A territory with a single
|
|
338
|
+
* anomaly may be named without the index.
|
|
339
|
+
*/
|
|
340
|
+
export function noteAnomalyTargets(store: ProjectStore, given: readonly Record<string, unknown>[]): { territoryId: string; index: number }[] | string {
|
|
341
|
+
const out: { territoryId: string; index: number }[] = [];
|
|
342
|
+
for (const [i, a] of given.entries()) {
|
|
343
|
+
const territoryId = text(a.territoryId).trim();
|
|
344
|
+
const t = store.territories.get(territoryId);
|
|
345
|
+
if (!t) return `codeAnomalies[${i}].territoryId: ${territoryId || '(empty)'} is not a code territory of the assets.`;
|
|
346
|
+
if (t.anomalies.length === 0) return `codeAnomalies[${i}]: ${t.name} has no anomaly for the note to be about.`;
|
|
347
|
+
const index = typeof a.index === 'number' ? a.index : t.anomalies.length === 1 ? 0 : NaN;
|
|
348
|
+
if (!Number.isInteger(index) || index < 0 || index >= t.anomalies.length) return `codeAnomalies[${i}].index: which of ${t.name}’s ${t.anomalies.length} anomalies the note is about, counted from 0.`;
|
|
349
|
+
if (!out.some((o) => o.territoryId === territoryId && o.index === index)) out.push({ territoryId, index });
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Why this note may not be written, or null (Spec §5.5, §4.1; CKC-11 AC-17, CKC-08 AC-21). A round writes notes
|
|
356
|
+
* for exactly two things — something the owner must decide, one question per note, and a situation worth the
|
|
357
|
+
* owner's knowing, one matter per note — and never one note per object that is behind: the object's own state
|
|
358
|
+
* already says what it has not followed, and 5 mount points on the 2026-09-18 assets carried the same note twice.
|
|
359
|
+
*/
|
|
360
|
+
export function noteRefusalFor(store: ProjectStore, n: {
|
|
361
|
+
id: string; mountIds: readonly string[]; changeIds: readonly string[]; ask: string;
|
|
362
|
+
title: string; preview: string; whyItMatters: string; whatWouldSettleIt: string;
|
|
363
|
+
}): string | null {
|
|
364
|
+
const duplicate = duplicateNote(store, n.mountIds, n.changeIds, n.title, n.id);
|
|
365
|
+
if (duplicate) {
|
|
366
|
+
const v = duplicate.versions[duplicate.versions.length - 1];
|
|
367
|
+
return `There is already a current note on this mount about the same matter: ${duplicate.id} — “${v?.title ?? ''}”. Update that one (pk_write_note with id: "${duplicate.id}" and a reason) instead of writing a second.`;
|
|
368
|
+
}
|
|
369
|
+
// The title and the first sentence (the preview) say the same thing (§4.2): a question in both is one question.
|
|
370
|
+
const asked = (t: string) => t.split('?').length - 1;
|
|
371
|
+
const questions = (asked(n.preview) || asked(n.title)) + asked(n.whatWouldSettleIt);
|
|
372
|
+
if (n.ask === 'For your decision' && questions > 1) {
|
|
373
|
+
return `One question per note: this one asks ${questions}. Write the one question the owner has to decide here, and a separate note for each of the others.`;
|
|
374
|
+
}
|
|
375
|
+
// A note that only repeats a propagation state makes a reminder and adds nothing the state does not already say.
|
|
376
|
+
const latest = new Map<string, ObjectJudgement>();
|
|
377
|
+
for (const j of store.propagation.all()) {
|
|
378
|
+
const before = latest.get(j.nodeId);
|
|
379
|
+
if (!before || roundNumberOf(j.roundId) > roundNumberOf(before.roundId)) latest.set(j.nodeId, j);
|
|
380
|
+
}
|
|
381
|
+
const behind = n.mountIds.some((id) => latest.get(id)?.state === 'Still on old understanding');
|
|
382
|
+
if (behind && n.ask !== 'For your decision' && !n.whyItMatters.trim() && !n.whatWouldSettleIt.trim()) {
|
|
383
|
+
return `That object's judgement already records what it has not followed, so a note only repeats it and makes a reminder (no note per object that is behind). Write a note here only for something the owner must decide (ask "For your decision", one question), or for a situation worth the owner's knowing — then say in whyItMatters what makes it worth knowing. What needs no owner decision goes to the holder as a modification request (pk_write_modification_request).`;
|
|
384
|
+
}
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const IDENT_RE = /\b([A-Z]{1,6}(?:-[A-Z]{1,4})?-?\d{1,4}(?:\.\d{1,3})*)\b/;
|
|
389
|
+
/**
|
|
390
|
+
* An id that is a whole project number (CJ: aligned with numbering.ts, whose index tables and prompt ids number work with two
|
|
391
|
+
* capitals — `AS`, `AY`): letters and digits, a prefix and digits, or two capital letters. Two capitals count only as an
|
|
392
|
+
* id given whole; in a name they are too often a word (`UI polish`), so a name still needs digits.
|
|
393
|
+
*/
|
|
394
|
+
const WHOLE_ID_RE = /^([A-Z]{1,6}(?:-[A-Z]{1,4})?-?\d{1,4}(?:\.\d{1,3})*|[A-Z]{2})$/;
|
|
395
|
+
/**
|
|
396
|
+
* CZ: an id written like one of the project's numbers (`E14`, `CKC-09`, `AP`; `V3` too — a side log's own numbering). A
|
|
397
|
+
* writer given such an id that is no record's own id looks it up as a number: it is never stored as a record id.
|
|
398
|
+
*/
|
|
399
|
+
export const isWrittenNumber = (s: string): boolean => WHOLE_ID_RE.test(s.trim().toUpperCase());
|
|
400
|
+
/** The identifier a reference item answers to: its first id, else the identifier its name starts with (D36, CKC-13, CK-M2, PA-10, R-07; AS, AY as ids). */
|
|
401
|
+
export function primaryIdentifier(ids: readonly string[], name: string): string | null {
|
|
402
|
+
// A version (v2.1) names a version of several documents, never one item.
|
|
403
|
+
const isVersion = (t: string) => /^V\d/.test(t);
|
|
404
|
+
for (const i of ids) {
|
|
405
|
+
const whole = WHOLE_ID_RE.exec(i.trim().toUpperCase());
|
|
406
|
+
if (whole && !isVersion(whole[1]!)) return whole[1]!;
|
|
407
|
+
const m = IDENT_RE.exec(i.toUpperCase());
|
|
408
|
+
if (m && !isVersion(m[1]!)) return m[1]!;
|
|
409
|
+
}
|
|
410
|
+
const head = name.slice(0, 24).toUpperCase();
|
|
411
|
+
const m = IDENT_RE.exec(head);
|
|
412
|
+
return m && !isVersion(m[1]!) && head.indexOf(m[1]!) <= 3 ? m[1]! : null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The reference item a written id names (CQ, D104): its store id; else the one live item whose ids carry it (a Plan's `K`
|
|
417
|
+
* or `P1`, an Area's `CK-M2`) or whose name opens with it; else the one Plan or Area whose name's leading token is it. So
|
|
418
|
+
* every writer that places by `serves` or `refines` finds a Plan by the id its document gives it, as `pk_place_range` does.
|
|
419
|
+
*/
|
|
420
|
+
/**
|
|
421
|
+
* The id a plan document gives a Plan when it is no number the numbering families know (CQ, D104): the leading token of
|
|
422
|
+
* its heading, before the first separator — a letter or a word, with a trailing number at most (`K`, `M1`, `Sprint 3`) —
|
|
423
|
+
* when a separator follows it (a plan named by one word alone keeps its name as its name). Null otherwise.
|
|
424
|
+
*/
|
|
425
|
+
export function planIdOf(name: string): string | null {
|
|
426
|
+
const squashed = name.replace(/\s+/g, ' ').trim();
|
|
427
|
+
const lead = (squashed.split(NAME_SEP)[0] ?? '').trim();
|
|
428
|
+
if (!lead || lead === squashed || lead.length > 16) return null;
|
|
429
|
+
return /^[\p{L}\p{N}][\p{L}\p{N}.-]{0,11}(?:\s?\p{N}{1,3})?$/u.test(lead) && !/^\d+$/.test(lead) ? lead : null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function referenceByWrittenId(store: ProjectStore, value: string): ReferenceItem | null {
|
|
433
|
+
const v = value.trim();
|
|
434
|
+
if (!v) return null;
|
|
435
|
+
const direct = store.reference.get(v);
|
|
436
|
+
if (direct) return direct;
|
|
437
|
+
const up = v.toUpperCase();
|
|
438
|
+
const live = store.reference.filter((r) => r.validity !== 'Replaced' && r.validity !== 'Removed');
|
|
439
|
+
const byIds = live.filter((r) => r.ids.some((i) => i.trim().toUpperCase() === up) || primaryIdentifier(r.ids, r.name) === up);
|
|
440
|
+
if (byIds.length === 1) return byIds[0]!;
|
|
441
|
+
if (byIds.length > 1) { const places = byIds.filter((r) => r.category === 'Plan' || r.category === 'Area'); return places.length === 1 ? places[0]! : null; }
|
|
442
|
+
const lead = live.filter((r) => (r.category === 'Plan' || r.category === 'Area') && (r.name.split(NAME_SEP)[0] ?? '').trim().toUpperCase() === up);
|
|
443
|
+
return lead.length === 1 ? lead[0]! : null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* When a decision was itself made, as the assets record it, and which field says so — or null when nothing dates
|
|
448
|
+
* it. §2.1 files a decision read out of history under the time its own material gives, and §1.8 keeps that time on
|
|
449
|
+
* the change item, so the item that recorded the decision is the field that dates the decision. The session its
|
|
450
|
+
* material comes from is the next best. `asOf` is deliberately not used: it is when the Keeper wrote the item
|
|
451
|
+
* down, so on a takeover every decision of a decision record carries the same afternoon and ordering by it would
|
|
452
|
+
* be ordering by the Keeper's reading order, not by the project's timeline.
|
|
453
|
+
*/
|
|
454
|
+
export function decisionTime(store: ProjectStore, item: ReferenceItem): { at: string; field: string } | null {
|
|
455
|
+
const dated: { at: string; field: string }[] = [];
|
|
456
|
+
for (const change of store.changes.all()) {
|
|
457
|
+
for (const i of itemsOf(change)) {
|
|
458
|
+
if (i.atSource !== 'material' || !i.affects.includes(item.id) || Number.isNaN(Date.parse(i.at))) continue;
|
|
459
|
+
dated.push({ at: i.at, field: `the change item that recorded it (${change.id}, at ${i.at})` });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
for (const id of item.sourceIds) {
|
|
463
|
+
const anchor = store.sources.get(id)?.anchor;
|
|
464
|
+
if (anchor?.kind !== 'session' || !anchor.at || Number.isNaN(Date.parse(anchor.at))) continue;
|
|
465
|
+
dated.push({ at: anchor.at, field: `the session its material comes from (${id}, at ${anchor.at})` });
|
|
466
|
+
}
|
|
467
|
+
// The earliest is when the decision appeared; a later item on the same decision is a change to it, not its date.
|
|
468
|
+
return dated.sort((a, b) => a.at.localeCompare(b.at))[0] ?? null;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Why this decision is not one the replacement check may write over: §5.5 checks a new decision against the
|
|
473
|
+
* decisions in force, and each other validity needs something else done first (§2.1).
|
|
474
|
+
*/
|
|
475
|
+
export function notCurrentRefusal(old: ReferenceItem): string {
|
|
476
|
+
const advice = old.validity === 'Proposed'
|
|
477
|
+
? 'A proposal was never in force, so nothing was written, done or verified to it and nothing can supersede it. If it was adopted, record that first (the basis is the owner’s approval or the project’s own record of adoption) and check again; if it was not, leave it out of this check.'
|
|
478
|
+
: old.validity === 'Replaced'
|
|
479
|
+
? `It is already Replaced${old.replacedBy ? `, by ${old.replacedBy}` : ''} and so a point-in-time record. Run the check against whichever decision is in force on this matter now; if it was replaced wrongly, the owner corrects that first.`
|
|
480
|
+
: old.validity === 'Abandoned'
|
|
481
|
+
? 'That direction was abandoned, so there is nothing of it left in force to supersede. Run the check against whichever decision is in force on this matter now.'
|
|
482
|
+
: `Only a Current decision is in force, and this one is ${old.validity}. Judge its validity first, then check what supersedes it.`;
|
|
483
|
+
return `${old.id} (${old.name}) is ${old.validity}, and the replacement check goes over the decisions in force. ${advice}`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** The time one item of a change record carries, or the record's own when the caller named no item (§1.8). */
|
|
487
|
+
export function itemTime(change: ChangeRecord, itemId: string): string {
|
|
488
|
+
return (itemId ? itemsOf(change).find((i) => i.id === itemId)?.at : undefined) ?? change.at;
|
|
489
|
+
}
|
|
490
|
+
/** The last thing a change record did: what makes it "a later change" for anything before it. */
|
|
491
|
+
export function lastItemTime(change: ChangeRecord): string {
|
|
492
|
+
return itemsOf(change).reduce((latest, i) => (i.at > latest ? i.at : latest), change.at);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** How many compact rows, and how many whole records, one pk_read_assets listing gives (CM: no listing hits the 120K cap). */
|
|
496
|
+
export const READ_PAGE = 200;
|
|
497
|
+
export const READ_FULL_PAGE = 40;
|
|
498
|
+
const clipped = (s: unknown, n: number): string => { const t = typeof s === 'string' ? s.replace(/\s+/g, ' ').trim() : ''; return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* One asset as a compact row (CM, E151; CK fix 5): its id, its name, its category, what it refines or serves, its
|
|
502
|
+
* validity — what the main agent needs to find and place an item; the full record comes with ids, or with full: true.
|
|
503
|
+
* The gated run's asset dumps took 71K and 106K characters of the main agent's context, and 22 results were cut at the
|
|
504
|
+
* 120K-character cap.
|
|
505
|
+
*/
|
|
506
|
+
export function compactRow(kind: string, x: Record<string, unknown>): Record<string, unknown> {
|
|
507
|
+
const pick = (keys: readonly string[]) => Object.fromEntries(keys.filter((k) => x[k] !== undefined && x[k] !== null && !(Array.isArray(x[k]) && (x[k] as unknown[]).length === 0)).map((k) => [k, x[k]]));
|
|
508
|
+
const ref = (r: Record<string, unknown>) => ({ ...pick(['id', 'category']), name: clipped(r.name, 120), ...pick(['ids', 'validity', 'refines', 'replacedBy', 'foundation']), ...(r.wholeProductWhy ? { wholeProductWhy: clipped(r.wholeProductWhy, 120) } : {}) });
|
|
509
|
+
switch (kind) {
|
|
510
|
+
case 'reference': return ref(x);
|
|
511
|
+
case 'area': { const u = x.understanding as Record<string, unknown> | null; return { ...ref(x), ...(u ? { understanding: u.id, effectNow: clipped(u.effectNow, 160) } : {}) }; }
|
|
512
|
+
case 'thread': return { id: x.id, title: clipped(x.title, 120), ...pick(['ids', 'progress', 'validity', 'replacedBy']), serves: ((x.serves ?? []) as { referenceId: string }[]).map((s) => s.referenceId), ...(x.wholePlanWhy ? { wholePlanWhy: clipped(x.wholePlanWhy, 120) } : {}) };
|
|
513
|
+
case 'areaUnderstanding': return { id: x.id, referenceId: x.referenceId, effectNow: clipped(x.effectNow, 160) };
|
|
514
|
+
case 'relation': return pick(['id', 'type', 'from', 'to', 'basis']);
|
|
515
|
+
case 'link': return { ...pick(['id', 'workId', 'stepKind', 'confirmed']), evidence: clipped((x.evidence as { label?: string } | null)?.label ?? x.ledgerRef, 100), ...(x.check ? { check: (x.check as { passed: boolean }).passed ? 'passed' : 'failed' } : {}) };
|
|
516
|
+
case 'roundDoc': return { ...pick(['id', 'roundId', 'kind', 'path']), title: clipped(x.title, 120), chars: typeof x.markdown === 'string' ? x.markdown.length : 0 };
|
|
517
|
+
case 'note': { const v = ((x.versions ?? []) as { title?: string; ask?: string }[]).at(-1); return { ...pick(['id', 'status']), title: clipped(v?.title, 120), ask: v?.ask ?? null }; }
|
|
518
|
+
case 'breakpoint': return { ...pick(['id', 'kind', 'targetId', 'basis', 'lit']), why: clipped(x.why, 120), ...(x.out ? { out: true } : {}), ...(x.looked ? { looked: true } : {}) };
|
|
519
|
+
case 'draft': return { id: x.id, session: x.session ? `${(x.session as { host?: string }).host} ${String((x.session as { sessionId?: string }).sessionId ?? '').slice(0, 8)}` : null, ownerLines: ((x.ownerLines ?? []) as unknown[]).length };
|
|
520
|
+
case 'clerkRound': return pick(['id', 'kind', 'number', 'status', 'stage', 'startedAt', 'endedAt']);
|
|
521
|
+
case 'generation': return { ...pick(['id', 'name']), workIds: ((x.workIds ?? []) as unknown[]).length, planRefs: ((x.planRefs ?? []) as unknown[]).length };
|
|
522
|
+
default: {
|
|
523
|
+
const name = x.name ?? x.title ?? x.label ?? x.summary ?? x.number ?? x.path ?? x.kind;
|
|
524
|
+
return { id: x.id, ...(name !== undefined ? { name: clipped(name, 120) } : {}), ...pick(['category', 'kind', 'status', 'validity']) };
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export function keeperTools(ctx: ToolContext): ToolDefinition[] {
|
|
530
|
+
const { store, project } = ctx;
|
|
531
|
+
const trace = (summary: string, basisSourceIds: readonly string[] = []) => ({ jobId: ctx.jobId, basisSourceIds, summary });
|
|
532
|
+
const saved = (collection: string, id: string, label: string) => ctx.onSaved?.(collection, id, label);
|
|
533
|
+
const writtenFacts = new Set<string>();
|
|
534
|
+
|
|
535
|
+
const tools: ToolDefinition[] = [
|
|
536
|
+
defineTool({
|
|
537
|
+
name: 'pk_project_overview', label: 'Project overview',
|
|
538
|
+
description: 'Where this project stands in the assets, compact: scope, the counts of each kind of asset, the Product, Goals, Areas and Plans by id and name, the rules in force in one line each, the current notes by title, the organizing plan counted, and coverage. Use it to position yourself before reading material; read any kind in full with pk_read_assets.',
|
|
539
|
+
parameters: Type.Object({}),
|
|
540
|
+
execute: async () => {
|
|
541
|
+
const cur = <T extends { validity?: string }>(xs: readonly T[]) => xs.filter((x) => x.validity === undefined || x.validity === 'Current');
|
|
542
|
+
const byCategory: Record<string, number> = {};
|
|
543
|
+
for (const r of cur(store.reference.all())) byCategory[r.category] = (byCategory[r.category] ?? 0) + 1;
|
|
544
|
+
const byProgress: Record<string, number> = {};
|
|
545
|
+
for (const t of cur(store.threads.all())) byProgress[t.progress] = (byProgress[t.progress] ?? 0) + 1;
|
|
546
|
+
const named = (cat: string) => cur(store.reference.filter((r) => r.category === cat)).map((r) => ({ id: r.id, name: clipped(r.name, 120), ...(r.foundation ? { foundation: true } : {}) }));
|
|
547
|
+
const plan = store.plans.get(PLAN_ID);
|
|
548
|
+
return ok({
|
|
549
|
+
project: { id: project.id, name: project.name, root: project.locations[0], language: project.language, roles: project.roles },
|
|
550
|
+
scope: project.scope.map((i) => ({ id: i.id, category: i.category, relation: i.relation, path: i.path })),
|
|
551
|
+
counts: { reference: byCategory, workItems: byProgress, relations: store.relations.size, notes: store.notes.filter((n) => n.status === 'Current').length, sources: store.sources.size },
|
|
552
|
+
product: named('Product'), goals: named('Goal'), areas: named('Area'), plans: named('Plan'),
|
|
553
|
+
rules: store.rules.filter((r) => r.validity === 'Current').map((r) => ({ id: r.id, group: r.group, summary: clipped(r.summary, 140) })),
|
|
554
|
+
notes: store.notes.filter((n) => n.status === 'Current').map((n) => { const v = n.versions[n.versions.length - 1]!; return { id: n.id, title: clipped(v.title, 140), ask: v.ask }; }),
|
|
555
|
+
organizingPlan: plan ? { id: PLAN_ID, byRule: plan.byRule.length, readClosely: plan.readClosely.length, focus: plan.focus.length, corrections: plan.corrections.length } : null,
|
|
556
|
+
coverage: store.coverage.scopes.map((x) => ({ id: x.id, coverage: x.coverage, pending: x.pending.length, asOf: x.asOf })),
|
|
557
|
+
more: 'pk_read_assets kind reference (category …), thread, area, relation … lists any kind compact; with ids, or full: true, in full.',
|
|
558
|
+
});
|
|
559
|
+
},
|
|
560
|
+
}),
|
|
561
|
+
defineTool({
|
|
562
|
+
name: 'pk_list_sources', label: 'List sources',
|
|
563
|
+
description: 'List sources (files by section, session segments, commits) with their ids. Filter by query text, path substring, session host, kind, or Used as. Returns compact rows, no excerpts.',
|
|
564
|
+
parameters: Type.Object({ query: Type.Optional(Type.String()), path: Type.Optional(Type.String()), kind: Type.Optional(Type.String({ description: 'file | session | commit | status | revision (an old version read from version history)' })), host: Type.Optional(Type.String({ description: 'claude | codex' })), usedAs: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()) }),
|
|
565
|
+
execute: async (_id, p) => {
|
|
566
|
+
// Paths are compared with one separator: a file's stored path has the platform's (backslashes on Windows), and a
|
|
567
|
+
// step asks with the repo-relative form it reads everywhere else (`product/PRD.md`).
|
|
568
|
+
const slashed = (v: string) => v.replace(/\\/g, '/').toLowerCase();
|
|
569
|
+
const q = slashed(text(p.query));
|
|
570
|
+
const at = p.path ? slashed(text(p.path)) : '';
|
|
571
|
+
const rows = store.sources.all().filter((s) => (!p.kind || s.anchor.kind === p.kind) && (!at || (s.anchor.kind === 'file' && slashed(s.anchor.path).includes(at))) && (!p.host || (s.anchor.kind === 'session' && s.anchor.host === p.host)) && (!p.usedAs || s.usedAs === p.usedAs) && (!q || s.title.toLowerCase().includes(q) || slashed(anchorLabel(s.anchor)).includes(q) || s.excerpt.toLowerCase().includes(q)))
|
|
572
|
+
.sort((a, b) => anchorLabel(a.anchor).localeCompare(anchorLabel(b.anchor)))
|
|
573
|
+
.slice(0, Math.min(400, Number(p.limit) || 200))
|
|
574
|
+
.map((s) => { const origin = worktreeOrigin(project.scope, s); return { id: s.id, title: s.title, at: anchorLabel(s.anchor), usedAs: s.usedAs ?? 'Not yet judged', ids: s.ids, chars: s.excerpt.length, availability: s.availability, ...(origin ? { inProgress: origin.label } : {}) }; });
|
|
575
|
+
return ok(rows);
|
|
576
|
+
},
|
|
577
|
+
}),
|
|
578
|
+
defineTool({
|
|
579
|
+
name: 'pk_read_source', label: 'Read source',
|
|
580
|
+
description: 'Read one source: its anchor, version and exact excerpt as read.',
|
|
581
|
+
parameters: Type.Object({ id: Type.String() }),
|
|
582
|
+
execute: async (_id, p) => { const s = store.sources.get(p.id); const origin = s ? worktreeOrigin(project.scope, s) : null; return s ? ok({ id: s.id, title: s.title, at: anchorLabel(s.anchor), anchor: s.anchor, version: s.version, usedAs: s.usedAs, availability: s.availability, ids: s.ids, ...(origin ? { inProgress: origin } : {}), excerpt: s.excerpt }) : fail(`unknown source ${p.id}`); },
|
|
583
|
+
}),
|
|
584
|
+
defineTool({
|
|
585
|
+
name: 'pk_read_assets', label: 'Read assets',
|
|
586
|
+
description: `Read existing assets by kind (fact, thread, area, areaUnderstanding, reference, note, relation, node, change, mark, rule, plan, merge; and the positions of the clerk method: breakpoint, sendback, patch, number, draft, territory, generation, layer, roundDoc, link, clerkRound). With ids: those, in full. Without ids: every one of the kind as compact rows (id, name, category, what it refines or serves, validity), ${READ_PAGE} at a time (offset pages; next says where the following page starts) — full: true gives them in full instead, ${READ_FULL_PAGE} at a time. category narrows reference (and area) to one category. kind area is the product's Areas (the Area reference items) each with its understanding; areaUnderstanding is the understanding records alone. The id of a work item merged into another reads the one kept. To find which assets cite or touch an id, use pk_find_references.`,
|
|
587
|
+
parameters: Type.Object({
|
|
588
|
+
kind: Type.String(), ids: Type.Optional(StrArr),
|
|
589
|
+
full: Type.Optional(Type.Boolean({ description: 'without ids: the whole records instead of compact rows' })),
|
|
590
|
+
category: Type.Optional(Type.String({ description: 'kind reference: only this category (Decision, Requirement, Area …)' })),
|
|
591
|
+
offset: Type.Optional(Type.Number()), limit: Type.Optional(Type.Number()),
|
|
592
|
+
}),
|
|
593
|
+
execute: async (_id, p) => {
|
|
594
|
+
const kinds = {
|
|
595
|
+
fact: store.facts, thread: store.threads, areaUnderstanding: store.areas, reference: store.reference, note: store.notes, relation: store.relations, node: store.nodes, change: store.changes, mark: store.marks, rule: store.rules, plan: store.plans, merge: store.merges,
|
|
596
|
+
breakpoint: store.breakpoints, sendback: store.sendbacks, patch: store.patches, number: store.numbers, draft: store.drafts, territory: store.territories, generation: store.generations, layer: store.layers, roundDoc: store.roundDocs, link: store.links, clerkRound: store.clerkRounds,
|
|
597
|
+
} as Record<string, { all(): unknown[]; get(id: string): unknown }>;
|
|
598
|
+
const kind = text(p.kind);
|
|
599
|
+
// CM (C13): `area` was the area understanding and read as an empty list when an ADR lane looked for the Areas; it
|
|
600
|
+
// is the Areas themselves now, each with its understanding.
|
|
601
|
+
const areaRows = () => store.reference.filter((r) => r.category === 'Area').map((r) => {
|
|
602
|
+
const u = store.areas.find((x) => x.referenceId === r.id);
|
|
603
|
+
return { ...r, understanding: u ? { id: u.id, effectNow: u.effectNow, gaps: u.gaps, contributions: u.contributions.length } : null };
|
|
604
|
+
});
|
|
605
|
+
const coll = kind === 'area' ? { all: areaRows, get: (id: string) => areaRows().find((r) => r.id === id) } : kinds[kind];
|
|
606
|
+
if (!coll) return fail(`kind must be area | areaUnderstanding | ${Object.keys(kinds).filter((k) => k !== 'areaUnderstanding').join(' | ')}`);
|
|
607
|
+
const ids = arr<string>(p.ids);
|
|
608
|
+
// §1.4 (CKC-06 AC-28): a merged work item's id reaches the one kept, and says it did.
|
|
609
|
+
const read = (id: string) => {
|
|
610
|
+
const hit = coll.get(id);
|
|
611
|
+
if (hit) return hit;
|
|
612
|
+
const kept = kind === 'thread' || kind === 'node' ? resolveMergedId(store, id) : id;
|
|
613
|
+
const found = kept !== id ? coll.get(kept) : undefined;
|
|
614
|
+
return found ? { ...(found as object), mergedFrom: id } : { id, missing: true };
|
|
615
|
+
};
|
|
616
|
+
if (ids.length) return ok(ids.map(read));
|
|
617
|
+
const category = text(p.category).trim();
|
|
618
|
+
let all = coll.all() as Record<string, unknown>[];
|
|
619
|
+
if (category) all = all.filter((x) => x.category === category);
|
|
620
|
+
const full = p.full === true;
|
|
621
|
+
const page = full ? READ_FULL_PAGE : READ_PAGE;
|
|
622
|
+
const offset = Math.max(0, Math.trunc(Number(p.offset) || 0));
|
|
623
|
+
const limit = Math.max(1, Math.min(page * 4, Math.trunc(Number(p.limit) || page)));
|
|
624
|
+
const rows = all.slice(offset, offset + limit).map((x) => (full ? x : compactRow(kind, x)));
|
|
625
|
+
const more = offset + rows.length < all.length;
|
|
626
|
+
return ok({ kind, ...(category ? { category } : {}), count: all.length, offset, rows, ...(more ? { next: offset + rows.length } : {}) });
|
|
627
|
+
},
|
|
628
|
+
}),
|
|
629
|
+
defineTool({
|
|
630
|
+
name: 'pk_set_used_as', label: 'Set Used as',
|
|
631
|
+
description: `Record how a source is used (${USED_AS.join(', ')}). Reference only: material the project says is for reference only, and the documents third-party material brings — citable, never a requirement, boundary or plan. History only: old versions and deleted files from version history, and what the project keeps for recovery only — it forms no current node and enters no context. When a project rule settles it, name the rule in byRuleId.`,
|
|
632
|
+
parameters: Type.Object({ sourceId: Type.String(), usedAs: Type.String(), byRuleId: Type.Optional(Type.String({ description: 'the project rule this follows, when one does: a Reference only rule for Reference only, a Recovery only rule for History only' })) }),
|
|
633
|
+
execute: async (_id, p) => {
|
|
634
|
+
const s = store.sources.get(p.sourceId);
|
|
635
|
+
if (!s) return fail(`unknown source ${p.sourceId}`);
|
|
636
|
+
if (!isOneOf(USED_AS, p.usedAs)) return fail(`usedAs must be one of ${USED_AS.join(', ')}`);
|
|
637
|
+
const fromHistory = usedAsHistoryRefusal(s, p.usedAs);
|
|
638
|
+
if (fromHistory) return fail(fromHistory);
|
|
639
|
+
const byRuleId = text(p.byRuleId).trim();
|
|
640
|
+
if (byRuleId) {
|
|
641
|
+
const refusal = ruleUseRefusal(store, byRuleId, p.usedAs === 'Reference only' ? 'Reference only' : p.usedAs === 'History only' ? 'History only' : 'Used as');
|
|
642
|
+
if (refusal) return fail(refusal);
|
|
643
|
+
}
|
|
644
|
+
// A judgement of this one source: no longer the location's (§1.1), so a change of classification leaves it be.
|
|
645
|
+
store.sources.put({ ...s, usedAs: p.usedAs, usedAsBy: 'keeper', usedAsByRuleId: byRuleId || null, usedAsByScopeItemId: null }, trace(`Used as ${p.usedAs}${byRuleId ? ` by rule ${byRuleId}` : ''}`, [s.id, ...(byRuleId ? store.rules.get(byRuleId)!.sourceIds : [])]));
|
|
646
|
+
return ok({ id: s.id, usedAs: p.usedAs, ...(byRuleId ? { byRuleId } : {}) });
|
|
647
|
+
},
|
|
648
|
+
}),
|
|
649
|
+
defineTool({
|
|
650
|
+
name: 'pk_write_fact_record', label: 'Write fact record',
|
|
651
|
+
description: 'Create or update a fact record formed from ONE material or session segment (plus project positioning). Statements carry a type and source ids; decisions carry who made them; open questions stay open. Pass id to update, or write again about the same material: an update gives only what it changes, and what it leaves out keeps its value (a list you give — statements, executionFacts, decisions, changes, openQuestions — replaces that list). A new fact record needs title, aboutSourceIds and statements.',
|
|
652
|
+
parameters: Type.Object({
|
|
653
|
+
id: Type.Optional(Type.String()),
|
|
654
|
+
title: Type.Optional(Type.String({ description: 'a new fact record needs it; an update keeps it when left out' })),
|
|
655
|
+
aboutSourceIds: Type.Optional(Type.Array(Type.String(), { description: 'the material it is about; a new fact record needs it, and it finds the record already written about that material' })),
|
|
656
|
+
statements: Type.Optional(Type.Array(StatementSchema, { description: 'a new fact record needs them (an empty list when there are none); an update keeps them when left out' })), executionFacts: Type.Optional(Type.Array(StatementSchema)),
|
|
657
|
+
decisions: Type.Optional(Type.Array(Type.Object({ text: Type.String(), byOwner: Type.Boolean({ description: 'true when the owner said or approved it' }), byName: Type.Optional(Type.String({ description: 'role or agent name when not the owner' })), sourceIds: StrArr, documented: Type.Boolean({ description: 'is it written in the project’s own decision record?' }) }))),
|
|
658
|
+
changes: Type.Optional(Type.Array(Type.Object({ text: Type.String(), sourceIds: StrArr }))),
|
|
659
|
+
openQuestions: Type.Optional(StrArr), usedAs: Type.Optional(Type.Record(Type.String(), Type.String(), { description: 'sourceId → Used as' })),
|
|
660
|
+
language: Type.Optional(Type.String()), inputs: InputsSchema,
|
|
661
|
+
}),
|
|
662
|
+
execute: async (_id, p) => {
|
|
663
|
+
const aboutGiven = !absent(p.aboutSourceIds);
|
|
664
|
+
let about = arr<string>(p.aboutSourceIds).filter((id) => store.sources.has(id));
|
|
665
|
+
if (aboutGiven && about.length === 0) return fail('aboutSourceIds must name at least one existing source');
|
|
666
|
+
const st = absent(p.statements) ? null : statements(ctx, p.statements, 'statements'); if (typeof st === 'string') return fail(st);
|
|
667
|
+
const ex = absent(p.executionFacts) ? null : statements(ctx, p.executionFacts, 'executionFacts'); if (typeof ex === 'string') return fail(ex);
|
|
668
|
+
const title = text(p.title);
|
|
669
|
+
let id = text(p.id);
|
|
670
|
+
if (!id && ctx.materials?.length && about.length) {
|
|
671
|
+
const hit = ctx.materials.filter((m) => m.sourceIds.some((s) => about.includes(s)));
|
|
672
|
+
if (hit.length === 1) {
|
|
673
|
+
const m = hit[0]!;
|
|
674
|
+
about = [...new Set([...about, ...m.sourceIds])];
|
|
675
|
+
id = stableId('fact', project.id, m.key);
|
|
676
|
+
// A second record this job writes about the same material, under another title, is a record of its own.
|
|
677
|
+
if (title && writtenFacts.has(id) && store.facts.get(id)?.title !== title) id = stableId('fact', project.id, m.key, title);
|
|
678
|
+
} else if (hit.length > 1) {
|
|
679
|
+
id = stableId('fact', project.id, ...hit.map((m) => m.key).sort());
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (!id && about.length) id = stableId('fact', ...about);
|
|
683
|
+
const previous = id ? store.facts.get(id) : undefined;
|
|
684
|
+
if (!previous) {
|
|
685
|
+
const missing = [...(absent(p.title) ? ['title'] : []), ...(aboutGiven ? [] : ['aboutSourceIds']), ...(st ? [] : ['statements'])];
|
|
686
|
+
if (missing.length) throw createNeeds('a fact record', id ? `${id} is no fact record of the assets` : 'This call names no fact record (no id, and no aboutSourceIds to find one by)', missing, 'To update a fact record, give its id, or the aboutSourceIds of the material it is about.');
|
|
687
|
+
}
|
|
688
|
+
if (!aboutGiven) about = [...previous!.aboutSourceIds];
|
|
689
|
+
writtenFacts.add(id);
|
|
690
|
+
// A claim's check against the code (pk_record_code_check) stays beside the claim when the record is written
|
|
691
|
+
// again with the same claim; a claim no longer there takes its check with it (Spec §2.4). Statements left out
|
|
692
|
+
// keep every statement, check included.
|
|
693
|
+
const squashed = (s: string) => s.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
694
|
+
const checks = st ? (previous?.statements ?? []).flatMap((c) => {
|
|
695
|
+
const claimed = c.check ? previous!.statements.find((x) => x.id === c.check!.of) : undefined;
|
|
696
|
+
const kept = claimed ? st.find((x) => x.type === 'Claimed' && squashed(x.text) === squashed(claimed.text)) : undefined;
|
|
697
|
+
return kept && c.check ? [{ ...c, check: { ...c.check, of: kept.id } }] : [];
|
|
698
|
+
}) : [];
|
|
699
|
+
const record: FactRecord = {
|
|
700
|
+
id, projectId: project.id, title: title || previous?.title || '', aboutSourceIds: about, statements: st ? [...st, ...checks] : [...previous!.statements],
|
|
701
|
+
decisions: absent(p.decisions) ? [...(previous?.decisions ?? [])] : arr<Record<string, unknown>>(p.decisions).map((d) => ({ text: text(d.text), documented: d.documented === true, sourceIds: arr<string>(d.sourceIds), by: d.byOwner === true ? { author: { kind: 'owner', name: null, window: null, host: null, model: null }, holder: null, identity: 'Decision' } : { author: { kind: 'agent', name: text(d.byName, null as unknown as string) || null, window: null, host: null, model: null }, holder: null, identity: 'Proposal' } })),
|
|
702
|
+
changes: absent(p.changes) ? [...(previous?.changes ?? [])] : arr<Record<string, unknown>>(p.changes).map((c) => ({ text: text(c.text), sourceIds: arr<string>(c.sourceIds), changeRecordId: null })),
|
|
703
|
+
openQuestions: absent(p.openQuestions) ? [...(previous?.openQuestions ?? [])] : arr<string>(p.openQuestions),
|
|
704
|
+
executionFacts: ex ?? [...(previous?.executionFacts ?? [])], language: text(p.language) || previous?.language || project.language,
|
|
705
|
+
inputs: p.inputs !== undefined ? inputsOf(ctx, p.inputs, `${project.name}`) : previous?.inputs ?? inputsOf(ctx, { sourceIds: about }, `${project.name}`),
|
|
706
|
+
// `Update pending` is the program's (update-pending.ts): writing the record does not take in what it waits for.
|
|
707
|
+
asOf: now(), updatedAt: now(), pendingSourceIds: previous?.pendingSourceIds ?? [],
|
|
708
|
+
};
|
|
709
|
+
store.facts.put(record, trace(previous ? `Fact record updated: ${record.title}` : `Fact record formed: ${record.title}`, about));
|
|
710
|
+
for (const [sid, ua] of Object.entries((p.usedAs ?? {}) as Record<string, string>)) {
|
|
711
|
+
const s = store.sources.get(sid);
|
|
712
|
+
if (s && isOneOf(USED_AS, ua) && !usedAsHistoryRefusal(s, ua)) store.sources.put({ ...s, usedAs: ua, usedAsBy: 'keeper', usedAsByRuleId: ua === s.usedAs ? s.usedAsByRuleId ?? null : null, usedAsByScopeItemId: ua === s.usedAs ? s.usedAsByScopeItemId ?? null : null }, trace(`Used as ${ua}`, [sid]));
|
|
713
|
+
}
|
|
714
|
+
saved('facts', id, `Fact record: ${record.title}`);
|
|
715
|
+
return ok({ id, statements: st ? st.length : record.statements.length, ...(previous ? { updated: true } : {}) });
|
|
716
|
+
},
|
|
717
|
+
}),
|
|
718
|
+
defineTool({
|
|
719
|
+
name: 'pk_write_thread', label: 'Write work thread',
|
|
720
|
+
description: 'Create or update a work item (work thread): one unit of work the project names (a task contract, a task, an issue, a TODO line), with what it does, what changed, actual results, what is unresolved, its fact records, and what it serves (first the Area(s) or Goal whose effect it contributes to — that places it, and the first Area written is its main module: the module of the contract it implements, else the one its dispatch ticket or prompt names, else the first listed; then the contract or requirement it fulfils; work that serves its whole plan — all of the plan’s modules — and no single one serves the Plan item and says why in wholePlanWhy). A call with the id of an existing work item, or the project id it carries (T-09, #18 …) as id or in ids, updates it and gives only what it changes: title, progress and the other fields you leave out or empty keep their value, and factRecordIds, serves, dependsOn and facts are added to what is there — serves after what it already has, in the order written, so the first Area stays first (replaceServes: true replaces the serves list, in the order given; replaceDependsOn: true replaces the dependsOn list — give the dependencies that stand, or none to clear it — so a dependency written wrongly can be taken out). A new work item needs title and progress.',
|
|
721
|
+
parameters: Type.Object({
|
|
722
|
+
id: Type.Optional(Type.String({ description: 'the work item to update: its id, or the project id it carries (T-09)' })),
|
|
723
|
+
title: Type.Optional(Type.String({ description: 'a new work item needs it; an update keeps it when left out' })),
|
|
724
|
+
ids: Type.Optional(StrArr), doing: Type.Optional(Type.String()), changed: Type.Optional(Type.String()), results: Type.Optional(Type.String()), unresolved: Type.Optional(Type.String()), replaceServes: Type.Optional(Type.Boolean()),
|
|
725
|
+
replaceDependsOn: Type.Optional(Type.Boolean({ description: 'true: dependsOn in this call is the whole list — what it leaves out is taken away (empty or left out clears it)' })),
|
|
726
|
+
doneMeans: Type.Optional(Type.String({ description: 'The completion conditions the material states: what must be true for the work itself to be finished. Not the conditions only the owner can judge — those go in acceptanceMeans. Empty when nothing states any' })),
|
|
727
|
+
acceptanceMeans: Type.Optional(Type.String({ description: 'What the material says only the owner can judge (an owner review, sign-off or acceptance list). Recording this never lowers progress' })),
|
|
728
|
+
acceptance: Type.Optional(Type.String({ description: 'Accepted | Not yet accepted — only from the owner’s own statement or an acceptance the project records. Leave empty when the project has no acceptance step' })),
|
|
729
|
+
factRecordIds: Type.Optional(StrArr), serves: Type.Optional(Type.Array(Type.Object({ referenceId: Type.String(), claim: Type.String(), basis: Type.String({ description: 'Explicit | Inferred' }) }))),
|
|
730
|
+
dependsOn: Type.Optional(Type.Array(Type.Object({ threadId: Type.String(), claim: Type.String(), basis: Type.String() }))),
|
|
731
|
+
executionFacts: Type.Optional(Type.Array(StatementSchema)), qcFacts: Type.Optional(Type.Array(StatementSchema)),
|
|
732
|
+
progress: Type.Optional(Type.String({ description: 'Planned | In progress | Done | On hold — a new work item needs it; an update keeps it when left out' })), validity: Type.Optional(Type.String({ description: 'Current | Proposed | Deferred | Replaced | Abandoned | Removed (its material was deleted from the project’s current version)' })),
|
|
733
|
+
replacedBy: Type.Optional(Type.String({ description: 'what replaced it or carried it on: the id of a work item or reference item, or the project’s own number of one (T-04, D32). An earlier generation’s item says where it went this way: the current contract that took its work over, or the decision that ended it' })),
|
|
734
|
+
identity: Type.Optional(Type.String({ description: 'Artifact | Proposal | Report' })), authorName: Type.Optional(Type.String()), holderRole: Type.Optional(Type.String()), inputs: InputsSchema,
|
|
735
|
+
carriesOut: Type.Optional(Type.Array(Type.String(), { description: 'when this unit is an execution batch, round or sprint that carries out work items the plan already names: their ids. No work item is created; what you describe is recorded as those work items’ progress' })),
|
|
736
|
+
validityByRuleId: Type.Optional(Type.String({ description: 'the project rule its validity follows, when one does (e.g. an Obsolete rule that withdraws it)' })),
|
|
737
|
+
progressByRuleId: Type.Optional(Type.String({ description: 'the project rule its progress is read by: the authoritative index or field (an Authoritative rule)' })),
|
|
738
|
+
implements: Type.Optional(Type.Array(Type.String(), { description: 'the contract(s) this ticket implements: the contract’s work item or Requirement, by id or by its number (T-22). Recorded as an implements relation from this work item, so the contract’s progress can be held against its tickets' })),
|
|
739
|
+
progressWhy: Type.Optional(Type.String({ description: 'why its progress is what it is when the material seems to say otherwise — a contract still Planned although tickets implementing it are Done' })),
|
|
740
|
+
wholePlanWhy: Type.Optional(Type.String({ description: 'work that serves its whole plan — all of the plan’s modules — and no single one (a milestone QC across every contract of the plan, a reading of the whole graph): why, in one short sentence. It serves the Plan item and no Area; it then stands in the plan’s cross-cutting cell with this reason and is not counted as work in no module. An empty string takes the reason away' })),
|
|
741
|
+
noPlanWhy: Type.Optional(Type.Union([Type.String(), Type.Null()], { description: 'the records lead nowhere for this work — no plan or dispatch table lists it, no prompt metadata, no contract, no decision it carries out or cites leads to a plan, no arrangement current or archived names it — and why, naming the records you read. The program refuses it when the records do lead to a plan, and says where. With it the work item is written as "no plan — why" and is not counted as work in no plan. null (or an empty string) clears it, only together with a placement (serves with the Plan item in the same call)' })),
|
|
742
|
+
noAreaWhy: Type.Optional(Type.Union([Type.String(), Type.Null()], { description: 'the records name no module for this work — no contract, no Module column, no dispatch or prompt field — and why, naming the records you read. The program refuses it when the records do name a module, and says which. null (or an empty string) clears it, only together with a placement' })),
|
|
743
|
+
}),
|
|
744
|
+
execute: async (_id, p) => {
|
|
745
|
+
// Progress left out (or empty) keeps the work item's; a new work item needs it.
|
|
746
|
+
const progressGiven = has(p.progress);
|
|
747
|
+
if (progressGiven && !isOneOf(PROGRESS, p.progress)) return fail(`progress must be one of ${PROGRESS.join(', ')}`);
|
|
748
|
+
// §1.4 / D39: an execution batch is not a work item of its own. On the 2026-09-18 assets B0–B9 were listed
|
|
749
|
+
// beside the contracts they executed, the middle column held the same work twice, and 8 Layer drift marks
|
|
750
|
+
// came out of the duplication. What the batch did is the progress of the work items it carried out.
|
|
751
|
+
const carries = [...new Set(arr<string>(p.carriesOut).map((id) => resolveMergedId(store, id)))].filter((id) => store.threads.has(id));
|
|
752
|
+
if (arr<string>(p.carriesOut).length > 0) {
|
|
753
|
+
if (carries.length === 0) return fail(`carriesOut names no existing work item: ${arr<string>(p.carriesOut).join(', ')}. Give the ids of the work items this batch carries out; those are what its progress is recorded on.`);
|
|
754
|
+
const batchFacts = statements(ctx, p.executionFacts ?? [], 'executionFacts');
|
|
755
|
+
if (typeof batchFacts === 'string') return fail(batchFacts);
|
|
756
|
+
// §2.4 (D63): what a batch says it did is its claim, with who and when. It used to be kept as an Observed
|
|
757
|
+
// statement with no source at all, which a reader could not tell from a fact.
|
|
758
|
+
const told = text(p.results) || text(p.changed) || text(p.doing);
|
|
759
|
+
if (told && batchFacts.length === 0) {
|
|
760
|
+
return fail(`What “${text(p.title)}” says it did is its claim: put it in executionFacts as Claimed statements, each with the source it is written in, claimedBy (for example "Worker agent, batch 3 receipt") and claimedAt (the date the material gives) — or as Observed only where a source shows it. A batch that only moves progress needs no statement: give progress alone.`);
|
|
761
|
+
}
|
|
762
|
+
// A batch records the progress of what it carries out, or what it did; one that gives neither records nothing.
|
|
763
|
+
if (!progressGiven && batchFacts.length === 0) {
|
|
764
|
+
throw incompleteCall(`an execution batch that carries out ${carries.map((c) => store.threads.get(c)?.ids[0] ?? c).join(', ')} records their progress (progress) or what it did (executionFacts), and this call gives neither. Nothing was written. Give the progress the batch brought them to, or its statements; the work items keep what the call leaves out.`);
|
|
765
|
+
}
|
|
766
|
+
// Each statement says which batch it came from.
|
|
767
|
+
const batch = text(p.title).trim();
|
|
768
|
+
const tagged = batchFacts.map((s) => (!batch || s.text.includes(batch) ? s : { ...s, text: `${batch}: ${s.text}` }));
|
|
769
|
+
for (const threadId of carries) {
|
|
770
|
+
const t = store.threads.get(threadId)!;
|
|
771
|
+
const merged = [...t.executionFacts];
|
|
772
|
+
for (const s of tagged) if (!merged.some((x) => x.text === s.text)) merged.push(s);
|
|
773
|
+
store.threads.put({ ...t, executionFacts: merged, results: text(p.results) && !t.results.includes(text(p.results)) ? [t.results, text(p.results)].filter(Boolean).join(' ') : t.results, progress: progressGiven ? p.progress as WorkThread['progress'] : t.progress, updatedAt: now() }, trace(`Progress from execution batch${batch ? ` “${batch}”` : ''}`, [...new Set(batchFacts.flatMap((s) => s.sourceIds))]));
|
|
774
|
+
saved('threads', threadId, `Work thread: ${t.title}`);
|
|
775
|
+
}
|
|
776
|
+
return ok({ recordedOn: carries, created: false, note: `An execution batch is not a work item of its own: ${batch ? `“${batch}”` : 'the batch'} was recorded as the progress of the ${carries.length} work item${carries.length === 1 ? '' : 's'} it carries out. The batch’s own plan, round or sprint belongs in a Plan reference item.` });
|
|
777
|
+
}
|
|
778
|
+
if (text(p.acceptance) && !isOneOf(ACCEPTANCE, p.acceptance)) return fail(`acceptance must be ${ACCEPTANCE.join(' or ')}, or empty when the project has no acceptance step`);
|
|
779
|
+
const validityGiven = has(p.validity);
|
|
780
|
+
if (validityGiven && !isOneOf(VALIDITY, p.validity)) return fail(`validity must be one of ${VALIDITY.join(', ')}`);
|
|
781
|
+
const facts = arr<string>(p.factRecordIds).filter((id) => store.facts.has(id));
|
|
782
|
+
const ex = statements(ctx, p.executionFacts ?? [], 'executionFacts'); if (typeof ex === 'string') return fail(ex);
|
|
783
|
+
const qc = statements(ctx, p.qcFacts ?? [], 'qcFacts'); if (typeof qc === 'string') return fail(qc);
|
|
784
|
+
// CQ (D104): a Plan or Area may be named by the id its document gives it (`K`, `P1`, `M1`) as well as by its store id.
|
|
785
|
+
const serves = arr<Record<string, unknown>>(p.serves).map((s) => ({ referenceId: referenceByWrittenId(store, text(s.referenceId))?.id ?? text(s.referenceId), claim: text(s.claim), basis: (isOneOf(BASIS, s.basis) ? s.basis : 'Inferred') as 'Explicit' | 'Inferred' }));
|
|
786
|
+
const unknownRef = serves.filter((s) => !store.reference.has(s.referenceId));
|
|
787
|
+
if (unknownRef.length) return fail(`serves cites unknown reference ids: ${unknownRef.map((s) => s.referenceId).join(', ')} (a Plan or Area is named by its id, or by the project id its document gives it, such as P1 or M2)`);
|
|
788
|
+
// CJ: the contracts it implements — a contract's work item (by id or number), or its Requirement.
|
|
789
|
+
const contracts: { id: string; label: string }[] = [];
|
|
790
|
+
for (const raw of arr<string>(p.implements).map((x) => text(x).trim()).filter(Boolean)) {
|
|
791
|
+
const up = raw.toUpperCase();
|
|
792
|
+
const t = store.threads.get(resolveMergedId(store, raw)) ?? store.threads.find((x) => x.ids.some((i) => i.toUpperCase() === up));
|
|
793
|
+
const r = t ? undefined : store.reference.get(raw) ?? store.reference.find((x) => x.category === 'Requirement' && x.ids.some((i) => i.toUpperCase() === up));
|
|
794
|
+
if (!t && !r) return fail(`implements: ${raw} is neither a work item nor a Requirement of the assets, by id or by number. Give the contract this ticket implements (its work item, or its number such as T-22). Nothing was written.`);
|
|
795
|
+
contracts.push(t ? { id: t.id, label: t.ids[0] && !t.title.includes(t.ids[0]) ? `${t.ids[0]} ${t.title}` : t.title } : { id: r!.id, label: r!.name });
|
|
796
|
+
}
|
|
797
|
+
// One work item per unit the project names: a call with the same project id updates it (several jobs write the plan's
|
|
798
|
+
// work items at once during takeover), and an update adds to what is there instead of wiping it.
|
|
799
|
+
const ids = arr<string>(p.ids).map((x) => text(x).trim()).filter(Boolean);
|
|
800
|
+
/** The work item that carries this project id (CKC-09), as its ids or the identifier its title starts with. */
|
|
801
|
+
const carrying = (n: string) => store.threads.find((t) => t.ids.some((x) => x.toUpperCase() === n.toUpperCase()) || primaryIdentifier(t.ids, t.title) === n.toUpperCase());
|
|
802
|
+
// §1.4 (CKC-06 AC-28): the id of a work item merged into another reaches the one kept, so a job that still
|
|
803
|
+
// remembers it updates that one instead of bringing the duplicate back.
|
|
804
|
+
let givenId = text(p.id) ? resolveMergedId(store, text(p.id).trim()) : '';
|
|
805
|
+
// Redirected by a merge (not by a project number naming the work item that carries it): the kept item keeps its title.
|
|
806
|
+
const redirected = givenId !== '' && givenId !== text(p.id).trim() && mergeOf(store, text(p.id).trim()) !== null;
|
|
807
|
+
// CJ: a project number given as id (`AP`) that no work item has or carries is that work item's number, kept in its
|
|
808
|
+
// ids — the process engine ties work by its ids (units.ts). It used to become the item's internal id with its ids
|
|
809
|
+
// left empty, and 50 of 63 finished tickets showed no delivery. A work item that got such an id before is given
|
|
810
|
+
// the number in its ids when it is next written.
|
|
811
|
+
if (givenId && isWrittenNumber(givenId)) {
|
|
812
|
+
const upper = givenId.toUpperCase();
|
|
813
|
+
if (!store.threads.has(givenId) && !carrying(givenId)) {
|
|
814
|
+
if (!ids.some((x) => x.toUpperCase() === upper)) ids.unshift(givenId);
|
|
815
|
+
givenId = '';
|
|
816
|
+
} else if (store.threads.get(givenId)?.ids.length === 0 && !ids.some((x) => x.toUpperCase() === upper)) {
|
|
817
|
+
ids.unshift(givenId);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
// CJ: ids are the project's own numbers — ones the ledger finds defined — and never another object's: a decision's
|
|
821
|
+
// (D99, D100, E145), a module's; and a title that opens with another object's number does not give the work item
|
|
822
|
+
// that number (BV–CB took D99, CE–CG took D100, and showed the ADR commits as their delivery).
|
|
823
|
+
const titleGiven = text(p.title).trim();
|
|
824
|
+
const needsLedger = ids.length > 0 || (titleGiven !== '' && (isNumberOnly(titleGiven) || primaryIdentifier([], titleGiven) !== null));
|
|
825
|
+
const checked = !needsLedger ? { refusal: null as string | null, titleNumberIsOther: false } : withNumbering(store, (ledger) => {
|
|
826
|
+
const known = new Set((store.threads.get(givenId)?.ids ?? []).map((x) => x.toUpperCase()));
|
|
827
|
+
const fresh = ids.filter((x) => !known.has(x.toUpperCase()));
|
|
828
|
+
const others = fresh.map((x) => ({ x, of: otherObjectOf(store, ledger, x) })).filter((o) => o.of);
|
|
829
|
+
if (others.length) return { refusal: `${others.map((o) => `${o.x} is the number of ${o.of}`).join('; ')}, not of this work item. A work item’s ids are its own number (the ticket’s, the task’s: AB, T-22); what it carries out goes in with pk_record_carry_out, and what it serves in serves. Nothing was written.` };
|
|
830
|
+
const unknown = undefinedNumbers(ledger, fresh);
|
|
831
|
+
if (unknown.length) return { refusal: `${unknown.join(', ')} ${unknown.length === 1 ? 'is' : 'are'} not a number the project defines — no heading, bold entry, table row, prompt id, file or branch name, or commit subject defines ${unknown.length === 1 ? 'it' : 'them'} (the ledger reads all of these). A work item’s ids are the project’s own numbers, as written; leave out a label of your own. Nothing was written.` };
|
|
832
|
+
const title = text(p.title).trim();
|
|
833
|
+
const fromTitle = ids.length ? null : primaryIdentifier([], title);
|
|
834
|
+
const titleNumberIsOther = fromTitle ? otherObjectOf(store, ledger, fromTitle) !== null : false;
|
|
835
|
+
const named = title ? numberOnlyRefusal(store, ledger, title, [], 'The title') : null;
|
|
836
|
+
return { refusal: named, titleNumberIsOther };
|
|
837
|
+
}, { refusal: null as string | null, titleNumberIsOther: false });
|
|
838
|
+
if (checked.refusal) return fail(checked.refusal);
|
|
839
|
+
const primary = checked.titleNumberIsOther && !ids.length ? null : primaryIdentifier(ids, text(p.title));
|
|
840
|
+
// An id that is no work item's own but the project id one carries names that work item: writing it as a new one
|
|
841
|
+
// would give one unit of work two work items (§1.4).
|
|
842
|
+
const numbered = givenId && !store.threads.has(givenId) ? carrying(givenId) : undefined;
|
|
843
|
+
const match = givenId || !primary ? undefined : carrying(primary);
|
|
844
|
+
const id = numbered?.id || givenId || match?.id || resolveMergedId(store, ids.length ? stableId('thread', project.id, ...ids) : newId('thread'));
|
|
845
|
+
const previous = store.threads.get(id);
|
|
846
|
+
if (contracts.some((c) => c.id === id)) return fail('implements names this work item itself: a ticket implements a contract other than itself. Nothing was written.');
|
|
847
|
+
if (!previous) {
|
|
848
|
+
const missing = [...(absent(p.title) ? ['title'] : []), ...(progressGiven ? [] : ['progress'])];
|
|
849
|
+
if (missing.length) {
|
|
850
|
+
const why = givenId ? `No work item has the id ${text(p.id)} or carries it as its project id`
|
|
851
|
+
: primary || ids.length ? `No work item carries ${primary ?? ids.join(', ')}`
|
|
852
|
+
: 'This call names no existing work item (no id, and no project id in ids or at the start of the title)';
|
|
853
|
+
throw createNeeds('a work item', why, missing, 'To update an existing work item, give its id — or the project id it carries, such as T-09 — as id.');
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
const validity = validityGiven ? p.validity as WorkThread['validity'] : (previous?.validity ?? 'Current');
|
|
857
|
+
const progress = progressGiven ? p.progress as WorkThread['progress'] : previous!.progress;
|
|
858
|
+
// CM (E151): what replaced it is named by id or by the project's own number; a name that stands for nothing is
|
|
859
|
+
// refused, so a destination is never a dead end (on the gated run the main agent believed the field did not exist).
|
|
860
|
+
let replacedBy: string | null = null;
|
|
861
|
+
if (has(p.replacedBy)) {
|
|
862
|
+
const written = text(p.replacedBy).trim();
|
|
863
|
+
const successor = successorOf(store, written);
|
|
864
|
+
if (successor?.id === id) return fail('replacedBy names this work item itself. Nothing was written.');
|
|
865
|
+
if (!successor && !isNumberShaped(written.toUpperCase())) return fail(`replacedBy: ${written} is no work item or reference item of the assets, by id or by number. Give the id, or the project’s own number (T-04, D32) of what replaced it. Nothing was written.`);
|
|
866
|
+
replacedBy = successor?.id ?? written;
|
|
867
|
+
}
|
|
868
|
+
// §1.4, §2.6: one unit of work has one work item. A number another work item already carries is that unit.
|
|
869
|
+
const clash = ids.filter((n) => !/^v\d/i.test(n)).map((n) => ({ n, t: store.threads.find((t) => t.id !== id && t.ids.some((x) => x.toUpperCase() === n.toUpperCase())) })).find((c) => c.t);
|
|
870
|
+
if (clash?.t) {
|
|
871
|
+
return fail(`${clash.n} is already the number of work item ${clash.t.id} (“${clash.t.title}”). One unit of work has one work item: to update that one, write with id: "${clash.t.id}"; an execution batch or report that carries it out goes in with carriesOut: ["${clash.t.id}"]; if the two turn out to be the same work, merge them at the end of the round with pk_merge_work_items.`);
|
|
872
|
+
}
|
|
873
|
+
// §1.15: a validity or progress read by one of the project's rules names it, and the rule has to say so.
|
|
874
|
+
const validityByRuleId = text(p.validityByRuleId).trim();
|
|
875
|
+
if (validityByRuleId) {
|
|
876
|
+
const refusal = ruleUseRefusal(store, validityByRuleId, validity === 'Removed' ? 'Removed' : 'Validity');
|
|
877
|
+
if (refusal) return fail(refusal);
|
|
878
|
+
if (store.rules.get(validityByRuleId)!.category === 'Obsolete' && validity !== 'Replaced' && validity !== 'Abandoned') {
|
|
879
|
+
return fail(`An Obsolete rule makes what it withdraws Replaced or Abandoned (Replaced points to the new way the rule names), never ${validity}.`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
const progressByRuleId = text(p.progressByRuleId).trim();
|
|
883
|
+
if (progressByRuleId) {
|
|
884
|
+
const refusal = ruleUseRefusal(store, progressByRuleId, 'Progress');
|
|
885
|
+
if (refusal) return fail(refusal);
|
|
886
|
+
}
|
|
887
|
+
// Removed is judged when a call sets it; an update that leaves validity out keeps what was judged then.
|
|
888
|
+
if (validityGiven && validity === 'Removed') {
|
|
889
|
+
const refusal = removedRefusal(store, [...new Set([...(previous?.factRecordIds ?? []), ...facts])].flatMap((f) => store.facts.get(f)?.aboutSourceIds ?? []), validityByRuleId);
|
|
890
|
+
if (refusal) return fail(refusal);
|
|
891
|
+
}
|
|
892
|
+
// §1.2, §2.6 (D61; CKC-02 AC-23): a work item is a current node, and what exists only in history forms none.
|
|
893
|
+
const restsOn = [
|
|
894
|
+
...[...new Set([...(previous?.factRecordIds ?? []), ...facts])].flatMap((f) => store.facts.get(f)?.aboutSourceIds ?? []),
|
|
895
|
+
...[...(previous?.executionFacts ?? []), ...(previous?.qcFacts ?? []), ...ex, ...qc].flatMap((s) => s.sourceIds),
|
|
896
|
+
];
|
|
897
|
+
const fromHistory = workItemHistoryRefusal(store, restsOn, validity);
|
|
898
|
+
if (fromHistory) return fail(fromHistory);
|
|
899
|
+
const identity = isOneOf(IDENTITY, p.identity) ? p.identity : (previous?.attribution.identity ?? 'Artifact');
|
|
900
|
+
const keep = (v: unknown, old: string | undefined) => text(v) || old || '';
|
|
901
|
+
const mergeBy = <T,>(old: readonly T[], add: readonly T[], key: (x: T) => string): T[] => { const m = new Map(old.map((x) => [key(x), x])); for (const x of add) m.set(key(x), x); return [...m.values()]; };
|
|
902
|
+
const wholePlanWhy = absent(p.wholePlanWhy) ? previous?.wholePlanWhy ?? null : redact(text(p.wholePlanWhy)).trim() || null;
|
|
903
|
+
// CQ (D104): the reasons that leave work unplaced on purpose, kept until a call gives another, or clears it (null or '').
|
|
904
|
+
const noPlanWhy = p.noPlanWhy === undefined ? previous?.noPlanWhy ?? null : redact(text(p.noPlanWhy)).trim() || null;
|
|
905
|
+
const noAreaWhy = p.noAreaWhy === undefined ? previous?.noAreaWhy ?? null : redact(text(p.noAreaWhy)).trim() || null;
|
|
906
|
+
const deps = arr<Record<string, unknown>>(p.dependsOn).map((d) => ({ threadId: resolveMergedId(store, text(d.threadId)), claim: text(d.claim), basis: (isOneOf(BASIS, d.basis) ? d.basis : 'Inferred') as 'Explicit' | 'Inferred' })).filter((d) => d.threadId !== id);
|
|
907
|
+
const thread: WorkThread = {
|
|
908
|
+
id, projectId: project.id, title: (redirected ? previous?.title : undefined) || text(p.title) || previous?.title || '', ids: [...new Set([...(previous?.ids ?? []), ...ids, ...(primary && !ids.length && !previous?.ids.length ? [primary] : [])])],
|
|
909
|
+
doing: keep(p.doing, previous?.doing), changed: keep(p.changed, previous?.changed), results: keep(p.results, previous?.results), unresolved: keep(p.unresolved, previous?.unresolved),
|
|
910
|
+
doneMeans: text(p.doneMeans) || previous?.doneMeans || '',
|
|
911
|
+
acceptanceMeans: text(p.acceptanceMeans) || previous?.acceptanceMeans || '',
|
|
912
|
+
acceptance: isOneOf(ACCEPTANCE, p.acceptance) ? p.acceptance : (previous?.acceptance ?? ''),
|
|
913
|
+
executionFacts: mergeBy(previous?.executionFacts ?? [], ex, (x) => x.text), qcFacts: mergeBy(previous?.qcFacts ?? [], qc, (x) => x.text),
|
|
914
|
+
factRecordIds: [...new Set([...(previous?.factRecordIds ?? []), ...facts])],
|
|
915
|
+
serves: p.replaceServes === true || !previous ? serves : mergeBy(previous.serves, serves, (x) => x.referenceId),
|
|
916
|
+
// CZ: dependencies are added to what is there; replaceDependsOn gives the list whole (a wrong one can be taken out).
|
|
917
|
+
dependsOn: p.replaceDependsOn === true || !previous ? deps : mergeBy(previous.dependsOn, deps, (x) => x.threadId),
|
|
918
|
+
progress, validity, replacedBy: replacedBy ?? previous?.replacedBy ?? null,
|
|
919
|
+
attribution: p.authorName || p.holderRole || !previous ? { author: { kind: p.authorName ? 'role' : 'unknown', name: text(p.authorName) || null, window: null, host: null, model: null }, holder: p.holderRole ? { role: p.holderRole, window: null } : null, identity } : { ...previous.attribution, identity },
|
|
920
|
+
// What the job received is recorded when the call gives it; an update that gives none keeps the record's.
|
|
921
|
+
inputs: p.inputs !== undefined ? inputsOf(ctx, p.inputs, project.name) : previous?.inputs ?? inputsOf(ctx, { factRecordIds: facts }, project.name),
|
|
922
|
+
asOf: now(), updatedAt: now(),
|
|
923
|
+
// `Update pending` is the program's, worked out from what waits for the next round (update-pending.ts): an
|
|
924
|
+
// update leaves it as it is, since a change it waits for is not taken in by writing the work item.
|
|
925
|
+
pendingSourceIds: previous?.pendingSourceIds ?? [], ...(previous?.waitsFor ? { waitsFor: previous.waitsFor } : {}),
|
|
926
|
+
// The rule stays named while the value it decided stays; a value changed without one no longer rests on it.
|
|
927
|
+
validityByRuleId: validityByRuleId || (previous && previous.validity === validity ? previous.validityByRuleId ?? null : null),
|
|
928
|
+
progressByRuleId: progressByRuleId || (previous && previous.progress === progress ? previous.progressByRuleId ?? null : null),
|
|
929
|
+
// The status its document writes, as the fill-in tools copied it (D99): the document's, so a judgement keeps it.
|
|
930
|
+
...(previous?.writtenStatus ? { writtenStatus: previous.writtenStatus } : {}),
|
|
931
|
+
// Why the progress stands as it does, while it does (CJ): a new progress without a reason drops the old one.
|
|
932
|
+
...(text(p.progressWhy).trim() ? { progressWhy: text(p.progressWhy).trim() } : previous?.progressWhy && previous.progress === progress ? { progressWhy: previous.progressWhy } : {}),
|
|
933
|
+
// CN (E152): why it serves its whole plan and no single module; kept until a call gives another, or an empty one.
|
|
934
|
+
...(wholePlanWhy ? { wholePlanWhy } : {}),
|
|
935
|
+
// CQ (D104): the recorded reasons the records lead nowhere, checked below against the program's inference.
|
|
936
|
+
...(noPlanWhy ? { noPlanWhy } : {}),
|
|
937
|
+
...(noAreaWhy ? { noAreaWhy } : {}),
|
|
938
|
+
};
|
|
939
|
+
// CN: whole-plan work is in a plan (it serves the Plan item, or an earlier generation lists it); with none there is
|
|
940
|
+
// no plan to serve the whole of. An Area it serves places it there, and the reason then says nothing.
|
|
941
|
+
let wholeNote: string | null = null;
|
|
942
|
+
if (has(p.wholePlanWhy)) {
|
|
943
|
+
const inPlan = plansOf(store, thread).length > 0 || earlierWork(store).has(id);
|
|
944
|
+
if (!inPlan) return fail('wholePlanWhy says the work serves its whole plan, and this work item is in no plan: give serves with the Plan item it belongs to in the same call (the plan or dispatch table that lists it, its prompt’s increment or milestone). Nothing was written.');
|
|
945
|
+
const areas = reachOf(store).ofThread(thread).areas.map((a) => store.reference.get(a)?.name ?? a);
|
|
946
|
+
if (areas.length) wholeNote = `It also serves ${areas.join(', ')}, so it stands in ${areas.length === 1 ? 'that module' : 'those modules'}; wholePlanWhy places work that serves no single module. To place it on the whole plan, give serves without an Area and replaceServes: true.`;
|
|
947
|
+
}
|
|
948
|
+
// CQ (D104): a reason for "no plan" / "no module" is a record the program can refuse (placement-inference.ts): refused
|
|
949
|
+
// when the work is placed already or the records lead to a plan or a module (the refusal names the chain); accepted
|
|
950
|
+
// when the chain is empty, and always when the project has no plan layer or no Areas. Clearing it needs a placement.
|
|
951
|
+
const inGeneration = earlierWork(store).has(id); // CZ: not an item carried on under the same number
|
|
952
|
+
const plansNow = plansOf(store, thread);
|
|
953
|
+
const inPlanNow = plansNow.length > 0 || inGeneration;
|
|
954
|
+
const planShort = (planId: string): string => { const r = store.reference.get(planId); return r ? r.ids[0] ?? r.name.split(NAME_SEP)[0]?.trim() ?? r.name : planId; };
|
|
955
|
+
const areasNow = reachOf(store).ofThread(thread).areas;
|
|
956
|
+
const withInference = <T,>(fn: (ctx: InferenceContext) => T): T => { let l: Ledger | null = null; try { l = Ledger.openDir(store.dir); } catch { l = null; } try { return fn(inferenceContext(store, l)); } finally { l?.close(); } };
|
|
957
|
+
if (p.noPlanWhy !== undefined) {
|
|
958
|
+
// DB: "in a plan" is the workbench's — a band of a current plan, or an earlier generation's (ui/placement.js). A
|
|
959
|
+
// plan with no band (not current: Proposed, Deferred, Replaced, Abandoned; or held by an earlier generation while
|
|
960
|
+
// this item was carried on) places nothing, so a reason may stand beside it. An item the workbench does not draw
|
|
961
|
+
// (deferred, abandoned) is judged as before, by the plans it names.
|
|
962
|
+
const bench = workbenchPlacement(store, { thread });
|
|
963
|
+
const at = bench.place.get(id);
|
|
964
|
+
const inBandNow = at ? at.zone === 'gen' || (at.band !== undefined && at.band !== NO_PLAN) : inPlanNow;
|
|
965
|
+
if (noPlanWhy) {
|
|
966
|
+
if (inBandNow) return fail(`noPlanWhy says no record places this work item in a plan, and it is in ${at?.plan ? planShort(at.plan) : at?.gen ? 'an earlier generation' : plansNow.map((r) => planShort(r.id)).join(', ') || 'an earlier generation'}: a reason is written only for work in no plan. Nothing was written.`);
|
|
967
|
+
const drawn = new Set(bench.plans.map((x) => x.id));
|
|
968
|
+
const refusal = withInference((ctx) => noPlanWhyRefusal(ctx, thread, drawn));
|
|
969
|
+
if (refusal) return fail(refusal);
|
|
970
|
+
} else if (previous?.noPlanWhy && !inBandNow) return fail('noPlanWhy is cleared only together with a placement: give serves with the Plan item it belongs to in the same call, or leave the reason. Nothing was written.');
|
|
971
|
+
}
|
|
972
|
+
if (p.noAreaWhy !== undefined) {
|
|
973
|
+
if (noAreaWhy) {
|
|
974
|
+
if (areasNow.length) return fail(`noAreaWhy says no record names a module for this work item, and it serves ${areasNow.map((a) => store.reference.get(a)?.name.split(NAME_SEP)[0]?.trim() ?? a).join(', ')}: a reason is written only for work in no module. Nothing was written.`);
|
|
975
|
+
const refusal = withInference((ctx) => noAreaWhyRefusal(ctx, thread));
|
|
976
|
+
if (refusal) return fail(refusal);
|
|
977
|
+
} else if (previous?.noAreaWhy && !areasNow.length && !thread.wholePlanWhy) return fail('noAreaWhy is cleared only together with a placement: give serves with the Area in the same call, or leave the reason. Nothing was written.');
|
|
978
|
+
}
|
|
979
|
+
// Un-placing: a replaceServes that takes the work out of every plan needs a reason the program accepts.
|
|
980
|
+
if (p.replaceServes === true && previous && !inPlanNow && plansOf(store, previous).length > 0 && !thread.noPlanWhy && planLayerPresent(store)) {
|
|
981
|
+
return fail(`replaceServes leaves this work item in no plan (it served ${plansOf(store, previous).map((r) => r.ids[0] ?? r.name.split(NAME_SEP)[0]?.trim() ?? r.name).join(', ')}): give the Plan item it belongs to in serves, or write noPlanWhy with the records you read (the program refuses it when the records lead somewhere). Nothing was written.`);
|
|
982
|
+
}
|
|
983
|
+
// What this write does with the program's Inferred placements of this item: kept as written, made its own, or replaced.
|
|
984
|
+
const replacedInferred = previous && programTargetsOf(store, 'thread', id).length
|
|
985
|
+
? noteOverride(store, 'thread', id, { targets: thread.serves.map((s) => s.referenceId), kept: (target) => { const now = thread.serves.find((s) => s.referenceId === target); if (!now) return 'gone'; const was = previous.serves.find((s) => s.referenceId === target); return now.basis !== was?.basis || now.claim !== was?.claim ? 'rewritten' : 'as written'; } }, { jobId: ctx.jobId, roundId: ctx.step?.roundId ?? null })
|
|
986
|
+
: [];
|
|
987
|
+
const basis = facts.flatMap((f) => store.facts.get(f)?.aboutSourceIds ?? []);
|
|
988
|
+
store.threads.put(thread, trace(`${previous ? `Work thread updated: ${thread.title}` : `Work thread formed: ${thread.title}`}${replacedInferred.length ? ` (replaced the program's Inferred placement on ${replacedInferred.join('; ')})` : ''}`, basis));
|
|
989
|
+
saved('threads', id, `Work thread: ${thread.title}`);
|
|
990
|
+
// CJ: the contract a ticket implements, as a relation from the ticket.
|
|
991
|
+
for (const c of contracts) {
|
|
992
|
+
const relId = stableId('rel', 'implements', id, c.id);
|
|
993
|
+
const before = store.relations.get(relId);
|
|
994
|
+
store.relations.put({
|
|
995
|
+
id: relId, projectId: project.id, type: 'implements', from: id, to: c.id, claim: before?.claim ?? `${thread.ids[0] ?? thread.title} implements ${c.label}`, basis: before?.basis ?? 'Explicit',
|
|
996
|
+
evidence: before?.evidence ?? { sourceIds: [], factRecordIds: thread.factRecordIds, factsSoFar: '' },
|
|
997
|
+
assessment: before?.assessment ?? 'Not assessed', assessedAt: before?.assessedAt ?? null, assessedInJobId: before?.assessedInJobId ?? null, updatedAt: now(),
|
|
998
|
+
}, trace(`Relation implements: ${thread.title} → ${c.label}`));
|
|
999
|
+
saved('relations', relId, 'Relation implements');
|
|
1000
|
+
}
|
|
1001
|
+
const placed = thread.serves.some((x) => { const r = store.reference.get(x.referenceId); return r && (r.category === 'Goal' || r.category === 'Product' || placement(store, r.id) !== null); });
|
|
1002
|
+
const effect = thread.serves.some((x) => { const c = store.reference.get(x.referenceId)?.category; return c === 'Area' || c === 'Goal'; });
|
|
1003
|
+
const warning = wholeNote ?? (thread.wholePlanWhy ? null
|
|
1004
|
+
: !placed ? 'Not placed: serves names nothing that leads to an Area, Goal or Product, so this work item shows as Unplaced. Add serves: the Area(s) whose effect this work contributes to.'
|
|
1005
|
+
: !effect && store.reference.find((r) => r.category === 'Area') ? 'serves names no Area or Goal directly; add the Area(s) whose effect this work contributes to as the first serves entry (the contract or document it fulfils stays as a second entry). Work that serves its whole plan and no single module says why in wholePlanWhy.' : null);
|
|
1006
|
+
return ok({ id, updated: Boolean(previous), ...(warning ? { warning } : {}), ...(replacedInferred.length ? { replacedInferred, note: 'This write replaced a placement the program had inferred; the round record keeps both.' } : {}) });
|
|
1007
|
+
},
|
|
1008
|
+
}),
|
|
1009
|
+
defineTool({
|
|
1010
|
+
name: 'pk_write_area', label: 'Write area understanding',
|
|
1011
|
+
description: 'Create or update the understanding of one product area (an Area reference item): the effect reached now, remaining gaps, and which work threads contribute with what claim. Pass id, or the Area’s referenceId, to update: an update gives only what it changes, and what it leaves out keeps its value (contributions, when given, replace the list). A new area understanding needs referenceId, effectNow, gaps and contributions.',
|
|
1012
|
+
parameters: Type.Object({
|
|
1013
|
+
id: Type.Optional(Type.String()),
|
|
1014
|
+
referenceId: Type.Optional(Type.String({ description: 'the Area reference item; a new area understanding needs it, and it finds the one already written for that Area' })),
|
|
1015
|
+
effectNow: Type.Optional(Type.String()), gaps: Type.Optional(Type.String()),
|
|
1016
|
+
contributions: Type.Optional(Type.Array(Type.Object({ threadId: Type.String(), claim: Type.String(), basis: Type.String() }))), inputs: InputsSchema,
|
|
1017
|
+
}),
|
|
1018
|
+
execute: async (_id, p) => {
|
|
1019
|
+
const named = text(p.id) ? store.areas.get(text(p.id)) : undefined;
|
|
1020
|
+
const referenceId = text(p.referenceId) || named?.referenceId || '';
|
|
1021
|
+
const ref = referenceId ? store.reference.get(referenceId) : undefined;
|
|
1022
|
+
if (text(p.referenceId) && (!ref || ref.category !== 'Area')) return fail(`referenceId must be an existing Area reference item`);
|
|
1023
|
+
const id = text(p.id) || (referenceId ? stableId('area', project.id, referenceId) : '');
|
|
1024
|
+
const previous = id ? store.areas.get(id) : undefined;
|
|
1025
|
+
if (!previous) {
|
|
1026
|
+
const missing = [...(referenceId ? [] : ['referenceId']), ...(['effectNow', 'gaps', 'contributions'] as const).filter((f) => absent(p[f]))];
|
|
1027
|
+
if (missing.length) throw createNeeds('an area understanding', id ? `${id} is no area understanding of the assets` : 'This call names no area understanding (no id, and no referenceId to find one by)', missing, 'To update an area understanding, give its id, or the referenceId of its Area.');
|
|
1028
|
+
}
|
|
1029
|
+
const contributions = absent(p.contributions) ? [...previous!.contributions] : arr<Record<string, unknown>>(p.contributions).map((c) => ({ threadId: resolveMergedId(store, text(c.threadId)), claim: text(c.claim), basis: (isOneOf(BASIS, c.basis) ? c.basis : 'Inferred') as 'Explicit' | 'Inferred' })).filter((c) => store.threads.has(c.threadId));
|
|
1030
|
+
const area: AreaUnderstanding = {
|
|
1031
|
+
id, projectId: project.id, referenceId, effectNow: text(p.effectNow) || previous?.effectNow || '', gaps: text(p.gaps) || previous?.gaps || '', contributions,
|
|
1032
|
+
inputs: p.inputs !== undefined ? inputsOf(ctx, p.inputs, project.name) : previous?.inputs ?? inputsOf(ctx, { threadIds: contributions.map((c) => c.threadId) }, project.name),
|
|
1033
|
+
// `Update pending` is the program's (update-pending.ts): writing the understanding does not take in what it waits for.
|
|
1034
|
+
asOf: now(), updatedAt: now(), pendingSourceIds: previous?.pendingSourceIds ?? [], ...(previous?.waitsFor ? { waitsFor: previous.waitsFor } : {}),
|
|
1035
|
+
};
|
|
1036
|
+
const label = ref?.name ?? referenceId;
|
|
1037
|
+
store.areas.put(area, trace(`Area understanding: ${label}`, ref?.sourceIds ?? []));
|
|
1038
|
+
saved('areas', id, `Area understanding: ${label}`);
|
|
1039
|
+
return ok(previous ? { id, updated: true } : { id });
|
|
1040
|
+
},
|
|
1041
|
+
}),
|
|
1042
|
+
defineTool({
|
|
1043
|
+
name: 'pk_write_reference', label: 'Write product reference item',
|
|
1044
|
+
description: 'Create or update a product reference item (Owner’s words, Product, Goal, Area, Requirement, Design, Decision, Plan, Boundary): the project’s own name/number, its text, the owner’s own words when it records an owner statement, basis (Explicit/Inferred), validity, identity, sources, what it refines (this places it: Goal → Product, Area → Goal, everything else → its Area(s), or the Product when it covers the whole product; a product description also refines the owner’s words it details), what replaced it. An Owner’s words item is the top layer: one thing the owner said or explicitly confirmed, with the owner’s words copied in quote and the session or decision record they were said in as its source — an agent’s restatement is not one. Material that is History only or Reference only forms no item. An item with the same project id (T-09, D36, P2 …) and category is updated, not duplicated. Pass id (or the project id, with the category when several items carry it) to update: an update gives only what it changes, and what it leaves out keeps its value (sourceIds and refines, when given, replace their lists; a new validity drops a replacedBy it does not give again). A new item needs category, name, text, basis, validity, identity and sourceIds. A new Area — and one newly marked foundation — places again, in the same call, everything that names it in the project’s own writing: its requirement group, the Spec chapter tagged with it, the contracts whose Module column writes it (its short form counts), the tickets implementing them, the decisions whose entry names those; the result lists them (placedAgain).',
|
|
1045
|
+
parameters: Type.Object({
|
|
1046
|
+
id: Type.Optional(Type.String({ description: 'the item to update: its id, or the project’s number it carries (D36, T-14) — a number is looked up and updates the item that carries it; it is never stored as an id' })),
|
|
1047
|
+
category: Type.Optional(Type.String({ description: 'a new item needs it; with the project id it finds the item to update' })),
|
|
1048
|
+
name: Type.Optional(Type.String()), ids: Type.Optional(StrArr), text: Type.Optional(Type.String()), quote: Type.Optional(Type.String({ description: 'the owner’s own words, copied exactly as they stand in the sources cited — …… where you leave words out' })),
|
|
1049
|
+
answers: Type.Optional(Type.String({ description: 'an Owner’s words quote too short to stand alone (“ok”, “yes, start the run”, 「可以」): the message it answers, copied from the session or the record cited — …… where you leave words out' })),
|
|
1050
|
+
basis: Type.Optional(Type.String({ description: 'Explicit | Inferred' })), validity: Type.Optional(Type.String({ description: 'Current | Proposed | Deferred | Replaced | Abandoned | Removed (its material was deleted from the project’s current version)' })), identity: Type.Optional(Type.String({ description: 'Decision | Artifact | Proposal | Report | Interpretation' })), authorKind: Type.Optional(Type.String({ description: 'owner | role | agent | unknown' })), authorName: Type.Optional(Type.String()),
|
|
1051
|
+
sourceIds: Type.Optional(StrArr), refines: Type.Optional(StrArr), replacedBy: Type.Optional(Type.String()), progress: Type.Optional(Type.String()), inputs: InputsSchema,
|
|
1052
|
+
validityByRuleId: Type.Optional(Type.String({ description: 'the project rule its validity follows, when one does: an Obsolete rule that withdraws it (then validity Replaced or Abandoned, basis Explicit), a Recovery only rule for Removed' })),
|
|
1053
|
+
holderRole: Type.Optional(Type.String({ description: 'the role that keeps this item, when the project divides its work among roles; an update that names none keeps it' })),
|
|
1054
|
+
foundation: Type.Optional(Type.Boolean({ description: 'an Area only: true when it is a cross-cutting foundation the project’s own documents treat as a peer of its modules — a row of the module or scope table, a requirement-group heading, a Spec chapter tagged with it, a value of a contract’s Module column. It is a column under the project’s own name, placed after the modules. Conventions that own no work are not an Area: they stay in the cross-cutting ring' })),
|
|
1055
|
+
wholeProductWhy: Type.Optional(Type.String({ description: 'a decision, boundary, requirement or design that concerns the whole product and no single area or plan: why — one sentence the cross-cutting ring shows with it. Without it, an item that refines only the Product counts as not placed. A foundation Area is an Area, not the whole product; a trial arrangement or a model choice belongs to the plan it shapes' })),
|
|
1056
|
+
}),
|
|
1057
|
+
execute: async (_id, p) => {
|
|
1058
|
+
const categoryGiven = text(p.category).trim();
|
|
1059
|
+
if (categoryGiven && !isOneOf(REFERENCE_CATEGORY, categoryGiven)) return fail(`category must be one of ${REFERENCE_CATEGORY.join(', ')}`);
|
|
1060
|
+
if (has(p.basis) && !isOneOf(BASIS, p.basis)) return fail(`basis must be Explicit or Inferred`);
|
|
1061
|
+
if (has(p.validity) && !isOneOf(VALIDITY, p.validity)) return fail(`validity must be one of ${VALIDITY.join(', ')}`);
|
|
1062
|
+
if (has(p.identity) && !isOneOf(IDENTITY, p.identity)) return fail(`identity must be one of ${IDENTITY.join(', ')}`);
|
|
1063
|
+
// One item per project identifier and category: a new call whose ids or leading identifier match an existing item of
|
|
1064
|
+
// the same category updates that item (on 2026-09-17 D36–D38 were each written three times under varying names).
|
|
1065
|
+
// An update that leaves the category out finds the one item carrying the identifier.
|
|
1066
|
+
const idsGiven = arr<string>(p.ids);
|
|
1067
|
+
// CZ: a number is written once. An id that is no item's own id is looked up as the project's number — it updates
|
|
1068
|
+
// the item that carries it, and is kept as the number of a new one; it never becomes a record id. (On the flash
|
|
1069
|
+
// run a lane wrote `id: "E14"` to rename the Decision the fill had written, and E14 stood twice.)
|
|
1070
|
+
const written = text(p.id).trim();
|
|
1071
|
+
const asNumber = written !== '' && !store.reference.has(written) && isWrittenNumber(written) ? written.toUpperCase() : null;
|
|
1072
|
+
const givenId = asNumber ? '' : written;
|
|
1073
|
+
const ids = asNumber && !idsGiven.some((x) => x.trim().toUpperCase() === asNumber) ? [written, ...idsGiven] : idsGiven;
|
|
1074
|
+
const primary = asNumber ?? primaryIdentifier(ids, text(p.name));
|
|
1075
|
+
const carrying = !givenId && primary ? store.reference.filter((r) => r.ids.some((x) => x.toUpperCase() === primary) || primaryIdentifier(r.ids, r.name) === primary) : [];
|
|
1076
|
+
if (!categoryGiven && carrying.length > 1) {
|
|
1077
|
+
return fail(`${carrying.length} product reference items carry ${primary}: ${carrying.map((r) => `${r.id} (${r.category} “${r.name}”)`).join(', ')}. Give the category of the one to update, or its id.`);
|
|
1078
|
+
}
|
|
1079
|
+
const match = categoryGiven ? carrying.find((r) => r.category === categoryGiven) : carrying[0];
|
|
1080
|
+
const id = givenId || match?.id || (categoryGiven ? stableId('ref', project.id, categoryGiven, ...(ids.length ? ids : [text(p.name)])) : '');
|
|
1081
|
+
const previous = id ? store.reference.get(id) : undefined;
|
|
1082
|
+
if (!previous) {
|
|
1083
|
+
const missing = (['category', 'name', 'text', 'basis', 'validity', 'identity', 'sourceIds'] as const).filter((f) => (f === 'category' ? !categoryGiven : absent(p[f])));
|
|
1084
|
+
if (missing.length) {
|
|
1085
|
+
const why = givenId ? `${givenId} is no product reference item of the assets` : primary ? `No product reference item${categoryGiven ? ` of category ${categoryGiven}` : ''} carries ${primary}` : 'This call names no existing product reference item (no id, and no project id in ids or at the start of the name)';
|
|
1086
|
+
throw createNeeds('a product reference item', why, missing, 'To update an existing item, give its id, or the project id it carries (ids) with its category.');
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
// What the call gives, over what the item has.
|
|
1090
|
+
const category = (categoryGiven || previous!.category) as ReferenceItem['category'];
|
|
1091
|
+
const basis = (has(p.basis) ? p.basis : previous!.basis) as ReferenceItem['basis'];
|
|
1092
|
+
const validity = (has(p.validity) ? p.validity : previous!.validity) as ReferenceItem['validity'];
|
|
1093
|
+
const identity = (has(p.identity) ? p.identity : previous!.attribution.identity) as ReferenceItem['attribution']['identity'];
|
|
1094
|
+
const validityChanged = previous !== undefined && validity !== previous.validity;
|
|
1095
|
+
const sourceIds = absent(p.sourceIds) ? [...previous!.sourceIds] : arr<string>(p.sourceIds).filter((s) => store.sources.has(s));
|
|
1096
|
+
const cited = sourceIds.map((s) => store.sources.get(s)).filter((s): s is Source => s !== undefined);
|
|
1097
|
+
const quote = text(p.quote) || previous?.quote || '';
|
|
1098
|
+
// Who said it follows the call; a call that changes the identity and names no author takes the author a new item
|
|
1099
|
+
// with that identity would get (a Decision is the owner's).
|
|
1100
|
+
const identityChanged = previous !== undefined && identity !== previous.attribution.identity;
|
|
1101
|
+
const authorKind = (['owner', 'role', 'agent', 'unknown'] as const).find((k) => k === p.authorKind)
|
|
1102
|
+
?? (previous && !identityChanged ? previous.attribution.author.kind : (identity === 'Decision' ? 'owner' : 'unknown'));
|
|
1103
|
+
const sameAuthor = previous !== undefined && authorKind === previous.attribution.author.kind && !has(p.authorName);
|
|
1104
|
+
// §1.3 (D63): the owner's words are what the owner said, copied, with where they said it. An agent's restatement,
|
|
1105
|
+
// summary or "the owner means …" is not one unless the owner confirmed it.
|
|
1106
|
+
if (category === OWNER_WORDS) {
|
|
1107
|
+
if (!quote.trim()) return fail(`An Owner's words item carries the owner’s own words (quote), copied from where they were said: each item opens to what the owner actually said. Put your one-line reading in text and the owner’s words in quote.`);
|
|
1108
|
+
if (sourceIds.length === 0) return fail(`An Owner's words item names where the owner said it: the source of the session message, or of the decision record that copies the owner’s words. None of the given ids is a source in the assets.`);
|
|
1109
|
+
if ((has(p.authorKind) && p.authorKind !== 'owner') || authorKind !== 'owner' || identity !== 'Decision') {
|
|
1110
|
+
return fail(`Owner's words are the owner’s own statements (author owner, identity Decision). An agent’s restatement or summary of what the owner wants is not one unless the owner confirmed it: record it as the product description it is, and let it refine the owner’s words it restates.`);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
if (sourceIds.length === 0 && basis === 'Explicit') return fail('an Explicit item must cite at least one existing source');
|
|
1114
|
+
if (identity === 'Decision' && authorKind !== 'owner') return fail('only the owner’s statements have identity Decision');
|
|
1115
|
+
// §1.2, §2.6 (D61; CKC-02 AC-23, AC-25): what exists only in history forms no current node, and reference-only
|
|
1116
|
+
// material — including the documents third-party material brings — never becomes a requirement, boundary or
|
|
1117
|
+
// plan. Either can be cited next to the project's own material; an item resting on nothing else is refused.
|
|
1118
|
+
if (validity !== 'Removed' && cited.length > 0 && cited.every((s) => s.usedAs === 'History only' || s.usedAs === 'Reference only')) {
|
|
1119
|
+
const history = cited.every((s) => s.usedAs === 'History only');
|
|
1120
|
+
const reference = cited.every((s) => s.usedAs === 'Reference only');
|
|
1121
|
+
return fail(history
|
|
1122
|
+
? `Every source this item cites is History only: old versions, deleted files and what the project keeps for recovery exist only in history and form no current node. Use them to mark what current material has outdated, to cross-check current documents, or to trace history — and if something was dropped that should not have been, say so in a note for the owner.`
|
|
1123
|
+
: reference
|
|
1124
|
+
? `Every source this item cites is Reference only: material the project keeps for reference, and the documents third-party material brings with it, can be cited but never become a requirement, boundary, design or plan of the project. What the project itself does with that material is a fact about the project, in its own sources.`
|
|
1125
|
+
: `Every source this item cites is History only or Reference only, and neither forms a current product reference item. Cite the project’s own current material it rests on.`);
|
|
1126
|
+
}
|
|
1127
|
+
// §1.15: a validity one of the project's rules decides names the rule, and the rule has to say so.
|
|
1128
|
+
const validityByRuleId = text(p.validityByRuleId).trim();
|
|
1129
|
+
if (validityByRuleId) {
|
|
1130
|
+
const refusal = ruleUseRefusal(store, validityByRuleId, validity === 'Removed' ? 'Removed' : 'Validity');
|
|
1131
|
+
if (refusal) return fail(refusal);
|
|
1132
|
+
if (store.rules.get(validityByRuleId)!.category === 'Obsolete') {
|
|
1133
|
+
if (validity !== 'Replaced' && validity !== 'Abandoned') return fail(`An Obsolete rule makes what it withdraws Replaced or Abandoned (Replaced points to the new way the rule names), never ${validity}.`);
|
|
1134
|
+
if (basis !== 'Explicit') return fail(`What the project’s own Obsolete rule withdraws is judged Explicit, with the rule as its basis — the project wrote it down; nothing about it is your inference.`);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
// Removed is judged when a call sets it; an update that leaves validity out keeps what was judged then.
|
|
1138
|
+
if (has(p.validity) && validity === 'Removed') {
|
|
1139
|
+
const refusal = removedRefusal(store, sourceIds, validityByRuleId);
|
|
1140
|
+
if (refusal) return fail(refusal);
|
|
1141
|
+
}
|
|
1142
|
+
// §1.3 (D63, E80, E86): an owner quote stands, part by part and in order, in the message or record cited,
|
|
1143
|
+
// whatever product-reference category carries it, or nothing is written.
|
|
1144
|
+
// A quote the item already carries, on the sources it already cites, is not checked again: its source may have
|
|
1145
|
+
// changed since, and the item must still be markable Replaced.
|
|
1146
|
+
const quoteKept = previous && sameWords(previous.quote, quote) && previous.sourceIds.every((s) => sourceIds.includes(s));
|
|
1147
|
+
if (quote.trim() && !quoteKept) {
|
|
1148
|
+
const check = checkVerbatim(store, quote, sourceIds);
|
|
1149
|
+
if (check.missing.length) return fail(category === OWNER_WORDS ? ownerWordsRefusal(check) : referenceQuoteRefusal(check));
|
|
1150
|
+
}
|
|
1151
|
+
// DA (Spec §3.11): an Owner's words quote too short to stand alone comes with the message it answers.
|
|
1152
|
+
const answers = text(p.answers).trim() || (previous && quoteKept ? previous.answers ?? '' : '');
|
|
1153
|
+
if (category === OWNER_WORDS && quote.trim() && !(quoteKept && !text(p.answers).trim())) {
|
|
1154
|
+
const refusal = ownerWordsContextRefusal(store, { quote, answers, sourceIds });
|
|
1155
|
+
if (refusal) return fail(refusal);
|
|
1156
|
+
}
|
|
1157
|
+
const name = text(p.name) || previous?.name || '';
|
|
1158
|
+
if (p.foundation === true && category !== 'Area') return fail('foundation marks an Area: a cross-cutting foundation the project’s documents treat as a peer of its modules. Nothing was written.');
|
|
1159
|
+
const wholeWhy = absent(p.wholeProductWhy) ? previous?.wholeProductWhy ?? null : redact(text(p.wholeProductWhy)).trim() || null;
|
|
1160
|
+
if (has(p.wholeProductWhy) && (category === 'Area' || category === 'Product' || category === 'Goal' || category === 'Plan' || category === OWNER_WORDS)) return fail(`wholeProductWhy says why a decision, boundary, requirement or design concerns the whole product; a ${category} is not placed that way. Nothing was written.`);
|
|
1161
|
+
// CJ (CKC-23 AC-21): an item is named as its document names it — `D1 · 做成通用产品…`, not `D1`.
|
|
1162
|
+
if (isNumberOnly(text(p.name))) {
|
|
1163
|
+
const named = withNumbering(store, (ledger) => numberOnlyRefusal(store, ledger, text(p.name).trim(), sourceIds, 'The name'), null);
|
|
1164
|
+
if (named) return fail(named);
|
|
1165
|
+
}
|
|
1166
|
+
// CZ: a number given as id finds the item; the item's ids change only where the call gives ids itself.
|
|
1167
|
+
const itemIds = previous && asNumber ? idsGiven : ids;
|
|
1168
|
+
const item: ReferenceItem = {
|
|
1169
|
+
// CQ (D104): a Plan's id is what its document names it — a number, or the leading token of its heading (`K`, `Sprint 3`).
|
|
1170
|
+
id, projectId: project.id, category, name, ids: itemIds.length ? itemIds : previous?.ids.length ? previous.ids : primary ? [primary] : category === 'Plan' && planIdOf(name) ? [planIdOf(name)!] : [], text: text(p.text) || previous?.text || '', quote: quote || null, basis, validity,
|
|
1171
|
+
...(category === OWNER_WORDS && answers ? { answers: redact(answers) } : {}),
|
|
1172
|
+
progress: isOneOf(PROGRESS, p.progress) ? p.progress : previous?.progress ?? null,
|
|
1173
|
+
attribution: {
|
|
1174
|
+
author: sameAuthor ? previous!.attribution.author : { kind: authorKind, name: text(p.authorName) || (previous && authorKind === previous.attribution.author.kind ? previous.attribution.author.name : null), window: null, host: null, model: null },
|
|
1175
|
+
holder: text(p.holderRole).trim() ? { role: text(p.holderRole).trim(), window: null } : previous?.attribution.holder ?? null, identity,
|
|
1176
|
+
},
|
|
1177
|
+
// CQ (D104): a Plan or Area may be named by the id its document gives it (`K`, `P1`) as well as by its store id.
|
|
1178
|
+
sourceIds, refines: absent(p.refines) ? [...(previous?.refines ?? [])] : arr<string>(p.refines).map((r) => referenceByWrittenId(store, text(r))?.id ?? text(r)).filter((r) => store.reference.has(r)),
|
|
1179
|
+
// What replaced it stays while the item stays Replaced; a call that changes the validity gives it anew or drops it.
|
|
1180
|
+
replacedBy: text(p.replacedBy) || (previous && !validityChanged ? previous.replacedBy : null),
|
|
1181
|
+
// Which parts of a decision a later one already took over is the replacement check's finding (§5.5), not
|
|
1182
|
+
// anything re-reading the material can see, so re-reading the decision record must not wipe it. The same
|
|
1183
|
+
// holds for whether the decision was carried out (§2.2).
|
|
1184
|
+
supersededParts: previous?.supersededParts,
|
|
1185
|
+
...(previous?.carryOut ? { carryOut: previous.carryOut } : {}),
|
|
1186
|
+
validityByRuleId: validityByRuleId || (previous && !validityChanged ? previous.validityByRuleId ?? null : null),
|
|
1187
|
+
...(previous?.writtenStatus ? { writtenStatus: previous.writtenStatus } : {}),
|
|
1188
|
+
...(category === 'Area' && (typeof p.foundation === 'boolean' ? p.foundation : previous?.foundation) ? { foundation: true } : {}),
|
|
1189
|
+
...(wholeWhy ? { wholeProductWhy: wholeWhy } : {}),
|
|
1190
|
+
inputs: p.inputs !== undefined ? inputsOf(ctx, p.inputs, project.name) : previous?.inputs ?? null, asOf: now(), updatedAt: now(),
|
|
1191
|
+
// `Update pending` is the program's (update-pending.ts): writing the item does not take in what it waits for.
|
|
1192
|
+
...(previous?.pendingSourceIds?.length ? { pendingSourceIds: previous.pendingSourceIds } : {}), ...(previous?.waitsFor ? { waitsFor: previous.waitsFor } : {}),
|
|
1193
|
+
};
|
|
1194
|
+
// CQ (D104): what this write does with the program's Inferred placements of this item — a refines list given whole
|
|
1195
|
+
// that keeps the target makes it the Keeper's own (confirmed); one that drops it replaces it (moved).
|
|
1196
|
+
const replacedInferred = previous && !absent(p.refines) && programTargetsOf(store, 'reference', id).length
|
|
1197
|
+
? noteOverride(store, 'reference', id, { targets: item.refines, kept: (target) => (item.refines.includes(target) ? 'rewritten' : 'gone') }, { jobId: ctx.jobId, roundId: ctx.step?.roundId ?? null })
|
|
1198
|
+
: [];
|
|
1199
|
+
store.reference.put(item, trace(`${previous ? `Reference item updated: ${name}` : `Reference item: ${name}`}${replacedInferred.length ? ` (replaced the program's Inferred placement on ${replacedInferred.join('; ')})` : ''}`, [...sourceIds, ...(validityByRuleId ? store.rules.get(validityByRuleId)!.sourceIds : [])]));
|
|
1200
|
+
saved('reference', id, `Reference: ${category} ${name}`);
|
|
1201
|
+
// D101 (CM): once an Area exists, everything that names it in the project's own writing is placed again in this
|
|
1202
|
+
// pass — a new Area, one renamed, one newly marked as the cross-cutting foundation.
|
|
1203
|
+
let placedAgain: { count: number; items: string[] } | null = null;
|
|
1204
|
+
if (category === 'Area' && validity === 'Current' && (!previous || previous.name !== name || (item.foundation === true && previous.foundation !== true))) {
|
|
1205
|
+
const moved = placeAgain(store, item, (summary) => trace(summary, sourceIds));
|
|
1206
|
+
for (const m of moved) saved(m.kind === 'thread' ? 'threads' : 'reference', m.id, `Placed on ${name}`);
|
|
1207
|
+
if (moved.length) placedAgain = { count: moved.length, items: moved.slice(0, 40).map((m) => `${m.name.slice(0, 70)} — ${m.how}`) };
|
|
1208
|
+
}
|
|
1209
|
+
const warning = referenceWarning(store, item);
|
|
1210
|
+
return ok({ id, merged: Boolean(match), ...(warning ? { warning } : {}), ...(placedAgain ? { placedAgain } : {}), ...(replacedInferred.length ? { replacedInferred, note: 'This write replaced a placement the program had inferred; the round record keeps both.' } : {}) });
|
|
1211
|
+
},
|
|
1212
|
+
}),
|
|
1213
|
+
defineTool({
|
|
1214
|
+
name: 'pk_relate', label: 'Write relation',
|
|
1215
|
+
description: 'Create or update a graph relation between two assets (reference item, thread, fact record, change, source): type, the claim the material makes, basis, evidence (source ids, fact record ids, how far the facts go). A relation is its type, fromId and toId: writing the same three again updates it, and what the call leaves out keeps its value. A new relation needs claim and basis. Assessment stays Not assessed until a product re-look judges it. withdraw: true takes a relation back — one you wrote, or one the fill wrote from a table cell that the document does not state (a “depends on” read out of a qualified clause) — with why; the relation is removed and the withdrawal is kept in the record.',
|
|
1216
|
+
parameters: Type.Object({
|
|
1217
|
+
type: Type.String(), fromId: Type.String(), toId: Type.String(),
|
|
1218
|
+
withdraw: Type.Optional(Type.Boolean({ description: 'true: take this relation back (type, fromId, toId name it); needs why' })),
|
|
1219
|
+
why: Type.Optional(Type.String({ description: 'withdraw: why the relation does not hold — what the document actually says' })),
|
|
1220
|
+
claim: Type.Optional(Type.String({ description: 'the claim the material makes; a new relation needs it, an update keeps it when left out' })),
|
|
1221
|
+
basis: Type.Optional(Type.String({ description: 'Explicit | Inferred — a new relation needs it' })),
|
|
1222
|
+
evidenceSourceIds: Type.Optional(StrArr), evidenceFactRecordIds: Type.Optional(StrArr), factsSoFar: Type.Optional(Type.String()),
|
|
1223
|
+
}),
|
|
1224
|
+
execute: async (_id, raw) => {
|
|
1225
|
+
// A work item merged into another is reached through the one kept (§1.4).
|
|
1226
|
+
const p = { ...raw, fromId: resolveMergedId(store, raw.fromId), toId: resolveMergedId(store, raw.toId) };
|
|
1227
|
+
if (!isOneOf(RELATION_TYPE, p.type)) return fail(`type must be one of ${RELATION_TYPE.join(', ')}`);
|
|
1228
|
+
if (has(p.basis) && !isOneOf(BASIS, p.basis)) return fail('basis must be Explicit or Inferred');
|
|
1229
|
+
// CZ: a relation written wrongly can be taken back — by the job that wrote it, or the one correcting a fill.
|
|
1230
|
+
if (p.withdraw === true) {
|
|
1231
|
+
const why = text(p.why).trim();
|
|
1232
|
+
const standing = store.relations.get(stableId('rel', p.type, p.fromId, p.toId));
|
|
1233
|
+
if (!standing) return fail(`There is no ${p.type} relation from ${p.fromId} to ${p.toId} to withdraw. Nothing changed.`);
|
|
1234
|
+
if (!why) return fail('why: why this relation does not hold — what the document actually says. Nothing changed.');
|
|
1235
|
+
if (p.type === 'carries out') return fail('A carries out relation is written with the decision’s carry-out record: correct it with pk_record_carry_out, which keeps the two together. Nothing changed.');
|
|
1236
|
+
const id = standing.id;
|
|
1237
|
+
store.relations.remove(id, trace(`Relation ${p.type} withdrawn: ${p.fromId} → ${p.toId} (“${standing.claim.slice(0, 120)}”) — ${why}`, standing.evidence.sourceIds));
|
|
1238
|
+
saved('relations', id, `Relation ${p.type} withdrawn`);
|
|
1239
|
+
return ok({ id, withdrawn: true });
|
|
1240
|
+
}
|
|
1241
|
+
const exists = (id: string) => store.reference.has(id) || store.threads.has(id) || store.facts.has(id) || store.changes.has(id) || store.sources.has(id) || store.areas.has(id) || store.nodes.has(id);
|
|
1242
|
+
if (!exists(p.fromId) || !exists(p.toId)) return fail('fromId and toId must be existing asset ids');
|
|
1243
|
+
const historyEnd = relationEndpointHistoryRefusal(store, p.fromId, p.toId);
|
|
1244
|
+
if (historyEnd) return fail(historyEnd);
|
|
1245
|
+
if (p.type === 'carries out' && !isDecision(store, p.toId)) {
|
|
1246
|
+
return fail(`carries out ends at a decision that asks for something to be done; ${p.toId} is ${store.reference.get(p.toId) ? `a ${store.reference.get(p.toId)!.category}` : 'not a decision'}. To say a decision was carried out, use pk_record_carry_out, which writes these relations with it.`);
|
|
1247
|
+
}
|
|
1248
|
+
const id = stableId('rel', p.type, p.fromId, p.toId);
|
|
1249
|
+
const previous = store.relations.get(id);
|
|
1250
|
+
if (!previous) {
|
|
1251
|
+
const missing = (['claim', 'basis'] as const).filter((f) => absent(p[f]));
|
|
1252
|
+
if (missing.length) throw createNeeds('a relation', `There is no ${p.type} relation from ${p.fromId} to ${p.toId} yet`, missing, `To update a relation, give the type, fromId and toId it has.`);
|
|
1253
|
+
}
|
|
1254
|
+
const relation: GraphRelation = {
|
|
1255
|
+
id, projectId: project.id, type: p.type, from: p.fromId, to: p.toId, claim: text(p.claim) || previous?.claim || '', basis: (has(p.basis) ? p.basis : previous!.basis) as GraphRelation['basis'],
|
|
1256
|
+
evidence: {
|
|
1257
|
+
sourceIds: absent(p.evidenceSourceIds) ? [...(previous?.evidence.sourceIds ?? [])] : arr<string>(p.evidenceSourceIds),
|
|
1258
|
+
factRecordIds: absent(p.evidenceFactRecordIds) ? [...(previous?.evidence.factRecordIds ?? [])] : arr<string>(p.evidenceFactRecordIds),
|
|
1259
|
+
factsSoFar: absent(p.factsSoFar) ? previous?.evidence.factsSoFar ?? '' : text(p.factsSoFar),
|
|
1260
|
+
},
|
|
1261
|
+
assessment: previous?.assessment ?? 'Not assessed', assessedAt: previous?.assessedAt ?? null, assessedInJobId: previous?.assessedInJobId ?? null, updatedAt: now(),
|
|
1262
|
+
};
|
|
1263
|
+
const warning = relationWarning(store, p.type, p.fromId, p.toId);
|
|
1264
|
+
store.relations.put(relation, trace(`Relation ${p.type}: ${p.fromId} → ${p.toId}`, relation.evidence.sourceIds));
|
|
1265
|
+
saved('relations', id, `Relation ${p.type}`);
|
|
1266
|
+
return ok(warning ? { id, warning } : { id });
|
|
1267
|
+
},
|
|
1268
|
+
}),
|
|
1269
|
+
defineTool({
|
|
1270
|
+
name: 'pk_write_change', label: 'Write change record',
|
|
1271
|
+
description: 'Record one piece of work and its net changes. A piece of work is one session or one execution, together with the files it changed and the commits it made; it gets one record, however many files it touched. The record lists that work’s items: one per net change, each with its own material category, effect (Added, Approved, Replaced, Deferred, Abandoned, Completed, Corrected), before/after, basis and affects — the objects that item directly changed, which is its subject. Restating, re-reading, checking a dependency, re-reading after a compaction, recalling from memory, changing something and changing it back, and wording, formatting or version-number changes are not changes and are refused. Calling this twice for the same piece of work — the same id, or the same piece of work — adds to the one record: an update gives only what it changes, and what it leaves out keeps its value (sourceIds are added to the record’s; the piece of work’s fields left out stay as they are; a call with no item changes only the record’s own fields). A new record needs sourceIds and its items.',
|
|
1272
|
+
parameters: Type.Object({
|
|
1273
|
+
id: Type.Optional(Type.String()),
|
|
1274
|
+
workKind: Type.Optional(Type.String({ description: 'Session | Execution | Time range — how this piece of work was cut out. Time range when the changes have no session' })),
|
|
1275
|
+
workLabel: Type.Optional(Type.String({ description: 'which session or execution, or how the time range was cut' })),
|
|
1276
|
+
workSessionId: Type.Optional(Type.String()),
|
|
1277
|
+
workStartedAt: Type.Optional(Type.String({ description: 'ISO time this piece of work began, or its date when the material gives only the day' })),
|
|
1278
|
+
workEndedAt: Type.Optional(Type.String({ description: 'ISO time it ended, or its last pause when it was still running at the start of this round; its date when the material gives only the day' })),
|
|
1279
|
+
workOpenEnded: Type.Optional(Type.Boolean({ description: 'true when the work was still going: what came after belongs to the next round’s record' })),
|
|
1280
|
+
sourceIds: Type.Optional(Type.Array(Type.String(), { description: 'the files this piece of work changed, its commits and its session segments; a new record needs them, and an update adds them to the record’s' })),
|
|
1281
|
+
items: Type.Optional(Type.Array(Type.Object({
|
|
1282
|
+
at: Type.String({ description: 'the time from the material, at its precision: a date (2026-09-17) stays a date; an ISO time when the material gives the clock' }), atFromMaterial: Type.Optional(Type.Boolean()),
|
|
1283
|
+
material: Type.String(), effect: Type.String(), title: Type.String(), summary: Type.String(),
|
|
1284
|
+
before: Type.Optional(Type.String()), after: Type.Optional(Type.String()), sourceIds: Type.Optional(StrArr),
|
|
1285
|
+
byOwner: Type.Optional(Type.Boolean()), byName: Type.Optional(Type.String()),
|
|
1286
|
+
why: Type.Optional(Type.String({ description: 'why it was changed, in the material’s own terms (the owner asked, a trial showed the old shape failed, a contract required it); leave out when the material gives no reason' })),
|
|
1287
|
+
affects: Type.Optional(Type.Array(Type.String(), { description: 'the objects this item directly changed — its subject' })),
|
|
1288
|
+
}), { description: 'this piece of work’s net changes, one entry each' })),
|
|
1289
|
+
at: Type.Optional(Type.String()), atFromMaterial: Type.Optional(Type.Boolean()), material: Type.Optional(Type.String()), effect: Type.Optional(Type.String()),
|
|
1290
|
+
title: Type.Optional(Type.String()), summary: Type.Optional(Type.String()), before: Type.Optional(Type.String()), after: Type.Optional(Type.String()),
|
|
1291
|
+
byOwner: Type.Optional(Type.Boolean()), byName: Type.Optional(Type.String()), affects: Type.Optional(StrArr),
|
|
1292
|
+
segmentName: Type.Optional(Type.String()), segmentSourceId: Type.Optional(Type.String()),
|
|
1293
|
+
}),
|
|
1294
|
+
execute: async (_id, p) => {
|
|
1295
|
+
const sourcesGiven = !absent(p.sourceIds);
|
|
1296
|
+
const callSources = arr<string>(p.sourceIds).filter((s) => store.sources.has(s));
|
|
1297
|
+
if (sourcesGiven && callSources.length === 0) return fail('a change record needs at least one existing source: the files this piece of work changed, its commits, and the session segments it happened in');
|
|
1298
|
+
// One record per piece of work: its identity, not the item's, decides where the items land. A call naming the
|
|
1299
|
+
// record by id gives only what changes of its piece of work; the fields it leaves out stay as recorded.
|
|
1300
|
+
const named = text(p.id) ? store.changes.get(text(p.id)) : undefined;
|
|
1301
|
+
const recorded: Record<string, unknown> = named?.work ? { workKind: named.work.kind, workLabel: named.work.label, workSessionId: named.work.sessionId ?? '', workStartedAt: named.work.startedAt, workEndedAt: named.work.endedAt, workOpenEnded: named.work.openEnded } : {};
|
|
1302
|
+
const workFields = ['workKind', 'workLabel', 'workSessionId', 'workStartedAt', 'workEndedAt', 'workOpenEnded'] as const;
|
|
1303
|
+
const work = workSegmentOf({ ...recorded, ...Object.fromEntries(workFields.filter((f) => !absent(p[f])).map((f) => [f, p[f]])) });
|
|
1304
|
+
if (typeof work === 'string') return fail(work);
|
|
1305
|
+
// Work that takes the project over belongs to no Follow up round, so no round's start cuts its pieces of work.
|
|
1306
|
+
const round = isTakeoverWork(store, ctx.jobId) ? null : openRound(store);
|
|
1307
|
+
if (work && round && work.endedAt > round.startedAt) {
|
|
1308
|
+
return fail(`A piece of work never spans two Follow up rounds. This one is recorded as running until ${work.endedAt}, after round ${round.number} began at ${round.startedAt}. Organize it only up to its last pause before ${round.startedAt}, give that time as workEndedAt with workOpenEnded: true, and leave the rest for the next round.`);
|
|
1309
|
+
}
|
|
1310
|
+
// Each entry the caller gave, or the flat fields read as this work's single item; an update may give none.
|
|
1311
|
+
const raw = arr<Record<string, unknown>>(p.items);
|
|
1312
|
+
const flat = (['at', 'atFromMaterial', 'material', 'effect', 'title', 'summary', 'before', 'after', 'byOwner', 'byName', 'affects'] as const).some((f) => !absent(p[f]));
|
|
1313
|
+
const given = raw.length ? raw : flat ? [{ at: p.at, atFromMaterial: p.atFromMaterial, material: p.material, effect: p.effect, title: p.title, summary: p.summary, before: p.before, after: p.after, sourceIds: callSources, why: undefined, byOwner: p.byOwner, byName: p.byName, affects: p.affects }] : [];
|
|
1314
|
+
const recordId = text(p.id) || (work
|
|
1315
|
+
? stableId('chg', project.id, work.kind, work.sessionId || work.label, work.startedAt)
|
|
1316
|
+
: stableId('chg', project.id, text(given[0]?.effect), text(given[0]?.title), ...callSources));
|
|
1317
|
+
const previous = store.changes.get(recordId);
|
|
1318
|
+
if (!previous) {
|
|
1319
|
+
const missing = [...(sourcesGiven ? [] : ['sourceIds']), ...(given.length ? [] : ['items'])];
|
|
1320
|
+
if (missing.length) {
|
|
1321
|
+
const why = text(p.id) ? `${text(p.id)} is no change record of the assets` : work ? `No change record covers the piece of work “${work.label}” (${work.kind}, from ${work.startedAt})` : 'This call names no existing change record (no id, and no piece of work to find one by)';
|
|
1322
|
+
throw createNeeds('a change record', why, missing, 'To add to the record of a piece of work, give its id, or the piece of work (workKind, workLabel or workSessionId, workStartedAt) as it was recorded; items are the work’s net changes, or one item’s material, effect, title and summary given flat.');
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
// What the items rest on when they name nothing of their own: the call's sources, or the record's.
|
|
1326
|
+
const sourceIds = callSources.length ? callSources : [...(previous?.sourceIds ?? [])];
|
|
1327
|
+
if (given.length === 0) {
|
|
1328
|
+
// An update with no item changes the record's own fields — its piece of work, its segment, its sources.
|
|
1329
|
+
const record: ChangeRecord = {
|
|
1330
|
+
...previous!, title: work ? work.label || previous!.title : previous!.title,
|
|
1331
|
+
sourceIds: [...new Set([...previous!.sourceIds, ...callSources])], work: work ?? previous!.work ?? null,
|
|
1332
|
+
segment: p.segmentName && p.segmentSourceId ? { name: p.segmentName, sourceId: p.segmentSourceId } : previous!.segment ?? null, updatedAt: now(),
|
|
1333
|
+
};
|
|
1334
|
+
store.changes.put(record, trace(`Change record updated${record.work ? ` (${record.work.kind}: ${record.work.label})` : ''}`, record.sourceIds));
|
|
1335
|
+
saved('changes', recordId, `Change record: ${record.title}`);
|
|
1336
|
+
return ok({ id: recordId, items: itemsOf(record).length, affects: record.affects, updated: true });
|
|
1337
|
+
}
|
|
1338
|
+
const built: ChangeItem[] = [];
|
|
1339
|
+
const notedAffects: number[] = [];
|
|
1340
|
+
for (const [i, r] of given.entries()) {
|
|
1341
|
+
if (!isOneOf(CHANGE_MATERIAL, r.material)) return fail(`items[${i}].material must be one of ${CHANGE_MATERIAL.join(', ')}`);
|
|
1342
|
+
if (!isOneOf(CHANGE_EFFECT, r.effect)) return fail(`items[${i}].effect must be one of ${CHANGE_EFFECT.join(', ')}`);
|
|
1343
|
+
// §1.8 (CKC-02 AC-27): the time keeps the precision the material gives — a date stays a date, never a midnight.
|
|
1344
|
+
const at = normalizeMaterialTime(text(r.at) || work?.startedAt || '');
|
|
1345
|
+
if (!at) return fail(`items[${i}].at must be the time the material gives: a date (2026-09-17), which stays a date, or an ISO time when the material gives the clock`);
|
|
1346
|
+
const itemSources = arr<string>(r.sourceIds).filter((s) => store.sources.has(s));
|
|
1347
|
+
const named = [...new Set(arr<string>(r.affects).map((a) => resolveMergedId(store, a)))].filter((a) => objectExists(store, a));
|
|
1348
|
+
const derived = named.length === 0 ? affectsFromSources(store, itemSources.length ? itemSources : sourceIds) : [];
|
|
1349
|
+
if (derived.length) notedAffects.push(i);
|
|
1350
|
+
built.push({
|
|
1351
|
+
id: stableId('item', recordId, r.effect, text(r.title)), at,
|
|
1352
|
+
atSource: r.atFromMaterial === false ? 'observed' : 'material', material: r.material, effect: r.effect,
|
|
1353
|
+
title: text(r.title), summary: text(r.summary), before: text(r.before) || null, after: text(r.after) || null,
|
|
1354
|
+
sourceIds: itemSources.length ? itemSources : sourceIds, why: text(r.why) || null,
|
|
1355
|
+
by: r.byOwner === true
|
|
1356
|
+
? { author: { kind: 'owner', name: null, window: null, host: null, model: null }, holder: null, identity: 'Decision' }
|
|
1357
|
+
: { author: { kind: r.byName ? 'role' : 'unknown', name: text(r.byName) || null, window: null, host: null, model: null }, holder: null, identity: 'Artifact' },
|
|
1358
|
+
affects: named.length ? named : derived,
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
// §1.8: what is recorded is the net change. Two items of this work that undo each other cancel; an item
|
|
1362
|
+
// that is only a restatement, a wording change or a version bump is refused with what it would duplicate.
|
|
1363
|
+
const refusals: string[] = [];
|
|
1364
|
+
const undone = new Set(undonePairs(built).flat());
|
|
1365
|
+
for (const i of undone) refusals.push(`item ${i + 1} (“${built[i]!.title}”) — Changed and changed back: ${NOT_A_CHANGE_ADVICE['Changed and changed back']}`);
|
|
1366
|
+
const kept = built.filter((item, i) => {
|
|
1367
|
+
if (undone.has(i)) return false;
|
|
1368
|
+
const why = notAChange(store, item, recordId);
|
|
1369
|
+
if (!why) return true;
|
|
1370
|
+
refusals.push(`item ${i + 1} (“${item.title}”) — ${why}: ${NOT_A_CHANGE_ADVICE[why]}`);
|
|
1371
|
+
return false;
|
|
1372
|
+
});
|
|
1373
|
+
if (kept.length === 0) {
|
|
1374
|
+
const nothing = `A piece of work with no net change gets no change record. Nothing here is a change: ${refusals.join('; ')}. The facts still belong in the fact record of the material.`;
|
|
1375
|
+
return previous ? fail(nothing) : fail(nothing);
|
|
1376
|
+
}
|
|
1377
|
+
const items = previous?.items?.length
|
|
1378
|
+
? [...previous.items.filter((old) => !kept.some((k) => k.id === old.id)), ...kept].sort((a, b) => a.at.localeCompare(b.at))
|
|
1379
|
+
: kept.sort((a, b) => a.at.localeCompare(b.at));
|
|
1380
|
+
const head = items[0]!;
|
|
1381
|
+
const affects = [...new Set(items.flatMap((i) => i.affects))];
|
|
1382
|
+
const record: ChangeRecord = {
|
|
1383
|
+
id: recordId, projectId: project.id, at: head.at, atSource: head.atSource, material: head.material,
|
|
1384
|
+
effect: head.effect, title: work ? work.label || head.title : head.title,
|
|
1385
|
+
summary: items.length > 1 ? items.map((i) => `${i.effect}: ${i.title}`).join('; ') : head.summary,
|
|
1386
|
+
before: head.before, after: head.after,
|
|
1387
|
+
sourceIds: [...new Set([...(previous?.sourceIds ?? []), ...sourceIds, ...items.flatMap((i) => i.sourceIds)])],
|
|
1388
|
+
by: head.by, affects,
|
|
1389
|
+
// A subject is not downstream of its own item, so nothing starts here: the downstream is found from the
|
|
1390
|
+
// relations by rule, and objects §2.10 never judges are listed apart instead.
|
|
1391
|
+
propagation: previous?.propagation ?? [],
|
|
1392
|
+
notJudged: previous?.notJudged ?? [],
|
|
1393
|
+
work: work ?? previous?.work ?? null, items,
|
|
1394
|
+
segment: p.segmentName && p.segmentSourceId ? { name: p.segmentName, sourceId: p.segmentSourceId } : previous?.segment ?? null,
|
|
1395
|
+
createdInJobId: previous?.createdInJobId ?? ctx.jobId, updatedAt: now(),
|
|
1396
|
+
};
|
|
1397
|
+
store.changes.put(record, trace(`Change record${work ? ` (${work.kind}: ${work.label})` : ''}: ${items.length} item${items.length === 1 ? '' : 's'}`, record.sourceIds));
|
|
1398
|
+
saved('changes', recordId, `Change record: ${record.title}`);
|
|
1399
|
+
const warnings = [...refusals];
|
|
1400
|
+
// A change nobody can trace to an asset cannot be followed up, and the workbench cannot star what it touched.
|
|
1401
|
+
if (affects.length === 0) warnings.push('No item names an object it changed, and nothing could be worked out from the material cited: name in each item’s affects the ids of the objects it touched (the replaced item and the one replacing it, the work item whose work changed, the area or requirement it constrains). Leave it empty only when the changed object is not an asset yet.');
|
|
1402
|
+
else if (notedAffects.length) warnings.push(`Item(s) ${notedAffects.map((i) => i + 1).join(', ')} named no affected object, so it was taken from what the cited material formed. Name them yourself when the item changed something else.`);
|
|
1403
|
+
return ok(warnings.length ? { id: recordId, items: items.length, affects, warnings } : { id: recordId, items: items.length, affects });
|
|
1404
|
+
},
|
|
1405
|
+
}),
|
|
1406
|
+
defineTool({
|
|
1407
|
+
name: 'pk_write_mark', label: 'Write entry mark',
|
|
1408
|
+
description: 'Attach a mark to an asset: Suspected stale, Undocumented decision, Layer drift, Scope question, Disposal (a leftover of an Abandoned direction: a document, code area, branch, worktree, config or process), Decided without owner (a rule or decision in force that a role set in the owner’s place — say who set it and when in decidedBy and decidedAt; it hangs on the product reference item or project rule itself, which stays Current and is not the owner’s Decision). Every mark says what differs (clue) and names the material it was checked against (clueSourceIds): a mark with neither is never shown to an agent, so it is refused. A mark never hangs on another mark or on a note, and a point-in-time record does not get Suspected stale or Layer drift because of something that happened later — that belongs on the current object still using the old content. The same kind on the same target is the same mark: writing it again updates it, and what the call leaves out keeps its value. A new mark needs clue and clueSourceIds.',
|
|
1409
|
+
parameters: Type.Object({
|
|
1410
|
+
kind: Type.String(), targetId: Type.String(),
|
|
1411
|
+
clue: Type.Optional(Type.String({ description: 'what differs, in one sentence; a new mark needs it' })),
|
|
1412
|
+
clueSourceIds: Type.Optional(Type.Array(Type.String(), { description: 'the material it was checked against; a new mark needs it' })),
|
|
1413
|
+
about: Type.Optional(Type.String({ description: `${MARK_ABOUT.join(' | ')} — only for a mark on a point-in-time record: "At its own time" means you doubt the record was true when it was written` })),
|
|
1414
|
+
decidedBy: Type.Optional(Type.String({ description: 'Decided without owner only: the role or agent that set it' })),
|
|
1415
|
+
decidedAt: Type.Optional(Type.String({ description: 'Decided without owner only: when it was set, as the material gives it (a date stays a date)' })),
|
|
1416
|
+
}),
|
|
1417
|
+
execute: async (_id, raw) => {
|
|
1418
|
+
const p = { ...raw, targetId: resolveMergedId(store, raw.targetId) };
|
|
1419
|
+
if (!isOneOf(MARK_KIND, p.kind)) return fail(`kind must be one of ${MARK_KIND.join(', ')}`);
|
|
1420
|
+
const id = stableId('mark', p.kind, p.targetId);
|
|
1421
|
+
const previous = store.marks.get(id);
|
|
1422
|
+
if (!previous) {
|
|
1423
|
+
const missing = (['clue', 'clueSourceIds'] as const).filter((f) => absent(p[f]));
|
|
1424
|
+
if (missing.length) throw createNeeds('a mark', `${p.targetId} has no ${p.kind} mark yet`, missing, 'To update a mark, give the kind and targetId it has.');
|
|
1425
|
+
}
|
|
1426
|
+
const clueSourceIds = absent(p.clueSourceIds) ? [...previous!.clueSourceIds] : arr<string>(p.clueSourceIds);
|
|
1427
|
+
const clue = text(p.clue) || previous?.clue || '';
|
|
1428
|
+
// What a mark was checked against has to be material an agent can read: a pack cites it and prints the mark as
|
|
1429
|
+
// checked (Spec §7.1; CKC-12 AC-32). An id that is not a source dropped out of the citation, and the mark still read
|
|
1430
|
+
// as checked (QC AH #14).
|
|
1431
|
+
const unknownClues = clueSourceIds.filter((id) => !store.sources.has(id));
|
|
1432
|
+
if (unknownClues.length) return fail(`clueSourceIds names what is not a source of this project: ${unknownClues.join(', ')}. Name the material the mark was checked against by its source id (src_…; pk_list_sources finds them), so the pack can cite it and an agent can read it.`);
|
|
1433
|
+
const about = isOneOf(MARK_ABOUT, p.about) ? (p.about as MarkAbout) : null;
|
|
1434
|
+
if (text(p.about) && !about) return fail(`about must be one of ${MARK_ABOUT.join(', ')}`);
|
|
1435
|
+
if (p.kind === 'Disposal') {
|
|
1436
|
+
if (clueSourceIds.length === 0) return fail('Disposal needs the source of the Abandoned direction it is left over from');
|
|
1437
|
+
if (!store.reference.find((r) => r.validity === 'Abandoned') && !store.threads.find((t) => t.validity === 'Abandoned')) return fail('Disposal is only allowed when an Abandoned direction with a source exists; record the abandonment first');
|
|
1438
|
+
}
|
|
1439
|
+
// §1.9 (D63): what a role set in the owner's place and is in force now, with who set it and when.
|
|
1440
|
+
let decidedBy: { who: string; at: string } | null = null;
|
|
1441
|
+
if (p.kind === 'Decided without owner') {
|
|
1442
|
+
const targetRefusal = decidedWithoutOwnerRefusal(store, p.targetId);
|
|
1443
|
+
if (targetRefusal) return fail(targetRefusal);
|
|
1444
|
+
const who = text(p.decidedBy).trim() || previous?.decidedBy?.who || '';
|
|
1445
|
+
const when = text(p.decidedAt).trim() || previous?.decidedBy?.at || '';
|
|
1446
|
+
if (!who || !when) return fail('Decided without owner says who set it and when: add decidedBy — the role or agent that set it — and decidedAt — when, as the material gives it (a date stays a date). The context lists it under Pending owner decisions with exactly that.');
|
|
1447
|
+
const at = normalizeMaterialTime(when);
|
|
1448
|
+
if (!at) return fail(`decidedAt must be when it was set, as the material gives it: a date (2026-09-17) or an ISO time. “${when}” is neither.`);
|
|
1449
|
+
decidedBy = { who, at };
|
|
1450
|
+
}
|
|
1451
|
+
const refusal = markRefusal(store, p.kind, p.targetId, clue, clueSourceIds, about);
|
|
1452
|
+
if (refusal) return fail(refusal);
|
|
1453
|
+
// Written again, the doubt is open again; what else the mark carries — which of the six things it is — stays.
|
|
1454
|
+
const mark: EntryMark = { ...previous, id, projectId: project.id, kind: p.kind, targetId: p.targetId, clueSourceIds, clue, since: previous?.since ?? now(), noteId: previous?.noteId ?? null, closed: null, ...(decidedBy ? { decidedBy } : {}) };
|
|
1455
|
+
store.marks.put(mark, trace(`Mark ${p.kind} on ${p.targetId}`, clueSourceIds));
|
|
1456
|
+
saved('marks', id, `Mark: ${p.kind}`);
|
|
1457
|
+
return ok(previous ? { id, updated: true, note: 'The mark that was already there had its clue updated; the same doubt does not become a second mark.' } : { id });
|
|
1458
|
+
},
|
|
1459
|
+
}),
|
|
1460
|
+
defineTool({
|
|
1461
|
+
name: 'pk_write_note', label: 'Write Keeper note',
|
|
1462
|
+
description: 'Write or update a note (interpretation, never a decision): mount (project — the whole project, mountIds empty | node | path — ids of objects in the assets | relation — relation ids), title, short preview, body sections (only those with content), what the owner is asked to do (For your decision | Worth discussing | For information), and the judgement record id from pk_record_judgement. Updating (the id of an existing note) creates a new version; give the reason. A note that says something has no follow-up, was let pass or was taken up by nobody carries what was read (looked: where, and up to when). An update keeps the note’s mount and gives only what changes: what the new version leaves out is taken from the version before. A new note needs mountKind, mountIds, title, preview and ask. A note from a change follow-up names the change or changes it is about in changeIds. A note about a code territory’s anomaly (residual code, code nobody claims, docs that disagree with the code) hangs on the area the territory serves or the work that built it, and names the anomaly in codeAnomalies — { territoryId, index } — so the anomaly in Code opens the note.',
|
|
1463
|
+
parameters: Type.Object({
|
|
1464
|
+
id: Type.Optional(Type.String({ description: 'the note to update' })),
|
|
1465
|
+
mountKind: Type.Optional(Type.String({ description: 'project | node | path | relation — a new note needs it; an update keeps its mount' })),
|
|
1466
|
+
mountIds: Type.Optional(Type.Array(Type.String(), { description: 'a new note needs them (empty for the whole project); an update keeps its mount' })),
|
|
1467
|
+
title: Type.Optional(Type.String()), preview: Type.Optional(Type.String()), ask: Type.Optional(Type.String({ description: `${NOTE_ASK.join(' | ')} — a new note needs it` })),
|
|
1468
|
+
currentView: Type.Optional(Type.String()), whyItMatters: Type.Optional(Type.String()), facts: Type.Optional(Type.Array(Type.Object({ text: Type.String(), sourceIds: StrArr, inferred: Type.Boolean() }))),
|
|
1469
|
+
otherExplanations: Type.Optional(Type.String()), keepAdjust: Type.Optional(Type.String()), whatWouldSettleIt: Type.Optional(Type.String()), judgementRecordId: Type.Optional(Type.String()), reason: Type.String(), language: Type.Optional(Type.String()),
|
|
1470
|
+
options: Type.Optional(Type.Array(Type.Object({
|
|
1471
|
+
option: Type.String({ description: 'the choice, in a few plain words' }),
|
|
1472
|
+
then: Type.String({ description: 'what follows from choosing it: what is changed and where' }),
|
|
1473
|
+
}), { description: 'a For your decision note: the owner’s options, at least two, one per choice — leaving it alone is one too. Its preview opens with the question.' })),
|
|
1474
|
+
changeIds: Type.Optional(StrArr),
|
|
1475
|
+
looked: Type.Optional(Type.Object({
|
|
1476
|
+
where: Type.Array(Type.String(), { description: 'each place you read for what came after, as the spot-check can open it again: a file with its section or lines, a ledger query, a commit range, a session' }),
|
|
1477
|
+
upTo: Type.String({ description: 'up to when your reading reaches: the date or time (2026-09-30, 2026-09-30T19:53Z) of the newest commit, document version or report you read' }),
|
|
1478
|
+
sessionsUpTo: Type.Optional(Type.String({ description: 'when sessions were read: the time of the last session message you read' })),
|
|
1479
|
+
}, { description: 'a note that says something has no follow-up, was let pass or was taken up by nobody carries what was read: where you looked for what came after, and up to when. In a round such a note is refused without it. When the look stops before the ledger’s newest session or commit, the note opens with “As far as read — …”' })),
|
|
1480
|
+
codeAnomalies: Type.Optional(Type.Array(Type.Object({
|
|
1481
|
+
territoryId: Type.String({ description: 'the code territory' }),
|
|
1482
|
+
index: Type.Optional(Type.Number({ description: 'which of its anomalies, counted from 0 (may be left out when it has one)' })),
|
|
1483
|
+
}), { description: 'the code-territory anomalies this note is about: each one opens the note from Code' })),
|
|
1484
|
+
}),
|
|
1485
|
+
execute: async (_id, p) => {
|
|
1486
|
+
const previous = text(p.id) ? store.notes.get(text(p.id)) : undefined;
|
|
1487
|
+
if (!previous) {
|
|
1488
|
+
const missing = (['mountKind', 'mountIds', 'title', 'preview', 'ask'] as const).filter((f) => absent(p[f]));
|
|
1489
|
+
if (missing.length) throw createNeeds('a note', text(p.id) ? `${text(p.id)} is no note of the assets` : 'This call names no note to update (no id)', missing, 'To update a note, give its id: the new version takes what it leaves out from the version before, and the note keeps its mount.');
|
|
1490
|
+
}
|
|
1491
|
+
// What the new version leaves out is the version before's.
|
|
1492
|
+
const last = previous?.versions[previous.versions.length - 1];
|
|
1493
|
+
const ask = has(p.ask) ? text(p.ask) : last?.ask ?? '';
|
|
1494
|
+
if (!isOneOf(NOTE_ASK, ask)) return fail(`ask must be one of ${NOTE_ASK.join(', ')}`);
|
|
1495
|
+
if (!previous && !['project', 'node', 'relation', 'path'].includes(text(p.mountKind))) return fail('mountKind must be project | node | relation | path');
|
|
1496
|
+
const judgementRecordId = text(p.judgementRecordId) || ctx.judgementId || last?.judgementRecordId || '';
|
|
1497
|
+
if (!store.judgements.has(judgementRecordId)) return fail('judgementRecordId must be an existing judgement record (call pk_record_judgement first)');
|
|
1498
|
+
const anomalies = noteAnomalyTargets(store, arr<Record<string, unknown>>(p.codeAnomalies));
|
|
1499
|
+
if (typeof anomalies === 'string') return fail(`${anomalies} Nothing was written.`);
|
|
1500
|
+
// An update keeps the mount it had; a new note's mount must name what is in the assets (owner 2026-09-22).
|
|
1501
|
+
// A work item merged into another is reached through the one kept (§1.4).
|
|
1502
|
+
const mountIds = previous ? [...previous.mount.ids] : [...new Set(arr<string>(p.mountIds).map((id) => resolveMergedId(store, id)))];
|
|
1503
|
+
if (!previous) {
|
|
1504
|
+
const mountRefusal = noteMountRefusal(store, text(p.mountKind), mountIds);
|
|
1505
|
+
if (mountRefusal) return fail(mountRefusal);
|
|
1506
|
+
}
|
|
1507
|
+
const kept = (v: unknown, before: string | null | undefined): string | null => text(v) || before || null;
|
|
1508
|
+
const title = text(p.title) || last?.title || '';
|
|
1509
|
+
const preview = text(p.preview) || last?.preview || '';
|
|
1510
|
+
const body: NoteBody = {
|
|
1511
|
+
currentView: kept(p.currentView, last?.body.currentView), whyItMatters: kept(p.whyItMatters, last?.body.whyItMatters),
|
|
1512
|
+
facts: absent(p.facts) ? [...(last?.body.facts ?? [])] : arr<Record<string, unknown>>(p.facts).map((f) => ({ text: text(f.text), sourceIds: arr<string>(f.sourceIds), inferred: f.inferred === true })),
|
|
1513
|
+
otherExplanations: kept(p.otherExplanations, last?.body.otherExplanations), keepAdjust: kept(p.keepAdjust, last?.body.keepAdjust), whatWouldSettleIt: kept(p.whatWouldSettleIt, last?.body.whatWouldSettleIt),
|
|
1514
|
+
// Options belong to a note the owner is asked to decide (§4.2): another note carries none.
|
|
1515
|
+
...(ask === 'For your decision' ? { options: absent(p.options) ? [...(last?.body.options ?? [])] : arr<Record<string, unknown>>(p.options).map((o) => ({ option: text(o.option).trim(), then: text(o.then).trim() })) } : {}),
|
|
1516
|
+
};
|
|
1517
|
+
const noteRefusal = noteRefusalFor(store, {
|
|
1518
|
+
id: text(p.id), mountIds, changeIds: arr<string>(p.changeIds), ask, title,
|
|
1519
|
+
preview, whyItMatters: body.whyItMatters ?? '', whatWouldSettleIt: body.whatWouldSettleIt ?? '',
|
|
1520
|
+
});
|
|
1521
|
+
if (noteRefusal) return fail(noteRefusal);
|
|
1522
|
+
// The form the owner reads it in (D105; CKC-08 AC-26, AC-27): no internal identifiers anywhere in it, and a
|
|
1523
|
+
// For your decision note opens with the question and gives the options. Checked here, so it costs one retry
|
|
1524
|
+
// and no attention up front; the whole version is checked, also what it takes from the version before.
|
|
1525
|
+
const formRefusal = noteFormRefusal({
|
|
1526
|
+
ask, title, preview, options: body.options ?? [],
|
|
1527
|
+
body: { currentView: body.currentView, whyItMatters: body.whyItMatters, ...Object.fromEntries(body.facts.map((f, i) => [`facts[${i}]`, f.text])), otherExplanations: body.otherExplanations, keepAdjust: body.keepAdjust, whatWouldSettleIt: body.whatWouldSettleIt },
|
|
1528
|
+
}, laneNamesOf(store), previous ? `Write the version again (pk_write_note with id "${previous.id}") with every part named above given anew` : 'Write it again');
|
|
1529
|
+
if (formRefusal) return fail(formRefusal);
|
|
1530
|
+
// CN (E152; Spec §2.12): a note that says something has no follow-up, was let pass or was taken up by nobody
|
|
1531
|
+
// carries what was read — where, and up to when. In a round the claim is refused without it; a look that stops
|
|
1532
|
+
// before the ledger's newest session or commit is said on the line everyone reads.
|
|
1533
|
+
let looked = previous?.looked ?? null;
|
|
1534
|
+
if (!absent(p.looked)) {
|
|
1535
|
+
const given = parseLooked(p.looked);
|
|
1536
|
+
if (typeof given === 'string') return fail(`${given} Nothing was written.`);
|
|
1537
|
+
looked = lookedRecord(given, presentOf(store), { roundId: ctx.step?.roundId ?? null, jobId: ctx.jobId, at: now() });
|
|
1538
|
+
}
|
|
1539
|
+
const claims = absenceClaims([title, preview, body.currentView ?? '', body.whyItMatters ?? '', ...body.facts.map((f) => f.text)].join('\n'));
|
|
1540
|
+
if (ctx.step?.roundId && claims.length && !looked) return fail(absenceRefusal(`The note “${title}”`, claims));
|
|
1541
|
+
const said = stampAsFarAs(preview, claims.length || !absent(p.looked) ? looked : null);
|
|
1542
|
+
const id = text(p.id) || newId('note');
|
|
1543
|
+
const version = { version: (previous?.versions.length ?? 0) + 1, at: now(), title, preview: said, body, ask: ask as Note['versions'][number]['ask'], judgementRecordId: judgementRecordId, reason: p.reason };
|
|
1544
|
+
const note: Note = previous
|
|
1545
|
+
? { ...previous, versions: [...previous.versions, version], status: 'Current', ...(looked ? { looked } : {}), updatedAt: now() }
|
|
1546
|
+
: { ...(looked ? { looked } : {}), id, projectId: project.id, mount: { kind: text(p.mountKind) as Note['mount']['kind'], ids: mountIds }, status: 'Current', ownerResponse: null, versions: [version], discussion: [], followUps: [], author: { agent: 'pi', model: ctx.model }, resolvedReason: null, withdrawnReason: null, delegatedTo: null, cameFrom: cameFromFor(store, ctx.jobId ? store.jobs.get(ctx.jobId) : undefined, mountIds, { given: arr<string>(p.changeIds) }), language: text(p.language, project.language), updatedAt: now() };
|
|
1547
|
+
store.notes.put(note, trace(previous ? `Note v${version.version}: ${title}` : `Note: ${title}`, body.facts.flatMap((f) => f.sourceIds)));
|
|
1548
|
+
const j = store.judgements.get(judgementRecordId)!;
|
|
1549
|
+
store.judgements.put({ ...j, outcome: { ...j.outcome, noteIds: [...new Set([...j.outcome.noteIds, id])] } });
|
|
1550
|
+
// The anomalies it is about carry the note, so Code jumps from each to it (§6.17; QC AY B6: noteIds were never written).
|
|
1551
|
+
for (const territoryId of [...new Set(anomalies.map((a) => a.territoryId))]) {
|
|
1552
|
+
const t = store.territories.get(territoryId)!;
|
|
1553
|
+
const indexes = new Set(anomalies.filter((a) => a.territoryId === territoryId).map((a) => a.index));
|
|
1554
|
+
if ([...indexes].every((i) => t.anomalies[i]!.noteIds.includes(id))) continue;
|
|
1555
|
+
store.territories.put({ ...t, anomalies: t.anomalies.map((a, i) => (indexes.has(i) && !a.noteIds.includes(id) ? { ...a, noteIds: [...a.noteIds, id] } : a)), updatedAt: now() }, trace(`Note “${title}” on ${t.name}: ${[...indexes].map((i) => t.anomalies[i]!.kind).join(', ')}`));
|
|
1556
|
+
saved('territories', t.id, `Note on ${t.name}`);
|
|
1557
|
+
}
|
|
1558
|
+
saved('notes', id, `Note: ${title}`);
|
|
1559
|
+
return ok({
|
|
1560
|
+
id, version: version.version, ...(anomalies.length ? { codeAnomalies: anomalies.length } : {}),
|
|
1561
|
+
...(looked && (claims.length || !absent(p.looked)) ? { looked: lookedLine(looked) } : {}),
|
|
1562
|
+
...(looked?.behind && said.startsWith(`[${AS_FAR_AS}`) ? { asFarAs: `Your look does not reach the present of the ledger (${looked.behind}), so the note opens with “${AS_FAR_AS} — …”. Word the claim as “as far as … read”, not as a bare “no follow-up”; read the rest and give looked again to take the opening off.` } : {}),
|
|
1563
|
+
});
|
|
1564
|
+
},
|
|
1565
|
+
}),
|
|
1566
|
+
defineTool({
|
|
1567
|
+
name: 'pk_record_judgement', label: 'Record judgement inputs',
|
|
1568
|
+
description: 'Record what this product re-look received as input (reference items, threads, areas, relations, key evidence sources, conflicting sources, previous notes, investigation conclusions) and what was deliberately excluded. Call it once before writing notes or assessments; returns the judgement record id.',
|
|
1569
|
+
parameters: Type.Object({ scopeKind: Type.String(), scopeIds: StrArr, scopeLabel: Type.String(), referenceIds: StrArr, threadIds: StrArr, areaIds: StrArr, relationIds: StrArr, keyEvidenceSourceIds: StrArr, conflictingSourceIds: StrArr, previousNoteIds: StrArr, investigations: Type.Optional(Type.Array(Type.Object({ jobId: Type.String(), conclusion: Type.String(), sourceIds: StrArr }))), excluded: StrArr, reconsideredOnly: Type.Optional(Type.Boolean()) }),
|
|
1570
|
+
execute: async (_id, p) => {
|
|
1571
|
+
const id = newId('jdg');
|
|
1572
|
+
store.judgements.put({ id, projectId: project.id, jobId: ctx.jobId, at: now(), scope: { kind: p.scopeKind, ids: arr<string>(p.scopeIds), label: p.scopeLabel }, inputs: { referenceIds: arr(p.referenceIds), threadIds: arr(p.threadIds), areaIds: arr(p.areaIds), relationIds: arr(p.relationIds), keyEvidenceSourceIds: arr(p.keyEvidenceSourceIds), conflictingSourceIds: arr(p.conflictingSourceIds), previousNoteIds: arr(p.previousNoteIds), investigations: arr<Record<string, unknown>>(p.investigations).map((i) => ({ jobId: text(i.jobId), conclusion: text(i.conclusion), sourceIds: arr<string>(i.sourceIds) })) }, excluded: arr(p.excluded), outcome: { noteIds: [], assessments: [], reconsideredOnly: p.reconsideredOnly === true } }, trace(`Judgement inputs recorded for ${p.scopeLabel}`));
|
|
1573
|
+
saved('judgements', id, `Judgement record: ${p.scopeLabel}`);
|
|
1574
|
+
return ok({ id });
|
|
1575
|
+
},
|
|
1576
|
+
}),
|
|
1577
|
+
defineTool({
|
|
1578
|
+
name: 'pk_assess_relation', label: 'Assess relation',
|
|
1579
|
+
description: 'Set the Keeper assessment of a relation after a product re-look: Holds, Questioned (needs a note explaining why) or Not assessed. Requires the judgement record id.',
|
|
1580
|
+
parameters: Type.Object({ relationId: Type.String(), assessment: Type.String(), judgementRecordId: Type.Optional(Type.String({ description: 'defaults to this re-look’s judgement record' })) }),
|
|
1581
|
+
execute: async (_id, p) => {
|
|
1582
|
+
const r = store.relations.get(p.relationId);
|
|
1583
|
+
if (!r) return fail(`unknown relation ${p.relationId}`);
|
|
1584
|
+
if (!isOneOf(ASSESSMENT, p.assessment)) return fail(`assessment must be one of ${ASSESSMENT.join(', ')}`);
|
|
1585
|
+
const j = store.judgements.get(p.judgementRecordId || ctx.judgementId || '');
|
|
1586
|
+
if (!j) return fail('judgementRecordId must exist');
|
|
1587
|
+
store.relations.put({ ...r, assessment: p.assessment, assessedAt: now(), assessedInJobId: ctx.jobId, updatedAt: now() }, trace(`Assessed ${p.assessment}: ${r.type} ${r.from} → ${r.to}`, r.evidence.sourceIds));
|
|
1588
|
+
store.judgements.put({ ...j, outcome: { ...j.outcome, assessments: [...j.outcome.assessments.filter((a) => a.relationId !== r.id), { relationId: r.id, assessment: p.assessment }] } });
|
|
1589
|
+
return ok({ id: r.id, assessment: p.assessment });
|
|
1590
|
+
},
|
|
1591
|
+
}),
|
|
1592
|
+
];
|
|
1593
|
+
|
|
1594
|
+
tools.push(defineTool({
|
|
1595
|
+
name: 'pk_begin_request', label: 'Take up the owner’s request',
|
|
1596
|
+
description: 'Call this when, in the conversation, the owner explicitly asks you to change project content or to do a specific piece of work (a review, a revision, a hand-over). It records the delegation with the owner’s own words as its basis and switches this work to "Your request"; from then on you may act within that scope without asking per step. Do not call it for questions, opinions or suggestions.',
|
|
1597
|
+
parameters: Type.Object({ scope: Type.String({ description: 'what the request covers, in your words' }), quote: Type.String({ description: 'the owner’s words that make the request, or the authorization’s words' }), authorizationId: Type.Optional(Type.String({ description: 'a standing authorization id when acting under one instead of a request in this conversation' })) }),
|
|
1598
|
+
execute: async (_id, p) => {
|
|
1599
|
+
if (!ctx.beginRequest) return fail('This job cannot take a request.');
|
|
1600
|
+
const authorizationId = text(p.authorizationId) || null;
|
|
1601
|
+
if (authorizationId) {
|
|
1602
|
+
const a = store.authorizations.get(authorizationId);
|
|
1603
|
+
if (!a || a.revokedAt) return fail('That standing authorization does not exist or was revoked.');
|
|
1604
|
+
}
|
|
1605
|
+
const label = text(p.scope).slice(0, 120);
|
|
1606
|
+
const outcome = ctx.beginRequest({ label, quote: text(p.quote), scope: text(p.scope), authorizationId });
|
|
1607
|
+
if (outcome.startsWith('ERROR:')) return fail(outcome.slice(6).trim());
|
|
1608
|
+
return ok({ ok: true, scope: label, note: outcome });
|
|
1609
|
+
},
|
|
1610
|
+
}));
|
|
1611
|
+
|
|
1612
|
+
tools.push(defineTool({
|
|
1613
|
+
name: 'pk_record_authorization', label: 'Record a standing authorization',
|
|
1614
|
+
description: 'When the owner gives a standing authorization in the conversation (for example "when a document reference is stale, fix it directly"), record it: the scope in your words, the owner’s words, and the message as its source. It is listed in Project scope and the owner can revoke it. Later, act under it only within its scope, through pk_begin_request with its id, and say which authorization you relied on.',
|
|
1615
|
+
parameters: Type.Object({ scope: Type.String(), quote: Type.String() }),
|
|
1616
|
+
execute: async (_id, p) => {
|
|
1617
|
+
if (!ctx.ownerSourceId) return fail('Only the owner’s message in the conversation can give an authorization.');
|
|
1618
|
+
const id = newId('auth');
|
|
1619
|
+
store.authorizations.put({ id, projectId: project.id, scope: text(p.scope), sourceId: ctx.ownerSourceId, quote: text(p.quote), at: now(), revokedAt: null }, trace(`Standing authorization: ${text(p.scope).slice(0, 100)}`, [ctx.ownerSourceId]));
|
|
1620
|
+
saved('authorizations', id, `Authorization: ${text(p.scope).slice(0, 60)}`);
|
|
1621
|
+
return ok({ id, scope: text(p.scope) });
|
|
1622
|
+
},
|
|
1623
|
+
}));
|
|
1624
|
+
|
|
1625
|
+
tools.push(defineTool({
|
|
1626
|
+
name: 'pk_write_investigation_result', label: 'Record an investigation result',
|
|
1627
|
+
description: 'Record the result of an investigation into what needs adjusting: what to adjust, why, and the affected items with sources. When the matter already has a decision: the description now in force, the materials still using the old wording, the references to update. When the direction is undecided: discussable options (each with effects, the work and results that would change, cost, what can be reused); options are proposals, never decisions.',
|
|
1628
|
+
parameters: Type.Object({
|
|
1629
|
+
noteId: Type.Optional(Type.String()), decided: Type.Boolean(), adjust: Type.String(), why: Type.String(),
|
|
1630
|
+
affected: Type.Array(Type.Object({ id: Type.String({ description: 'asset id (reference item, thread, relation, source)' }), reason: Type.String(), sourceIds: StrArr })),
|
|
1631
|
+
currentDescription: Type.Optional(Type.String()), oldWording: Type.Optional(Type.Array(Type.Object({ sourceId: Type.String(), wording: Type.String() }))), referencesToUpdate: Type.Optional(StrArr),
|
|
1632
|
+
options: Type.Optional(Type.Array(Type.Object({ title: Type.String(), effects: Type.String(), workToChange: Type.String(), cost: Type.String(), reusable: Type.String() }))),
|
|
1633
|
+
}),
|
|
1634
|
+
execute: async (_id, p) => {
|
|
1635
|
+
const result = { decided: p.decided === true, adjust: text(p.adjust), why: text(p.why), affected: arr<Record<string, unknown>>(p.affected).map((a) => ({ id: text(a.id), reason: text(a.reason), sourceIds: arr<string>(a.sourceIds) })), currentDescription: text(p.currentDescription) || null, oldWording: arr<Record<string, unknown>>(p.oldWording).map((o) => ({ sourceId: text(o.sourceId), wording: text(o.wording) })), referencesToUpdate: arr<string>(p.referencesToUpdate), options: arr<Record<string, unknown>>(p.options).map((o) => ({ title: text(o.title), effects: text(o.effects), workToChange: text(o.workToChange), cost: text(o.cost), reusable: text(o.reusable) })), at: now(), jobId: ctx.jobId };
|
|
1636
|
+
ctx.onInvestigationResult?.(result);
|
|
1637
|
+
const noteId = text(p.noteId);
|
|
1638
|
+
if (noteId && store.notes.has(noteId)) {
|
|
1639
|
+
const n = store.notes.get(noteId)!;
|
|
1640
|
+
store.notes.put({ ...n, followUps: [...n.followUps, { kind: 'investigation', jobId: ctx.jobId, at: now(), summary: result.adjust.slice(0, 240) }], updatedAt: now() }, trace(`Investigation on note: ${result.adjust.slice(0, 100)}`, result.affected.flatMap((a) => a.sourceIds)));
|
|
1641
|
+
}
|
|
1642
|
+
saved('investigations', ctx.jobId, `Investigation: ${result.adjust.slice(0, 60)}`);
|
|
1643
|
+
return ok({ recorded: true, affected: result.affected.length });
|
|
1644
|
+
},
|
|
1645
|
+
}));
|
|
1646
|
+
|
|
1647
|
+
tools.push(defineTool({
|
|
1648
|
+
name: 'pk_write_modification_request', label: 'Prepare a modification request for a role holder',
|
|
1649
|
+
description: 'In a project with roles, a change to the product meaning of an artifact belongs to its holder. Prepare the request: what to change, why, the basis (source ids), the affected items; it reaches the holder in their next context (Notes for you). A Follow up round’s main job gives one holder one request per round, however many of their objects are behind: a second call in the same round adds a line to the one request. A request written anywhere else — in the owner’s conversation, for instance — is a request of its own and belongs to no round. This does not write into the project; that needs the owner’s request or a standing authorization.',
|
|
1650
|
+
parameters: Type.Object({ holderRole: Type.String(), what: Type.String(), why: Type.String(), basisSourceIds: StrArr, impact: StrArr, noteId: Type.Optional(Type.String()), changeIds: Type.Optional(StrArr) }),
|
|
1651
|
+
execute: async (_id, p) => {
|
|
1652
|
+
const noteId = text(p.noteId) || null;
|
|
1653
|
+
const holder = text(p.holderRole);
|
|
1654
|
+
const impact = arr<string>(p.impact);
|
|
1655
|
+
// A round's requests are part of its one result, and the result is its main job's (§5.4, §5.5; CKC-11 AC-25): only
|
|
1656
|
+
// a Follow up round's main job files a request under the round and adds to the round's request to that holder. A
|
|
1657
|
+
// request written while taking the project over (D59 rule 1), or by any other job the store knows — the owner's
|
|
1658
|
+
// conversation, an answer, a re-look — is a request of its own, under no round, and reaches the holder the same
|
|
1659
|
+
// way. Filing it under whichever round was open made the owner's own delegation part of that round's result.
|
|
1660
|
+
const job = store.jobs.get(ctx.jobId);
|
|
1661
|
+
const clerkRecordId = clerkFollowUpRecordOf(store, job);
|
|
1662
|
+
const round = isTakeoverWork(store, ctx.jobId) || (job && !judgesFollowUp(store, job)) ? null : (clerkRecordId ? store.rounds.get(clerkRecordId) ?? null : openRound(store));
|
|
1663
|
+
const existing = requestForHolder(store, holder, round?.id ?? null);
|
|
1664
|
+
const lines = [
|
|
1665
|
+
...(existing?.lines ?? []),
|
|
1666
|
+
...(impact.length ? impact : ['']).map((nodeId) => ({ nodeId, what: text(p.what), changeIds: arr<string>(p.changeIds) })),
|
|
1667
|
+
];
|
|
1668
|
+
const id = existing?.id ?? newId('req');
|
|
1669
|
+
store.requests.put({
|
|
1670
|
+
id, projectId: project.id, holder,
|
|
1671
|
+
what: existing ? `${existing.what}\n- ${text(p.what)}` : text(p.what),
|
|
1672
|
+
why: existing && existing.why !== text(p.why) ? `${existing.why}\n- ${text(p.why)}` : text(p.why),
|
|
1673
|
+
basisSourceIds: [...new Set([...(existing?.basisSourceIds ?? []), ...arr<string>(p.basisSourceIds)])],
|
|
1674
|
+
impact: [...new Set([...(existing?.impact ?? []), ...impact])],
|
|
1675
|
+
noteId: noteId ?? existing?.noteId ?? null, at: existing?.at ?? now(), handled: null,
|
|
1676
|
+
roundId: round?.id ?? null, lines,
|
|
1677
|
+
}, trace(`Modification request to ${holder}: ${text(p.what).slice(0, 100)}`, arr<string>(p.basisSourceIds)));
|
|
1678
|
+
if (noteId && store.notes.has(noteId)) {
|
|
1679
|
+
const n = store.notes.get(noteId)!;
|
|
1680
|
+
store.notes.put({ ...n, delegatedTo: { holder, requestId: id, handledAt: null }, followUps: [...n.followUps, { kind: 'request', jobId: ctx.jobId, at: now(), summary: `Request to ${holder}: ${text(p.what).slice(0, 200)}` }], updatedAt: now() });
|
|
1681
|
+
}
|
|
1682
|
+
saved('requests', id, `Request to ${holder}`);
|
|
1683
|
+
return ok(existing
|
|
1684
|
+
? { id, merged: true, lines: lines.length, note: `${holder} already has this round’s request; this was added to it as one more line, so the holder gets one list, not many.` }
|
|
1685
|
+
: { id, lines: lines.length });
|
|
1686
|
+
},
|
|
1687
|
+
}));
|
|
1688
|
+
|
|
1689
|
+
tools.push(defineTool({
|
|
1690
|
+
name: 'pk_check_decisions', label: 'Check what a new decision replaces',
|
|
1691
|
+
description: 'A decision is not judged for propagation; what it needs is the replacement check. In the round a new decision appears — and for the historical decisions read during a takeover — go down the timeline of decisions and say, for each current decision it touches: which part it supersedes (supersedes; part: which part, stated: true when the new decision says so, false when it only gives different content for the same matter, whole: true only when nothing of the old decision is left in force), or that you cannot tell whether it supersedes it (unclear, with the clue). A part being superseded is recorded on the old decision and in a replaces relation, and the old decision stays Current for the rest of it; a whole replacement makes it Replaced pointing at the new decision. What is unclear gets Suspected stale and keeps its validity. Only current decisions are checked, and only against decisions older than the new one. Objects still working to a superseded decision are judged as usual.',
|
|
1692
|
+
parameters: Type.Object({
|
|
1693
|
+
newDecisionId: Type.String(),
|
|
1694
|
+
supersedes: Type.Optional(Type.Array(Type.Object({
|
|
1695
|
+
decisionId: Type.String(), part: Type.String({ description: 'which part of the old decision this one replaces' }),
|
|
1696
|
+
stated: Type.Boolean({ description: 'does the new decision say so itself?' }),
|
|
1697
|
+
whole: Type.Optional(Type.Boolean({ description: 'true only when the new decision leaves nothing of the old one in force; otherwise the rest of the old decision stays Current and only the named part is recorded as superseded' })),
|
|
1698
|
+
}))),
|
|
1699
|
+
unclear: Type.Optional(Type.Array(Type.Object({ decisionId: Type.String(), clue: Type.String(), clueSourceIds: StrArr }))),
|
|
1700
|
+
}),
|
|
1701
|
+
execute: async (_id, p) => {
|
|
1702
|
+
const fresh = store.reference.get(text(p.newDecisionId));
|
|
1703
|
+
if (!fresh) return fail(`unknown decision ${text(p.newDecisionId)}`);
|
|
1704
|
+
if (!isDecision(store, fresh.id)) return fail(`${fresh.id} is a ${fresh.category}, not a decision; the replacement check runs over the timeline of decisions`);
|
|
1705
|
+
// The mirror of the gate on the old decision below: what is not in force supersedes nothing either. A
|
|
1706
|
+
// proposal is a candidate, and letting a candidate replace current decisions would put the assets ahead of
|
|
1707
|
+
// the project — objects would be judged behind against content nobody has adopted.
|
|
1708
|
+
if (fresh.validity !== 'Current') {
|
|
1709
|
+
const advice = fresh.validity === 'Proposed'
|
|
1710
|
+
? 'A proposal is a candidate, not a decision in force: record its adoption first (the basis is the owner’s approval or the project’s own record of adoption), then check what it supersedes.'
|
|
1711
|
+
: `Only a Current decision supersedes another. Run the check from the decision in force on this matter, not from a ${fresh.validity} one.`;
|
|
1712
|
+
return fail(`${fresh.id} (${fresh.name}) is ${fresh.validity}, so nothing follows it yet and it supersedes nothing. ${advice}`);
|
|
1713
|
+
}
|
|
1714
|
+
// §5.5 gives the two outcomes as alternatives — what is superseded is marked, what cannot be told gets
|
|
1715
|
+
// Suspected stale — so the same decision in both lists is a call that contradicts itself, and it is refused
|
|
1716
|
+
// before anything is written rather than half-applied.
|
|
1717
|
+
const unclearIds = arr<Record<string, unknown>>(p.unclear).map((u) => text(u.decisionId));
|
|
1718
|
+
const inBoth = [...new Set(arr<Record<string, unknown>>(p.supersedes).map((s) => text(s.decisionId)).filter((id) => id && unclearIds.includes(id)))];
|
|
1719
|
+
if (inBoth.length) {
|
|
1720
|
+
return fail(`${inBoth.join(', ')} ${inBoth.length === 1 ? 'is' : 'are'} listed both as superseded and as unclear, and one decision is one or the other: what the new decision takes over is marked and points at it, what you cannot tell about keeps its validity and carries the clue. Decide which this is — name the part in supersedes, or move it to unclear alone — and call again.`);
|
|
1721
|
+
}
|
|
1722
|
+
const replaced: string[] = [];
|
|
1723
|
+
const partlySuperseded: string[] = [];
|
|
1724
|
+
const suspected: string[] = [];
|
|
1725
|
+
const problems: string[] = [];
|
|
1726
|
+
const undated: string[] = [];
|
|
1727
|
+
const freshTime = decisionTime(store, fresh);
|
|
1728
|
+
for (const s of arr<Record<string, unknown>>(p.supersedes)) {
|
|
1729
|
+
const old = store.reference.get(text(s.decisionId));
|
|
1730
|
+
if (!old || !isDecision(store, old.id)) { problems.push(`${text(s.decisionId)} is not a decision in the assets`); continue; }
|
|
1731
|
+
if (old.id === fresh.id) { problems.push('a decision does not replace itself'); continue; }
|
|
1732
|
+
if (!text(s.part)) { problems.push(`${old.id}: say which part of it the new decision replaces`); continue; }
|
|
1733
|
+
// §5.5 checks the new decision against the decisions that are in force. A proposal never was one, and a
|
|
1734
|
+
// decision already replaced or abandoned is a point-in-time record (§2.10): writing over either would
|
|
1735
|
+
// invent a supersession of something nobody was following.
|
|
1736
|
+
if (old.validity !== 'Current') { problems.push(notCurrentRefusal(old)); continue; }
|
|
1737
|
+
// And the new decision has to be the later one: without this the tool wrote the timeline backwards,
|
|
1738
|
+
// marking a decision superseded by one that came before it and pointing `replacedBy` the wrong way.
|
|
1739
|
+
const oldTime = decisionTime(store, old);
|
|
1740
|
+
if (oldTime && freshTime && oldTime.at > freshTime.at) {
|
|
1741
|
+
problems.push(`${old.id} (${old.name}) is not older than ${fresh.name}: the assets date it ${oldTime.at}, from ${oldTime.field}, and date ${fresh.id} ${freshTime.at}, from ${freshTime.field}. A decision is superseded only by a later one (the check goes down the timeline of decisions), so run this check from ${old.id} over ${fresh.id} instead — or, if the dates are wrong, correct the change record that files each decision under the time its own material gives.`);
|
|
1742
|
+
continue;
|
|
1743
|
+
}
|
|
1744
|
+
if (!oldTime || !freshTime) {
|
|
1745
|
+
undated.push(`Nothing in the assets dates ${!oldTime ? old.id : fresh.id}, so which of ${old.id} and ${fresh.id} came first could not be checked. Record the piece of work that made it (pk_write_change, with the time its material gives) to make the timeline checkable.`);
|
|
1746
|
+
}
|
|
1747
|
+
// The basis is the program's to set: Explicit when the new decision names what it replaces, Inferred when
|
|
1748
|
+
// it only gives different content for the same matter. The owner can correct it.
|
|
1749
|
+
const basis = s.stated === true ? 'Explicit' as const : 'Inferred' as const;
|
|
1750
|
+
const part = text(s.part);
|
|
1751
|
+
const whole = s.whole === true;
|
|
1752
|
+
if (whole) {
|
|
1753
|
+
store.reference.put({ ...old, validity: 'Replaced', replacedBy: fresh.id, updatedAt: now() }, trace(`Replaced by ${fresh.name}: ${part}`, fresh.sourceIds));
|
|
1754
|
+
replaced.push(old.id);
|
|
1755
|
+
} else {
|
|
1756
|
+
// §5.5: the superseded part is marked and the rest stays as it was. Flipping the whole decision to
|
|
1757
|
+
// `Replaced` for one part of it took the other parts out of `Current` too, so work that still had to
|
|
1758
|
+
// follow them was left following a decision the assets said no longer applied.
|
|
1759
|
+
const already = (old.supersededParts ?? []).filter((x) => !(x.byId === fresh.id && x.part === part));
|
|
1760
|
+
store.reference.put({ ...old, supersededParts: [...already, { part, byId: fresh.id, basis, at: now() }], updatedAt: now() }, trace(`Part superseded by ${fresh.name}: ${part}`, fresh.sourceIds));
|
|
1761
|
+
partlySuperseded.push(old.id);
|
|
1762
|
+
}
|
|
1763
|
+
const relId = stableId('rel', 'replaces', fresh.id, old.id);
|
|
1764
|
+
store.relations.put({
|
|
1765
|
+
id: relId, projectId: project.id, type: 'replaces', from: fresh.id, to: old.id,
|
|
1766
|
+
claim: whole ? `${fresh.name} replaces ${old.name} as a whole: ${part}` : `${fresh.name} supersedes part of ${old.name}: ${part}; the rest of ${old.name} stays in force`, basis,
|
|
1767
|
+
evidence: { sourceIds: fresh.sourceIds, factRecordIds: [], factsSoFar: '' },
|
|
1768
|
+
assessment: store.relations.get(relId)?.assessment ?? 'Not assessed', assessedAt: null, assessedInJobId: null, updatedAt: now(),
|
|
1769
|
+
}, trace(`replaces: ${fresh.id} → ${old.id}`, fresh.sourceIds));
|
|
1770
|
+
saved('reference', old.id, whole ? `Replaced: ${old.name}` : `Part superseded: ${old.name}`);
|
|
1771
|
+
}
|
|
1772
|
+
for (const u of arr<Record<string, unknown>>(p.unclear)) {
|
|
1773
|
+
const old = store.reference.get(text(u.decisionId));
|
|
1774
|
+
if (!old || !isDecision(store, old.id)) { problems.push(`${text(u.decisionId)} is not a decision in the assets`); continue; }
|
|
1775
|
+
if (old.validity !== 'Current') { problems.push(notCurrentRefusal(old)); continue; }
|
|
1776
|
+
const clueSourceIds = arr<string>(u.clueSourceIds).length ? arr<string>(u.clueSourceIds) : fresh.sourceIds;
|
|
1777
|
+
const refusal = markRefusal(store, 'Suspected stale', old.id, text(u.clue), clueSourceIds, 'At its own time');
|
|
1778
|
+
if (refusal) { problems.push(`${old.id}: ${refusal}`); continue; }
|
|
1779
|
+
const markId = stableId('mark', 'Suspected stale', old.id);
|
|
1780
|
+
const previous = store.marks.get(markId);
|
|
1781
|
+
store.marks.put({ id: markId, projectId: project.id, kind: 'Suspected stale', targetId: old.id, clueSourceIds, clue: text(u.clue), since: previous?.since ?? now(), noteId: previous?.noteId ?? null, closed: null }, trace(`Suspected stale on ${old.id}: may be superseded by ${fresh.name}`, clueSourceIds));
|
|
1782
|
+
saved('marks', markId, 'Mark: Suspected stale');
|
|
1783
|
+
suspected.push(old.id);
|
|
1784
|
+
}
|
|
1785
|
+
// A call every entry of which was refused wrote nothing, and an `ok` with an empty list reads as a check
|
|
1786
|
+
// that found nothing to do; the caller has to see that the check did not happen.
|
|
1787
|
+
if (problems.length && replaced.length + partlySuperseded.length + suspected.length === 0) return fail(problems.join(' '));
|
|
1788
|
+
return ok({ newDecisionId: fresh.id, replaced, partlySuperseded, suspected, problems, undated });
|
|
1789
|
+
},
|
|
1790
|
+
}));
|
|
1791
|
+
|
|
1792
|
+
tools.push(defineTool({
|
|
1793
|
+
name: 'pk_write_round_result', label: 'Write this round’s one result',
|
|
1794
|
+
description: 'One Follow up round produces one result. Write its account in your own words: which objects are behind and what they lack, which upstreams are stale, which dependencies were not taken into account, which decisions the new ones replaced. Every number in the result is counted from the assets by the program — do not write totals of your own. Objects with a holder are reached through pk_write_modification_request, and only what the owner must decide or is worth their knowing becomes a note.',
|
|
1795
|
+
parameters: Type.Object({ summary: Type.String({ description: 'the round’s account, without totals' }), unassigned: Type.Optional(Type.Array(Type.String(), { description: 'objects behind for which no holder could be identified' })) }),
|
|
1796
|
+
execute: async (_id, p) => {
|
|
1797
|
+
const round = roundOf();
|
|
1798
|
+
if (typeof round === 'string') return fail(round);
|
|
1799
|
+
// The round's result is read by the owner: no internal identifiers in it (§6.13, D105; CKC-08 AC-27).
|
|
1800
|
+
const unreadable = ownerTextRefusal('The round’s result', { summary: text(p.summary) }, laneNamesOf(store));
|
|
1801
|
+
if (unreadable) return fail(unreadable);
|
|
1802
|
+
const result = roundResultOf(store, round.id, text(p.summary), now());
|
|
1803
|
+
store.rounds.put({ ...round, endedAt: now(), result }, trace(`Follow up round ${round.number}: ${result.counts.objectsJudged} objects judged`));
|
|
1804
|
+
saved('rounds', round.id, `Follow up round ${round.number}`);
|
|
1805
|
+
return ok({ round: round.id, ...result.counts });
|
|
1806
|
+
},
|
|
1807
|
+
}));
|
|
1808
|
+
|
|
1809
|
+
tools.push(defineTool({
|
|
1810
|
+
name: 'pk_mark_request_handled', label: 'Mark a modification request handled',
|
|
1811
|
+
description: 'Later material shows the holder handled a modification request: record it with the evidence sources.',
|
|
1812
|
+
parameters: Type.Object({ requestId: Type.String(), evidenceSourceIds: StrArr }),
|
|
1813
|
+
execute: async (_id, p) => {
|
|
1814
|
+
const r = store.requests.get(p.requestId);
|
|
1815
|
+
if (!r) return fail(`unknown request ${p.requestId}`);
|
|
1816
|
+
store.requests.put({ ...r, handled: { at: now(), evidenceSourceIds: arr<string>(p.evidenceSourceIds) } }, trace(`Request handled by ${r.holder}`, arr<string>(p.evidenceSourceIds)));
|
|
1817
|
+
if (r.noteId && store.notes.has(r.noteId)) { const n = store.notes.get(r.noteId)!; if (n.delegatedTo?.requestId === r.id) store.notes.put({ ...n, delegatedTo: { ...n.delegatedTo, handledAt: now() }, updatedAt: now() }); }
|
|
1818
|
+
return ok({ id: r.id, handled: true });
|
|
1819
|
+
},
|
|
1820
|
+
}));
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* The round this job writes into; the first judgement of a job opens one when none is open (§5.5). Work that takes
|
|
1824
|
+
* the project over never opens or joins a Follow up round: propagation is judged in the Follow up after the
|
|
1825
|
+
* takeover (D59 rule 1). An unregistered job is refused too; it cannot acquire a round merely by presenting an id.
|
|
1826
|
+
* Nor does any other registered job that is not a Follow up round's main job — the owner's conversation, an answer,
|
|
1827
|
+
* a re-look, a subagent: a round's judgements and its one result are its main job's (§3.3, §5.5; CKC-11 AC-25), and
|
|
1828
|
+
* roles.ts keeps these tools from such a job in the first place. The reason comes back as a string the caller refuses.
|
|
1829
|
+
*/
|
|
1830
|
+
const roundOf = (): FollowUpRound | string => {
|
|
1831
|
+
if (isTakeoverWork(store, ctx.jobId)) return TAKEOVER_NO_ROUND;
|
|
1832
|
+
const job = store.jobs.get(ctx.jobId);
|
|
1833
|
+
if (!job) return UNKNOWN_ROUND_JOB(ctx.jobId);
|
|
1834
|
+
// By the clerk method a Follow up round opened its record when it began, and its cross-check judges into that one.
|
|
1835
|
+
const clerkRecordId = clerkFollowUpRecordOf(store, job);
|
|
1836
|
+
const clerkRecord = clerkRecordId ? store.rounds.get(clerkRecordId) : undefined;
|
|
1837
|
+
if (clerkRecord) return clerkRecord;
|
|
1838
|
+
if (roundKindOf(job) !== 'follow-up') return NOT_THE_ROUNDS_MAIN_JOB;
|
|
1839
|
+
const named = text(ctx.roundId) ? store.rounds.get(text(ctx.roundId)) : undefined;
|
|
1840
|
+
const open = named ?? openRound(store);
|
|
1841
|
+
if (open) return open;
|
|
1842
|
+
// Nor does anything else open one while the project is still being taken over (§5.5): a conversation that judged
|
|
1843
|
+
// an object during the takeover used to open a Follow up round the takeover then ran beside (C1's hand-back item 3).
|
|
1844
|
+
const stage = store.coverage.takeover?.stage;
|
|
1845
|
+
if (stage && stage !== 'Daily') return TAKEOVER_UNDERWAY(stage);
|
|
1846
|
+
return startRound(store, project.id, ctx.jobId);
|
|
1847
|
+
};
|
|
1848
|
+
/** Why this object is not judged at all, as the caller is told it. */
|
|
1849
|
+
const notJudgedRefusal = (nodeId: string): string | null => {
|
|
1850
|
+
if (!objectExists(store, nodeId)) return `unknown object ${nodeId}; use the ids the pk_write_* tools returned`;
|
|
1851
|
+
const reason = notJudgedReason(store, nodeId);
|
|
1852
|
+
return reason ? `${nodeId} is not judged: ${NOT_JUDGED_MESSAGE[reason]}` : null;
|
|
1853
|
+
};
|
|
1854
|
+
const itemRefs = (raw: unknown): ItemRef[] => arr<Record<string, unknown>>(raw).map((r) => ({ changeId: text(r.changeId), itemId: text(r.itemId) }));
|
|
1855
|
+
|
|
1856
|
+
tools.push(defineTool({
|
|
1857
|
+
name: 'pk_judge_object', label: 'Judge one downstream object',
|
|
1858
|
+
description: 'One object, one state, one round. Judge the object against every item of every change that reached it, together with the items on it that have no conclusion yet, and give one state: Updated (it follows the new content, give the source), Still on old understanding (it is still written, done or verified against what at least one item replaced — name which items it followed and which it lacks, and what each lacks), Reusable as is (the items do not touch it, or it holds either way, give the reason), Not yet checked (say what is missing). Point-in-time records and decisions are never judged. An item ends only when the object follows it, a later change supersedes it, or the owner or holder says it need not be handled — say which in closed, always with the reason, and with the superseding change id or with who said so and where they said it. Every item you name must be one of the items that reached this object.',
|
|
1859
|
+
parameters: Type.Object({
|
|
1860
|
+
nodeId: Type.String(), state: Type.String(), sourceOrReason: Type.String({ description: 'the source that shows it, or the reason' }),
|
|
1861
|
+
followed: Type.Optional(Type.Array(Type.Object({ changeId: Type.String(), itemId: Type.Optional(Type.String()) }), { description: 'the items the object has followed' })),
|
|
1862
|
+
lacks: Type.Optional(Type.Array(Type.Object({ changeId: Type.String(), itemId: Type.Optional(Type.String()), what: Type.String({ description: 'what the object still has to follow' }) }), { description: 'the items it still lacks' })),
|
|
1863
|
+
closed: Type.Optional(Type.Array(Type.Object({
|
|
1864
|
+
changeId: Type.String(), itemId: Type.Optional(Type.String()), close: Type.String({ description: ITEM_CLOSE.join(' | ') }),
|
|
1865
|
+
reason: Type.String({ description: 'what ends this item, in one clause' }),
|
|
1866
|
+
supersededByChangeId: Type.Optional(Type.String({ description: 'Superseded by a later change: the id of the change record whose later content took this item’s place' })),
|
|
1867
|
+
saidBy: Type.Optional(Type.String({ description: 'No action needed: "owner", or the role of the holder who said it need not be handled' })),
|
|
1868
|
+
saidInSourceId: Type.Optional(Type.String({ description: 'No action needed: the source id of the message or material where they said it' })),
|
|
1869
|
+
}))),
|
|
1870
|
+
}),
|
|
1871
|
+
execute: async (_id, raw) => {
|
|
1872
|
+
// A work item merged into another is judged as the one kept (§1.4).
|
|
1873
|
+
const p = { ...raw, nodeId: resolveMergedId(store, raw.nodeId) };
|
|
1874
|
+
const refusal = notJudgedRefusal(p.nodeId);
|
|
1875
|
+
if (refusal) return fail(refusal);
|
|
1876
|
+
if (!isOneOf(PROPAGATION, p.state)) return fail(`state must be one of ${PROPAGATION.join(', ')}`);
|
|
1877
|
+
const lacks: LackedItem[] = arr<Record<string, unknown>>(p.lacks).map((r) => ({ changeId: text(r.changeId), itemId: text(r.itemId), what: text(r.what) }));
|
|
1878
|
+
if (p.state === 'Still on old understanding' && lacks.length === 0) {
|
|
1879
|
+
return fail('"Still on old understanding" that names nothing is never shown to an agent and cannot be checked. List in lacks the items the object has not followed, each with what it still has to follow, and in followed the ones it has.');
|
|
1880
|
+
}
|
|
1881
|
+
const empty = lacks.filter((l) => l.what.trim().length === 0);
|
|
1882
|
+
if (empty.length) return fail(`lacks[${lacks.indexOf(empty[0]!)}].what is empty: say what the object still has to follow, in one clause, so the agent reading it can check the same thing.`);
|
|
1883
|
+
if (!text(p.sourceOrReason)) return fail('sourceOrReason is required: the source that shows the object followed, or the reason it holds either way.');
|
|
1884
|
+
// §2.10 ends an item in exactly three ways, and each of the last two rests on something outside this
|
|
1885
|
+
// judgement: a later change, or the owner or holder saying it need not be handled. Written as prose alone
|
|
1886
|
+
// nobody could check either, and a model that mistook one of them closed the item for good.
|
|
1887
|
+
const closedIn: ClosedItem[] = [];
|
|
1888
|
+
for (const [i, r] of arr<Record<string, unknown>>(p.closed).entries()) {
|
|
1889
|
+
const close = r.close;
|
|
1890
|
+
if (!isOneOf(ITEM_CLOSE, close)) return fail(`closed[${i}].close must be one of ${ITEM_CLOSE.join(', ')}`);
|
|
1891
|
+
const reason = text(r.reason).trim();
|
|
1892
|
+
if (!reason) return fail(`closed[${i}].reason is empty: say in one clause what ends this item (an item stays with the object until the object follows it, a later change supersedes it, or the owner or holder says it need not be handled). A close nobody can check is never shown to an agent.`);
|
|
1893
|
+
const entry: ClosedItem = { changeId: text(r.changeId), itemId: text(r.itemId), close, reason };
|
|
1894
|
+
if (close === 'Superseded by a later change') {
|
|
1895
|
+
const byId = text(r.supersededByChangeId);
|
|
1896
|
+
if (!byId) return fail(`closed[${i}] ends an item because a later change superseded it, but does not say which change that is. Add supersededByChangeId: the id pk_write_change returned for the later piece of work.`);
|
|
1897
|
+
const by = store.changes.get(byId);
|
|
1898
|
+
if (!by) return fail(`closed[${i}].supersededByChangeId names no change record in the assets: ${byId}. Give the id of the record that holds the later content, so the next reader can look it up.`);
|
|
1899
|
+
const superseded = store.changes.get(entry.changeId);
|
|
1900
|
+
const wasAt = superseded ? itemTime(superseded, entry.itemId) : '';
|
|
1901
|
+
if (wasAt && !(lastItemTime(by) > wasAt)) {
|
|
1902
|
+
return fail(`closed[${i}] says ${byId} superseded this item, but ${byId}’s last item is at ${lastItemTime(by)} and the item it would supersede is at ${wasAt}: what did not come after it cannot supersede it. Name the change that really came later, or end the item another way — the object followed it, or the owner or holder says it need not be handled.`);
|
|
1903
|
+
}
|
|
1904
|
+
closedIn.push({ ...entry, supersededByChangeId: byId });
|
|
1905
|
+
continue;
|
|
1906
|
+
}
|
|
1907
|
+
if (close === 'No action needed') {
|
|
1908
|
+
const who = text(r.saidBy).trim();
|
|
1909
|
+
if (!who) return fail(`closed[${i}] ends an item because nobody has to handle it, but does not say who said so (only the owner or the object’s holder can). Add saidBy: "owner", or the holder’s role.`);
|
|
1910
|
+
const sourceId = text(r.saidInSourceId).trim();
|
|
1911
|
+
if (!store.sources.has(sourceId)) return fail(`closed[${i}] says ${who} decided this item need not be handled, but ${sourceId ? `saidInSourceId names no source in the assets: ${sourceId}` : 'names no material that shows it'}. Add saidInSourceId: the source id of the owner’s message, the holder’s reply, or the later material that says so — that an item need not be handled is read from the owner’s response or from the material, and a close that cannot be checked is never shown to an agent.`);
|
|
1912
|
+
const owner = who.toLowerCase() === 'owner';
|
|
1913
|
+
closedIn.push({ ...entry, saidBy: { who: owner ? 'owner' : 'holder', role: owner ? null : who, sourceId } });
|
|
1914
|
+
continue;
|
|
1915
|
+
}
|
|
1916
|
+
closedIn.push(entry);
|
|
1917
|
+
}
|
|
1918
|
+
const round = roundOf();
|
|
1919
|
+
if (typeof round === 'string') return fail(round);
|
|
1920
|
+
const { covers, carried } = itemsForObject(store, p.nodeId, round.id);
|
|
1921
|
+
// §2.10, §5.5: an item that has a conclusion is not judged again, even after the object changed; what the
|
|
1922
|
+
// object still lacks is judged again only once it has moved or something new has reached it. When nothing is
|
|
1923
|
+
// open, the state from the last round simply stands.
|
|
1924
|
+
if (covers.length === 0) {
|
|
1925
|
+
const previous = lastJudgement(store, p.nodeId, round.id);
|
|
1926
|
+
return ok({
|
|
1927
|
+
nodeId: p.nodeId, round: round.id, covers: 0, wrote: false,
|
|
1928
|
+
note: previous
|
|
1929
|
+
? `Nothing is open on ${p.nodeId}: what reached it has a conclusion from an earlier round, so round ${previous.roundId}’s judgement stands: ${previous.state}${carried.length ? `, still lacking ${carried.length} item${carried.length === 1 ? '' : 's'}, which ${carried.length === 1 ? 'is' : 'are'} judged again once the object moves or something new reaches it` : ''}. A concluded item is not judged again.`
|
|
1930
|
+
: `No change has reached ${p.nodeId}, so there is nothing to judge.`,
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
// What this round judges for this object: the items that reached it, and what the previous round left open
|
|
1934
|
+
// (§5.5). These are the only refs the judgement keeps, so a ref outside them is one that would be dropped
|
|
1935
|
+
// inside — and an item reference the program drops silently is a state nobody can check (§7.1).
|
|
1936
|
+
const reached: ItemRef[] = [...covers, ...carried.filter((l) => store.changes.has(l.changeId)).map((l) => ({ changeId: l.changeId, itemId: l.itemId }))];
|
|
1937
|
+
// An empty itemId means the whole record (§1.8: a record written before items existed is one item), so it
|
|
1938
|
+
// reaches the object whenever any item of that record did.
|
|
1939
|
+
const reaches = (r: ItemRef) => (r.itemId ? reached.some((c) => sameItem(c, r)) : reached.some((c) => c.changeId === r.changeId));
|
|
1940
|
+
const itemsHere = (changeId: string) => reached.filter((c) => c.changeId === changeId).map((c) => c.itemId || '(the whole record)');
|
|
1941
|
+
const named = [...itemRefs(p.followed), ...lacks, ...closedIn];
|
|
1942
|
+
// A real change id with an item id that never came near this object used to pass — the check asked only
|
|
1943
|
+
// whether the change existed — and was then dropped, leaving `Still on old understanding` with nothing in
|
|
1944
|
+
// `lacks`. It is refused here, with the item ids that did reach the object, so the caller can name one.
|
|
1945
|
+
const offItem = named.find((r) => !reaches(r) && reached.some((c) => c.changeId === r.changeId));
|
|
1946
|
+
if (offItem) {
|
|
1947
|
+
return fail(`${offItem.changeId} reached ${p.nodeId}, but “${offItem.itemId}” is not one of its items that did. The items of that record to judge here are: ${itemsHere(offItem.changeId).join(', ')}. Name one of those, or leave itemId out to mean the whole record (a judgement says which records and which of their items it covers).`);
|
|
1948
|
+
}
|
|
1949
|
+
const unknown = named.filter((r) => !reaches(r));
|
|
1950
|
+
if (unknown.length && unknown.length === named.length) return fail(`none of the changes named reached ${p.nodeId}: ${[...new Set(unknown.map((u) => u.changeId))].join(', ')}. The items to judge are listed with the object in the task.`);
|
|
1951
|
+
// `Still on old understanding` whose every lacked item is dropped would be exactly the state §7.1 refuses
|
|
1952
|
+
// above, reached the long way round: an object recorded as behind with nothing anyone can check it against.
|
|
1953
|
+
if (p.state === 'Still on old understanding' && !lacks.some(reaches)) {
|
|
1954
|
+
return fail(`None of the ${lacks.length} item${lacks.length === 1 ? '' : 's'} in lacks reached ${p.nodeId} this round, so "Still on old understanding" would be recorded with nothing to check it against. The items to judge here are: ${[...new Set(reached.map((c) => `${c.changeId}${c.itemId ? ` / ${c.itemId}` : ''}`))].join(', ')}. Name the ones the object has not followed, or give the state it is really in.`);
|
|
1955
|
+
}
|
|
1956
|
+
const judgement = judgeObject(store, project.id, round.id, {
|
|
1957
|
+
nodeId: p.nodeId, state: p.state, sourceOrReason: text(p.sourceOrReason),
|
|
1958
|
+
followed: itemRefs(p.followed), lacks, closed: closedIn,
|
|
1959
|
+
jobId: ctx.jobId,
|
|
1960
|
+
});
|
|
1961
|
+
saved('propagation', judgement.id, `${judgement.state}: ${p.nodeId}`);
|
|
1962
|
+
return ok({ nodeId: p.nodeId, round: round.id, state: judgement.state, covers: judgement.covers.length, followed: judgement.followed.length, lacks: judgement.lacks.length, ignored: unknown.length ? unknown.map((u) => u.changeId) : undefined });
|
|
1963
|
+
},
|
|
1964
|
+
}));
|
|
1965
|
+
|
|
1966
|
+
tools.push(defineTool({
|
|
1967
|
+
name: 'pk_set_propagation', label: 'Set propagation state',
|
|
1968
|
+
description: 'How one downstream object stands against one change record. It joins the object’s one judgement for this round; prefer pk_judge_object, which takes the whole object at once.',
|
|
1969
|
+
parameters: Type.Object({ changeId: Type.String(), nodeId: Type.String(), state: Type.String(), sourceOrReason: Type.String() }),
|
|
1970
|
+
execute: async (_id, raw) => {
|
|
1971
|
+
const p = { ...raw, nodeId: resolveMergedId(store, raw.nodeId) };
|
|
1972
|
+
const c = store.changes.get(p.changeId);
|
|
1973
|
+
if (!c) return fail(`unknown change ${p.changeId}`);
|
|
1974
|
+
if (!isOneOf(PROPAGATION, p.state)) return fail(`state must be one of ${PROPAGATION.join(', ')}`);
|
|
1975
|
+
const refusal = notJudgedRefusal(p.nodeId);
|
|
1976
|
+
if (refusal) return fail(refusal);
|
|
1977
|
+
const result = applyPerChange(p.nodeId, [{ change: c, state: p.state, sourceOrReason: text(p.sourceOrReason) }]);
|
|
1978
|
+
return typeof result === 'string' ? fail(result) : ok({ changeId: c.id, nodeId: p.nodeId, state: result.state, round: result.roundId });
|
|
1979
|
+
},
|
|
1980
|
+
}));
|
|
1981
|
+
|
|
1982
|
+
/** Several per-change judgements of one object, folded into the object's one state for this round. */
|
|
1983
|
+
const applyPerChange = (nodeId: string, judged: readonly { change: ChangeRecord; state: string; sourceOrReason: string }[]) => {
|
|
1984
|
+
const behind = judged.filter((j) => j.state === 'Still on old understanding');
|
|
1985
|
+
const unbacked = behind.find((j) => !j.sourceOrReason.trim());
|
|
1986
|
+
if (unbacked) return `"Still on old understanding" on ${unbacked.change.id} says neither what the object lacks nor what it was checked against, so it would never be shown to an agent. Say what the object still has to follow.`;
|
|
1987
|
+
const round = roundOf();
|
|
1988
|
+
if (typeof round === 'string') return round;
|
|
1989
|
+
const followed: ItemRef[] = [];
|
|
1990
|
+
const lacks: LackedItem[] = [];
|
|
1991
|
+
for (const j of judged) {
|
|
1992
|
+
// Only the items that actually reached this object, the same rule the round's own pack names them by. Taking
|
|
1993
|
+
// every item of the record handed the object items that never came near it, and `judgeObject` then dropped
|
|
1994
|
+
// them as unknown — leaving `Still on old understanding` with nothing named, which nobody can check (§7.1).
|
|
1995
|
+
const entry = j.change.propagation.find((p) => p.nodeId === nodeId);
|
|
1996
|
+
const reached = entry?.itemIds ? itemsOf(j.change).filter((i) => entry.itemIds!.includes(i.id)) : itemsOf(j.change);
|
|
1997
|
+
for (const item of reached) {
|
|
1998
|
+
const ref = { changeId: j.change.id, itemId: item.id };
|
|
1999
|
+
if (j.state === 'Still on old understanding') lacks.push({ ...ref, what: j.sourceOrReason });
|
|
2000
|
+
else if (j.state !== 'Not yet checked') followed.push(ref);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
const state = behind.length ? 'Still on old understanding' : judged.every((j) => j.state === 'Not yet checked') ? 'Not yet checked' : judged.find((j) => j.state === 'Reusable as is') && !judged.some((j) => j.state === 'Updated') ? 'Reusable as is' : 'Updated';
|
|
2004
|
+
const judgement = judgeObject(store, project.id, round.id, { nodeId, state, sourceOrReason: judged.map((j) => j.sourceOrReason).filter(Boolean).join(' · '), followed, lacks, jobId: ctx.jobId });
|
|
2005
|
+
saved('propagation', judgement.id, `${judgement.state}: ${nodeId}`);
|
|
2006
|
+
return { state: judgement.state, roundId: round.id };
|
|
2007
|
+
};
|
|
2008
|
+
|
|
2009
|
+
// One object judged against several changes at once (change follow-up is organized by object): one call per object.
|
|
2010
|
+
tools.push(defineTool({
|
|
2011
|
+
name: 'pk_set_propagations', label: 'Set propagation states of one object',
|
|
2012
|
+
description: 'For one downstream object, how it stands against each of several change records. The judgements are folded into the object’s one state for this round: Updated, Still on old understanding (say what it lacks), Reusable as is, or Not yet checked. pk_judge_object does the same and lets you name the individual items.',
|
|
2013
|
+
parameters: Type.Object({ nodeId: Type.String(), judgements: Type.Array(Type.Object({ changeId: Type.String(), state: Type.String(), sourceOrReason: Type.String() })) }),
|
|
2014
|
+
execute: async (_id, raw) => {
|
|
2015
|
+
const p = { ...raw, nodeId: resolveMergedId(store, raw.nodeId) };
|
|
2016
|
+
const refusal = notJudgedRefusal(p.nodeId);
|
|
2017
|
+
if (refusal) return fail(refusal);
|
|
2018
|
+
const judged: { change: ChangeRecord; state: string; sourceOrReason: string }[] = [];
|
|
2019
|
+
const problems: string[] = [];
|
|
2020
|
+
for (const j of arr<{ changeId: string; state: string; sourceOrReason: string }>(p.judgements)) {
|
|
2021
|
+
const c = store.changes.get(j.changeId);
|
|
2022
|
+
if (!c) { problems.push(`unknown change ${j.changeId}`); continue; }
|
|
2023
|
+
if (!isOneOf(PROPAGATION, j.state)) { problems.push(`${j.changeId}: state must be one of ${PROPAGATION.join(', ')}`); continue; }
|
|
2024
|
+
judged.push({ change: c, state: j.state, sourceOrReason: text(j.sourceOrReason) });
|
|
2025
|
+
}
|
|
2026
|
+
if (judged.length === 0) return fail(problems.join('; ') || 'no judgements given');
|
|
2027
|
+
const result = applyPerChange(p.nodeId, judged);
|
|
2028
|
+
return typeof result === 'string' ? fail(result) : ok({ nodeId: p.nodeId, round: result.roundId, state: result.state, recorded: judged.length, problems });
|
|
2029
|
+
},
|
|
2030
|
+
}));
|
|
2031
|
+
|
|
2032
|
+
tools.push(defineTool({
|
|
2033
|
+
name: 'pk_request_relook', label: 'Ask for a product re-look',
|
|
2034
|
+
description: 'After an adjustment (yours, or one the owner says was done elsewhere), ask for a product re-look of the affected scope (project, an Area reference id, or a thread id). The re-look decides whether notes are Resolved; changing files alone never does.',
|
|
2035
|
+
parameters: Type.Object({ scopeKind: Type.String({ description: 'project | area | thread' }), id: Type.Optional(Type.String()), reason: Type.String() }),
|
|
2036
|
+
execute: async (_id, p) => {
|
|
2037
|
+
if (!ctx.requestRelook) return fail('Re-looks cannot be requested from this job.');
|
|
2038
|
+
const kind = p.scopeKind === 'area' || p.scopeKind === 'thread' ? p.scopeKind : 'project';
|
|
2039
|
+
ctx.requestRelook({ kind, id: kind === 'project' ? null : text(p.id) || null }, text(p.reason));
|
|
2040
|
+
return ok({ requested: true, scope: kind });
|
|
2041
|
+
},
|
|
2042
|
+
}));
|
|
2043
|
+
|
|
2044
|
+
tools.push(defineTool({
|
|
2045
|
+
name: 'pk_close_note', label: 'Close a note',
|
|
2046
|
+
description: 'Mark a note Resolved (a later re-look confirmed, with evidence, that the situation no longer exists) or Withdrawn (the judgement itself was wrong). Give the reason; the note stays in history.',
|
|
2047
|
+
parameters: Type.Object({ id: Type.String(), status: Type.String({ description: 'Resolved | Withdrawn' }), reason: Type.String() }),
|
|
2048
|
+
execute: async (_id, p) => {
|
|
2049
|
+
const n = store.notes.get(p.id);
|
|
2050
|
+
if (!n) return fail(`unknown note ${p.id}`);
|
|
2051
|
+
if (p.status !== 'Resolved' && p.status !== 'Withdrawn') return fail('status must be Resolved or Withdrawn');
|
|
2052
|
+
store.notes.put({ ...n, status: p.status, resolvedReason: p.status === 'Resolved' ? p.reason : n.resolvedReason, withdrawnReason: p.status === 'Withdrawn' ? p.reason : n.withdrawnReason, updatedAt: now() }, trace(`Note ${p.status}: ${p.reason.slice(0, 120)}`));
|
|
2053
|
+
saved('notes', n.id, `Note ${p.status}`);
|
|
2054
|
+
return ok({ id: n.id, status: p.status });
|
|
2055
|
+
},
|
|
2056
|
+
}));
|
|
2057
|
+
|
|
2058
|
+
// ───────────────────────── the project's rules (Spec §1.15; D62, D64; CKC-21) ─────────────────────────
|
|
2059
|
+
|
|
2060
|
+
tools.push(defineTool({
|
|
2061
|
+
name: 'pk_write_rule', label: 'Write one of the project’s rules',
|
|
2062
|
+
description: `Record a rule the project set for itself — you find it, dig it out and follow it; you never set it. Groups: How work is organized (single or several agents; whether the owner wants independent QC, how many rounds, by whom; roles and role cards; who decides what; who hosts the work now and who did before, since when), Working rules (how tasks are numbered and the next number; pushing and merging; where credentials live — the place only, never a value; where deliverables go; how the owner accepts work; any other working rule the project has), Material rules (with a category: ${MATERIAL_RULE.join(', ')}). Each rule has your one-sentence summary, the project’s own words (excerpt — required when basis is Explicit), its sources (for a rule inferred from practice, basis Inferred, the records you inferred it from), what it applies to, and its validity. When the project’s way of working comes from a system the owner summarized (for example a role system), name it in ownerSystem and note where actual practice differs in differsInPractice, both sides with sources. When a rule changed, write the new one with replaces: [old rule ids] — the old ones become Replaced and point to it. With id, what you leave out keeps its value. In the owner’s conversation, ownerConfirmed records the owner confirming an inferred rule: it becomes Explicit with the owner’s message as a source. A rule is not an area, a work item or a graph node.`,
|
|
2063
|
+
parameters: Type.Object({
|
|
2064
|
+
id: Type.Optional(Type.String({ description: 'an existing rule to update' })),
|
|
2065
|
+
group: Type.Optional(Type.String({ description: RULE_GROUP.join(' | ') })),
|
|
2066
|
+
category: Type.Optional(Type.String({ description: `Material rules only: ${MATERIAL_RULE.join(' | ')}` })),
|
|
2067
|
+
summary: Type.Optional(Type.String({ description: 'your one sentence' })),
|
|
2068
|
+
excerpt: Type.Optional(Type.String({ description: 'the project’s own words, copied exactly as they stand in the sources cited — …… where you leave words out; required when basis is Explicit' })),
|
|
2069
|
+
sourceIds: Type.Optional(StrArr),
|
|
2070
|
+
appliesTo: Type.Optional(Type.Array(Type.String(), { description: 'the directories, branches, files, fields, roles or work it covers; "the whole project" when it covers everything' })),
|
|
2071
|
+
basis: Type.Optional(Type.String({ description: 'Explicit (the project writes it down) | Inferred (drawn from what the project does)' })),
|
|
2072
|
+
validity: Type.Optional(Type.String({ description: 'Current by default; Replaced (with replacedBy) when a newer rule took its place' })),
|
|
2073
|
+
replacedBy: Type.Optional(Type.String()),
|
|
2074
|
+
replaces: Type.Optional(Type.Array(Type.String(), { description: 'older rules this one replaces; they become Replaced and point here' })),
|
|
2075
|
+
ownerSystem: Type.Optional(Type.String({ description: 'the way of working the owner summarized that this rule belongs to, when it does' })),
|
|
2076
|
+
differsInPractice: Type.Optional(Type.Array(Type.Object({ text: Type.String(), sourceIds: StrArr }), { description: 'where the project’s actual practice differs from this rule, with sources' })),
|
|
2077
|
+
expects: Type.Optional(Type.Array(Type.String(), { description: `How work is organized: the steps this rule says the project expects of a piece of work — ${PROCESS_EXPECTATION.join(' | ')}. The breakpoints read them: a missing step is lit only where the project expects it. Name only what the rule says; leave out what it does not.` })),
|
|
2078
|
+
ownerConfirmed: Type.Optional(Type.Object({ quote: Type.String({ description: 'the owner’s words confirming it' }) })),
|
|
2079
|
+
}),
|
|
2080
|
+
execute: async (_id, p) => {
|
|
2081
|
+
const previous = text(p.id) ? store.rules.get(text(p.id)) : undefined;
|
|
2082
|
+
if (text(p.id) && !previous) return fail(`unknown rule ${text(p.id)}; leave id out to record a new one`);
|
|
2083
|
+
const group = text(p.group) || previous?.group || '';
|
|
2084
|
+
if (!isOneOf(RULE_GROUP, group)) return fail(`group must be one of ${RULE_GROUP.join(', ')}`);
|
|
2085
|
+
const categoryGiven = p.category !== undefined ? text(p.category).trim() : (previous?.category ?? '');
|
|
2086
|
+
let category: MaterialRule | null = null;
|
|
2087
|
+
if (group === 'Material rules') {
|
|
2088
|
+
if (!isOneOf(MATERIAL_RULE, categoryGiven)) return fail(`A material rule needs its category, one of ${MATERIAL_RULE.join(', ')}. Other is for a rule about the project’s material that none of the six names.`);
|
|
2089
|
+
category = categoryGiven;
|
|
2090
|
+
} else if (categoryGiven) {
|
|
2091
|
+
return fail(`category is for material rules only; ${group} has none. If this rule is about the project’s material — what is obsolete, reference only, for recovery only, authoritative, untrusted or declared open — write it under Material rules.`);
|
|
2092
|
+
}
|
|
2093
|
+
const summary = redact(p.summary !== undefined ? text(p.summary) : previous?.summary ?? '').trim();
|
|
2094
|
+
if (!summary) return fail('A rule needs your one-sentence summary (summary).');
|
|
2095
|
+
const excerpt = redact(p.excerpt !== undefined ? text(p.excerpt) : previous?.excerpt ?? '').trim() || null;
|
|
2096
|
+
// One rule per group, category and the project's own words: writing the same rule again updates it, and keeps
|
|
2097
|
+
// what it already had (the owner's confirmation above all) unless the call says otherwise.
|
|
2098
|
+
const id = previous?.id ?? stableId('rule', project.id, group, category ?? '', excerpt ?? summary);
|
|
2099
|
+
const prior = previous ?? store.rules.get(id);
|
|
2100
|
+
const basisGiven = text(p.basis) || prior?.basis || '';
|
|
2101
|
+
if (!isOneOf(BASIS, basisGiven)) return fail('basis must be Explicit (the project writes it down) or Inferred (you drew it from what the project does)');
|
|
2102
|
+
let basis: 'Explicit' | 'Inferred' = basisGiven;
|
|
2103
|
+
// §3.9: the owner's confirmation is their own words in their conversation, nowhere else; once given it stays,
|
|
2104
|
+
// and so does the Explicit it made of the rule.
|
|
2105
|
+
let ownerConfirmation = prior?.ownerConfirmation ?? null;
|
|
2106
|
+
if (p.ownerConfirmed !== undefined) {
|
|
2107
|
+
if (!ctx.ownerSourceId) return fail('Only the owner’s message in the conversation can confirm a rule. Until the owner confirms it, an inferred rule stays Inferred and is shown as an inference everywhere.');
|
|
2108
|
+
const quote = redact(text(p.ownerConfirmed.quote)).trim();
|
|
2109
|
+
if (!quote) return fail('ownerConfirmed.quote: the owner’s words that confirm the rule.');
|
|
2110
|
+
const confirmation = checkVerbatim(store, quote, [ctx.ownerSourceId]);
|
|
2111
|
+
if (confirmation.missing.length) return fail(ownerConfirmationRefusal(confirmation));
|
|
2112
|
+
ownerConfirmation = { sourceId: ctx.ownerSourceId, quote, at: now() };
|
|
2113
|
+
}
|
|
2114
|
+
if (ownerConfirmation) basis = 'Explicit';
|
|
2115
|
+
const sourceIds = [...new Set([
|
|
2116
|
+
...(p.sourceIds !== undefined ? arr<string>(p.sourceIds) : [...(prior?.sourceIds ?? [])]),
|
|
2117
|
+
...(ownerConfirmation ? [ownerConfirmation.sourceId] : []),
|
|
2118
|
+
])].filter((s) => store.sources.has(s));
|
|
2119
|
+
if (sourceIds.length === 0) return fail('A rule needs its source: the existing source id(s) where the project writes it down, or — for a rule you inferred from practice — the records you inferred it from. None of the given ids is a source in the assets.');
|
|
2120
|
+
if (basis === 'Explicit' && !excerpt && !ownerConfirmation) {
|
|
2121
|
+
return fail('An Explicit rule quotes the project’s own words (your one-sentence summary, plus the project’s own text): add excerpt, copied from the source. A rule you drew from what the project does is basis Inferred and names the records it rests on instead.');
|
|
2122
|
+
}
|
|
2123
|
+
const appliesTo = (p.appliesTo !== undefined ? arr<string>(p.appliesTo) : [...(prior?.appliesTo ?? [])]).map((a) => redact(text(a)).trim()).filter(Boolean);
|
|
2124
|
+
if (appliesTo.length === 0) return fail('Say what the rule applies to (appliesTo): the directories, branches, files, fields, roles or work it covers — "the whole project" when it covers everything.');
|
|
2125
|
+
const validity = text(p.validity) || prior?.validity || 'Current';
|
|
2126
|
+
if (!isOneOf(VALIDITY, validity)) return fail(`validity must be one of ${VALIDITY.join(', ')}`);
|
|
2127
|
+
const fromHistory = ruleHistoryRefusal(store, sourceIds, validity);
|
|
2128
|
+
if (fromHistory) return fail(fromHistory);
|
|
2129
|
+
const replacedBy = p.replacedBy !== undefined ? text(p.replacedBy).trim() || null : prior?.replacedBy ?? null;
|
|
2130
|
+
if (validity === 'Replaced' && (!replacedBy || !store.rules.has(replacedBy))) {
|
|
2131
|
+
return fail(`A Replaced rule points to the rule that replaced it (replacedBy: an existing rule id)${replacedBy ? `; ${replacedBy} is not one` : ''}. A changed rule is not left beside the new one.`);
|
|
2132
|
+
}
|
|
2133
|
+
if (replacedBy === id) return fail('A rule does not replace itself.');
|
|
2134
|
+
if (replacedBy && replacementCircle(store, id, replacedBy)) return fail(`${id} cannot be replaced by ${replacedBy}: ${replacedBy} is already, through replacedBy, replaced by ${id}, so this would make a circle.`);
|
|
2135
|
+
const replaces = [...new Set(arr<string>(p.replaces).map((r) => text(r).trim()).filter(Boolean))];
|
|
2136
|
+
if (replaces.includes(id)) return fail('A rule does not replace itself: name the older rule(s) in replaces.');
|
|
2137
|
+
const unknownOld = replaces.filter((r) => !store.rules.has(r));
|
|
2138
|
+
if (unknownOld.length) return fail(`replaces names no rule in the assets: ${unknownOld.join(', ')}`);
|
|
2139
|
+
if (replaces.length && validity !== 'Current') return fail(`Only a rule in force replaces others (the new one is Current, the old ones Replaced); this one is ${validity}.`);
|
|
2140
|
+
const circling = replaces.find((r) => replacementCircle(store, r, id) || (replacedBy !== null && replacementCircle(store, r, replacedBy)));
|
|
2141
|
+
if (circling) return fail(`${id} cannot replace ${circling}: that would make a circle of replacements.`);
|
|
2142
|
+
const differsInPractice = p.differsInPractice !== undefined
|
|
2143
|
+
? arr<Record<string, unknown>>(p.differsInPractice).map((d) => ({ text: redact(text(d.text)).trim(), sourceIds: arr<string>(d.sourceIds).filter((s) => store.sources.has(s)) })).filter((d) => d.text)
|
|
2144
|
+
: [...(prior?.differsInPractice ?? [])];
|
|
2145
|
+
// §1.15 (E80): a rule Explicit because the project wrote it quotes the project's own words, part by part, in the
|
|
2146
|
+
// sources cited, or it is not written. A rule inferred from practice, or Explicit because the owner confirmed it
|
|
2147
|
+
// (§3.9: the confirmation is never refused for it), is written and told when its excerpt is not the project's
|
|
2148
|
+
// words. An Explicit rule's excerpt, on the sources it already cites, is not checked again (its source may have changed).
|
|
2149
|
+
const kept = prior?.basis === 'Explicit' && sameWords(prior.excerpt, excerpt) && prior.sourceIds.every((s) => sourceIds.includes(s));
|
|
2150
|
+
const verbatim = excerpt && !kept ? checkVerbatim(store, excerpt, sourceIds) : null;
|
|
2151
|
+
if (verbatim?.missing.length && basis === 'Explicit' && !ownerConfirmation) return fail(ruleExcerptRefusal(verbatim));
|
|
2152
|
+
const expectsIn = p.expects !== undefined ? arr<string>(p.expects).map((e) => text(e).trim()) : null;
|
|
2153
|
+
const badExpect = expectsIn?.find((e) => !isOneOf(PROCESS_EXPECTATION, e));
|
|
2154
|
+
if (badExpect) return fail(`expects: ${badExpect} is not one of ${PROCESS_EXPECTATION.join(', ')}`);
|
|
2155
|
+
if (expectsIn?.length && group !== 'How work is organized') return fail('expects belongs to a rule of How work is organized: it says which steps the project expects of its work.');
|
|
2156
|
+
const expects = expectsIn ? [...new Set(expectsIn)] as ProcessExpectation[] : [...(prior?.expects ?? [])];
|
|
2157
|
+
const rule: ProjectRule = {
|
|
2158
|
+
id, projectId: project.id, group, category, summary, excerpt, sourceIds, appliesTo, basis, validity, replacedBy, ...(expects.length ? { expects } : {}),
|
|
2159
|
+
ownerSystem: p.ownerSystem !== undefined ? text(p.ownerSystem).trim() || null : prior?.ownerSystem ?? null,
|
|
2160
|
+
differsInPractice, ownerConfirmation, jobId: ctx.jobId, asOf: now(), updatedAt: now(),
|
|
2161
|
+
};
|
|
2162
|
+
store.rules.put(rule, trace(`${prior ? 'Rule updated' : 'Rule'} (${group}${category ? ` · ${category}` : ''}): ${summary.slice(0, 100)}`, sourceIds));
|
|
2163
|
+
for (const old of replaces) store.rules.put({ ...store.rules.get(old)!, validity: 'Replaced', replacedBy: id, updatedAt: now() }, trace(`Rule replaced by ${id}: ${summary.slice(0, 80)}`, sourceIds));
|
|
2164
|
+
saved('rules', id, `Rule: ${summary.slice(0, 60)}`);
|
|
2165
|
+
const warning = verbatim?.missing.length ? inferredExcerptWarning(verbatim) : null;
|
|
2166
|
+
return ok({ id, updated: Boolean(prior), ...(replaces.length ? { replaced: replaces } : {}), ...(warning ? { warning } : {}) });
|
|
2167
|
+
},
|
|
2168
|
+
}));
|
|
2169
|
+
|
|
2170
|
+
tools.push(defineTool({
|
|
2171
|
+
name: 'pk_write_organizing_plan', label: 'Write the organizing plan and focus',
|
|
2172
|
+
description: 'The orientation step’s plan for the organizing that follows: which material the project’s rules settle directly (byRule: what, its targets — directories, files or branches as the project names them — the id of the rule, and the treatment, e.g. Reference only or History only), what needs close reading (readClosely: what, targets, why), where the focus is (focus: what, why, sources — for example where documents part from the owner’s words, work still in progress, status that rests on reports), and in what order. The owner sees it in Project scope and can correct it; every deepening round follows it; the depth question counts by it (what the rules settle is not counted as close reading). A later call replaces the fields it gives and keeps the others. In the owner’s conversation, ownerCorrection records the owner correcting it: their words are kept, with what the plan said before.',
|
|
2173
|
+
parameters: Type.Object({
|
|
2174
|
+
byRule: Type.Optional(Type.Array(Type.Object({ what: Type.String(), targets: StrArr, ruleId: Type.String(), treatment: Type.String({ description: 'what the rule makes of it, e.g. Reference only, History only, Replaced' }) }))),
|
|
2175
|
+
readClosely: Type.Optional(Type.Array(Type.Object({ what: Type.String(), targets: Type.Optional(StrArr), why: Type.Optional(Type.String()) }))),
|
|
2176
|
+
focus: Type.Optional(Type.Array(Type.Object({ what: Type.String(), why: Type.Optional(Type.String()), sourceIds: Type.Optional(StrArr) }))),
|
|
2177
|
+
order: Type.Optional(Type.Array(Type.String(), { description: 'what is organized first, next, …' })),
|
|
2178
|
+
ownerCorrection: Type.Optional(Type.Object({ quote: Type.String({ description: 'the owner’s words' }), changed: Type.String({ description: 'what the correction changed, in your words' }) })),
|
|
2179
|
+
}),
|
|
2180
|
+
execute: async (_id, p) => {
|
|
2181
|
+
const previous = store.plans.get(PLAN_ID);
|
|
2182
|
+
const byRule: OrganizingPlanContent['byRule'][number][] = [];
|
|
2183
|
+
if (p.byRule !== undefined) {
|
|
2184
|
+
for (const [i, e] of arr<Record<string, unknown>>(p.byRule).entries()) {
|
|
2185
|
+
const what = text(e.what).trim();
|
|
2186
|
+
const targets = arr<string>(e.targets).map((t) => text(t).trim()).filter(Boolean);
|
|
2187
|
+
const ruleId = text(e.ruleId).trim();
|
|
2188
|
+
const treatment = text(e.treatment).trim();
|
|
2189
|
+
if (!what) return fail(`byRule[${i}] says which material it settles (what).`);
|
|
2190
|
+
if (targets.length === 0) return fail(`byRule[${i}] names no targets: the directories, files or branches the rule settles, as the project names them — the depth question counts by them.`);
|
|
2191
|
+
if (!store.rules.has(ruleId)) return fail(`byRule[${i}].ruleId names no rule in the assets: ${ruleId || '(empty)'}. What the rules settle is settled by a recorded rule: write it first with pk_write_rule.`);
|
|
2192
|
+
const refusal = ruleUseRefusal(store, ruleId, 'Validity');
|
|
2193
|
+
if (refusal) return fail(`byRule[${i}]: ${refusal}`);
|
|
2194
|
+
if (!treatment) return fail(`byRule[${i}] says what the rule makes of it (treatment), e.g. Reference only or History only.`);
|
|
2195
|
+
byRule.push({ what, targets, ruleId, treatment });
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
const readClosely = arr<Record<string, unknown>>(p.readClosely).map((e) => ({ what: text(e.what).trim(), targets: arr<string>(e.targets).map((t) => text(t).trim()).filter(Boolean), why: text(e.why).trim() }));
|
|
2199
|
+
if (readClosely.some((e) => !e.what)) return fail('Each readClosely entry says what is read closely (what).');
|
|
2200
|
+
const focus = arr<Record<string, unknown>>(p.focus).map((e) => ({ what: text(e.what).trim(), why: text(e.why).trim(), sourceIds: arr<string>(e.sourceIds).filter((s) => store.sources.has(s)) }));
|
|
2201
|
+
if (focus.some((e) => !e.what)) return fail('Each focus entry says where the focus is (what).');
|
|
2202
|
+
const order = arr<string>(p.order).map((o) => text(o).trim()).filter(Boolean);
|
|
2203
|
+
const content: OrganizingPlanContent = {
|
|
2204
|
+
byRule: p.byRule !== undefined ? byRule : previous?.byRule ?? [],
|
|
2205
|
+
readClosely: p.readClosely !== undefined ? readClosely : previous?.readClosely ?? [],
|
|
2206
|
+
focus: p.focus !== undefined ? focus : previous?.focus ?? [],
|
|
2207
|
+
order: p.order !== undefined ? order : previous?.order ?? [],
|
|
2208
|
+
};
|
|
2209
|
+
if (!previous && !content.byRule.length && !content.readClosely.length && !content.focus.length) return fail('The plan says at least what the rules settle, what is read closely, or where the focus is.');
|
|
2210
|
+
let correction: OrganizingPlan['corrections'][number] | null = null;
|
|
2211
|
+
if (p.ownerCorrection !== undefined) {
|
|
2212
|
+
if (!ctx.ownerSourceId) return fail('Only the owner’s message in the conversation can correct the plan. Rewriting it from a job is an ordinary update; the owner’s corrections stay on record either way.');
|
|
2213
|
+
const quote = text(p.ownerCorrection.quote).trim();
|
|
2214
|
+
const changed = text(p.ownerCorrection.changed).trim();
|
|
2215
|
+
if (!quote || !changed) return fail('ownerCorrection needs the owner’s words (quote) and what they changed (changed).');
|
|
2216
|
+
// The owner's first word on it may come before any step wrote a plan: the correction starts it, and what it replaced is nothing.
|
|
2217
|
+
correction = { at: now(), sourceId: ctx.ownerSourceId, quote, changed, previous: { byRule: previous?.byRule ?? [], readClosely: previous?.readClosely ?? [], focus: previous?.focus ?? [], order: previous?.order ?? [] } };
|
|
2218
|
+
}
|
|
2219
|
+
const plan: OrganizingPlan = {
|
|
2220
|
+
id: PLAN_ID, projectId: project.id, ...content,
|
|
2221
|
+
corrections: [...(previous?.corrections ?? []), ...(correction ? [correction] : [])],
|
|
2222
|
+
jobId: ctx.jobId, asOf: now(), updatedAt: now(),
|
|
2223
|
+
};
|
|
2224
|
+
store.plans.put(plan, trace(correction ? `Organizing plan corrected by the owner: ${correction.changed.slice(0, 100)}` : 'Organizing plan and focus', [...(correction ? [correction.sourceId] : []), ...content.byRule.flatMap((e) => store.rules.get(e.ruleId)?.sourceIds ?? []), ...content.focus.flatMap((f) => f.sourceIds)]));
|
|
2225
|
+
saved('plans', PLAN_ID, 'Organizing plan and focus');
|
|
2226
|
+
return ok({ id: PLAN_ID, byRule: plan.byRule.length, readClosely: plan.readClosely.length, focus: plan.focus.length, order: plan.order.length, corrections: plan.corrections.length });
|
|
2227
|
+
},
|
|
2228
|
+
}));
|
|
2229
|
+
|
|
2230
|
+
// ───────────────────────── merging duplicates (Spec §1.4; CKC-06 AC-28) ─────────────────────────
|
|
2231
|
+
|
|
2232
|
+
tools.push(defineTool({
|
|
2233
|
+
name: 'pk_merge_work_items', label: 'Merge duplicate work items',
|
|
2234
|
+
description: 'At the end of a round, merge work items that turned out to be one unit of work — the same number in the project’s index, the same task under two names. keepId stays; each of mergeIds leaves the work items: its numbers, fact records, facts, what it serves and depends on, and every relation, mark, note, change entry and judgement that named it move to the one kept, and its id still reaches the one kept. This is not a replacement: nothing becomes Replaced. Say why they are one (reason) and what shows it (sourceIds). Before creating a work item, check with pk_find_references (the number) whether it already exists.',
|
|
2235
|
+
parameters: Type.Object({ keepId: Type.String(), mergeIds: StrArr, reason: Type.String(), sourceIds: Type.Optional(StrArr) }),
|
|
2236
|
+
execute: async (_id, p) => {
|
|
2237
|
+
const mergeIds = [...new Set(arr<string>(p.mergeIds).map((m) => text(m).trim()).filter(Boolean))];
|
|
2238
|
+
const refusal = mergeRefusal(store, text(p.keepId).trim(), mergeIds, text(p.reason));
|
|
2239
|
+
if (refusal) return fail(refusal);
|
|
2240
|
+
const outcome = mergeWorkItems(store, {
|
|
2241
|
+
projectId: project.id, keepId: text(p.keepId).trim(), mergeIds, reason: text(p.reason).trim(),
|
|
2242
|
+
sourceIds: arr<string>(p.sourceIds).filter((s) => store.sources.has(s)), jobId: ctx.jobId,
|
|
2243
|
+
// The round the job belongs to: a takeover round is its own record and never a Follow up round (D59 rule 1).
|
|
2244
|
+
roundId: takeoverRoundOf(store, ctx.jobId) ?? (text(ctx.roundId) || openRound(store)?.id || null),
|
|
2245
|
+
});
|
|
2246
|
+
saved('threads', outcome.keptId, `Work thread: ${store.threads.get(outcome.keptId)?.title ?? outcome.keptId}`);
|
|
2247
|
+
for (const m of outcome.merged) saved('merges', m, `Merged ${m} into ${outcome.keptId}`);
|
|
2248
|
+
return ok(outcome);
|
|
2249
|
+
},
|
|
2250
|
+
}));
|
|
2251
|
+
|
|
2252
|
+
// ───────────────────────── a decision's carry-out (Spec §2.2; CKC-02 AC-26) ─────────────────────────
|
|
2253
|
+
|
|
2254
|
+
tools.push(defineTool({
|
|
2255
|
+
name: 'pk_record_carry_out', label: 'Record whether a decision was carried out',
|
|
2256
|
+
description: `For a decision that asks for something to be done (“this endpoint must become asynchronous”, “the old configuration goes with the next release”), record whether it was carried out: ${CARRY_OUT.join(', ')} — Partly carried out says what is still left (remaining). Name the work items that carry it out (workIds): each is linked to the decision by a carries out relation. Give what shows it (evidenceSourceIds); when it concerns what the code does, read the code first. A decision carried out stays Current and is no longer read as a to-do. A decision whose agreed time or condition has passed without it being carried out also gets Layer drift (pk_write_mark). A later call updates the carry-out and gives only what changes: status, remaining, workIds and evidenceSourceIds it leaves out stay as they were; workIds, when given, is the whole list — work no longer named no longer carries it out. The first record of a decision’s carry-out needs status.`,
|
|
2257
|
+
parameters: Type.Object({
|
|
2258
|
+
decisionId: Type.String(), status: Type.Optional(Type.String({ description: `${CARRY_OUT.join(' | ')} — the first record needs it; a later call keeps it when left out` })), remaining: Type.Optional(Type.String({ description: 'Partly carried out: what is still left' })),
|
|
2259
|
+
workIds: Type.Optional(Type.Array(Type.String(), { description: 'the work items that carry it out: the whole list; left out, the ones recorded stay' })), evidenceSourceIds: Type.Optional(StrArr),
|
|
2260
|
+
basis: Type.Optional(Type.String({ description: 'Explicit when the material says this work carries the decision out; Inferred (default) when you conclude it' })),
|
|
2261
|
+
claim: Type.Optional(Type.String({ description: 'how the work carries it out, for the relation' })),
|
|
2262
|
+
}),
|
|
2263
|
+
execute: async (_id, p) => {
|
|
2264
|
+
const decision = store.reference.get(text(p.decisionId));
|
|
2265
|
+
if (!decision) return fail(`unknown decision ${text(p.decisionId)}`);
|
|
2266
|
+
if (!isDecision(store, decision.id)) return fail(`${decision.id} (${decision.name}) is a ${decision.category}, not a decision: whether something was carried out is recorded on a decision that asks for it to be done. The progress of a requirement’s work is the progress of its work items.`);
|
|
2267
|
+
if (decision.validity !== 'Current') return fail(`${decision.id} (${decision.name}) is ${decision.validity}: a carry-out is recorded on a decision in force.`);
|
|
2268
|
+
// A later call gives what changed; what it leaves out stays as the carry-out recorded it.
|
|
2269
|
+
const before = decision.carryOut ?? null;
|
|
2270
|
+
if (!before && !has(p.status)) throw createNeeds('the carry-out of this decision', `${decision.id} (${decision.name}) has no carry-out recorded yet`, ['status'], 'Give its status; a later call updates the carry-out recorded.');
|
|
2271
|
+
const status = has(p.status) ? text(p.status) : before!.status;
|
|
2272
|
+
if (!isOneOf(CARRY_OUT, status)) return fail(`status must be one of ${CARRY_OUT.join(', ')}`);
|
|
2273
|
+
const remaining = text(p.remaining).trim() || (status === before?.status ? before?.remaining ?? '' : '');
|
|
2274
|
+
if (status === 'Partly carried out' && !remaining) return fail('Partly carried out says what is still left (remaining), so the next agent sees what is done and what is not.');
|
|
2275
|
+
const workIds = absent(p.workIds) ? [...(before?.workIds ?? [])] : [...new Set(arr<string>(p.workIds).map((w) => resolveMergedId(store, text(w).trim())).filter(Boolean))];
|
|
2276
|
+
const unknownWork = workIds.filter((w) => !store.threads.has(w));
|
|
2277
|
+
if (unknownWork.length) return fail(`workIds names no work item in the assets: ${unknownWork.join(', ')}`);
|
|
2278
|
+
if (status !== 'Not carried out yet' && workIds.length === 0) return fail(`${status} names the work that carries it out (workIds): the work items whose results do what the decision asks. Each is linked to the decision by carries out.`);
|
|
2279
|
+
const evidence = absent(p.evidenceSourceIds) ? [...(before?.evidenceSourceIds ?? [])] : arr<string>(p.evidenceSourceIds).filter((s) => store.sources.has(s));
|
|
2280
|
+
const at = now();
|
|
2281
|
+
for (const workId of workIds) {
|
|
2282
|
+
const relId = stableId('rel', 'carries out', workId, decision.id);
|
|
2283
|
+
const prev = store.relations.get(relId);
|
|
2284
|
+
// A relation already there keeps its claim and basis unless the call gives them.
|
|
2285
|
+
const basis = (isOneOf(BASIS, p.basis) ? p.basis : prev?.basis ?? 'Inferred') as 'Explicit' | 'Inferred';
|
|
2286
|
+
store.relations.put({
|
|
2287
|
+
id: relId, projectId: project.id, type: 'carries out', from: workId, to: decision.id,
|
|
2288
|
+
claim: text(p.claim) || prev?.claim || `${store.threads.get(workId)!.title} carries out ${decision.name}`, basis,
|
|
2289
|
+
evidence: { sourceIds: evidence, factRecordIds: [], factsSoFar: `${status}${status === 'Partly carried out' && remaining ? `; still left: ${remaining}` : ''}` },
|
|
2290
|
+
assessment: prev?.assessment ?? 'Not assessed', assessedAt: prev?.assessedAt ?? null, assessedInJobId: prev?.assessedInJobId ?? null, updatedAt: at,
|
|
2291
|
+
}, trace(`carries out: ${workId} → ${decision.id}`, evidence));
|
|
2292
|
+
}
|
|
2293
|
+
// The work items the carry-out now names are the whole list: work no longer named no longer carries it out.
|
|
2294
|
+
for (const dropped of (before?.workIds ?? []).filter((w) => !workIds.includes(w))) {
|
|
2295
|
+
store.relations.remove(stableId('rel', 'carries out', dropped, decision.id), trace(`carries out withdrawn: ${dropped} → ${decision.id}`, evidence));
|
|
2296
|
+
}
|
|
2297
|
+
store.reference.put({ ...decision, carryOut: { status: status as NonNullable<ReferenceItem['carryOut']>['status'], remaining: status === 'Partly carried out' ? remaining : null, workIds, evidenceSourceIds: evidence, at, jobId: ctx.jobId }, updatedAt: at }, trace(`Carry-out of ${decision.name}: ${status}`, evidence));
|
|
2298
|
+
saved('reference', decision.id, `Carry-out: ${decision.name}`);
|
|
2299
|
+
return ok({ decisionId: decision.id, status, workIds });
|
|
2300
|
+
},
|
|
2301
|
+
}));
|
|
2302
|
+
|
|
2303
|
+
// ───────────────────────── looking up one's own assets (Spec §3.1; CKC-03 AC-22) ─────────────────────────
|
|
2304
|
+
|
|
2305
|
+
tools.push(defineTool({
|
|
2306
|
+
name: 'pk_find_references', label: 'Find what refers to an id',
|
|
2307
|
+
description: 'Look up your own assets by id: give a source id or any record id — fact record, reference item, work item, area, relation, change, mark, note, rule — or a number the project uses (a task number), and get short rows (kind, id, name, category, via) of every fact record, reference item, work item, relation, change record, mark, note and rule that cites it or touches it. Read any row in full by its id with pk_read_source or pk_read_assets. This answers from the assets alone: your own records are never looked for in the file system.',
|
|
2308
|
+
parameters: Type.Object({ id: Type.String(), limit: Type.Optional(Type.Number({ description: 'most rows to return (default 300)' })) }),
|
|
2309
|
+
execute: async (_id, p) => {
|
|
2310
|
+
const found = findReferences(store, text(p.id).trim());
|
|
2311
|
+
const limit = Math.max(1, Math.min(1000, Number(p.limit) || 300));
|
|
2312
|
+
return ok(found.rows.length > limit ? { ...found, rows: found.rows.slice(0, limit), more: found.rows.length - limit } : found);
|
|
2313
|
+
},
|
|
2314
|
+
}));
|
|
2315
|
+
|
|
2316
|
+
tools.push(...scopeTools(ctx)); // §1.1 the Keeper's word on the project scope (CKC-04 AC-13, AC-16, AC-17)
|
|
2317
|
+
tools.push(...historyTools(ctx), ...ownerUtteranceTools(ctx));
|
|
2318
|
+
|
|
2319
|
+
if (ctx.investigate) {
|
|
2320
|
+
tools.push(defineTool({
|
|
2321
|
+
name: 'pk_investigate', label: 'Delegate a focused investigation',
|
|
2322
|
+
description: 'Run a focused investigation in a separate Keeper session: it reads deep material (files, code, history, command output) and returns a conclusion with source ids. Use it when you need details without pulling a long history into this context.',
|
|
2323
|
+
parameters: Type.Object({ question: Type.String(), hints: Type.Optional(StrArr) }),
|
|
2324
|
+
execute: async (_id, p) => {
|
|
2325
|
+
try {
|
|
2326
|
+
const result = await ctx.investigate!(p.question, arr<string>(p.hints));
|
|
2327
|
+
if (ctx.judgementId) {
|
|
2328
|
+
const j = store.judgements.get(ctx.judgementId);
|
|
2329
|
+
if (j) store.judgements.put({ ...j, inputs: { ...j.inputs, investigations: [...j.inputs.investigations, { jobId: result.jobId, conclusion: result.conclusion.slice(0, 2000), sourceIds: result.sourceIds }] } });
|
|
2330
|
+
}
|
|
2331
|
+
return ok(result);
|
|
2332
|
+
} catch (e) { return fail((e as Error).message); }
|
|
2333
|
+
},
|
|
2334
|
+
}));
|
|
2335
|
+
}
|
|
2336
|
+
return tools;
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
export const NODE_CATEGORIES = NODE_CATEGORY;
|
|
2340
|
+
export type { GraphNode };
|