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
package/README.md CHANGED
@@ -58,9 +58,9 @@ Run `crewhaus` with no arguments for the full usage text, or any subcommand with
58
58
  |---|---|
59
59
  | `compile <spec.yaml> -o <out-dir>` | Compile a spec to a runnable bundle; `--emit-ir` prints the lowered IR as JSON instead |
60
60
  | `run <spec.yaml>` | Compile in-memory and execute the agent (`--model`, `--resume <id>` / `--continue`, `--prompt <text>`, …) |
61
- | `eval <spec.yaml> --dataset <data> --graders <graders.yaml>` | Run the agent against a dataset and grade (deterministic graders + LLM-as-judge) |
62
- | `eval-report diff <prev> <new>` | Compare two eval runs and emit a diff report |
63
- | `optimize <spec.yaml> --dataset <data> --graders <graders.yaml>` | Active eval-driven optimization; `--write-back` rewrites the spec in place |
61
+ | `eval <spec.yaml> --dataset <data> --graders <graders.yaml>` | Run the agent against a dataset and grade (deterministic graders + LLM-as-judge); every run is indexed and auto-diffed against the pinned baseline (`--gate` exits non-zero on regression, `--no-promote` keeps the pin); the per-spec `<specName>-regressions` suite is unioned in by default (`--no-regressions` skips) |
62
+ | `eval-report diff <prev> <new>` | Compare two eval runs and emit a diff report; `history` lists recorded runs, `baseline show\|set <runId>` inspects or pins baselines |
63
+ | `optimize <spec.yaml> --dataset <data> --graders <graders.yaml>` | Active eval-driven optimization; `--write-back` rewrites the spec in place; accepted patches pin their fail→pass recoveries into `<specName>-regressions` (`--no-pin-regressions` skips) |
64
64
  | `init [name]` | Scaffold a new `crewhaus.yaml` |
65
65
  | `doctor` | Check environment health |
66
66
  | `context --bundle` | Emit a single-markdown orientation manifest |
@@ -76,6 +76,13 @@ Run `crewhaus` with no arguments for the full usage text, or any subcommand with
76
76
  | `compliance evidence` | Collect SOC 2 / ISO 27001 / HIPAA evidence |
77
77
  | `version` | Print the CLI version (also `--version`, `-v`) |
78
78
 
79
+ ## Notes
80
+
81
+ Known limitations of the eval run-history features:
82
+
83
+ - **Baseline keys ignore spec identity beyond the name.** Run-index entries and baselines key on `(specName, datasetName)`; two different specs sharing a `name:` (or a spec whose instructions changed) share a lineage. The `specHash` is recorded in every index entry for forensics but does not partition baselines yet.
84
+ - **1-sample matrix cells can misclassify a crash.** `eval --models` flags a cell as "never really ran" when *all* of its samples errored; with a 1-sample dataset a single transient provider error is indistinguishable from a bad credential, so the cell is reported as crashed rather than as a result row.
85
+
79
86
  ## Docs
80
87
 
81
88
  - [Documentation](https://crewhaus.ai/docs) — start with the [quickstart](https://crewhaus.ai/docs/quickstart) and the [CLI target guide](https://crewhaus.ai/docs/targets/cli)
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Item 15 — `crewhaus optimize --from-advice <suggestions.json>`: the
3
+ * eval-gated apply path for the SpecPatches `crewhaus advise` emits
4
+ * (agent.max_tokens, compaction.curate, …). The optimize search only ever
5
+ * constructs `agent.instructions` patches; this module closes the other
6
+ * half of the telemetry loop by taking the advisor's pre-validated config
7
+ * patches and accepting each one only after a real eval proves it safe.
8
+ *
9
+ * The loop mirrors the flywheel's accept-then-write shape (baseline eval →
10
+ * per-candidate compile gate → candidate eval → acceptance gate), with two
11
+ * deliberate differences:
12
+ *
13
+ * 1. ONE baseline. The unpatched spec is evaluated exactly once; every
14
+ * candidate (accumulated accepted spec + patch k) is judged against
15
+ * that same baseline, so N patches cost N+1 evals, not 2N.
16
+ * 2. ACCEPTANCE: gate-pass only — the strict run-history gate must hold
17
+ * (`gateRuns`: any pass-rate drop or per-sample pass→fail flip
18
+ * rejects, fail-closed on incomparable rates), but strict improvement
19
+ * is NOT required. Advisor patches tune config for latency/cost/
20
+ * robustness, not accuracy — an equal pass rate with zero regressions
21
+ * is exactly the "didn't break anything" bar they need to clear. The
22
+ * delta is printed and persisted either way. (Contrast the flywheel,
23
+ * which additionally demands pass_rate strictly up: a rewritten
24
+ * prompt that buys nothing is not worth a PR; a max_tokens bump that
25
+ * holds the pass rate still fixes truncations.)
26
+ *
27
+ * Accepted patches COMPOSE: patch k+1 applies on top of the accumulated
28
+ * accepted YAML, so the final write-back is one coherent spec. Rejected
29
+ * patches are reported with their eval delta and never touch the
30
+ * accumulation. Every patch re-runs `validatePatch` against the CURRENT
31
+ * accumulated spec — the OPTIMIZABLE_PATHS whitelist stays the safety
32
+ * floor even for a hand-edited suggestions file.
33
+ *
34
+ * Kept in a side-effect-free module (the CLI entry file runs an argv
35
+ * switch on import) mirroring `eval-history.ts` / `advise-rules.ts`: the
36
+ * loop takes injected compile/eval hooks so accept/reject/compose
37
+ * semantics are unit-testable with synthetic summaries — no credentials.
38
+ */
39
+ import type { EvalRunSummary } from "@crewhaus/eval-runner";
40
+ import { type SpecPatch } from "@crewhaus/spec-patch";
41
+ /** Thrown on invalid flag combos and suggestions-file validation failures.
42
+ * The CLI entry file catches it and routes the message through `die()`;
43
+ * tests assert on `.message` without the process exiting. */
44
+ export declare class AdviceApplyError extends Error {
45
+ readonly name = "AdviceApplyError";
46
+ }
47
+ /**
48
+ * `--from-advice` replaces the mutation search entirely — the patches come
49
+ * from the suggestions file, so the mutator/iteration knobs have nothing to
50
+ * steer. Reject the combination loudly instead of silently ignoring flags
51
+ * the user believed were in effect (mirrors eval-matrix's
52
+ * `assertMatrixFlagsCompatible`). --dataset/--graders/--ratings still
53
+ * resolve as usual: the apply path needs an eval.
54
+ */
55
+ export declare function assertFromAdviceFlagsCompatible(flags: {
56
+ readonly mutator: boolean;
57
+ readonly iterations: boolean;
58
+ }): void;
59
+ /** One patch lifted out of a validated suggestions.json entry. */
60
+ export type ParsedAdvicePatch = {
61
+ readonly findingId?: string;
62
+ readonly summary?: string;
63
+ readonly patch: SpecPatch;
64
+ };
65
+ /**
66
+ * Parse and schema-validate a suggestions.json produced by `crewhaus
67
+ * advise` (`buildSuggestionsFile` in ./advise-rules). Unknown or invalid
68
+ * patches are REJECTED with a clear per-entry error — an apply path must
69
+ * never guess at a malformed mutation. Advice-only findings never reach
70
+ * the file (they are report-only), so every entry must carry a patch.
71
+ */
72
+ export declare function parseSuggestionsFile(text: string): ParsedAdvicePatch[];
73
+ export type AdvicePatchVerdict = {
74
+ readonly accepted: boolean;
75
+ readonly reason: string;
76
+ readonly passRateBefore: number;
77
+ readonly passRateAfter: number;
78
+ /** Sample-level pass→fail flips vs. the baseline run. */
79
+ readonly regressions: number;
80
+ /** Sample-level fail→pass flips vs. the baseline run. */
81
+ readonly recoveries: number;
82
+ };
83
+ /**
84
+ * The from-advice acceptance bar (see the module header for the full
85
+ * rationale): the strict run-history gate must pass (`gateRuns`, the same
86
+ * gate the flywheel and `eval --gate` use: any pass-rate drop or
87
+ * per-sample pass→fail flip fails), but — unlike the flywheel — strict
88
+ * improvement is NOT required. Config patches fix latency/cost/robustness,
89
+ * not accuracy, so equal pass rate with zero regressions ACCEPTS; the
90
+ * delta is reported either way. Fail-closed on incomparable pass rates
91
+ * (a NaN rate satisfies no comparison, so it must reject explicitly).
92
+ */
93
+ export declare function evaluateAdvicePatchAcceptance(before: EvalRunSummary, after: EvalRunSummary): AdvicePatchVerdict;
94
+ export type AdviceApplyHooks = {
95
+ /** Offline parse→lower gate. Throws when the compiler rejects the YAML.
96
+ * `label` is `"baseline"` or the candidate's `patch-NNN` label. */
97
+ readonly compileCheck: (yaml: string, label: string) => void;
98
+ /** One full eval pass of `yaml` over the dev split. The CLI persists the
99
+ * run under `<out>/advice/<label>/`. */
100
+ readonly evalRun: (label: string, yaml: string) => Promise<EvalRunSummary>;
101
+ };
102
+ export type AdvicePatchDecision = {
103
+ /** 1-based position in the suggestions file; matches the `patch-NNN`
104
+ * eval-dir label. */
105
+ readonly index: number;
106
+ readonly findingId?: string;
107
+ readonly summary?: string;
108
+ readonly patch: SpecPatch;
109
+ readonly status: "accepted" | "rejected";
110
+ readonly reason: string;
111
+ /** Present when the patch reached its eval (absent for patches rejected
112
+ * by validation / apply / compile — no eval was spent on those). */
113
+ readonly passRateBefore?: number;
114
+ readonly passRateAfter?: number;
115
+ readonly regressions?: number;
116
+ readonly recoveries?: number;
117
+ readonly evalDir?: string;
118
+ };
119
+ export type ApplyAdvicePatchesResult = {
120
+ readonly baseline: EvalRunSummary;
121
+ readonly decisions: ReadonlyArray<AdvicePatchDecision>;
122
+ readonly accepted: number;
123
+ /** The accumulated accepted YAML (identical to the source when nothing
124
+ * was accepted). NOT yet written anywhere — accept-then-write. */
125
+ readonly finalYaml: string;
126
+ /** The last ACCEPTED candidate's eval summary — the "after" side for the
127
+ * write-back header and regression pinning. Absent when 0 accepted. */
128
+ readonly finalSummary?: EvalRunSummary;
129
+ };
130
+ /** Format a candidate's eval-dir label from its 1-based index. */
131
+ export declare function patchLabel(index: number): string;
132
+ /**
133
+ * The complete eval-gated apply loop. The source is never written by this
134
+ * function — the caller owns write-back gating (`--write-back`). Baseline
135
+ * runs ONCE; each candidate is the accumulated accepted YAML plus patch k,
136
+ * compared against that one baseline. A baseline compile/eval failure
137
+ * propagates (nothing useful can happen without it); every per-patch
138
+ * failure (whitelist, apply conflict, compile, gate) records a rejection
139
+ * and the loop continues with the remaining patches.
140
+ */
141
+ export declare function applyAdvicePatches(opts: {
142
+ readonly sourceYaml: string;
143
+ readonly patches: ReadonlyArray<ParsedAdvicePatch>;
144
+ readonly hooks: AdviceApplyHooks;
145
+ }): Promise<ApplyAdvicePatchesResult>;
146
+ export type AdviceDecisionsFile = {
147
+ readonly runId: string;
148
+ readonly generatedAt: string;
149
+ /** The suggestions file the patches came from. */
150
+ readonly source: string;
151
+ readonly baseline: {
152
+ readonly passRate: number;
153
+ readonly evalDir: string;
154
+ };
155
+ readonly evaluated: number;
156
+ readonly accepted: number;
157
+ readonly decisions: ReadonlyArray<AdvicePatchDecision>;
158
+ };
159
+ /** The `decisions.json` payload persisted under `<out>/advice/`. */
160
+ export declare function buildAdviceDecisionsFile(opts: {
161
+ readonly runId: string;
162
+ readonly generatedAt: string;
163
+ readonly source: string;
164
+ readonly baseline: EvalRunSummary;
165
+ readonly decisions: ReadonlyArray<AdvicePatchDecision>;
166
+ }): AdviceDecisionsFile;
167
+ /** One line per decision for the CLI's report. */
168
+ export declare function formatAdviceDecisionLine(d: AdvicePatchDecision): string;
169
+ /**
170
+ * Stamp the accumulated accepted YAML with the same provenance header a
171
+ * successful `optimize --write-back` produces (`formatWriteBackHeader`, the
172
+ * stamp `spec log` distills), with `mutator: advisor` naming the patch
173
+ * source and `iterations` carrying the number of patches evaluated.
174
+ */
175
+ export declare function stampAdviceWriteBack(opts: {
176
+ readonly runId: string;
177
+ readonly yaml: string;
178
+ readonly passRateBefore: number;
179
+ readonly passRateAfter: number;
180
+ readonly patchesEvaluated: number;
181
+ readonly timestamp?: string;
182
+ }): string;
@@ -0,0 +1,286 @@
1
+ import { parseSpec } from "@crewhaus/spec";
2
+ import { applySpecPatch, formatWriteBackHeader, validatePatch, } from "@crewhaus/spec-patch";
3
+ import { gateRuns } from "./eval-history";
4
+ /** Thrown on invalid flag combos and suggestions-file validation failures.
5
+ * The CLI entry file catches it and routes the message through `die()`;
6
+ * tests assert on `.message` without the process exiting. */
7
+ export class AdviceApplyError extends Error {
8
+ name = "AdviceApplyError";
9
+ }
10
+ // -------- flag validation --------
11
+ /**
12
+ * `--from-advice` replaces the mutation search entirely — the patches come
13
+ * from the suggestions file, so the mutator/iteration knobs have nothing to
14
+ * steer. Reject the combination loudly instead of silently ignoring flags
15
+ * the user believed were in effect (mirrors eval-matrix's
16
+ * `assertMatrixFlagsCompatible`). --dataset/--graders/--ratings still
17
+ * resolve as usual: the apply path needs an eval.
18
+ */
19
+ export function assertFromAdviceFlagsCompatible(flags) {
20
+ if (flags.mutator) {
21
+ throw new AdviceApplyError("--from-advice is mutually exclusive with --mutator — the patches come from the suggestions file; no mutation search runs");
22
+ }
23
+ if (flags.iterations) {
24
+ throw new AdviceApplyError("--from-advice is mutually exclusive with --iterations — every patch in the suggestions file is evaluated exactly once");
25
+ }
26
+ }
27
+ const PATCH_OPS = new Set(["replace", "add", "remove"]);
28
+ function isPlainObject(v) {
29
+ return typeof v === "object" && v !== null && !Array.isArray(v);
30
+ }
31
+ /** Structural validation of one entry's `patch` — shape only; the
32
+ * spec-aware checks (target match, OPTIMIZABLE_PATHS) run per-patch in
33
+ * the loop via `validatePatch`. Returns an error string or undefined. */
34
+ function patchShapeError(patch) {
35
+ if (!isPlainObject(patch))
36
+ return "patch must be an object";
37
+ if (typeof patch["target"] !== "string" || patch["target"] === "") {
38
+ return "patch.target must be a non-empty string";
39
+ }
40
+ const path = patch["path"];
41
+ if (!Array.isArray(path) ||
42
+ path.length === 0 ||
43
+ path.some((p) => typeof p !== "string" || p === "")) {
44
+ return "patch.path must be a non-empty array of non-empty strings";
45
+ }
46
+ const op = patch["op"];
47
+ if (typeof op !== "string" || !PATCH_OPS.has(op)) {
48
+ return `patch.op must be one of replace|add|remove (got ${JSON.stringify(op)})`;
49
+ }
50
+ if (op !== "remove" && patch["value"] === undefined) {
51
+ return `patch.value is required for op "${op}"`;
52
+ }
53
+ if (patch["rationale"] !== undefined && typeof patch["rationale"] !== "string") {
54
+ return "patch.rationale must be a string when present";
55
+ }
56
+ return undefined;
57
+ }
58
+ /**
59
+ * Parse and schema-validate a suggestions.json produced by `crewhaus
60
+ * advise` (`buildSuggestionsFile` in ./advise-rules). Unknown or invalid
61
+ * patches are REJECTED with a clear per-entry error — an apply path must
62
+ * never guess at a malformed mutation. Advice-only findings never reach
63
+ * the file (they are report-only), so every entry must carry a patch.
64
+ */
65
+ export function parseSuggestionsFile(text) {
66
+ let parsed;
67
+ try {
68
+ parsed = JSON.parse(text);
69
+ }
70
+ catch (err) {
71
+ throw new AdviceApplyError(`suggestions file is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
72
+ }
73
+ if (!isPlainObject(parsed) || !Array.isArray(parsed["suggestions"])) {
74
+ throw new AdviceApplyError('suggestions file must be an object with a "suggestions" array (the `crewhaus advise` suggestions.json format)');
75
+ }
76
+ const out = [];
77
+ for (const [i, entry] of parsed["suggestions"].entries()) {
78
+ if (!isPlainObject(entry)) {
79
+ throw new AdviceApplyError(`suggestions[${i}] must be an object`);
80
+ }
81
+ const findingId = typeof entry["findingId"] === "string" ? entry["findingId"] : undefined;
82
+ const label = findingId !== undefined ? `suggestions[${i}] (${findingId})` : `suggestions[${i}]`;
83
+ const shapeError = patchShapeError(entry["patch"]);
84
+ if (shapeError !== undefined) {
85
+ throw new AdviceApplyError(`${label}: ${shapeError}`);
86
+ }
87
+ const raw = entry["patch"];
88
+ const patch = {
89
+ target: raw["target"],
90
+ path: raw["path"],
91
+ op: raw["op"],
92
+ ...(raw["value"] !== undefined ? { value: raw["value"] } : {}),
93
+ ...(typeof raw["rationale"] === "string" ? { rationale: raw["rationale"] } : {}),
94
+ };
95
+ out.push({
96
+ ...(findingId !== undefined ? { findingId } : {}),
97
+ ...(typeof entry["summary"] === "string" ? { summary: entry["summary"] } : {}),
98
+ patch,
99
+ });
100
+ }
101
+ return out;
102
+ }
103
+ function pct(rate) {
104
+ return `${(rate * 100).toFixed(1)}%`;
105
+ }
106
+ /**
107
+ * The from-advice acceptance bar (see the module header for the full
108
+ * rationale): the strict run-history gate must pass (`gateRuns`, the same
109
+ * gate the flywheel and `eval --gate` use: any pass-rate drop or
110
+ * per-sample pass→fail flip fails), but — unlike the flywheel — strict
111
+ * improvement is NOT required. Config patches fix latency/cost/robustness,
112
+ * not accuracy, so equal pass rate with zero regressions ACCEPTS; the
113
+ * delta is reported either way. Fail-closed on incomparable pass rates
114
+ * (a NaN rate satisfies no comparison, so it must reject explicitly).
115
+ */
116
+ export function evaluateAdvicePatchAcceptance(before, after) {
117
+ const passRateBefore = before.aggregates.passRate;
118
+ const passRateAfter = after.aggregates.passRate;
119
+ const verdict = gateRuns(before, after);
120
+ const base = {
121
+ passRateBefore,
122
+ passRateAfter,
123
+ regressions: verdict.report.regressions.length,
124
+ recoveries: verdict.report.recoveries.length,
125
+ };
126
+ if (!Number.isFinite(passRateBefore) || !Number.isFinite(passRateAfter)) {
127
+ return {
128
+ accepted: false,
129
+ reason: `pass_rate not comparable (before=${String(passRateBefore)}, ` +
130
+ `after=${String(passRateAfter)}) — rejecting fail-closed`,
131
+ ...base,
132
+ };
133
+ }
134
+ if (verdict.verdict === "fail") {
135
+ return { accepted: false, reason: verdict.reason ?? "regression gate failed", ...base };
136
+ }
137
+ // Gate passed ⇒ no drop and no flips. Equal-or-better both accept; word
138
+ // the reason so the printed delta documents which case this was.
139
+ if (passRateAfter > passRateBefore) {
140
+ return {
141
+ accepted: true,
142
+ reason: `pass_rate ${pct(passRateBefore)} → ${pct(passRateAfter)} with zero regressions`,
143
+ ...base,
144
+ };
145
+ }
146
+ return {
147
+ accepted: true,
148
+ reason: `pass_rate held at ${pct(passRateBefore)} with zero regressions (config patch: gate-pass accepts; strict improvement not required)`,
149
+ ...base,
150
+ };
151
+ }
152
+ /** Format a candidate's eval-dir label from its 1-based index. */
153
+ export function patchLabel(index) {
154
+ return `patch-${String(index).padStart(3, "0")}`;
155
+ }
156
+ /**
157
+ * The complete eval-gated apply loop. The source is never written by this
158
+ * function — the caller owns write-back gating (`--write-back`). Baseline
159
+ * runs ONCE; each candidate is the accumulated accepted YAML plus patch k,
160
+ * compared against that one baseline. A baseline compile/eval failure
161
+ * propagates (nothing useful can happen without it); every per-patch
162
+ * failure (whitelist, apply conflict, compile, gate) records a rejection
163
+ * and the loop continues with the remaining patches.
164
+ */
165
+ export async function applyAdvicePatches(opts) {
166
+ const { hooks } = opts;
167
+ // 1. Offline sanity gate — a spec that doesn't compile must never reach
168
+ // the (paid) baseline eval.
169
+ hooks.compileCheck(opts.sourceYaml, "baseline");
170
+ // 2. Baseline eval — once, on the unpatched spec.
171
+ const baseline = await hooks.evalRun("baseline", opts.sourceYaml);
172
+ const decisions = [];
173
+ let accumulatedYaml = opts.sourceYaml;
174
+ let accepted = 0;
175
+ let finalSummary;
176
+ for (const [i, entry] of opts.patches.entries()) {
177
+ const index = i + 1;
178
+ const label = patchLabel(index);
179
+ const meta = {
180
+ index,
181
+ ...(entry.findingId !== undefined ? { findingId: entry.findingId } : {}),
182
+ ...(entry.summary !== undefined ? { summary: entry.summary } : {}),
183
+ patch: entry.patch,
184
+ };
185
+ // 3a. Spec-aware validation against the CURRENT accumulated spec: the
186
+ // OPTIMIZABLE_PATHS whitelist + target match stay the safety floor
187
+ // even for a hand-edited suggestions file.
188
+ try {
189
+ validatePatch(parseSpec(accumulatedYaml), entry.patch);
190
+ }
191
+ catch (err) {
192
+ decisions.push({
193
+ ...meta,
194
+ status: "rejected",
195
+ reason: `patch invalid: ${err instanceof Error ? err.message : String(err)}`,
196
+ });
197
+ continue;
198
+ }
199
+ // 3b. Apply in-memory on top of the accumulated accepted YAML.
200
+ let candidateYaml;
201
+ try {
202
+ candidateYaml = applySpecPatch(accumulatedYaml, entry.patch).yaml;
203
+ }
204
+ catch (err) {
205
+ decisions.push({
206
+ ...meta,
207
+ status: "rejected",
208
+ reason: `patch failed to apply: ${err instanceof Error ? err.message : String(err)}`,
209
+ });
210
+ continue;
211
+ }
212
+ // 3c. Compile gate before the paid eval.
213
+ try {
214
+ hooks.compileCheck(candidateYaml, label);
215
+ }
216
+ catch (err) {
217
+ decisions.push({
218
+ ...meta,
219
+ status: "rejected",
220
+ reason: `patched spec failed to compile: ${err instanceof Error ? err.message : String(err)}`,
221
+ });
222
+ continue;
223
+ }
224
+ // 3d. Candidate eval + acceptance gate vs. the ONE baseline.
225
+ const candidate = await hooks.evalRun(label, candidateYaml);
226
+ const verdict = evaluateAdvicePatchAcceptance(baseline, candidate);
227
+ const evalFields = {
228
+ passRateBefore: verdict.passRateBefore,
229
+ passRateAfter: verdict.passRateAfter,
230
+ regressions: verdict.regressions,
231
+ recoveries: verdict.recoveries,
232
+ evalDir: candidate.outDir,
233
+ };
234
+ if (!verdict.accepted) {
235
+ decisions.push({ ...meta, status: "rejected", reason: verdict.reason, ...evalFields });
236
+ continue;
237
+ }
238
+ accumulatedYaml = candidateYaml;
239
+ finalSummary = candidate;
240
+ accepted += 1;
241
+ decisions.push({ ...meta, status: "accepted", reason: verdict.reason, ...evalFields });
242
+ }
243
+ return {
244
+ baseline,
245
+ decisions,
246
+ accepted,
247
+ finalYaml: accumulatedYaml,
248
+ ...(finalSummary !== undefined ? { finalSummary } : {}),
249
+ };
250
+ }
251
+ /** The `decisions.json` payload persisted under `<out>/advice/`. */
252
+ export function buildAdviceDecisionsFile(opts) {
253
+ return {
254
+ runId: opts.runId,
255
+ generatedAt: opts.generatedAt,
256
+ source: opts.source,
257
+ baseline: { passRate: opts.baseline.aggregates.passRate, evalDir: opts.baseline.outDir },
258
+ evaluated: opts.decisions.length,
259
+ accepted: opts.decisions.filter((d) => d.status === "accepted").length,
260
+ decisions: opts.decisions,
261
+ };
262
+ }
263
+ /** One line per decision for the CLI's report. */
264
+ export function formatAdviceDecisionLine(d) {
265
+ const id = d.findingId !== undefined ? ` ${d.findingId}` : "";
266
+ const value = d.patch.op === "remove" ? "" : ` → ${JSON.stringify(d.patch.value)}`;
267
+ const head = `${patchLabel(d.index)}${id}: ${d.patch.op} ${d.patch.path.join(".")}${value}`;
268
+ return `${head} — ${d.status.toUpperCase()}: ${d.reason}`;
269
+ }
270
+ /**
271
+ * Stamp the accumulated accepted YAML with the same provenance header a
272
+ * successful `optimize --write-back` produces (`formatWriteBackHeader`, the
273
+ * stamp `spec log` distills), with `mutator: advisor` naming the patch
274
+ * source and `iterations` carrying the number of patches evaluated.
275
+ */
276
+ export function stampAdviceWriteBack(opts) {
277
+ const header = formatWriteBackHeader({
278
+ runId: opts.runId,
279
+ mutator: "advisor",
280
+ scoreBefore: opts.passRateBefore,
281
+ scoreAfter: opts.passRateAfter,
282
+ iterations: opts.patchesEvaluated,
283
+ ...(opts.timestamp !== undefined ? { timestamp: opts.timestamp } : {}),
284
+ });
285
+ return `${header}${opts.yaml}`;
286
+ }