crewhaus 0.1.8 → 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.
Files changed (136) 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 +172 -0
  25. package/dist/dataset-mine.js +403 -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 +11601 -964
  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/scaffold-evals.d.ts +138 -0
  107. package/dist/scaffold-evals.js +410 -0
  108. package/dist/scope-audit-drift.d.ts +139 -0
  109. package/dist/scope-audit-drift.js +260 -0
  110. package/dist/security-corpus.d.ts +237 -0
  111. package/dist/security-corpus.js +516 -0
  112. package/dist/security-digest.d.ts +173 -0
  113. package/dist/security-digest.js +650 -0
  114. package/dist/sessions-index.d.ts +27 -0
  115. package/dist/sessions-index.js +51 -0
  116. package/dist/slo-doctor.d.ts +67 -0
  117. package/dist/slo-doctor.js +119 -0
  118. package/dist/slo-sink.d.ts +96 -0
  119. package/dist/slo-sink.js +107 -0
  120. package/dist/spec-changelog.d.ts +102 -0
  121. package/dist/spec-changelog.js +237 -0
  122. package/dist/state-backup.d.ts +223 -0
  123. package/dist/state-backup.js +648 -0
  124. package/dist/tools-cli.d.ts +170 -0
  125. package/dist/tools-cli.js +298 -0
  126. package/dist/triage.d.ts +202 -0
  127. package/dist/triage.js +403 -0
  128. package/dist/upgrade.d.ts +57 -0
  129. package/dist/upgrade.js +113 -0
  130. package/dist/version.d.ts +6 -0
  131. package/dist/version.js +27 -0
  132. package/dist/voice-eval.d.ts +138 -0
  133. package/dist/voice-eval.js +309 -0
  134. package/dist/watch.d.ts +58 -0
  135. package/dist/watch.js +97 -0
  136. package/package.json +89 -65
@@ -0,0 +1,118 @@
1
+ /** Thrown for operational failures (missing harness, active pin without
2
+ * --force, a failed step). The CLI routes the message through `die()`. */
3
+ export declare class RetireError extends Error {
4
+ readonly name = "RetireError";
5
+ }
6
+ /** The retirement log written into the archive. */
7
+ export declare const RETIREMENT_LOG_FILENAME = "retirement.json";
8
+ export type ActivePin = {
9
+ readonly env: string;
10
+ readonly version: string;
11
+ };
12
+ /**
13
+ * Whether the harness's spec has an active deployment pin. `pins` is the
14
+ * registry manifest's env→version map (empty when unregistered). An active
15
+ * pin is any env pointing at a registered version. Retirement refuses this
16
+ * without `--force`.
17
+ */
18
+ export declare function activePins(pins: Readonly<Record<string, string>>): ActivePin[];
19
+ export type StepOutcome = {
20
+ readonly step: string;
21
+ readonly ok: boolean;
22
+ readonly detail: string;
23
+ };
24
+ /**
25
+ * The injectable heavy steps. Each returns a `StepOutcome` recorded in the
26
+ * retirement log. Production wires these to `createStateBackup`, the
27
+ * compliance collector, `audit verify`, the registry, and knowledge push;
28
+ * tests inject stubs that record the call order.
29
+ */
30
+ export type RetirementSteps = {
31
+ /** Export durable state (`.crewhaus`) into the archive; return the tarball path. */
32
+ backupState(archiveDir: string): Promise<StepOutcome & {
33
+ readonly tarball?: string;
34
+ }>;
35
+ /** Write the final compliance-evidence bundle into the archive. */
36
+ complianceEvidence(archiveDir: string): Promise<StepOutcome>;
37
+ /** Run `audit verify` and record the verdict. */
38
+ auditVerify(): Promise<StepOutcome>;
39
+ /** Push the harness's knowledge to the shared store (#63). Skipped when not
40
+ * requested; returns a "skipped" outcome then. */
41
+ pushKnowledge(archiveDir: string): Promise<StepOutcome>;
42
+ /** Tombstone the registry entry (delete versions → clears pins). */
43
+ tombstoneRegistry(): Promise<StepOutcome>;
44
+ };
45
+ export type RetirementPlan = {
46
+ readonly specName: string;
47
+ readonly harnessDir: string;
48
+ readonly archiveDir: string;
49
+ readonly activePins: ReadonlyArray<ActivePin>;
50
+ readonly force: boolean;
51
+ readonly pushKnowledge: boolean;
52
+ /** The ordered step names the run will execute. */
53
+ readonly steps: ReadonlyArray<string>;
54
+ };
55
+ export type BuildPlanOptions = {
56
+ readonly specName: string;
57
+ readonly harnessDir: string;
58
+ readonly archiveDir: string;
59
+ readonly pins: Readonly<Record<string, string>>;
60
+ readonly force: boolean;
61
+ readonly pushKnowledge: boolean;
62
+ };
63
+ /**
64
+ * Build the retirement plan (pure). Throws {@link RetireError} when the
65
+ * harness has an active pin and `--force` was not given — the plan itself is
66
+ * the refusal gate, so `--dry-run` surfaces the refusal without side effects.
67
+ */
68
+ export declare function buildRetirementPlan(opts: BuildPlanOptions): RetirementPlan;
69
+ /** Render the plan as the CLI's `--dry-run` lines. */
70
+ export declare function formatPlan(plan: RetirementPlan): ReadonlyArray<string>;
71
+ export type RetirementLog = {
72
+ readonly specName: string;
73
+ readonly harnessDir: string;
74
+ readonly retiredAt: string;
75
+ readonly force: boolean;
76
+ readonly activePinsAtRetirement: ReadonlyArray<ActivePin>;
77
+ readonly outcomes: ReadonlyArray<StepOutcome>;
78
+ readonly archived: boolean;
79
+ };
80
+ export type RunRetirementOptions = {
81
+ readonly plan: RetirementPlan;
82
+ readonly steps: RetirementSteps;
83
+ readonly dryRun: boolean;
84
+ readonly now?: () => Date;
85
+ /** Test seam: whether to actually move/remove the state dir (default true). */
86
+ readonly performArchive?: boolean;
87
+ /** F4 — proceed with the destructive move EVEN IF `auditVerify` reports
88
+ * tamper or `complianceEvidence` failed. Distinct from `--force`
89
+ * (`plan.force`, which only overrides the active-pin refusal). Default
90
+ * false: a provably-tampered audit chain ABORTS the retire, state intact. */
91
+ readonly forceUnverified?: boolean;
92
+ };
93
+ export type RunRetirementResult = {
94
+ readonly log: RetirementLog;
95
+ /** Where the retirement log was written (unset on dry run). */
96
+ readonly logPath?: string;
97
+ /** True when the live .crewhaus was removed. */
98
+ readonly removedState: boolean;
99
+ };
100
+ /**
101
+ * Execute the plan. Each heavy step runs in order and its outcome is recorded.
102
+ * A step that returns `ok: false` ABORTS the retirement BEFORE the destructive
103
+ * archive/remove (a failed backup, tombstone, and — per F4 — a failed
104
+ * compliance-evidence bundle or a tamper-reporting `auditVerify` all abort;
105
+ * the audit/compliance gate is overridable only with `forceUnverified`). The
106
+ * retirement log — every step + outcome — is written+fsynced INTO the archive
107
+ * BEFORE the destructive move (and rewritten after) so a crash mid-remove
108
+ * still leaves the evidence and the decommission is self-evidenced. Dry runs
109
+ * execute nothing.
110
+ */
111
+ export declare function runRetirement(opts: RunRetirementOptions): Promise<RunRetirementResult>;
112
+ /** Read a retirement log back (for verification / display). */
113
+ export declare function readRetirementLog(archiveDir: string): RetirementLog | undefined;
114
+ /** Render a completed retirement as summary lines. */
115
+ export declare function formatRetirementResult(result: RunRetirementResult): ReadonlyArray<string>;
116
+ /** Ensure the archive dir's parent exists + the dir is empty/new; returns the
117
+ * resolved dir. */
118
+ export declare function prepareArchiveDir(archiveDir: string): string;
package/dist/retire.js ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Item 64 — `crewhaus retire <spec>`: the lifecycle's missing last phase. A
3
+ * clean, evidenced decommission of a harness:
4
+ *
5
+ * 1. refuse if the spec still has an ACTIVE deployment pin (an env points at
6
+ * a live version) — unless `--force`; retiring a deployed bot silently
7
+ * would be the worst failure mode;
8
+ * 2. export the harness's durable state to the archive (reuse `crewhaus
9
+ * state backup` — the merged tarball path — so sessions/feedback/memories/
10
+ * audit travel out byte-identical);
11
+ * 3. record a FINAL compliance-evidence bundle + an `audit verify` result,
12
+ * so the shutdown itself is provable;
13
+ * 4. optionally push the harness's knowledge to the shared store (#63) so a
14
+ * retired bot's lessons outlive it;
15
+ * 5. tombstone the registry entry — delete the spec's registered versions,
16
+ * which clears every env pin pointing at them (the "unpin/rollback"
17
+ * step);
18
+ * 6. archive everything + remove the live `.crewhaus` state, with a
19
+ * retirement log (`retirement.json`) written INTO the archive recording
20
+ * every step + its outcome — so the retirement is itself evidenced.
21
+ *
22
+ * `--dry-run` prints the plan and touches nothing.
23
+ *
24
+ * Factored out of the entry file `index.ts` (which runs a top-level argv
25
+ * switch and so cannot be imported by a test without executing the CLI).
26
+ * Side-effect-free on import, mirroring `state-backup.ts`. The heavy steps —
27
+ * state backup, compliance-evidence, audit-verify, registry tombstone,
28
+ * knowledge push — are INJECTED as a `RetirementSteps` seam, so the
29
+ * orchestration (order, refusal, dry-run, the archived log) is unit-tested
30
+ * without running any of them; the CLI wires the real steps.
31
+ */
32
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync, writeSync, } from "node:fs";
33
+ import { dirname, join, resolve } from "node:path";
34
+ /** Thrown for operational failures (missing harness, active pin without
35
+ * --force, a failed step). The CLI routes the message through `die()`. */
36
+ export class RetireError extends Error {
37
+ name = "RetireError";
38
+ }
39
+ /** The retirement log written into the archive. */
40
+ export const RETIREMENT_LOG_FILENAME = "retirement.json";
41
+ /**
42
+ * Whether the harness's spec has an active deployment pin. `pins` is the
43
+ * registry manifest's env→version map (empty when unregistered). An active
44
+ * pin is any env pointing at a registered version. Retirement refuses this
45
+ * without `--force`.
46
+ */
47
+ export function activePins(pins) {
48
+ return Object.entries(pins)
49
+ .map(([env, version]) => ({ env, version }))
50
+ .sort((a, b) => a.env.localeCompare(b.env));
51
+ }
52
+ /**
53
+ * Build the retirement plan (pure). Throws {@link RetireError} when the
54
+ * harness has an active pin and `--force` was not given — the plan itself is
55
+ * the refusal gate, so `--dry-run` surfaces the refusal without side effects.
56
+ */
57
+ export function buildRetirementPlan(opts) {
58
+ const pins = activePins(opts.pins);
59
+ if (pins.length > 0 && !opts.force) {
60
+ throw new RetireError(`refusing to retire "${opts.specName}" — it has ${pins.length} active deployment pin(s): ${pins
61
+ .map((p) => `${p.env}→${p.version}`)
62
+ .join(", ")}. Roll back / unpin first, or pass --force to retire anyway (the pins will be tombstoned).`);
63
+ }
64
+ const steps = [
65
+ "backupState",
66
+ "complianceEvidence",
67
+ "auditVerify",
68
+ ...(opts.pushKnowledge ? ["pushKnowledge"] : []),
69
+ "tombstoneRegistry",
70
+ "archive",
71
+ ];
72
+ return {
73
+ specName: opts.specName,
74
+ harnessDir: resolve(opts.harnessDir),
75
+ archiveDir: resolve(opts.archiveDir),
76
+ activePins: pins,
77
+ force: opts.force,
78
+ pushKnowledge: opts.pushKnowledge,
79
+ steps,
80
+ };
81
+ }
82
+ /** Render the plan as the CLI's `--dry-run` lines. */
83
+ export function formatPlan(plan) {
84
+ const lines = [
85
+ `retire ${plan.specName} — plan (dry run):`,
86
+ ` harness: ${plan.harnessDir}`,
87
+ ` archive: ${plan.archiveDir}`,
88
+ ];
89
+ if (plan.activePins.length > 0) {
90
+ lines.push(` ⚠ active pins (will be tombstoned, --force): ${plan.activePins
91
+ .map((p) => `${p.env}→${p.version}`)
92
+ .join(", ")}`);
93
+ }
94
+ lines.push(" steps:");
95
+ for (const s of plan.steps)
96
+ lines.push(` ${describeStep(s, plan)}`);
97
+ lines.push(" nothing was executed (dry run)");
98
+ return lines;
99
+ }
100
+ function describeStep(step, plan) {
101
+ switch (step) {
102
+ case "backupState":
103
+ return "1. export durable state (.crewhaus → archive tarball)";
104
+ case "complianceEvidence":
105
+ return "2. write a final compliance-evidence bundle";
106
+ case "auditVerify":
107
+ return "3. verify the audit chain (tamper check)";
108
+ case "pushKnowledge":
109
+ return "4. push knowledge to the shared store (#63)";
110
+ case "tombstoneRegistry":
111
+ return `${plan.pushKnowledge ? "5" : "4"}. tombstone the registry entry (delete versions → clear pins)`;
112
+ case "archive":
113
+ return `${plan.pushKnowledge ? "6" : "5"}. archive + remove the live .crewhaus state`;
114
+ default:
115
+ return step;
116
+ }
117
+ }
118
+ /**
119
+ * Execute the plan. Each heavy step runs in order and its outcome is recorded.
120
+ * A step that returns `ok: false` ABORTS the retirement BEFORE the destructive
121
+ * archive/remove (a failed backup, tombstone, and — per F4 — a failed
122
+ * compliance-evidence bundle or a tamper-reporting `auditVerify` all abort;
123
+ * the audit/compliance gate is overridable only with `forceUnverified`). The
124
+ * retirement log — every step + outcome — is written+fsynced INTO the archive
125
+ * BEFORE the destructive move (and rewritten after) so a crash mid-remove
126
+ * still leaves the evidence and the decommission is self-evidenced. Dry runs
127
+ * execute nothing.
128
+ */
129
+ export async function runRetirement(opts) {
130
+ const { plan, steps } = opts;
131
+ const now = opts.now ?? (() => new Date());
132
+ const retiredAt = now().toISOString();
133
+ if (opts.dryRun) {
134
+ return {
135
+ log: {
136
+ specName: plan.specName,
137
+ harnessDir: plan.harnessDir,
138
+ retiredAt,
139
+ force: plan.force,
140
+ activePinsAtRetirement: plan.activePins,
141
+ outcomes: [],
142
+ archived: false,
143
+ },
144
+ removedState: false,
145
+ };
146
+ }
147
+ mkdirSync(plan.archiveDir, { recursive: true });
148
+ const outcomes = [];
149
+ const record = (o) => {
150
+ outcomes.push(o);
151
+ return o;
152
+ };
153
+ const forceUnverified = opts.forceUnverified ?? false;
154
+ // 1–4/5: the non-destructive steps. A failure aborts before archiving.
155
+ const backup = record(await steps.backupState(plan.archiveDir));
156
+ if (!backup.ok)
157
+ throw new RetireError(`state backup failed — aborting before any destructive step: ${backup.detail}`);
158
+ // F4 — the compliance-evidence bundle and the audit-verify verdict GATE the
159
+ // destructive step: a provably-tampered audit chain (or a failed evidence
160
+ // bundle) must ABORT the retire so the `.crewhaus` state is left intact for
161
+ // investigation, NOT moved+removed. `--force-unverified` is the explicit
162
+ // override (distinct from `--force`, which only overrides the active-pin
163
+ // refusal). The log is written into the archive before we bail, so the
164
+ // refusal is itself evidenced.
165
+ const compliance = record(await steps.complianceEvidence(plan.archiveDir));
166
+ const audit = record(await steps.auditVerify());
167
+ if (plan.pushKnowledge)
168
+ record(await steps.pushKnowledge(plan.archiveDir));
169
+ const tamperReasons = [];
170
+ if (!audit.ok)
171
+ tamperReasons.push(`audit verify reported tamper: ${audit.detail}`);
172
+ if (!compliance.ok)
173
+ tamperReasons.push(`compliance evidence failed: ${compliance.detail}`);
174
+ if (tamperReasons.length > 0 && !forceUnverified) {
175
+ // Persist the aborted-run log into the archive so the refusal is evidenced,
176
+ // then abort BEFORE tombstone/archive — the live state stays put.
177
+ const abortedLog = {
178
+ specName: plan.specName,
179
+ harnessDir: plan.harnessDir,
180
+ retiredAt,
181
+ force: plan.force,
182
+ activePinsAtRetirement: plan.activePins,
183
+ outcomes,
184
+ archived: false,
185
+ };
186
+ writeLogDurably(join(plan.archiveDir, RETIREMENT_LOG_FILENAME), abortedLog);
187
+ throw new RetireError(`aborting retire before any destructive step — ${tamperReasons.join("; ")}. The live .crewhaus state was left intact. Pass --force-unverified to retire anyway.`);
188
+ }
189
+ const tombstone = record(await steps.tombstoneRegistry());
190
+ if (!tombstone.ok) {
191
+ throw new RetireError(`registry tombstone failed — aborting before archiving: ${tombstone.detail}`);
192
+ }
193
+ // F4 — write + fsync the retirement log into the archive BEFORE the
194
+ // destructive move, so a crash mid-remove still leaves the evidence beside
195
+ // the backup that already exists pre-destruction. The `archive` outcome is
196
+ // appended after the move and the log is rewritten to capture it.
197
+ const logPath = join(plan.archiveDir, RETIREMENT_LOG_FILENAME);
198
+ const preMoveLog = {
199
+ specName: plan.specName,
200
+ harnessDir: plan.harnessDir,
201
+ retiredAt,
202
+ force: plan.force,
203
+ activePinsAtRetirement: plan.activePins,
204
+ outcomes,
205
+ archived: false,
206
+ };
207
+ writeLogDurably(logPath, preMoveLog);
208
+ // Destructive archive/remove: move the live .crewhaus into the archive, then
209
+ // it is gone from the harness. Guarded by performArchive for tests.
210
+ const performArchive = opts.performArchive ?? true;
211
+ let removedState = false;
212
+ const stateDir = join(plan.harnessDir, ".crewhaus");
213
+ if (performArchive && existsSync(stateDir)) {
214
+ const movedTo = join(plan.archiveDir, "crewhaus-state");
215
+ // The state backup already captured a byte-identical tarball; MOVE the
216
+ // live dir into the archive too (belt + braces) then it's removed from the
217
+ // harness. A pre-existing target is replaced to keep the op idempotent.
218
+ if (existsSync(movedTo))
219
+ rmSync(movedTo, { recursive: true, force: true });
220
+ renameSync(stateDir, movedTo);
221
+ removedState = true;
222
+ record({ step: "archive", ok: true, detail: `moved .crewhaus → ${movedTo}` });
223
+ }
224
+ else {
225
+ record({
226
+ step: "archive",
227
+ ok: true,
228
+ detail: existsSync(stateDir)
229
+ ? "archive skipped (test seam)"
230
+ : "no .crewhaus state to archive",
231
+ });
232
+ }
233
+ const log = {
234
+ specName: plan.specName,
235
+ harnessDir: plan.harnessDir,
236
+ retiredAt,
237
+ force: plan.force,
238
+ activePinsAtRetirement: plan.activePins,
239
+ outcomes,
240
+ archived: removedState,
241
+ };
242
+ // Rewrite the log now that the `archive` outcome is known (the pre-move write
243
+ // above is the crash-safety copy; this is the final, complete record).
244
+ writeLogDurably(logPath, log);
245
+ return { log, logPath, removedState };
246
+ }
247
+ /** Write a retirement log and fsync it to disk before returning, so a crash
248
+ * immediately after cannot lose the evidence. */
249
+ function writeLogDurably(logPath, log) {
250
+ const body = `${JSON.stringify(log, null, 2)}\n`;
251
+ const fd = openSync(logPath, "w", 0o600);
252
+ try {
253
+ writeSync(fd, body);
254
+ fsyncSync(fd);
255
+ }
256
+ finally {
257
+ closeSync(fd);
258
+ }
259
+ }
260
+ /** Read a retirement log back (for verification / display). */
261
+ export function readRetirementLog(archiveDir) {
262
+ const p = join(archiveDir, RETIREMENT_LOG_FILENAME);
263
+ if (!existsSync(p))
264
+ return undefined;
265
+ try {
266
+ return JSON.parse(readFileSync(p, "utf-8"));
267
+ }
268
+ catch {
269
+ return undefined;
270
+ }
271
+ }
272
+ /** Render a completed retirement as summary lines. */
273
+ export function formatRetirementResult(result) {
274
+ const lines = [`retired ${result.log.specName} @ ${result.log.retiredAt}`];
275
+ for (const o of result.log.outcomes) {
276
+ lines.push(` ${o.ok ? "✓" : "✗"} ${o.step}: ${o.detail}`);
277
+ }
278
+ if (result.logPath !== undefined)
279
+ lines.push(` retirement log: ${result.logPath}`);
280
+ lines.push(result.removedState
281
+ ? " live .crewhaus state removed (archived)"
282
+ : " live state left in place");
283
+ return lines;
284
+ }
285
+ /** Ensure the archive dir's parent exists + the dir is empty/new; returns the
286
+ * resolved dir. */
287
+ export function prepareArchiveDir(archiveDir) {
288
+ const resolved = resolve(archiveDir);
289
+ mkdirSync(dirname(resolved), { recursive: true });
290
+ return resolved;
291
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Item 25 — `crewhaus model right-size <spec>`: an enumerate → compile → eval
3
+ * loop that searches for a CHEAPER model in ONE slot that HOLDS quality.
4
+ *
5
+ * This is a DEDICATED loop, deliberately NOT the prompt mutator: the
6
+ * `MutationProvider` seam optimize uses is prompt-string-only, whereas
7
+ * right-sizing swaps model IDENTIFIERS (agent.model, cli sub-agents[*].model,
8
+ * compaction.model, the judge model). Each candidate is the spec with exactly
9
+ * ONE model slot swapped to a cheaper same-provider pricing-table sibling; the
10
+ * candidate is evaled, its per-candidate USD computed from the eval's token
11
+ * aggregates (eval artifacts carry no `cost_accrual`, so cost is projected via
12
+ * `resolvePricing`), and the set ranked by score-retained-per-dollar-saved.
13
+ * A candidate is RECOMMENDED only when pass-rate holds and cost drops >= N%.
14
+ *
15
+ * Proposal-only: model paths stay outside `OPTIMIZABLE_PATHS`, so nothing is
16
+ * auto-applied — a documented `--write` does the direct comment-preserving CST
17
+ * edit (reusing `writeModelField`). This module is the pure/injectable core
18
+ * (candidate enumeration + cost projection + ranking); the CLI wires the real
19
+ * compile+eval.
20
+ */
21
+ import { type PricingTable, type RankedRightSize } from "@crewhaus/cost-tracker";
22
+ /** A swappable model slot in the spec, addressed by its patch path. */
23
+ export type ModelSlot = {
24
+ /** Human label (agent.model, compaction.model, sub-agent <name>.model, judge). */
25
+ readonly label: string;
26
+ /** The spec model string currently in the slot. */
27
+ readonly currentModel: string;
28
+ /** The patch path (for the eventual CST edit); undefined for the judge (a
29
+ * CLI flag, not a spec field). */
30
+ readonly path?: ReadonlyArray<string>;
31
+ };
32
+ /** One candidate: the spec with `slot` swapped to `candidateModel`. */
33
+ export type SlotCandidate = {
34
+ readonly slot: ModelSlot;
35
+ readonly candidateModel: string;
36
+ };
37
+ export type EnumerateSlotCandidatesOptions = {
38
+ readonly pricing?: PricingTable;
39
+ /** Cap the candidates per slot (cheapest-first). Default 3. */
40
+ readonly perSlotLimit?: number;
41
+ };
42
+ /**
43
+ * Enumerate downshift candidates across every slot: each slot yields its
44
+ * cheaper SAME-PROVIDER siblings (cheapest-first, current excluded, capped).
45
+ * Cross-provider is out of scope here — a right-size stays on the provider so
46
+ * credentials/cache continuity hold; cross-provider replacement is model-scan's
47
+ * job. Slots whose model isn't table-backed contribute nothing.
48
+ */
49
+ export declare function enumerateSlotCandidates(slots: ReadonlyArray<ModelSlot>, opts?: EnumerateSlotCandidatesOptions): SlotCandidate[];
50
+ /**
51
+ * Project a candidate run's USD from its token aggregates, for its model.
52
+ * Returns `undefined` on a pricing miss (an unpriced model can't be compared
53
+ * on cost, so it's never recommended). USD, not micros, for legible reports.
54
+ */
55
+ export declare function projectCostUsd(model: string, tokens: {
56
+ readonly input: number;
57
+ readonly output: number;
58
+ }, pricing?: PricingTable): number | undefined;
59
+ /** The eval outcome of one slot candidate (from the injected runner). */
60
+ export type SlotEvalOutcome = {
61
+ readonly candidate: SlotCandidate;
62
+ readonly passRate: number;
63
+ readonly tokens: {
64
+ readonly input: number;
65
+ readonly output: number;
66
+ };
67
+ readonly error?: string;
68
+ };
69
+ /** The baseline (unchanged spec) eval outcome. */
70
+ export type BaselineEvalOutcome = {
71
+ readonly passRate: number;
72
+ readonly tokens: {
73
+ readonly input: number;
74
+ readonly output: number;
75
+ };
76
+ /** The primary agent model (for cost projection of the baseline). */
77
+ readonly model: string;
78
+ };
79
+ export type RightSizeReport = {
80
+ readonly baseline: {
81
+ readonly passRate: number;
82
+ readonly costUsd: number;
83
+ };
84
+ readonly ranked: ReadonlyArray<RankedRightSize>;
85
+ readonly best?: RankedRightSize & {
86
+ readonly slotPath?: ReadonlyArray<string>;
87
+ };
88
+ };
89
+ /**
90
+ * Fold baseline + per-candidate eval outcomes into a ranked right-size report.
91
+ * Baseline cost is projected from ITS tokens on ITS model; each candidate's
92
+ * cost from its tokens on its swapped model. Ranking + the recommend gate
93
+ * (holds pass rate AND cost drop >= minCostDropRatio) come from cost-tracker's
94
+ * `rankRightSizeProposals`.
95
+ */
96
+ export declare function buildRightSizeReport(baseline: BaselineEvalOutcome, outcomes: ReadonlyArray<SlotEvalOutcome>, opts: {
97
+ readonly minCostDropRatio: number;
98
+ readonly passRateTolerance?: number;
99
+ readonly pricing?: PricingTable;
100
+ }): RightSizeReport;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Item 25 — `crewhaus model right-size <spec>`: an enumerate → compile → eval
3
+ * loop that searches for a CHEAPER model in ONE slot that HOLDS quality.
4
+ *
5
+ * This is a DEDICATED loop, deliberately NOT the prompt mutator: the
6
+ * `MutationProvider` seam optimize uses is prompt-string-only, whereas
7
+ * right-sizing swaps model IDENTIFIERS (agent.model, cli sub-agents[*].model,
8
+ * compaction.model, the judge model). Each candidate is the spec with exactly
9
+ * ONE model slot swapped to a cheaper same-provider pricing-table sibling; the
10
+ * candidate is evaled, its per-candidate USD computed from the eval's token
11
+ * aggregates (eval artifacts carry no `cost_accrual`, so cost is projected via
12
+ * `resolvePricing`), and the set ranked by score-retained-per-dollar-saved.
13
+ * A candidate is RECOMMENDED only when pass-rate holds and cost drops >= N%.
14
+ *
15
+ * Proposal-only: model paths stay outside `OPTIMIZABLE_PATHS`, so nothing is
16
+ * auto-applied — a documented `--write` does the direct comment-preserving CST
17
+ * edit (reusing `writeModelField`). This module is the pure/injectable core
18
+ * (candidate enumeration + cost projection + ranking); the CLI wires the real
19
+ * compile+eval.
20
+ */
21
+ import { DEFAULT_PRICING, computeCostMicros, enumerateCandidates, providerOfSpecString, rankRightSizeProposals, resolvePricing, } from "@crewhaus/cost-tracker";
22
+ /**
23
+ * Enumerate downshift candidates across every slot: each slot yields its
24
+ * cheaper SAME-PROVIDER siblings (cheapest-first, current excluded, capped).
25
+ * Cross-provider is out of scope here — a right-size stays on the provider so
26
+ * credentials/cache continuity hold; cross-provider replacement is model-scan's
27
+ * job. Slots whose model isn't table-backed contribute nothing.
28
+ */
29
+ export function enumerateSlotCandidates(slots, opts = {}) {
30
+ const perSlotLimit = opts.perSlotLimit ?? 3;
31
+ const out = [];
32
+ const seen = new Set();
33
+ for (const slot of slots) {
34
+ const parsed = providerOfSpecString(slot.currentModel);
35
+ if (parsed === undefined)
36
+ continue;
37
+ const siblings = enumerateCandidates(parsed, {
38
+ ...(opts.pricing !== undefined ? { pricing: opts.pricing } : {}),
39
+ sameProviderOnly: true,
40
+ excludeCurrent: true,
41
+ });
42
+ // Only DOWNSHIFTS: cheaper than the current model.
43
+ const currentPrice = blendedOf(parsed, opts.pricing);
44
+ let taken = 0;
45
+ for (const sib of siblings) {
46
+ if (currentPrice !== undefined && sib.blendedPer1M >= currentPrice)
47
+ continue;
48
+ const key = `${slot.label}→${sib.modelString}`;
49
+ if (seen.has(key))
50
+ continue;
51
+ seen.add(key);
52
+ out.push({ slot, candidateModel: sib.modelString });
53
+ if (++taken >= perSlotLimit)
54
+ break;
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+ function blendedOf(parsed, pricing) {
60
+ const row = resolvePricing(pricing ?? DEFAULT_PRICING, parsed.provider, parsed.modelId);
61
+ if (row === undefined)
62
+ return undefined;
63
+ return (row.inputPer1M * 3 + row.outputPer1M) / 4;
64
+ }
65
+ /**
66
+ * Project a candidate run's USD from its token aggregates, for its model.
67
+ * Returns `undefined` on a pricing miss (an unpriced model can't be compared
68
+ * on cost, so it's never recommended). USD, not micros, for legible reports.
69
+ */
70
+ export function projectCostUsd(model, tokens, pricing) {
71
+ const parsed = providerOfSpecString(model);
72
+ if (parsed === undefined)
73
+ return undefined;
74
+ const row = resolvePricing(pricing ?? DEFAULT_PRICING, parsed.provider, parsed.modelId);
75
+ if (row === undefined)
76
+ return undefined;
77
+ return computeCostMicros(row, tokens.input, tokens.output, 0) / 1_000_000;
78
+ }
79
+ /**
80
+ * Fold baseline + per-candidate eval outcomes into a ranked right-size report.
81
+ * Baseline cost is projected from ITS tokens on ITS model; each candidate's
82
+ * cost from its tokens on its swapped model. Ranking + the recommend gate
83
+ * (holds pass rate AND cost drop >= minCostDropRatio) come from cost-tracker's
84
+ * `rankRightSizeProposals`.
85
+ */
86
+ export function buildRightSizeReport(baseline, outcomes, opts) {
87
+ const baselineCostUsd = projectCostUsd(baseline.model, baseline.tokens, opts.pricing) ?? 0;
88
+ const proposals = [];
89
+ const pathByKey = new Map();
90
+ for (const o of outcomes) {
91
+ if (o.error !== undefined)
92
+ continue; // a crashed cell can't be compared
93
+ const costUsd = projectCostUsd(o.candidate.candidateModel, o.tokens, opts.pricing);
94
+ if (costUsd === undefined)
95
+ continue; // pricing miss → not comparable
96
+ const key = `${o.candidate.slot.label}→${o.candidate.candidateModel}`;
97
+ pathByKey.set(key, o.candidate.slot.path);
98
+ proposals.push({
99
+ modelString: o.candidate.candidateModel,
100
+ slot: o.candidate.slot.label,
101
+ passRate: o.passRate,
102
+ costUsd,
103
+ });
104
+ }
105
+ const ranked = rankRightSizeProposals({ passRate: baseline.passRate, costUsd: baselineCostUsd }, proposals, {
106
+ minCostDropRatio: opts.minCostDropRatio,
107
+ ...(opts.passRateTolerance !== undefined
108
+ ? { passRateTolerance: opts.passRateTolerance }
109
+ : {}),
110
+ });
111
+ const topRecommended = ranked.find((r) => r.recommended);
112
+ const best = topRecommended !== undefined
113
+ ? {
114
+ ...topRecommended,
115
+ slotPath: pathByKey.get(`${topRecommended.slot}→${topRecommended.modelString}`),
116
+ }
117
+ : undefined;
118
+ return {
119
+ baseline: { passRate: baseline.passRate, costUsd: baselineCostUsd },
120
+ ranked,
121
+ ...(best !== undefined ? { best } : {}),
122
+ };
123
+ }