crewhaus 0.1.8 → 0.2.1

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 (138) 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 +173 -0
  25. package/dist/dataset-mine.js +404 -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 +11664 -963
  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/route.d.ts +19 -0
  107. package/dist/route.js +90 -0
  108. package/dist/scaffold-evals.d.ts +138 -0
  109. package/dist/scaffold-evals.js +410 -0
  110. package/dist/scope-audit-drift.d.ts +139 -0
  111. package/dist/scope-audit-drift.js +260 -0
  112. package/dist/security-corpus.d.ts +237 -0
  113. package/dist/security-corpus.js +516 -0
  114. package/dist/security-digest.d.ts +174 -0
  115. package/dist/security-digest.js +656 -0
  116. package/dist/sessions-index.d.ts +27 -0
  117. package/dist/sessions-index.js +51 -0
  118. package/dist/slo-doctor.d.ts +67 -0
  119. package/dist/slo-doctor.js +119 -0
  120. package/dist/slo-sink.d.ts +96 -0
  121. package/dist/slo-sink.js +107 -0
  122. package/dist/spec-changelog.d.ts +102 -0
  123. package/dist/spec-changelog.js +237 -0
  124. package/dist/state-backup.d.ts +223 -0
  125. package/dist/state-backup.js +648 -0
  126. package/dist/tools-cli.d.ts +170 -0
  127. package/dist/tools-cli.js +300 -0
  128. package/dist/triage.d.ts +202 -0
  129. package/dist/triage.js +403 -0
  130. package/dist/upgrade.d.ts +57 -0
  131. package/dist/upgrade.js +113 -0
  132. package/dist/version.d.ts +6 -0
  133. package/dist/version.js +27 -0
  134. package/dist/voice-eval.d.ts +138 -0
  135. package/dist/voice-eval.js +309 -0
  136. package/dist/watch.d.ts +58 -0
  137. package/dist/watch.js +97 -0
  138. package/package.json +90 -65
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Item 16 — `crewhaus permissions suggest`: mine persisted ask/deny history
3
+ * into reviewable permission rules. The pure, side-effect-free half; all
4
+ * filesystem access + the interactive `--apply` confirm live in
5
+ * `apps/cli/src/index.ts` (mirrors `advise-rules.ts`).
6
+ *
7
+ * SIGNAL SHAPE (verified against `@crewhaus/event-log` + runtime-core's
8
+ * advisor subscriber): the durable `permission` line is
9
+ * `{ toolName, decision, askOutcome }` and carries NO tool input (the input
10
+ * lives only on the adjacent `tool_use` line `{ id, name, input }`). Both
11
+ * `permission.toolName` and `tool_use.name` are the RegisteredTool's
12
+ * PascalCase `.name` (`Read`, `Bash`), so the two correlate directly by name.
13
+ * We therefore aggregate asks by toolName and derive an OPTIONAL arg glob by
14
+ * sampling the operative field of the tool_use inputs seen for that tool.
15
+ *
16
+ * SAFETY: permissions are deliberately EXCLUDED from OPTIMIZABLE_PATHS — an
17
+ * optimizer must never widen its own permissions. This module only ever
18
+ * PROPOSES rules; nothing here writes, and the CLI's `--apply` is always an
19
+ * interactive human confirm (never eval-gated). Read-only tools get
20
+ * `alwaysAllow` proposals first (lowest blast radius); recurring DENIED asks
21
+ * get an `alwaysAsk` tightening (never a blanket `alwaysDeny` — a human
22
+ * denied THIS call, not necessarily every future one).
23
+ */
24
+ import { OPERATIVE_ARG_FIELDS as MATCHER_OPERATIVE_ARG_FIELDS, escapeGlobLiteral, } from "@crewhaus/tool-permission-matcher";
25
+ import { payloadOf } from "./advise-rules";
26
+ /**
27
+ * The operative input field(s) per built-in tool — the one a permission
28
+ * arg-glob constrains. Re-exported from `@crewhaus/tool-permission-matcher`
29
+ * (the SINGLE source of truth) so a suggested pattern always targets the SAME
30
+ * field the matcher checks. This was hand-copied here (a silent-desync risk);
31
+ * now a matcher edit propagates automatically. Keyed by the PascalCase runtime
32
+ * `.name`.
33
+ */
34
+ export const OPERATIVE_ARG_FIELDS = MATCHER_OPERATIVE_ARG_FIELDS;
35
+ /** Max distinct operative-arg samples kept per tool (bounds report size). */
36
+ export const MAX_ARG_SAMPLES = 5;
37
+ function operativeValue(toolName, input) {
38
+ const fields = OPERATIVE_ARG_FIELDS[toolName];
39
+ if (fields === undefined || input === null || typeof input !== "object")
40
+ return undefined;
41
+ const record = input;
42
+ for (const f of fields) {
43
+ const v = record[f];
44
+ if (typeof v === "string" && v.length > 0)
45
+ return v;
46
+ }
47
+ return undefined;
48
+ }
49
+ /**
50
+ * Fold sessions into per-tool ask aggregates. Reads `permission` lines for
51
+ * the ask counts and `tool_use` lines for the operative-arg samples. Pure;
52
+ * tolerant of old-vintage logs (no permission lines → empty result).
53
+ */
54
+ export function aggregateAsks(sessions) {
55
+ const byTool = new Map();
56
+ const get = (toolName) => {
57
+ let agg = byTool.get(toolName);
58
+ if (agg === undefined) {
59
+ agg = { toolName, asks: 0, approved: 0, denied: 0, argSamples: [] };
60
+ byTool.set(toolName, agg);
61
+ }
62
+ return agg;
63
+ };
64
+ // First pass: tool_use inputs → operative-arg sample bank per tool.
65
+ for (const session of sessions) {
66
+ for (const obj of session.objects) {
67
+ const tu = payloadOf(obj, "tool_use");
68
+ if (tu === undefined || typeof tu["name"] !== "string")
69
+ continue;
70
+ const value = operativeValue(tu["name"], tu["input"]);
71
+ if (value === undefined)
72
+ continue;
73
+ const agg = get(tu["name"]);
74
+ if (agg.argSamples.length < MAX_ARG_SAMPLES && !agg.argSamples.includes(value)) {
75
+ agg.argSamples.push(value);
76
+ }
77
+ }
78
+ }
79
+ // Second pass: resolved permission asks → counts.
80
+ for (const session of sessions) {
81
+ for (const obj of session.objects) {
82
+ const perm = payloadOf(obj, "permission");
83
+ if (perm === undefined || typeof perm["toolName"] !== "string")
84
+ continue;
85
+ if (perm["decision"] !== "ask")
86
+ continue; // allow/deny were not human prompts
87
+ const agg = get(perm["toolName"]);
88
+ agg.asks += 1;
89
+ if (perm["askOutcome"] === "approved")
90
+ agg.approved += 1;
91
+ else if (perm["askOutcome"] === "denied")
92
+ agg.denied += 1;
93
+ }
94
+ }
95
+ // Drop tools that only contributed arg samples but never actually prompted.
96
+ for (const [name, agg] of byTool)
97
+ if (agg.asks === 0)
98
+ byTool.delete(name);
99
+ return byTool;
100
+ }
101
+ export const DEFAULT_SUGGEST_THRESHOLDS = Object.freeze({
102
+ minAsks: 3,
103
+ approveRate: 1.0, // only propose a grant when EVERY ask was approved
104
+ denyRate: 0.5,
105
+ });
106
+ /** Build a name → readOnly lookup from the resolvable tool map (keyed by the
107
+ * RegisteredTool `.name`, which is what the ask aggregate is keyed by). */
108
+ export function readOnlyByName(toolMap) {
109
+ const out = new Map();
110
+ for (const t of Object.values(toolMap))
111
+ out.set(t.name, t.readOnly);
112
+ return out;
113
+ }
114
+ /**
115
+ * Compile a permission pattern for a tool. When exactly one operative-arg
116
+ * value recurred we emit a tool+arg glob (`Bash(git status)`); a small set of
117
+ * distinct values stays a bare tool glob (a per-value rule set would be noise
118
+ * — the human can tighten). Tools with no operative field (or no samples) get
119
+ * the bare tool name.
120
+ *
121
+ * SAFETY (glob widening): the observed value is a LITERAL the human approved,
122
+ * not a glob the human authored. tool-permission-matcher treats `*`/`?` in an
123
+ * arg-glob as wildcards, so splicing a raw value that contains them would make
124
+ * the suggested rule match unapproved siblings (`Bash(npm run test:*)` matching
125
+ * `Bash(npm run test:PRODUCTION-DELETE)`). We `escapeGlobLiteral` the value so
126
+ * the rule matches ONLY the approved string. A value containing `(`/`)` cannot
127
+ * be represented in the matcher's `Tool(arg)` format (its paren split isn't
128
+ * escape-aware), so we fall back to a bare tool glob rather than emit a rule
129
+ * that misparses — the reviewer can hand-write a tighter one.
130
+ */
131
+ export function patternFor(agg) {
132
+ if (agg.argSamples.length === 1 && OPERATIVE_ARG_FIELDS[agg.toolName] !== undefined) {
133
+ const value = agg.argSamples[0];
134
+ if (!value.includes("(") && !value.includes(")")) {
135
+ // A single recurring operative value → a precise glob for exactly it,
136
+ // with glob metacharacters neutralised so it matches only the literal.
137
+ return `${agg.toolName}(${escapeGlobLiteral(value)})`;
138
+ }
139
+ }
140
+ return agg.toolName;
141
+ }
142
+ /**
143
+ * Rank ask aggregates into reviewable permission suggestions. Deterministic:
144
+ * - recurring-APPROVED tools (100% approved, ≥ minAsks) → `alwaysAllow`,
145
+ * read-only tools FIRST (lowest blast radius), then by approval count;
146
+ * - recurring-DENIED tools (deny rate ≥ denyRate) → `alwaysAsk` tightening
147
+ * (NOT alwaysDeny — a human denied a specific call, not the whole tool),
148
+ * by denial count.
149
+ * Read-only-ness comes from the tool map; an unknown tool defaults to false
150
+ * (treated as non-read-only ⇒ ranked after read-only grants).
151
+ */
152
+ export function rankSuggestions(aggregates, readOnly, thresholds = DEFAULT_SUGGEST_THRESHOLDS) {
153
+ const grants = [];
154
+ const tightenings = [];
155
+ for (const agg of aggregates.values()) {
156
+ if (agg.asks < thresholds.minAsks)
157
+ continue;
158
+ const ro = readOnly.get(agg.toolName) ?? false;
159
+ const approveRate = agg.approved / agg.asks;
160
+ const denyRate = agg.denied / agg.asks;
161
+ const pattern = patternFor(agg);
162
+ if (agg.denied === 0 && approveRate >= thresholds.approveRate) {
163
+ grants.push({
164
+ rule: { type: "alwaysAllow", pattern, source: "settings" },
165
+ reason: "recurring-approved",
166
+ toolName: agg.toolName,
167
+ readOnly: ro,
168
+ weight: agg.approved,
169
+ evidence: [
170
+ `${agg.approved}/${agg.asks} ${agg.toolName} asks approved, 0 denied (threshold: ≥${thresholds.minAsks} asks, 100% approved)`,
171
+ ro
172
+ ? "tool is read-only — lowest blast radius to auto-allow"
173
+ : "tool is NOT read-only — review the effect before granting",
174
+ ...(pattern.includes("(")
175
+ ? [`derived from a single recurring input: ${pattern}`]
176
+ : agg.argSamples.length > 1
177
+ ? [`inputs varied (${agg.argSamples.length} distinct) — proposing a bare tool grant`]
178
+ : []),
179
+ ],
180
+ });
181
+ }
182
+ else if (denyRate >= thresholds.denyRate) {
183
+ tightenings.push({
184
+ rule: { type: "alwaysAsk", pattern, source: "settings" },
185
+ reason: "recurring-denied",
186
+ toolName: agg.toolName,
187
+ readOnly: ro,
188
+ weight: agg.denied,
189
+ evidence: [
190
+ `${agg.denied}/${agg.asks} ${agg.toolName} asks DENIED (${(denyRate * 100).toFixed(0)}%, threshold: ≥${(thresholds.denyRate * 100).toFixed(0)}%)`,
191
+ "proposing alwaysAsk (keep prompting) — a human denied specific calls; alwaysDeny would over-reach",
192
+ ],
193
+ });
194
+ }
195
+ }
196
+ // Grants: read-only first, then approval count desc, then tool name.
197
+ grants.sort((a, b) => {
198
+ if (a.readOnly !== b.readOnly)
199
+ return a.readOnly ? -1 : 1;
200
+ if (b.weight !== a.weight)
201
+ return b.weight - a.weight;
202
+ return a.toolName.localeCompare(b.toolName);
203
+ });
204
+ // Tightenings: denial count desc, then tool name.
205
+ tightenings.sort((a, b) => {
206
+ if (b.weight !== a.weight)
207
+ return b.weight - a.weight;
208
+ return a.toolName.localeCompare(b.toolName);
209
+ });
210
+ return [...grants, ...tightenings];
211
+ }
212
+ /**
213
+ * Read the existing `permissions.rules` out of a parsed `.crewhaus/settings.json`
214
+ * root (the EXACT shape `buildRuleSet` in index.ts consumes). Tolerant of a
215
+ * missing/foreign file: anything that isn't the expected shape yields `[]`.
216
+ */
217
+ export function existingSettingsRules(settingsRoot) {
218
+ if (settingsRoot === null || typeof settingsRoot !== "object")
219
+ return [];
220
+ const perms = settingsRoot.permissions;
221
+ if (perms === null || typeof perms !== "object")
222
+ return [];
223
+ const rules = perms.rules;
224
+ if (!Array.isArray(rules))
225
+ return [];
226
+ const out = [];
227
+ for (const r of rules) {
228
+ if (r === null || typeof r !== "object")
229
+ continue;
230
+ const type = r.type;
231
+ const pattern = r.pattern;
232
+ if ((type === "alwaysAllow" || type === "alwaysDeny" || type === "alwaysAsk") &&
233
+ typeof pattern === "string" &&
234
+ pattern.length > 0) {
235
+ out.push({ type, pattern });
236
+ }
237
+ }
238
+ return out;
239
+ }
240
+ /**
241
+ * Compute the additive diff between the existing settings rules and the
242
+ * ranked suggestions. A suggestion whose (type, pattern) already exists is
243
+ * reported as already-present, never duplicated. Order-preserving: existing
244
+ * rules first, then new additions in suggestion rank order.
245
+ */
246
+ export function diffPermissions(existing, suggestions) {
247
+ const key = (r) => `${r.type} ${r.pattern}`;
248
+ const have = new Set(existing.map(key));
249
+ const additions = [];
250
+ const alreadyPresent = [];
251
+ const seen = new Set(have);
252
+ for (const s of suggestions) {
253
+ const r = { type: s.rule.type, pattern: s.rule.pattern };
254
+ if (have.has(key(r))) {
255
+ alreadyPresent.push(r);
256
+ }
257
+ else if (!seen.has(key(r))) {
258
+ additions.push(r);
259
+ seen.add(key(r));
260
+ }
261
+ }
262
+ return { additions, alreadyPresent, merged: [...existing, ...additions] };
263
+ }
264
+ /**
265
+ * Render the settings.json `permissions` block that `--apply` would write,
266
+ * MERGING into the existing settings root so unrelated top-level keys
267
+ * (`hooks`, skills, …) survive. Returns the full new root object; the CLI
268
+ * JSON-stringifies it. Pure — never touches disk.
269
+ */
270
+ export function applyToSettingsRoot(settingsRoot, merged) {
271
+ const root = settingsRoot !== null && typeof settingsRoot === "object"
272
+ ? { ...settingsRoot }
273
+ : {};
274
+ const existingPerms = root["permissions"] !== null && typeof root["permissions"] === "object"
275
+ ? { ...root["permissions"] }
276
+ : {};
277
+ root["permissions"] = { ...existingPerms, rules: merged.map((r) => ({ ...r })) };
278
+ return root;
279
+ }
280
+ // -------- rendering --------
281
+ /** Human-readable suggestion lines for the CLI text mode. */
282
+ export function formatSuggestionLines(suggestions) {
283
+ const lines = [];
284
+ for (const s of suggestions) {
285
+ lines.push(`[${s.reason}] ${s.rule.type} ${s.rule.pattern}${s.readOnly ? " (read-only)" : ""}`);
286
+ for (const e of s.evidence)
287
+ lines.push(` · ${e}`);
288
+ }
289
+ return lines;
290
+ }
291
+ /**
292
+ * A unified-diff-ish view of the settings.json permissions change: existing
293
+ * rules as context, additions as `+` lines. Purely for human review — this
294
+ * is NOT applied unless the user confirms `--apply` interactively.
295
+ */
296
+ export function formatSettingsDiff(diff) {
297
+ const lines = [".crewhaus/settings.json → permissions.rules:"];
298
+ if (diff.merged.length === 0) {
299
+ lines.push(" (no rules — nothing to suggest)");
300
+ return lines;
301
+ }
302
+ const addKeys = new Set(diff.additions.map((r) => `${r.type} ${r.pattern}`));
303
+ for (const r of diff.merged) {
304
+ const isNew = addKeys.has(`${r.type} ${r.pattern}`);
305
+ // Flag any UNESCAPED wildcard still present — such a rule matches multiple
306
+ // values (an existing hand-authored broad rule or, defensively, a
307
+ // suggestion that somehow kept a wildcard). Suggested rules escape their
308
+ // literals in patternFor, so a `+` line should never trip this.
309
+ const warn = hasUnescapedWildcard(r.pattern) ? " (⚠ wildcard — matches multiple)" : "";
310
+ lines.push(` ${isNew ? "+" : " "} { type: ${r.type}, pattern: ${JSON.stringify(r.pattern)} }${warn}`);
311
+ }
312
+ if (diff.additions.length === 0) {
313
+ lines.push(" (all suggestions already present — nothing to add)");
314
+ }
315
+ return lines;
316
+ }
317
+ /**
318
+ * True when a pattern contains a glob wildcard (`*`/`?`) that is NOT
319
+ * backslash-escaped — i.e. one the matcher will treat as widening. A `\\*`/`\\?`
320
+ * (a literal escaped by `escapeGlobLiteral`) does not count.
321
+ */
322
+ export function hasUnescapedWildcard(pattern) {
323
+ for (let i = 0; i < pattern.length; i++) {
324
+ const ch = pattern[i];
325
+ if (ch === "\\") {
326
+ i++; // skip the escaped char — it's a literal, not a wildcard
327
+ continue;
328
+ }
329
+ if (ch === "*" || ch === "?")
330
+ return true;
331
+ }
332
+ return false;
333
+ }
@@ -0,0 +1,107 @@
1
+ import { type PiiPolicyFile } from "@crewhaus/pii-redactor";
2
+ /**
3
+ * AUTOMATION-OPPORTUNITIES.md item 51 — `crewhaus pii tune` core: aggregate
4
+ * PII-redaction history across sessions to find (a) high-frequency FALSE-
5
+ * POSITIVE over-redaction candidates and (b) detector coverage gaps, and
6
+ * propose a reviewed `.crewhaus/pii-policy.json` the redactor consults
7
+ * additively. Side-effect-free so it is unit-testable (all filesystem access
8
+ * lives in index.ts), mirroring `advise-rules.ts` / `egress-triage.ts`.
9
+ *
10
+ * NO-RAW-PII INVARIANT (the whole point of item 51): this module NEVER stores,
11
+ * prints, or returns a raw PII value. Every value is reduced to
12
+ * `HMAC-SHA256(secret, value)` (via `@crewhaus/pii-redactor`'s `hmacValue` —
13
+ * the SAME digest the redactor's hash-mode marker and hashed allow-list use)
14
+ * the instant it is detected. Aggregation, the proposed policy, and every
15
+ * renderer carry only { kind, hash, count } — so a proposed allow entry can be
16
+ * matched by the redactor at runtime WITHOUT anyone ever seeing the value. The
17
+ * tests assert a known raw value never appears in any output.
18
+ *
19
+ * WHERE THE SIGNAL COMES FROM (durability, verified 2026-07): the pii-redactor
20
+ * has no runtime writer emitting durable redaction events, so `pii tune` re-
21
+ * derives the redaction history by running the detector over durable session
22
+ * `tool_result` + assistant content itself (the durable surface), hashing each
23
+ * hit immediately. It cross-references `.crewhaus/feedback` ratings: a PII
24
+ * (kind, hash) that recurs in the outputs of turns the human UP-RATED/accepted
25
+ * is an over-redaction candidate — the operator kept the answer that carried
26
+ * it, so redacting it may be too aggressive. Coverage gaps are kinds seen only
27
+ * via the classifier sentinel or at the detector's edges.
28
+ */
29
+ export type HashedPiiKey = {
30
+ readonly kind: string;
31
+ readonly hash: string;
32
+ };
33
+ export type PiiAggregate = {
34
+ readonly kind: string;
35
+ readonly hash: string;
36
+ /** Total occurrences across scanned content. */
37
+ readonly count: number;
38
+ /** Occurrences in content from turns the human up-rated/accepted. */
39
+ readonly acceptedCount: number;
40
+ };
41
+ export type PiiTuneContext = {
42
+ /** Per (kind, hash) aggregate, ranked by acceptedCount then count. */
43
+ readonly aggregates: ReadonlyArray<PiiAggregate>;
44
+ /** Total PII hits detected across all scanned content. */
45
+ readonly totalHits: number;
46
+ /** Per-kind totals (coverage view). */
47
+ readonly byKind: Readonly<Record<string, number>>;
48
+ /** Content units scanned. */
49
+ readonly scanned: number;
50
+ };
51
+ /** A single unit of scanned content plus whether its turn was accepted. */
52
+ export type ScanUnit = {
53
+ readonly content: string;
54
+ /** True when the human up-rated/accepted the turn this content belongs to. */
55
+ readonly accepted: boolean;
56
+ };
57
+ /**
58
+ * Fold scan units into the hashed aggregate. `secret` is the HMAC key (never
59
+ * stored); each detected value is hashed immediately and the raw value is
60
+ * dropped. Detectors default to the shipped `DEFAULT_PII_DETECTORS`.
61
+ */
62
+ export declare function buildPiiTuneContext(units: ReadonlyArray<ScanUnit>, secret: string, detectors?: readonly import("@crewhaus/grader-safety-classifiers").PiiDetector[]): PiiTuneContext;
63
+ export type PiiTuneThresholds = {
64
+ /** Occurrences in ACCEPTED outputs at/above which a (kind,hash) is a
65
+ * false-positive over-redaction candidate. */
66
+ readonly acceptedMin: number;
67
+ /** Total occurrences at/above which a (kind,hash) is judged at all. */
68
+ readonly countMin: number;
69
+ };
70
+ export declare const DEFAULT_PII_TUNE_THRESHOLDS: PiiTuneThresholds;
71
+ export type FalsePositiveCandidate = {
72
+ readonly kind: string;
73
+ readonly hash: string;
74
+ readonly count: number;
75
+ readonly acceptedCount: number;
76
+ /** acceptedCount / count — how consistently this value survived into kept
77
+ * outputs (1.0 = every occurrence was in an accepted turn). */
78
+ readonly acceptedRatio: number;
79
+ };
80
+ /**
81
+ * Find false-positive over-redaction candidates: (kind, hash) pairs seen ≥
82
+ * countMin times whose occurrences land in ACCEPTED outputs ≥ acceptedMin
83
+ * times. These are values the human kept, so redacting them may be too
84
+ * aggressive — they become proposed hashed allow entries.
85
+ */
86
+ export declare function findFalsePositives(ctx: PiiTuneContext, thresholds?: PiiTuneThresholds): FalsePositiveCandidate[];
87
+ export type CoverageGap = {
88
+ readonly reason: string;
89
+ readonly detail: string;
90
+ };
91
+ /**
92
+ * Detect detector coverage gaps. Deliberately conservative and structural (no
93
+ * raw content): kinds present in the shipped detectors that produced ZERO hits
94
+ * over a non-trivial scan (either the content genuinely lacks them, or the
95
+ * detector under-fires) are surfaced as "verify coverage" notes, and a scan
96
+ * with hits but NO email/ssn/credit_card at all over many units is flagged as
97
+ * a possible high-value-PII blind spot worth a custom detector.
98
+ */
99
+ export declare function findCoverageGaps(ctx: PiiTuneContext, detectorKinds?: ReadonlyArray<string>): CoverageGap[];
100
+ /**
101
+ * Turn false-positive candidates into a reviewed `PiiPolicyFile`. Carries ONLY
102
+ * hashed allow entries — never raw PII — so it is safe to commit and the
103
+ * redactor can honour it additively via `createPiiRedactorWithPolicy`.
104
+ * Deterministic given the candidates.
105
+ */
106
+ export declare function buildPiiPolicy(candidates: ReadonlyArray<FalsePositiveCandidate>): PiiPolicyFile;
107
+ export declare function renderPiiTuneLines(ctx: PiiTuneContext, candidates: ReadonlyArray<FalsePositiveCandidate>, gaps: ReadonlyArray<CoverageGap>): ReadonlyArray<string>;
@@ -0,0 +1,122 @@
1
+ import { DEFAULT_PII_DETECTORS, detectPii, hmacValue, } from "@crewhaus/pii-redactor";
2
+ /**
3
+ * Fold scan units into the hashed aggregate. `secret` is the HMAC key (never
4
+ * stored); each detected value is hashed immediately and the raw value is
5
+ * dropped. Detectors default to the shipped `DEFAULT_PII_DETECTORS`.
6
+ */
7
+ export function buildPiiTuneContext(units, secret, detectors = DEFAULT_PII_DETECTORS) {
8
+ const byKey = new Map();
9
+ const byKind = new Map();
10
+ let totalHits = 0;
11
+ for (const unit of units) {
12
+ const hits = detectPii(unit.content, detectors);
13
+ for (const hit of hits) {
14
+ if (hit.value.length === 0)
15
+ continue; // classifier sentinel has no value
16
+ const hash = hmacValue(hit.value, secret);
17
+ const key = `${hit.kind}|${hash}`;
18
+ const acc = byKey.get(key) ?? { kind: hit.kind, hash, count: 0, acceptedCount: 0 };
19
+ acc.count += 1;
20
+ if (unit.accepted)
21
+ acc.acceptedCount += 1;
22
+ byKey.set(key, acc);
23
+ byKind.set(hit.kind, (byKind.get(hit.kind) ?? 0) + 1);
24
+ totalHits += 1;
25
+ }
26
+ }
27
+ const aggregates = [...byKey.values()]
28
+ .map((a) => ({ kind: a.kind, hash: a.hash, count: a.count, acceptedCount: a.acceptedCount }))
29
+ .sort((x, y) => y.acceptedCount - x.acceptedCount ||
30
+ y.count - x.count ||
31
+ x.kind.localeCompare(y.kind) ||
32
+ x.hash.localeCompare(y.hash));
33
+ return {
34
+ aggregates,
35
+ totalHits,
36
+ byKind: Object.fromEntries([...byKind.entries()].sort()),
37
+ scanned: units.length,
38
+ };
39
+ }
40
+ export const DEFAULT_PII_TUNE_THRESHOLDS = {
41
+ acceptedMin: 2,
42
+ countMin: 3,
43
+ };
44
+ /**
45
+ * Find false-positive over-redaction candidates: (kind, hash) pairs seen ≥
46
+ * countMin times whose occurrences land in ACCEPTED outputs ≥ acceptedMin
47
+ * times. These are values the human kept, so redacting them may be too
48
+ * aggressive — they become proposed hashed allow entries.
49
+ */
50
+ export function findFalsePositives(ctx, thresholds = DEFAULT_PII_TUNE_THRESHOLDS) {
51
+ const out = [];
52
+ for (const a of ctx.aggregates) {
53
+ if (a.count < thresholds.countMin)
54
+ continue;
55
+ if (a.acceptedCount < thresholds.acceptedMin)
56
+ continue;
57
+ out.push({
58
+ kind: a.kind,
59
+ hash: a.hash,
60
+ count: a.count,
61
+ acceptedCount: a.acceptedCount,
62
+ acceptedRatio: a.count > 0 ? a.acceptedCount / a.count : 0,
63
+ });
64
+ }
65
+ // Highest acceptedRatio (then acceptedCount) first — the strongest FPs.
66
+ return out.sort((x, y) => y.acceptedRatio - x.acceptedRatio ||
67
+ y.acceptedCount - x.acceptedCount ||
68
+ x.kind.localeCompare(y.kind));
69
+ }
70
+ /**
71
+ * Detect detector coverage gaps. Deliberately conservative and structural (no
72
+ * raw content): kinds present in the shipped detectors that produced ZERO hits
73
+ * over a non-trivial scan (either the content genuinely lacks them, or the
74
+ * detector under-fires) are surfaced as "verify coverage" notes, and a scan
75
+ * with hits but NO email/ssn/credit_card at all over many units is flagged as
76
+ * a possible high-value-PII blind spot worth a custom detector.
77
+ */
78
+ export function findCoverageGaps(ctx, detectorKinds = DEFAULT_PII_DETECTORS.map((d) => d.kind)) {
79
+ const gaps = [];
80
+ if (ctx.scanned < 5)
81
+ return gaps; // too little to judge coverage
82
+ const seen = new Set(Object.keys(ctx.byKind));
83
+ const missing = detectorKinds.filter((k) => !seen.has(k)).sort();
84
+ if (missing.length > 0) {
85
+ gaps.push({
86
+ reason: "detector-kinds-never-fired",
87
+ detail: `detector kinds with 0 hits over ${ctx.scanned} scanned unit(s): ${missing.join(", ")} — either the content lacks them or the pattern under-fires; verify against a known-positive sample before trusting coverage`,
88
+ });
89
+ }
90
+ return gaps;
91
+ }
92
+ // -------- policy file proposal --------
93
+ /**
94
+ * Turn false-positive candidates into a reviewed `PiiPolicyFile`. Carries ONLY
95
+ * hashed allow entries — never raw PII — so it is safe to commit and the
96
+ * redactor can honour it additively via `createPiiRedactorWithPolicy`.
97
+ * Deterministic given the candidates.
98
+ */
99
+ export function buildPiiPolicy(candidates) {
100
+ const allow = candidates
101
+ .map((c) => ({ kind: c.kind, hash: c.hash }))
102
+ .sort((a, b) => a.kind.localeCompare(b.kind) || a.hash.localeCompare(b.hash));
103
+ return { version: 1, allow };
104
+ }
105
+ // -------- renderers (hashes + counts ONLY, never raw values) --------
106
+ export function renderPiiTuneLines(ctx, candidates, gaps) {
107
+ const lines = [];
108
+ lines.push(`scanned ${ctx.scanned} content unit(s): ${ctx.totalHits} PII hit(s) across ${Object.keys(ctx.byKind).length} kind(s)`);
109
+ const kindLine = Object.entries(ctx.byKind)
110
+ .map(([k, n]) => `${k}=${n}`)
111
+ .join(", ");
112
+ if (kindLine !== "")
113
+ lines.push(` by kind: ${kindLine}`);
114
+ lines.push(`false-positive over-redaction candidates: ${candidates.length}`);
115
+ for (const c of candidates) {
116
+ // Hash + counts ONLY — the raw value is never available here.
117
+ lines.push(` ~ ${c.kind}:${c.hash} — kept in ${c.acceptedCount}/${c.count} accepted output(s) (${(c.acceptedRatio * 100).toFixed(0)}% accepted) → propose allow`);
118
+ }
119
+ for (const g of gaps)
120
+ lines.push(`coverage: ${g.detail}`);
121
+ return lines;
122
+ }
@@ -0,0 +1,117 @@
1
+ import { type SpecDiffEntry } from "@crewhaus/spec-patch";
2
+ /** Thrown for operational failures (unreadable spec, no change, driver
3
+ * failure). The CLI routes the message through `die()`; tests assert on
4
+ * `.message`. */
5
+ export declare class ProposeError extends Error {
6
+ readonly name = "ProposeError";
7
+ }
8
+ /** Where a proposal's review bundle lands within the repo. */
9
+ export declare const PROPOSALS_RELDIR = ".crewhaus/proposals";
10
+ export type ProposeSource = "optimize" | "advise" | "model-scan" | "manual";
11
+ /** sha256 hex of a spec's bytes — the patch identity carried into provenance. */
12
+ export declare function specContentHash(yaml: string): string;
13
+ export type EvalDelta = {
14
+ readonly scoreBefore: number;
15
+ readonly scoreAfter: number;
16
+ readonly datasetName?: string;
17
+ };
18
+ export type ProposalProvenance = {
19
+ readonly source: ProposeSource;
20
+ readonly specName: string;
21
+ /** sha256 of the proposed spec bytes. */
22
+ readonly patchHash: string;
23
+ /** The optimize/advise run id, when the proposal came from one. */
24
+ readonly runId?: string;
25
+ readonly generatedAt: string;
26
+ };
27
+ /** The machine-readable half of the review bundle (`patch.json`). */
28
+ export type ProposalPatch = {
29
+ readonly specName: string;
30
+ readonly diff: ReadonlyArray<SpecDiffEntry>;
31
+ readonly provenance: ProposalProvenance;
32
+ readonly evalDelta?: EvalDelta;
33
+ };
34
+ export type AssembleProposalOptions = {
35
+ readonly specName: string;
36
+ readonly currentYaml: string;
37
+ readonly proposedYaml: string;
38
+ readonly source: ProposeSource;
39
+ readonly runId?: string;
40
+ readonly evalDelta?: EvalDelta;
41
+ /** Optimize run dir whose provenance the changelog entry folds in. */
42
+ readonly optimizeRootDir?: string;
43
+ readonly patchJsonPath?: string;
44
+ readonly proposedVersion: string;
45
+ readonly now?: () => Date;
46
+ };
47
+ export type AssembledProposal = {
48
+ readonly patch: ProposalPatch;
49
+ /** `patch.json` text. */
50
+ readonly patchJson: string;
51
+ /** The rendered CHANGELOG entry for the proposed version. */
52
+ readonly changelogEntry: string;
53
+ readonly prTitle: string;
54
+ readonly prBody: string;
55
+ /** Whether the proposed spec differs structurally from the current one. */
56
+ readonly hasStructuralChange: boolean;
57
+ };
58
+ /**
59
+ * Assemble the review bundle from current + proposed spec YAML. Pure — no
60
+ * filesystem, no git. Throws when the proposed spec is byte-identical to the
61
+ * current one (nothing to propose); a structurally-empty-but-reformatted
62
+ * change is allowed through with a note (comments matter to reviewers).
63
+ */
64
+ export declare function assembleProposal(opts: AssembleProposalOptions): AssembledProposal;
65
+ /** The write-and-open plan the driver executes: which files to write, the
66
+ * branch, and the PR title/body. Assembled purely; the driver is the only
67
+ * side-effect surface. */
68
+ export type ProposalPrPlan = {
69
+ readonly branch: string;
70
+ readonly title: string;
71
+ readonly body: string;
72
+ /** repo-relative path → contents to write on the branch. */
73
+ readonly files: Readonly<Record<string, string>>;
74
+ /** Commit message for the single proposal commit. */
75
+ readonly commitMessage: string;
76
+ };
77
+ /** Slug-safe proposal id: `<spec>-<version>-<shorthash>-<stamp>`. */
78
+ export declare function proposalId(specName: string, version: string, patchHash: string, now: Date): string;
79
+ export type BuildPrPlanOptions = {
80
+ readonly assembled: AssembledProposal;
81
+ /** The proposed spec YAML — written to the harness's spec file on the branch. */
82
+ readonly proposedYaml: string;
83
+ /** Repo-relative path of the harness spec file (e.g. "crewhaus.yaml"). */
84
+ readonly specRelPath: string;
85
+ readonly proposedVersion: string;
86
+ readonly now?: () => Date;
87
+ };
88
+ /** Assemble the branch/commit/PR plan + the review-bundle files. Pure. */
89
+ export declare function buildProposalPrPlan(opts: BuildPrPlanOptions): {
90
+ readonly plan: ProposalPrPlan;
91
+ readonly proposalId: string;
92
+ };
93
+ /**
94
+ * Driver seam: execute a `ProposalPrPlan` against a real repo (branch → write
95
+ * files → commit → push → `gh pr create`) and resolve to the opened PR. The
96
+ * CLI wires this to git + gh (through `GITHUB_TOKEN`); tests inject a stub
97
+ * that records the plan and returns a fixed PR ref. The driver MUST NOT
98
+ * auto-merge.
99
+ */
100
+ export type GitPrDriver = (plan: ProposalPrPlan) => Promise<OpenedPr>;
101
+ export type OpenedPr = {
102
+ readonly prNumber?: number;
103
+ readonly url: string;
104
+ readonly branch: string;
105
+ };
106
+ export type GovernanceProposalPayload = {
107
+ readonly source: ProposeSource;
108
+ readonly specName: string;
109
+ readonly patchHash: string;
110
+ readonly proposedVersion: string;
111
+ readonly evalDelta?: EvalDelta;
112
+ readonly prNumber?: number;
113
+ readonly prUrl?: string;
114
+ readonly branch: string;
115
+ readonly ts: number;
116
+ };
117
+ export declare function buildProposalAuditPayload(assembled: AssembledProposal, opened: OpenedPr, proposedVersion: string, now: () => number): GovernanceProposalPayload;