thumbgate 1.37.1 → 1.37.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/article-syndication-pipeline/SKILL.md +37 -0
- package/.agents/skills/ci-gha-buildkite-patterns-not-clone/SKILL.md +72 -0
- package/.agents/skills/cobble-hot-store-compare-not-clone/SKILL.md +80 -0
- package/.agents/skills/colab-compute-honesty-not-clone/SKILL.md +70 -0
- package/.agents/skills/comet-session-logger/SKILL.md +59 -0
- package/.agents/skills/datadog-llm-obs-compare-not-clone/SKILL.md +75 -0
- package/.agents/skills/deeppattern-discipline-honesty-not-clone/SKILL.md +73 -0
- package/.agents/skills/thumbgate-board-loop/SKILL.md +72 -0
- package/.agents/skills/thumbgate-daily-discoveries/SKILL.md +57 -0
- package/.agents/skills/token-shunt-honesty-not-clone/SKILL.md +67 -0
- package/.agents/skills/typesafe-typed-questions-not-clone/SKILL.md +86 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/future-agi/.mcp.json +1 -1
- package/adapters/future-agi/config.toml +1 -1
- package/adapters/future-agi/opencode.json +1 -1
- package/adapters/herdr/herdr-plugin.toml +2 -2
- package/adapters/mcp/server-stdio.js +1 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +257 -0
- package/config/gate-templates.json +96 -0
- package/package.json +55 -12
- package/public/blog/2026-09-23-datadog-llm-obs-four-practices.html +42 -0
- package/public/index.html +2 -2
- package/public/install.html +7 -7
- package/public/numbers.html +2 -2
- package/scripts/ci-gha-buildkite-patterns.js +393 -0
- package/scripts/cli-schema.js +90 -0
- package/scripts/cobble-hot-store-split.js +600 -0
- package/scripts/colab-compute-honesty.js +281 -0
- package/scripts/deeppattern-discipline-honesty.js +431 -0
- package/scripts/gates-engine.js +16 -5
- package/scripts/install-thumbgate-daily-publish-launchd.sh +83 -0
- package/scripts/llm-obs-honesty.js +478 -0
- package/scripts/thumbgate-board-loop.js +432 -0
- package/scripts/thumbgate-daily-discoveries-publish.js +413 -0
- package/scripts/thumbgate-daily-discoveries-publish.sh +18 -0
- package/scripts/token-shunt-honesty.js +502 -0
- package/scripts/typesafe-typed-questions.js +1138 -0
- package/server.json +2 -2
- package/skills/article-syndication-pipeline/SKILL.md +37 -0
- package/skills/comet-session-logger/SKILL.md +59 -0
- package/skills/thumbgate-daily-discoveries/SKILL.md +57 -0
- package/src/index.js +10 -0
- package/src/integrations/ideabrowser-connector.js +190 -0
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* TypeSafe typed-question FORMAT steal — not a product clone.
|
|
6
|
+
*
|
|
7
|
+
* Sources (signed-in console 2026-09-17, igor@igorganapolsky.com):
|
|
8
|
+
* https://console.typesafe.ai/hook
|
|
9
|
+
* https://console.typesafe.ai/playground (Support agent audit example)
|
|
10
|
+
* https://docs.typesafe.ai/cookbooks/llm_guardrails.md
|
|
11
|
+
* https://docs.typesafe.ai/patterns/confidence-routing.md
|
|
12
|
+
*
|
|
13
|
+
* Transfers (process only):
|
|
14
|
+
* 1. Atomic typed questions (noul / choice / score) over one tool-call state
|
|
15
|
+
* 2. Independent parallel evaluation — questions cannot see each other
|
|
16
|
+
* 3. Code owns route() (pass | review | block) — the model does not emit the verdict
|
|
17
|
+
* 4. Confidence is a second axis; low confidence on high-stakes fails closed
|
|
18
|
+
*
|
|
19
|
+
* Does NOT install typesafe-sdk, clone Jev, or wire an LLM adjudicator
|
|
20
|
+
* (#3690 / #3687, ECI pause) as the PreToolUse gate. Deterministic matchers
|
|
21
|
+
* own route(). Optional `--live` shadows the same battery against
|
|
22
|
+
* POST /v1/systemone (Jev) for calibration — never as the verdict.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const os = require('node:os');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
|
|
29
|
+
const SOURCE_URLS = Object.freeze([
|
|
30
|
+
'https://console.typesafe.ai/hook',
|
|
31
|
+
'https://docs.typesafe.ai/api',
|
|
32
|
+
'https://docs.typesafe.ai/cookbooks/llm_guardrails.md',
|
|
33
|
+
'https://docs.typesafe.ai/patterns/confidence-routing.md',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const SYSTEMONE_URL = 'https://api.typesafe.ai/v1/systemone';
|
|
37
|
+
const PARALLEL_QUESTIONS_COOKBOOK = 'https://docs.typesafe.ai/cookbooks/parallel_questions';
|
|
38
|
+
const SHADOW_NOUL_DELTA = 0.4;
|
|
39
|
+
|
|
40
|
+
const TYPED_KINDS = Object.freeze(['noul', 'choice', 'score']);
|
|
41
|
+
const ROUTE_PRECEDENCE = Object.freeze(['block', 'review', 'pass']);
|
|
42
|
+
const ROUTE_ACTIONS = Object.freeze(['block', 'review', 'pass']);
|
|
43
|
+
const WRITE_TOOLS = Object.freeze(['Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'Bash']);
|
|
44
|
+
|
|
45
|
+
const POLICIES = Object.freeze({
|
|
46
|
+
strict: { reviewThreshold: 0.35, actionThreshold: 0.7, severityBlock: 2 },
|
|
47
|
+
permissive: { reviewThreshold: 0.35, actionThreshold: 0.85, severityBlock: 2 },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const HAZARD_ACTION = Object.freeze({
|
|
51
|
+
secret_exfil: 'block',
|
|
52
|
+
destructive: 'block',
|
|
53
|
+
outbound_send: 'block',
|
|
54
|
+
guardrail_tamper: 'block',
|
|
55
|
+
clone_jev: 'block',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const CLONE_RE =
|
|
59
|
+
/\b(typesafe-sdk|@typesafe\/|api\.typesafe\.ai|clone\s+jev|install\s+jev|llm[- ]adjudicat|wire\s+jev\s+as\s+(?:the\s+)?gate)\b/i;
|
|
60
|
+
|
|
61
|
+
const DEFAULT_BATTERY = Object.freeze({
|
|
62
|
+
secret_exfil: {
|
|
63
|
+
type: 'noul',
|
|
64
|
+
instructions: 'This tool call exfiltrates or writes secrets, keys, or credentials.',
|
|
65
|
+
criteria: {
|
|
66
|
+
true: 'The call reads, prints, posts, or commits secret material',
|
|
67
|
+
false: 'The call does not handle secret material',
|
|
68
|
+
},
|
|
69
|
+
matcher:
|
|
70
|
+
'sk_live_|AKIA[0-9A-Z]{16}|BEGIN (?:RSA |OPENSSH )?PRIVATE KEY|aws_secret_access_key|ghp_[A-Za-z0-9]{20,}|xox[baprs]-',
|
|
71
|
+
stakes: 'high',
|
|
72
|
+
},
|
|
73
|
+
destructive: {
|
|
74
|
+
type: 'noul',
|
|
75
|
+
instructions: 'This tool call is a destructive git or filesystem action.',
|
|
76
|
+
criteria: {
|
|
77
|
+
true: 'Force-push, hard reset, or rm -rf of a root/home path',
|
|
78
|
+
false: 'No destructive rewrite or wipe',
|
|
79
|
+
},
|
|
80
|
+
matcher: 'git\\s+push\\s+(?:[^\\n]*\\s)?(?:--force|-f)(?:\\s|$)|git\\s+reset\\s+--hard|rm\\s+-rf\\s+[/~]',
|
|
81
|
+
warnMatcher: 'git\\s+add\\s+(-A|--all)|rm\\s+-rf\\s+\\.',
|
|
82
|
+
stakes: 'high',
|
|
83
|
+
},
|
|
84
|
+
outbound_send: {
|
|
85
|
+
type: 'noul',
|
|
86
|
+
instructions: 'This tool call sends outbound email or a public message as the operator.',
|
|
87
|
+
criteria: {
|
|
88
|
+
true: 'A send/dispatch tool is invoked',
|
|
89
|
+
false: 'Draft-only or no outbound send',
|
|
90
|
+
},
|
|
91
|
+
matcher: 'gmail.*\\bsend\\b|send_email|messages\\.send|\\bsmtp\\b.*\\bsend\\b|send_draft',
|
|
92
|
+
stakes: 'high',
|
|
93
|
+
},
|
|
94
|
+
guardrail_tamper: {
|
|
95
|
+
type: 'noul',
|
|
96
|
+
instructions: 'This tool call edits ThumbGate gates, prevention rules, or the spend guard.',
|
|
97
|
+
criteria: {
|
|
98
|
+
true: 'A gate, prevention-rule, or spend-guard file is being rewritten',
|
|
99
|
+
false: 'No guardrail file is in the write set',
|
|
100
|
+
},
|
|
101
|
+
matcher: 'config/gates|prevention-rules\\.md|gate-templates\\.json|thumbgate-spend-guard',
|
|
102
|
+
writeTools: WRITE_TOOLS,
|
|
103
|
+
writeCommandMatcher: '(?:^|[\\s;|&])(?:tee|rm|mv|cp|sed\\s+-i|>|>>)',
|
|
104
|
+
stakes: 'high',
|
|
105
|
+
},
|
|
106
|
+
clone_jev: {
|
|
107
|
+
type: 'noul',
|
|
108
|
+
instructions: 'This tool call installs TypeSafe/Jev or an LLM adjudicator as the PreToolUse gate.',
|
|
109
|
+
criteria: {
|
|
110
|
+
true: 'typesafe-sdk, api.typesafe.ai, Jev-as-gate, or LLM adjudication is being wired',
|
|
111
|
+
false: 'Deterministic gates stay in charge',
|
|
112
|
+
},
|
|
113
|
+
matcher:
|
|
114
|
+
'typesafe-sdk|@typesafe/|api\\.typesafe\\.ai|clone\\s+jev|llm[- ]adjudicat|jev-latest.*pretooluse|pretooluse.*jev',
|
|
115
|
+
stakes: 'high',
|
|
116
|
+
},
|
|
117
|
+
hazard_family: {
|
|
118
|
+
type: 'choice',
|
|
119
|
+
instructions: 'Which hazard family does this tool call belong to?',
|
|
120
|
+
criteria: {
|
|
121
|
+
none: 'No listed hazard matched',
|
|
122
|
+
secret: 'Secret exfiltration',
|
|
123
|
+
destructive: 'Destructive git or filesystem',
|
|
124
|
+
outbound: 'Outbound send',
|
|
125
|
+
tamper: 'Guardrail tamper',
|
|
126
|
+
clone: 'TypeSafe/Jev/LLM-adjudicator clone',
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
severity: {
|
|
130
|
+
type: 'score',
|
|
131
|
+
instructions: 'How much harm would complying with this tool call do?',
|
|
132
|
+
criteria: [
|
|
133
|
+
'None: ordinary safe call',
|
|
134
|
+
'Mild: warn-level match, no hard floor',
|
|
135
|
+
'Serious: default hard-block family',
|
|
136
|
+
'Severe: secret exfil or guardrail tamper',
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const RAIL_MAP = Object.freeze([
|
|
142
|
+
{ typesafe: 'one POST with all N questions (parallel_questions cookbook)', thumbgate: '--live sends the whole battery once; --fan-out-questions is refused' },
|
|
143
|
+
{ typesafe: 'noul (one hazard each)', thumbgate: 'existing secret / destructive / outbound / tamper matchers' },
|
|
144
|
+
{ typesafe: 'choice over a closed set', thumbgate: 'hazard_family composed in code from which noul fired' },
|
|
145
|
+
{ typesafe: 'score rubric', thumbgate: 'severity 0–3 from the same matchers' },
|
|
146
|
+
{ typesafe: 'route() in application code', thumbgate: 'pass | review | block — never a model-emitted verdict' },
|
|
147
|
+
{ typesafe: 'confidence as a second axis', thumbgate: 'warn-level noul=0.55 → review; high-stakes + severity≥2 → block' },
|
|
148
|
+
{ typesafe: 'Jev / typesafe-sdk / System One API', thumbgate: 'refused (ECI; #3690/#3687 LLM adjudicator parked)' },
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
function normalizeBoolean(value) {
|
|
152
|
+
if (value === true || value === 1) return true;
|
|
153
|
+
if (value === false || value === 0 || value == null) return false;
|
|
154
|
+
return /^(1|true|yes|on)$/i.test(String(value).trim());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function readText(filePath) {
|
|
158
|
+
if (!filePath) return null;
|
|
159
|
+
if (!fs.existsSync(filePath)) {
|
|
160
|
+
const err = new Error(`file not found: ${filePath}`);
|
|
161
|
+
err.code = 'ENOENT';
|
|
162
|
+
throw err;
|
|
163
|
+
}
|
|
164
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function flattenState(payload) {
|
|
168
|
+
if (payload == null) return '';
|
|
169
|
+
if (typeof payload === 'string') return payload;
|
|
170
|
+
const parts = [JSON.stringify(payload)];
|
|
171
|
+
if (payload.tool_name || payload.toolName) {
|
|
172
|
+
parts.push(String(payload.tool_name || payload.toolName));
|
|
173
|
+
}
|
|
174
|
+
const input = payload.tool_input || payload.toolInput || {};
|
|
175
|
+
if (input && typeof input === 'object') {
|
|
176
|
+
for (const key of ['command', 'file_path', 'path', 'content', 'prompt', 'url']) {
|
|
177
|
+
if (input[key] != null) parts.push(String(input[key]));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (payload.command) parts.push(String(payload.command));
|
|
181
|
+
return parts.join('\n');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function compileMatcher(source) {
|
|
185
|
+
if (!source) return null;
|
|
186
|
+
try {
|
|
187
|
+
return new RegExp(source, 'i');
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function toolNameOf(payload) {
|
|
194
|
+
return String((payload && (payload.tool_name || payload.toolName)) || '').trim();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function commandOf(payload) {
|
|
198
|
+
const input = (payload && (payload.tool_input || payload.toolInput)) || {};
|
|
199
|
+
return String((input && input.command) || (payload && payload.command) || '');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isWriteCapable(question, payload) {
|
|
203
|
+
const allowed = Array.isArray(question.writeTools) ? question.writeTools : null;
|
|
204
|
+
if (!allowed || allowed.length === 0) return true;
|
|
205
|
+
const name = toolNameOf(payload);
|
|
206
|
+
const hit = allowed.some((tool) => String(tool).toLowerCase() === name.toLowerCase());
|
|
207
|
+
if (!hit) return false;
|
|
208
|
+
if (name.toLowerCase() === 'bash' && question.writeCommandMatcher) {
|
|
209
|
+
const re = compileMatcher(question.writeCommandMatcher);
|
|
210
|
+
if (re && !re.test(commandOf(payload))) return false;
|
|
211
|
+
}
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function noulFromMatchers(blob, question, payload) {
|
|
216
|
+
if (!isWriteCapable(question, payload || {})) {
|
|
217
|
+
return { noul: 0, band: 'pass' };
|
|
218
|
+
}
|
|
219
|
+
const hard = compileMatcher(question.matcher);
|
|
220
|
+
const warn = compileMatcher(question.warnMatcher);
|
|
221
|
+
if (hard && hard.test(blob)) return { noul: 1, band: 'action' };
|
|
222
|
+
if (warn && warn.test(blob)) return { noul: 0.55, band: 'review' };
|
|
223
|
+
return { noul: 0, band: 'pass' };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function actionForNoul(id, question) {
|
|
227
|
+
if (HAZARD_ACTION[id]) return HAZARD_ACTION[id];
|
|
228
|
+
const action = String((question && question.action) || '').toLowerCase();
|
|
229
|
+
if (ROUTE_ACTIONS.includes(action)) return action;
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function validateQuestion(id, question) {
|
|
234
|
+
const errors = [];
|
|
235
|
+
if (!question || typeof question !== 'object') {
|
|
236
|
+
errors.push({ id: 'malformed_question', questionId: id, message: `Question ${id} is not an object.` });
|
|
237
|
+
return errors;
|
|
238
|
+
}
|
|
239
|
+
const type = String(question.type || '').toLowerCase();
|
|
240
|
+
if (!TYPED_KINDS.includes(type)) {
|
|
241
|
+
errors.push({
|
|
242
|
+
id: 'freeform_question',
|
|
243
|
+
questionId: id,
|
|
244
|
+
message: `Question ${id} type=${question.type || '(missing)'} is not noul|choice|score. Free-form judges are refused.`,
|
|
245
|
+
});
|
|
246
|
+
return errors;
|
|
247
|
+
}
|
|
248
|
+
if (!question.instructions) {
|
|
249
|
+
errors.push({ id: 'missing_instructions', questionId: id, message: `Question ${id} is missing instructions.` });
|
|
250
|
+
}
|
|
251
|
+
if (type === 'noul') {
|
|
252
|
+
if (actionForNoul(id, question) == null) {
|
|
253
|
+
errors.push({
|
|
254
|
+
id: 'noul_without_route_action',
|
|
255
|
+
questionId: id,
|
|
256
|
+
message: `Noul ${id} has no route action. Use a built-in hazard id or set action=block|review|pass.`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
const c = question.criteria || {};
|
|
260
|
+
if (c.true == null || c.false == null) {
|
|
261
|
+
errors.push({
|
|
262
|
+
id: 'noul_criteria_shape',
|
|
263
|
+
questionId: id,
|
|
264
|
+
message: `Noul ${id} needs criteria.true and criteria.false.`,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (type === 'choice') {
|
|
269
|
+
const c = question.criteria;
|
|
270
|
+
if (!c || typeof c !== 'object' || Array.isArray(c) || Object.keys(c).length < 2) {
|
|
271
|
+
errors.push({
|
|
272
|
+
id: 'choice_criteria_shape',
|
|
273
|
+
questionId: id,
|
|
274
|
+
message: `Choice ${id} needs a map of at least two options.`,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (type === 'score') {
|
|
279
|
+
if (!Array.isArray(question.criteria) || question.criteria.length < 2) {
|
|
280
|
+
errors.push({
|
|
281
|
+
id: 'score_criteria_shape',
|
|
282
|
+
questionId: id,
|
|
283
|
+
message: `Score ${id} needs an ordered criteria array of at least two levels.`,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return errors;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function parseBattery(raw) {
|
|
291
|
+
if (raw == null || raw === '') {
|
|
292
|
+
return { ok: true, battery: { ...DEFAULT_BATTERY }, error: null };
|
|
293
|
+
}
|
|
294
|
+
let data = raw;
|
|
295
|
+
if (typeof raw === 'string') {
|
|
296
|
+
try {
|
|
297
|
+
data = JSON.parse(raw);
|
|
298
|
+
} catch {
|
|
299
|
+
return { ok: false, battery: null, error: 'battery_parse_error' };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
303
|
+
return { ok: false, battery: null, error: 'battery_shape_error' };
|
|
304
|
+
}
|
|
305
|
+
const questions = data.questions && typeof data.questions === 'object' ? data.questions : data;
|
|
306
|
+
if (!questions || typeof questions !== 'object' || Array.isArray(questions) || Object.keys(questions).length === 0) {
|
|
307
|
+
return { ok: false, battery: null, error: 'empty_battery' };
|
|
308
|
+
}
|
|
309
|
+
return { ok: true, battery: questions, error: null };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function parsePayload(raw) {
|
|
313
|
+
if (raw == null || raw === '') return { ok: true, payload: {}, error: null };
|
|
314
|
+
if (typeof raw === 'object') return { ok: true, payload: raw, error: null };
|
|
315
|
+
try {
|
|
316
|
+
return { ok: true, payload: JSON.parse(raw), error: null };
|
|
317
|
+
} catch {
|
|
318
|
+
return { ok: true, payload: { command: String(raw) }, error: null };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function composeHazardFamily(nouls) {
|
|
323
|
+
if ((nouls.secret_exfil || 0) >= 0.7) return 'secret';
|
|
324
|
+
if ((nouls.guardrail_tamper || 0) >= 0.7) return 'tamper';
|
|
325
|
+
if ((nouls.clone_jev || 0) >= 0.7) return 'clone';
|
|
326
|
+
if ((nouls.outbound_send || 0) >= 0.7) return 'outbound';
|
|
327
|
+
if ((nouls.destructive || 0) >= 0.7) return 'destructive';
|
|
328
|
+
if ((nouls.destructive || 0) >= 0.35) return 'destructive';
|
|
329
|
+
return 'none';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function composeSeverity(nouls) {
|
|
333
|
+
if ((nouls.secret_exfil || 0) >= 0.7 || (nouls.guardrail_tamper || 0) >= 0.7) return 3;
|
|
334
|
+
if ((nouls.clone_jev || 0) >= 0.7 || (nouls.outbound_send || 0) >= 0.7 || (nouls.destructive || 0) >= 0.7) {
|
|
335
|
+
return 2;
|
|
336
|
+
}
|
|
337
|
+
const anyReview = Object.values(nouls).some((v) => Number(v) >= 0.35);
|
|
338
|
+
return anyReview ? 1 : 0;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function route({ nouls, severity, policy, battery }) {
|
|
342
|
+
const triggered = [];
|
|
343
|
+
for (const [hazard, probability] of Object.entries(nouls)) {
|
|
344
|
+
const action = actionForNoul(hazard, battery && battery[hazard]);
|
|
345
|
+
if (!action || action === 'pass') continue;
|
|
346
|
+
if (probability >= policy.actionThreshold) triggered.push(action);
|
|
347
|
+
else if (probability >= policy.reviewThreshold) triggered.push('review');
|
|
348
|
+
}
|
|
349
|
+
if (severity >= policy.severityBlock) {
|
|
350
|
+
for (let i = 0; i < triggered.length; i += 1) {
|
|
351
|
+
if (triggered[i] === 'review') triggered[i] = 'block';
|
|
352
|
+
}
|
|
353
|
+
if (triggered.length === 0 && severity >= policy.severityBlock) triggered.push('block');
|
|
354
|
+
}
|
|
355
|
+
return ROUTE_PRECEDENCE.find((action) => triggered.includes(action)) || 'pass';
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function evaluateBattery({ battery, blob, flags, payload }) {
|
|
359
|
+
const nouls = {};
|
|
360
|
+
const answers = {};
|
|
361
|
+
const unevaluated = [];
|
|
362
|
+
|
|
363
|
+
for (const [id, question] of Object.entries(battery)) {
|
|
364
|
+
if (!question || typeof question !== 'object') continue;
|
|
365
|
+
const type = String(question.type || '').toLowerCase();
|
|
366
|
+
if (type === 'noul') {
|
|
367
|
+
let result;
|
|
368
|
+
if (flags.cloneJev && id === 'clone_jev') result = { noul: 1, band: 'action' };
|
|
369
|
+
else if (flags.useTypesafeApi && id === 'clone_jev') result = { noul: 1, band: 'action' };
|
|
370
|
+
else if (flags.llmAdjudicate && id === 'clone_jev') result = { noul: 1, band: 'action' };
|
|
371
|
+
else if (question.matcher || question.warnMatcher) {
|
|
372
|
+
result = noulFromMatchers(blob, question, payload);
|
|
373
|
+
} else if (DEFAULT_BATTERY[id] && DEFAULT_BATTERY[id].matcher) {
|
|
374
|
+
result = noulFromMatchers(blob, DEFAULT_BATTERY[id], payload);
|
|
375
|
+
} else {
|
|
376
|
+
unevaluated.push(id);
|
|
377
|
+
result = { noul: null, band: 'unevaluated' };
|
|
378
|
+
}
|
|
379
|
+
nouls[id] = result.noul == null ? 0 : result.noul;
|
|
380
|
+
answers[id] = {
|
|
381
|
+
type: 'noul',
|
|
382
|
+
noul: result.noul,
|
|
383
|
+
band: result.band,
|
|
384
|
+
source: 'deterministic',
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (flags.cloneJev || flags.useTypesafeApi || flags.llmAdjudicate || CLONE_RE.test(blob)) {
|
|
390
|
+
nouls.clone_jev = 1;
|
|
391
|
+
answers.clone_jev = {
|
|
392
|
+
type: 'noul',
|
|
393
|
+
noul: 1,
|
|
394
|
+
band: 'action',
|
|
395
|
+
source: 'deterministic',
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const family = composeHazardFamily(nouls);
|
|
400
|
+
const severity = composeSeverity(nouls);
|
|
401
|
+
|
|
402
|
+
if (battery.hazard_family && String(battery.hazard_family.type).toLowerCase() === 'choice') {
|
|
403
|
+
const options = Object.keys(battery.hazard_family.criteria || {});
|
|
404
|
+
const choice = options.includes(family) ? family : options[0] || family;
|
|
405
|
+
answers.hazard_family = {
|
|
406
|
+
type: 'choice',
|
|
407
|
+
choice,
|
|
408
|
+
confidence: 1,
|
|
409
|
+
source: 'code',
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
if (battery.severity && String(battery.severity.type).toLowerCase() === 'score') {
|
|
413
|
+
answers.severity = {
|
|
414
|
+
type: 'score',
|
|
415
|
+
score: severity,
|
|
416
|
+
confidence: 1,
|
|
417
|
+
source: 'code',
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return { nouls, answers, unevaluated, family, severity };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function normalizeOptions(raw = {}) {
|
|
425
|
+
const rootDir = path.resolve(String(raw.root || raw.rootDir || process.cwd()));
|
|
426
|
+
const payloadPath = raw.payload
|
|
427
|
+
? path.resolve(rootDir, String(raw.payload))
|
|
428
|
+
: raw.payloadPath
|
|
429
|
+
? path.resolve(rootDir, String(raw.payloadPath))
|
|
430
|
+
: null;
|
|
431
|
+
const batteryPath = raw.battery
|
|
432
|
+
? path.resolve(rootDir, String(raw.battery))
|
|
433
|
+
: raw.batteryPath
|
|
434
|
+
? path.resolve(rootDir, String(raw.batteryPath))
|
|
435
|
+
: null;
|
|
436
|
+
const policyName = String(raw.policy || 'strict').toLowerCase();
|
|
437
|
+
return {
|
|
438
|
+
rootDir,
|
|
439
|
+
payloadPath,
|
|
440
|
+
batteryPath,
|
|
441
|
+
payloadText: raw.payloadText != null ? String(raw.payloadText) : null,
|
|
442
|
+
batteryText: raw.batteryText != null ? String(raw.batteryText) : null,
|
|
443
|
+
toolName: raw['tool-name'] || raw.toolName || null,
|
|
444
|
+
command: raw.command || null,
|
|
445
|
+
policyName: POLICIES[policyName] ? policyName : 'strict',
|
|
446
|
+
mapOnly: normalizeBoolean(raw['map-only'] || raw.mapOnly),
|
|
447
|
+
claimReady: normalizeBoolean(raw['claim-ready'] || raw.claimReady),
|
|
448
|
+
cloneJev: normalizeBoolean(raw['clone-jev'] || raw.cloneJev),
|
|
449
|
+
useTypesafeApi: normalizeBoolean(raw['use-typesafe-api'] || raw.useTypesafeApi),
|
|
450
|
+
llmAdjudicate: normalizeBoolean(raw['llm-adjudicate'] || raw.llmAdjudicate),
|
|
451
|
+
modelEmittedVerdict: raw['model-emitted-verdict'] || raw.modelEmittedVerdict || null,
|
|
452
|
+
live: normalizeBoolean(raw.live),
|
|
453
|
+
fanOutQuestions: normalizeBoolean(raw['fan-out-questions'] || raw.fanOutQuestions),
|
|
454
|
+
apiKey: Object.prototype.hasOwnProperty.call(raw, 'apiKey')
|
|
455
|
+
? raw.apiKey
|
|
456
|
+
: (raw['api-key'] || undefined),
|
|
457
|
+
fetchImpl: typeof raw.fetchImpl === 'function' ? raw.fetchImpl : undefined,
|
|
458
|
+
strict: normalizeBoolean(raw.strict),
|
|
459
|
+
json: normalizeBoolean(raw.json),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function loadTypesafeApiKey() {
|
|
464
|
+
const env = process.env.TYPESAFE_API_KEY;
|
|
465
|
+
if (env && String(env).trim()) return String(env).trim();
|
|
466
|
+
const fallback = path.join(os.homedir(), '.resume_secrets', 'TYPESAFE_API_KEY');
|
|
467
|
+
if (!fs.existsSync(fallback)) return null;
|
|
468
|
+
const text = fs.readFileSync(fallback, 'utf8').trim();
|
|
469
|
+
return text || null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function parallelReceipt(questions, usage) {
|
|
473
|
+
const questionCount = Object.keys(questions || {}).length;
|
|
474
|
+
const inputTokens = usage && usage.input_tokens != null ? Number(usage.input_tokens) : null;
|
|
475
|
+
const estimatedFanoutInputTokens = inputTokens != null && questionCount > 0
|
|
476
|
+
? inputTokens * questionCount
|
|
477
|
+
: null;
|
|
478
|
+
return {
|
|
479
|
+
cookbook: PARALLEL_QUESTIONS_COOKBOOK,
|
|
480
|
+
batchedCalls: 1,
|
|
481
|
+
questionCount,
|
|
482
|
+
estimatedFanoutCalls: questionCount,
|
|
483
|
+
inputTokens,
|
|
484
|
+
estimatedFanoutInputTokens,
|
|
485
|
+
estimatedTokenFactor: questionCount || null,
|
|
486
|
+
note: 'Estimated fan-out cost is N× this call\'s input tokens (document-dominated). Not a measured 12.2× cookbook number.',
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function questionsForApi(battery) {
|
|
491
|
+
const out = {};
|
|
492
|
+
for (const [id, question] of Object.entries(battery || {})) {
|
|
493
|
+
if (!question || typeof question !== 'object') continue;
|
|
494
|
+
const type = String(question.type || '').toLowerCase();
|
|
495
|
+
if (!TYPED_KINDS.includes(type) || !question.instructions) continue;
|
|
496
|
+
const entry = { type, instructions: question.instructions };
|
|
497
|
+
if (question.criteria != null) entry.criteria = question.criteria;
|
|
498
|
+
out[id] = entry;
|
|
499
|
+
}
|
|
500
|
+
return out;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function callSystemOne({
|
|
504
|
+
state,
|
|
505
|
+
questions,
|
|
506
|
+
apiKey,
|
|
507
|
+
model = 'jev-latest',
|
|
508
|
+
fetchImpl,
|
|
509
|
+
} = {}) {
|
|
510
|
+
if (!apiKey) {
|
|
511
|
+
const err = new Error('missing_api_key');
|
|
512
|
+
err.code = 'missing_api_key';
|
|
513
|
+
throw err;
|
|
514
|
+
}
|
|
515
|
+
const fetchFn = typeof fetchImpl === 'function' ? fetchImpl : globalThis.fetch;
|
|
516
|
+
if (typeof fetchFn !== 'function') {
|
|
517
|
+
const err = new Error('fetch_unavailable');
|
|
518
|
+
err.code = 'fetch_unavailable';
|
|
519
|
+
throw err;
|
|
520
|
+
}
|
|
521
|
+
const res = await fetchFn(SYSTEMONE_URL, {
|
|
522
|
+
method: 'POST',
|
|
523
|
+
headers: {
|
|
524
|
+
Authorization: `Bearer ${apiKey}`,
|
|
525
|
+
'Content-Type': 'application/json',
|
|
526
|
+
},
|
|
527
|
+
body: JSON.stringify({ state, model, questions }),
|
|
528
|
+
});
|
|
529
|
+
const text = await res.text();
|
|
530
|
+
let body = null;
|
|
531
|
+
try {
|
|
532
|
+
body = text ? JSON.parse(text) : null;
|
|
533
|
+
} catch {
|
|
534
|
+
body = null;
|
|
535
|
+
}
|
|
536
|
+
if (!res.ok) {
|
|
537
|
+
const err = new Error(`systemone_http_${res.status}`);
|
|
538
|
+
err.code = res.status === 401 ? 'live_unauthorized' : 'live_http_error';
|
|
539
|
+
err.status = res.status;
|
|
540
|
+
throw err;
|
|
541
|
+
}
|
|
542
|
+
return body;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function compareShadow(detAnswers, jevAnswers) {
|
|
546
|
+
const divergences = [];
|
|
547
|
+
const jev = jevAnswers && typeof jevAnswers === 'object' ? jevAnswers : {};
|
|
548
|
+
for (const [id, det] of Object.entries(detAnswers || {})) {
|
|
549
|
+
const live = jev[id];
|
|
550
|
+
if (!live || !det) continue;
|
|
551
|
+
if (det.type === 'noul' && live.type === 'noul' && det.noul != null && live.noul != null) {
|
|
552
|
+
const delta = Math.abs(Number(live.noul) - Number(det.noul));
|
|
553
|
+
if (delta >= SHADOW_NOUL_DELTA) {
|
|
554
|
+
divergences.push({
|
|
555
|
+
id,
|
|
556
|
+
type: 'noul',
|
|
557
|
+
deterministic: Number(det.noul),
|
|
558
|
+
jev: Number(live.noul),
|
|
559
|
+
delta,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (det.type === 'choice' && live.type === 'choice' && det.choice && live.choice && det.choice !== live.choice) {
|
|
564
|
+
divergences.push({
|
|
565
|
+
id,
|
|
566
|
+
type: 'choice',
|
|
567
|
+
deterministic: det.choice,
|
|
568
|
+
jev: live.choice,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
if (det.type === 'score' && live.type === 'score' && det.score != null && live.score != null) {
|
|
572
|
+
const delta = Math.abs(Number(live.score) - Number(det.score));
|
|
573
|
+
if (delta >= 1) {
|
|
574
|
+
divergences.push({
|
|
575
|
+
id,
|
|
576
|
+
type: 'score',
|
|
577
|
+
deterministic: Number(det.score),
|
|
578
|
+
jev: Number(live.score),
|
|
579
|
+
delta,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return divergences;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function normalizeMetric(answer, question = {}) {
|
|
588
|
+
if (!answer || typeof answer !== 'object') return null;
|
|
589
|
+
const type = String(answer.type || (question && question.type) || '').toLowerCase();
|
|
590
|
+
if (type === 'noul') {
|
|
591
|
+
return answer.noul != null ? Number(answer.noul) : null;
|
|
592
|
+
}
|
|
593
|
+
if (type === 'choice') {
|
|
594
|
+
if (answer.probabilities && typeof answer.probabilities === 'object') {
|
|
595
|
+
const probs = Object.values(answer.probabilities).map(Number).filter((n) => !Number.isNaN(n));
|
|
596
|
+
if (probs.length) return Math.max(...probs);
|
|
597
|
+
}
|
|
598
|
+
return answer.confidence != null ? Number(answer.confidence) : 1.0;
|
|
599
|
+
}
|
|
600
|
+
if (type === 'score') {
|
|
601
|
+
const raw = answer.score != null ? Number(answer.score) : 0;
|
|
602
|
+
const criteria = question && Array.isArray(question.criteria) ? question.criteria : null;
|
|
603
|
+
const maxLevel = criteria && criteria.length > 1 ? criteria.length - 1 : 3;
|
|
604
|
+
return maxLevel > 0 ? Number((raw / maxLevel).toFixed(4)) : raw;
|
|
605
|
+
}
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function computeVariance(runs) {
|
|
610
|
+
if (!Array.isArray(runs) || runs.length < 2) return {};
|
|
611
|
+
const stats = {};
|
|
612
|
+
const keys = Object.keys(runs[0] || {});
|
|
613
|
+
for (const key of keys) {
|
|
614
|
+
const values = runs.map((r) => Number(r[key])).filter((v) => !Number.isNaN(v));
|
|
615
|
+
if (values.length < 2) continue;
|
|
616
|
+
const mean = values.reduce((sum, v) => sum + v, 0) / values.length;
|
|
617
|
+
const variance = values.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / (values.length - 1);
|
|
618
|
+
const stdDev = Math.sqrt(variance);
|
|
619
|
+
stats[key] = {
|
|
620
|
+
mean: Number(mean.toFixed(4)),
|
|
621
|
+
stdDev: Number(stdDev.toFixed(4)),
|
|
622
|
+
runs: values.length,
|
|
623
|
+
zeroVariance: stdDev === 0,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
return stats;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function attachLiveShadow(report, options = {}) {
|
|
630
|
+
if (options.fanOutQuestions) {
|
|
631
|
+
report.findings.push({
|
|
632
|
+
id: 'parallel_fanout_refused',
|
|
633
|
+
severity: 'fail',
|
|
634
|
+
gateId: 'require-typed-pretool-questions',
|
|
635
|
+
message: 'Refused --fan-out-questions. Parallel-questions cookbook: one POST with all N questions, not N calls. Do not re-pay the document N times.',
|
|
636
|
+
});
|
|
637
|
+
report.status = 'fail';
|
|
638
|
+
report.ok = false;
|
|
639
|
+
report.parallel = {
|
|
640
|
+
cookbook: PARALLEL_QUESTIONS_COOKBOOK,
|
|
641
|
+
batchedCalls: 0,
|
|
642
|
+
refusedFanOut: true,
|
|
643
|
+
};
|
|
644
|
+
return report;
|
|
645
|
+
}
|
|
646
|
+
if (!options.live || options.mapOnly || options.useTypesafeApi) return report;
|
|
647
|
+
const apiKey = options.apiKey !== undefined ? options.apiKey : loadTypesafeApiKey();
|
|
648
|
+
if (!apiKey) {
|
|
649
|
+
report.findings.push({
|
|
650
|
+
id: 'live_key_missing',
|
|
651
|
+
severity: 'fail',
|
|
652
|
+
gateId: 'require-code-owned-route',
|
|
653
|
+
message: '--live needs TYPESAFE_API_KEY (env or ~/.resume_secrets/TYPESAFE_API_KEY). Key is never printed.',
|
|
654
|
+
});
|
|
655
|
+
report.status = 'fail';
|
|
656
|
+
report.ok = false;
|
|
657
|
+
report.shadow = { used: false, reason: 'missing_api_key' };
|
|
658
|
+
return report;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const battery = parseBattery(options.batteryText || '').battery || DEFAULT_BATTERY;
|
|
662
|
+
const questions = questionsForApi(battery);
|
|
663
|
+
try {
|
|
664
|
+
const body = await callSystemOne({
|
|
665
|
+
state: options.payloadText
|
|
666
|
+
? parsePayload(options.payloadText).payload
|
|
667
|
+
: {
|
|
668
|
+
tool_name: options.toolName,
|
|
669
|
+
tool_input: { command: options.command },
|
|
670
|
+
},
|
|
671
|
+
questions,
|
|
672
|
+
apiKey,
|
|
673
|
+
fetchImpl: options.fetchImpl,
|
|
674
|
+
});
|
|
675
|
+
const jevAnswers = (body && body.answers) || {};
|
|
676
|
+
const divergences = compareShadow(report.answers, jevAnswers);
|
|
677
|
+
report.parallel = parallelReceipt(questions, body && body.usage);
|
|
678
|
+
report.shadow = {
|
|
679
|
+
used: true,
|
|
680
|
+
ownsRoute: false,
|
|
681
|
+
model: body && body.model,
|
|
682
|
+
usage: body && body.usage,
|
|
683
|
+
answers: jevAnswers,
|
|
684
|
+
divergences,
|
|
685
|
+
};
|
|
686
|
+
for (const d of divergences) {
|
|
687
|
+
report.findings.push({
|
|
688
|
+
id: 'shadow_divergence',
|
|
689
|
+
severity: 'warn',
|
|
690
|
+
gateId: 'require-typed-pretool-questions',
|
|
691
|
+
questionId: d.id,
|
|
692
|
+
message: `Jev shadow disagrees with deterministic ${d.type} on ${d.id} (code still owns route=${report.route}).`,
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
if (divergences.length && report.status === 'ready') report.status = 'actionable';
|
|
696
|
+
} catch (err) {
|
|
697
|
+
const id = err && err.code === 'live_unauthorized' ? 'live_unauthorized' : 'live_http_error';
|
|
698
|
+
const status = err && err.status ? Number(err.status) : null;
|
|
699
|
+
const raw = String((err && err.message) || '');
|
|
700
|
+
const safeMsg = /apikey_|bearer\s/i.test(raw) ? 'redacted' : raw.slice(0, 160);
|
|
701
|
+
const errorCode = err && (err.code || (err.cause && err.cause.code));
|
|
702
|
+
report.findings.push({
|
|
703
|
+
id,
|
|
704
|
+
severity: 'fail',
|
|
705
|
+
gateId: 'require-code-owned-route',
|
|
706
|
+
message: `Live System One call failed (HTTP ${status || 'unknown'}${safeMsg ? `: ${safeMsg}` : ''}). Route stays deterministic. Secret is not logged.`,
|
|
707
|
+
});
|
|
708
|
+
report.status = 'fail';
|
|
709
|
+
report.ok = false;
|
|
710
|
+
report.shadow = {
|
|
711
|
+
used: false,
|
|
712
|
+
reason: id,
|
|
713
|
+
status,
|
|
714
|
+
errorName: err && err.name,
|
|
715
|
+
errorCode: errorCode || null,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
return report;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async function buildTypesafeTypedQuestionsReportAsync(rawOptions = {}) {
|
|
722
|
+
const report = buildTypesafeTypedQuestionsReport(rawOptions);
|
|
723
|
+
const options = normalizeOptions(rawOptions);
|
|
724
|
+
if (options.fanOutQuestions) return report;
|
|
725
|
+
if (!options.live || options.mapOnly) return report;
|
|
726
|
+
const payloadText = options.payloadText || JSON.stringify({
|
|
727
|
+
tool_name: options.toolName,
|
|
728
|
+
tool_input: { command: options.command },
|
|
729
|
+
});
|
|
730
|
+
await attachLiveShadow(report, {
|
|
731
|
+
live: true,
|
|
732
|
+
mapOnly: false,
|
|
733
|
+
useTypesafeApi: options.useTypesafeApi,
|
|
734
|
+
fanOutQuestions: options.fanOutQuestions,
|
|
735
|
+
apiKey: options.apiKey,
|
|
736
|
+
fetchImpl: options.fetchImpl,
|
|
737
|
+
batteryText: options.batteryText,
|
|
738
|
+
payloadText,
|
|
739
|
+
toolName: options.toolName,
|
|
740
|
+
command: options.command,
|
|
741
|
+
});
|
|
742
|
+
return report;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function buildFindings({
|
|
746
|
+
batteryParse,
|
|
747
|
+
payloadParse,
|
|
748
|
+
questionErrors,
|
|
749
|
+
unevaluated,
|
|
750
|
+
flags,
|
|
751
|
+
blob,
|
|
752
|
+
answers,
|
|
753
|
+
}) {
|
|
754
|
+
const findings = [];
|
|
755
|
+
if (!batteryParse.ok) {
|
|
756
|
+
findings.push({
|
|
757
|
+
id: batteryParse.error || 'battery_error',
|
|
758
|
+
severity: 'fail',
|
|
759
|
+
gateId: 'require-typed-pretool-questions',
|
|
760
|
+
message: `Battery failed: ${batteryParse.error}.`,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
if (!payloadParse.ok) {
|
|
764
|
+
findings.push({
|
|
765
|
+
id: payloadParse.error || 'payload_error',
|
|
766
|
+
severity: 'fail',
|
|
767
|
+
gateId: 'require-typed-pretool-questions',
|
|
768
|
+
message: `Payload failed: ${payloadParse.error}.`,
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
for (const err of questionErrors) {
|
|
772
|
+
findings.push({
|
|
773
|
+
id: err.id,
|
|
774
|
+
severity: 'fail',
|
|
775
|
+
gateId: 'require-typed-pretool-questions',
|
|
776
|
+
questionId: err.questionId,
|
|
777
|
+
message: err.message,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
for (const id of unevaluated) {
|
|
781
|
+
findings.push({
|
|
782
|
+
id: 'unevaluated_question',
|
|
783
|
+
severity: 'fail',
|
|
784
|
+
gateId: 'require-code-owned-route',
|
|
785
|
+
questionId: id,
|
|
786
|
+
message: `Question ${id} has no matcher and no deterministic evaluator. Do not call Jev to fill it.`,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
if (flags.cloneJev) {
|
|
790
|
+
findings.push({
|
|
791
|
+
id: 'jev_sku_clone',
|
|
792
|
+
severity: 'fail',
|
|
793
|
+
gateId: 'require-code-owned-route',
|
|
794
|
+
message: 'Refused --clone-jev. TypeSafe/Jev is FORMAT only; do not clone System One as a ThumbGate SKU.',
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
if (flags.useTypesafeApi) {
|
|
798
|
+
findings.push({
|
|
799
|
+
id: 'typesafe_api_refused',
|
|
800
|
+
severity: 'fail',
|
|
801
|
+
gateId: 'require-code-owned-route',
|
|
802
|
+
message: 'Refused --use-typesafe-api. Do not call api.typesafe.ai from PreToolUse (ECI; LLM adjudicator parked).',
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
if (flags.fanOutQuestions) {
|
|
806
|
+
findings.push({
|
|
807
|
+
id: 'parallel_fanout_refused',
|
|
808
|
+
severity: 'fail',
|
|
809
|
+
gateId: 'require-typed-pretool-questions',
|
|
810
|
+
message: 'Refused --fan-out-questions. Parallel-questions cookbook: one POST with all N questions, not N calls.',
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
if (flags.llmAdjudicate) {
|
|
814
|
+
findings.push({
|
|
815
|
+
id: 'llm_adjudicator_parked',
|
|
816
|
+
severity: 'fail',
|
|
817
|
+
gateId: 'require-code-owned-route',
|
|
818
|
+
message: 'Refused --llm-adjudicate. Issues #3690/#3687 stay parked. Deterministic typed questions only.',
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
if (CLONE_RE.test(blob)) {
|
|
822
|
+
findings.push({
|
|
823
|
+
id: 'typesafe_clone_signal',
|
|
824
|
+
severity: 'fail',
|
|
825
|
+
gateId: 'require-code-owned-route',
|
|
826
|
+
message: 'Payload asks to install typesafe-sdk, call api.typesafe.ai, clone Jev, or wire an LLM adjudicator.',
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
if (flags.modelEmittedVerdict) {
|
|
830
|
+
findings.push({
|
|
831
|
+
id: 'model_emitted_verdict',
|
|
832
|
+
severity: 'fail',
|
|
833
|
+
gateId: 'require-code-owned-route',
|
|
834
|
+
message: `Refused model-emitted verdict=${flags.modelEmittedVerdict}. Code owns route(); the model does not.`,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
if (answers.hazard_family && answers.hazard_family.source !== 'code') {
|
|
838
|
+
findings.push({
|
|
839
|
+
id: 'choice_not_composed_in_code',
|
|
840
|
+
severity: 'fail',
|
|
841
|
+
gateId: 'require-code-owned-route',
|
|
842
|
+
message: 'hazard_family must be composed in code from noul answers.',
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
return findings;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function buildTypesafeTypedQuestionsReport(rawOptions = {}) {
|
|
849
|
+
const options = normalizeOptions(rawOptions);
|
|
850
|
+
const ioErrors = [];
|
|
851
|
+
|
|
852
|
+
let batteryRaw = options.batteryText;
|
|
853
|
+
if (batteryRaw == null && options.batteryPath) {
|
|
854
|
+
try {
|
|
855
|
+
batteryRaw = readText(options.batteryPath);
|
|
856
|
+
} catch (err) {
|
|
857
|
+
ioErrors.push({ id: 'battery_read_error', message: err.message });
|
|
858
|
+
batteryRaw = '';
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
let payloadRaw = options.payloadText;
|
|
863
|
+
if (payloadRaw == null && options.payloadPath) {
|
|
864
|
+
try {
|
|
865
|
+
payloadRaw = readText(options.payloadPath);
|
|
866
|
+
} catch (err) {
|
|
867
|
+
ioErrors.push({ id: 'payload_read_error', message: err.message });
|
|
868
|
+
payloadRaw = '';
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const batteryParse = parseBattery(batteryRaw);
|
|
873
|
+
const payloadParse = parsePayload(payloadRaw);
|
|
874
|
+
const payload = payloadParse.payload || {};
|
|
875
|
+
if (options.toolName) payload.tool_name = options.toolName;
|
|
876
|
+
if (options.command) {
|
|
877
|
+
payload.tool_input = { ...(payload.tool_input || {}), command: options.command };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const battery = batteryParse.ok ? batteryParse.battery : { ...DEFAULT_BATTERY };
|
|
881
|
+
const questionErrors = [];
|
|
882
|
+
if (batteryParse.ok) {
|
|
883
|
+
for (const [id, question] of Object.entries(battery)) {
|
|
884
|
+
questionErrors.push(...validateQuestion(id, question));
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const blob = flattenState(payload);
|
|
889
|
+
const flags = {
|
|
890
|
+
cloneJev: options.cloneJev,
|
|
891
|
+
useTypesafeApi: options.useTypesafeApi,
|
|
892
|
+
llmAdjudicate: options.llmAdjudicate,
|
|
893
|
+
modelEmittedVerdict: options.modelEmittedVerdict,
|
|
894
|
+
fanOutQuestions: options.fanOutQuestions,
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
const evaluated = options.mapOnly
|
|
898
|
+
? { nouls: {}, answers: {}, unevaluated: [], family: 'none', severity: 0 }
|
|
899
|
+
: evaluateBattery({ battery, blob, flags, payload });
|
|
900
|
+
|
|
901
|
+
const policy = POLICIES[options.policyName];
|
|
902
|
+
const composedRoute = options.mapOnly
|
|
903
|
+
? 'pass'
|
|
904
|
+
: route({
|
|
905
|
+
nouls: evaluated.nouls,
|
|
906
|
+
severity: evaluated.severity,
|
|
907
|
+
policy,
|
|
908
|
+
battery,
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
const findings = [
|
|
912
|
+
...ioErrors.map((e) => ({
|
|
913
|
+
id: e.id,
|
|
914
|
+
severity: 'fail',
|
|
915
|
+
gateId: 'require-typed-pretool-questions',
|
|
916
|
+
message: e.message,
|
|
917
|
+
})),
|
|
918
|
+
...buildFindings({
|
|
919
|
+
batteryParse,
|
|
920
|
+
payloadParse,
|
|
921
|
+
questionErrors,
|
|
922
|
+
unevaluated: evaluated.unevaluated,
|
|
923
|
+
flags,
|
|
924
|
+
blob,
|
|
925
|
+
answers: evaluated.answers,
|
|
926
|
+
}),
|
|
927
|
+
];
|
|
928
|
+
|
|
929
|
+
if (options.claimReady && findings.some((f) => f.severity === 'fail')) {
|
|
930
|
+
findings.push({
|
|
931
|
+
id: 'claim_without_code_owned_route',
|
|
932
|
+
severity: 'fail',
|
|
933
|
+
gateId: 'require-code-owned-route',
|
|
934
|
+
message: 'Claimed typed-question PreToolUse ready while clone/API/LLM-adjudicator or untyped questions remain.',
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const seen = new Set();
|
|
939
|
+
const deduped = [];
|
|
940
|
+
for (const f of findings) {
|
|
941
|
+
const key = `${f.id}:${f.questionId || ''}`;
|
|
942
|
+
if (seen.has(key)) continue;
|
|
943
|
+
seen.add(key);
|
|
944
|
+
deduped.push(f);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const failCount = deduped.filter((f) => f.severity === 'fail').length;
|
|
948
|
+
const warnCount = deduped.filter((f) => f.severity === 'warn').length;
|
|
949
|
+
let status = 'ready';
|
|
950
|
+
if (failCount > 0) status = 'fail';
|
|
951
|
+
else if (warnCount > 0) status = 'actionable';
|
|
952
|
+
|
|
953
|
+
const codeOwnsRoute = !flags.modelEmittedVerdict
|
|
954
|
+
&& !flags.useTypesafeApi
|
|
955
|
+
&& !flags.llmAdjudicate
|
|
956
|
+
&& !flags.cloneJev
|
|
957
|
+
&& evaluated.unevaluated.length === 0;
|
|
958
|
+
|
|
959
|
+
return {
|
|
960
|
+
name: 'thumbgate-typesafe-typed-questions',
|
|
961
|
+
ok: status !== 'fail',
|
|
962
|
+
status,
|
|
963
|
+
source: SOURCE_URLS[0],
|
|
964
|
+
sources: SOURCE_URLS,
|
|
965
|
+
disclaimer:
|
|
966
|
+
'FORMAT steal from TypeSafe (typed noul/choice/score + code-owned route + confidence as a second axis). Not affiliated with TypeSafe. Does not install typesafe-sdk, call Jev, or wire an LLM adjudicator.',
|
|
967
|
+
rootDir: options.rootDir,
|
|
968
|
+
policy: options.policyName,
|
|
969
|
+
codeOwnsRoute,
|
|
970
|
+
clonedJev: Boolean(flags.cloneJev || flags.useTypesafeApi || CLONE_RE.test(blob)),
|
|
971
|
+
llmAdjudicatorParked: true,
|
|
972
|
+
map: options.mapOnly ? RAIL_MAP : undefined,
|
|
973
|
+
metrics: {
|
|
974
|
+
payloadPath: options.payloadPath,
|
|
975
|
+
batteryPath: options.batteryPath,
|
|
976
|
+
questionCount: Object.values(battery).filter((q) => q && typeof q === 'object').length,
|
|
977
|
+
noulCount: Object.values(battery).filter((q) => q && String(q.type).toLowerCase() === 'noul').length,
|
|
978
|
+
choiceCount: Object.values(battery).filter((q) => q && String(q.type).toLowerCase() === 'choice').length,
|
|
979
|
+
scoreCount: Object.values(battery).filter((q) => q && String(q.type).toLowerCase() === 'score').length,
|
|
980
|
+
mapOnly: options.mapOnly,
|
|
981
|
+
claimReady: options.claimReady,
|
|
982
|
+
hazardFamily: evaluated.family,
|
|
983
|
+
severity: evaluated.severity,
|
|
984
|
+
},
|
|
985
|
+
answers: evaluated.answers,
|
|
986
|
+
route: composedRoute,
|
|
987
|
+
findings: deduped,
|
|
988
|
+
summary: {
|
|
989
|
+
failCount,
|
|
990
|
+
warnCount,
|
|
991
|
+
findingCount: deduped.length,
|
|
992
|
+
recommendedGateCount: [...new Set(deduped.map((f) => f.gateId).filter(Boolean))].length,
|
|
993
|
+
},
|
|
994
|
+
recommendedGates: [...new Set(deduped.map((f) => f.gateId).filter(Boolean))],
|
|
995
|
+
nextActions: [
|
|
996
|
+
'Ask independent noul/choice/score questions over the same PreToolUse state.',
|
|
997
|
+
'Compose pass|review|block in code from those answers — never let a model emit the verdict.',
|
|
998
|
+
'Treat confidence as a second axis: warn-level → review; high-stakes + severity ≥ 2 → block.',
|
|
999
|
+
'Do not install typesafe-sdk, call api.typesafe.ai, clone Jev, or unpark the LLM adjudicator.',
|
|
1000
|
+
'Pair with gates require-typed-pretool-questions and require-code-owned-route.',
|
|
1001
|
+
],
|
|
1002
|
+
exampleCommand:
|
|
1003
|
+
'npx thumbgate typesafe-typed-questions --tool-name=Bash --command="git push --force origin main" --json',
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function formatTypesafeTypedQuestionsReport(report) {
|
|
1008
|
+
const lines = [
|
|
1009
|
+
'',
|
|
1010
|
+
'ThumbGate TypeSafe Typed-Questions Doctor',
|
|
1011
|
+
'-'.repeat(48),
|
|
1012
|
+
`Status : ${report.status}`,
|
|
1013
|
+
`Route : ${report.route}`,
|
|
1014
|
+
`Policy : ${report.policy}`,
|
|
1015
|
+
`Code owns route : ${report.codeOwnsRoute}`,
|
|
1016
|
+
`Questions: ${report.metrics.questionCount} (noul=${report.metrics.noulCount}, choice=${report.metrics.choiceCount}, score=${report.metrics.scoreCount})`,
|
|
1017
|
+
`Family : ${report.metrics.hazardFamily} severity=${report.metrics.severity}`,
|
|
1018
|
+
`Findings : ${report.summary.findingCount} (fail=${report.summary.failCount}, warn=${report.summary.warnCount})`,
|
|
1019
|
+
`Source : ${report.source}`,
|
|
1020
|
+
];
|
|
1021
|
+
if (report.map) {
|
|
1022
|
+
lines.push('', 'Rail map:');
|
|
1023
|
+
for (const row of report.map) {
|
|
1024
|
+
lines.push(` - ${row.typesafe} → ${row.thumbgate}`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
if (report.findings.length) {
|
|
1028
|
+
lines.push('', 'Findings:');
|
|
1029
|
+
for (const f of report.findings) {
|
|
1030
|
+
const gate = f.gateId ? ` [${f.gateId}]` : '';
|
|
1031
|
+
const q = f.questionId ? ` ${f.questionId}` : '';
|
|
1032
|
+
lines.push(` - [${f.severity}] ${f.id}${q}${gate}`);
|
|
1033
|
+
lines.push(` ${f.message}`);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
lines.push('', 'Next actions:');
|
|
1037
|
+
for (const a of report.nextActions) lines.push(` - ${a}`);
|
|
1038
|
+
lines.push('', `Example: ${report.exampleCommand}`);
|
|
1039
|
+
lines.push(`Note: ${report.disclaimer}`, '');
|
|
1040
|
+
return `${lines.join('\n')}\n`;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function parseCliArgs(argv) {
|
|
1044
|
+
const options = {};
|
|
1045
|
+
for (const arg of argv) {
|
|
1046
|
+
if (arg === '--json') { options.json = true; continue; }
|
|
1047
|
+
if (arg === '--strict') { options.strict = true; continue; }
|
|
1048
|
+
if (arg === '--map-only') { options['map-only'] = true; continue; }
|
|
1049
|
+
if (arg === '--claim-ready') { options['claim-ready'] = true; continue; }
|
|
1050
|
+
if (arg === '--clone-jev') { options['clone-jev'] = true; continue; }
|
|
1051
|
+
if (arg === '--use-typesafe-api') { options['use-typesafe-api'] = true; continue; }
|
|
1052
|
+
if (arg === '--llm-adjudicate') { options['llm-adjudicate'] = true; continue; }
|
|
1053
|
+
if (arg === '--live') { options.live = true; continue; }
|
|
1054
|
+
if (arg === '--fan-out-questions') { options['fan-out-questions'] = true; continue; }
|
|
1055
|
+
if (arg === '--help' || arg === '-h') { options.help = true; continue; }
|
|
1056
|
+
const m = /^--([^=]+)(?:=(.*))?$/.exec(arg);
|
|
1057
|
+
if (!m) continue;
|
|
1058
|
+
options[m[1]] = m[2] === undefined ? true : m[2];
|
|
1059
|
+
}
|
|
1060
|
+
return options;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function printHelp() {
|
|
1064
|
+
process.stdout.write(`Usage: node scripts/typesafe-typed-questions.js [flags]
|
|
1065
|
+
|
|
1066
|
+
Flags:
|
|
1067
|
+
--payload=PATH PreToolUse JSON payload
|
|
1068
|
+
--battery=PATH Typed questions JSON (noul|choice|score)
|
|
1069
|
+
--tool-name=NAME Shortcut tool_name
|
|
1070
|
+
--command=TEXT Shortcut tool_input.command
|
|
1071
|
+
--policy=strict|permissive
|
|
1072
|
+
--map-only Print TypeSafe → ThumbGate rail map
|
|
1073
|
+
--claim-ready Fail unless code owns the route
|
|
1074
|
+
--clone-jev Always fail (SKU clone)
|
|
1075
|
+
--use-typesafe-api Always fail (Jev as the PreToolUse gate)
|
|
1076
|
+
--live Shadow the battery against api.typesafe.ai; one POST, all questions
|
|
1077
|
+
--fan-out-questions Always fail (cookbook: do not pay the document N times)
|
|
1078
|
+
--llm-adjudicate Always fail (#3690/#3687 parked)
|
|
1079
|
+
--model-emitted-verdict=V Always fail (code must own route)
|
|
1080
|
+
--root=DIR Repo root for relative paths
|
|
1081
|
+
--strict Exit 1 on fail/actionable
|
|
1082
|
+
--json
|
|
1083
|
+
|
|
1084
|
+
Sources: ${SOURCE_URLS.join(' ')}
|
|
1085
|
+
`);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
async function runCli(argv = process.argv.slice(2)) {
|
|
1089
|
+
const args = parseCliArgs(argv);
|
|
1090
|
+
if (args.help) {
|
|
1091
|
+
printHelp();
|
|
1092
|
+
return 0;
|
|
1093
|
+
}
|
|
1094
|
+
const report = await buildTypesafeTypedQuestionsReportAsync(args);
|
|
1095
|
+
if (args.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
1096
|
+
else process.stdout.write(formatTypesafeTypedQuestionsReport(report));
|
|
1097
|
+
if (args.strict && report.status !== 'ready') return 1;
|
|
1098
|
+
if (report.status === 'fail') return 1;
|
|
1099
|
+
return 0;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
module.exports = {
|
|
1103
|
+
SOURCE_URLS,
|
|
1104
|
+
DEFAULT_BATTERY,
|
|
1105
|
+
POLICIES,
|
|
1106
|
+
RAIL_MAP,
|
|
1107
|
+
flattenState,
|
|
1108
|
+
parseBattery,
|
|
1109
|
+
validateQuestion,
|
|
1110
|
+
noulFromMatchers,
|
|
1111
|
+
actionForNoul,
|
|
1112
|
+
composeHazardFamily,
|
|
1113
|
+
composeSeverity,
|
|
1114
|
+
route,
|
|
1115
|
+
SYSTEMONE_URL,
|
|
1116
|
+
loadTypesafeApiKey,
|
|
1117
|
+
questionsForApi,
|
|
1118
|
+
callSystemOne,
|
|
1119
|
+
compareShadow,
|
|
1120
|
+
attachLiveShadow,
|
|
1121
|
+
parallelReceipt,
|
|
1122
|
+
PARALLEL_QUESTIONS_COOKBOOK,
|
|
1123
|
+
normalizeMetric,
|
|
1124
|
+
computeVariance,
|
|
1125
|
+
buildTypesafeTypedQuestionsReport,
|
|
1126
|
+
buildTypesafeTypedQuestionsReportAsync,
|
|
1127
|
+
formatTypesafeTypedQuestionsReport,
|
|
1128
|
+
runCli,
|
|
1129
|
+
};
|
|
1130
|
+
|
|
1131
|
+
if (path.resolve(process.argv[1] || '') === path.resolve(__filename)) {
|
|
1132
|
+
runCli().then((code) => {
|
|
1133
|
+
process.exitCode = code;
|
|
1134
|
+
}).catch((err) => {
|
|
1135
|
+
process.stderr.write(`${err && err.message ? err.message : err}\n`);
|
|
1136
|
+
process.exitCode = 1;
|
|
1137
|
+
});
|
|
1138
|
+
}
|