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,51 @@
1
+ /**
2
+ * Item #57 — durable sessions index. Before a session JSONL is evicted (30-day
3
+ * TTL), it is summarized into a compact index entry under
4
+ * `.crewhaus/sessions-index/<id>.json` so the harness retains long-term
5
+ * knowledge (outcome, tools used, ratings, key facts) past raw-transcript
6
+ * retention. These entries feed the memory-store / few-shot / FAQ features.
7
+ *
8
+ * The summary logic lives in `@crewhaus/session-store` (`summarizeSession`, a
9
+ * deterministic no-model reducer). This module is the CLI-side glue: read a
10
+ * session's JSONL, summarize it, and write the index entry idempotently. Kept
11
+ * thin + separately testable, mirroring `feedback.ts` / `dataset-mine.ts`.
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { summarizeSession } from "@crewhaus/session-store";
16
+ /** The index directory, relative to a session root's PARENT `.crewhaus`. */
17
+ export const SESSIONS_INDEX_DIRNAME = "sessions-index";
18
+ /** Parse a JSONL blob into `{ kind, payload }` events, skipping bad lines. */
19
+ export function parseSessionLog(text) {
20
+ const out = [];
21
+ for (const line of text.split("\n")) {
22
+ if (line.trim() === "")
23
+ continue;
24
+ try {
25
+ out.push(JSON.parse(line));
26
+ }
27
+ catch {
28
+ // A single malformed line must not abort the summary.
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+ /**
34
+ * Summarize the session whose `.jsonl` lives at `logPath` into `indexDir`,
35
+ * returning the written summary (or undefined when the log is missing/empty).
36
+ * Idempotent: re-writing the same session overwrites its entry rather than
37
+ * duplicating. `now` is injectable for deterministic tests.
38
+ */
39
+ export function summarizeSessionIntoIndex(sessionId, logPath, indexDir, now = () => new Date()) {
40
+ if (!existsSync(logPath))
41
+ return undefined;
42
+ const events = parseSessionLog(readFileSync(logPath, "utf-8"));
43
+ if (events.length === 0)
44
+ return undefined;
45
+ const summary = summarizeSession(sessionId, events, { now });
46
+ mkdirSync(indexDir, { recursive: true });
47
+ writeFileSync(join(indexDir, `${sessionId}.json`), `${JSON.stringify(summary, null, 2)}\n`, {
48
+ mode: 0o600,
49
+ });
50
+ return summary;
51
+ }
@@ -0,0 +1,67 @@
1
+ import { type PricingTable } from "@crewhaus/cost-tracker";
2
+ /**
3
+ * A metrics-collector histogram series (one label set). `counts[i]` is the
4
+ * CUMULATIVE count at or below `buckets[i]` (as `Histogram.observe` stores it),
5
+ * `total` is the overall count. Used for the Prometheus-scrape fallback source.
6
+ */
7
+ export type HistogramSeries = {
8
+ readonly buckets: ReadonlyArray<number>;
9
+ readonly counts: ReadonlyArray<number>;
10
+ readonly total: number;
11
+ };
12
+ /**
13
+ * Derive an approximate p-th percentile from a cumulative histogram's buckets —
14
+ * the coarse bucket-boundary estimate a Prometheus `histogram_quantile` gives.
15
+ * Returns the upper edge of the first bucket whose cumulative count reaches the
16
+ * rank. Empty ⇒ 0. The registry stores `model_ttft_seconds` in SECONDS, so the
17
+ * caller multiplies by 1000 for a millisecond comparison.
18
+ */
19
+ export declare function percentileFromHistogram(series: HistogramSeries, p: number): number;
20
+ /**
21
+ * Compute the trailing-N mean of the durable per-session `ttftP95Seconds`
22
+ * values from a sessions.jsonl text (torn lines skipped). Returns undefined
23
+ * when there is no usable history, so the probe can report "no data" rather
24
+ * than a spurious 0ms. `windowSessions` bounds how much recency the probe
25
+ * trusts (default 20 — a working week of runs).
26
+ */
27
+ export declare function recentTtftP95Ms(sessionsJsonl: string, windowSessions?: number): number | undefined;
28
+ export type FasterCandidate = {
29
+ readonly modelString: string;
30
+ readonly blendedPer1M: number;
31
+ };
32
+ /**
33
+ * Name faster-candidate models for a slow slot, cheapest blended price first.
34
+ * Cheaper models are smaller and — as a first-order proxy — faster to first
35
+ * token; the probe surfaces them as CANDIDATES to eval, never as an automatic
36
+ * swap (a spec change stays human-reviewed). Excludes the current model's own
37
+ * family and known sunsets. Empty when the current model isn't table-backed
38
+ * (local / azure / named-host) — the probe then just reports the breach.
39
+ */
40
+ export declare function nameFasterCandidates(currentModel: string, opts?: {
41
+ readonly pricing?: PricingTable;
42
+ readonly limit?: number;
43
+ }): FasterCandidate[];
44
+ export type SloProbeResult = {
45
+ /** 0 = within SLO / no data (a probe must not flap on a cold store); 1 = breach. */
46
+ readonly exitCode: number;
47
+ readonly lines: ReadonlyArray<string>;
48
+ };
49
+ export type SloProbeInput = {
50
+ /** SLO ttft target in ms, from the lowered spec. Undefined ⇒ nothing to probe. */
51
+ readonly ttftTargetMs: number | undefined;
52
+ /** The spec's current agent model string (for candidate naming). */
53
+ readonly currentModel: string | undefined;
54
+ /** Durable sessions.jsonl text (`.crewhaus/metrics/sessions.jsonl`), or "". */
55
+ readonly sessionsJsonl: string;
56
+ /** Optional histogram fallback source (metrics-collector snapshot). */
57
+ readonly histogram?: HistogramSeries;
58
+ readonly pricing?: PricingTable;
59
+ };
60
+ /**
61
+ * Run the TTFT probe: build the human-readable report + the exit code. Pure —
62
+ * the CLI reads the files and owns `process.exit`. Precedence: durable
63
+ * sessions.jsonl first (the alert-watchdog's own history), then the histogram
64
+ * fallback. No target ⇒ exit 0 with a "nothing declared" note; no data ⇒ exit 0
65
+ * (a HEALTHCHECK must not flap on a store that has not accrued history yet).
66
+ */
67
+ export declare function runSloProbe(input: SloProbeInput): SloProbeResult;
@@ -0,0 +1,119 @@
1
+ import { DEFAULT_PRICING, enumerateCandidates, providerOfSpecString, } from "@crewhaus/cost-tracker";
2
+ /**
3
+ * Derive an approximate p-th percentile from a cumulative histogram's buckets —
4
+ * the coarse bucket-boundary estimate a Prometheus `histogram_quantile` gives.
5
+ * Returns the upper edge of the first bucket whose cumulative count reaches the
6
+ * rank. Empty ⇒ 0. The registry stores `model_ttft_seconds` in SECONDS, so the
7
+ * caller multiplies by 1000 for a millisecond comparison.
8
+ */
9
+ export function percentileFromHistogram(series, p) {
10
+ if (series.total === 0 || series.buckets.length === 0)
11
+ return 0;
12
+ const rank = p * series.total;
13
+ for (let i = 0; i < series.buckets.length; i += 1) {
14
+ if ((series.counts[i] ?? 0) >= rank)
15
+ return series.buckets[i] ?? 0;
16
+ }
17
+ // Above the top finite bucket → report the top edge (the +Inf bucket has no
18
+ // upper bound to name).
19
+ return series.buckets[series.buckets.length - 1] ?? 0;
20
+ }
21
+ /**
22
+ * Compute the trailing-N mean of the durable per-session `ttftP95Seconds`
23
+ * values from a sessions.jsonl text (torn lines skipped). Returns undefined
24
+ * when there is no usable history, so the probe can report "no data" rather
25
+ * than a spurious 0ms. `windowSessions` bounds how much recency the probe
26
+ * trusts (default 20 — a working week of runs).
27
+ */
28
+ export function recentTtftP95Ms(sessionsJsonl, windowSessions = 20) {
29
+ const values = [];
30
+ for (const line of sessionsJsonl.split("\n")) {
31
+ const trimmed = line.trim();
32
+ if (trimmed === "")
33
+ continue;
34
+ try {
35
+ const snap = JSON.parse(trimmed);
36
+ if (typeof snap.ttftP95Seconds === "number" && Number.isFinite(snap.ttftP95Seconds)) {
37
+ values.push(snap.ttftP95Seconds * 1000);
38
+ }
39
+ }
40
+ catch {
41
+ // torn/append-in-progress line — skip.
42
+ }
43
+ }
44
+ if (values.length === 0)
45
+ return undefined;
46
+ const window = values.slice(-windowSessions);
47
+ return window.reduce((s, v) => s + v, 0) / window.length;
48
+ }
49
+ /**
50
+ * Name faster-candidate models for a slow slot, cheapest blended price first.
51
+ * Cheaper models are smaller and — as a first-order proxy — faster to first
52
+ * token; the probe surfaces them as CANDIDATES to eval, never as an automatic
53
+ * swap (a spec change stays human-reviewed). Excludes the current model's own
54
+ * family and known sunsets. Empty when the current model isn't table-backed
55
+ * (local / azure / named-host) — the probe then just reports the breach.
56
+ */
57
+ export function nameFasterCandidates(currentModel, opts = {}) {
58
+ const parsed = providerOfSpecString(currentModel);
59
+ if (parsed === undefined)
60
+ return [];
61
+ const pricing = opts.pricing ?? DEFAULT_PRICING;
62
+ const candidates = enumerateCandidates(parsed, {
63
+ pricing,
64
+ sameProviderOnly: true,
65
+ excludeCurrent: true,
66
+ excludeSunsets: true,
67
+ });
68
+ const limit = opts.limit ?? 3;
69
+ return candidates.slice(0, limit).map((c) => ({
70
+ modelString: c.modelString,
71
+ blendedPer1M: c.blendedPer1M,
72
+ }));
73
+ }
74
+ /**
75
+ * Run the TTFT probe: build the human-readable report + the exit code. Pure —
76
+ * the CLI reads the files and owns `process.exit`. Precedence: durable
77
+ * sessions.jsonl first (the alert-watchdog's own history), then the histogram
78
+ * fallback. No target ⇒ exit 0 with a "nothing declared" note; no data ⇒ exit 0
79
+ * (a HEALTHCHECK must not flap on a store that has not accrued history yet).
80
+ */
81
+ export function runSloProbe(input) {
82
+ const lines = [];
83
+ if (input.ttftTargetMs === undefined) {
84
+ lines.push("~ SLO ttft: no observability.slo.ttft_ms declared in the cwd spec — nothing to probe");
85
+ return { exitCode: 0, lines };
86
+ }
87
+ let observedMs = recentTtftP95Ms(input.sessionsJsonl);
88
+ let source = ".crewhaus/metrics/sessions.jsonl";
89
+ if (observedMs === undefined && input.histogram !== undefined) {
90
+ observedMs = percentileFromHistogram(input.histogram, 0.95) * 1000;
91
+ source = "metrics histogram (model_ttft_seconds)";
92
+ }
93
+ if (observedMs === undefined) {
94
+ lines.push("~ SLO ttft: no recent TTFT history yet (run the agent a few times so `.crewhaus/metrics/sessions.jsonl` accrues) — probe skipped");
95
+ return { exitCode: 0, lines };
96
+ }
97
+ const target = input.ttftTargetMs;
98
+ const within = observedMs <= target;
99
+ const fmt = (n) => `${Math.round(n)}ms`;
100
+ if (within) {
101
+ lines.push(`✓ SLO ttft: p95 ${fmt(observedMs)} within target ${fmt(target)} (source: ${source})`);
102
+ return { exitCode: 0, lines };
103
+ }
104
+ lines.push(`✗ SLO ttft: p95 ${fmt(observedMs)} exceeds target ${fmt(target)} (source: ${source})`);
105
+ const candidates = input.currentModel !== undefined
106
+ ? nameFasterCandidates(input.currentModel, {
107
+ ...(input.pricing !== undefined ? { pricing: input.pricing } : {}),
108
+ })
109
+ : [];
110
+ if (candidates.length > 0) {
111
+ const list = candidates.map((c) => c.modelString).join(",");
112
+ lines.push(` faster candidates (cheaper ⇒ smaller ⇒ lower TTFT, same provider): ${list}`);
113
+ lines.push(` confirm the swap holds quality: crewhaus eval --models ${input.currentModel},${list}`);
114
+ }
115
+ else if (input.currentModel !== undefined) {
116
+ lines.push(` no table-backed faster candidates for ${input.currentModel} (local/named-host model) — measure alternatives with crewhaus eval --models <m1,m2>`);
117
+ }
118
+ return { exitCode: 1, lines };
119
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Ops item 37 — construct the `SloMitigationSink` the runtime SLO monitor walks
3
+ * on a sustained breach. Three ladder rungs beyond the trace event, all
4
+ * best-effort + injected so this entry-file module stays testable and no
5
+ * deploy/gateway/audit I/O happens at import time:
6
+ *
7
+ * - `audit` → a tamper-evident `slo_mitigation` record on the
8
+ * hash-chained audit log (so the mitigation history is
9
+ * itself auditable), for EVERY attempted rung;
10
+ * - `alert` → reuses the same delivery the alert watchdog builds
11
+ * (settings.json `alert` hook + webhook POST) — an SLO
12
+ * breach is an alert;
13
+ * - `pauseIntake` → flips a durable intake gate (`.crewhaus/slo/intake.json`
14
+ * `{ paused: true }`) that the gateway/managed daemon reads
15
+ * to send new requests down the 429 `budget_exceeded` path;
16
+ * the SAME writer is invoked with `paused:false` (resume)
17
+ * when the breach clears, so admission re-opens without
18
+ * operator intervention;
19
+ * - `rollback` → auto-rollback the env pin to the LAST-KNOWN-GOOD version —
20
+ * the version that was pinned to the env immediately before
21
+ * the current one, read from the `deployment_action` audit
22
+ * history (see {@link lastKnownGoodFromAuditRecords}), NOT a
23
+ * lexicographic guess — via the injected deployment-controller.
24
+ *
25
+ * Side-effect-free + injected (audit log, alert delivery, intake writer, rollback
26
+ * closure) mirroring `alert-sink.ts`. The CLI `run` path wires the real
27
+ * `openAuditLog`, the reused alert sink, an fs-backed intake writer, and a
28
+ * deployment-controller `rollback` when a spec-registry + `slo.rollback` target
29
+ * are present.
30
+ */
31
+ import type { SloMitigationEvent, SloMitigationSink } from "@crewhaus/runtime-core";
32
+ /** Minimal audit seam — structurally satisfied by `@crewhaus/audit-log`. */
33
+ export type SloAuditSink = {
34
+ append(input: {
35
+ readonly kind: "slo_mitigation";
36
+ readonly payload: unknown;
37
+ }): Promise<unknown>;
38
+ };
39
+ /** The intake-gate writer seam. Flips a durable pause flag the daemon reads.
40
+ * Called with `paused:true` on the `pause-intake` rung and `paused:false` when
41
+ * the breach clears (resume), so the gate can honestly re-open admission. */
42
+ export type IntakeGateWriter = (paused: boolean, reason: string) => Promise<void>;
43
+ /** The rollback seam — a deployment-controller `rollback` closure bound to the
44
+ * harness name + env + last-known-good version. */
45
+ export type SloRollback = (event: SloMitigationEvent) => Promise<void>;
46
+ /** The alert-delivery seam — reuses the alert watchdog's fireAlertHook shape. */
47
+ export type SloAlertDelivery = (event: SloMitigationEvent) => Promise<void>;
48
+ export type BuildSloSinkOptions = {
49
+ /** Durable audit log (undefined ⇒ no audit records). */
50
+ readonly audit?: SloAuditSink;
51
+ /** Alert delivery (undefined ⇒ the `alert` rung is a no-op, still audited). */
52
+ readonly alert?: SloAlertDelivery;
53
+ /** Intake-gate writer (undefined ⇒ the `pause-intake` rung is a no-op). */
54
+ readonly pauseIntake?: IntakeGateWriter;
55
+ /** Rollback closure (undefined ⇒ the `rollback` rung is a no-op). */
56
+ readonly rollback?: SloRollback;
57
+ /** Warn sink for best-effort failures (defaults to stderr). */
58
+ readonly warn?: (line: string) => void;
59
+ };
60
+ /**
61
+ * Build the `SloMitigationSink`, or `undefined` when NO channel is configured
62
+ * (the caller then spreads nothing and the monitor keeps only its trace-event
63
+ * behaviour). Every rung is best-effort: a failure is warned, never thrown — a
64
+ * mitigation-delivery hiccup must not fail a run (the monitor's own catch also
65
+ * guards this, but a double floor is cheap).
66
+ */
67
+ export declare function buildSloSink(opts: BuildSloSinkOptions): SloMitigationSink | undefined;
68
+ /** The on-disk intake-gate shape read by the gateway/managed daemon. Paused
69
+ * ⇒ new requests get the 429 `budget_exceeded` path until an operator clears
70
+ * the flag (`crewhaus ... ` or by deleting the file). */
71
+ export type IntakeGateFile = {
72
+ readonly version: 1;
73
+ readonly paused: boolean;
74
+ readonly reason?: string;
75
+ readonly ts: number;
76
+ };
77
+ /** Build the JSON payload for the durable intake gate (pure — the caller owns
78
+ * the write). Kept next to the reader shape so the two can't drift. */
79
+ export declare function intakeGatePayload(paused: boolean, reason: string, now?: number): IntakeGateFile;
80
+ /**
81
+ * Resolve the LAST-KNOWN-GOOD version to roll `name`'s `env` pin back to, from
82
+ * the `deployment_action` audit history — the actual version that was pinned to
83
+ * `env` immediately BEFORE `current`, never a lexicographic guess.
84
+ *
85
+ * The predecessor is the `fromVersion` of the most recent `deployment_action`
86
+ * that set `env` to `current` (a promote with `toEnv === env` OR a rollback with
87
+ * `env === env`, in both cases `toVersion === current`). Records are consulted
88
+ * newest-first by `ts` so an env that flip-flopped (v1 → v9 → v1 → v9) rolls
89
+ * back to whatever was live just before THIS pin, not an ancient one.
90
+ *
91
+ * Returns `undefined` when the history reveals no distinct predecessor (e.g. the
92
+ * env was pinned once via a raw `spec pin`, or the recorded predecessor equals
93
+ * `current`). The caller MUST treat `undefined` as "cannot safely roll back —
94
+ * skip", never fall back to a lexicographic pick.
95
+ */
96
+ export declare function lastKnownGoodFromAuditRecords(records: ReadonlyArray<unknown>, name: string, env: string, current: string | undefined): string | undefined;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Build the `SloMitigationSink`, or `undefined` when NO channel is configured
3
+ * (the caller then spreads nothing and the monitor keeps only its trace-event
4
+ * behaviour). Every rung is best-effort: a failure is warned, never thrown — a
5
+ * mitigation-delivery hiccup must not fail a run (the monitor's own catch also
6
+ * guards this, but a double floor is cheap).
7
+ */
8
+ export function buildSloSink(opts) {
9
+ const hasAny = opts.audit !== undefined ||
10
+ opts.alert !== undefined ||
11
+ opts.pauseIntake !== undefined ||
12
+ opts.rollback !== undefined;
13
+ if (!hasAny)
14
+ return undefined;
15
+ const warn = opts.warn ?? ((line) => process.stderr.write(`${line}\n`));
16
+ const sink = {};
17
+ if (opts.audit !== undefined) {
18
+ sink.audit = async (event) => {
19
+ await opts.audit?.append({ kind: "slo_mitigation", payload: event }).catch((err) => {
20
+ warn(`[slo] audit failed: ${err instanceof Error ? err.message : String(err)}`);
21
+ });
22
+ };
23
+ }
24
+ if (opts.alert !== undefined) {
25
+ sink.alert = async (event) => {
26
+ await opts.alert?.(event).catch((err) => {
27
+ warn(`[slo] alert failed: ${err instanceof Error ? err.message : String(err)}`);
28
+ });
29
+ };
30
+ }
31
+ if (opts.pauseIntake !== undefined) {
32
+ sink.pauseIntake = async (event) => {
33
+ await opts.pauseIntake?.(true, `SLO breach: ${event.breach.detail}`).catch((err) => {
34
+ warn(`[slo] pause-intake failed: ${err instanceof Error ? err.message : String(err)}`);
35
+ });
36
+ };
37
+ // Resume is the SAME durable gate flipped back to `paused:false` when the
38
+ // breach clears, so admission re-opens without operator intervention.
39
+ sink.resumeIntake = async (event) => {
40
+ await opts.pauseIntake?.(false, `SLO recovered: ${event.breach.detail}`).catch((err) => {
41
+ warn(`[slo] resume-intake failed: ${err instanceof Error ? err.message : String(err)}`);
42
+ });
43
+ };
44
+ }
45
+ if (opts.rollback !== undefined) {
46
+ sink.rollback = async (event) => {
47
+ await opts.rollback?.(event).catch((err) => {
48
+ warn(`[slo] rollback failed: ${err instanceof Error ? err.message : String(err)}`);
49
+ });
50
+ };
51
+ }
52
+ return sink;
53
+ }
54
+ /** Build the JSON payload for the durable intake gate (pure — the caller owns
55
+ * the write). Kept next to the reader shape so the two can't drift. */
56
+ export function intakeGatePayload(paused, reason, now = Date.now()) {
57
+ return { version: 1, paused, ...(reason !== "" ? { reason } : {}), ts: now };
58
+ }
59
+ /**
60
+ * Resolve the LAST-KNOWN-GOOD version to roll `name`'s `env` pin back to, from
61
+ * the `deployment_action` audit history — the actual version that was pinned to
62
+ * `env` immediately BEFORE `current`, never a lexicographic guess.
63
+ *
64
+ * The predecessor is the `fromVersion` of the most recent `deployment_action`
65
+ * that set `env` to `current` (a promote with `toEnv === env` OR a rollback with
66
+ * `env === env`, in both cases `toVersion === current`). Records are consulted
67
+ * newest-first by `ts` so an env that flip-flopped (v1 → v9 → v1 → v9) rolls
68
+ * back to whatever was live just before THIS pin, not an ancient one.
69
+ *
70
+ * Returns `undefined` when the history reveals no distinct predecessor (e.g. the
71
+ * env was pinned once via a raw `spec pin`, or the recorded predecessor equals
72
+ * `current`). The caller MUST treat `undefined` as "cannot safely roll back —
73
+ * skip", never fall back to a lexicographic pick.
74
+ */
75
+ export function lastKnownGoodFromAuditRecords(records, name, env, current) {
76
+ let best;
77
+ for (const raw of records) {
78
+ if (raw === null || typeof raw !== "object")
79
+ continue;
80
+ const rec = raw;
81
+ if (rec.kind !== "deployment_action")
82
+ continue;
83
+ const p = rec.payload;
84
+ if (p === null || typeof p !== "object")
85
+ continue;
86
+ const payload = p;
87
+ if (payload.name !== name)
88
+ continue;
89
+ // The env this action targeted: promote → toEnv, rollback → env.
90
+ const targetEnv = payload.action === "promote" ? payload.toEnv : payload.env;
91
+ if (targetEnv !== env)
92
+ continue;
93
+ // Only actions that produced the CURRENT pin tell us its predecessor. When
94
+ // `current` is unknown (no live pin), any prior good pin for the env works,
95
+ // so accept the newest action's fromVersion.
96
+ if (current !== undefined && payload.toVersion !== current)
97
+ continue;
98
+ const from = payload.fromVersion;
99
+ if (typeof from !== "string" || from === "" || from === current)
100
+ continue;
101
+ // Prefer the record with the LATEST timestamp (payload.ts, else record ts).
102
+ const ts = typeof payload.ts === "number" ? payload.ts : (rec.ts ?? 0);
103
+ if (best === undefined || ts >= best.ts)
104
+ best = { fromVersion: from, ts };
105
+ }
106
+ return best?.fromVersion;
107
+ }
@@ -0,0 +1,102 @@
1
+ import { type WriteBackHeaderInfo } from "@crewhaus/spec-patch";
2
+ import type { RegistryAdapter } from "@crewhaus/spec-registry";
3
+ /** The per-spec changelog file, beside the registry's `manifest.json`. */
4
+ export declare const CHANGELOG_FILE = "CHANGELOG.md";
5
+ /**
6
+ * Map a spec's display name (spec `safeName`: letters, digits, spaces,
7
+ * `_ . - :`) onto the registry's stricter name grammar: runs of characters
8
+ * outside `[A-Za-z0-9_.-]` collapse to a single `-`, leading dots are
9
+ * stripped (the registry hides dot-dirs), and an empty result falls back to
10
+ * `"spec"`. Deterministic, so the same spec always lands in the same slot.
11
+ */
12
+ export declare function registrySpecName(specName: string): string;
13
+ /** SHA-256 hex of the exact YAML text (the registry stores bytes verbatim). */
14
+ export declare function contentHash(yaml: string): string;
15
+ /**
16
+ * Next auto-version: `v<max+1>` over the existing `v<N>` versions (manifest
17
+ * order, numeric — `v10` beats `v2`). Manually-put versions that don't match
18
+ * `v<N>` (e.g. `2.0`, `release-1`) are ignored for numbering.
19
+ */
20
+ export declare function nextVersion(existing: ReadonlyArray<string>): string;
21
+ /**
22
+ * Optimizer provenance for a changelog entry: the write-back header fields
23
+ * plus the `rationale` string the optimizer stored in that run's
24
+ * `patch.json` (the SpecPatch field documented as existing "for audit /
25
+ * write-back commit messages" — this is where it finally surfaces).
26
+ */
27
+ export type OptimizeMetadata = WriteBackHeaderInfo & {
28
+ readonly rationale?: string;
29
+ };
30
+ export type ExtractOptimizeMetadataOptions = {
31
+ /** The YAML being registered (its leading comment block is scanned). */
32
+ readonly yaml: string;
33
+ /** Root of the optimizer run dirs (`.crewhaus/optimize`) for `<runId>/patch.json` lookup. */
34
+ readonly optimizeRootDir?: string | undefined;
35
+ /** Explicit patch.json path — the optimize --write-back hook knows its own out-dir (which `-o` may relocate). */
36
+ readonly patchJsonPath?: string | undefined;
37
+ };
38
+ /**
39
+ * When the YAML carries a `formatWriteBackHeader` stamp, recover the run
40
+ * metadata and — if that run's `patch.json` is readable — its `rationale`.
41
+ * Returns `undefined` for content that was never written back. Never throws:
42
+ * provenance is best-effort garnish on the changelog, not a gate.
43
+ */
44
+ export declare function extractOptimizeMetadata(opts: ExtractOptimizeMetadataOptions): OptimizeMetadata | undefined;
45
+ export type ChangelogEntryOptions = {
46
+ readonly version: string;
47
+ readonly yaml: string;
48
+ /** The previous version's YAML; omit for a spec's first version. */
49
+ readonly previousYaml?: string | undefined;
50
+ readonly optimizeRootDir?: string | undefined;
51
+ readonly patchJsonPath?: string | undefined;
52
+ /** Injectable clock for deterministic tests. */
53
+ readonly now?: Date | undefined;
54
+ };
55
+ /**
56
+ * Render one changelog entry (pure): `## <version> — <date>` followed by
57
+ * the field-level diff bullets vs the previous version (or `initial
58
+ * version`) and, when present, the optimizer provenance + rationale.
59
+ */
60
+ export declare function renderChangelogEntry(opts: ChangelogEntryOptions): string;
61
+ /**
62
+ * Insert an entry into a changelog text, NEWEST FIRST (pure): the new entry
63
+ * lands above the previous top `## ` heading, under the `# Changelog — …`
64
+ * title. `crewhaus spec log` can therefore print the file verbatim and read
65
+ * most-recent-first.
66
+ */
67
+ export declare function insertNewestFirst(existing: string, name: string, entry: string): string;
68
+ export type AppendChangelogOptions = ChangelogEntryOptions & {
69
+ /** Registry root (`.crewhaus/specs`) — the changelog lives beside `<name>/manifest.json`. */
70
+ readonly registryRootDir: string;
71
+ /** Registry-grammar spec name (already sanitized/validated by the caller's `put`). */
72
+ readonly name: string;
73
+ };
74
+ /**
75
+ * Append (newest-first) one entry to `<registryRoot>/<name>/CHANGELOG.md`,
76
+ * creating the file with its title on first use. Returns the file path.
77
+ */
78
+ export declare function appendChangelogEntry(opts: AppendChangelogOptions): string;
79
+ export type AutoRegisterOptions = {
80
+ readonly registry: RegistryAdapter;
81
+ readonly registryRootDir: string;
82
+ /** The spec's display name straight from the YAML (sanitized internally). */
83
+ readonly specName: string;
84
+ /** The exact YAML text to register (including any write-back header). */
85
+ readonly yaml: string;
86
+ readonly optimizeRootDir?: string | undefined;
87
+ readonly patchJsonPath?: string | undefined;
88
+ readonly now?: Date | undefined;
89
+ };
90
+ export type AutoRegisterResult = {
91
+ readonly status: "registered" | "unchanged";
92
+ /** The registry-grammar name the spec landed under. */
93
+ readonly name: string;
94
+ readonly version: string;
95
+ };
96
+ /**
97
+ * Content-hash gated auto-put: when some stored version already carries this
98
+ * exact content, report `unchanged` (with that version); otherwise `put` the
99
+ * next `vN` and append the distilled changelog entry. The previous-latest
100
+ * version (manifest order) is the diff baseline.
101
+ */
102
+ export declare function autoRegisterSpecVersion(opts: AutoRegisterOptions): Promise<AutoRegisterResult>;