thumbgate 0.9.14 → 1.1.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 (64) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.well-known/mcp/server-card.json +1 -1
  4. package/README.md +1 -0
  5. package/adapters/README.md +1 -1
  6. package/adapters/chatgpt/openapi.yaml +105 -0
  7. package/adapters/claude/.mcp.json +2 -2
  8. package/adapters/codex/config.toml +2 -2
  9. package/adapters/forge/forge.yaml +28 -0
  10. package/adapters/mcp/server-stdio.js +41 -1
  11. package/adapters/opencode/opencode.json +1 -1
  12. package/bin/cli.js +18 -3
  13. package/config/mcp-allowlists.json +11 -0
  14. package/openapi/openapi.yaml +105 -0
  15. package/package.json +7 -5
  16. package/plugins/amp-skill/INSTALL.md +3 -4
  17. package/plugins/amp-skill/SKILL.md +0 -1
  18. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  19. package/plugins/claude-codex-bridge/.mcp.json +1 -1
  20. package/plugins/claude-skill/INSTALL.md +1 -2
  21. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  22. package/plugins/codex-profile/.mcp.json +1 -1
  23. package/plugins/codex-profile/INSTALL.md +1 -1
  24. package/plugins/codex-profile/README.md +1 -1
  25. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  26. package/plugins/opencode-profile/INSTALL.md +1 -1
  27. package/public/blog.html +1 -0
  28. package/public/dashboard.html +1 -1
  29. package/public/guide.html +1 -1
  30. package/public/index.html +8 -4
  31. package/public/learn/agent-harness-pattern.html +1 -1
  32. package/public/learn/ai-agent-persistent-memory.html +1 -1
  33. package/public/learn/mcp-pre-action-gates-explained.html +1 -1
  34. package/public/learn/stop-ai-agent-force-push.html +1 -1
  35. package/public/learn/vibe-coding-safety-net.html +1 -1
  36. package/public/learn.html +1 -1
  37. package/public/lessons.html +1 -1
  38. package/public/pro.html +1 -1
  39. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  40. package/scripts/agent-security-hardening.js +4 -4
  41. package/scripts/async-job-runner.js +84 -24
  42. package/scripts/auto-wire-hooks.js +59 -1
  43. package/scripts/context-manager.js +330 -0
  44. package/scripts/dashboard.js +1 -1
  45. package/scripts/distribution-surfaces.js +12 -0
  46. package/scripts/ensure-repo-bootstrap.js +15 -14
  47. package/scripts/export-hf-dataset.js +293 -0
  48. package/scripts/gates-engine.js +96 -10
  49. package/scripts/hook-auto-capture.sh +1 -1
  50. package/scripts/hosted-job-launcher.js +260 -0
  51. package/scripts/managed-dpo-export.js +91 -0
  52. package/scripts/obsidian-export.js +0 -1
  53. package/scripts/operational-integrity.js +50 -7
  54. package/scripts/prove-lancedb.js +62 -4
  55. package/scripts/publish-decision.js +16 -0
  56. package/scripts/self-healing-check.js +6 -1
  57. package/scripts/social-analytics/load-env.js +33 -2
  58. package/scripts/social-analytics/store.js +200 -2
  59. package/scripts/sync-version.js +18 -11
  60. package/scripts/tool-registry.js +48 -0
  61. package/scripts/train_from_feedback.py +0 -4
  62. package/scripts/workflow-sentinel.js +793 -0
  63. package/src/api/server.js +205 -27
  64. /package/scripts/{rlhf_session_start.sh → thumbgate_session_start.sh} +0 -0
@@ -0,0 +1,793 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { execFileSync } = require('child_process');
7
+
8
+ const {
9
+ DEFAULT_BASE_BRANCH,
10
+ DEFAULT_RELEASE_SENSITIVE_GLOBS,
11
+ classifyCommand,
12
+ evaluateOperationalIntegrity,
13
+ findReleaseSensitiveFiles,
14
+ normalizePosix,
15
+ resolveRepoRoot,
16
+ } = require('./operational-integrity');
17
+ const { evaluatePretool } = require('./hybrid-feedback-context');
18
+
19
+ const GOVERNANCE_STATE_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'governance-state.json');
20
+ const DEFAULT_PROTECTED_FILE_GLOBS = [
21
+ 'AGENTS.md',
22
+ 'CLAUDE.md',
23
+ 'CLAUDE.local.md',
24
+ 'GEMINI.md',
25
+ 'README.md',
26
+ '.gitignore',
27
+ '.husky/**',
28
+ '.claude/**',
29
+ 'skills/**',
30
+ 'SKILL.md',
31
+ 'config/gates/**',
32
+ ];
33
+ const EDIT_LIKE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
34
+ const HIGH_RISK_BASH_PATTERN = /\b(?:git\s+(?:add|commit|push)|gh\s+pr\s+(?:create|merge)|gh\s+release\s+create|npm\s+publish|yarn\s+publish|pnpm\s+publish|rm\s+-rf)\b/i;
35
+
36
+ const SURFACE_RULES = [
37
+ { key: 'policy', pattern: /^(?:AGENTS\.md|CLAUDE(?:\.local)?\.md|GEMINI\.md|config\/gates\/|config\/mcp-allowlists\.json|scripts\/tool-registry\.js)/ },
38
+ { key: 'release', pattern: /^(?:package\.json|package-lock\.json|server\.json|\.github\/workflows\/|scripts\/publish-decision\.js|scripts\/pr-manager\.js)/ },
39
+ { key: 'runtime', pattern: /^(?:scripts\/|src\/api\/|adapters\/mcp\/)/ },
40
+ { key: 'tests', pattern: /^(?:tests\/|proof\/)/ },
41
+ { key: 'docs', pattern: /^(?:docs\/|README\.md|CHANGELOG\.md|WORKFLOW\.md)/ },
42
+ { key: 'public', pattern: /^(?:public\/|\.well-known\/)/ },
43
+ ];
44
+
45
+ function loadJson(filePath) {
46
+ if (!fs.existsSync(filePath)) return {};
47
+ try {
48
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ function loadGovernanceState() {
55
+ const raw = loadJson(GOVERNANCE_STATE_PATH);
56
+ return {
57
+ taskScope: raw && raw.taskScope && typeof raw.taskScope === 'object' ? raw.taskScope : null,
58
+ protectedApprovals: Array.isArray(raw && raw.protectedApprovals) ? raw.protectedApprovals : [],
59
+ branchGovernance: raw && raw.branchGovernance && typeof raw.branchGovernance === 'object'
60
+ ? raw.branchGovernance
61
+ : null,
62
+ };
63
+ }
64
+
65
+ function safeExecFileLines(binary, args, cwd) {
66
+ try {
67
+ const output = execFileSync(binary, args, {
68
+ cwd,
69
+ encoding: 'utf8',
70
+ stdio: ['ignore', 'pipe', 'ignore'],
71
+ }).trim();
72
+ if (!output) return [];
73
+ return output.split('\n').map((line) => line.trim()).filter(Boolean);
74
+ } catch {
75
+ return [];
76
+ }
77
+ }
78
+
79
+ function normalizeGlob(glob) {
80
+ return normalizePosix(glob).replace(/\/+$/, '');
81
+ }
82
+
83
+ function sanitizeGlobList(globs) {
84
+ if (!Array.isArray(globs)) return [];
85
+ return [...new Set(globs.map((glob) => normalizeGlob(glob)).filter(Boolean))];
86
+ }
87
+
88
+ function globToRegExp(glob) {
89
+ const normalized = normalizeGlob(glob);
90
+ let pattern = '^';
91
+ for (let i = 0; i < normalized.length; i += 1) {
92
+ const char = normalized[i];
93
+ const next = normalized[i + 1];
94
+ if (char === '*') {
95
+ if (next === '*') {
96
+ pattern += '.*';
97
+ i += 1;
98
+ } else {
99
+ pattern += '[^/]*';
100
+ }
101
+ continue;
102
+ }
103
+ if ('\\^$+?.()|{}[]'.includes(char)) {
104
+ pattern += `\\${char}`;
105
+ continue;
106
+ }
107
+ pattern += char;
108
+ }
109
+ pattern += '$';
110
+ return new RegExp(pattern);
111
+ }
112
+
113
+ function matchesAnyGlob(filePath, globs) {
114
+ const normalized = sanitizeGlobList(globs);
115
+ if (!filePath || normalized.length === 0) return false;
116
+ return normalized.some((glob) => {
117
+ try {
118
+ return globToRegExp(glob).test(normalizePosix(filePath));
119
+ } catch {
120
+ return false;
121
+ }
122
+ });
123
+ }
124
+
125
+ function toRepoRelativePath(filePath, repoRoot) {
126
+ const value = String(filePath || '').trim();
127
+ if (!value) return '';
128
+ if (repoRoot && path.isAbsolute(value)) {
129
+ const relative = path.relative(repoRoot, value);
130
+ if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
131
+ return normalizePosix(relative);
132
+ }
133
+ }
134
+ return normalizePosix(value);
135
+ }
136
+
137
+ function collectInlineAffectedFiles(toolInput = {}, repoRoot) {
138
+ const collected = [];
139
+ const arrayFields = [
140
+ toolInput.changed_files,
141
+ toolInput.changedFiles,
142
+ toolInput.files,
143
+ toolInput.file_paths,
144
+ toolInput.filePaths,
145
+ toolInput.paths,
146
+ ];
147
+
148
+ for (const field of arrayFields) {
149
+ if (!Array.isArray(field)) continue;
150
+ for (const entry of field) {
151
+ const normalized = toRepoRelativePath(entry, repoRoot);
152
+ if (normalized) collected.push(normalized);
153
+ }
154
+ }
155
+
156
+ const scalarFields = [
157
+ toolInput.file_path,
158
+ toolInput.filePath,
159
+ toolInput.path,
160
+ ];
161
+ for (const field of scalarFields) {
162
+ const normalized = toRepoRelativePath(field, repoRoot);
163
+ if (normalized) collected.push(normalized);
164
+ }
165
+
166
+ return [...new Set(collected)];
167
+ }
168
+
169
+ function getUpstreamRef(repoRoot) {
170
+ const upstream = safeExecFileLines('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], repoRoot)[0];
171
+ if (upstream) return upstream;
172
+ const remoteHead = safeExecFileLines('git', ['symbolic-ref', 'refs/remotes/origin/HEAD'], repoRoot)[0];
173
+ if (remoteHead) return remoteHead.replace(/^refs\/remotes\//, '');
174
+ return null;
175
+ }
176
+
177
+ function getBranchDiffFiles(repoRoot) {
178
+ const upstream = getUpstreamRef(repoRoot);
179
+ if (upstream) {
180
+ return safeExecFileLines('git', ['diff', '--name-only', `${upstream}...HEAD`], repoRoot);
181
+ }
182
+ const headParent = safeExecFileLines('git', ['rev-parse', '--verify', 'HEAD~1'], repoRoot)[0];
183
+ if (headParent) {
184
+ return safeExecFileLines('git', ['diff', '--name-only', 'HEAD~1..HEAD'], repoRoot);
185
+ }
186
+ return safeExecFileLines('git', ['diff', '--name-only'], repoRoot);
187
+ }
188
+
189
+ function collectAffectedFiles(toolName, toolInput = {}, repoRoot) {
190
+ const files = new Set(collectInlineAffectedFiles(toolInput, repoRoot));
191
+ const command = String(toolInput.command || '');
192
+ const hasExplicitAffectedFiles = files.size > 0;
193
+
194
+ if (toolName === 'Bash' && repoRoot && command) {
195
+ if (hasExplicitAffectedFiles) {
196
+ return [...files].filter(Boolean);
197
+ }
198
+
199
+ if (/\bgit\s+commit\b/i.test(command)) {
200
+ for (const filePath of safeExecFileLines('git', ['diff', '--cached', '--name-only'], repoRoot)) {
201
+ files.add(normalizePosix(filePath));
202
+ }
203
+ }
204
+
205
+ if (/\bgit\s+add\b/i.test(command)) {
206
+ for (const filePath of safeExecFileLines('git', ['diff', '--name-only'], repoRoot)) {
207
+ files.add(normalizePosix(filePath));
208
+ }
209
+ for (const filePath of safeExecFileLines('git', ['ls-files', '--others', '--exclude-standard'], repoRoot)) {
210
+ files.add(normalizePosix(filePath));
211
+ }
212
+ }
213
+
214
+ if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command)) {
215
+ for (const filePath of getBranchDiffFiles(repoRoot)) {
216
+ files.add(normalizePosix(filePath));
217
+ }
218
+ }
219
+ }
220
+
221
+ return [...files].filter(Boolean);
222
+ }
223
+
224
+ function isHighRiskAction(toolName, toolInput = {}, affectedFiles = []) {
225
+ if (EDIT_LIKE_TOOLS.has(toolName) && affectedFiles.length > 0) return true;
226
+ if (toolName !== 'Bash') return false;
227
+ return HIGH_RISK_BASH_PATTERN.test(String(toolInput.command || ''));
228
+ }
229
+
230
+ function isProtectedApprovalRelevant(toolName, toolInput = {}) {
231
+ if (EDIT_LIKE_TOOLS.has(toolName)) return true;
232
+ if (toolName !== 'Bash') return false;
233
+ const commandInfo = classifyCommand(toolInput.command || '');
234
+ return commandInfo.isPublish || commandInfo.isReleaseCreate || commandInfo.isTagCreate;
235
+ }
236
+
237
+ function normalizeMemoryGuardForSentinel(memoryGuard, isHighRisk) {
238
+ if (!memoryGuard || memoryGuard.mode === 'allow') return memoryGuard;
239
+ const reason = String(memoryGuard.reason || '');
240
+ const broadToolOnlySignal = /^Tool "[^"]+" has \d+ attributed negative\(s\), \d+ total negative\(s\)$/i.test(reason);
241
+ if (!isHighRisk && broadToolOnlySignal) {
242
+ return {
243
+ ...memoryGuard,
244
+ mode: 'warn',
245
+ reason: `${reason}. Treating this as advisory because the current action is not in the high-risk command set.`,
246
+ };
247
+ }
248
+ return memoryGuard;
249
+ }
250
+
251
+ function buildTaskScopeViolation(taskScope, affectedFiles) {
252
+ if (!Array.isArray(affectedFiles) || affectedFiles.length === 0) return null;
253
+ if (!taskScope || !Array.isArray(taskScope.allowedPaths) || taskScope.allowedPaths.length === 0) {
254
+ return {
255
+ reasonCode: 'missing_task_scope',
256
+ outsideFiles: affectedFiles.slice(),
257
+ allowedPaths: [],
258
+ summary: null,
259
+ };
260
+ }
261
+ const outsideFiles = affectedFiles.filter((filePath) => !matchesAnyGlob(filePath, taskScope.allowedPaths));
262
+ if (outsideFiles.length === 0) return null;
263
+ return {
264
+ reasonCode: 'outside_declared_scope',
265
+ outsideFiles,
266
+ allowedPaths: taskScope.allowedPaths.slice(),
267
+ summary: taskScope.summary || null,
268
+ };
269
+ }
270
+
271
+ function buildProtectedSurface(governanceState, affectedFiles) {
272
+ const protectedGlobs = sanitizeGlobList(
273
+ governanceState && governanceState.taskScope && Array.isArray(governanceState.taskScope.protectedPaths)
274
+ ? governanceState.taskScope.protectedPaths
275
+ : DEFAULT_PROTECTED_FILE_GLOBS
276
+ );
277
+ const protectedFiles = affectedFiles.filter((filePath) => matchesAnyGlob(filePath, protectedGlobs));
278
+ const approvals = Array.isArray(governanceState && governanceState.protectedApprovals)
279
+ ? governanceState.protectedApprovals
280
+ : [];
281
+ const unapprovedProtectedFiles = protectedFiles.filter((filePath) => {
282
+ return !approvals.some((entry) => matchesAnyGlob(filePath, entry.pathGlobs || []));
283
+ });
284
+ return {
285
+ protectedGlobs,
286
+ protectedFiles,
287
+ unapprovedProtectedFiles,
288
+ };
289
+ }
290
+
291
+ function classifySurface(filePath) {
292
+ const normalized = normalizePosix(filePath);
293
+ for (const rule of SURFACE_RULES) {
294
+ if (rule.pattern.test(normalized)) return rule.key;
295
+ }
296
+ return 'product';
297
+ }
298
+
299
+ function summarizeSurfaces(affectedFiles) {
300
+ const buckets = new Map();
301
+ for (const filePath of affectedFiles) {
302
+ const key = classifySurface(filePath);
303
+ if (!buckets.has(key)) {
304
+ buckets.set(key, { key, fileCount: 0, files: [] });
305
+ }
306
+ const bucket = buckets.get(key);
307
+ bucket.fileCount += 1;
308
+ bucket.files.push(filePath);
309
+ }
310
+ return [...buckets.values()].sort((left, right) => {
311
+ return right.fileCount - left.fileCount || left.key.localeCompare(right.key);
312
+ });
313
+ }
314
+
315
+ function formatFileList(files, limit = 5) {
316
+ const items = Array.isArray(files) ? files.filter(Boolean) : [];
317
+ if (items.length === 0) return 'none';
318
+ if (items.length <= limit) return items.join(', ');
319
+ return `${items.slice(0, limit).join(', ')} (+${items.length - limit} more)`;
320
+ }
321
+
322
+ function severityFromScore(score) {
323
+ if (score >= 0.8) return 'critical';
324
+ if (score >= 0.55) return 'high';
325
+ if (score >= 0.3) return 'medium';
326
+ return 'low';
327
+ }
328
+
329
+ function buildBlastRadius({ affectedFiles, integrity, protectedSurface }) {
330
+ const surfaces = summarizeSurfaces(affectedFiles);
331
+ const surfaceCount = surfaces.length;
332
+ const releaseSensitiveFiles = findReleaseSensitiveFiles(
333
+ affectedFiles,
334
+ integrity && Array.isArray(integrity.releaseSensitiveFiles) && integrity.releaseSensitiveFiles.length > 0
335
+ ? integrity.releaseSensitiveFiles
336
+ : DEFAULT_RELEASE_SENSITIVE_GLOBS
337
+ );
338
+ const severityScore = Math.min(1, (
339
+ (affectedFiles.length >= 4 ? 0.22 : affectedFiles.length > 0 ? 0.12 : 0) +
340
+ (affectedFiles.length >= 12 ? 0.18 : 0) +
341
+ (surfaceCount >= 3 ? 0.18 : surfaceCount === 2 ? 0.1 : 0) +
342
+ (releaseSensitiveFiles.length > 0 ? 0.22 : 0) +
343
+ (protectedSurface.unapprovedProtectedFiles.length > 0 ? 0.22 : protectedSurface.protectedFiles.length > 0 ? 0.12 : 0)
344
+ ));
345
+ const severity = severityFromScore(severityScore);
346
+ const summaryParts = [];
347
+ if (affectedFiles.length > 0) {
348
+ summaryParts.push(`${affectedFiles.length} files across ${surfaceCount || 1} surface${surfaceCount === 1 ? '' : 's'}`);
349
+ } else {
350
+ summaryParts.push('No explicit file blast radius detected');
351
+ }
352
+ if (releaseSensitiveFiles.length > 0) {
353
+ summaryParts.push(`${releaseSensitiveFiles.length} release-sensitive`);
354
+ }
355
+ if (protectedSurface.unapprovedProtectedFiles.length > 0) {
356
+ summaryParts.push(`${protectedSurface.unapprovedProtectedFiles.length} protected without approval`);
357
+ }
358
+
359
+ return {
360
+ severity,
361
+ severityScore: Number(severityScore.toFixed(4)),
362
+ fileCount: affectedFiles.length,
363
+ surfaceCount,
364
+ affectedFiles,
365
+ surfaces,
366
+ protectedFiles: protectedSurface.protectedFiles,
367
+ unapprovedProtectedFiles: protectedSurface.unapprovedProtectedFiles,
368
+ releaseSensitiveFiles,
369
+ summary: summaryParts.join(' · '),
370
+ };
371
+ }
372
+
373
+ function addDriver(drivers, key, weight, reason, metadata = {}) {
374
+ if (!weight || weight <= 0) return;
375
+ drivers.push({
376
+ key,
377
+ weight: Number(weight.toFixed(4)),
378
+ reason,
379
+ metadata,
380
+ });
381
+ }
382
+
383
+ function scoreRisk({
384
+ toolName,
385
+ toolInput,
386
+ affectedFiles,
387
+ integrity,
388
+ memoryGuard,
389
+ blastRadius,
390
+ taskScopeViolation,
391
+ protectedSurface,
392
+ }) {
393
+ const drivers = [];
394
+ const commandInfo = classifyCommand(toolInput.command || '');
395
+
396
+ if (isHighRiskAction(toolName, toolInput, affectedFiles)) {
397
+ addDriver(drivers, 'high_risk_action', 0.18, 'Command or edit pattern is classified as high risk.');
398
+ }
399
+ if (commandInfo.isPrCreate || commandInfo.isPrMerge || commandInfo.isPublish || commandInfo.isReleaseCreate || commandInfo.isTagCreate) {
400
+ addDriver(drivers, 'governed_command', 0.16, 'Action touches PR, release, or publish workflow state.');
401
+ }
402
+ if (/\bgit\s+push\b.*(?:--force|-f)\b/i.test(commandInfo.text)) {
403
+ addDriver(drivers, 'force_push', 0.5, 'Force push predicts destructive branch history rewrite.');
404
+ }
405
+ if (/\bgh\s+pr\s+merge\b.*--admin\b/i.test(commandInfo.text)) {
406
+ addDriver(drivers, 'admin_merge_bypass', 0.45, 'Admin merge bypass skips the protected merge path.');
407
+ }
408
+ if (/\brm\s+-rf\b/i.test(commandInfo.text)) {
409
+ addDriver(drivers, 'destructive_delete', 0.28, 'Recursive delete is destructive and difficult to recover from.');
410
+ }
411
+ if (taskScopeViolation) {
412
+ addDriver(
413
+ drivers,
414
+ taskScopeViolation.reasonCode,
415
+ taskScopeViolation.reasonCode === 'missing_task_scope' ? 0.14 : 0.18,
416
+ taskScopeViolation.reasonCode === 'missing_task_scope'
417
+ ? 'No explicit task scope is declared for the affected files.'
418
+ : 'Action extends beyond the declared task scope.',
419
+ { outsideFiles: taskScopeViolation.outsideFiles }
420
+ );
421
+ }
422
+ if (protectedSurface.unapprovedProtectedFiles.length > 0) {
423
+ addDriver(drivers, 'protected_without_approval', 0.22, 'Protected files are affected without an active approval.', {
424
+ files: protectedSurface.unapprovedProtectedFiles,
425
+ });
426
+ }
427
+ if (blastRadius.releaseSensitiveFiles.length > 0) {
428
+ addDriver(drivers, 'release_sensitive', 0.2, 'Release-sensitive files are in the predicted blast radius.', {
429
+ files: blastRadius.releaseSensitiveFiles,
430
+ });
431
+ }
432
+ if (blastRadius.releaseSensitiveFiles.length > 0 && blastRadius.surfaceCount >= 3) {
433
+ addDriver(
434
+ drivers,
435
+ 'release_sensitive_multi_surface',
436
+ 0.08,
437
+ 'Release-sensitive changes span multiple workflow surfaces.'
438
+ );
439
+ }
440
+ if (blastRadius.fileCount >= 4) {
441
+ addDriver(
442
+ drivers,
443
+ 'multi_file_change',
444
+ blastRadius.fileCount >= 12 ? 0.18 : 0.1,
445
+ `Change spans ${blastRadius.fileCount} files.`
446
+ );
447
+ }
448
+ if (blastRadius.surfaceCount >= 2) {
449
+ addDriver(
450
+ drivers,
451
+ 'multi_surface_change',
452
+ blastRadius.surfaceCount >= 4 ? 0.18 : 0.1,
453
+ `Change spans ${blastRadius.surfaceCount} distinct workflow surfaces.`
454
+ );
455
+ }
456
+ if (integrity && Array.isArray(integrity.blockers) && integrity.blockers.length > 0) {
457
+ addDriver(
458
+ drivers,
459
+ 'operational_blockers',
460
+ Math.min(0.4, 0.18 + ((integrity.blockers.length - 1) * 0.08)),
461
+ `Operational integrity surfaced ${integrity.blockers.length} blocker${integrity.blockers.length === 1 ? '' : 's'}.`,
462
+ { blockers: integrity.blockers.map((blocker) => blocker.code) }
463
+ );
464
+ }
465
+ if (memoryGuard && memoryGuard.mode && memoryGuard.mode !== 'allow') {
466
+ addDriver(
467
+ drivers,
468
+ 'memory_recurrence',
469
+ memoryGuard.mode === 'block' ? 0.28 : 0.16,
470
+ 'Past failures predict recurrence for this tool/input combination.',
471
+ { mode: memoryGuard.mode }
472
+ );
473
+ }
474
+
475
+ const score = Math.min(1, drivers.reduce((sum, driver) => sum + driver.weight, 0));
476
+ return {
477
+ score: Number(score.toFixed(4)),
478
+ band: severityFromScore(score) === 'critical'
479
+ ? 'very_high'
480
+ : severityFromScore(score) === 'high'
481
+ ? 'high'
482
+ : severityFromScore(score) === 'medium'
483
+ ? 'medium'
484
+ : score > 0
485
+ ? 'low'
486
+ : 'very_low',
487
+ drivers: drivers.sort((left, right) => right.weight - left.weight || left.key.localeCompare(right.key)),
488
+ };
489
+ }
490
+
491
+ function buildEvidence({
492
+ integrity,
493
+ memoryGuard,
494
+ blastRadius,
495
+ taskScopeViolation,
496
+ protectedSurface,
497
+ }) {
498
+ const evidence = [];
499
+ if (memoryGuard && memoryGuard.mode && memoryGuard.mode !== 'allow') {
500
+ evidence.push(`Memory guard predicted ${memoryGuard.mode}: ${memoryGuard.reason}`);
501
+ }
502
+ if (taskScopeViolation) {
503
+ evidence.push(
504
+ taskScopeViolation.reasonCode === 'missing_task_scope'
505
+ ? 'No task scope is declared for the affected files.'
506
+ : `Files outside task scope: ${formatFileList(taskScopeViolation.outsideFiles)}.`
507
+ );
508
+ }
509
+ if (protectedSurface.unapprovedProtectedFiles.length > 0) {
510
+ evidence.push(`Protected files without approval: ${formatFileList(protectedSurface.unapprovedProtectedFiles)}.`);
511
+ }
512
+ if (blastRadius.releaseSensitiveFiles.length > 0) {
513
+ evidence.push(`Release-sensitive files in blast radius: ${formatFileList(blastRadius.releaseSensitiveFiles)}.`);
514
+ }
515
+ if (integrity && Array.isArray(integrity.blockers)) {
516
+ for (const blocker of integrity.blockers.slice(0, 3)) {
517
+ evidence.push(`Operational blocker ${blocker.code}: ${blocker.message}`);
518
+ }
519
+ }
520
+ if (blastRadius.fileCount > 0) {
521
+ evidence.push(`Blast radius summary: ${blastRadius.summary}.`);
522
+ }
523
+ return evidence;
524
+ }
525
+
526
+ function buildRemediations({
527
+ integrity,
528
+ taskScopeViolation,
529
+ protectedSurface,
530
+ blastRadius,
531
+ memoryGuard,
532
+ }) {
533
+ const remediations = [];
534
+ const seen = new Set();
535
+
536
+ function push(id, title, action, why) {
537
+ if (seen.has(id)) return;
538
+ seen.add(id);
539
+ remediations.push({ id, title, action, why });
540
+ }
541
+
542
+ if (taskScopeViolation) {
543
+ push(
544
+ 'declare_task_scope',
545
+ 'Declare task scope',
546
+ 'Call set_task_scope with allowedPaths covering only the intended files before retrying.',
547
+ 'High-risk changes should stay inside an explicit file boundary.'
548
+ );
549
+ }
550
+ if (protectedSurface.unapprovedProtectedFiles.length > 0) {
551
+ push(
552
+ 'approve_protected_files',
553
+ 'Get protected-file approval',
554
+ `Call approve_protected_action for ${formatFileList(protectedSurface.unapprovedProtectedFiles)} before editing or publishing.`,
555
+ 'Protected policy files need an explicit time-bounded approval.'
556
+ );
557
+ }
558
+ if (integrity && Array.isArray(integrity.blockers)) {
559
+ const blockerCodes = new Set(integrity.blockers.map((blocker) => blocker.code));
560
+ if (blockerCodes.has('missing_branch_governance')) {
561
+ push(
562
+ 'set_branch_governance',
563
+ 'Declare branch governance',
564
+ 'Call set_branch_governance with branchName, baseBranch, and PR/release expectations.',
565
+ 'Release, merge, and PR workflows need explicit branch state.'
566
+ );
567
+ }
568
+ if (blockerCodes.has('merge_requires_pr_context')) {
569
+ push(
570
+ 'attach_pr_context',
571
+ 'Attach PR context',
572
+ 'Update branch governance with prNumber or prUrl before merging.',
573
+ 'Merge actions should be tied to one explicit review surface.'
574
+ );
575
+ }
576
+ if (blockerCodes.has('missing_release_version') || blockerCodes.has('release_version_mismatch')) {
577
+ push(
578
+ 'align_release_version',
579
+ 'Align release version',
580
+ 'Set branch governance releaseVersion and verify it matches package.json before publish.',
581
+ 'Release metadata should match the artifact being published.'
582
+ );
583
+ }
584
+ if (blockerCodes.has('publish_requires_base_branch') || blockerCodes.has('publish_requires_mainline_head')) {
585
+ push(
586
+ 'switch_to_mainline',
587
+ 'Run publish from mainline',
588
+ `Move the action onto ${integrity.baseBranch || DEFAULT_BASE_BRANCH} after the merge commit exists.`,
589
+ 'Publish and tag flows should execute from the protected mainline branch.'
590
+ );
591
+ }
592
+ }
593
+ if (memoryGuard && memoryGuard.mode && memoryGuard.mode !== 'allow') {
594
+ push(
595
+ 'retrieve_lessons',
596
+ 'Inspect prior lessons',
597
+ 'Call retrieve_lessons or search_lessons for this tool context before retrying.',
598
+ 'The system already has evidence that this action pattern failed before.'
599
+ );
600
+ }
601
+ if (blastRadius.fileCount >= 4 || blastRadius.surfaceCount >= 3) {
602
+ push(
603
+ 'split_blast_radius',
604
+ 'Split the change',
605
+ 'Reduce the affected files or surfaces into smaller sequential steps before executing.',
606
+ 'Smaller blast radii are easier to verify and recover.'
607
+ );
608
+ }
609
+
610
+ return remediations;
611
+ }
612
+
613
+ function buildReasoning(report) {
614
+ const lines = [
615
+ `Workflow sentinel risk ${report.band} (${report.riskScore}) for ${report.toolName}.`,
616
+ `Blast radius: ${report.blastRadius.summary}.`,
617
+ ];
618
+ for (const driver of report.drivers.slice(0, 4)) {
619
+ lines.push(`Driver ${driver.key} (+${driver.weight}): ${driver.reason}`);
620
+ }
621
+ for (const remediation of report.remediations.slice(0, 3)) {
622
+ lines.push(`Remediation: ${remediation.title} — ${remediation.action}`);
623
+ }
624
+ return lines;
625
+ }
626
+
627
+ function chooseDecision({ riskScore, integrity, memoryGuard, blastRadius, command }) {
628
+ const hasOperationalBlockers = Boolean(integrity && Array.isArray(integrity.blockers) && integrity.blockers.length > 0);
629
+ const destructiveBypass = /\bgit\s+push\b.*(?:--force|-f)\b/i.test(command) || /\bgh\s+pr\s+merge\b.*--admin\b/i.test(command);
630
+ const lowBlastRadius = blastRadius.fileCount <= 1
631
+ && blastRadius.surfaceCount <= 1
632
+ && blastRadius.releaseSensitiveFiles.length === 0
633
+ && blastRadius.unapprovedProtectedFiles === 0;
634
+ const lowRiskHandoff = /\bgit\s+push\b|\bgh\s+pr\s+(?:create|merge)\b/i.test(command)
635
+ && !destructiveBypass
636
+ && lowBlastRadius
637
+ && !hasOperationalBlockers
638
+ && memoryGuard
639
+ && memoryGuard.mode !== 'allow'
640
+ && riskScore <= 0.62;
641
+ const repeatedHighBlast = Boolean(
642
+ memoryGuard
643
+ && memoryGuard.mode === 'block'
644
+ && (
645
+ blastRadius.severity === 'high'
646
+ || blastRadius.severity === 'critical'
647
+ || blastRadius.releaseSensitiveFiles.length > 0
648
+ || blastRadius.unapprovedProtectedFiles > 0
649
+ )
650
+ );
651
+
652
+ if (lowRiskHandoff) {
653
+ return 'allow';
654
+ }
655
+ if (destructiveBypass || repeatedHighBlast || (hasOperationalBlockers && riskScore >= 0.72) || riskScore >= 0.86) {
656
+ return 'deny';
657
+ }
658
+ if (riskScore >= 0.45) {
659
+ return 'warn';
660
+ }
661
+ return 'allow';
662
+ }
663
+
664
+ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
665
+ const governanceState = options.governanceState || loadGovernanceState();
666
+ const repoPath = options.repoPath || toolInput.repoPath || toolInput.cwd || process.cwd();
667
+ const repoRoot = resolveRepoRoot(repoPath) || null;
668
+ const affectedFiles = Array.isArray(options.affectedFiles)
669
+ ? options.affectedFiles.map((filePath) => normalizePosix(filePath)).filter(Boolean)
670
+ : collectAffectedFiles(toolName, toolInput, repoRoot);
671
+ const highRiskAction = isHighRiskAction(toolName, toolInput, affectedFiles);
672
+ const baseBranch = options.baseBranch
673
+ || (governanceState.branchGovernance && governanceState.branchGovernance.baseBranch)
674
+ || toolInput.baseBranch
675
+ || DEFAULT_BASE_BRANCH;
676
+ const integrity = evaluateOperationalIntegrity({
677
+ repoPath,
678
+ baseBranch,
679
+ command: toolInput.command,
680
+ changedFiles: affectedFiles,
681
+ requirePrForReleaseSensitive: options.requirePrForReleaseSensitive === true,
682
+ requireVersionNotBehindBase: options.requireVersionNotBehindBase === true,
683
+ branchGovernance: governanceState.branchGovernance,
684
+ });
685
+ const taskScopeViolation = buildTaskScopeViolation(governanceState.taskScope, affectedFiles);
686
+ const protectedSurface = buildProtectedSurface(governanceState, affectedFiles);
687
+ const protectedSurfaceForRisk = isProtectedApprovalRelevant(toolName, toolInput)
688
+ ? protectedSurface
689
+ : {
690
+ ...protectedSurface,
691
+ protectedFiles: [],
692
+ unapprovedProtectedFiles: [],
693
+ };
694
+ const rawMemoryGuard = options.memoryGuard || evaluatePretool(toolName, JSON.stringify({
695
+ toolName,
696
+ command: toolInput.command || null,
697
+ filePath: toolInput.file_path || toolInput.filePath || toolInput.path || null,
698
+ affectedFiles,
699
+ }), options.feedbackOptions || {});
700
+ const memoryGuard = normalizeMemoryGuardForSentinel(rawMemoryGuard, highRiskAction);
701
+ const blastRadius = buildBlastRadius({
702
+ affectedFiles,
703
+ integrity,
704
+ protectedSurface: protectedSurfaceForRisk,
705
+ });
706
+ const risk = scoreRisk({
707
+ toolName,
708
+ toolInput,
709
+ affectedFiles,
710
+ integrity,
711
+ memoryGuard,
712
+ blastRadius,
713
+ taskScopeViolation,
714
+ protectedSurface: protectedSurfaceForRisk,
715
+ });
716
+ const decision = chooseDecision({
717
+ riskScore: risk.score,
718
+ integrity,
719
+ memoryGuard,
720
+ blastRadius: {
721
+ ...blastRadius,
722
+ unapprovedProtectedFiles: protectedSurfaceForRisk.unapprovedProtectedFiles.length,
723
+ },
724
+ command: toolInput.command || '',
725
+ });
726
+ const evidence = buildEvidence({
727
+ integrity,
728
+ memoryGuard,
729
+ blastRadius,
730
+ taskScopeViolation,
731
+ protectedSurface: protectedSurfaceForRisk,
732
+ });
733
+ const remediations = buildRemediations({
734
+ integrity,
735
+ taskScopeViolation,
736
+ protectedSurface: protectedSurfaceForRisk,
737
+ blastRadius,
738
+ memoryGuard,
739
+ });
740
+ const summary = decision === 'allow'
741
+ ? 'No predictive workflow blockers detected.'
742
+ : decision === 'warn'
743
+ ? 'Predicted workflow risk is elevated before execution.'
744
+ : 'Predicted workflow failure before execution.';
745
+ const report = {
746
+ sentinelVersion: 'workflow-sentinel-v1',
747
+ toolName,
748
+ decision,
749
+ riskScore: risk.score,
750
+ band: risk.band,
751
+ summary,
752
+ drivers: risk.drivers,
753
+ blastRadius,
754
+ evidence,
755
+ remediations,
756
+ memoryGuard,
757
+ taskScopeViolation,
758
+ operationalIntegrity: {
759
+ ok: integrity.ok,
760
+ currentBranch: integrity.currentBranch,
761
+ baseBranch: integrity.baseBranch,
762
+ blockers: integrity.blockers,
763
+ releaseSensitiveFiles: integrity.releaseSensitiveFiles,
764
+ openPr: integrity.openPr,
765
+ commandInfo: integrity.commandInfo,
766
+ },
767
+ };
768
+ report.reasoning = buildReasoning(report);
769
+ return report;
770
+ }
771
+
772
+ module.exports = {
773
+ DEFAULT_PROTECTED_FILE_GLOBS,
774
+ buildBlastRadius,
775
+ buildEvidence,
776
+ buildProtectedSurface,
777
+ buildReasoning,
778
+ buildRemediations,
779
+ buildTaskScopeViolation,
780
+ classifySurface,
781
+ collectAffectedFiles,
782
+ evaluateWorkflowSentinel,
783
+ isHighRiskAction,
784
+ loadGovernanceState,
785
+ scoreRisk,
786
+ };
787
+
788
+ if (require.main === module) {
789
+ const report = evaluateWorkflowSentinel(process.argv[2] || 'Bash', {
790
+ command: process.argv.slice(3).join(' '),
791
+ });
792
+ console.log(JSON.stringify(report, null, 2));
793
+ }