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,48 @@
1
+ /**
2
+ * Item 31 — construct the `AlertSink` the alert watchdog uses for durable +
3
+ * off-box delivery of a threshold breach. Two channels, both best-effort:
4
+ * - `appendAudit` → a tamper-evident `alert_raised` record on the
5
+ * hash-chained audit log (so the alert history itself is
6
+ * auditable);
7
+ * - `fireAlertHook` → the settings.json `alert` hook (HOOK_EVENTS gained
8
+ * `"alert"` additively) AND/OR an `alerts.webhook` POST.
9
+ *
10
+ * Side-effect-free + injected (audit log, hook runner, hook list, fetch) so
11
+ * this entry file (which runs an argv switch on import) stays testable and no
12
+ * network/audit I/O happens at import time. `crewhaus run` wires the real
13
+ * `openAuditLog`, `loadHooks`+`runHooks`, and `globalThis.fetch`.
14
+ */
15
+ import type { AlertSink } from "@crewhaus/runtime-core";
16
+ /** Minimal audit seam — structurally satisfied by `@crewhaus/audit-log`. */
17
+ export type AlertAuditSink = {
18
+ append(input: {
19
+ readonly kind: "alert_raised";
20
+ readonly payload: unknown;
21
+ }): Promise<unknown>;
22
+ };
23
+ /** Minimal hook seam — structurally satisfied by `hooks-engine`'s `runHooks`. */
24
+ export type AlertHookRunner = (event: "alert", payload: unknown, matcherKey: string) => Promise<void>;
25
+ export type BuildAlertSinkOptions = {
26
+ /** Durable audit log (undefined ⇒ no audit channel). */
27
+ readonly audit?: AlertAuditSink;
28
+ /** Runs settings.json `alert` hooks (undefined ⇒ no hook channel). */
29
+ readonly runAlertHooks?: AlertHookRunner;
30
+ /** Webhook URL to POST the breach JSON to (undefined ⇒ no webhook channel). */
31
+ readonly webhookUrl?: string;
32
+ /** Injected fetch (defaults to global fetch) for the webhook POST. */
33
+ readonly fetchImpl?: typeof fetch;
34
+ /** Warn sink for best-effort delivery failures (defaults to stderr). */
35
+ readonly warn?: (line: string) => void;
36
+ };
37
+ /**
38
+ * Build the `AlertSink`, or `undefined` when NO channel is configured (so the
39
+ * caller spreads nothing into `runChatLoop` and the watchdog keeps only its
40
+ * trace-event + snapshot behaviour). Every channel is best-effort: a failure
41
+ * is warned, never thrown — an alert-delivery hiccup must not fail a run.
42
+ */
43
+ export declare function buildAlertSink(opts: BuildAlertSinkOptions): AlertSink | undefined;
44
+ /**
45
+ * Read the optional `alerts` block from a parsed settings.json root. Only the
46
+ * `webhook` string is consumed today; unknown keys are ignored (additive).
47
+ */
48
+ export declare function alertWebhookFromSettings(settingsRoot: unknown): string | undefined;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Build the `AlertSink`, or `undefined` when NO channel is configured (so the
3
+ * caller spreads nothing into `runChatLoop` and the watchdog keeps only its
4
+ * trace-event + snapshot behaviour). Every channel is best-effort: a failure
5
+ * is warned, never thrown — an alert-delivery hiccup must not fail a run.
6
+ */
7
+ export function buildAlertSink(opts) {
8
+ const hasAudit = opts.audit !== undefined;
9
+ const hasHooks = opts.runAlertHooks !== undefined;
10
+ const hasWebhook = typeof opts.webhookUrl === "string" && opts.webhookUrl !== "";
11
+ if (!hasAudit && !hasHooks && !hasWebhook)
12
+ return undefined;
13
+ const warn = opts.warn ?? ((line) => process.stderr.write(`${line}\n`));
14
+ const sink = {};
15
+ if (hasAudit) {
16
+ sink.appendAudit = async (breach) => {
17
+ await opts.audit?.append({ kind: "alert_raised", payload: breach });
18
+ };
19
+ }
20
+ if (hasHooks || hasWebhook) {
21
+ sink.fireAlertHook = async (breach) => {
22
+ // Match the hook by the breached metric name so a settings.json hook can
23
+ // target `matcher: "cost_burn_usd_per_min"`, etc.
24
+ if (hasHooks) {
25
+ try {
26
+ await opts.runAlertHooks?.("alert", breach, "metric");
27
+ }
28
+ catch (err) {
29
+ warn(`[alert] hook failed: ${err instanceof Error ? err.message : String(err)}`);
30
+ }
31
+ }
32
+ if (hasWebhook && opts.webhookUrl !== undefined) {
33
+ // F5 — warn (never block) on a non-https scheme, mirroring
34
+ // security-digest.ts's notifySecurityDigest: an alert breach payload
35
+ // carries session/metric/threshold detail, and posting it cleartext
36
+ // leaks it to every on-path observer. Advisory only — the webhook
37
+ // still POSTs; a local tunnel/dev receiver is a legitimate use case.
38
+ if (isNonHttpsUrl(opts.webhookUrl)) {
39
+ warn(`[alert] webhook ${opts.webhookUrl} uses plain http — the breach payload will transit unencrypted; prefer an https webhook`);
40
+ }
41
+ const doFetch = opts.fetchImpl ?? fetch;
42
+ try {
43
+ const res = await doFetch(opts.webhookUrl, {
44
+ method: "POST",
45
+ headers: { "content-type": "application/json" },
46
+ body: JSON.stringify(breach),
47
+ });
48
+ if (!res.ok)
49
+ warn(`[alert] webhook POST returned ${res.status}`);
50
+ }
51
+ catch (err) {
52
+ warn(`[alert] webhook POST failed: ${err instanceof Error ? err.message : String(err)}`);
53
+ }
54
+ }
55
+ };
56
+ }
57
+ return sink;
58
+ }
59
+ /**
60
+ * F5 — true when `url` parses and its scheme is anything other than
61
+ * `https:` (notably plain `http:`). An unparseable URL returns `false` here
62
+ * — the webhook POST itself will surface that failure via its own
63
+ * try/catch, so this check only ever adds the scheme warning, never a new
64
+ * failure mode.
65
+ */
66
+ function isNonHttpsUrl(url) {
67
+ try {
68
+ return new URL(url).protocol !== "https:";
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ /**
75
+ * Read the optional `alerts` block from a parsed settings.json root. Only the
76
+ * `webhook` string is consumed today; unknown keys are ignored (additive).
77
+ */
78
+ export function alertWebhookFromSettings(settingsRoot) {
79
+ if (typeof settingsRoot !== "object" || settingsRoot === null)
80
+ return undefined;
81
+ const alerts = settingsRoot.alerts;
82
+ if (typeof alerts !== "object" || alerts === null)
83
+ return undefined;
84
+ const webhook = alerts.webhook;
85
+ return typeof webhook === "string" && webhook !== "" ? webhook : undefined;
86
+ }
@@ -0,0 +1,127 @@
1
+ /** Thrown for malformed config / approvals files. The CLI routes the message
2
+ * through `die()`; tests assert on `.message`. */
3
+ export declare class ApprovalGateError extends Error {
4
+ readonly name = "ApprovalGateError";
5
+ }
6
+ export declare const ENVIRONMENTS_CONFIG_RELPATH = ".crewhaus/environments.json";
7
+ export declare const APPROVALS_RELDIR = ".crewhaus/approvals";
8
+ export type EnvironmentPolicy = {
9
+ readonly requireApproval: boolean;
10
+ /** Distinct approvals required to flip this env. Defaults to 1 when
11
+ * requireApproval is true and unset. */
12
+ readonly minApprovals: number;
13
+ };
14
+ export type EnvironmentsConfig = {
15
+ readonly environments: Readonly<Record<string, EnvironmentPolicy>>;
16
+ /** true when a real config file was found (vs the all-unprotected default). */
17
+ readonly fromFile: boolean;
18
+ };
19
+ /**
20
+ * Load `.crewhaus/environments.json` from a harness root. A missing file
21
+ * yields the all-unprotected default (`fromFile: false`). A present-but-
22
+ * malformed file throws — a protected-env policy that silently degraded to
23
+ * "unprotected" would be the worst possible failure mode.
24
+ */
25
+ export declare function loadEnvironmentsConfig(rootDir: string): EnvironmentsConfig;
26
+ /** The policy for `env`, or an unprotected default. */
27
+ export declare function policyForEnv(config: EnvironmentsConfig, env: string): EnvironmentPolicy;
28
+ export type ApprovalRecord = {
29
+ readonly approver: string;
30
+ readonly ts: string;
31
+ /** Candidate version this approval is FOR; when set it must match the
32
+ * version being promoted to count. */
33
+ readonly version?: string;
34
+ readonly comment?: string;
35
+ };
36
+ /** Approvals file name for a (spec, env) pair. `/` is not possible in a spec
37
+ * registry name, so `__` is a safe joiner. */
38
+ export declare function approvalsFileName(specName: string, env: string): string;
39
+ /**
40
+ * Read recorded approvals for a (spec, env) from
41
+ * `.crewhaus/approvals/<spec>__<env>.json`. Missing file → []. A malformed
42
+ * file throws (a dropped approval could silently under-count the quorum).
43
+ */
44
+ export declare function readApprovals(rootDir: string, specName: string, env: string): ApprovalRecord[];
45
+ export type PrCheckState = {
46
+ /** "success" | "pending" | "failure" | "none" (no PR found for the change). */
47
+ readonly conclusion: "success" | "pending" | "failure" | "none";
48
+ readonly prNumber?: number;
49
+ readonly url?: string;
50
+ };
51
+ /** Reader seam: resolve the PR-check state for a (spec, env, version) change.
52
+ * Production drives `gh`; tests inject a fixed state. */
53
+ export type PrCheckReader = (args: {
54
+ readonly specName: string;
55
+ readonly env: string;
56
+ readonly version: string;
57
+ }) => Promise<PrCheckState>;
58
+ /** One entry in a GitHub PR's `statusCheckRollup` — a StatusContext carries a
59
+ * `state`, a CheckRun carries a `conclusion`; either (or neither) may be set. */
60
+ export type RollupCheck = {
61
+ readonly conclusion?: string;
62
+ readonly state?: string;
63
+ };
64
+ /**
65
+ * F3(a) — FAIL-CLOSED rollup verdict. A PR is a `success` witness ONLY when it
66
+ * has at least one check AND every check terminated in an explicit SUCCESS.
67
+ * Any failure/cancel/timeout, any still-running check, and any
68
+ * SKIPPED/NEUTRAL/unknown conclusion yields NOT a witness — an ambiguous or
69
+ * non-passing state must never sign off a protected promotion.
70
+ */
71
+ export declare function rollupConclusion(rollup: ReadonlyArray<RollupCheck>): "success" | "pending" | "failure" | "none";
72
+ /**
73
+ * F3(b) — does this PR reference `version` as a bounded token in its head ref,
74
+ * title, or body? `propose` names the branch `propose/<slug>-<version>-<hash>-
75
+ * <stamp>`, so the version is a bounded segment of the head ref; a bare
76
+ * substring match would let `v1` witness `v10`, so require non-alphanumeric
77
+ * (or string-edge) bounds. Binds the PR witness to the EXACT promoted version.
78
+ */
79
+ export declare function prReferencesVersion(pr: {
80
+ readonly headRefName?: string;
81
+ readonly title?: string;
82
+ readonly body?: string;
83
+ }, version: string): boolean;
84
+ export type ApprovalDecisionInput = {
85
+ readonly specName: string;
86
+ readonly toEnv: string;
87
+ /** The candidate version the promotion would pin to. */
88
+ readonly toVersion: string;
89
+ readonly policy: EnvironmentPolicy;
90
+ readonly approvals: ReadonlyArray<ApprovalRecord>;
91
+ /** Present only when a PR-check witness was consulted. */
92
+ readonly prCheck?: PrCheckState;
93
+ };
94
+ export type ApprovalDecision = {
95
+ readonly satisfied: boolean;
96
+ /** Distinct approvers whose approval is valid for this candidate version. */
97
+ readonly countedApprovers: ReadonlyArray<string>;
98
+ /** True when a green PR check contributed a witness. */
99
+ readonly prWitness: boolean;
100
+ readonly required: number;
101
+ readonly reason: string;
102
+ };
103
+ /**
104
+ * Decide whether the gate is satisfied. Counts DISTINCT approvers whose
105
+ * approval either carries no version pin or matches `toVersion`, plus one
106
+ * collective witness for a green PR check. Satisfied when
107
+ * `witnesses >= required`.
108
+ */
109
+ export declare function decideApproval(input: ApprovalDecisionInput): ApprovalDecision;
110
+ /** The `governance_approval` audit payload for a gate evaluation. Recorded
111
+ * whether or not the gate was satisfied. */
112
+ export type GovernanceApprovalPayload = {
113
+ readonly name: string;
114
+ readonly toEnv: string;
115
+ readonly toVersion: string;
116
+ readonly required: number;
117
+ readonly satisfied: boolean;
118
+ readonly countedApprovers: ReadonlyArray<string>;
119
+ readonly prWitness: boolean;
120
+ readonly prNumber?: number;
121
+ readonly actor?: string;
122
+ readonly ts: number;
123
+ };
124
+ export declare function buildGovernancePayload(input: ApprovalDecisionInput, decision: ApprovalDecision, opts: {
125
+ readonly actor?: string;
126
+ readonly now: () => number;
127
+ }): GovernanceApprovalPayload;
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Item 59 (half 1) — approval-gated promotion. `crewhaus deploy promote
3
+ * --require-approval` must not flip a PROTECTED environment's pin until an
4
+ * approval quorum is satisfied: a recorded set of approval tokens AND/OR a
5
+ * green PR check. The gate is a side-effect-free decision layer that runs
6
+ * BEFORE the deployment-controller's `promote` — the controller stays a dumb
7
+ * pin-flipper; policy lives here (and is testable without a registry).
8
+ *
9
+ * Factored out of the entry file `index.ts` (which runs a top-level argv
10
+ * switch and so cannot be imported by a test without executing the CLI).
11
+ * Mirrors `retention.ts` / `audit-verify.ts`: pure config + decision, plus a
12
+ * thin recorded-approvals reader over `.crewhaus/approvals`.
13
+ *
14
+ * WHAT IS PROTECTED — `.crewhaus/environments.json` declares which
15
+ * environments require approval and how many:
16
+ *
17
+ * { "environments": {
18
+ * "prod": { "requireApproval": true, "minApprovals": 2 },
19
+ * "staging": { "requireApproval": false }
20
+ * } }
21
+ *
22
+ * A missing file / missing entry means the env is UNPROTECTED (the pre-item-59
23
+ * behavior — `promote` runs unconditionally). `--require-approval` is opt-in
24
+ * per invocation; an env can also be marked protected in the config so a
25
+ * plain `promote` to it is refused with a pointer to `--require-approval`.
26
+ *
27
+ * WHAT SATISFIES THE GATE — two independent, OR-combined witnesses:
28
+ * 1. Recorded approvals: `.crewhaus/approvals/<spec>__<env>.json` holds an
29
+ * array of `{ approver, ts, version? }`. Distinct approvers count toward
30
+ * `minApprovals`; an approval pinned to a DIFFERENT candidate version
31
+ * than the one being promoted does not count (you approved v2, not v3).
32
+ * 2. A green PR check: an injected `PrCheckReader` resolves the merge/CI
33
+ * state of the proposal PR for this change. A `success` conclusion
34
+ * counts as one collective approval witness (a reviewed+green PR is the
35
+ * team's sign-off) — enough on its own only when `minApprovals <= 1`,
36
+ * otherwise it ADDS to the recorded-approval count.
37
+ *
38
+ * The gate NEVER approves itself: with no witnesses it refuses, and the
39
+ * refusal is as audit-worthy as the approval — both produce a
40
+ * `governance_approval` record so a scheduled promotion that was blocked is
41
+ * evidenced too.
42
+ */
43
+ import { existsSync, readFileSync } from "node:fs";
44
+ import { join } from "node:path";
45
+ /** Thrown for malformed config / approvals files. The CLI routes the message
46
+ * through `die()`; tests assert on `.message`. */
47
+ export class ApprovalGateError extends Error {
48
+ name = "ApprovalGateError";
49
+ }
50
+ export const ENVIRONMENTS_CONFIG_RELPATH = ".crewhaus/environments.json";
51
+ export const APPROVALS_RELDIR = ".crewhaus/approvals";
52
+ /**
53
+ * Load `.crewhaus/environments.json` from a harness root. A missing file
54
+ * yields the all-unprotected default (`fromFile: false`). A present-but-
55
+ * malformed file throws — a protected-env policy that silently degraded to
56
+ * "unprotected" would be the worst possible failure mode.
57
+ */
58
+ export function loadEnvironmentsConfig(rootDir) {
59
+ const path = join(rootDir, ENVIRONMENTS_CONFIG_RELPATH);
60
+ if (!existsSync(path))
61
+ return { environments: {}, fromFile: false };
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(readFileSync(path, "utf-8"));
65
+ }
66
+ catch (err) {
67
+ throw new ApprovalGateError(`${ENVIRONMENTS_CONFIG_RELPATH} is not valid JSON: ${err.message}`);
68
+ }
69
+ if (typeof parsed !== "object" || parsed === null) {
70
+ throw new ApprovalGateError(`${ENVIRONMENTS_CONFIG_RELPATH} must be a JSON object`);
71
+ }
72
+ const envsRaw = parsed["environments"];
73
+ if (typeof envsRaw !== "object" || envsRaw === null) {
74
+ throw new ApprovalGateError(`${ENVIRONMENTS_CONFIG_RELPATH} must carry an "environments" object`);
75
+ }
76
+ const environments = {};
77
+ for (const [env, v] of Object.entries(envsRaw)) {
78
+ if (typeof v !== "object" || v === null) {
79
+ throw new ApprovalGateError(`environments.${env} must be an object`);
80
+ }
81
+ const rec = v;
82
+ const requireApproval = rec["requireApproval"] === true;
83
+ let minApprovals = 1;
84
+ if (rec["minApprovals"] !== undefined) {
85
+ const n = rec["minApprovals"];
86
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 1) {
87
+ throw new ApprovalGateError(`environments.${env}.minApprovals must be a positive integer`);
88
+ }
89
+ minApprovals = n;
90
+ }
91
+ environments[env] = { requireApproval, minApprovals };
92
+ }
93
+ return { environments, fromFile: true };
94
+ }
95
+ /** The policy for `env`, or an unprotected default. */
96
+ export function policyForEnv(config, env) {
97
+ return config.environments[env] ?? { requireApproval: false, minApprovals: 1 };
98
+ }
99
+ /** Approvals file name for a (spec, env) pair. `/` is not possible in a spec
100
+ * registry name, so `__` is a safe joiner. */
101
+ export function approvalsFileName(specName, env) {
102
+ return `${specName}__${env}.json`;
103
+ }
104
+ /**
105
+ * Read recorded approvals for a (spec, env) from
106
+ * `.crewhaus/approvals/<spec>__<env>.json`. Missing file → []. A malformed
107
+ * file throws (a dropped approval could silently under-count the quorum).
108
+ */
109
+ export function readApprovals(rootDir, specName, env) {
110
+ const path = join(rootDir, APPROVALS_RELDIR, approvalsFileName(specName, env));
111
+ if (!existsSync(path))
112
+ return [];
113
+ let parsed;
114
+ try {
115
+ parsed = JSON.parse(readFileSync(path, "utf-8"));
116
+ }
117
+ catch (err) {
118
+ throw new ApprovalGateError(`approvals file ${path} is not valid JSON: ${err.message}`);
119
+ }
120
+ if (!Array.isArray(parsed)) {
121
+ throw new ApprovalGateError(`approvals file ${path} must be a JSON array of { approver, ts }`);
122
+ }
123
+ const out = [];
124
+ for (const item of parsed) {
125
+ if (typeof item !== "object" || item === null)
126
+ continue;
127
+ const rec = item;
128
+ if (typeof rec["approver"] !== "string" || typeof rec["ts"] !== "string")
129
+ continue;
130
+ out.push({
131
+ approver: rec["approver"],
132
+ ts: rec["ts"],
133
+ ...(typeof rec["version"] === "string" ? { version: rec["version"] } : {}),
134
+ ...(typeof rec["comment"] === "string" ? { comment: rec["comment"] } : {}),
135
+ });
136
+ }
137
+ return out;
138
+ }
139
+ /** Terminal check conclusions/states that mean FAILURE (fail-closed). */
140
+ const ROLLUP_FAILED = new Set([
141
+ "FAILURE",
142
+ "CANCELLED",
143
+ "TIMED_OUT",
144
+ "ACTION_REQUIRED",
145
+ "STARTUP_FAILURE",
146
+ "STALE",
147
+ "ERROR",
148
+ ]);
149
+ /** States that mean a check is still RUNNING (not yet a witness). */
150
+ const ROLLUP_RUNNING = new Set([
151
+ "PENDING",
152
+ "IN_PROGRESS",
153
+ "QUEUED",
154
+ "WAITING",
155
+ "REQUESTED",
156
+ ]);
157
+ /**
158
+ * F3(a) — FAIL-CLOSED rollup verdict. A PR is a `success` witness ONLY when it
159
+ * has at least one check AND every check terminated in an explicit SUCCESS.
160
+ * Any failure/cancel/timeout, any still-running check, and any
161
+ * SKIPPED/NEUTRAL/unknown conclusion yields NOT a witness — an ambiguous or
162
+ * non-passing state must never sign off a protected promotion.
163
+ */
164
+ export function rollupConclusion(rollup) {
165
+ if (rollup.length === 0)
166
+ return "none";
167
+ let anyFail = false;
168
+ let anyPending = false;
169
+ let allSuccess = true;
170
+ for (const c of rollup) {
171
+ const state = (c.state ?? "").toUpperCase();
172
+ const conclusion = (c.conclusion ?? "").toUpperCase();
173
+ const terminal = conclusion !== "" ? conclusion : state;
174
+ if (ROLLUP_RUNNING.has(state) || ROLLUP_RUNNING.has(conclusion) || terminal === "") {
175
+ anyPending = true;
176
+ }
177
+ if (ROLLUP_FAILED.has(state) || ROLLUP_FAILED.has(conclusion))
178
+ anyFail = true;
179
+ // Only an explicit SUCCESS counts toward "all passed"; SKIPPED/NEUTRAL/
180
+ // anything else leaves allSuccess false (fail-closed).
181
+ if (terminal !== "SUCCESS")
182
+ allSuccess = false;
183
+ }
184
+ if (anyFail)
185
+ return "failure";
186
+ if (anyPending)
187
+ return "pending";
188
+ return allSuccess ? "success" : "none";
189
+ }
190
+ /**
191
+ * F3(b) — does this PR reference `version` as a bounded token in its head ref,
192
+ * title, or body? `propose` names the branch `propose/<slug>-<version>-<hash>-
193
+ * <stamp>`, so the version is a bounded segment of the head ref; a bare
194
+ * substring match would let `v1` witness `v10`, so require non-alphanumeric
195
+ * (or string-edge) bounds. Binds the PR witness to the EXACT promoted version.
196
+ */
197
+ export function prReferencesVersion(pr, version) {
198
+ const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
199
+ const bounded = new RegExp(`(^|[^A-Za-z0-9.])${esc}([^A-Za-z0-9.]|$)`);
200
+ return [pr.headRefName, pr.title, pr.body].some((s) => typeof s === "string" && bounded.test(s));
201
+ }
202
+ /**
203
+ * Decide whether the gate is satisfied. Counts DISTINCT approvers whose
204
+ * approval either carries no version pin or matches `toVersion`, plus one
205
+ * collective witness for a green PR check. Satisfied when
206
+ * `witnesses >= required`.
207
+ */
208
+ export function decideApproval(input) {
209
+ const required = input.policy.minApprovals;
210
+ const counted = new Set();
211
+ for (const a of input.approvals) {
212
+ if (a.version !== undefined && a.version !== input.toVersion)
213
+ continue;
214
+ counted.add(a.approver);
215
+ }
216
+ const prWitness = input.prCheck?.conclusion === "success";
217
+ const witnesses = counted.size + (prWitness ? 1 : 0);
218
+ const satisfied = witnesses >= required;
219
+ let reason;
220
+ if (satisfied) {
221
+ const parts = [];
222
+ if (counted.size > 0)
223
+ parts.push(`${counted.size} recorded approval(s): ${[...counted].sort().join(", ")}`);
224
+ if (prWitness)
225
+ parts.push(`green PR check (#${input.prCheck?.prNumber ?? "?"})`);
226
+ reason = `quorum met (${witnesses}/${required}) — ${parts.join("; ")}`;
227
+ }
228
+ else {
229
+ const missing = required - witnesses;
230
+ const detail = [];
231
+ detail.push(`have ${witnesses} witness(es), need ${required}`);
232
+ if (counted.size > 0)
233
+ detail.push(`approvers: ${[...counted].sort().join(", ")}`);
234
+ if (input.prCheck !== undefined && !prWitness) {
235
+ detail.push(`PR check: ${input.prCheck.conclusion}`);
236
+ }
237
+ reason = `quorum NOT met — ${detail.join("; ")} (${missing} more needed; record approvals in ${APPROVALS_RELDIR}/${approvalsFileName(input.specName, input.toEnv)} or land a green proposal PR)`;
238
+ }
239
+ return { satisfied, countedApprovers: [...counted].sort(), prWitness, required, reason };
240
+ }
241
+ export function buildGovernancePayload(input, decision, opts) {
242
+ return {
243
+ name: input.specName,
244
+ toEnv: input.toEnv,
245
+ toVersion: input.toVersion,
246
+ required: decision.required,
247
+ satisfied: decision.satisfied,
248
+ countedApprovers: decision.countedApprovers,
249
+ prWitness: decision.prWitness,
250
+ ...(input.prCheck?.prNumber !== undefined ? { prNumber: input.prCheck.prNumber } : {}),
251
+ ...(opts.actor !== undefined ? { actor: opts.actor } : {}),
252
+ ts: opts.now(),
253
+ };
254
+ }
@@ -0,0 +1,69 @@
1
+ import type { VerifyResult } from "@crewhaus/audit-log";
2
+ /**
3
+ * Item 34 — `crewhaus audit verify` plumbing, factored out of the entry file
4
+ * `index.ts` (which runs a top-level argv switch and so cannot be imported by
5
+ * a test without executing the CLI). Side-effect-free and directly
6
+ * unit-testable, mirroring `scope-audit.ts` / `justification-gate.ts` /
7
+ * `doctor-checks.ts`.
8
+ *
9
+ * Two halves:
10
+ * 1. `resolveAnchorFlag` — parse the `--anchor <scheme>:<path>` flag. Only
11
+ * `file:` ships today (`@crewhaus/audit-log`'s `FileAnchorStore`); an
12
+ * unknown scheme throws so the entry file can `die()` with the allowed
13
+ * list instead of silently skipping the cross-check the user asked for.
14
+ * 2. `summarizeVerifyResult` — turn the library's `VerifyResult` into the
15
+ * per-check ✓/~/✗ summary lines + exit code. The exit-code contract:
16
+ * 0 only when the chain is intact AND (when an anchor store was
17
+ * explicitly requested) the external anchor was actually cross-checked —
18
+ * a requested-but-unconsulted anchor exits 1, because a scheduled
19
+ * tamper check that silently skipped its strongest witness is worse
20
+ * than one that fails loudly.
21
+ */
22
+ export type AnchorFlagChoice = {
23
+ readonly scheme: "file";
24
+ readonly path: string;
25
+ };
26
+ /** Thrown by `resolveAnchorFlag` on a malformed `--anchor` value. The CLI
27
+ * entry file catches it and routes the message through `die()`; tests
28
+ * assert on `.message` without the process exiting. */
29
+ export declare class InvalidAnchorFlagError extends Error {
30
+ readonly value: string;
31
+ readonly name = "InvalidAnchorFlagError";
32
+ constructor(value: string);
33
+ }
34
+ /**
35
+ * Parse the raw `--anchor` flag value (or undefined when absent). `file:<path>`
36
+ * selects the file-backed AnchorStore rooted at `<path>`; anything else —
37
+ * missing scheme, empty path, unknown scheme (S3 et al. are out of scope) —
38
+ * throws {@link InvalidAnchorFlagError}.
39
+ */
40
+ export declare function resolveAnchorFlag(flagValue: string | undefined): AnchorFlagChoice | undefined;
41
+ export type VerifySummary = {
42
+ readonly lines: ReadonlyArray<string>;
43
+ readonly exitCode: 0 | 1;
44
+ };
45
+ /**
46
+ * Render a `VerifyResult` as per-check summary lines + the process exit code.
47
+ * `anchorRequested` is whether the user passed `--anchor`: when true, an
48
+ * `ok` result whose external anchor was NOT actually consulted (store empty,
49
+ * unreadable, or lagging into nonexistence) is a FAILURE — the check the
50
+ * user scheduled did not run, so exiting 0 would fabricate assurance.
51
+ */
52
+ export declare function summarizeVerifyResult(result: VerifyResult, opts: {
53
+ readonly anchorRequested: boolean;
54
+ }): VerifySummary;
55
+ /** Shape shared with `index.ts`'s DoctorCheck (label/pass/warn/reason). */
56
+ export type AuditIntegrityCheck = {
57
+ readonly label: string;
58
+ readonly pass: boolean;
59
+ readonly warn?: boolean;
60
+ readonly reason?: string;
61
+ };
62
+ /**
63
+ * Map a `VerifyResult` onto the doctor's ✓/~/✗ check shape so `crewhaus
64
+ * doctor` can surface audit-log tampering when a `.crewhaus/audit` store
65
+ * exists in the cwd. An intact chain without the on-host anchor is a warn
66
+ * ("~"), never a failure — mirroring the library's limitation-not-tamper
67
+ * stance — and a broken chain fails doctor with the first finding inline.
68
+ */
69
+ export declare function buildAuditIntegrityCheck(result: VerifyResult): AuditIntegrityCheck;