crewhaus 0.1.8 → 0.2.1

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.
Files changed (138) hide show
  1. package/README.md +10 -3
  2. package/dist/advice-apply.d.ts +182 -0
  3. package/dist/advice-apply.js +286 -0
  4. package/dist/advise-rules.d.ts +348 -0
  5. package/dist/advise-rules.js +905 -0
  6. package/dist/alert-sink.d.ts +48 -0
  7. package/dist/alert-sink.js +86 -0
  8. package/dist/approval-gate.d.ts +127 -0
  9. package/dist/approval-gate.js +254 -0
  10. package/dist/audit-verify.d.ts +69 -0
  11. package/dist/audit-verify.js +97 -0
  12. package/dist/autodistill.d.ts +113 -0
  13. package/dist/autodistill.js +256 -0
  14. package/dist/channel-provision.d.ts +360 -0
  15. package/dist/channel-provision.js +881 -0
  16. package/dist/ci-scaffold.d.ts +31 -0
  17. package/dist/ci-scaffold.js +343 -0
  18. package/dist/compile-check.d.ts +90 -0
  19. package/dist/compile-check.js +285 -0
  20. package/dist/compliance-schedule.d.ts +35 -0
  21. package/dist/compliance-schedule.js +36 -0
  22. package/dist/context-pressure.d.ts +80 -0
  23. package/dist/context-pressure.js +166 -0
  24. package/dist/dataset-mine.d.ts +173 -0
  25. package/dist/dataset-mine.js +404 -0
  26. package/dist/datasets.d.ts +124 -0
  27. package/dist/datasets.js +260 -0
  28. package/dist/deploy-canary.d.ts +83 -0
  29. package/dist/deploy-canary.js +87 -0
  30. package/dist/doctor-checks.d.ts +33 -0
  31. package/dist/doctor-checks.js +92 -0
  32. package/dist/doctor-detect.d.ts +108 -0
  33. package/dist/doctor-detect.js +214 -0
  34. package/dist/doctor-fix.d.ts +81 -0
  35. package/dist/doctor-fix.js +164 -0
  36. package/dist/egress-triage.d.ts +121 -0
  37. package/dist/egress-triage.js +261 -0
  38. package/dist/eval-bridge.d.ts +114 -0
  39. package/dist/eval-bridge.js +158 -0
  40. package/dist/eval-coverage.d.ts +140 -0
  41. package/dist/eval-coverage.js +428 -0
  42. package/dist/eval-history.d.ts +48 -0
  43. package/dist/eval-history.js +157 -0
  44. package/dist/eval-matrix.d.ts +80 -0
  45. package/dist/eval-matrix.js +182 -0
  46. package/dist/eval-sentinel.d.ts +65 -0
  47. package/dist/eval-sentinel.js +132 -0
  48. package/dist/faq.d.ts +68 -0
  49. package/dist/faq.js +168 -0
  50. package/dist/feedback.d.ts +9 -2
  51. package/dist/feedback.js +17 -7
  52. package/dist/fewshot.d.ts +83 -0
  53. package/dist/fewshot.js +158 -0
  54. package/dist/fleet.d.ts +207 -0
  55. package/dist/fleet.js +488 -0
  56. package/dist/flywheel.d.ts +193 -0
  57. package/dist/flywheel.js +519 -0
  58. package/dist/graders-suggest.d.ts +186 -0
  59. package/dist/graders-suggest.js +658 -0
  60. package/dist/incident.d.ts +99 -0
  61. package/dist/incident.js +217 -0
  62. package/dist/index.d.ts +9 -1
  63. package/dist/index.js +11664 -963
  64. package/dist/init-interactive.d.ts +105 -0
  65. package/dist/init-interactive.js +208 -0
  66. package/dist/intents.d.ts +105 -0
  67. package/dist/intents.js +292 -0
  68. package/dist/judge-calibrate.d.ts +137 -0
  69. package/dist/judge-calibrate.js +247 -0
  70. package/dist/justification-calibrate.d.ts +150 -0
  71. package/dist/justification-calibrate.js +262 -0
  72. package/dist/justification-gate.d.ts +27 -6
  73. package/dist/justification-gate.js +30 -6
  74. package/dist/knowledge-sync.d.ts +179 -0
  75. package/dist/knowledge-sync.js +551 -0
  76. package/dist/lessons.d.ts +87 -0
  77. package/dist/lessons.js +207 -0
  78. package/dist/lint.d.ts +127 -0
  79. package/dist/lint.js +226 -0
  80. package/dist/loadtest.d.ts +114 -0
  81. package/dist/loadtest.js +196 -0
  82. package/dist/marketplace-cli.d.ts +110 -0
  83. package/dist/marketplace-cli.js +250 -0
  84. package/dist/mcp-doctor.d.ts +121 -0
  85. package/dist/mcp-doctor.js +249 -0
  86. package/dist/model-scan.d.ts +116 -0
  87. package/dist/model-scan.js +226 -0
  88. package/dist/onchain-tune.d.ts +164 -0
  89. package/dist/onchain-tune.js +346 -0
  90. package/dist/permissions-suggest.d.ts +126 -0
  91. package/dist/permissions-suggest.js +333 -0
  92. package/dist/pii-tune.d.ts +107 -0
  93. package/dist/pii-tune.js +122 -0
  94. package/dist/propose.d.ts +117 -0
  95. package/dist/propose.js +184 -0
  96. package/dist/refresh-goldens.d.ts +82 -0
  97. package/dist/refresh-goldens.js +221 -0
  98. package/dist/regression-pin.d.ts +160 -0
  99. package/dist/regression-pin.js +281 -0
  100. package/dist/retention.d.ts +193 -0
  101. package/dist/retention.js +607 -0
  102. package/dist/retire.d.ts +118 -0
  103. package/dist/retire.js +291 -0
  104. package/dist/right-size.d.ts +100 -0
  105. package/dist/right-size.js +123 -0
  106. package/dist/route.d.ts +19 -0
  107. package/dist/route.js +90 -0
  108. package/dist/scaffold-evals.d.ts +138 -0
  109. package/dist/scaffold-evals.js +410 -0
  110. package/dist/scope-audit-drift.d.ts +139 -0
  111. package/dist/scope-audit-drift.js +260 -0
  112. package/dist/security-corpus.d.ts +237 -0
  113. package/dist/security-corpus.js +516 -0
  114. package/dist/security-digest.d.ts +174 -0
  115. package/dist/security-digest.js +656 -0
  116. package/dist/sessions-index.d.ts +27 -0
  117. package/dist/sessions-index.js +51 -0
  118. package/dist/slo-doctor.d.ts +67 -0
  119. package/dist/slo-doctor.js +119 -0
  120. package/dist/slo-sink.d.ts +96 -0
  121. package/dist/slo-sink.js +107 -0
  122. package/dist/spec-changelog.d.ts +102 -0
  123. package/dist/spec-changelog.js +237 -0
  124. package/dist/state-backup.d.ts +223 -0
  125. package/dist/state-backup.js +648 -0
  126. package/dist/tools-cli.d.ts +170 -0
  127. package/dist/tools-cli.js +300 -0
  128. package/dist/triage.d.ts +202 -0
  129. package/dist/triage.js +403 -0
  130. package/dist/upgrade.d.ts +57 -0
  131. package/dist/upgrade.js +113 -0
  132. package/dist/version.d.ts +6 -0
  133. package/dist/version.js +27 -0
  134. package/dist/voice-eval.d.ts +138 -0
  135. package/dist/voice-eval.js +309 -0
  136. package/dist/watch.d.ts +58 -0
  137. package/dist/watch.js +97 -0
  138. package/package.json +90 -65
@@ -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
+ }