@quickchatai/quickreview 1.2.0 → 1.3.2
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/README.md +116 -53
- package/bin/qcr.js +1 -1
- package/config.example.toml +25 -7
- package/deploy/ansible/README.md +20 -12
- package/deploy/ansible/inventory.example.ini +1 -1
- package/deploy/collect-secrets.sh +1 -1
- package/deploy/qcr-update.sh +1 -1
- package/dist/extensions/cli/home.js +14 -0
- package/dist/extensions/cli/home.js.map +1 -0
- package/dist/extensions/cli/selfimprove.js +18 -0
- package/dist/extensions/cli/selfimprove.js.map +1 -0
- package/dist/extensions/home.js +350 -0
- package/dist/extensions/home.js.map +1 -0
- package/dist/extensions/login-route.js +1 -1
- package/dist/extensions/login-route.js.map +1 -1
- package/dist/extensions/onboarding.js +8 -7
- package/dist/extensions/onboarding.js.map +1 -1
- package/dist/extensions/selfimprove.js +49 -0
- package/dist/extensions/selfimprove.js.map +1 -0
- package/dist/extensions/setup-scan.js +23 -592
- package/dist/extensions/setup-scan.js.map +1 -1
- package/dist/extensions/setup.js +2 -47
- package/dist/extensions/setup.js.map +1 -1
- package/dist/extensions/vendor/openviking-pi/index.js +4 -0
- package/dist/extensions/vendor/openviking-pi/index.js.map +1 -1
- package/dist/extensions/vendor/openviking-pi/qcr-hooks.js +3 -1
- package/dist/extensions/vendor/openviking-pi/qcr-hooks.js.map +1 -1
- package/dist/extensions/vendor/openviking-pi/sync.js +81 -0
- package/dist/extensions/vendor/openviking-pi/sync.js.map +1 -1
- package/dist/src/archive/restore.js +18 -15
- package/dist/src/archive/restore.js.map +1 -1
- package/dist/src/botworker.js +13 -2
- package/dist/src/botworker.js.map +1 -1
- package/dist/src/channelfs.js +3 -26
- package/dist/src/channelfs.js.map +1 -1
- package/dist/src/cli.js +14 -50
- package/dist/src/cli.js.map +1 -1
- package/dist/src/config.js +16 -0
- package/dist/src/config.js.map +1 -1
- package/dist/src/daemon.js +33 -4
- package/dist/src/daemon.js.map +1 -1
- package/dist/src/debuground.js +1 -0
- package/dist/src/debuground.js.map +1 -1
- package/dist/src/debugroundpi.js +1 -0
- package/dist/src/debugroundpi.js.map +1 -1
- package/dist/src/engine/caserunner-sdk.js +15 -251
- package/dist/src/engine/caserunner-sdk.js.map +1 -1
- package/dist/src/home/adapters.js +349 -0
- package/dist/src/home/adapters.js.map +1 -0
- package/dist/src/home/advanced.js +318 -0
- package/dist/src/home/advanced.js.map +1 -0
- package/dist/src/home/advancedtools.js +57 -0
- package/dist/src/home/advancedtools.js.map +1 -0
- package/dist/src/home/autoreview.js +325 -0
- package/dist/src/home/autoreview.js.map +1 -0
- package/dist/src/home/daemonstatus.js +32 -0
- package/dist/src/home/daemonstatus.js.map +1 -0
- package/dist/src/home/devicelogin.js +61 -0
- package/dist/src/home/devicelogin.js.map +1 -0
- package/dist/src/home/launch.js +202 -0
- package/dist/src/home/launch.js.map +1 -0
- package/dist/src/home/loginowner.js +205 -0
- package/dist/src/home/loginowner.js.map +1 -0
- package/dist/src/home/memory.js +122 -0
- package/dist/src/home/memory.js.map +1 -0
- package/dist/src/home/model.js +811 -0
- package/dist/src/home/model.js.map +1 -0
- package/dist/src/home/panel.js +520 -0
- package/dist/src/home/panel.js.map +1 -0
- package/dist/src/home/ports.js +19 -0
- package/dist/src/home/ports.js.map +1 -0
- package/dist/src/home/review.js +163 -0
- package/dist/src/home/review.js.map +1 -0
- package/dist/src/home/reviews.js +478 -0
- package/dist/src/home/reviews.js.map +1 -0
- package/dist/src/home/setup.js +507 -0
- package/dist/src/home/setup.js.map +1 -0
- package/dist/src/home/setupadapters.js +214 -0
- package/dist/src/home/setupadapters.js.map +1 -0
- package/dist/src/home/setupcard.js +199 -0
- package/dist/src/home/setupcard.js.map +1 -0
- package/dist/src/home/setuptools.js +45 -0
- package/dist/src/home/setuptools.js.map +1 -0
- package/dist/src/home/spend.js +160 -0
- package/dist/src/home/spend.js.map +1 -0
- package/dist/src/home/switches.js +209 -0
- package/dist/src/home/switches.js.map +1 -0
- package/dist/src/home/switchon.js +439 -0
- package/dist/src/home/switchon.js.map +1 -0
- package/dist/src/home/toolnames.js +41 -0
- package/dist/src/home/toolnames.js.map +1 -0
- package/dist/src/home/tune.js +341 -0
- package/dist/src/home/tune.js.map +1 -0
- package/dist/src/home/words.js +7 -0
- package/dist/src/home/words.js.map +1 -0
- package/dist/src/homes.js +14 -0
- package/dist/src/homes.js.map +1 -1
- package/dist/src/install.js +1 -1
- package/dist/src/install.js.map +1 -1
- package/dist/src/ipc/workerspec.js +8 -0
- package/dist/src/ipc/workerspec.js.map +1 -1
- package/dist/src/ledgercheck.js +39 -10
- package/dist/src/ledgercheck.js.map +1 -1
- package/dist/src/live/publish.js +1 -1
- package/dist/src/live/publish.js.map +1 -1
- package/dist/src/livecli.js +1 -1
- package/dist/src/livecli.js.map +1 -1
- package/dist/src/loginrefresh/handover.js +121 -0
- package/dist/src/loginrefresh/handover.js.map +1 -0
- package/dist/src/loginrefresh/lock.js +54 -3
- package/dist/src/loginrefresh/lock.js.map +1 -1
- package/dist/src/loginrefresh/refresher.js +33 -5
- package/dist/src/loginrefresh/refresher.js.map +1 -1
- package/dist/src/loginrefresh/supervisor.js +18 -1
- package/dist/src/loginrefresh/supervisor.js.map +1 -1
- package/dist/src/npmverbs.js +42 -12
- package/dist/src/npmverbs.js.map +1 -1
- package/dist/src/onboard/choices.js +6 -3
- package/dist/src/onboard/choices.js.map +1 -1
- package/dist/src/onboard/engine.js +33 -292
- package/dist/src/onboard/engine.js.map +1 -1
- package/dist/src/onboard/steps/bringup.js +3 -2
- package/dist/src/onboard/steps/bringup.js.map +1 -1
- package/dist/src/onboard/steps/configfill.js +18 -7
- package/dist/src/onboard/steps/configfill.js.map +1 -1
- package/dist/src/onboard/steps/loginshare.js +213 -0
- package/dist/src/onboard/steps/loginshare.js.map +1 -0
- package/dist/src/onboard/steps/memory.js +14 -2
- package/dist/src/onboard/steps/memory.js.map +1 -1
- package/dist/src/onboard/steps/repoconfig.js +72 -0
- package/dist/src/onboard/steps/repoconfig.js.map +1 -0
- package/dist/src/onboard/toolnames.js +2 -2
- package/dist/src/onboard/toolnames.js.map +1 -1
- package/dist/src/piagentdir.js +13 -0
- package/dist/src/piagentdir.js.map +1 -0
- package/dist/src/pilogin.js +31 -16
- package/dist/src/pilogin.js.map +1 -1
- package/dist/src/qcrbin.js +16 -11
- package/dist/src/qcrbin.js.map +1 -1
- package/dist/src/reviewargs.js +122 -0
- package/dist/src/reviewargs.js.map +1 -0
- package/dist/src/reviewcli.js +7 -161
- package/dist/src/reviewcli.js.map +1 -1
- package/dist/src/reviewdetach.js +76 -11
- package/dist/src/reviewdetach.js.map +1 -1
- package/dist/src/scancli.js +39 -691
- package/dist/src/scancli.js.map +1 -1
- package/dist/src/selfimprove.js +66 -0
- package/dist/src/selfimprove.js.map +1 -0
- package/dist/src/session-command-supervisor.js +38 -9
- package/dist/src/session-command-supervisor.js.map +1 -1
- package/dist/src/setup/adapters.js +5 -223
- package/dist/src/setup/adapters.js.map +1 -1
- package/dist/src/setup/caserunner.js +15 -249
- package/dist/src/setup/caserunner.js.map +1 -1
- package/dist/src/setup/engine.js +32 -341
- package/dist/src/setup/engine.js.map +1 -1
- package/dist/src/setup/facts.js +3 -592
- package/dist/src/setup/facts.js.map +1 -1
- package/dist/src/setup/findings.js +3 -947
- package/dist/src/setup/findings.js.map +1 -1
- package/dist/src/setup/presentation.js +3 -8
- package/dist/src/setup/presentation.js.map +1 -1
- package/dist/src/setup/scan.js +3 -724
- package/dist/src/setup/scan.js.map +1 -1
- package/dist/src/setup/scanbuild.js +9 -92
- package/dist/src/setup/scanbuild.js.map +1 -1
- package/dist/src/setup/scanlease.js +3 -250
- package/dist/src/setup/scanlease.js.map +1 -1
- package/dist/src/setup/scanreport.js +3 -288
- package/dist/src/setup/scanreport.js.map +1 -1
- package/dist/src/setup/scanrun.js +3 -1732
- package/dist/src/setup/scanrun.js.map +1 -1
- package/dist/src/setup/scantree.js +3 -256
- package/dist/src/setup/scantree.js.map +1 -1
- package/dist/src/setup/stage2request.js +3 -258
- package/dist/src/setup/stage2request.js.map +1 -1
- package/dist/src/setup/unitcut.js +3 -436
- package/dist/src/setup/unitcut.js.map +1 -1
- package/dist/src/state.js +75 -9
- package/dist/src/state.js.map +1 -1
- package/dist/src/takeover.js +1 -0
- package/dist/src/takeover.js.map +1 -1
- package/dist/src/takeoverpi.js +2 -1
- package/dist/src/takeoverpi.js.map +1 -1
- package/dist/src/tui/alerts.js +4 -1
- package/dist/src/tui/alerts.js.map +1 -1
- package/dist/src/tui/board.js +2 -2
- package/dist/src/tui/board.js.map +1 -1
- package/dist/src/tui/companion.js +2 -1
- package/dist/src/tui/companion.js.map +1 -1
- package/dist/src/tui/deploytab.js +2 -2
- package/dist/src/tui/deploytab.js.map +1 -1
- package/dist/src/tui/index.js +9 -6
- package/dist/src/tui/index.js.map +1 -1
- package/dist/src/tui/memory.js +3 -16
- package/dist/src/tui/memory.js.map +1 -1
- package/dist/src/tui/memoryurl.js +22 -0
- package/dist/src/tui/memoryurl.js.map +1 -0
- package/dist/src/tui/remote.js +1 -1
- package/dist/src/tui/remote.js.map +1 -1
- package/dist/src/tui/route.js +2 -2
- package/dist/src/tui/route.js.map +1 -1
- package/dist/src/tui/setupstate.js +8 -5
- package/dist/src/tui/setupstate.js.map +1 -1
- package/dist/src/tui/sshtriage.js +1 -1
- package/dist/src/tui/sshtriage.js.map +1 -1
- package/dist/src/verdicts.js +12 -0
- package/dist/src/verdicts.js.map +1 -1
- package/dist/src/wire.js +9 -4
- package/dist/src/wire.js.map +1 -1
- package/dist/src/workerbreaker.js +32 -5
- package/dist/src/workerbreaker.js.map +1 -1
- package/dist/src/workerworld.js +4 -0
- package/dist/src/workerworld.js.map +1 -1
- package/docker/README.md +1 -1
- package/docker/entrypoint.sh +40 -6
- package/docker-compose.yml +16 -1
- package/extensions/cli/home.ts +14 -0
- package/extensions/cli/selfimprove.ts +18 -0
- package/extensions/home.ts +484 -0
- package/extensions/login-route.ts +1 -1
- package/extensions/onboarding.ts +8 -7
- package/extensions/selfimprove.ts +65 -0
- package/extensions/setup-scan.ts +79 -766
- package/extensions/setup.ts +14 -70
- package/extensions/vendor/openviking-pi/NOTICE +5 -0
- package/extensions/vendor/openviking-pi/README.md +39 -5
- package/extensions/vendor/openviking-pi/index.ts +4 -0
- package/extensions/vendor/openviking-pi/patches/0014-one-turn-sync-in-flight.patch +239 -0
- package/extensions/vendor/openviking-pi/qcr-hooks.ts +3 -1
- package/extensions/vendor/openviking-pi/sync.ts +80 -0
- package/extensions/vendor/openviking-pi/upstream.json +2 -1
- package/package.json +6 -4
- package/prompts/onboarding.md +8 -7
- package/schemas/review-result.v1.json +60 -0
- package/scripts/deploy.sh +8 -6
- package/scripts/qcr-host.sh +28 -5
- package/scripts/vm-ghcr-login.sh +7 -4
- package/src/archive/restore.ts +18 -21
- package/src/botworker.ts +13 -2
- package/src/channelfs.ts +3 -26
- package/src/cli.ts +13 -54
- package/src/config.ts +38 -0
- package/src/daemon.ts +34 -4
- package/src/debuground.ts +3 -0
- package/src/debugroundpi.ts +1 -0
- package/src/engine/caserunner-sdk.ts +29 -314
- package/src/home/adapters.ts +418 -0
- package/src/home/advanced.ts +421 -0
- package/src/home/advancedtools.ts +96 -0
- package/src/home/autoreview.ts +385 -0
- package/src/home/daemonstatus.ts +55 -0
- package/src/home/devicelogin.ts +107 -0
- package/src/home/launch.ts +257 -0
- package/src/home/loginowner.ts +248 -0
- package/src/home/memory.ts +176 -0
- package/src/home/model.ts +965 -0
- package/src/home/panel.ts +666 -0
- package/src/home/ports.ts +338 -0
- package/src/home/review.ts +206 -0
- package/src/home/reviews.ts +565 -0
- package/src/home/setup.ts +689 -0
- package/src/home/setupadapters.ts +217 -0
- package/src/home/setupcard.ts +288 -0
- package/src/home/setuptools.ts +57 -0
- package/src/home/spend.ts +194 -0
- package/src/home/switches.ts +293 -0
- package/src/home/switchon.ts +554 -0
- package/src/home/toolnames.ts +46 -0
- package/src/home/tune.ts +405 -0
- package/src/home/words.ts +8 -0
- package/src/homes.ts +17 -0
- package/src/install.ts +1 -1
- package/src/ipc/workerspec.ts +15 -0
- package/src/ledgercheck.ts +45 -9
- package/src/live/publish.ts +4 -1
- package/src/livecli.ts +6 -1
- package/src/loginrefresh/handover.ts +138 -0
- package/src/loginrefresh/lock.ts +72 -3
- package/src/loginrefresh/refresher.ts +34 -5
- package/src/loginrefresh/supervisor.ts +25 -2
- package/src/npmverbs.ts +51 -11
- package/src/onboard/choices.ts +6 -3
- package/src/onboard/engine.ts +64 -434
- package/src/onboard/steps/bringup.ts +3 -2
- package/src/onboard/steps/configfill.ts +23 -7
- package/src/onboard/steps/loginshare.ts +258 -0
- package/src/onboard/steps/memory.ts +18 -2
- package/src/onboard/steps/repoconfig.ts +85 -0
- package/src/onboard/toolnames.ts +2 -2
- package/src/piagentdir.ts +12 -0
- package/src/pilogin.ts +32 -16
- package/src/qcrbin.ts +16 -11
- package/src/reviewargs.ts +119 -0
- package/src/reviewcli.ts +6 -167
- package/src/reviewdetach.ts +107 -13
- package/src/scancli.ts +75 -826
- package/src/selfimprove.ts +90 -0
- package/src/session-command-supervisor.ts +41 -8
- package/src/setup/adapters.ts +13 -221
- package/src/setup/caserunner.ts +34 -273
- package/src/setup/engine.ts +62 -496
- package/src/setup/facts.ts +3 -627
- package/src/setup/findings.ts +3 -1264
- package/src/setup/presentation.ts +3 -18
- package/src/setup/scan.ts +3 -1033
- package/src/setup/scanbuild.ts +20 -114
- package/src/setup/scanlease.ts +3 -297
- package/src/setup/scanreport.ts +3 -373
- package/src/setup/scanrun.ts +3 -2268
- package/src/setup/scantree.ts +3 -259
- package/src/setup/stage2request.ts +3 -347
- package/src/setup/unitcut.ts +3 -523
- package/src/state.ts +126 -11
- package/src/takeover.ts +5 -0
- package/src/takeoverpi.ts +3 -2
- package/src/tui/alerts.ts +9 -2
- package/src/tui/board.ts +2 -2
- package/src/tui/companion.ts +2 -1
- package/src/tui/deploytab.ts +2 -2
- package/src/tui/index.ts +9 -6
- package/src/tui/memory.ts +3 -15
- package/src/tui/memoryurl.ts +22 -0
- package/src/tui/remote.ts +1 -1
- package/src/tui/route.ts +2 -2
- package/src/tui/setupstate.ts +20 -16
- package/src/tui/sshtriage.ts +1 -1
- package/src/verdicts.ts +13 -0
- package/src/wire.ts +9 -4
- package/src/workerbreaker.ts +41 -5
- package/src/workerworld.ts +9 -1
- package/dist/src/live/projection.js +0 -450
- package/dist/src/live/projection.js.map +0 -1
- package/dist/src/live/read.js +0 -60
- package/dist/src/live/read.js.map +0 -1
- package/dist/src/onboard/launch.js +0 -75
- package/dist/src/onboard/launch.js.map +0 -1
- package/dist/src/setup/ledger.js +0 -207
- package/dist/src/setup/ledger.js.map +0 -1
- package/dist/src/setup/stage2.js +0 -617
- package/dist/src/setup/stage2.js.map +0 -1
- package/dist/src/setup/textsniff.js +0 -89
- package/dist/src/setup/textsniff.js.map +0 -1
- package/src/live/projection.ts +0 -491
- package/src/live/read.ts +0 -86
- package/src/onboard/launch.ts +0 -103
- package/src/setup/ledger.ts +0 -324
- package/src/setup/stage2.ts +0 -926
- package/src/setup/textsniff.ts +0 -95
package/src/home/tune.ts
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// "Tune to this repo": the home runs Setup for the repository's profile when
|
|
2
|
+
// the person chooses it (CONTEXT.md: Setup is the survey, the review history
|
|
3
|
+
// and the interview, and its result is the repo profile). Nothing waits on
|
|
4
|
+
// it: a review before it, the first one included, runs on the default
|
|
5
|
+
// profile, and the review button never asks for it.
|
|
6
|
+
//
|
|
7
|
+
// Setup is a conversation, so it runs with the agent the person is already
|
|
8
|
+
// talking to: the home hands the chat the plugin's own Setup brief
|
|
9
|
+
// (`profileSetupBrief`, the one `/qcr-setup profile` sends), over a checkout
|
|
10
|
+
// of the repository on this machine. The agent surveys the checkout with its
|
|
11
|
+
// read tools, reads the review history when it can, interviews the person in
|
|
12
|
+
// the chat, and writes the profile, `.qcr/` at the checkout's root, through
|
|
13
|
+
// `home_tune_write`: the one write the home lends it, which writes only a
|
|
14
|
+
// profile file, only while a tune runs, and only on the person's yes.
|
|
15
|
+
//
|
|
16
|
+
// A profile is several files (profile.toml and one addendum per house rule),
|
|
17
|
+
// so the tune stays open through all of them: it ends when the agent marks a
|
|
18
|
+
// write as the profile's last file (`last`), or with the session. Until the
|
|
19
|
+
// agent writes through the tool, the home also watches the profile (the
|
|
20
|
+
// plugin's own detect: `.qcr/profile.toml` in the checkout) and says when one
|
|
21
|
+
// is written another way. A review reads the profile from the base commit,
|
|
22
|
+
// so it applies once the person commits it there.
|
|
23
|
+
//
|
|
24
|
+
// The checkout is untrusted: its contributors choose what is in it, links
|
|
25
|
+
// included. A write creates its temporary file exclusively, never through a
|
|
26
|
+
// link, and refuses a `.qcr/` or `addenda/` that is a link or resolves
|
|
27
|
+
// outside the checkout, so a yes writes only the file it names.
|
|
28
|
+
//
|
|
29
|
+
// tune a key (t, alt+t) and the `home_tune` tool
|
|
30
|
+
// write the `home_tune_write` tool: the agent's draft, the person's yes
|
|
31
|
+
import { randomBytes } from "node:crypto";
|
|
32
|
+
import {
|
|
33
|
+
closeSync,
|
|
34
|
+
constants,
|
|
35
|
+
existsSync,
|
|
36
|
+
lstatSync,
|
|
37
|
+
mkdirSync,
|
|
38
|
+
openSync,
|
|
39
|
+
readFileSync,
|
|
40
|
+
realpathSync,
|
|
41
|
+
renameSync,
|
|
42
|
+
statSync,
|
|
43
|
+
unlinkSync,
|
|
44
|
+
writeSync,
|
|
45
|
+
type Stats,
|
|
46
|
+
} from "node:fs";
|
|
47
|
+
import { basename, dirname, join, relative, sep } from "node:path";
|
|
48
|
+
import { profileSetupBrief } from "@quickchatai/pi-quickreview/src/plugin/setup.js";
|
|
49
|
+
import { REPO_PROFILE_DIR } from "@quickchatai/pi-quickreview/src/profileoverrides.js";
|
|
50
|
+
import { parse } from "smol-toml";
|
|
51
|
+
import type { ProcessRunner } from "../onboard/engine.js";
|
|
52
|
+
import { known, unknown, type Known, type Outcome } from "./ports.js";
|
|
53
|
+
import { parseRepoInput } from "./setup.js";
|
|
54
|
+
import type { Ask } from "./switches.js";
|
|
55
|
+
|
|
56
|
+
/** Where the repository's Setup stands. */
|
|
57
|
+
export interface TuneStatus {
|
|
58
|
+
/** It has a profile of its own: Setup has run. */
|
|
59
|
+
tuned: boolean;
|
|
60
|
+
/** As the person reads it. */
|
|
61
|
+
detail: string;
|
|
62
|
+
/** When its profile was last written, ms since the epoch; null without one. */
|
|
63
|
+
at: number | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Setup, ready to hand the agent. */
|
|
67
|
+
export interface TuneStart {
|
|
68
|
+
/** The checkout Setup reads and writes the profile into. */
|
|
69
|
+
checkout: string;
|
|
70
|
+
/** The Setup brief. */
|
|
71
|
+
brief: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface TunePort {
|
|
75
|
+
status(repo: string): Promise<Known<TuneStatus>>;
|
|
76
|
+
/** Find the checkout Setup runs on, and its brief. */
|
|
77
|
+
start(repo: string): Promise<Outcome<TuneStart>>;
|
|
78
|
+
/** Write one file of the profile, `file` relative to `.qcr/`. */
|
|
79
|
+
write(checkout: string, file: string, text: string): Promise<Outcome<{ path: string }>>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** How often the home looks for the profile while a tune runs. */
|
|
83
|
+
export const TUNE_POLL_MS = 5000;
|
|
84
|
+
|
|
85
|
+
/** The files of a repo profile Setup may write: profile.toml, and one
|
|
86
|
+
* addendum per house rule. Null: not one of them. */
|
|
87
|
+
export function profileFile(file: string): string | null {
|
|
88
|
+
const f = file.trim().replace(/^\.qcr\//, "");
|
|
89
|
+
if (f === "profile.toml") return f;
|
|
90
|
+
return /^addenda\/[A-Za-z0-9][A-Za-z0-9._-]*\.md$/.test(f) && !f.includes("..") ? f : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What the home adds to the plugin's brief: the write it lends, and what
|
|
94
|
+
* the reviewer-only home's agent cannot do. */
|
|
95
|
+
export function homeBriefNote(checkout: string): string {
|
|
96
|
+
return [
|
|
97
|
+
"",
|
|
98
|
+
`In this home, the checkout is ${checkout}: read it with your read tools. Write each file of the profile with the \`home_tune_write\` tool (\`file\` is relative to ${REPO_PROFILE_DIR}/: \`profile.toml\` or \`addenda/<nn>-<rule-id>.md\`); it shows me the file and writes it only on my yes. Pass \`last: true\` with the profile's final file: the tune ends with it. If you cannot reach \`gh\` from here, skip the review history and say so.`,
|
|
99
|
+
].join("\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const WRITE_NO = "No, don't write it";
|
|
103
|
+
export const WRITE_YES = "Yes, write it";
|
|
104
|
+
|
|
105
|
+
const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
|
106
|
+
|
|
107
|
+
export interface HomeTuneOptions {
|
|
108
|
+
port?: TunePort;
|
|
109
|
+
/** The repository the home reviews, or null. */
|
|
110
|
+
repo: () => string | null;
|
|
111
|
+
say: (text: string, kind?: "info" | "ok" | "warn") => void;
|
|
112
|
+
/** Run `fn` once after `ms`; the returned function cancels it. */
|
|
113
|
+
after: (ms: number, fn: () => void) => () => void;
|
|
114
|
+
onChange?: () => void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface Tuning {
|
|
118
|
+
repo: string;
|
|
119
|
+
checkout: string;
|
|
120
|
+
before: TuneStatus | null;
|
|
121
|
+
/** The files written through the tool; once there is one, only `last` ends it. */
|
|
122
|
+
wrote: string[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The tune: one at a time, from the person's choice to the profile's last file. */
|
|
126
|
+
export class HomeTune {
|
|
127
|
+
private tuning: Tuning | null = null;
|
|
128
|
+
private cancel: (() => void) | null = null;
|
|
129
|
+
private disposed = false;
|
|
130
|
+
|
|
131
|
+
constructor(private readonly o: HomeTuneOptions) {}
|
|
132
|
+
|
|
133
|
+
/** Is Setup under way? */
|
|
134
|
+
running(): boolean {
|
|
135
|
+
return this.tuning !== null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
dispose(): void {
|
|
139
|
+
this.disposed = true;
|
|
140
|
+
this.cancel?.();
|
|
141
|
+
this.cancel = null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Run Setup for the repository: hand `tellAgent` the brief, then watch for
|
|
145
|
+
* the profile. Without an agent to tell (outside a session), nothing starts. */
|
|
146
|
+
async tune(tellAgent: ((text: string) => void) | undefined): Promise<string> {
|
|
147
|
+
const port = this.o.port;
|
|
148
|
+
if (!port) return "Setup can't be run from this home";
|
|
149
|
+
const repo = this.o.repo();
|
|
150
|
+
if (repo === null) return "no repository is chosen, so there is nothing to tune to";
|
|
151
|
+
if (this.tuning)
|
|
152
|
+
return `Setup of ${this.tuning.repo} is under way with your agent in the chat (${this.tuning.checkout}); it ends when your agent writes the profile's last file`;
|
|
153
|
+
if (!tellAgent) return "Setup is a conversation with the chat's agent, and there is none here";
|
|
154
|
+
let started: Outcome<TuneStart>;
|
|
155
|
+
try {
|
|
156
|
+
started = await port.start(repo);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
started = { ok: false, why: message(err) };
|
|
159
|
+
}
|
|
160
|
+
if (!started.ok) {
|
|
161
|
+
this.o.say(`Can't tune to ${repo} yet: ${started.why}`, "warn");
|
|
162
|
+
return `Setup did not start: ${started.why}`;
|
|
163
|
+
}
|
|
164
|
+
const before = await this.read(repo);
|
|
165
|
+
this.tuning = {
|
|
166
|
+
repo,
|
|
167
|
+
checkout: started.checkout,
|
|
168
|
+
before: before.kind === "known" ? before.value : null,
|
|
169
|
+
wrote: [],
|
|
170
|
+
};
|
|
171
|
+
tellAgent(`${started.brief}\n${homeBriefNote(started.checkout)}`);
|
|
172
|
+
this.o.say(
|
|
173
|
+
`Tuning to ${repo}: your agent surveys ${started.checkout}, reads its review history and asks you about it, here in the chat. Reviews run on the default profile until it is done.`,
|
|
174
|
+
);
|
|
175
|
+
this.o.onChange?.();
|
|
176
|
+
this.watch();
|
|
177
|
+
return `Setup of ${repo} started with the chat's agent, over ${started.checkout}; the home says when the profile is written`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The agent's draft of one profile file, written on the person's yes.
|
|
181
|
+
* `last`: it is the profile's final file, and the tune ends with it. */
|
|
182
|
+
async write(file: string, text: string, ask: Ask | undefined, last = false): Promise<string> {
|
|
183
|
+
const t = this.tuning;
|
|
184
|
+
const port = this.o.port;
|
|
185
|
+
if (!t || !port) return "no tune is running: start one first (home_tune)";
|
|
186
|
+
const f = profileFile(file);
|
|
187
|
+
if (f === null)
|
|
188
|
+
return `${JSON.stringify(file)} is not a profile file: write profile.toml or addenda/<nn>-<rule-id>.md`;
|
|
189
|
+
if (!ask) return "the person answers whether to write it, and no one can answer here";
|
|
190
|
+
const where = join(t.checkout, REPO_PROFILE_DIR, f);
|
|
191
|
+
let yes: string | undefined;
|
|
192
|
+
try {
|
|
193
|
+
const lines = text.split("\n").filter((l) => l.trim() !== "").length;
|
|
194
|
+
yes = await ask.select(`Write ${where} (${lines} lines, as your agent showed it)?`, [
|
|
195
|
+
WRITE_NO,
|
|
196
|
+
WRITE_YES,
|
|
197
|
+
]);
|
|
198
|
+
} catch {
|
|
199
|
+
yes = undefined;
|
|
200
|
+
}
|
|
201
|
+
if (yes !== WRITE_YES) return `the person said no: ${REPO_PROFILE_DIR}/${f} was not written`;
|
|
202
|
+
let r: Outcome<{ path: string }>;
|
|
203
|
+
try {
|
|
204
|
+
r = await port.write(t.checkout, f, text);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
r = { ok: false, why: message(err) };
|
|
207
|
+
}
|
|
208
|
+
if (!r.ok) return `${REPO_PROFILE_DIR}/${f} was not written: ${r.why}`;
|
|
209
|
+
if (this.tuning !== t) return `wrote ${r.path}`;
|
|
210
|
+
t.wrote.push(f);
|
|
211
|
+
if (!last)
|
|
212
|
+
return `wrote ${r.path}; the tune stays open: write the profile's other files, and pass last: true with the final one`;
|
|
213
|
+
const n = t.wrote.length === 1 ? "1 file" : `${t.wrote.length} files`;
|
|
214
|
+
this.finish(t, `${n} of ${REPO_PROFILE_DIR}/ in ${t.checkout}`);
|
|
215
|
+
return `wrote ${r.path}; Setup is done`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private async read(repo: string): Promise<Known<TuneStatus>> {
|
|
219
|
+
try {
|
|
220
|
+
return await this.o.port!.status(repo);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
return unknown(message(err));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private watch(): void {
|
|
227
|
+
if (this.disposed || !this.tuning) return;
|
|
228
|
+
this.cancel = this.o.after(TUNE_POLL_MS, () => {
|
|
229
|
+
this.cancel = null;
|
|
230
|
+
void this.check().then((done) => {
|
|
231
|
+
if (!done) this.watch();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Has the profile been written another way since the tune started? Says
|
|
237
|
+
* so once. Once the agent writes through the tool, only `last` ends it: a
|
|
238
|
+
* profile.toml is the first file of several, not the end. */
|
|
239
|
+
private async check(): Promise<boolean> {
|
|
240
|
+
const t = this.tuning;
|
|
241
|
+
if (!t || t.wrote.length > 0) return true;
|
|
242
|
+
const s = await this.read(t.repo);
|
|
243
|
+
if (this.tuning !== t || t.wrote.length > 0) return true;
|
|
244
|
+
if (s.kind === "unknown" || !s.value.tuned) return false;
|
|
245
|
+
const b = t.before;
|
|
246
|
+
const fresh = !b?.tuned || (s.value.at ?? 0) > (b.at ?? 0);
|
|
247
|
+
if (!fresh) return false;
|
|
248
|
+
this.finish(t, s.value.detail);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private finish(t: Tuning, detail: string): void {
|
|
253
|
+
this.tuning = null;
|
|
254
|
+
this.cancel?.();
|
|
255
|
+
this.cancel = null;
|
|
256
|
+
this.o.say(
|
|
257
|
+
`Tuned to ${t.repo}: ${detail}. Reviews use it once it is committed to the base branch.`,
|
|
258
|
+
"ok",
|
|
259
|
+
);
|
|
260
|
+
this.o.onChange?.();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---- the real port: a checkout on this machine ---------------------------
|
|
265
|
+
|
|
266
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
267
|
+
|
|
268
|
+
export interface CheckoutTuneOptions {
|
|
269
|
+
env: Env;
|
|
270
|
+
/** The asset root: the shipped profiles and lenses the brief points at. */
|
|
271
|
+
root: () => string;
|
|
272
|
+
/** The app config, for `[companion] reviewed_repo_dir`. */
|
|
273
|
+
configPath: () => string;
|
|
274
|
+
runner: ProcessRunner;
|
|
275
|
+
read?: (p: string) => string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The checkout of `repo` on this machine: QCR_REVIEWED_REPO, the config's
|
|
279
|
+
* `[companion] reviewed_repo_dir`, then the directory the home was opened
|
|
280
|
+
* from, each only when git says its origin is `repo`. */
|
|
281
|
+
export async function findCheckout(
|
|
282
|
+
repo: string,
|
|
283
|
+
o: Pick<CheckoutTuneOptions, "env" | "configPath" | "runner" | "read">,
|
|
284
|
+
): Promise<string | null> {
|
|
285
|
+
const candidates: string[] = [];
|
|
286
|
+
const add = (v: unknown): void => {
|
|
287
|
+
if (typeof v === "string" && v.trim() !== "") candidates.push(v.trim());
|
|
288
|
+
};
|
|
289
|
+
add(o.env["QCR_REVIEWED_REPO"]);
|
|
290
|
+
try {
|
|
291
|
+
const read = o.read ?? ((p: string) => readFileSync(p, "utf8"));
|
|
292
|
+
const cfg = parse(read(o.configPath())) as Record<string, unknown>;
|
|
293
|
+
const companion = cfg["companion"];
|
|
294
|
+
if (typeof companion === "object" && companion !== null)
|
|
295
|
+
add((companion as Record<string, unknown>)["reviewed_repo_dir"]);
|
|
296
|
+
} catch {
|
|
297
|
+
// no config to read: the other places still count
|
|
298
|
+
}
|
|
299
|
+
add(o.env["PWD"]);
|
|
300
|
+
for (const dir of candidates) {
|
|
301
|
+
const top = await o.runner.run("git", ["-C", dir, "rev-parse", "--show-toplevel"]);
|
|
302
|
+
if (top.code !== 0) continue;
|
|
303
|
+
const root = top.stdout.trim();
|
|
304
|
+
const origin = await o.runner.run("git", ["-C", root, "remote", "get-url", "origin"]);
|
|
305
|
+
const named = origin.code === 0 ? parseRepoInput(origin.stdout.trim()) : null;
|
|
306
|
+
if (named !== null && named.toLowerCase() === repo.toLowerCase()) return root;
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The tune over a checkout of the repository on this machine. */
|
|
312
|
+
export function checkoutTune(o: CheckoutTuneOptions): TunePort {
|
|
313
|
+
const profileAt = (top: string): string => join(top, REPO_PROFILE_DIR, "profile.toml");
|
|
314
|
+
const missing = (repo: string): string =>
|
|
315
|
+
`Setup reads a checkout of ${repo}, and there is none here: open the home from inside one (cd into it, then run qcr), or set QCR_REVIEWED_REPO to it`;
|
|
316
|
+
return {
|
|
317
|
+
status: async (repo) => {
|
|
318
|
+
const top = await findCheckout(repo, o);
|
|
319
|
+
if (top === null) return unknown(`no checkout of ${repo} on this machine`);
|
|
320
|
+
const file = profileAt(top);
|
|
321
|
+
if (!existsSync(file))
|
|
322
|
+
return known({ tuned: false, detail: "reviews run on the default profile", at: null });
|
|
323
|
+
let at: number | null = null;
|
|
324
|
+
try {
|
|
325
|
+
at = statSync(file).mtimeMs;
|
|
326
|
+
} catch {
|
|
327
|
+
// it is there: when it was written is all that is not known
|
|
328
|
+
}
|
|
329
|
+
return known({ tuned: true, detail: `${REPO_PROFILE_DIR}/profile.toml in ${top}`, at });
|
|
330
|
+
},
|
|
331
|
+
start: async (repo) => {
|
|
332
|
+
const top = await findCheckout(repo, o);
|
|
333
|
+
if (top === null) return { ok: false, why: missing(repo) };
|
|
334
|
+
return { ok: true, checkout: top, brief: profileSetupBrief({ top, root: o.root() }) };
|
|
335
|
+
},
|
|
336
|
+
write: async (checkout, file, text) => {
|
|
337
|
+
const f = profileFile(file);
|
|
338
|
+
if (f === null) return { ok: false, why: `${file} is not a profile file` };
|
|
339
|
+
const path = join(checkout, REPO_PROFILE_DIR, f);
|
|
340
|
+
try {
|
|
341
|
+
const body = text.endsWith("\n") ? text : `${text}\n`;
|
|
342
|
+
writeInCheckout(checkout, `${REPO_PROFILE_DIR}/${f}`, body);
|
|
343
|
+
} catch (err) {
|
|
344
|
+
return { ok: false, why: message(err) };
|
|
345
|
+
}
|
|
346
|
+
return { ok: true, path };
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Write `rel` (slash-separated, below `checkout`) into an untrusted checkout.
|
|
352
|
+
* Every directory on the way is a real directory, made here when missing,
|
|
353
|
+
* never a link, and resolves inside the checkout; the file itself, when there,
|
|
354
|
+
* is a regular file. The text goes to a fresh temporary file, created
|
|
355
|
+
* exclusively and never through a link, then renamed over the target, so no
|
|
356
|
+
* name a contributor planted is opened for writing. */
|
|
357
|
+
export function writeInCheckout(checkout: string, rel: string, text: string): void {
|
|
358
|
+
const top = realpathSync(checkout);
|
|
359
|
+
const parts = rel.split("/");
|
|
360
|
+
const name = parts.pop()!;
|
|
361
|
+
let dir = top;
|
|
362
|
+
for (const part of parts) {
|
|
363
|
+
dir = join(dir, part);
|
|
364
|
+
let st: Stats;
|
|
365
|
+
try {
|
|
366
|
+
st = lstatSync(dir);
|
|
367
|
+
} catch {
|
|
368
|
+
mkdirSync(dir);
|
|
369
|
+
st = lstatSync(dir);
|
|
370
|
+
}
|
|
371
|
+
if (st.isSymbolicLink()) throw new Error(`${relative(top, dir)} is a link, not a directory`);
|
|
372
|
+
if (!st.isDirectory()) throw new Error(`${relative(top, dir)} is not a directory`);
|
|
373
|
+
}
|
|
374
|
+
const real = realpathSync(dir);
|
|
375
|
+
if (real !== top && !real.startsWith(top + sep))
|
|
376
|
+
throw new Error(`${relative(top, dir)} resolves outside the checkout`);
|
|
377
|
+
const path = join(real, name);
|
|
378
|
+
try {
|
|
379
|
+
const st = lstatSync(path);
|
|
380
|
+
if (!st.isFile()) throw new Error(`${rel} is there and is not a plain file`);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
383
|
+
}
|
|
384
|
+
const tmp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString("hex")}.tmp`);
|
|
385
|
+
const fd = openSync(
|
|
386
|
+
tmp,
|
|
387
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
388
|
+
0o644,
|
|
389
|
+
);
|
|
390
|
+
try {
|
|
391
|
+
try {
|
|
392
|
+
writeSync(fd, text);
|
|
393
|
+
} finally {
|
|
394
|
+
closeSync(fd);
|
|
395
|
+
}
|
|
396
|
+
renameSync(tmp, path);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
try {
|
|
399
|
+
unlinkSync(tmp);
|
|
400
|
+
} catch {
|
|
401
|
+
// already renamed, or never there: nothing to clean up
|
|
402
|
+
}
|
|
403
|
+
throw err;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The home's words for an amount: one money and one plural, so the panel, the
|
|
2
|
+
// model's notices and the review message say a price or a count alike.
|
|
3
|
+
|
|
4
|
+
/** "$1.33". */
|
|
5
|
+
export const money = (n: number): string => `$${n.toFixed(2)}`;
|
|
6
|
+
|
|
7
|
+
/** "1 finding", "3 findings". */
|
|
8
|
+
export const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? "" : "s"}`;
|
package/src/homes.ts
CHANGED
|
@@ -104,6 +104,23 @@ export function operatorStateDir(
|
|
|
104
104
|
return kind === "global" || kind === "local" ? stateHome(env) : join(root, "state");
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The compose project the home's switches and the admin TUI share, with the
|
|
109
|
+
* setup's state under it (`<root>/state`, `<root>/.env`): `QCR_ROOT` when
|
|
110
|
+
* set; a checkout or the image is its own; an app installed by a package
|
|
111
|
+
* manager gets one in its state home, never inside the package — for the
|
|
112
|
+
* same reasons as `operatorStateDir`.
|
|
113
|
+
*/
|
|
114
|
+
export function stackRoot(
|
|
115
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
116
|
+
pkgRoot: string,
|
|
117
|
+
): string {
|
|
118
|
+
const explicit = (env["QCR_ROOT"] ?? "").trim();
|
|
119
|
+
if (explicit !== "") return explicit;
|
|
120
|
+
const kind = detectInstall(pkgRoot, env).kind;
|
|
121
|
+
return kind === "global" || kind === "local" ? join(stateHome(env), "stack") : pkgRoot;
|
|
122
|
+
}
|
|
123
|
+
|
|
107
124
|
export function locateConfig(opts: LocateOptions = {}): LocatedConfig {
|
|
108
125
|
return core.locateConfig(optEnv(opts));
|
|
109
126
|
}
|
package/src/install.ts
CHANGED
|
@@ -190,7 +190,7 @@ export function migrateReport(outcome: MigrateOutcome): { code: number; lines: s
|
|
|
190
190
|
return {
|
|
191
191
|
code: 0,
|
|
192
192
|
lines: [
|
|
193
|
-
`no config.toml in the checkout or at ${outcome.to} — nothing to move (\`qcr
|
|
193
|
+
`no config.toml in the checkout or at ${outcome.to} — nothing to move (a bare \`qcr\` writes one there when you choose a repository)`,
|
|
194
194
|
],
|
|
195
195
|
};
|
|
196
196
|
case "override":
|
package/src/ipc/workerspec.ts
CHANGED
|
@@ -43,6 +43,14 @@ export interface WorkerSpec {
|
|
|
43
43
|
* in memory, never written back), and where models.json and the built lens
|
|
44
44
|
* agents are read from. */
|
|
45
45
|
agent_dir: string;
|
|
46
|
+
/** Where the login is borrowed from instead, when it is not in `agent_dir`:
|
|
47
|
+
* the single box's shared login (src/loginrefresh/supervisor.ts
|
|
48
|
+
* `loginDirOf`). Absent: `agent_dir`. */
|
|
49
|
+
login_dir?: string;
|
|
50
|
+
/** The providers the review's models name (the config's, plugin/login.ts
|
|
51
|
+
* `reviewProviders`): the login's admission checks only theirs. Omitted:
|
|
52
|
+
* every login in it. */
|
|
53
|
+
providers?: string[];
|
|
46
54
|
/** Where the PR's main JSONL lives (`state/pi-sessions/pr-<n>`). The worker
|
|
47
55
|
* resumes the newest one under it, or starts a session there at birth. */
|
|
48
56
|
session_dir: string;
|
|
@@ -169,6 +177,13 @@ export function parseWorkerSpec(raw: unknown): WorkerSpec {
|
|
|
169
177
|
spec.cap_usd = cap;
|
|
170
178
|
}
|
|
171
179
|
if (raw["lens_dir"] !== undefined) spec.lens_dir = abs(raw, "lens_dir");
|
|
180
|
+
if (raw["login_dir"] !== undefined) spec.login_dir = abs(raw, "login_dir");
|
|
181
|
+
if (raw["providers"] !== undefined) {
|
|
182
|
+
const p = raw["providers"];
|
|
183
|
+
if (!Array.isArray(p) || p.some((x) => typeof x !== "string" || x === ""))
|
|
184
|
+
return bad("`providers` must be a list of provider names");
|
|
185
|
+
spec.providers = p as string[];
|
|
186
|
+
}
|
|
172
187
|
if (raw["compact"] !== undefined) {
|
|
173
188
|
if (typeof raw["compact"] !== "boolean") return bad("`compact` must be a boolean");
|
|
174
189
|
spec.compact = raw["compact"];
|
package/src/ledgercheck.ts
CHANGED
|
@@ -11,8 +11,14 @@
|
|
|
11
11
|
// its ledger — no review work, no posting, no verdict file. A second stale
|
|
12
12
|
// detection for the same round never nudges again: it logs
|
|
13
13
|
// `ledger_stale_unresolved`, the grade that wakes the overseer (the session
|
|
14
|
-
// ignored its one nudge)
|
|
15
|
-
//
|
|
14
|
+
// ignored its one nudge), once per nudge key; later checks at the same key
|
|
15
|
+
// are a quiet `ledger_stale_repeat` event. The once-mark is persisted BEFORE
|
|
16
|
+
// the inject, so even a crashed inject can never turn into a nudge loop.
|
|
17
|
+
//
|
|
18
|
+
// A recheck uphold (round-less) updates the ledger only "if the delta settled
|
|
19
|
+
// or re-opened a finding" (prompts/round-recheck.md), so on that path a header
|
|
20
|
+
// naming the head the PR was last reviewed at, or a head a recheck upheld
|
|
21
|
+
// since, is fresh too.
|
|
16
22
|
import { readFileSync } from "node:fs";
|
|
17
23
|
import type { StateStore } from "./state.js";
|
|
18
24
|
import { wakeOrHold } from "./wakes.js";
|
|
@@ -36,15 +42,32 @@ export function classifyLedger(
|
|
|
36
42
|
content: string | null,
|
|
37
43
|
pr: number,
|
|
38
44
|
headSha: string,
|
|
45
|
+
alsoFresh: readonly string[] = [],
|
|
39
46
|
): LedgerFreshness {
|
|
40
47
|
if (content === null) return "missing";
|
|
41
48
|
const match = LEDGER_HEADER_RE.exec(content);
|
|
42
49
|
if (!match) return "unparseable";
|
|
43
50
|
if (Number(match[1]) !== pr) return "wrong_pr";
|
|
44
51
|
const written = (match[2] as string).toLowerCase();
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
const names = (sha: string): boolean => {
|
|
53
|
+
const head = sha.toLowerCase();
|
|
54
|
+
return written === head || (written.length >= MIN_SHA_PREFIX && head.startsWith(written));
|
|
55
|
+
};
|
|
56
|
+
return names(headSha) || alsoFresh.some(names) ? "fresh" : "stale_sha";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The heads a recheck uphold may find in the header besides its own: the head
|
|
60
|
+
* of the PR's last verdicted round and every head a recheck upheld since. */
|
|
61
|
+
export function upheldLedgerHeads(store: StateStore, pr: number): string[] {
|
|
62
|
+
const reviewed = store
|
|
63
|
+
.roundsFor(pr)
|
|
64
|
+
.filter((r) => r.verdict !== null)
|
|
65
|
+
.sort((a, b) => b.round_no - a.round_no)[0];
|
|
66
|
+
const since = reviewed ? (reviewed.finished_at ?? reviewed.started_at) : null;
|
|
67
|
+
return [
|
|
68
|
+
...(reviewed?.head_sha ? [reviewed.head_sha] : []),
|
|
69
|
+
...store.recheckUpheldHeads(pr, since),
|
|
70
|
+
];
|
|
48
71
|
}
|
|
49
72
|
|
|
50
73
|
/** The re-nudge wake. Explicitly bookkeeping-only: the rewrite arrives
|
|
@@ -99,7 +122,12 @@ export async function enforceLedgerFreshness(
|
|
|
99
122
|
} catch {
|
|
100
123
|
content = null;
|
|
101
124
|
}
|
|
102
|
-
const freshness = classifyLedger(
|
|
125
|
+
const freshness = classifyLedger(
|
|
126
|
+
content,
|
|
127
|
+
pr,
|
|
128
|
+
headSha,
|
|
129
|
+
roundNo === null ? upheldLedgerHeads(store, pr) : [],
|
|
130
|
+
);
|
|
103
131
|
if (freshness === "fresh") return "fresh";
|
|
104
132
|
|
|
105
133
|
const detail = `ledger ${freshness} at ${path}: expected header "LEDGER PR #${pr} @ ${headSha}"`;
|
|
@@ -140,9 +168,17 @@ export async function enforceLedgerFreshness(
|
|
|
140
168
|
if (store.ledgerNudgeSent(pr, key)) {
|
|
141
169
|
// Still stale AFTER the re-nudge: the session ignored its one nudge —
|
|
142
170
|
// this grade WAKES the overseer (the severity map in src/anomalies.ts), first-detection
|
|
143
|
-
// "ledger_stale" stays quiet (the nudge itself is the response).
|
|
144
|
-
|
|
145
|
-
|
|
171
|
+
// "ledger_stale" stays quiet (the nudge itself is the response). Raised
|
|
172
|
+
// once per key: a later check at the same key has no new nudge to give,
|
|
173
|
+
// so it is the quiet repeat event alone.
|
|
174
|
+
if (!store.ledgerRepeatSeen(pr, key))
|
|
175
|
+
store.logAnomaly("ledger_stale_unresolved", pr, roundNo, detail);
|
|
176
|
+
store.logEvent("ledger_stale_repeat", pr, {
|
|
177
|
+
key,
|
|
178
|
+
round_no: roundNo,
|
|
179
|
+
head_sha: headSha,
|
|
180
|
+
freshness,
|
|
181
|
+
});
|
|
146
182
|
return "already_nudged";
|
|
147
183
|
}
|
|
148
184
|
store.logAnomaly("ledger_stale", pr, roundNo, detail);
|
package/src/live/publish.ts
CHANGED
|
@@ -17,7 +17,10 @@ import {
|
|
|
17
17
|
progressLogPath,
|
|
18
18
|
} from "@quickchatai/pi-quickreview/src/round-progress.js";
|
|
19
19
|
import { readLiveEvents } from "@quickchatai/pi-quickreview/src/live/events.js";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
RoundProgressProjection,
|
|
22
|
+
type ProjectionIdentity,
|
|
23
|
+
} from "@quickchatai/pi-quickreview/src/live/projection.js";
|
|
21
24
|
|
|
22
25
|
/** `<root>/state/round-progress/pr-<n>.json`, where the supervisor reads it. */
|
|
23
26
|
export function roundProgressPath(root: string, pr: number): string {
|
package/src/livecli.ts
CHANGED
|
@@ -15,7 +15,12 @@ import {
|
|
|
15
15
|
readLiveEvents,
|
|
16
16
|
type LiveEvent,
|
|
17
17
|
} from "@quickchatai/pi-quickreview/src/live/events.js";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
latestLiveRound,
|
|
20
|
+
lensLogIds,
|
|
21
|
+
listLiveRounds,
|
|
22
|
+
type LiveRoundRef,
|
|
23
|
+
} from "@quickchatai/pi-quickreview/src/live/read.js";
|
|
19
24
|
|
|
20
25
|
export const LIVE_USAGE =
|
|
21
26
|
"usage: qcr live [RUN_ID | DIR] [--lens ID] [--round N] [--follow] [--json]";
|