crewhaus 0.1.7 → 0.2.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/README.md +10 -3
- package/dist/advice-apply.d.ts +182 -0
- package/dist/advice-apply.js +286 -0
- package/dist/advise-rules.d.ts +348 -0
- package/dist/advise-rules.js +905 -0
- package/dist/alert-sink.d.ts +48 -0
- package/dist/alert-sink.js +86 -0
- package/dist/approval-gate.d.ts +127 -0
- package/dist/approval-gate.js +254 -0
- package/dist/audit-verify.d.ts +69 -0
- package/dist/audit-verify.js +97 -0
- package/dist/autodistill.d.ts +113 -0
- package/dist/autodistill.js +256 -0
- package/dist/channel-provision.d.ts +360 -0
- package/dist/channel-provision.js +881 -0
- package/dist/ci-scaffold.d.ts +31 -0
- package/dist/ci-scaffold.js +343 -0
- package/dist/compile-check.d.ts +90 -0
- package/dist/compile-check.js +285 -0
- package/dist/compliance-schedule.d.ts +35 -0
- package/dist/compliance-schedule.js +36 -0
- package/dist/context-pressure.d.ts +80 -0
- package/dist/context-pressure.js +166 -0
- package/dist/dataset-mine.d.ts +172 -0
- package/dist/dataset-mine.js +403 -0
- package/dist/datasets.d.ts +124 -0
- package/dist/datasets.js +260 -0
- package/dist/deploy-canary.d.ts +83 -0
- package/dist/deploy-canary.js +87 -0
- package/dist/doctor-checks.d.ts +33 -0
- package/dist/doctor-checks.js +92 -0
- package/dist/doctor-detect.d.ts +108 -0
- package/dist/doctor-detect.js +214 -0
- package/dist/doctor-fix.d.ts +81 -0
- package/dist/doctor-fix.js +164 -0
- package/dist/egress-triage.d.ts +121 -0
- package/dist/egress-triage.js +261 -0
- package/dist/eval-bridge.d.ts +114 -0
- package/dist/eval-bridge.js +158 -0
- package/dist/eval-coverage.d.ts +140 -0
- package/dist/eval-coverage.js +428 -0
- package/dist/eval-history.d.ts +48 -0
- package/dist/eval-history.js +157 -0
- package/dist/eval-matrix.d.ts +80 -0
- package/dist/eval-matrix.js +182 -0
- package/dist/eval-sentinel.d.ts +65 -0
- package/dist/eval-sentinel.js +132 -0
- package/dist/faq.d.ts +68 -0
- package/dist/faq.js +168 -0
- package/dist/feedback.d.ts +246 -0
- package/dist/feedback.js +612 -0
- package/dist/fewshot.d.ts +83 -0
- package/dist/fewshot.js +158 -0
- package/dist/fleet.d.ts +207 -0
- package/dist/fleet.js +488 -0
- package/dist/flywheel.d.ts +193 -0
- package/dist/flywheel.js +519 -0
- package/dist/graders-suggest.d.ts +186 -0
- package/dist/graders-suggest.js +658 -0
- package/dist/incident.d.ts +99 -0
- package/dist/incident.js +217 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +11727 -740
- package/dist/init-interactive.d.ts +105 -0
- package/dist/init-interactive.js +208 -0
- package/dist/intents.d.ts +105 -0
- package/dist/intents.js +292 -0
- package/dist/judge-calibrate.d.ts +137 -0
- package/dist/judge-calibrate.js +247 -0
- package/dist/justification-calibrate.d.ts +150 -0
- package/dist/justification-calibrate.js +262 -0
- package/dist/justification-gate.d.ts +27 -6
- package/dist/justification-gate.js +30 -6
- package/dist/knowledge-sync.d.ts +179 -0
- package/dist/knowledge-sync.js +551 -0
- package/dist/lessons.d.ts +87 -0
- package/dist/lessons.js +207 -0
- package/dist/lint.d.ts +127 -0
- package/dist/lint.js +226 -0
- package/dist/loadtest.d.ts +114 -0
- package/dist/loadtest.js +196 -0
- package/dist/marketplace-cli.d.ts +110 -0
- package/dist/marketplace-cli.js +250 -0
- package/dist/mcp-doctor.d.ts +121 -0
- package/dist/mcp-doctor.js +249 -0
- package/dist/model-scan.d.ts +116 -0
- package/dist/model-scan.js +226 -0
- package/dist/onchain-tune.d.ts +164 -0
- package/dist/onchain-tune.js +346 -0
- package/dist/permissions-suggest.d.ts +126 -0
- package/dist/permissions-suggest.js +333 -0
- package/dist/pii-tune.d.ts +107 -0
- package/dist/pii-tune.js +122 -0
- package/dist/propose.d.ts +117 -0
- package/dist/propose.js +184 -0
- package/dist/refresh-goldens.d.ts +82 -0
- package/dist/refresh-goldens.js +221 -0
- package/dist/regression-pin.d.ts +160 -0
- package/dist/regression-pin.js +281 -0
- package/dist/retention.d.ts +193 -0
- package/dist/retention.js +607 -0
- package/dist/retire.d.ts +118 -0
- package/dist/retire.js +291 -0
- package/dist/right-size.d.ts +100 -0
- package/dist/right-size.js +123 -0
- package/dist/scaffold-evals.d.ts +138 -0
- package/dist/scaffold-evals.js +410 -0
- package/dist/scope-audit-drift.d.ts +139 -0
- package/dist/scope-audit-drift.js +260 -0
- package/dist/security-corpus.d.ts +237 -0
- package/dist/security-corpus.js +516 -0
- package/dist/security-digest.d.ts +173 -0
- package/dist/security-digest.js +650 -0
- package/dist/sessions-index.d.ts +27 -0
- package/dist/sessions-index.js +51 -0
- package/dist/slo-doctor.d.ts +67 -0
- package/dist/slo-doctor.js +119 -0
- package/dist/slo-sink.d.ts +96 -0
- package/dist/slo-sink.js +107 -0
- package/dist/spec-changelog.d.ts +102 -0
- package/dist/spec-changelog.js +237 -0
- package/dist/state-backup.d.ts +223 -0
- package/dist/state-backup.js +648 -0
- package/dist/tools-cli.d.ts +170 -0
- package/dist/tools-cli.js +298 -0
- package/dist/triage.d.ts +202 -0
- package/dist/triage.js +403 -0
- package/dist/upgrade.d.ts +57 -0
- package/dist/upgrade.js +113 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.js +27 -0
- package/dist/voice-eval.d.ts +138 -0
- package/dist/voice-eval.js +309 -0
- package/dist/watch.d.ts +58 -0
- package/dist/watch.js +97 -0
- package/package.json +89 -64
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const VALID_ANCHOR_SCHEMES = ["file"];
|
|
2
|
+
/** Thrown by `resolveAnchorFlag` on a malformed `--anchor` value. The CLI
|
|
3
|
+
* entry file catches it and routes the message through `die()`; tests
|
|
4
|
+
* assert on `.message` without the process exiting. */
|
|
5
|
+
export class InvalidAnchorFlagError extends Error {
|
|
6
|
+
value;
|
|
7
|
+
name = "InvalidAnchorFlagError";
|
|
8
|
+
constructor(value) {
|
|
9
|
+
super(`invalid --anchor "${value}" — expected <scheme>:<path> with scheme one of: ${VALID_ANCHOR_SCHEMES.join(", ")} (e.g. file:/var/crewhaus/anchors)`);
|
|
10
|
+
this.value = value;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Parse the raw `--anchor` flag value (or undefined when absent). `file:<path>`
|
|
15
|
+
* selects the file-backed AnchorStore rooted at `<path>`; anything else —
|
|
16
|
+
* missing scheme, empty path, unknown scheme (S3 et al. are out of scope) —
|
|
17
|
+
* throws {@link InvalidAnchorFlagError}.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveAnchorFlag(flagValue) {
|
|
20
|
+
if (flagValue === undefined)
|
|
21
|
+
return undefined;
|
|
22
|
+
const sep = flagValue.indexOf(":");
|
|
23
|
+
if (sep <= 0)
|
|
24
|
+
throw new InvalidAnchorFlagError(flagValue);
|
|
25
|
+
const scheme = flagValue.slice(0, sep);
|
|
26
|
+
const path = flagValue.slice(sep + 1);
|
|
27
|
+
if (scheme !== "file" || path === "")
|
|
28
|
+
throw new InvalidAnchorFlagError(flagValue);
|
|
29
|
+
return { scheme, path };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Render a `VerifyResult` as per-check summary lines + the process exit code.
|
|
33
|
+
* `anchorRequested` is whether the user passed `--anchor`: when true, an
|
|
34
|
+
* `ok` result whose external anchor was NOT actually consulted (store empty,
|
|
35
|
+
* unreadable, or lagging into nonexistence) is a FAILURE — the check the
|
|
36
|
+
* user scheduled did not run, so exiting 0 would fabricate assurance.
|
|
37
|
+
*/
|
|
38
|
+
export function summarizeVerifyResult(result, opts) {
|
|
39
|
+
if (!result.ok) {
|
|
40
|
+
return {
|
|
41
|
+
exitCode: 1,
|
|
42
|
+
lines: [
|
|
43
|
+
`✗ tamper finding at ${result.file}:${result.line} — ${result.reason}`,
|
|
44
|
+
` ${result.recordsChecked} record(s) verified cleanly before the finding`,
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const lines = [
|
|
49
|
+
`✓ hash chain intact — ${result.recordsChecked} record(s) checked (per-record hash, prevHash links, gapless seq from 0)`,
|
|
50
|
+
];
|
|
51
|
+
if (result.anchorChecked) {
|
|
52
|
+
lines.push("✓ on-host chain-tail anchor matches the surviving tail");
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
lines.push("~ on-host chain-tail anchor absent — tail truncation cannot be ruled out (limitation, not tamper)");
|
|
56
|
+
}
|
|
57
|
+
let exitCode = 0;
|
|
58
|
+
if (result.externalAnchorChecked) {
|
|
59
|
+
lines.push("✓ external anchor store agrees with the chain tip");
|
|
60
|
+
}
|
|
61
|
+
else if (opts.anchorRequested) {
|
|
62
|
+
exitCode = 1;
|
|
63
|
+
lines.push("✗ external anchor requested (--anchor) but could not be cross-checked — the store holds no anchor for this log (or is unreadable), so the strongest tamper witness did not run");
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
lines.push("~ external anchor not consulted (pass --anchor file:<path> to cross-check one)");
|
|
67
|
+
}
|
|
68
|
+
return { lines, exitCode };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Map a `VerifyResult` onto the doctor's ✓/~/✗ check shape so `crewhaus
|
|
72
|
+
* doctor` can surface audit-log tampering when a `.crewhaus/audit` store
|
|
73
|
+
* exists in the cwd. An intact chain without the on-host anchor is a warn
|
|
74
|
+
* ("~"), never a failure — mirroring the library's limitation-not-tamper
|
|
75
|
+
* stance — and a broken chain fails doctor with the first finding inline.
|
|
76
|
+
*/
|
|
77
|
+
export function buildAuditIntegrityCheck(result) {
|
|
78
|
+
if (!result.ok) {
|
|
79
|
+
return {
|
|
80
|
+
label: "Audit log integrity (.crewhaus/audit)",
|
|
81
|
+
pass: false,
|
|
82
|
+
reason: `${result.reason} (${result.file}:${result.line}) — run \`crewhaus audit verify\` for details`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (!result.anchorChecked) {
|
|
86
|
+
return {
|
|
87
|
+
label: `Audit log integrity (.crewhaus/audit, ${result.recordsChecked} records)`,
|
|
88
|
+
pass: true,
|
|
89
|
+
warn: true,
|
|
90
|
+
reason: "chain intact but the chain-tail anchor is absent — tail truncation cannot be ruled out",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
label: `Audit log integrity (.crewhaus/audit, ${result.recordsChecked} records)`,
|
|
95
|
+
pass: true,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { DatasetRegistry } from "@crewhaus/dataset-registry";
|
|
2
|
+
import type { IrFeedback } from "@crewhaus/ir";
|
|
3
|
+
import { type DerivedTurn, type FeedbackRecord, type SessionTurn } from "./feedback";
|
|
4
|
+
/** Default "≥ N unprocessed ratings" trigger (the spec's `autoDistill` is a
|
|
5
|
+
* plain boolean and carries no threshold of its own). */
|
|
6
|
+
export declare const DEFAULT_AUTODISTILL_THRESHOLD = 5;
|
|
7
|
+
/** Env override for the trigger threshold. */
|
|
8
|
+
export declare const AUTODISTILL_THRESHOLD_ENV = "CREWHAUS_AUTODISTILL_THRESHOLD";
|
|
9
|
+
/** Opt-out env for the REPL exit rating prompt. */
|
|
10
|
+
export declare const NO_EXIT_RATING_ENV = "CREWHAUS_NO_EXIT_RATING";
|
|
11
|
+
/** The watermark file, relative to the harness cwd. */
|
|
12
|
+
export declare const DISTILL_STATE_RELPATH: string;
|
|
13
|
+
/** The registry dataset autoDistill maintains for a spec. */
|
|
14
|
+
export declare function ratingsDatasetName(specName: string): string;
|
|
15
|
+
export type DistillState = {
|
|
16
|
+
readonly schemaVersion: 1;
|
|
17
|
+
/** ISO ts of the newest feedback record folded into the last auto-distill;
|
|
18
|
+
* records with a STRICTLY greater ts count as unprocessed. */
|
|
19
|
+
readonly lastProcessedTs: string;
|
|
20
|
+
/** How many records the last auto-distill saw in total (informational). */
|
|
21
|
+
readonly processedCount: number;
|
|
22
|
+
/** The last registry version this consumer produced (informational). */
|
|
23
|
+
readonly lastRegistered?: {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly version: string;
|
|
26
|
+
readonly at: string;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
/** Narrow parsed JSON to a DistillState; undefined on any malformation (a
|
|
30
|
+
* corrupt watermark degrades to "everything is unprocessed", never throws). */
|
|
31
|
+
export declare function parseDistillState(text: string): DistillState | undefined;
|
|
32
|
+
export declare function readDistillState(path: string): DistillState | undefined;
|
|
33
|
+
export declare function writeDistillState(path: string, state: DistillState): void;
|
|
34
|
+
/** Records newer than the watermark (strict ISO-string comparison — the
|
|
35
|
+
* capture surfaces all stamp `new Date().toISOString()`, which compares
|
|
36
|
+
* lexicographically). No watermark → everything is unprocessed. */
|
|
37
|
+
export declare function countUnprocessed(records: ReadonlyArray<FeedbackRecord>, lastProcessedTs: string | undefined): number;
|
|
38
|
+
export declare function newestTs(records: ReadonlyArray<FeedbackRecord>): string | undefined;
|
|
39
|
+
export declare function resolveAutoDistillThreshold(env: Readonly<Record<string, string | undefined>>): number;
|
|
40
|
+
export type AutoDistillDecision = {
|
|
41
|
+
readonly run: boolean;
|
|
42
|
+
readonly reason: string;
|
|
43
|
+
};
|
|
44
|
+
/** Whether the teardown should distill: the spec opted in
|
|
45
|
+
* (`feedback.autoDistill: true`, block not disabled) AND enough
|
|
46
|
+
* unprocessed ratings accumulated. */
|
|
47
|
+
export declare function shouldAutoDistill(opts: {
|
|
48
|
+
readonly feedback: IrFeedback | undefined;
|
|
49
|
+
readonly unprocessed: number;
|
|
50
|
+
readonly threshold: number;
|
|
51
|
+
}): AutoDistillDecision;
|
|
52
|
+
export type MaybeAutoDistillOptions = {
|
|
53
|
+
readonly specName: string;
|
|
54
|
+
readonly feedback: IrFeedback | undefined;
|
|
55
|
+
/** All sessions' turns (tagged with the sessionId join key). */
|
|
56
|
+
readonly turns: ReadonlyArray<SessionTurn>;
|
|
57
|
+
/** The accumulated store: in-transcript `user_feedback` records plus the
|
|
58
|
+
* web-UI `.crewhaus/feedback/*.jsonl` records. */
|
|
59
|
+
readonly records: ReadonlyArray<FeedbackRecord>;
|
|
60
|
+
readonly registry: DatasetRegistry;
|
|
61
|
+
/** Watermark file path (see {@link DISTILL_STATE_RELPATH}). */
|
|
62
|
+
readonly stateFilePath: string;
|
|
63
|
+
/** Env for the threshold/opt-outs; defaults to process.env. */
|
|
64
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
65
|
+
/** Positive-sample cutoff forwarded to distill(); default 0.7 (matches
|
|
66
|
+
* `crewhaus distill --min-score`). */
|
|
67
|
+
readonly minScore?: number;
|
|
68
|
+
readonly now?: () => Date;
|
|
69
|
+
/** Line sink; defaults to stdout. */
|
|
70
|
+
readonly write?: (line: string) => void;
|
|
71
|
+
};
|
|
72
|
+
export type MaybeAutoDistillResult = {
|
|
73
|
+
readonly ran: boolean;
|
|
74
|
+
readonly reason: string;
|
|
75
|
+
readonly registered?: {
|
|
76
|
+
readonly name: string;
|
|
77
|
+
readonly version: string;
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* The teardown consumer. One printed line per outcome that did anything;
|
|
82
|
+
* silent when the trigger simply hasn't accumulated yet. Best-effort by
|
|
83
|
+
* contract — the CALLER wraps it so a failure can never turn a successful
|
|
84
|
+
* session into a non-zero exit.
|
|
85
|
+
*/
|
|
86
|
+
export declare function maybeAutoDistill(opts: MaybeAutoDistillOptions): Promise<MaybeAutoDistillResult>;
|
|
87
|
+
export declare const EXIT_RATING_PROMPT = "rate this session? [g]ood / [b]ad / [enter] skip: ";
|
|
88
|
+
export declare const EXIT_RATING_TIMEOUT_MS = 10000;
|
|
89
|
+
/** Turns with a non-empty assistant answer — the "session actually said
|
|
90
|
+
* something worth rating" floor for the exit prompt. */
|
|
91
|
+
export declare function countAssistantTurns(turns: ReadonlyArray<DerivedTurn>): number;
|
|
92
|
+
export type ExitRatingDecision = {
|
|
93
|
+
readonly prompt: boolean;
|
|
94
|
+
readonly reason: string;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* The pure gate for the exit prompt. Prompts only when ALL hold: the spec
|
|
98
|
+
* has a feedback block (presence opts in), it isn't disabled
|
|
99
|
+
* (`enabled: false`) or prompt-opted-out (`exitPrompt: false`), the
|
|
100
|
+
* CREWHAUS_NO_EXIT_RATING env is not set, stdin is a real TTY (NEVER in
|
|
101
|
+
* piped/CI mode), and the session produced at least one assistant answer.
|
|
102
|
+
*/
|
|
103
|
+
export declare function shouldPromptExitRating(opts: {
|
|
104
|
+
readonly stdinIsTTY: boolean;
|
|
105
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
106
|
+
readonly feedback: IrFeedback | undefined;
|
|
107
|
+
readonly assistantTurns: number;
|
|
108
|
+
}): ExitRatingDecision;
|
|
109
|
+
export type ExitRatingChoice = "up" | "down" | "skip";
|
|
110
|
+
/** Map the raw keystroke to a rating: g/G → good (thumbs up), b/B → bad
|
|
111
|
+
* (thumbs down), anything else — enter, timeout (undefined), Ctrl-C/D —
|
|
112
|
+
* skips. One keystroke, no confirmation, zero-cost to ignore. */
|
|
113
|
+
export declare function parseExitRatingKey(key: string | undefined): ExitRatingChoice;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item 1 — the `feedback.autoDistill` consumer + the REPL exit-rating
|
|
3
|
+
* gating logic. Gives the parsed-but-inert spec flag its first reader:
|
|
4
|
+
*
|
|
5
|
+
* - **autoDistill**: at CLI `run` teardown (NOT a spawned hook — hooks
|
|
6
|
+
* run credential-stripped), when the accumulated feedback store holds
|
|
7
|
+
* ≥ N unprocessed `user_feedback` records, the existing `distill()`
|
|
8
|
+
* runs over ALL sessions and the result is registered as a new
|
|
9
|
+
* auto-bumped version of the `<specName>-ratings` registry dataset —
|
|
10
|
+
* immediately consumable as `--dataset registry:<specName>-ratings`
|
|
11
|
+
* (the item-12 shorthand `eval`/`optimize` already speak). The spec's
|
|
12
|
+
* `autoDistill` is a plain boolean, so the threshold defaults to
|
|
13
|
+
* {@link DEFAULT_AUTODISTILL_THRESHOLD} (env-tunable via
|
|
14
|
+
* `CREWHAUS_AUTODISTILL_THRESHOLD`).
|
|
15
|
+
*
|
|
16
|
+
* - **Watermark** (`.crewhaus/feedback/.distill-state.json`): one small
|
|
17
|
+
* JSON file holding the ISO timestamp of the newest feedback record
|
|
18
|
+
* folded into the last auto-distill. "Unprocessed" = records with a
|
|
19
|
+
* strictly greater `ts`. The trigger counts only unprocessed records,
|
|
20
|
+
* but each distill re-reads EVERYTHING — the registry version is always
|
|
21
|
+
* a full rebuild (merge semantics live in `mergeFeedback`), so the
|
|
22
|
+
* watermark only rate-limits registrations, never shapes the dataset.
|
|
23
|
+
* A failed registration leaves the watermark untouched (retried next
|
|
24
|
+
* run); a distill that matched zero turns advances it (those records
|
|
25
|
+
* are unmatchable — their sessions are gone — so retrying is noise).
|
|
26
|
+
*
|
|
27
|
+
* - **Exit-rating prompt gating**: the pure decision function for the
|
|
28
|
+
* one-keystroke `rate this session? [g]ood / [b]ad / [enter] skip`
|
|
29
|
+
* prompt at clean REPL exit. NEVER prompts in non-TTY/piped mode; opt
|
|
30
|
+
* out with CREWHAUS_NO_EXIT_RATING=1 or spec `feedback.exitPrompt:
|
|
31
|
+
* false`. The raw-mode keystroke read stays in the entry file (thin
|
|
32
|
+
* IO); everything decidable is here so it is unit-testable.
|
|
33
|
+
*
|
|
34
|
+
* Kept in a module with no import-time side effects (the CLI entry file
|
|
35
|
+
* runs an argv switch on import), mirroring `eval-history.ts` /
|
|
36
|
+
* `datasets.ts`. Filesystem access is limited to the watermark file.
|
|
37
|
+
*/
|
|
38
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
39
|
+
import { dirname, join } from "node:path";
|
|
40
|
+
import { DEFAULT_SPLIT_SPEC, registerDataset } from "./datasets";
|
|
41
|
+
import { distill } from "./feedback";
|
|
42
|
+
import { isRegistrySafeName } from "./regression-pin";
|
|
43
|
+
/** Default "≥ N unprocessed ratings" trigger (the spec's `autoDistill` is a
|
|
44
|
+
* plain boolean and carries no threshold of its own). */
|
|
45
|
+
export const DEFAULT_AUTODISTILL_THRESHOLD = 5;
|
|
46
|
+
/** Env override for the trigger threshold. */
|
|
47
|
+
export const AUTODISTILL_THRESHOLD_ENV = "CREWHAUS_AUTODISTILL_THRESHOLD";
|
|
48
|
+
/** Opt-out env for the REPL exit rating prompt. */
|
|
49
|
+
export const NO_EXIT_RATING_ENV = "CREWHAUS_NO_EXIT_RATING";
|
|
50
|
+
/** The watermark file, relative to the harness cwd. */
|
|
51
|
+
export const DISTILL_STATE_RELPATH = join(".crewhaus", "feedback", ".distill-state.json");
|
|
52
|
+
/** The registry dataset autoDistill maintains for a spec. */
|
|
53
|
+
export function ratingsDatasetName(specName) {
|
|
54
|
+
return `${specName}-ratings`;
|
|
55
|
+
}
|
|
56
|
+
/** Narrow parsed JSON to a DistillState; undefined on any malformation (a
|
|
57
|
+
* corrupt watermark degrades to "everything is unprocessed", never throws). */
|
|
58
|
+
export function parseDistillState(text) {
|
|
59
|
+
let value;
|
|
60
|
+
try {
|
|
61
|
+
value = JSON.parse(text);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
if (typeof value !== "object" || value === null)
|
|
67
|
+
return undefined;
|
|
68
|
+
const v = value;
|
|
69
|
+
if (v["schemaVersion"] !== 1)
|
|
70
|
+
return undefined;
|
|
71
|
+
if (typeof v["lastProcessedTs"] !== "string")
|
|
72
|
+
return undefined;
|
|
73
|
+
if (typeof v["processedCount"] !== "number")
|
|
74
|
+
return undefined;
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
export function readDistillState(path) {
|
|
78
|
+
if (!existsSync(path))
|
|
79
|
+
return undefined;
|
|
80
|
+
try {
|
|
81
|
+
return parseDistillState(readFileSync(path, "utf-8"));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function writeDistillState(path, state) {
|
|
88
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
89
|
+
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
90
|
+
}
|
|
91
|
+
/** Records newer than the watermark (strict ISO-string comparison — the
|
|
92
|
+
* capture surfaces all stamp `new Date().toISOString()`, which compares
|
|
93
|
+
* lexicographically). No watermark → everything is unprocessed. */
|
|
94
|
+
export function countUnprocessed(records, lastProcessedTs) {
|
|
95
|
+
if (lastProcessedTs === undefined)
|
|
96
|
+
return records.length;
|
|
97
|
+
return records.filter((r) => r.ts > lastProcessedTs).length;
|
|
98
|
+
}
|
|
99
|
+
export function newestTs(records) {
|
|
100
|
+
let max;
|
|
101
|
+
for (const r of records) {
|
|
102
|
+
if (max === undefined || r.ts > max)
|
|
103
|
+
max = r.ts;
|
|
104
|
+
}
|
|
105
|
+
return max;
|
|
106
|
+
}
|
|
107
|
+
// -------- trigger decision --------
|
|
108
|
+
export function resolveAutoDistillThreshold(env) {
|
|
109
|
+
const raw = env[AUTODISTILL_THRESHOLD_ENV];
|
|
110
|
+
if (raw === undefined || raw === "")
|
|
111
|
+
return DEFAULT_AUTODISTILL_THRESHOLD;
|
|
112
|
+
const n = Number.parseInt(raw, 10);
|
|
113
|
+
if (Number.isNaN(n) || n < 1)
|
|
114
|
+
return DEFAULT_AUTODISTILL_THRESHOLD;
|
|
115
|
+
return n;
|
|
116
|
+
}
|
|
117
|
+
/** Whether the teardown should distill: the spec opted in
|
|
118
|
+
* (`feedback.autoDistill: true`, block not disabled) AND enough
|
|
119
|
+
* unprocessed ratings accumulated. */
|
|
120
|
+
export function shouldAutoDistill(opts) {
|
|
121
|
+
if (opts.feedback === undefined) {
|
|
122
|
+
return { run: false, reason: "spec has no feedback block" };
|
|
123
|
+
}
|
|
124
|
+
if (opts.feedback.enabled === false) {
|
|
125
|
+
return { run: false, reason: "feedback block is disabled (enabled: false)" };
|
|
126
|
+
}
|
|
127
|
+
if (opts.feedback.autoDistill !== true) {
|
|
128
|
+
return { run: false, reason: "feedback.autoDistill is not enabled" };
|
|
129
|
+
}
|
|
130
|
+
if (opts.unprocessed < opts.threshold) {
|
|
131
|
+
return {
|
|
132
|
+
run: false,
|
|
133
|
+
reason: `${opts.unprocessed} unprocessed rating(s) < threshold ${opts.threshold}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
run: true,
|
|
138
|
+
reason: `${opts.unprocessed} unprocessed rating(s) ≥ threshold ${opts.threshold}`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The teardown consumer. One printed line per outcome that did anything;
|
|
143
|
+
* silent when the trigger simply hasn't accumulated yet. Best-effort by
|
|
144
|
+
* contract — the CALLER wraps it so a failure can never turn a successful
|
|
145
|
+
* session into a non-zero exit.
|
|
146
|
+
*/
|
|
147
|
+
export async function maybeAutoDistill(opts) {
|
|
148
|
+
const write = opts.write ?? ((line) => process.stdout.write(`${line}\n`));
|
|
149
|
+
const env = opts.env ?? process.env;
|
|
150
|
+
const threshold = resolveAutoDistillThreshold(env);
|
|
151
|
+
const state = readDistillState(opts.stateFilePath);
|
|
152
|
+
const unprocessed = countUnprocessed(opts.records, state?.lastProcessedTs);
|
|
153
|
+
const decision = shouldAutoDistill({ feedback: opts.feedback, unprocessed, threshold });
|
|
154
|
+
if (!decision.run)
|
|
155
|
+
return { ran: false, reason: decision.reason };
|
|
156
|
+
const name = ratingsDatasetName(opts.specName);
|
|
157
|
+
if (!isRegistrySafeName(name)) {
|
|
158
|
+
write(`[feedback] auto-distill skipped: spec name "${opts.specName}" can't form a registry dataset name ("${name}")`);
|
|
159
|
+
return { ran: false, reason: "spec name is not registry-safe" };
|
|
160
|
+
}
|
|
161
|
+
const nowIso = (opts.now?.() ?? new Date()).toISOString();
|
|
162
|
+
const watermark = newestTs(opts.records) ?? nowIso;
|
|
163
|
+
// Full rebuild over EVERYTHING (see the module doc): merge semantics stay
|
|
164
|
+
// in mergeFeedback, and each registered version is self-contained.
|
|
165
|
+
const result = distill(opts.turns, opts.records, { minScore: opts.minScore ?? 0.7 });
|
|
166
|
+
if (result.samples.length === 0) {
|
|
167
|
+
// The ratings exist but none joined to a transcript turn (sessions
|
|
168
|
+
// purged/rotated). Advance the watermark so the same unmatchable
|
|
169
|
+
// records don't re-trigger a doomed distill on every run.
|
|
170
|
+
writeDistillState(opts.stateFilePath, {
|
|
171
|
+
schemaVersion: 1,
|
|
172
|
+
lastProcessedTs: watermark,
|
|
173
|
+
processedCount: opts.records.length,
|
|
174
|
+
...(state?.lastRegistered !== undefined ? { lastRegistered: state.lastRegistered } : {}),
|
|
175
|
+
});
|
|
176
|
+
write("[feedback] auto-distill: no rated turn matched a transcript — nothing registered (watermark advanced)");
|
|
177
|
+
return { ran: false, reason: "no rated turns matched the transcripts" };
|
|
178
|
+
}
|
|
179
|
+
// Register FIRST, advance the watermark only on success — a transient
|
|
180
|
+
// registry failure retries on the next teardown instead of losing the
|
|
181
|
+
// trigger.
|
|
182
|
+
let record;
|
|
183
|
+
try {
|
|
184
|
+
record = await registerDataset({
|
|
185
|
+
registry: opts.registry,
|
|
186
|
+
name,
|
|
187
|
+
samples: result.samples,
|
|
188
|
+
splitSpec: DEFAULT_SPLIT_SPEC,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
write(`[feedback] auto-distill failed to register ${name}: ${err instanceof Error ? err.message : String(err)} (will retry next run)`);
|
|
193
|
+
return { ran: false, reason: "registry put failed" };
|
|
194
|
+
}
|
|
195
|
+
writeDistillState(opts.stateFilePath, {
|
|
196
|
+
schemaVersion: 1,
|
|
197
|
+
lastProcessedTs: watermark,
|
|
198
|
+
processedCount: opts.records.length,
|
|
199
|
+
lastRegistered: { name: record.name, version: record.version, at: nowIso },
|
|
200
|
+
});
|
|
201
|
+
write(`[feedback] auto-distilled ${result.stats.matchedTurns} rating(s) → registry:${record.name}@${record.version} ` +
|
|
202
|
+
`(train ${record.splits.train.length} / dev ${record.splits.dev.length} / test ${record.splits.test?.length ?? 0})`);
|
|
203
|
+
return {
|
|
204
|
+
ran: true,
|
|
205
|
+
reason: decision.reason,
|
|
206
|
+
registered: { name: record.name, version: record.version },
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
// -------- REPL exit rating prompt (gating + key parsing) --------
|
|
210
|
+
export const EXIT_RATING_PROMPT = "rate this session? [g]ood / [b]ad / [enter] skip: ";
|
|
211
|
+
export const EXIT_RATING_TIMEOUT_MS = 10_000;
|
|
212
|
+
/** Turns with a non-empty assistant answer — the "session actually said
|
|
213
|
+
* something worth rating" floor for the exit prompt. */
|
|
214
|
+
export function countAssistantTurns(turns) {
|
|
215
|
+
return turns.filter((t) => t.output.trim() !== "").length;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The pure gate for the exit prompt. Prompts only when ALL hold: the spec
|
|
219
|
+
* has a feedback block (presence opts in), it isn't disabled
|
|
220
|
+
* (`enabled: false`) or prompt-opted-out (`exitPrompt: false`), the
|
|
221
|
+
* CREWHAUS_NO_EXIT_RATING env is not set, stdin is a real TTY (NEVER in
|
|
222
|
+
* piped/CI mode), and the session produced at least one assistant answer.
|
|
223
|
+
*/
|
|
224
|
+
export function shouldPromptExitRating(opts) {
|
|
225
|
+
if (opts.feedback === undefined)
|
|
226
|
+
return { prompt: false, reason: "spec has no feedback block" };
|
|
227
|
+
if (opts.feedback.enabled === false) {
|
|
228
|
+
return { prompt: false, reason: "feedback block is disabled (enabled: false)" };
|
|
229
|
+
}
|
|
230
|
+
if (opts.feedback.exitPrompt === false) {
|
|
231
|
+
return { prompt: false, reason: "feedback.exitPrompt is false" };
|
|
232
|
+
}
|
|
233
|
+
const optOut = opts.env[NO_EXIT_RATING_ENV];
|
|
234
|
+
if (optOut !== undefined && optOut !== "" && optOut !== "0") {
|
|
235
|
+
return { prompt: false, reason: `${NO_EXIT_RATING_ENV} is set` };
|
|
236
|
+
}
|
|
237
|
+
if (!opts.stdinIsTTY)
|
|
238
|
+
return { prompt: false, reason: "stdin is not a TTY" };
|
|
239
|
+
if (opts.assistantTurns < 1) {
|
|
240
|
+
return { prompt: false, reason: "session has no assistant turns to rate" };
|
|
241
|
+
}
|
|
242
|
+
return { prompt: true, reason: "feedback block present, TTY, rated-able session" };
|
|
243
|
+
}
|
|
244
|
+
/** Map the raw keystroke to a rating: g/G → good (thumbs up), b/B → bad
|
|
245
|
+
* (thumbs down), anything else — enter, timeout (undefined), Ctrl-C/D —
|
|
246
|
+
* skips. One keystroke, no confirmation, zero-cost to ignore. */
|
|
247
|
+
export function parseExitRatingKey(key) {
|
|
248
|
+
if (key === undefined || key.length === 0)
|
|
249
|
+
return "skip";
|
|
250
|
+
const first = key[0];
|
|
251
|
+
if (first === "g" || first === "G")
|
|
252
|
+
return "up";
|
|
253
|
+
if (first === "b" || first === "B")
|
|
254
|
+
return "down";
|
|
255
|
+
return "skip";
|
|
256
|
+
}
|