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,1395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The clerk method's rounds (Spec v3.0 §3.3, §3.7, §3.8; CKC-23, CKC-13 v3.1, CKC-07 v3.0), as D99 runs them.
|
|
3
|
+
*
|
|
4
|
+
* A round — the takeover's first usable round, the deepening at the depth the owner chose, each Follow up — is the
|
|
5
|
+
* program's groundwork, then one main agent, then the synthesis in a session of its own (D103), then the independent
|
|
6
|
+
* spot-check and the program's process:
|
|
7
|
+
*
|
|
8
|
+
* First usable ledger → session drafts → main → synthesis → process
|
|
9
|
+
* Deepen ledger → session drafts → main → synthesis → spot-check → process
|
|
10
|
+
* Follow up ledger → session drafts → main → synthesis → spot-check → process
|
|
11
|
+
*
|
|
12
|
+
* The main agent is one model job in one pi session through the round's stages (`ClerkRound.stage`, clerk-steps.ts
|
|
13
|
+
* `ROUND_STAGES`): it moves on with `pk_stage`, which records each stage and returns its skill, and it sends its lanes —
|
|
14
|
+
* subagents that answer a question each and write their slots — several in one `pk_send_lanes` call (lane-tools.ts). Each
|
|
15
|
+
* stage has a skill (skills.ts); the program keeps the order, the four kinds of question, the coverage gate and the
|
|
16
|
+
* breakpoint candidates (stage-tools.ts). The main job is done once it has handed the round over (`ClerkRound.handover`):
|
|
17
|
+
* its last stage — the reconciling, or the cross-check — ends with `pk_stage({ to: "synthesis", handover })`. A session
|
|
18
|
+
* that ends before is run again in its own session, told which stage it stopped at.
|
|
19
|
+
*
|
|
20
|
+
* The synthesis (D103; the owner: 「可以,合成另开会话,做吧」) is the round's product look-back, one model job in a fresh pi
|
|
21
|
+
* session, the way the spot-check is: it was not there for the round, so it is given the round's documents by reference —
|
|
22
|
+
* the question list, the lanes' briefs and reports, the adoption record — the main agent's handover, the lanes' Unsure
|
|
23
|
+
* items, what is still open as counts, the candidates' state, what the six things are judged from, what the round wrote
|
|
24
|
+
* by position, and (a deepening, a Follow up) the notes still current from earlier rounds, each of which gets a result
|
|
25
|
+
* (`startSynthesis`). It reads the reports first and then writes the notes, the six things, the send-backs, area
|
|
26
|
+
* understanding and the round's Result. It is done once the round has its Result; a session that ends before is run again
|
|
27
|
+
* in its own session. On the CM run the main agent entered the synthesis at 341–399K tokens and wrote, within three
|
|
28
|
+
* minutes and from memory, the two notes the spot-check had to correct.
|
|
29
|
+
*
|
|
30
|
+
* The planner is a pull like the one before it: after every job it looks at the round under way and starts the next step
|
|
31
|
+
* whose predecessors are done.
|
|
32
|
+
*
|
|
33
|
+
* Every job is given the organizing plan as it stands when it is queued (Spec §3.7; D62, the owner: 「之后的整理照它执行」),
|
|
34
|
+
* so the owner's correction in conversation applies to whatever is not yet queued (D97).
|
|
35
|
+
*
|
|
36
|
+
* What a round produced is counted by the program when it closes (CKC-23 AC-6): the writes of its jobs by workbench
|
|
37
|
+
* position, what served as groundwork, and what served nothing.
|
|
38
|
+
*
|
|
39
|
+
* A round runs through (Spec §3.10; owner D97): a step's job that ends without finishing is run again by the program, and
|
|
40
|
+
* one that still does not finish is listed — on the round and in the coverage, with why — and the round goes on with what
|
|
41
|
+
* was saved (`stepEnding`, `RUN_AGAIN_LIMIT`). Only the owner's Stop holds a round, until the owner continues it. The
|
|
42
|
+
* main agent's lanes are the main agent's to run again (`pk_send_lanes`), not the planner's.
|
|
43
|
+
*/
|
|
44
|
+
import type { App } from '../../server/app.ts';
|
|
45
|
+
import type { ProjectStore } from '../../store/project-store.ts';
|
|
46
|
+
import type { ClerkRound, ClerkStage, KeeperNumber, RoundDoc, RoundFailure, RoundKind, RoundStepKind } from '../../model/k-types.ts';
|
|
47
|
+
import type { JudgementRecord, KeeperJob, Project, Usage } from '../../model/types.ts';
|
|
48
|
+
import { newId } from '../../model/ids.ts';
|
|
49
|
+
import { organizingHeld } from '../held.ts';
|
|
50
|
+
import { countRound, roundResultOf, startRound as openFollowUpRecord } from '../adjustment.ts';
|
|
51
|
+
import { syncProjectFolder } from '../project-folder.ts';
|
|
52
|
+
import { scheduleDueFor, scheduleInForce } from './schedule-state.ts';
|
|
53
|
+
import { takeoverState } from './takeover-state.ts';
|
|
54
|
+
import { codeAnomalyCandidates, type CodeAnomalyCandidate } from '../../codemap/facts.ts';
|
|
55
|
+
import { computeBreakpoints, processBlock } from '../../process/index.ts';
|
|
56
|
+
import { Facts } from '../../process/facts.ts';
|
|
57
|
+
import { buildUnits, type UnitIndex } from '../../process/units.ts';
|
|
58
|
+
import { breakpointClaims, claimCheckBlock, type ClaimText } from '../../process/claim-check.ts';
|
|
59
|
+
import { SPOT_CHECK_SAMPLE, placementLine, spotCheckBlock, spotCheckTargets } from '../../process/breakpoint-candidates.ts';
|
|
60
|
+
import { settleProgramPlacements } from './placement-inference.ts';
|
|
61
|
+
import { unnumberedWorkBlock } from '../../process/unnumbered.ts';
|
|
62
|
+
import { ownerUtterances } from '../../sources/sessions/utterances.ts';
|
|
63
|
+
import { positioningBlock } from './positioning.ts';
|
|
64
|
+
import { clerkStepPrompt } from './clerk-prompts.ts';
|
|
65
|
+
import { judgedLinesBlock, ownerLinesCountBlock } from './owner-lines.ts';
|
|
66
|
+
import { emptySlotsBlock } from './slots.ts';
|
|
67
|
+
import { generationCandidatesBlock } from './generation-check.ts';
|
|
68
|
+
import { clerkPending } from './clerk-coverage.ts';
|
|
69
|
+
import { roundBaseline, roundNews } from './round-news.ts';
|
|
70
|
+
import { HANDOVER_TO, ROUND_STAGES } from '../clerk-steps.ts';
|
|
71
|
+
import { roundBlockOf, rulesBlockOf } from './round-blocks.ts';
|
|
72
|
+
import { spotCheckSkill, stageSkill, synthesisSkill } from './skills.ts';
|
|
73
|
+
import { absenceLookedBlock, unsureBlock } from './absence.ts';
|
|
74
|
+
import { handedOver, lastStageOf, stageOfRound } from './stage-gate.ts';
|
|
75
|
+
import { endStage, handoverDoc, recomputesCandidates, skippableStages } from './stage-tools.ts';
|
|
76
|
+
import { lanesOf } from './lane-tools.ts';
|
|
77
|
+
import { candidatesAnswered, OPEN_KEYS, openCounts, roundOpen, unplacedNote } from './round-open.ts';
|
|
78
|
+
import { looksAtStandingNotes, standingCounts, standingNotesBlock } from './standing-notes.ts';
|
|
79
|
+
|
|
80
|
+
export const ACTIVE_STATUSES: readonly string[] = ['Queued', 'Running', 'Waiting for quota', 'Paused'];
|
|
81
|
+
|
|
82
|
+
// ───────────────────────── a step that did not finish (Spec §3.10) ─────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* How many times the program runs a step's job again by itself (Spec §3.10: 瞬时失败自动重试;多次失败后停下,列入覆盖情况的
|
|
86
|
+
* 失败清单并写明原因,不静默跳过): twice, so a job runs at most three times before it is listed and the round goes on
|
|
87
|
+
* without it. The same job runs again, with what it saved kept and why its last run ended at the end of its task.
|
|
88
|
+
* - A run that a restart of ProjectKeeper cut short goes on and does not count (§3.10: 被中断的单项工作按 Continue 的规则继续).
|
|
89
|
+
* - The owner's Retry or Continue starts the count again.
|
|
90
|
+
* - The owner's Stop is the owner's choice: the round waits until the owner continues the job.
|
|
91
|
+
* - A job its time limit stopped is not run again by itself: the limit is there for a step that never ends, and another run
|
|
92
|
+
* would give it the same hours again. It is listed at once; the owner can Continue it.
|
|
93
|
+
*/
|
|
94
|
+
export const RUN_AGAIN_LIMIT = 2;
|
|
95
|
+
|
|
96
|
+
/** One earlier run of a round step's job that ended without finishing, kept on the job (`task.extra.runs`). */
|
|
97
|
+
export interface StepRun {
|
|
98
|
+
readonly endedAt: string;
|
|
99
|
+
readonly status: 'Failed' | 'Stopped';
|
|
100
|
+
readonly reason: string;
|
|
101
|
+
/** What it had spent: the job's usage when that run ended. runtime.ts `run` counts a run from zero, except a run that
|
|
102
|
+
* goes on from an interrupted one — a restart, the provider's limits — which adds to it (D99). */
|
|
103
|
+
readonly usage: Usage | null;
|
|
104
|
+
/** Who ran it again, and when. */
|
|
105
|
+
readonly again: 'program' | 'owner';
|
|
106
|
+
readonly at: string;
|
|
107
|
+
/** Whether it counts toward RUN_AGAIN_LIMIT: a run a restart cut short does not, nor one the owner ran again. */
|
|
108
|
+
readonly counted: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* How a step's job stands for its round: finished, still going, stopped by the owner (the round waits), to be run again
|
|
113
|
+
* now, or listed as not finished (the round goes on without it).
|
|
114
|
+
*/
|
|
115
|
+
export type StepEnding =
|
|
116
|
+
| { readonly kind: 'done' | 'active' | 'owner' }
|
|
117
|
+
| { readonly kind: 'again'; readonly reason: string; readonly counted: boolean }
|
|
118
|
+
| { readonly kind: 'listed'; readonly reason: string; readonly runs: number };
|
|
119
|
+
|
|
120
|
+
/** The runtime's words for a job a restart cut short (runtime.ts `init`) and one its time limit stopped (`run`). */
|
|
121
|
+
const RESTARTED = /restarted while this work was running/i;
|
|
122
|
+
const TIME_LIMIT = /time limit/i;
|
|
123
|
+
|
|
124
|
+
type JobExtra = { readonly runs?: readonly StepRun[]; readonly program?: string } & Record<string, unknown>;
|
|
125
|
+
const extraOfJob = (job: KeeperJob): JobExtra => ((job.task as { extra?: JobExtra | null } | null)?.extra ?? {});
|
|
126
|
+
|
|
127
|
+
/** The earlier runs of a step's job that ended without finishing, oldest first. */
|
|
128
|
+
export const runsOf = (job: KeeperJob): readonly StepRun[] => extraOfJob(job).runs ?? [];
|
|
129
|
+
|
|
130
|
+
/** A job that stands for the program's own part of a step — starting it, or holding its reading against the plan — when that part threw. */
|
|
131
|
+
export const isProgramPart = (job: KeeperJob): boolean => job.agent === 'program' && extraOfJob(job).program === 'part';
|
|
132
|
+
|
|
133
|
+
const reasonOf = (job: KeeperJob): string => job.error?.trim() || `${job.status === 'Stopped' ? 'Stopped' : 'Failed'} with no reason recorded`;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The program's part of a step can run again when it can do so without doubling what is there: the step has not started
|
|
137
|
+
* (no job of it but such parts).
|
|
138
|
+
*/
|
|
139
|
+
function partCanRunAgain(store: ProjectStore, job: KeeperJob): boolean {
|
|
140
|
+
if (!job.step) return false;
|
|
141
|
+
const { roundId, kind } = job.step;
|
|
142
|
+
return !store.jobs.find((j) => j.step?.roundId === roundId && j.step.kind === kind && !isProgramPart(j));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* How a round step's job stands (`StepEnding`), by the rule of `RUN_AGAIN_LIMIT`. Once its round has closed, nothing of
|
|
147
|
+
* it runs again by itself: what did not finish is listed, and runs again only when the owner asks.
|
|
148
|
+
*/
|
|
149
|
+
export function stepEnding(store: ProjectStore, job: KeeperJob): StepEnding {
|
|
150
|
+
if (job.status === 'Done') return { kind: 'done' };
|
|
151
|
+
if (ACTIVE_STATUSES.includes(job.status)) return { kind: 'active' };
|
|
152
|
+
const runs = runsOf(job);
|
|
153
|
+
const reason = reasonOf(job);
|
|
154
|
+
const listed: StepEnding = { kind: 'listed', reason, runs: runs.length + 1 };
|
|
155
|
+
const round = job.step ? store.clerkRounds.get(job.step.roundId) : undefined;
|
|
156
|
+
if (round && round.status !== 'Running') return listed;
|
|
157
|
+
if (job.status === 'Stopped') {
|
|
158
|
+
if (RESTARTED.test(reason)) return { kind: 'again', reason, counted: false };
|
|
159
|
+
if (TIME_LIMIT.test(reason)) return listed;
|
|
160
|
+
return { kind: 'owner' };
|
|
161
|
+
}
|
|
162
|
+
if (job.status !== 'Failed') return { kind: 'owner' };
|
|
163
|
+
if (isProgramPart(job) && !partCanRunAgain(store, job)) return { kind: 'listed', reason: `${reason} (the step had started in part, so it is not started again)`, runs: runs.length + 1 };
|
|
164
|
+
// A job the program did itself runs again only if it is the ledger, the process or the program's part of a step.
|
|
165
|
+
if (job.agent === 'program' && !isProgramPart(job) && job.step?.kind !== 'ledger' && job.step?.kind !== 'process') return listed;
|
|
166
|
+
// What the program ran again since the owner last did: the owner's Retry or Continue starts the count again.
|
|
167
|
+
const since = runs.slice(runs.map((r) => r.again).lastIndexOf('owner') + 1).filter((r) => r.counted).length;
|
|
168
|
+
return since < RUN_AGAIN_LIMIT ? { kind: 'again', reason, counted: true } : listed;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The main agent's work in a round is done once it has handed the round over to the synthesis (D103). */
|
|
172
|
+
export function mainFinished(_store: ProjectStore, round: ClerkRound): boolean {
|
|
173
|
+
return handedOver(round);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The synthesis' work in a round is done once the round has its Result (D103: 合成才算完). */
|
|
177
|
+
export function synthesisFinished(store: ProjectStore, round: Pick<ClerkRound, 'id'>): boolean {
|
|
178
|
+
return store.roundDocs.find((d) => d.roundId === round.id && d.kind === 'Result') !== undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** "failed 3 times", "was stopped at its time limit": how a listed job ended, in words. */
|
|
182
|
+
function endedText(status: 'Failed' | 'Stopped', runs: number): string {
|
|
183
|
+
return status === 'Stopped' ? 'was stopped' : runs > 1 ? `failed ${runs} times` : 'failed';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* What the coverage's failure list says of a round step's job that did not finish (Spec §3.10, §1.11): which job, how it
|
|
188
|
+
* ended and why, and where it stands — run again by the program (once organizing resumes, when it is paused: `held`),
|
|
189
|
+
* waiting for the owner, or listed with the round going on (or closed) without it.
|
|
190
|
+
*/
|
|
191
|
+
export function stepFailureText(store: ProjectStore, job: KeeperJob, held = false): string {
|
|
192
|
+
const round = job.step ? store.clerkRounds.get(job.step.roundId) : undefined;
|
|
193
|
+
const e = stepEnding(store, job);
|
|
194
|
+
const runs = runsOf(job).length + 1;
|
|
195
|
+
const again = job.status === 'Stopped' ? 'Continue' : 'Retry';
|
|
196
|
+
if (e.kind === 'owner') return `${job.scope.label}: stopped by you — the round waits until you Continue it`;
|
|
197
|
+
if (e.kind === 'again') {
|
|
198
|
+
const when = held ? ' once organizing resumes' : '';
|
|
199
|
+
return e.counted
|
|
200
|
+
? `${job.scope.label}: ${endedText('Failed', runs)} — ${e.reason}. ${job.step?.kind === 'lane' ? 'The main agent runs it again when it sends its lanes again' : `The program runs it again${when}`}`
|
|
201
|
+
: `${job.scope.label}: cut short when ProjectKeeper restarted — it goes on${when}`;
|
|
202
|
+
}
|
|
203
|
+
if (e.kind !== 'listed') return `${job.scope.label}: ${job.status}`;
|
|
204
|
+
const where = round && round.status !== 'Running' ? `${round.kind} round ${round.number} closed without it` : 'the round went on without it';
|
|
205
|
+
return `${job.scope.label}: ${endedText(job.status === 'Stopped' ? 'Stopped' : 'Failed', e.runs)} — ${e.reason}. Listed: ${where}; ${again} runs it again`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** A listed job as the round lists it. */
|
|
209
|
+
function failureOf(job: KeeperJob, reason: string, runs: number, at: string): RoundFailure {
|
|
210
|
+
return { jobId: job.id, step: job.step!.kind, label: job.scope.label, status: job.status === 'Stopped' ? 'Stopped' : 'Failed', reason, runs, at };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const CONTINUING = '\n\n=== This job ran before and did not finish';
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A job's task with its earlier runs at the end, for the model that runs it again: when and how each ended and why, what
|
|
217
|
+
* the job saved (it stays where it was saved), and to go on from there. Written afresh on every run again.
|
|
218
|
+
*/
|
|
219
|
+
export function withEarlierRuns(prompt: string, job: KeeperJob, runs: readonly StepRun[]): string {
|
|
220
|
+
const base = prompt.split(CONTINUING)[0] ?? prompt;
|
|
221
|
+
const ended = runs.map((r, i) => `${i + 1}. ${r.endedAt.slice(0, 16).replace('T', ' ')} UTC — ${r.status === 'Stopped' ? 'stopped' : 'failed'}: ${r.reason}${r.again === 'owner' ? ' (the owner ran it again)' : ''}`);
|
|
222
|
+
const saved = job.savedResults.map((s) => `- ${s.collection} ${s.id}: ${s.label.replace(/\s+/g, ' ').slice(0, 160)}`);
|
|
223
|
+
return [
|
|
224
|
+
`${base}${CONTINUING} — this is run ${runs.length + 1}`,
|
|
225
|
+
...ended,
|
|
226
|
+
saved.length ? `What its earlier runs saved stays where it was saved (${saved.length}):\n${saved.join('\n')}` : 'Its earlier runs saved nothing that is kept.',
|
|
227
|
+
'Go on from there: read back what you need, do not write again what is saved, and do what is left of the task. When the reason above is something the job did — a call the tools refused, a reply cut off at the output limit — do it differently this time.',
|
|
228
|
+
].join('\n');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** What did not finish earlier in the round, for a step that starts after it (the round goes on with what was saved). */
|
|
232
|
+
function failuresBlock(failures: readonly RoundFailure[]): string {
|
|
233
|
+
return [
|
|
234
|
+
'=== What did not finish earlier in this round',
|
|
235
|
+
...failures.map((f) => `- ${f.label} (${f.jobId}): ${endedText(f.status, f.runs)} — ${f.reason}`),
|
|
236
|
+
'What those jobs saved is on the workbench; what they did not do is missing. Work from what is there, and where a gap leaves your work without its ground, say so in what you write rather than assume it was done.',
|
|
237
|
+
].join('\n');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The steps of each kind of round, in order (§3.3, §3.7, §3.8; D99, D103). `ledger` and `process` are the program's; `main`
|
|
242
|
+
* is the main agent through its stages, whose lanes it sends itself; `synthesis` is the round's product look-back in a
|
|
243
|
+
* session of its own, after the main agent's handover; `spot-check` is the independent check of a deepening and a Follow
|
|
244
|
+
* up — the round's final re-check, after the synthesis (a first usable round lights nothing and gets none, CKC-23 AC-22:
|
|
245
|
+
* the notes it wrote are given a result by the next round's synthesis and checked by that round's spot-check).
|
|
246
|
+
*/
|
|
247
|
+
export const ROUND_STEPS: Readonly<Record<RoundKind, readonly RoundStepKind[]>> = {
|
|
248
|
+
'First usable': ['ledger', 'session-drafts', 'main', 'synthesis', 'process'],
|
|
249
|
+
Deepen: ['ledger', 'session-drafts', 'main', 'synthesis', 'spot-check', 'process'],
|
|
250
|
+
'Follow up': ['ledger', 'session-drafts', 'main', 'synthesis', 'spot-check', 'process'],
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The lists the program computes for a job to judge from, besides the round's own blocks (Spec §3.3: the model judges,
|
|
255
|
+
* the program supplies what it judges from; D76). Found missing in test-C-1, and the owner's rule of 2026-09-28:
|
|
256
|
+
* - `owner-lines`: the owner's Decision and Confirmation lines no rule or Owner's words item cites yet — for the jobs
|
|
257
|
+
* that write rules and Owner's words;
|
|
258
|
+
* - `claims`: for the items a "let pass" or "dropped" claim names, the later commits, merges and reports that name them —
|
|
259
|
+
* for the jobs that adopt, write or check such claims;
|
|
260
|
+
* - `unnumbered`: the work merged without a task number — for the jobs that build work items.
|
|
261
|
+
* - `unsure` (CN, E152): what this round's lane reports marked Unsure — for the synthesis, which writes the notes, and
|
|
262
|
+
* the spot-check, which checks them: an Unsure is read to settle it or written as unsure, never restated as fact.
|
|
263
|
+
* The main agent (D99) is given its first stage's lists in its prompt and each later stage's as it enters it
|
|
264
|
+
* (`stageBlocksOf`, `stageEntered`); the synthesis (D103) and the spot-check are given theirs in their prompts.
|
|
265
|
+
*/
|
|
266
|
+
export type MethodBlock = 'owner-lines' | 'claims' | 'unnumbered' | 'unsure';
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The lists the main agent is given with each stage (D99; the skills say they "come as a block given with this stage"):
|
|
270
|
+
* orientation the owner's lines (in the main job's prompt, its first stage); the reconciling the owner's lines and the
|
|
271
|
+
* unnumbered work; the cross-check the owner's lines and the claims, and in a Follow up the unnumbered work too.
|
|
272
|
+
* `pk_stage` returns them as the stage is entered (`stageEntered`). The handover gives the main agent none (D103): the
|
|
273
|
+
* claims and the lanes' Unsure items go to the synthesis job, in its prompt (`methodBlocksOf`).
|
|
274
|
+
*/
|
|
275
|
+
export function stageBlocksOf(stage: ClerkStage, round: RoundKind): MethodBlock[] {
|
|
276
|
+
switch (stage) {
|
|
277
|
+
case 'orientation': return ['owner-lines'];
|
|
278
|
+
case 'reconcile': return ['owner-lines', 'unnumbered'];
|
|
279
|
+
case 'cross-check': return round === 'Follow up' ? ['owner-lines', 'claims', 'unnumbered'] : ['owner-lines', 'claims'];
|
|
280
|
+
default: return [];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** The lists a job the planner starts is given in its prompt: the main agent its first stage's; the synthesis and the spot-check the claims and the lanes' Unsure items. */
|
|
284
|
+
export function methodBlocksOf(step: RoundStepKind, round: RoundKind): MethodBlock[] {
|
|
285
|
+
switch (step) {
|
|
286
|
+
case 'main': return stageBlocksOf('orientation', round);
|
|
287
|
+
case 'synthesis': return ['claims', 'unsure'];
|
|
288
|
+
case 'spot-check': return ['claims', 'unsure'];
|
|
289
|
+
default: return [];
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const STEP_LABEL: Readonly<Record<RoundStepKind, string>> = {
|
|
294
|
+
ledger: 'Ledger', 'session-drafts': 'Session drafts', orientation: 'Orientation', skeleton: 'Skeleton', dig: 'Deep sweep',
|
|
295
|
+
'cross-check': 'Cross-check', process: 'Process and breakpoints', synthesis: 'Synthesis', 'spot-check': 'Spot-check',
|
|
296
|
+
main: 'Main agent', lane: 'Lane',
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* No job has a time limit that would cut it short in this round (owner D90: 「这一轮不管时间和token」); the limit only
|
|
301
|
+
* guards against a job that never ends. A job stopped by it is listed as not finished and the round goes on without it
|
|
302
|
+
* (`stepEnding`); the owner can Continue it. The main agent goes through every stage in one session and waits for its
|
|
303
|
+
* lanes, so its limit is about a day; a lane's, the synthesis' and every other step's is eight hours (E148 D-j).
|
|
304
|
+
*/
|
|
305
|
+
const STEP_TIMEOUT_MS = 8 * 60 * 60_000;
|
|
306
|
+
const MAIN_TIMEOUT_MS = 24 * 60 * 60_000;
|
|
307
|
+
|
|
308
|
+
/** Owner text per session-draft job, so one job never drowns in a long history of sessions. */
|
|
309
|
+
const DRAFT_CHARS_PER_JOB = 60_000;
|
|
310
|
+
const DRAFT_SESSIONS_PER_JOB = 8;
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The program step (§1.16, CKC-22 AC-16): bring the ledger up to date. The ledger is built by `src/ledger`; until a build
|
|
314
|
+
* carries it, the runner is absent and the step records that the ledger was not available.
|
|
315
|
+
*/
|
|
316
|
+
export interface LedgerRunner {
|
|
317
|
+
/** `keeperNumbers`: the numbers the Keeper gave, for the ledger to record with the project's own (CKC-22 AC-5). */
|
|
318
|
+
run(project: Project, extra?: { readonly keeperNumbers?: readonly KeeperNumber[] }): Promise<{ readonly commitsAdded: number; readonly note?: string }>;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The program step after the trunk is built or cross-checked and before the synthesis (§2.12, §3.7): each work item's
|
|
323
|
+
* steps from the ledger and the links the round confirmed, the breakpoints lit and put out, the send-backs moved on. It
|
|
324
|
+
* runs every round, so what the program computes is recomputed each time; the synthesis reads what it left.
|
|
325
|
+
*/
|
|
326
|
+
export interface ProcessRunner {
|
|
327
|
+
run(store: ProjectStore, project: Project, roundId: string): Promise<{ readonly note: string }>;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
type StepStart = 'started' | 'skipped' | 'wait';
|
|
331
|
+
|
|
332
|
+
export class ClerkPlanner {
|
|
333
|
+
private readonly app: App;
|
|
334
|
+
private ledger: LedgerRunner | null;
|
|
335
|
+
private process: ProcessRunner | null = null;
|
|
336
|
+
private readonly ledgerRunning = new Set<string>();
|
|
337
|
+
private readonly processRunning = new Set<string>();
|
|
338
|
+
|
|
339
|
+
constructor(app: App, ledger: LedgerRunner | null = null) {
|
|
340
|
+
this.app = app;
|
|
341
|
+
this.ledger = ledger;
|
|
342
|
+
// D99: the main agent's `pk_stage` reaches the planner as it enters a stage (runtime hook).
|
|
343
|
+
if (app?.keeper?.hooks) app.keeper.hooks.stageEntered = (projectId, roundId, stage) => this.stageEntered(projectId, roundId, stage);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
setLedgerRunner(runner: LedgerRunner | null): void { this.ledger = runner; }
|
|
347
|
+
setProcessRunner(runner: ProcessRunner | null): void { this.process = runner; }
|
|
348
|
+
|
|
349
|
+
/** The clock the schedule is read against; a test sets it. */
|
|
350
|
+
clock: () => number = () => Date.now();
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* One planning pass for a project: advance the round under way, or start the next round when one is due. Nothing is
|
|
354
|
+
* planned for a project the owner has not started (D105), while its material is still being read in after `Start`,
|
|
355
|
+
* or while it is being cleared.
|
|
356
|
+
*/
|
|
357
|
+
async plan(projectId: string): Promise<void> {
|
|
358
|
+
if (this.app.isClearing?.(projectId)) return;
|
|
359
|
+
const store = this.app.store(projectId);
|
|
360
|
+
const project = this.app.project(projectId);
|
|
361
|
+
const running = this.runningRound(store);
|
|
362
|
+
if (running) {
|
|
363
|
+
// A scheduled time that comes while a round runs starts no second round; what changed meanwhile is the next round's (§3.8).
|
|
364
|
+
this.scheduledTime(store, project, true);
|
|
365
|
+
await this.advance(store, this.app.project(projectId), running);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (organizingHeld(project)) return;
|
|
369
|
+
if (store.clerkRounds.size === 0 && this.app.intakeBusy?.(projectId)) return;
|
|
370
|
+
const next = this.nextRoundKind(store, project);
|
|
371
|
+
if (!next) return;
|
|
372
|
+
const round = this.startRound(store, this.app.project(projectId), next);
|
|
373
|
+
await this.advance(store, this.app.project(projectId), round);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
runningRound(store: ProjectStore): ClerkRound | null {
|
|
377
|
+
return store.clerkRounds.filter((r) => r.status === 'Running').sort((a, b) => a.startedAt.localeCompare(b.startedAt))[0] ?? null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The next round due (§3.7, §3.8; D105). Nothing is due on a project the owner has not started: adding a project
|
|
382
|
+
* starts no round. From `Start` the first usable round comes first, then — without asking again — the deepening to the
|
|
383
|
+
* depth chosen before the start (`Full` or `Focused`; `First picture only` deepens nothing). A depth deeper than the
|
|
384
|
+
* one that ran, chosen afterwards, is a further deepening that goes on from what is done. Follow up rounds come once
|
|
385
|
+
* the takeover is done, on the owner's schedule or when the owner presses Follow up. A round the owner stopped, or one
|
|
386
|
+
* that failed, is not started again by itself: the owner continues or retries its step.
|
|
387
|
+
*/
|
|
388
|
+
nextRoundKind(store: ProjectStore, project: Project): RoundKind | null {
|
|
389
|
+
const state = takeoverState(store, project);
|
|
390
|
+
if (state.phase === 'Not started') return null;
|
|
391
|
+
const last = (kind: RoundKind) => state.rounds.filter((r) => r.kind === kind).sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0];
|
|
392
|
+
const first = last('First usable');
|
|
393
|
+
if (!first) return 'First usable';
|
|
394
|
+
if (first.status !== 'Done') return null;
|
|
395
|
+
if (state.phase === 'Under way') {
|
|
396
|
+
const deepen = last('Deepen');
|
|
397
|
+
// A deepening that did not close waits for the owner; one that closed short of the chosen depth is followed by a deeper one.
|
|
398
|
+
return deepen && deepen.status !== 'Done' ? null : 'Deepen';
|
|
399
|
+
}
|
|
400
|
+
return this.followUpDue(store, project) ? 'Follow up' : null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* A Follow up is due when the owner pressed Follow up since the last round (nothing new is needed then); on a
|
|
405
|
+
* `Continuous` schedule, when something waits; and otherwise when a scheduled time has come that was not dealt with
|
|
406
|
+
* (`scheduledTime`) and something waits for a round — what the coverage shows as pending (clerk-coverage.ts): material
|
|
407
|
+
* read since the latest round started, so what came in while a round ran is the next round's (CKC-07 AC-24), and the
|
|
408
|
+
* Keeper's own commit of its project folder opens no round. A scheduled time with nothing to organize starts no round
|
|
409
|
+
* and is recorded as such, so the `Daily` page can say so (§3.8; CKC-07 AC-30).
|
|
410
|
+
*/
|
|
411
|
+
private followUpDue(store: ProjectStore, project: Project): boolean {
|
|
412
|
+
const lastEnd = store.clerkRounds.all().reduce((at, r) => ((r.endedAt ?? '') > at ? r.endedAt ?? '' : at), '');
|
|
413
|
+
if ((project.followUpAt ?? '') > lastEnd) return true;
|
|
414
|
+
if (scheduleInForce(project, takeoverState(store, project).completedAt).frequency === 'Continuous') return clerkPending(store, project).pending.length > 0;
|
|
415
|
+
return this.scheduledTime(store, project, false);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** What starts the Follow up round that is due now: the owner's press since the last round, else the schedule. */
|
|
419
|
+
private followUpStartedBy(store: ProjectStore, project: Project): 'owner' | 'schedule' | 'continuous' {
|
|
420
|
+
const lastEnd = store.clerkRounds.all().reduce((at, r) => ((r.endedAt ?? '') > at ? r.endedAt ?? '' : at), '');
|
|
421
|
+
if ((project.followUpAt ?? '') > lastEnd) return 'owner';
|
|
422
|
+
return scheduleInForce(project, takeoverState(store, project).completedAt).frequency === 'Continuous' ? 'continuous' : 'schedule';
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Deal with a scheduled time that has come (§3.8 到了时间): returns whether a round is to start for it. With a round
|
|
427
|
+
* already running none starts and what changed is left to the next round; with nothing changed none starts either.
|
|
428
|
+
* Either way the time is recorded as dealt with on the project, so times missed while ProjectKeeper was not running
|
|
429
|
+
* are made up for once — the latest one stands for all of them — and no time is acted on twice.
|
|
430
|
+
*/
|
|
431
|
+
private scheduledTime(store: ProjectStore, project: Project, roundRunning: boolean): boolean {
|
|
432
|
+
const state = takeoverState(store, project);
|
|
433
|
+
if (state.phase !== 'Done') return false;
|
|
434
|
+
const due = scheduleDueFor(store, project, state.completedAt, this.clock());
|
|
435
|
+
if (!due) return false;
|
|
436
|
+
const changed = roundRunning ? false : clerkPending(store, project).pending.length > 0;
|
|
437
|
+
const outcome = roundRunning ? 'a round was running' as const : changed ? 'round' as const : 'nothing changed' as const;
|
|
438
|
+
this.app.updateProject({ ...this.app.project(project.id), scheduleHandled: { slot: due.slot.toISOString(), at: new Date(this.clock()).toISOString(), outcome, roundId: null } });
|
|
439
|
+
return changed;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ───────────────────────── rounds ─────────────────────────
|
|
443
|
+
|
|
444
|
+
startRound(store: ProjectStore, project: Project, kind: RoundKind): ClerkRound {
|
|
445
|
+
const number = store.clerkRounds.size + 1;
|
|
446
|
+
const id = newId('crd');
|
|
447
|
+
// A deepening runs at the depth the takeover runs to, and keeps it on its own record: what ran is read from there.
|
|
448
|
+
const depth = kind === 'Deepen' ? takeoverState(store, project).chosen ?? project.takeoverDepth ?? 'Full' : null;
|
|
449
|
+
const label = kind === 'First usable' ? `Takeover round ${number}: the first usable picture` : kind === 'Deepen' ? `Takeover round ${number}: deepening (${depth})` : `Follow up round ${number}`;
|
|
450
|
+
const root = this.app.keeper.recordProgramJob(project.id, { kind: 'Organizing', scope: { kind: 'clerk-round', ids: [id], label }, step: null, parentJobId: null, task: { kind: 'clerk-round', roundId: id, roundKind: kind } });
|
|
451
|
+
const now = new Date().toISOString();
|
|
452
|
+
// A Follow up round keeps its judgements in the record it opens now (§5.5): what became a point-in-time record or
|
|
453
|
+
// a decision since the entries were made is settled first, and its cross-check judges into this record.
|
|
454
|
+
const followUpRoundId = kind === 'Follow up' ? openFollowUpRecord(store, project.id, root.id, now).id : null;
|
|
455
|
+
const round: ClerkRound = {
|
|
456
|
+
id, projectId: project.id, kind, number, startedAt: now, endedAt: null, status: 'Running', rootJobId: root.id,
|
|
457
|
+
questionsDocId: null, paths: [], outputs: [], groundwork: [], unplaced: { count: 0, reasons: [] }, spotCheck: null, ledger: null,
|
|
458
|
+
followUpRoundId, updatedAt: now,
|
|
459
|
+
...(depth ? { depth } : {}),
|
|
460
|
+
...(kind === 'Follow up' ? { startedBy: this.followUpStartedBy(store, project) } : {}),
|
|
461
|
+
// Where the Follow up news stands at the start, to tell what this round made new when it closes (§3.8, D79).
|
|
462
|
+
...(kind === 'Follow up' ? { baseline: roundBaseline(store) } : {}),
|
|
463
|
+
};
|
|
464
|
+
store.clerkRounds.put(round, { jobId: root.id, summary: `${label} started` });
|
|
465
|
+
// The scheduled time this round was started for names the round, so the `Daily` page opens it from there.
|
|
466
|
+
const handled = this.app.project(project.id).scheduleHandled;
|
|
467
|
+
if (kind === 'Follow up' && handled && handled.outcome === 'round' && !handled.roundId) this.app.updateProject({ ...this.app.project(project.id), scheduleHandled: { ...handled, roundId: id } });
|
|
468
|
+
return round;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Start the first step of the round whose predecessors are done; close the round when every step is done.
|
|
473
|
+
*
|
|
474
|
+
* A step's job that ended without finishing does not hold the round (Spec §3.10; `stepEnding`): the program runs it
|
|
475
|
+
* again, and once it has done so `RUN_AGAIN_LIMIT` times the job is listed — on the round and in the coverage, with why
|
|
476
|
+
* — and the round goes on with what was saved. Only the owner's Stop holds the round, until the owner continues the job.
|
|
477
|
+
*/
|
|
478
|
+
async advance(store: ProjectStore, project: Project, round: ClerkRound): Promise<void> {
|
|
479
|
+
this.resumeRoot(store, round);
|
|
480
|
+
this.settleMain(store, round);
|
|
481
|
+
this.settleSynthesis(store, round);
|
|
482
|
+
this.syncFailures(store, round);
|
|
483
|
+
for (const step of ROUND_STEPS[round.kind]) {
|
|
484
|
+
const jobs = store.jobs.filter((j) => j.step?.roundId === round.id && j.step.kind === step);
|
|
485
|
+
if (jobs.length === 0) {
|
|
486
|
+
if (organizingHeld(project) && step !== 'ledger') return;
|
|
487
|
+
const r = await this.startStepRecorded(store, project, round, step);
|
|
488
|
+
if (r === 'skipped') continue;
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const endings = jobs.map((job) => ({ job, ending: stepEnding(store, job) }));
|
|
492
|
+
// What ended without finishing runs again now — while organizing is held it waits, as a step's start does.
|
|
493
|
+
const again = endings.filter((e) => e.ending.kind === 'again');
|
|
494
|
+
if (again.length) {
|
|
495
|
+
if (organizingHeld(project) && step !== 'ledger') return;
|
|
496
|
+
for (const e of again) await this.runAgain(store, project, round, e.job, 'program').finished;
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
// Still running, or stopped by the owner: the round waits. What is listed does not hold it. The main agent's lanes
|
|
500
|
+
// hold it while they run: they belong to the main agent, which runs them again itself (lane-tools.ts).
|
|
501
|
+
if (endings.some((e) => e.ending.kind === 'active' || e.ending.kind === 'owner')) return;
|
|
502
|
+
if (step === 'main' && store.jobs.find((j) => j.step?.roundId === round.id && j.step.kind === 'lane' && ACTIVE_STATUSES.includes(j.status)) !== undefined) return;
|
|
503
|
+
}
|
|
504
|
+
this.closeRound(store, project, round);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* The main job is done only once it has handed the round over to the synthesis (D103). A session that ended `Done`
|
|
509
|
+
* before that did not finish its work: it is recorded as not finished, with where it stopped, so it runs again — in
|
|
510
|
+
* its own session — or is listed like any job that did not finish.
|
|
511
|
+
*/
|
|
512
|
+
private settleMain(store: ProjectStore, round: ClerkRound): void {
|
|
513
|
+
const current = store.clerkRounds.get(round.id) ?? round;
|
|
514
|
+
for (const job of store.jobs.filter((j) => j.step?.roundId === round.id && j.step.kind === 'main' && j.status === 'Done')) {
|
|
515
|
+
if (mainFinished(store, current)) continue;
|
|
516
|
+
const missing = `in the ${stageOfRound(current)} stage, before its handover to the synthesis`;
|
|
517
|
+
store.jobs.put({ ...job, status: 'Failed', error: `The main agent's session ended ${missing}` }, { jobId: job.id, summary: `${job.scope.label}: its session ended ${missing}; the main agent's work is not done` });
|
|
518
|
+
}
|
|
519
|
+
// Its work is done at the handover. A session that failed after it — the reply that follows the handover refused by
|
|
520
|
+
// the provider, or cut short by a restart — has nothing left to do: it is not run again, and the synthesis starts.
|
|
521
|
+
// The owner's Stop stays the owner's.
|
|
522
|
+
if (!mainFinished(store, current)) return;
|
|
523
|
+
for (const job of store.jobs.filter((j) => j.step?.roundId === round.id && j.step.kind === 'main' && (j.status === 'Failed' || (j.status === 'Stopped' && RESTARTED.test(j.error ?? ''))))) {
|
|
524
|
+
const why = reasonOf(job);
|
|
525
|
+
store.jobs.put({ ...job, status: 'Done', error: null, resume: null, endedAt: job.endedAt ?? new Date().toISOString() }, { jobId: job.id, summary: `${job.scope.label}: its session ended after its handover (${why.slice(0, 200)}); its work was done at the handover` });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* The synthesis job is done only once the round has its Result (D103: 合成才算完). A session that ended `Done` without
|
|
531
|
+
* one did not finish: it is recorded as not finished, so it runs again — in its own session — or is listed.
|
|
532
|
+
*/
|
|
533
|
+
private settleSynthesis(store: ProjectStore, round: ClerkRound): void {
|
|
534
|
+
if (synthesisFinished(store, round)) return;
|
|
535
|
+
for (const job of store.jobs.filter((j) => j.step?.roundId === round.id && j.step.kind === 'synthesis' && j.status === 'Done' && j.agent !== 'program')) {
|
|
536
|
+
const missing = 'without writing the round\'s Result';
|
|
537
|
+
store.jobs.put({ ...job, status: 'Failed', error: `The synthesis' session ended ${missing}` }, { jobId: job.id, summary: `${job.scope.label}: its session ended ${missing}; the round's synthesis is not done` });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** After a restart the round's root job reads Stopped (runtime.ts `init`); the round goes on, so its root is Running again. */
|
|
542
|
+
private resumeRoot(store: ProjectStore, round: ClerkRound): void {
|
|
543
|
+
const root = store.jobs.get(round.rootJobId);
|
|
544
|
+
if (!root || root.status !== 'Stopped' || !RESTARTED.test(root.error ?? '')) return;
|
|
545
|
+
store.jobs.put({ ...root, status: 'Running', endedAt: null, error: null }, { jobId: root.id, summary: `${root.scope.label}: goes on after ProjectKeeper restarted` });
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** The round's list of what did not finish (`ClerkRound.failures`), kept as its steps' jobs end and run again. */
|
|
549
|
+
private syncFailures(store: ProjectStore, round: ClerkRound): RoundFailure[] {
|
|
550
|
+
const current = store.clerkRounds.get(round.id) ?? round;
|
|
551
|
+
const before = new Map((current.failures ?? []).map((f) => [f.jobId, f]));
|
|
552
|
+
const now = new Date().toISOString();
|
|
553
|
+
const failures = store.jobs.filter((j) => j.step?.roundId === round.id).sort((a, b) => a.queuedAt.localeCompare(b.queuedAt)).flatMap((job) => {
|
|
554
|
+
const e = stepEnding(store, job);
|
|
555
|
+
return e.kind === 'listed' ? [failureOf(job, e.reason, e.runs, before.get(job.id)?.at ?? now)] : [];
|
|
556
|
+
});
|
|
557
|
+
if (JSON.stringify(failures) === JSON.stringify(current.failures ?? [])) return failures;
|
|
558
|
+
const added = failures.filter((f) => !before.has(f.jobId));
|
|
559
|
+
const summary = added.length
|
|
560
|
+
? `Round ${round.number}: ${added.map((f) => `${f.label} ${endedText(f.status, f.runs)} (${f.reason.slice(0, 160)})`).join('; ')} — listed; the round goes on without ${added.length === 1 ? 'it' : 'them'}`
|
|
561
|
+
: `Round ${round.number}: ${failures.length} job${failures.length === 1 ? '' : 's'} listed as not finished`;
|
|
562
|
+
store.clerkRounds.put({ ...current, failures, updatedAt: now }, { jobId: round.rootJobId, summary });
|
|
563
|
+
return failures;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Run a step's job again (`stepEnding`): the same job, what it saved kept. A model's job goes back on the queue with its
|
|
568
|
+
* earlier runs — when and why each ended, what it saved — at the end of its task (runtime.ts `restartJob`); a job the
|
|
569
|
+
* program did itself (the ledger, the process, the program's part of a step) the program runs again here, since the
|
|
570
|
+
* runtime runs only a model's jobs. `ok` says whether it runs; `finished` settles when a program's run has ended.
|
|
571
|
+
*/
|
|
572
|
+
private runAgain(store: ProjectStore, project: Project, round: ClerkRound, job: KeeperJob, by: 'program' | 'owner'): { readonly ok: boolean; readonly finished: Promise<void> } {
|
|
573
|
+
const no = { ok: false, finished: Promise.resolve() };
|
|
574
|
+
if (job.status !== 'Failed' && job.status !== 'Stopped') return no;
|
|
575
|
+
const ending = stepEnding(store, job);
|
|
576
|
+
if (job.agent === 'program' && !this.programCanRunAgain(store, job)) return no;
|
|
577
|
+
const now = new Date().toISOString();
|
|
578
|
+
const run: StepRun = {
|
|
579
|
+
endedAt: job.endedAt ?? now, status: job.status, reason: reasonOf(job), usage: job.agent === 'program' ? null : job.usage, again: by, at: now,
|
|
580
|
+
counted: by === 'program' && ending.kind === 'again' && ending.counted,
|
|
581
|
+
};
|
|
582
|
+
const runs = [...runsOf(job), run];
|
|
583
|
+
const task = (job.task ?? {}) as { prompt?: string; extra?: Record<string, unknown> | null };
|
|
584
|
+
const extra = { ...(task.extra ?? {}), runs };
|
|
585
|
+
const summary = `${job.scope.label}: run again by ${by === 'owner' ? 'the owner' : 'the program'} (run ${runs.length + 1}); its last run ${job.status === 'Stopped' ? 'was stopped' : 'failed'}: ${run.reason.slice(0, 200)}`;
|
|
586
|
+
if (job.agent === 'program') {
|
|
587
|
+
const running: KeeperJob = { ...job, status: 'Running', startedAt: now, endedAt: null, error: null, task: { ...task, extra } };
|
|
588
|
+
store.jobs.put(running, { jobId: job.id, summary });
|
|
589
|
+
return { ok: true, finished: this.runProgram(store, project, round, running) };
|
|
590
|
+
}
|
|
591
|
+
// The main agent goes on in its own session (D99, the BU continuation), told which stage it stopped at and why its
|
|
592
|
+
// last run ended — its task, where the earlier runs are listed, is not sent again in its session; when its session
|
|
593
|
+
// cannot be opened it starts afresh from its task and its earlier runs, and finds its place with pk_round_state.
|
|
594
|
+
// The synthesis job and the spot-check go on in their own sessions the same way (D103), told what is left of their work.
|
|
595
|
+
const kind = job.step?.kind;
|
|
596
|
+
const where = kind === 'main' ? this.stoppedAt(store, round) : kind === 'synthesis' ? this.synthesisStoppedAt(store, round) : kind === 'spot-check' ? this.spotCheckStoppedAt(store, round) : null;
|
|
597
|
+
const resume = where !== null && job.sessionFile
|
|
598
|
+
? { why: job.resume?.why ?? ('again' as const), since: job.resume?.since ?? job.endedAt ?? now, note: `${where}\nWhy your last run ended: ${run.reason}${run.status === 'Failed' ? ' — when that is something you did (a call the tools refused, a reply cut off at the output limit), do it differently this time.' : ''}` }
|
|
599
|
+
: job.resume ?? null;
|
|
600
|
+
store.jobs.put({ ...job, resume, task: { ...task, prompt: withEarlierRuns(task.prompt ?? '', job, runs), extra } }, { jobId: job.id, summary });
|
|
601
|
+
return { ok: this.app.keeper.restartJob(project.id, job.id), finished: Promise.resolve() };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Where the main agent stopped, for the message its continued session gets (D99). */
|
|
605
|
+
private stoppedAt(store: ProjectStore, round: ClerkRound): string {
|
|
606
|
+
const current = store.clerkRounds.get(round.id) ?? round;
|
|
607
|
+
const stage = stageOfRound(current);
|
|
608
|
+
const order = ROUND_STAGES[current.kind];
|
|
609
|
+
const lanes = lanesOf(store, current);
|
|
610
|
+
const open = lanes.filter((l) => l.status !== 'Done');
|
|
611
|
+
return [
|
|
612
|
+
`You stopped in the ${stage} stage of this ${current.kind} round (its stages: ${order.join(' → ')}, then the handover to the synthesis).`,
|
|
613
|
+
lanes.length ? `Your lanes: ${lanes.length} sent, ${lanes.length - open.length} done${open.length ? `; not done: ${open.map((l) => `${l.name} (${l.status})`).join(', ')} — send the same pk_send_lanes call again to wait for them or let them go on` : ''}.` : '',
|
|
614
|
+
'Call pk_round_state to see where the round stands, then go on from there: your work is done once you have handed the round over to the synthesis (pk_stage to synthesis, with your handover).',
|
|
615
|
+
].filter(Boolean).join(' ');
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** What is left of the synthesis' work, for the message its continued session gets (D103). */
|
|
619
|
+
private synthesisStoppedAt(store: ProjectStore, round: ClerkRound): string {
|
|
620
|
+
const current = store.clerkRounds.get(round.id) ?? round;
|
|
621
|
+
const standing = standingCounts(store, current);
|
|
622
|
+
return [
|
|
623
|
+
`You are the synthesis of this ${current.kind} round, and it has no Result yet.`,
|
|
624
|
+
standing.open ? `${standing.open} of the ${standing.standing} notes standing from earlier rounds have no result yet (pk_round_state { list: "standingNotes" }).` : '',
|
|
625
|
+
'Read back what you wrote (pk_read_assets kind note), do not write it again, and do what is left: the round is done once its Result is written (pk_write_round_doc kind Result).',
|
|
626
|
+
].filter(Boolean).join(' ');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** What is left of the spot-check's work, for the message its continued session gets (D103). */
|
|
630
|
+
private spotCheckStoppedAt(store: ProjectStore, round: ClerkRound): string {
|
|
631
|
+
const current = store.clerkRounds.get(round.id) ?? round;
|
|
632
|
+
const recorded = current.spotCheck?.sampled ?? 0;
|
|
633
|
+
const written = store.roundDocs.find((d) => d.roundId === round.id && d.kind === 'Spot check') !== undefined;
|
|
634
|
+
return [
|
|
635
|
+
`You are the spot-check of this ${current.kind} round. ${recorded} check${recorded === 1 ? ' is' : 's are'} recorded so far${current.spotCheck?.synthesis ? ` (${current.spotCheck.synthesis.checked} of the ${current.spotCheck.synthesis.outputs} checked in full)` : ''}${current.spotCheck?.placement ? `; ${placementLine(current.spotCheck.placement)}` : ''}; a judgement counts once, whichever run records it.`,
|
|
636
|
+
`Go on with what is left of your targets, and ${written ? 'update' : 'write'} the round's Spot check document (pk_write_round_doc kind Spot check).`,
|
|
637
|
+
].join(' ');
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Whether the program can run a job it did itself again: the ledger and the process step, and the program's part of a step that can run again. */
|
|
641
|
+
private programCanRunAgain(store: ProjectStore, job: KeeperJob): boolean {
|
|
642
|
+
const step = job.step;
|
|
643
|
+
if (!step) return false;
|
|
644
|
+
if (isProgramPart(job)) return partCanRunAgain(store, job);
|
|
645
|
+
if (step.kind === 'ledger') return !this.ledgerRunning.has(step.roundId);
|
|
646
|
+
if (step.kind === 'process') return Boolean(this.process) && !this.processRunning.has(step.roundId);
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** The program's own run of a job it did itself, again: the job is Running already and ends with the run. */
|
|
651
|
+
private async runProgram(store: ProjectStore, project: Project, round: ClerkRound, job: KeeperJob): Promise<void> {
|
|
652
|
+
if (isProgramPart(job)) await this.runPart(store, project, round, job);
|
|
653
|
+
else if (job.step?.kind === 'ledger') await this.runLedger(store, project, round, job);
|
|
654
|
+
else await this.runProcess(store, project, round, job);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* The owner's Retry or Continue on a job of a round's step (Spec §3.10: 失败的工作可以重试): it runs again as the program
|
|
659
|
+
* would run it, and the program's count of runs again starts afresh. A job the program did itself runs here; a model's
|
|
660
|
+
* job goes back on the runtime's queue. The round, if it is still under way, waits for it.
|
|
661
|
+
*/
|
|
662
|
+
ownerRunAgain(store: ProjectStore, project: Project, job: KeeperJob): boolean {
|
|
663
|
+
const round = job.step ? store.clerkRounds.get(job.step.roundId) : undefined;
|
|
664
|
+
if (!round || (job.status !== 'Failed' && job.status !== 'Stopped')) return job.agent === 'program' ? false : this.app.keeper.restartJob(project.id, job.id);
|
|
665
|
+
const r = this.runAgain(store, project, round, job, 'owner');
|
|
666
|
+
r.finished.catch((e: unknown) => console.warn(`[organize ${project.id}] ${job.scope.label}: ${(e as Error).message}`));
|
|
667
|
+
return r.ok;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Start a step; when the program's own part of it throws, that is a job of the step and runs again like any other. */
|
|
671
|
+
private async startStepRecorded(store: ProjectStore, project: Project, round: ClerkRound, step: RoundStepKind): Promise<StepStart> {
|
|
672
|
+
try {
|
|
673
|
+
return await this.startStep(store, project, round, step);
|
|
674
|
+
} catch (e) {
|
|
675
|
+
this.recordPart(store, project, round, step, `The program could not start this step: ${(e as Error).message}`);
|
|
676
|
+
return 'started';
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** The program's part of a step failed: a job of the step, ended Failed with why, so it shows, runs again and is listed like any. */
|
|
681
|
+
private recordPart(store: ProjectStore, project: Project, round: ClerkRound, step: RoundStepKind, error: string): void {
|
|
682
|
+
const job = this.app.keeper.recordProgramJob(project.id, { kind: 'Organizing', scope: { kind: 'clerk-step', ids: [round.id], label: `${STEP_LABEL[step]}: the program's part` }, step: { roundId: round.id, kind: step, path: null }, parentJobId: round.rootJobId, task: { kind: 'clerk-step', roundId: round.id, program: 'part' } });
|
|
683
|
+
this.app.keeper.endProgramJob(project.id, job.id, 'Failed', { error });
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** The program's part of a step, run again: start the step. */
|
|
687
|
+
private async runPart(store: ProjectStore, project: Project, round: ClerkRound, job: KeeperJob): Promise<void> {
|
|
688
|
+
const step = job.step!.kind;
|
|
689
|
+
const current = store.clerkRounds.get(round.id) ?? round;
|
|
690
|
+
try {
|
|
691
|
+
const r = await this.startStep(store, project, current, step);
|
|
692
|
+
this.app.keeper.endProgramJob(project.id, job.id, 'Done', { resultText: r === 'skipped' ? 'The step had nothing to do' : 'The step started' });
|
|
693
|
+
} catch (e) {
|
|
694
|
+
this.app.keeper.endProgramJob(project.id, job.id, 'Failed', { error: `The program could not start this step: ${(e as Error).message}` });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private async startStep(store: ProjectStore, project: Project, round: ClerkRound, step: RoundStepKind): Promise<StepStart> {
|
|
699
|
+
switch (step) {
|
|
700
|
+
case 'ledger': return this.runLedger(store, project, round);
|
|
701
|
+
case 'session-drafts': return this.startDrafts(store, project, round);
|
|
702
|
+
case 'main': return this.startMain(store, project, round);
|
|
703
|
+
case 'synthesis': return this.startSynthesis(store, project, round);
|
|
704
|
+
case 'process': return this.runProcess(store, project, round);
|
|
705
|
+
case 'spot-check': return this.startSpotCheck(store, project, round);
|
|
706
|
+
// The main agent sends its lanes itself (pk_send_lanes); the stages before D99 are the main agent's now.
|
|
707
|
+
case 'lane': throw new Error('A lane is sent by the main agent (pk_send_lanes), not by the planner');
|
|
708
|
+
case 'orientation': case 'skeleton': case 'dig': case 'cross-check':
|
|
709
|
+
throw new Error(`The ${step} step is now a stage of the main agent's session, not a step of its own`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* §2.12: the program recomputes each work item's process and its breakpoints, and moves the send-backs on. `again`: the
|
|
715
|
+
* step's job, run again (`runAgain`), already Running.
|
|
716
|
+
*/
|
|
717
|
+
private async runProcess(store: ProjectStore, project: Project, round: ClerkRound, again: KeeperJob | null = null): Promise<StepStart> {
|
|
718
|
+
if (!this.process) return this.skip(store, project, round, 'process', 'The process engine is not available in this build; no breakpoint was computed');
|
|
719
|
+
if (this.processRunning.has(round.id)) return 'wait';
|
|
720
|
+
this.processRunning.add(round.id);
|
|
721
|
+
const job = again ?? this.app.keeper.recordProgramJob(project.id, { kind: 'Organizing', scope: { kind: 'clerk-step', ids: [round.id], label: STEP_LABEL.process }, step: { roundId: round.id, kind: 'process', path: null }, parentJobId: round.rootJobId, task: { kind: 'clerk-step', roundId: round.id } });
|
|
722
|
+
const started = Date.now();
|
|
723
|
+
try {
|
|
724
|
+
const result = await this.process.run(store, project, round.id);
|
|
725
|
+
const ms = Date.now() - started;
|
|
726
|
+
this.app.keeper.endProgramJob(project.id, job.id, 'Done', { resultText: result.note, timing: { wallMs: ms, generationMs: 0, toolMs: ms, queueMs: 0, parseRetryMs: 0, otherMs: 0 } });
|
|
727
|
+
} catch (e) {
|
|
728
|
+
this.app.keeper.endProgramJob(project.id, job.id, 'Failed', { error: (e as Error).message });
|
|
729
|
+
} finally {
|
|
730
|
+
this.processRunning.delete(round.id);
|
|
731
|
+
}
|
|
732
|
+
return 'started';
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Step 0 (§1.16, CKC-22 AC-16): the ledger is brought up to date by the program, before any judging. `again`: the step's
|
|
737
|
+
* job, run again (`runAgain`), already Running.
|
|
738
|
+
*/
|
|
739
|
+
private async runLedger(store: ProjectStore, project: Project, round: ClerkRound, again: KeeperJob | null = null): Promise<StepStart> {
|
|
740
|
+
if (this.ledgerRunning.has(round.id)) return 'wait';
|
|
741
|
+
this.ledgerRunning.add(round.id);
|
|
742
|
+
const job = again ?? this.app.keeper.recordProgramJob(project.id, { kind: 'Organizing', scope: { kind: 'clerk-step', ids: [round.id], label: `${STEP_LABEL.ledger}: bring it up to date` }, step: { roundId: round.id, kind: 'ledger', path: null }, parentJobId: round.rootJobId, task: { kind: 'clerk-step', roundId: round.id } });
|
|
743
|
+
const started = Date.now();
|
|
744
|
+
try {
|
|
745
|
+
const result = this.ledger ? await this.ledger.run(project, { keeperNumbers: store.numbers.all() }) : { commitsAdded: 0, note: 'The ledger is not available in this build; the steps judge from the sources and git directly.' };
|
|
746
|
+
const ms = Date.now() - started;
|
|
747
|
+
store.clerkRounds.put({ ...(store.clerkRounds.get(round.id) ?? round), ledger: { ms, commitsAdded: result.commitsAdded }, updatedAt: new Date().toISOString() });
|
|
748
|
+
this.app.keeper.endProgramJob(project.id, job.id, 'Done', { resultText: result.note ?? `${result.commitsAdded} commits added in ${Math.round(ms / 1000)} s`, timing: { wallMs: ms, generationMs: 0, toolMs: ms, queueMs: 0, parseRetryMs: 0, otherMs: 0 } });
|
|
749
|
+
} catch (e) {
|
|
750
|
+
this.app.keeper.endProgramJob(project.id, job.id, 'Failed', { error: (e as Error).message });
|
|
751
|
+
} finally {
|
|
752
|
+
this.ledgerRunning.delete(round.id);
|
|
753
|
+
}
|
|
754
|
+
// The pull continues with the next step at once.
|
|
755
|
+
setImmediate(() => void this.app.organizing.replan(project.id));
|
|
756
|
+
return 'started';
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** §3.11, D88: one draft per session that has the owner's words and no draft yet (or new words since). */
|
|
760
|
+
private startDrafts(store: ProjectStore, project: Project, round: ClerkRound): StepStart {
|
|
761
|
+
const set = ownerUtterances(store);
|
|
762
|
+
if (typeof set === 'string') return this.skip(store, project, round, 'session-drafts', set);
|
|
763
|
+
const drafted = new Map(store.drafts.all().map((d) => [d.session.file || d.session.sessionId, d.at]));
|
|
764
|
+
const bySession = new Map<string, { chars: number; last: string }>();
|
|
765
|
+
for (const u of set.utterances) {
|
|
766
|
+
if (u.headless) continue; // a scripted run's prompts are a program's or an agent's words, not the owner's
|
|
767
|
+
const cur = bySession.get(u.session) ?? { chars: 0, last: '' };
|
|
768
|
+
bySession.set(u.session, { chars: cur.chars + u.chars, last: (u.at ?? '') > cur.last ? u.at ?? '' : cur.last });
|
|
769
|
+
}
|
|
770
|
+
const todo = [...bySession].filter(([session, s]) => {
|
|
771
|
+
const file = session.slice(session.indexOf(':') + 1);
|
|
772
|
+
const at = drafted.get(file) ?? drafted.get(session);
|
|
773
|
+
return at === undefined || s.last > at;
|
|
774
|
+
});
|
|
775
|
+
if (todo.length === 0) return this.skip(store, project, round, 'session-drafts', 'No session has owner’s words without a draft');
|
|
776
|
+
const batches: { sessions: string[]; chars: number }[] = [];
|
|
777
|
+
for (const [session, s] of todo.sort((a, b) => a[1].last.localeCompare(b[1].last))) {
|
|
778
|
+
const cur = batches[batches.length - 1];
|
|
779
|
+
if (!cur || cur.chars + s.chars > DRAFT_CHARS_PER_JOB || cur.sessions.length >= DRAFT_SESSIONS_PER_JOB) batches.push({ sessions: [session], chars: s.chars });
|
|
780
|
+
else { cur.sessions.push(session); cur.chars += s.chars; }
|
|
781
|
+
}
|
|
782
|
+
batches.forEach((b, i) => {
|
|
783
|
+
const block = `=== Sessions for this job (${b.sessions.length}; about ${b.chars} characters of the owner's words)\n${b.sessions.map((s) => `- ${s}`).join('\n')}`;
|
|
784
|
+
this.enqueueStep(store, project, round, 'session-drafts', `batch ${i + 1} of ${batches.length}`, [block]);
|
|
785
|
+
});
|
|
786
|
+
return 'started';
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* The main agent (D99): one job, one pi session through the round's stages, from its first stage's skill. It is given
|
|
791
|
+
* the round and the organizing plan, the project's positioning and rules, the lists the program computes for its start
|
|
792
|
+
* (`methodBlocksOf('main')`), and what did not finish earlier in the round. Its work ends with its handover to the
|
|
793
|
+
* synthesis (D103), which has the round's judgement record.
|
|
794
|
+
*/
|
|
795
|
+
private startMain(store: ProjectStore, project: Project, round: ClerkRound): StepStart {
|
|
796
|
+
const now = new Date().toISOString();
|
|
797
|
+
const order = ROUND_STAGES[round.kind];
|
|
798
|
+
const first = order[0]!;
|
|
799
|
+
const current = store.clerkRounds.get(round.id) ?? round;
|
|
800
|
+
store.clerkRounds.put({ ...current, stage: first, stageLog: [{ stage: first, startedAt: now, endedAt: null, timing: null }], lanes: current.lanes ?? [], updatedAt: now }, { jobId: round.rootJobId, summary: `Round ${round.number}: the main agent starts in ${first}` });
|
|
801
|
+
const chain = round.kind === 'Follow up'
|
|
802
|
+
? [`=== The document chain\n${this.documentChainChanged(store, round) ? 'A document of the chain has a new version since the last round: build the skeleton for it.' : `No document of the chain changed since the last round: you may skip ${skippableStages('Follow up').filter((s) => s === 'skeleton' || s === 'reconcile').join(' and ')} (pk_stage with why).`}`]
|
|
803
|
+
: [];
|
|
804
|
+
const failures = (store.clerkRounds.get(round.id) ?? round).failures ?? [];
|
|
805
|
+
const prompt = [
|
|
806
|
+
`Task: the main agent of this round — one session through its stages: ${order.join(' → ')}. You start in ${first}. Move to the next stage with pk_stage when a stage is done: it returns that stage's skill. Send the lanes of a stage all in one pk_send_lanes call. Call pk_round_state whenever you need to find your place. Your work is done once you have handed the round over to the synthesis: when your last stage (${lastStageOf(round.kind)}) is done, call pk_stage({ to: "${HANDOVER_TO}", handover: { settled, open, first } }). The synthesis — the notes, the six things, the round's Result — runs after you in a session of its own and reads the lane reports and your handover; you write none of it.`,
|
|
807
|
+
stageSkill(first),
|
|
808
|
+
roundBlockOf(store, project, round),
|
|
809
|
+
...chain,
|
|
810
|
+
positioningBlock(store, project),
|
|
811
|
+
...this.methodBlocks(store, project, round, 'main'),
|
|
812
|
+
// CM (E151): the candidate generations, for orientation to accept or reject (generation-check.ts).
|
|
813
|
+
...this.candidatesBlock(store, project),
|
|
814
|
+
`=== The project's rules in force\n${rulesBlockOf(store) || 'None recorded yet.'}`,
|
|
815
|
+
...(failures.length ? [failuresBlock(failures)] : []),
|
|
816
|
+
].join('\n\n');
|
|
817
|
+
this.app.keeper.enqueue(project.id, {
|
|
818
|
+
kind: 'Organizing', initiator: 'auto', priority: 1, timeoutMs: MAIN_TIMEOUT_MS,
|
|
819
|
+
scope: { kind: 'clerk-step', ids: [round.id], label: STEP_LABEL.main },
|
|
820
|
+
prompt, parentJobId: round.rootJobId, step: { roundId: round.id, kind: 'main', path: null },
|
|
821
|
+
judgementId: null, task: { kind: 'clerk-step', roundId: round.id, route: 'main' },
|
|
822
|
+
});
|
|
823
|
+
return 'started';
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** The candidate generations the program lists for orientation (generation-check.ts); nothing when it cannot. */
|
|
827
|
+
private candidatesBlock(store: ProjectStore, project: Project): string[] {
|
|
828
|
+
try { return [generationCandidatesBlock(store, this.app.ledger.ledger(project.id))]; } catch { return []; }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* What the synthesis is given, as it stands when it starts (§3.4; CKC-23 AC-8 `Based on`): the product reference and
|
|
833
|
+
* the assets, the notes current from earlier, and the round's documents it reads — the lanes' reports, the adoption
|
|
834
|
+
* record and the main agent's handover.
|
|
835
|
+
*/
|
|
836
|
+
private synthesisInputs(store: ProjectStore, round: ClerkRound): JudgementRecord['inputs'] {
|
|
837
|
+
return {
|
|
838
|
+
referenceIds: store.reference.filter((r) => r.validity === 'Current').map((r) => r.id),
|
|
839
|
+
threadIds: store.threads.filter((t) => t.validity === 'Current').map((t) => t.id),
|
|
840
|
+
areaIds: store.reference.filter((r) => r.category === 'Area' && r.validity === 'Current').map((r) => r.id),
|
|
841
|
+
relationIds: [], keyEvidenceSourceIds: [], conflictingSourceIds: [],
|
|
842
|
+
previousNoteIds: store.notes.filter((n) => n.status === 'Current').map((n) => n.id),
|
|
843
|
+
investigations: [],
|
|
844
|
+
roundDocIds: store.roundDocs.filter((d) => d.roundId === round.id && ['Report', 'Adoption', 'Handover'].includes(d.kind)).sort((a, b) => a.at.localeCompare(b.at)).map((d) => d.id),
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* The main agent entered a stage (`pk_stage`, D99): the breakpoint candidates are recomputed where the stage needs them
|
|
850
|
+
* (`recomputesCandidates`), and the stage is given what the program computes for it — the cross-check the lanes'
|
|
851
|
+
* reports, the code anomaly candidates and the claims to check. Handing over to the synthesis (D103) recomputes the
|
|
852
|
+
* candidates and gives the main agent nothing more: the whole picture, the process and the claims go to the synthesis
|
|
853
|
+
* job, in its prompt (`startSynthesis`).
|
|
854
|
+
*/
|
|
855
|
+
async stageEntered(projectId: string, roundId: string, stage: ClerkStage): Promise<{ readonly note: string | null }> {
|
|
856
|
+
const store = this.app.store(projectId);
|
|
857
|
+
const project = this.app.project(projectId);
|
|
858
|
+
const round = store.clerkRounds.get(roundId);
|
|
859
|
+
if (!round) return { note: null };
|
|
860
|
+
const notes: string[] = [];
|
|
861
|
+
if (recomputesCandidates(round.kind, stage)) {
|
|
862
|
+
const ledger = this.app.ledger.ledger(project.id);
|
|
863
|
+
if (!ledger) notes.push('=== Breakpoint candidates\nThe ledger is not available, so the candidates were not recomputed.');
|
|
864
|
+
else {
|
|
865
|
+
try {
|
|
866
|
+
// Recorded as the main job's: what the candidates are as it enters the stage (W6).
|
|
867
|
+
const mainJob = store.jobs.filter((j) => j.step?.roundId === round.id && j.step.kind === 'main').sort((a, b) => b.queuedAt.localeCompare(a.queuedAt))[0];
|
|
868
|
+
computeBreakpoints(store, project, ledger, round.id, mainJob?.id ?? null);
|
|
869
|
+
const open = store.breakpoints.filter((b) => !b.out);
|
|
870
|
+
notes.push(`=== Breakpoint candidates\nRecomputed as you ${stage === HANDOVER_TO ? 'handed over to the synthesis' : `entered ${stage}`}: ${open.filter((b) => !b.lit).length} candidates, ${open.filter((b) => b.looked).length} looked for, ${open.filter((b) => b.lit).length} lit.`);
|
|
871
|
+
} catch (e) {
|
|
872
|
+
notes.push(`=== Breakpoint candidates\nThe program could not recompute them (${(e as Error).message}); judge from the ledger.`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
try {
|
|
877
|
+
const blocks = (as: RoundStepKind) => this.methodBlocks(store, project, round, as, stageBlocksOf(stage, round.kind));
|
|
878
|
+
if (stage === 'reconcile') {
|
|
879
|
+
notes.push(...blocks('skeleton'));
|
|
880
|
+
} else if (stage === 'cross-check') {
|
|
881
|
+
notes.push(this.reportsBlock(store, round), this.codeCandidatesBlock(store, project), ...blocks('cross-check'));
|
|
882
|
+
}
|
|
883
|
+
} catch (e) {
|
|
884
|
+
notes.push(`=== The program's lists for ${stage}\nThe program could not compute them (${(e as Error).message}).`);
|
|
885
|
+
}
|
|
886
|
+
return { note: notes.length ? notes.join('\n\n') : null };
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* The synthesis (D103; Spec §3.3 合成另开会话, §3.4): the round's product look-back, one job in a fresh pi session after the
|
|
891
|
+
* main agent's handover. It was not there for the round, so its prompt is its skill and what it reads from — given by
|
|
892
|
+
* reference and as counts (CM's rules: a list comes on request, nothing is pasted whole):
|
|
893
|
+
* - the round, the organizing plan and the project;
|
|
894
|
+
* - the main agent's handover, in full (it is short and written for this reader), or that there is none;
|
|
895
|
+
* - the round's documents by id and size: the question list, each lane's brief and report, the adoption record;
|
|
896
|
+
* - the lanes' Unsure items and the claims of "not handled" with what came after (`methodBlocksOf('synthesis')`);
|
|
897
|
+
* - what is still open, counted, and what stays unplaced;
|
|
898
|
+
* - the process: the breakpoints lit, the candidates counted, the send-backs open — what the six things are judged from;
|
|
899
|
+
* - what the round wrote on the workbench, by position, and in a Follow up what it made new;
|
|
900
|
+
* - in a deepening and a Follow up, the notes still current from earlier rounds, each to get a result (standing-notes.ts);
|
|
901
|
+
* - the top of the picture by name, the rest as counts.
|
|
902
|
+
* Its judgement record is opened now with these as its inputs (`Based on`, CKC-23 AC-8). A round whose main agent did
|
|
903
|
+
* not hand over (it was listed as not finished) still gets its synthesis, told so. It is done once the round has its
|
|
904
|
+
* Result (`settleSynthesis`).
|
|
905
|
+
*/
|
|
906
|
+
private startSynthesis(store: ProjectStore, project: Project, round: ClerkRound): StepStart {
|
|
907
|
+
const current = store.clerkRounds.get(round.id) ?? round;
|
|
908
|
+
// A round recorded before D103 whose main agent wrote the Result in its own synthesis stage has nothing left to synthesize.
|
|
909
|
+
if (!current.handover && current.stage === 'synthesis' && synthesisFinished(store, current)) {
|
|
910
|
+
return this.skip(store, project, round, 'synthesis', 'The main agent wrote this round’s Result in its own session, before the synthesis had a session of its own');
|
|
911
|
+
}
|
|
912
|
+
// The main agent's handover recomputed the candidates; a round with no handover gets them recomputed here.
|
|
913
|
+
if (!current.handover) {
|
|
914
|
+
try {
|
|
915
|
+
const ledger = this.app.ledger.ledger(project.id);
|
|
916
|
+
if (ledger) computeBreakpoints(store, project, ledger, round.id, store.jobs.filter((j) => j.step?.roundId === round.id && j.step.kind === 'main').sort((a, b) => b.queuedAt.localeCompare(a.queuedAt))[0]?.id ?? null);
|
|
917
|
+
} catch (e) { console.warn(`[organize ${project.id}] the candidates were not recomputed before the synthesis: ${(e as Error).message}`); }
|
|
918
|
+
}
|
|
919
|
+
const now = new Date().toISOString();
|
|
920
|
+
const judgement: JudgementRecord = {
|
|
921
|
+
id: newId('jdg'), projectId: project.id, jobId: '', at: now,
|
|
922
|
+
scope: { kind: 'project', ids: [], label: `Synthesis of ${round.kind === 'Follow up' ? 'Follow up' : 'takeover'} round ${round.number}` },
|
|
923
|
+
inputs: this.synthesisInputs(store, current),
|
|
924
|
+
excluded: [
|
|
925
|
+
'The sessions of the project’s own implementation and QC work: the synthesis gets what the round wrote on the workbench and in its lanes’ reports',
|
|
926
|
+
'The main agent’s and the lanes’ own sessions: the synthesis runs in a session of its own and reads what they wrote down — the reports, the adoption record, the handover',
|
|
927
|
+
],
|
|
928
|
+
outcome: { noteIds: [], assessments: [], reconsideredOnly: false },
|
|
929
|
+
};
|
|
930
|
+
store.judgements.put(judgement, { jobId: null, summary: `Synthesis inputs recorded: round ${round.number}` });
|
|
931
|
+
const failures = current.failures ?? [];
|
|
932
|
+
const prompt = this.synthesisPrompt(store, project, current, failures);
|
|
933
|
+
const job = this.app.keeper.enqueue(project.id, {
|
|
934
|
+
kind: 'Organizing', initiator: 'auto', priority: 1, timeoutMs: STEP_TIMEOUT_MS,
|
|
935
|
+
scope: { kind: 'clerk-step', ids: [round.id], label: STEP_LABEL.synthesis },
|
|
936
|
+
prompt, parentJobId: round.rootJobId, step: { roundId: round.id, kind: 'synthesis', path: null },
|
|
937
|
+
judgementId: judgement.id, task: { kind: 'clerk-step', roundId: round.id, route: 'synthesis' },
|
|
938
|
+
});
|
|
939
|
+
store.judgements.put({ ...judgement, jobId: job.id });
|
|
940
|
+
return 'started';
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** The synthesis job's task as it stands now (`startSynthesis`); also what a replay of a recorded round builds. */
|
|
944
|
+
synthesisPrompt(store: ProjectStore, project: Project, round: ClerkRound, failures: readonly RoundFailure[] = round.failures ?? []): string {
|
|
945
|
+
const standing = standingNotesBlock(store, round);
|
|
946
|
+
return [
|
|
947
|
+
`Task: the synthesis of this round — its product look-back, in a session of your own. You were not there for the round: the main agent and its lanes did their work in their sessions and wrote it down. Read first — the main agent's handover below, then every lane report in full — and only then write: the notes, the six things, the send-backs, area understanding${standing ? ', a result for each note still current from earlier rounds' : ''}, and the round's Result (pk_write_round_doc kind Result). The round is done once its Result is written.`,
|
|
948
|
+
synthesisSkill(),
|
|
949
|
+
roundBlockOf(store, project, round),
|
|
950
|
+
`=== The project\n${positioningBlock(store, project)}`,
|
|
951
|
+
this.handoverBlock(store, round),
|
|
952
|
+
this.roundDocsBlock(store, round),
|
|
953
|
+
...this.methodBlocks(store, project, round, 'synthesis'),
|
|
954
|
+
this.openBlock(store, round),
|
|
955
|
+
processBlock(store, { candidates: 'count', answered: candidatesAnswered(store, round.id).length }),
|
|
956
|
+
this.writtenBlock(store, round),
|
|
957
|
+
...(round.kind === 'Follow up' ? [this.newsBlock(store, round)] : []),
|
|
958
|
+
...(standing ? [standing] : []),
|
|
959
|
+
this.compositionBlock(store, round, { documents: false }),
|
|
960
|
+
`=== The project's rules in force\n${rulesBlockOf(store) || 'None recorded yet.'}`,
|
|
961
|
+
...(failures.length ? [failuresBlock(failures)] : []),
|
|
962
|
+
].join('\n\n');
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** The main agent's handover for the synthesis, in full; or that it did not hand over, and where it stopped. */
|
|
966
|
+
private handoverBlock(store: ProjectStore, round: ClerkRound): string {
|
|
967
|
+
const doc = handoverDoc(store, round);
|
|
968
|
+
if (doc && round.handover) return `=== The main agent's handover (${doc.id}; written as it left the ${stageOfRound(round)} stage)\n${doc.markdown}`;
|
|
969
|
+
return `=== The main agent's handover\nThere is none: the main agent's session ended in the ${stageOfRound(round)} stage without handing over. What it and its lanes wrote is on the workbench and in the round's documents; what it did not do is missing. Read the reports${store.roundDocs.find((d) => d.roundId === round.id && d.kind === 'Adoption') ? ' and the adoption record' : ''}, judge from what is there, and say in the Result that the round had no handover and what that leaves unchecked.`;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* The round's documents for the synthesis, by id and size (read in full with pk_read_assets kind roundDoc): the question
|
|
974
|
+
* list and the history map, each lane with its brief and its report, the adoption record. Nothing of them is pasted.
|
|
975
|
+
*/
|
|
976
|
+
private roundDocsBlock(store: ProjectStore, round: ClerkRound): string {
|
|
977
|
+
const docs = store.roundDocs.filter((d) => d.roundId === round.id);
|
|
978
|
+
const ref = (d: RoundDoc | undefined) => (d ? `${d.id} (${d.markdown.length} characters)` : 'none');
|
|
979
|
+
const one = (kind: string) => docs.filter((d) => d.kind === kind).sort((a, b) => b.at.localeCompare(a.at))[0];
|
|
980
|
+
const lanes = lanesOf(store, round);
|
|
981
|
+
const reports = docs.filter((d) => d.kind === 'Report');
|
|
982
|
+
const out = [`=== This round's documents (read each in full with pk_read_assets kind roundDoc, by its id)`];
|
|
983
|
+
out.push(`The round's questions: ${ref(one('Questions'))}`);
|
|
984
|
+
if (one('History map')) out.push(`History map: ${ref(one('History map'))}`);
|
|
985
|
+
out.push(`Adoption record (what the cross-check adopted, what not, and why): ${ref(one('Adoption'))}`);
|
|
986
|
+
out.push(`Lanes (${lanes.length})${lanes.length ? ' — read every report before you write:' : ': none ran in this round.'}`);
|
|
987
|
+
for (const l of lanes) {
|
|
988
|
+
const report = reports.find((d) => d.id === l.reportDocId) ?? reports.find((d) => d.path === l.name);
|
|
989
|
+
out.push(`- ${l.name} (${l.kind}; ${l.slots.join(', ') || 'no slots'}; sent in ${l.stage}) · ${l.status} · brief ${ref(store.roundDocs.get(l.briefDocId))} · report ${ref(report)}`);
|
|
990
|
+
}
|
|
991
|
+
const rest = reports.filter((d) => !lanes.some((l) => l.reportDocId === d.id || l.name === d.path));
|
|
992
|
+
if (rest.length) out.push('Other reports of this round:', ...rest.map((d) => `- ${d.path ?? d.title} · ${ref(d)}`));
|
|
993
|
+
return out.join('\n');
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** What is still open as the synthesis starts, counted by list (one list in full on request), and what stays unplaced. */
|
|
997
|
+
private openBlock(store: ProjectStore, round: ClerkRound): string {
|
|
998
|
+
const head = '=== What is still open (the program\'s counts as you start; pk_round_state({}) counts them again, pk_round_state({ list: "<key>" }) gives one list in full)';
|
|
999
|
+
try {
|
|
1000
|
+
const open = roundOpen(store, round, { limit: Infinity, suggest: false });
|
|
1001
|
+
const counts = Object.entries(openCounts(open)).filter(([, n]) => n > 0);
|
|
1002
|
+
const lines = counts.map(([k, n]) => `- ${k}: ${n} — ${OPEN_KEYS[k]!.what.split(/[;:(]/)[0]!.trim()}`);
|
|
1003
|
+
const unplaced = unplacedNote(open);
|
|
1004
|
+
return [head, ...(lines.length ? lines : ['Nothing is open.']), ...(unplaced ? [unplaced.replace(/^=== Unplaced on the workbench\n/, 'Unplaced on the workbench: ')] : [])].join('\n');
|
|
1005
|
+
} catch (e) {
|
|
1006
|
+
return `${head}\nThe program could not count them (${(e as Error).message}); pk_round_state({}) counts them.`;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* What this round's jobs wrote on the workbench so far, counted by position (the program's count from their writes):
|
|
1012
|
+
* what changed this round, for the Result's "what is new" and for where to look. The records themselves are read with
|
|
1013
|
+
* pk_read_assets.
|
|
1014
|
+
*/
|
|
1015
|
+
private writtenBlock(store: ProjectStore, round: ClerkRound): string {
|
|
1016
|
+
const written = new Map<string, Set<string>>();
|
|
1017
|
+
for (const job of store.jobs.filter((j) => j.step?.roundId === round.id)) for (const e of store.traceByJob(job.id, 100_000)) {
|
|
1018
|
+
if (e.op !== 'put') continue;
|
|
1019
|
+
if (!written.has(e.collection)) written.set(e.collection, new Set());
|
|
1020
|
+
written.get(e.collection)!.add(e.id);
|
|
1021
|
+
}
|
|
1022
|
+
const rows = [...written].flatMap(([collection, ids]) => { const where = POSITION_OF[collection]; return where?.kind === 'position' ? [{ label: where.label, collection, n: ids.size }] : []; }).sort((a, b) => b.n - a.n);
|
|
1023
|
+
const head = '=== What this round wrote on the workbench so far (the program\'s count of its jobs\' writes, by position; read the records with pk_read_assets)';
|
|
1024
|
+
return [head, ...(rows.length ? rows.map((r) => `- ${r.label}: ${r.n} (${r.collection})`) : ['Nothing yet.'])].join('\n');
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* §3.3 抽查 (D99, D103; CKC-23 AC-9, AC-22): the round's independent spot-check, in a session of its own after the
|
|
1029
|
+
* synthesis — the round's final re-check. It checks every breakpoint candidate a lane looked for and did not find — only
|
|
1030
|
+
* it lights one (breakpoint-candidates.ts) — every "missing" conclusion the round wrote (the claims block), in full
|
|
1031
|
+
* what the synthesis wrote and every note current at the end of the round (`fullCheckTargets`), and a sample of the
|
|
1032
|
+
* rest of the round's judgements, weighted to what goes wrong most (`spotCheckTargets`). It runs whenever there is a
|
|
1033
|
+
* looked candidate or a judgement to check; a round that wrote nothing and looked at nothing has nothing to check. Its
|
|
1034
|
+
* prompt is its skill, then the round, the project, the targets, the claims and the rules.
|
|
1035
|
+
*/
|
|
1036
|
+
private startSpotCheck(store: ProjectStore, project: Project, round: ClerkRound): StepStart {
|
|
1037
|
+
// CM (E151): the candidates as they stand after everything the main agent wrote — recomputed before the spot-check
|
|
1038
|
+
// reads them, so a link confirmed late in the cross-check has put its candidate out (CK fix 9).
|
|
1039
|
+
let ledgerForTargets: Parameters<typeof spotCheckTargets>[3] = null;
|
|
1040
|
+
try {
|
|
1041
|
+
const ledger = this.app.ledger.ledger(project.id);
|
|
1042
|
+
ledgerForTargets = ledger ?? null;
|
|
1043
|
+
if (ledger) computeBreakpoints(store, project, ledger, round.id, store.jobs.filter((j) => j.step?.roundId === round.id && j.step.kind === 'main').sort((a, b) => b.queuedAt.localeCompare(a.queuedAt))[0]?.id ?? null);
|
|
1044
|
+
} catch (e) { console.warn(`[organize ${project.id}] the candidates were not recomputed before the spot-check: ${(e as Error).message}`); }
|
|
1045
|
+
// CS: a program placement of any round that a write took off its item has its result before the sample is drawn.
|
|
1046
|
+
try { settleProgramPlacements(store, { jobId: round.rootJobId, roundId: round.id }); } catch (e) { console.warn(`[organize ${project.id}] the program placements were not settled before the spot-check: ${(e as Error).message}`); }
|
|
1047
|
+
// CQ (D104): the ledger lets the targets name the records the program's trace followed for each reason that left an item unplaced.
|
|
1048
|
+
const targets = spotCheckTargets(store, round, SPOT_CHECK_SAMPLE, ledgerForTargets);
|
|
1049
|
+
if (targets.candidates.length === 0 && targets.sample.length === 0 && targets.full.length === 0) return this.skip(store, project, round, 'spot-check', 'This round wrote no judgement to check, no note is current, no reason for leaving an item unplaced stands unchecked, and no lane looked for a breakpoint candidate');
|
|
1050
|
+
// CS: what this spot-check is given of the placement readings is fixed on the round as it starts — the reasons checked
|
|
1051
|
+
// in full, the program placements in its sample — so its own corrections take nothing away from what it is counted
|
|
1052
|
+
// against (pk_record_spot_check). A spot-check started again keeps what the first start fixed.
|
|
1053
|
+
const asStarted = store.clerkRounds.get(round.id) ?? round;
|
|
1054
|
+
if (!asStarted.placementCheck && (targets.reasons.length || targets.placements.length)) {
|
|
1055
|
+
const at = new Date().toISOString();
|
|
1056
|
+
store.clerkRounds.put({ ...asStarted, placementCheck: { at, reasons: targets.reasons, placements: targets.placements }, updatedAt: at }, { jobId: round.rootJobId, basisSourceIds: [], summary: `Round ${round.number}: the spot-check is given ${targets.reasons.length} reason${targets.reasons.length === 1 ? '' : 's'} for leaving an item unplaced, and ${targets.placements.length} of the program's ${targets.placementsUnreviewed} unreviewed placement${targets.placementsUnreviewed === 1 ? '' : 's'}` });
|
|
1057
|
+
}
|
|
1058
|
+
const failures = (store.clerkRounds.get(round.id) ?? round).failures ?? [];
|
|
1059
|
+
const prompt = [
|
|
1060
|
+
`Task: the independent spot-check of this round, its final re-check — every breakpoint candidate a lane looked for and did not find (${targets.candidates.length}); every "missing" conclusion the round wrote; in full, what the synthesis wrote and every note current now, with the claims of the round's Result, and every reason that leaves an item unplaced and that no spot-check has checked yet, whichever round wrote it (${targets.full.length} in all, ${targets.reasons.length} of them such reasons); and a sample of ${targets.sample.length} of the ${targets.written} other judgements — what the round wrote, and the program's placements nobody has reviewed yet (${targets.placements.length} of ${targets.placementsUnreviewed} in the sample). Record every check (pk_record_spot_check) and write the round's Spot check document (pk_write_round_doc kind Spot check).`,
|
|
1061
|
+
spotCheckSkill(),
|
|
1062
|
+
roundBlockOf(store, project, round),
|
|
1063
|
+
`=== The project\n${positioningBlock(store, project)}`,
|
|
1064
|
+
spotCheckBlock(store, targets),
|
|
1065
|
+
absenceLookedBlock(store, round),
|
|
1066
|
+
// DA: the owner's lines this round judged to need nothing — a sample for the spot-check.
|
|
1067
|
+
...[judgedLinesBlock(store, round, ledgerForTargets)].filter((b): b is string => b !== null),
|
|
1068
|
+
// DB: the slots the skeleton left to no lane, with the main agent's reasons, while they still hold nothing.
|
|
1069
|
+
...[emptySlotsBlock(store, round)].filter((b): b is string => b !== null),
|
|
1070
|
+
...this.methodBlocks(store, project, round, 'spot-check'),
|
|
1071
|
+
`=== The project's rules in force\n${rulesBlockOf(store) || 'None recorded yet.'}`,
|
|
1072
|
+
...(failures.length ? [failuresBlock(failures)] : []),
|
|
1073
|
+
].join('\n\n');
|
|
1074
|
+
this.app.keeper.enqueue(project.id, {
|
|
1075
|
+
kind: 'Organizing', initiator: 'auto', priority: 1, timeoutMs: STEP_TIMEOUT_MS,
|
|
1076
|
+
scope: { kind: 'clerk-step', ids: [round.id], label: STEP_LABEL['spot-check'] },
|
|
1077
|
+
prompt, parentJobId: round.rootJobId, step: { roundId: round.id, kind: 'spot-check', path: null },
|
|
1078
|
+
judgementId: null, task: { kind: 'clerk-step', roundId: round.id, route: 'spot-check' },
|
|
1079
|
+
});
|
|
1080
|
+
return 'started';
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// ───────────────────────── what the program lists for a step to judge (methodBlocksOf) ─────────────────────────
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* The step's program-computed lists (methodBlocksOf), each computed now, when the step starts, so it holds what the
|
|
1087
|
+
* earlier steps of the round wrote. A Follow up lists what came since the last round in full, and the older rest in one
|
|
1088
|
+
* line each. The ledger's facts are read once for the lists that need them; a list that cannot be computed says so and
|
|
1089
|
+
* does not stop the step.
|
|
1090
|
+
*/
|
|
1091
|
+
private methodBlocks(store: ProjectStore, project: Project, round: ClerkRound, step: RoundStepKind, kinds: readonly MethodBlock[] = methodBlocksOf(step, round.kind)): string[] {
|
|
1092
|
+
if (!kinds.length) return [];
|
|
1093
|
+
const ledger = this.app.ledger.ledger(project.id);
|
|
1094
|
+
const previous = store.clerkRounds.filter((r) => r.id !== round.id && r.status === 'Done').sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0];
|
|
1095
|
+
const since = round.kind === 'Follow up' ? previous?.startedAt ?? null : null;
|
|
1096
|
+
let shared: { facts: Facts; index: UnitIndex } | null = null;
|
|
1097
|
+
const analysis = () => {
|
|
1098
|
+
if (!shared && ledger) { const facts = new Facts(ledger); shared = { facts, index: buildUnits(store, facts) }; }
|
|
1099
|
+
return shared;
|
|
1100
|
+
};
|
|
1101
|
+
return kinds.map((kind) => {
|
|
1102
|
+
try {
|
|
1103
|
+
switch (kind) {
|
|
1104
|
+
// CM (E151): the main agent gets a count and the lines naming generations; the full list goes to the Owner's words lane.
|
|
1105
|
+
case 'owner-lines': return ownerLinesCountBlock(store, ledger, { naming: step === 'main' });
|
|
1106
|
+
case 'claims': { const c = this.claimTexts(store, round, step); return claimCheckBlock(store, ledger, c.texts, { what: c.what, analysis }); }
|
|
1107
|
+
case 'unnumbered': return unnumberedWorkBlock(store, project, ledger, { since, analysis });
|
|
1108
|
+
case 'unsure': return unsureBlock(store, round, { forStep: step === 'spot-check' ? 'spot-check' : 'synthesis' });
|
|
1109
|
+
}
|
|
1110
|
+
} catch (e) {
|
|
1111
|
+
return `=== ${kind}: the program could not compute this list (${(e as Error).message}); check what it would have given against the ledger yourself.`;
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Where the "let pass" and "dropped" candidates are for a stage or step: the cross-check adopts from the lanes' reports; the
|
|
1118
|
+
* synthesis writes from the reports, the adoption record and the program's own breakpoints of those kinds; the
|
|
1119
|
+
* spot-check checks what this round wrote — its notes, send-backs and documents.
|
|
1120
|
+
*/
|
|
1121
|
+
private claimTexts(store: ProjectStore, round: ClerkRound, step: RoundStepKind): { texts: ClaimText[]; what: string } {
|
|
1122
|
+
const docText = (d: RoundDoc): ClaimText => ({ from: `${d.id} (${d.kind}${d.path ? ` · ${d.path}` : ''})`, text: d.markdown });
|
|
1123
|
+
const docs = (kinds: readonly string[]) => store.roundDocs.filter((d) => d.roundId === round.id && kinds.includes(d.kind)).sort((a, b) => a.at.localeCompare(b.at)).map(docText);
|
|
1124
|
+
if (step === 'cross-check') return { texts: docs(['Report']), what: "this round's lane reports" };
|
|
1125
|
+
if (step === 'synthesis') return { texts: [...docs(['Report', 'Adoption']), ...breakpointClaims(store)], what: "this round's lane reports and adoption record, and the program's lit breakpoints of let pass and dropped along the way" };
|
|
1126
|
+
// The spot-check: what this round's steps wrote.
|
|
1127
|
+
const jobIds = store.jobs.filter((j) => j.step?.roundId === round.id && j.step.kind !== 'spot-check').map((j) => j.id);
|
|
1128
|
+
const written = new Map<string, Set<string>>();
|
|
1129
|
+
for (const jobId of jobIds) for (const e of store.traceByJob(jobId, 5_000)) {
|
|
1130
|
+
if (e.op !== 'put' || (e.collection !== 'notes' && e.collection !== 'sendbacks')) continue;
|
|
1131
|
+
written.set(e.collection, new Set([...(written.get(e.collection) ?? []), e.id]));
|
|
1132
|
+
}
|
|
1133
|
+
const notes = [...(written.get('notes') ?? [])].flatMap((id) => {
|
|
1134
|
+
const n = store.notes.get(id);
|
|
1135
|
+
const v = n?.versions[n.versions.length - 1];
|
|
1136
|
+
return v ? [{ from: `${id} (note “${v.title}”)`, text: [v.title, v.preview, v.body.currentView ?? '', ...v.body.facts.map((f) => f.text), v.body.keepAdjust ?? '', v.body.whatWouldSettleIt ?? ''].filter(Boolean).join('\n') }] : [];
|
|
1137
|
+
});
|
|
1138
|
+
const sendbacks = [...(written.get('sendbacks') ?? [])].flatMap((id) => {
|
|
1139
|
+
const s = store.sendbacks.get(id);
|
|
1140
|
+
return s ? [{ from: `${id} (send-back to ${s.to})`, text: `${s.what}\n${s.suggestion}` }] : [];
|
|
1141
|
+
});
|
|
1142
|
+
return { texts: [...notes, ...sendbacks, ...docs(['Adoption', 'Result'])], what: "what this round wrote: its notes, send-backs, adoption record and result" };
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/** A step whose prompt the program writes itself: the session drafts (clerk-prompts.ts). */
|
|
1146
|
+
private enqueueStep(store: ProjectStore, project: Project, round: ClerkRound, step: RoundStepKind, detail: string | null, blocks: readonly string[]): KeeperJob {
|
|
1147
|
+
// The round goes on without what did not finish (Spec §3.10): a step that starts after it is told what is missing.
|
|
1148
|
+
const failures = (store.clerkRounds.get(round.id) ?? round).failures ?? [];
|
|
1149
|
+
const prompt = clerkStepPrompt({ step, round: round.kind, positioning: positioningBlock(store, project), rules: rulesBlockOf(store), blocks: failures.length ? [...blocks, failuresBlock(failures)] : blocks });
|
|
1150
|
+
return this.app.keeper.enqueue(project.id, {
|
|
1151
|
+
kind: 'Organizing', initiator: 'auto', priority: 1, timeoutMs: STEP_TIMEOUT_MS,
|
|
1152
|
+
scope: { kind: 'clerk-step', ids: [round.id], label: `${STEP_LABEL[step]}${detail ? `: ${detail}` : ''}` },
|
|
1153
|
+
prompt, parentJobId: round.rootJobId, step: { roundId: round.id, kind: step, path: null },
|
|
1154
|
+
judgementId: null, task: { kind: 'clerk-step', roundId: round.id, route: step },
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
private skip(store: ProjectStore, project: Project, round: ClerkRound, step: RoundStepKind, why: string): StepStart {
|
|
1159
|
+
const job = this.app.keeper.recordProgramJob(project.id, { kind: 'Organizing', scope: { kind: 'clerk-step', ids: [round.id], label: `${STEP_LABEL[step]}: nothing to do` }, step: { roundId: round.id, kind: step, path: null }, parentJobId: round.rootJobId, task: { kind: 'clerk-step', roundId: round.id } });
|
|
1160
|
+
this.app.keeper.endProgramJob(project.id, job.id, 'Done', { resultText: why, timing: { wallMs: 0, generationMs: 0, toolMs: 0, queueMs: 0, parseRetryMs: 0, otherMs: 0 } });
|
|
1161
|
+
return 'skipped';
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// ───────────────────────── what the main agent is given as it enters a stage ─────────────────────────
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* The lanes' reports for the cross-check (§3.3; D99): each lane with its kind, its slots, how it ended and its Report.
|
|
1168
|
+
* The cross-check judges across the lanes from these; it never has to read their material again, though it can open
|
|
1169
|
+
* any original to check a claim.
|
|
1170
|
+
*/
|
|
1171
|
+
private reportsBlock(store: ProjectStore, round: ClerkRound): string {
|
|
1172
|
+
const reports = store.roundDocs.filter((d) => d.roundId === round.id && d.kind === 'Report');
|
|
1173
|
+
const lanes = lanesOf(store, store.clerkRounds.get(round.id) ?? round);
|
|
1174
|
+
if (reports.length === 0 && lanes.length === 0) return '=== The lanes\' reports\nNo lane ran in this round.';
|
|
1175
|
+
const line = (d: RoundDoc) => `${d.id} · ${d.markdown.length} characters`;
|
|
1176
|
+
const out = ['=== The lanes\' reports (read each in full with pk_read_assets kind roundDoc)'];
|
|
1177
|
+
for (const l of lanes) {
|
|
1178
|
+
const doc = reports.find((d) => d.path === l.name);
|
|
1179
|
+
out.push(`- ${l.name} (${l.kind}; ${l.slots.join(', ') || 'no slots'}) · ${l.status} · ${doc ? line(doc) : 'no report'}`);
|
|
1180
|
+
}
|
|
1181
|
+
const rest = reports.filter((d) => !lanes.some((l) => l.name === d.path));
|
|
1182
|
+
if (rest.length) out.push('Other reports of this round:', ...rest.map((d) => `- ${d.path ?? d.title} · ${line(d)}`));
|
|
1183
|
+
return out.join('\n');
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* The program's code anomaly candidates (§2.13 row 6; CKC-25 AC-4, AC-5), counted by kind and by territory, for the
|
|
1188
|
+
* cross-check to judge before it writes a territory's anomalies: the program lists, the model decides. A file nothing
|
|
1189
|
+
* imports can still be used through registration, routing, reflection or a manual entry point. CM (E151; CK fix 10): the
|
|
1190
|
+
* list itself — 53K characters on the gated run, waved off in one sentence — is fetched when it is acted on
|
|
1191
|
+
* (`pk_round_state { list: "codeAnomalies" }`), not pasted into the stage's notes.
|
|
1192
|
+
*/
|
|
1193
|
+
private codeCandidatesBlock(store: ProjectStore, project: Project): string {
|
|
1194
|
+
const head = '=== Code anomaly candidates (the program\'s count from the ledger; pk_round_state { list: "codeAnomalies" } lists them, to judge each before writing it as an anomaly with pk_write_territory)';
|
|
1195
|
+
const ledger = this.app.ledger.ledger(project.id);
|
|
1196
|
+
if (!ledger) return `${head}\nThe ledger is not available, so no candidate was listed.`;
|
|
1197
|
+
const all = codeAnomalyCandidates(ledger, store, project);
|
|
1198
|
+
if (!all.length) return `${head}\nNone: every current source file is referenced, no referenced file has a residual-looking name, and every integrated commit is claimed by a work item.`;
|
|
1199
|
+
const byKind = new Map<string, CodeAnomalyCandidate[]>();
|
|
1200
|
+
for (const c of all) byKind.set(c.kind, [...(byKind.get(c.kind) ?? []), c]);
|
|
1201
|
+
const where = (cs: readonly CodeAnomalyCandidate[]) => {
|
|
1202
|
+
const n = new Map<string, number>();
|
|
1203
|
+
for (const c of cs) { const t = c.territoryId ?? c.path?.split('/').slice(0, 2).join('/') ?? 'elsewhere'; n.set(t, (n.get(t) ?? 0) + 1); }
|
|
1204
|
+
const top = [...n].sort((x, y) => y[1] - x[1]);
|
|
1205
|
+
return `${top.slice(0, 6).map(([t, k]) => `${t} ${k}`).join(', ')}${top.length > 6 ? `, and ${top.length - 6} more places` : ''}`;
|
|
1206
|
+
};
|
|
1207
|
+
return [head, `${all.length} candidates: ${[...byKind].map(([kind, cs]) => `${kind} ${cs.length} (${where(cs)})`).join('; ')}.`].join('\n');
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/** The synthesis' context (§3.4): the product reference from the owner's words down and what this round wrote, as ids to read. */
|
|
1211
|
+
private compositionBlock(store: ProjectStore, round: ClerkRound, opts: { readonly documents?: boolean } = {}): string {
|
|
1212
|
+
const cur = <T extends { validity?: string }>(xs: readonly T[]) => xs.filter((x) => x.validity === undefined || x.validity === 'Current');
|
|
1213
|
+
const ref = (cat: string) => cur(store.reference.filter((r) => r.category === cat)).map((r) => `${r.id} ${r.name}`);
|
|
1214
|
+
const docs = store.roundDocs.filter((d) => d.roundId === round.id).map((d) => `${d.id} · ${d.kind}${d.path ? ` · ${d.path}` : ''}`);
|
|
1215
|
+
// Every row, not the first so many: the synthesis is the product re-look and reads the whole picture (§3.4). The
|
|
1216
|
+
// breakpoints and send-backs come in the process engine's own block, with their evidence.
|
|
1217
|
+
const section = (title: string, rows: readonly string[]) => `${title} (${rows.length})${rows.length ? `\n${rows.map((r) => `- ${r}`).join('\n')}` : ''}`;
|
|
1218
|
+
// CM (E151): the top of the picture by name — the Product, the Goals, the Areas, the Plans, this round's documents —
|
|
1219
|
+
// and the long lists as counts with where to list them (pk_read_assets gives compact rows): the synthesis reads what
|
|
1220
|
+
// it weighs, and a list of every work item and owner's line pasted here was read by nobody.
|
|
1221
|
+
const threads = cur(store.threads.all());
|
|
1222
|
+
const byProgress = new Map<string, number>();
|
|
1223
|
+
for (const t of threads) byProgress.set(t.progress, (byProgress.get(t.progress) ?? 0) + 1);
|
|
1224
|
+
const count = (title: string, n: number, where: string, extra = '') => `${title} (${n})${extra ? `: ${extra}` : ''} — ${where}`;
|
|
1225
|
+
return [
|
|
1226
|
+
'=== What you are given (read any of them in full with pk_read_assets)',
|
|
1227
|
+
count("Owner's words", ref("Owner's words").length, `pk_read_assets kind reference, category "Owner's words"`),
|
|
1228
|
+
section('Product', ref('Product')),
|
|
1229
|
+
section('Goals', ref('Goal')),
|
|
1230
|
+
section('Areas', ref('Area')),
|
|
1231
|
+
section('Plans', ref('Plan')),
|
|
1232
|
+
count('Work items', threads.length, 'pk_read_assets kind thread', [...byProgress].map(([k, n]) => `${n} ${k}`).join(', ')),
|
|
1233
|
+
...(opts.documents === false ? [] : [section("This round's documents", docs)]),
|
|
1234
|
+
count('Area understanding', store.areas.size, 'pk_read_assets kind area'),
|
|
1235
|
+
count('Semantic patches', store.patches.filter((x) => x.status !== 'Rejected').length, 'pk_read_assets kind patch'),
|
|
1236
|
+
count('Code territories', store.territories.size, 'pk_read_assets kind territory', `${store.territories.filter((t) => t.anomalies.length > 0).length} with anomalies`),
|
|
1237
|
+
section('Current notes (earlier views, not evidence)', store.notes.filter((n) => n.status === 'Current').map((n) => `${n.id} ${n.versions[n.versions.length - 1]?.title ?? ''}`)),
|
|
1238
|
+
].join('\n\n');
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* A Follow up synthesis is told what the round has made new so far, as the program counts it (round-news.ts), so the
|
|
1243
|
+
* Result it writes and the round's news in `Notes (attention)` name the same things (CKC-07 AC-27).
|
|
1244
|
+
*/
|
|
1245
|
+
private newsBlock(store: ProjectStore, round: ClerkRound): string {
|
|
1246
|
+
const news = roundNews(store, store.clerkRounds.get(round.id) ?? round);
|
|
1247
|
+
const section = (title: string, items: readonly { label: string; position: string; detail: string | null }[]) =>
|
|
1248
|
+
`${title} (${items.length})${items.length ? `\n${items.map((i) => `- ${i.label} — ${i.position}${i.detail ? ` (${i.detail})` : ''}`).join('\n')}` : ''}`;
|
|
1249
|
+
return [
|
|
1250
|
+
"=== What this round has made new so far (the program's count; your notes, send-backs and six-thing tags join it)",
|
|
1251
|
+
section('Breakpoints newly lit', news.breakpoints),
|
|
1252
|
+
section('Send-backs new or moved', news.sendbacks),
|
|
1253
|
+
section('Newly among the six things', news.sixThings),
|
|
1254
|
+
section('Semantic patches confirmed', news.patches),
|
|
1255
|
+
section('Notes written or updated', news.notes),
|
|
1256
|
+
].join('\n');
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/** Whether any document of a document-chain layer got a new version since the last round (a Follow up's skeleton, §3.8). */
|
|
1260
|
+
private documentChainChanged(store: ProjectStore, round: ClerkRound): boolean {
|
|
1261
|
+
const previous = store.clerkRounds.filter((r) => r.id !== round.id && r.status === 'Done').sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0];
|
|
1262
|
+
if (!previous) return true;
|
|
1263
|
+
const chain = new Set(['Product', 'PRD', 'Spec', 'Plan', 'Task index', 'Task contract', 'Decision record']);
|
|
1264
|
+
const paths = store.layers.filter((l) => chain.has(l.layer)).map((l) => l.path.split('\\').join('/'));
|
|
1265
|
+
if (paths.length === 0) return true;
|
|
1266
|
+
return store.sources.find((s) => s.anchor.kind === 'file' && s.version.readAt > previous.startedAt && paths.some((p) => s.anchor.kind === 'file' && s.anchor.path.split('\\').join('/').endsWith(p))) !== undefined;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// ───────────────────────── closing a round ─────────────────────────
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* CKC-23 AC-6, AC-10: what the round produced, counted from its steps' writes by workbench position; the groundwork;
|
|
1273
|
+
* what served nothing; the wall clock and the longest path.
|
|
1274
|
+
*
|
|
1275
|
+
* A round the planner closes ran through: its status is `Done`, and what did not finish is listed with it — on the
|
|
1276
|
+
* round (`failures`), in its root job's result, in a Follow up's record, and in the coverage's failure list until a run
|
|
1277
|
+
* again finishes it (Spec §3.10: 不静默跳过). Later rounds build on it as on any round that ran.
|
|
1278
|
+
*/
|
|
1279
|
+
closeRound(store: ProjectStore, project: Project, round: ClerkRound): void {
|
|
1280
|
+
const steps = store.jobs.filter((j) => j.step?.roundId === round.id);
|
|
1281
|
+
const written = new Map<string, Set<string>>();
|
|
1282
|
+
// The steps' writes, and the program's own under the round (the briefs it composed for a deepening, CKC-23 AC-4).
|
|
1283
|
+
for (const jobId of [...steps.map((j) => j.id), round.rootJobId]) for (const e of store.traceByJob(jobId, 100_000)) {
|
|
1284
|
+
if (e.op !== 'put') continue;
|
|
1285
|
+
if (!written.has(e.collection)) written.set(e.collection, new Set());
|
|
1286
|
+
written.get(e.collection)!.add(e.id);
|
|
1287
|
+
}
|
|
1288
|
+
const outputs: { position: string; count: number }[] = [];
|
|
1289
|
+
const groundwork: { kind: string; count: number }[] = [];
|
|
1290
|
+
const unplacedReasons: string[] = [];
|
|
1291
|
+
let unplaced = 0;
|
|
1292
|
+
for (const [collection, ids] of written) {
|
|
1293
|
+
const where = POSITION_OF[collection];
|
|
1294
|
+
if (where?.kind === 'position') outputs.push({ position: where.label, count: ids.size });
|
|
1295
|
+
else if (where?.kind === 'groundwork') groundwork.push({ kind: where.label, count: ids.size });
|
|
1296
|
+
else if (where?.kind === 'bookkeeping') continue;
|
|
1297
|
+
else { unplaced += ids.size; unplacedReasons.push(`${ids.size} ${collection} record(s): no workbench position or later step reads this collection`); }
|
|
1298
|
+
}
|
|
1299
|
+
const now = new Date().toISOString();
|
|
1300
|
+
const current = store.clerkRounds.get(round.id) ?? round;
|
|
1301
|
+
const status: ClerkRound['status'] = 'Done';
|
|
1302
|
+
// What did not finish: the jobs listed while the round ran, as they stand now (a job the owner ran again since and
|
|
1303
|
+
// that finished is not among them).
|
|
1304
|
+
const listedAt = new Map((current.failures ?? []).map((f) => [f.jobId, f.at]));
|
|
1305
|
+
const failures = steps.filter((j) => j.status === 'Failed' || j.status === 'Stopped').sort((a, b) => a.queuedAt.localeCompare(b.queuedAt)).map((j) => {
|
|
1306
|
+
const e = stepEnding(store, j);
|
|
1307
|
+
return failureOf(j, e.kind === 'listed' ? e.reason : reasonOf(j), e.kind === 'listed' ? e.runs : runsOf(j).length + 1, listedAt.get(j.id) ?? now);
|
|
1308
|
+
});
|
|
1309
|
+
const unfinished = failures.length ? `${failures.length} job${failures.length === 1 ? '' : 's'} did not finish and ${failures.length === 1 ? 'is' : 'are'} listed: ${failures.map((f) => `${f.label} (${endedText(f.status, f.runs)}: ${f.reason.slice(0, 200)})`).join('; ')}` : null;
|
|
1310
|
+
// The project folder follows the assets the round wrote; what happened to it is kept on the round (QC AW-2).
|
|
1311
|
+
const folder = this.syncFolder(store, project);
|
|
1312
|
+
// The main agent's last stage ended with its handover (D103); one still open — the main agent never handed over —
|
|
1313
|
+
// ends with its job: its time is the job's less the stages before it (D99).
|
|
1314
|
+
const mainJob = steps.filter((j) => j.step?.kind === 'main').sort((a, b) => b.queuedAt.localeCompare(a.queuedAt))[0];
|
|
1315
|
+
const stageLog = current.stageLog?.some((e) => e.endedAt === null) ? endStage(current.stageLog, mainJob?.endedAt ?? now, mainJob?.timing ?? null) : current.stageLog;
|
|
1316
|
+
// What became of the notes standing from earlier rounds, counted as the round closes (D103; a deepening, a Follow up).
|
|
1317
|
+
const standing = looksAtStandingNotes(current) ? standingCounts(store, current) : null;
|
|
1318
|
+
store.clerkRounds.put({ ...current, ...(stageLog ? { stageLog } : {}), ...(standing ? { standingNotes: standing } : {}), endedAt: now, status, outputs: outputs.sort((a, b) => b.count - a.count), groundwork, unplaced: { count: unplaced, reasons: unplacedReasons }, folder, failures, updatedAt: now }, { jobId: round.rootJobId, summary: `${round.kind} round ${round.number} ${status}${failures.length ? ` with ${failures.length} job${failures.length === 1 ? '' : 's'} listed as not finished` : ''}` });
|
|
1319
|
+
this.closeFollowUpRecord(store, current, status, now, unfinished);
|
|
1320
|
+
this.app.keeper.endProgramJob(project.id, round.rootJobId, 'Done', { resultText: `${outputs.reduce((n, o) => n + o.count, 0)} writes on workbench positions, ${groundwork.reduce((n, g) => n + g.count, 0)} groundwork records, ${unplaced} unplaced${folder ? `; ${folder}` : ''}${unfinished ? `; ${unfinished}` : ''}` });
|
|
1321
|
+
if (round.kind !== 'First usable') this.app.setProjectRound(project.id, now);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* The project's projectkeeper/ folder follows the assets once a round has written them (CKC-26 AC-8, D89): only with
|
|
1326
|
+
* the owner's standing authorization, only that folder, never pushed. What stops it is said on the round and does not
|
|
1327
|
+
* fail it; the next round writes again.
|
|
1328
|
+
*/
|
|
1329
|
+
private syncFolder(store: ProjectStore, project: Project): string | null {
|
|
1330
|
+
try {
|
|
1331
|
+
const r = syncProjectFolder(store, project);
|
|
1332
|
+
if (r.status === 'not-authorized') return null;
|
|
1333
|
+
if (r.reason) return `project folder: ${r.reason}`;
|
|
1334
|
+
return r.status === 'written' ? `project folder: wrote ${r.changedFiles.join(', ') || 'nothing new'}${r.commit ? `, commit ${r.commit.slice(0, 12)}` : ''}` : null;
|
|
1335
|
+
} catch (error) {
|
|
1336
|
+
return `project folder not written: ${error instanceof Error ? error.message : String(error)}`;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* A Follow up round's one result (§5.5), closed with the round: the account is the synthesis's Result document and
|
|
1342
|
+
* every number is counted from the assets (D43). A round that ended without one says so, and what it did judge is
|
|
1343
|
+
* still counted, so the next Follow up opens a record of its own. What the round made new — or that it found nothing
|
|
1344
|
+
* new — is counted with it (round-news.ts; CKC-07 AC-27, CKC-24 AC-15), and the baseline it was counted against goes.
|
|
1345
|
+
* What did not finish in the round (`unfinished`) is said at the end of the account.
|
|
1346
|
+
*/
|
|
1347
|
+
private closeFollowUpRecord(store: ProjectStore, round: ClerkRound, status: ClerkRound['status'], at: string, unfinished: string | null = null): void {
|
|
1348
|
+
const record = round.followUpRoundId ? store.rounds.get(round.followUpRoundId) : undefined;
|
|
1349
|
+
if (!record || record.endedAt !== null) return;
|
|
1350
|
+
const doc = store.roundDocs.filter((d) => d.roundId === round.id && d.kind === 'Result').sort((a, b) => b.at.localeCompare(a.at))[0];
|
|
1351
|
+
const account = doc?.markdown.trim() || `Follow up round ${round.number} ended ${status === 'Done' ? 'without a Result document' : `as ${status}`}; what it judged is counted here.`;
|
|
1352
|
+
const summary = unfinished ? `${account}\n\nIn this round ${unfinished}.` : account;
|
|
1353
|
+
const behind = countRound(store, record.id).counts.behind;
|
|
1354
|
+
const news = roundNews(store, store.clerkRounds.get(round.id) ?? round, behind, at);
|
|
1355
|
+
store.rounds.put({ ...record, endedAt: at, result: roundResultOf(store, record.id, summary, at, news) }, { jobId: round.rootJobId, summary: `Follow up record ${record.number} closed with round ${round.number}: ${news.statement}` });
|
|
1356
|
+
const closed = store.clerkRounds.get(round.id);
|
|
1357
|
+
if (closed?.baseline) store.clerkRounds.put({ ...closed, baseline: null, updatedAt: at });
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
/**
|
|
1362
|
+
* Which collection serves which workbench position, or which later step or recall (Spec §3.3 table; D81 as corrected).
|
|
1363
|
+
* A collection a round writes that is in neither list is counted as serving nothing, with the reason (CKC-23 AC-6).
|
|
1364
|
+
*/
|
|
1365
|
+
export const POSITION_OF: Readonly<Record<string, { readonly kind: 'position' | 'groundwork' | 'bookkeeping'; readonly label: string }>> = {
|
|
1366
|
+
reference: { kind: 'position', label: 'Product intent (graph, List, popovers)' },
|
|
1367
|
+
threads: { kind: 'position', label: 'Work items (process view)' },
|
|
1368
|
+
areas: { kind: 'position', label: 'Area understanding (List Product intent)' },
|
|
1369
|
+
relations: { kind: 'position', label: 'Relations (graph)' },
|
|
1370
|
+
marks: { kind: 'position', label: 'Marks on objects' },
|
|
1371
|
+
notes: { kind: 'position', label: 'Notes (attention, log, popovers)' },
|
|
1372
|
+
changes: { kind: 'position', label: 'Change log' },
|
|
1373
|
+
rules: { kind: 'position', label: 'How this project works (Project scope)' },
|
|
1374
|
+
layers: { kind: 'position', label: 'Layers (Project scope)' },
|
|
1375
|
+
generations: { kind: 'position', label: 'Earlier generations (process view bands)' },
|
|
1376
|
+
patches: { kind: 'position', label: 'Semantic patches (Change log, strike-throughs, project folder)' },
|
|
1377
|
+
numbers: { kind: 'position', label: 'Keeper numbers (process view)' },
|
|
1378
|
+
links: { kind: 'position', label: 'Process steps (Work and Reality)' },
|
|
1379
|
+
breakpoints: { kind: 'position', label: 'Breakpoints (objects, top bar)' },
|
|
1380
|
+
sendbacks: { kind: 'position', label: 'Send-backs (Reality, Copy for agent)' },
|
|
1381
|
+
territories: { kind: 'position', label: 'Code territories (Code view)' },
|
|
1382
|
+
merges: { kind: 'position', label: 'Merged duplicates' },
|
|
1383
|
+
scopeJudgements: { kind: 'position', label: 'Project scope classification' },
|
|
1384
|
+
propagation: { kind: 'position', label: 'Downstream judgements (Follow up)' },
|
|
1385
|
+
requests: { kind: 'position', label: 'Requests to role holders' },
|
|
1386
|
+
drafts: { kind: 'groundwork', label: 'Session drafts' },
|
|
1387
|
+
roundDocs: { kind: 'groundwork', label: 'Round documents (history map, questions, briefs, reports, adoption, result, spot check)' },
|
|
1388
|
+
sources: { kind: 'bookkeeping', label: 'How a source is used' },
|
|
1389
|
+
judgements: { kind: 'bookkeeping', label: 'Judgement records (Based on)' },
|
|
1390
|
+
jobs: { kind: 'bookkeeping', label: 'Keeper jobs' },
|
|
1391
|
+
clerkRounds: { kind: 'bookkeeping', label: 'Rounds' },
|
|
1392
|
+
rounds: { kind: 'bookkeeping', label: 'Follow up rounds' },
|
|
1393
|
+
authorizations: { kind: 'bookkeeping', label: 'Authorizations' },
|
|
1394
|
+
contexts: { kind: 'bookkeeping', label: 'Context packages' },
|
|
1395
|
+
};
|