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.
- 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 +9 -2
- package/dist/feedback.js +17 -7
- 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 +11601 -964
- 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 -65
package/dist/retire.d.ts
ADDED
|
@@ -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
|
+
}
|