thumbgate 1.27.19 → 1.28.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.
- package/.claude/commands/dashboard.md +15 -0
- package/.claude/commands/thumbgate-blocked.md +27 -0
- package/.claude/commands/thumbgate-dashboard.md +15 -0
- package/.claude/commands/thumbgate-doctor.md +30 -0
- package/.claude/commands/thumbgate-guard.md +36 -0
- package/.claude/commands/thumbgate-protect.md +30 -0
- package/.claude/commands/thumbgate-rules.md +30 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +63 -45
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/gcp/dfcx-webhook-gate.js +295 -0
- package/adapters/letta/README.md +41 -0
- package/adapters/letta/thumbgate-letta-adapter.js +133 -0
- package/adapters/mcp/server-stdio.js +1 -1
- package/adapters/opencode/opencode.json +1 -1
- package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
- package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
- package/bench/observability-eval-suite.json +26 -0
- package/bin/cli.js +52 -9
- package/bin/dashboard-cli.js +7 -0
- package/bin/postinstall.js +14 -23
- package/commands/dashboard.md +15 -0
- package/commands/thumbgate-dashboard.md +15 -0
- package/config/entitlement-public-keys.json +6 -0
- package/config/gates/default.json +1 -1
- package/config/github-about.json +2 -2
- package/config/merge-quality-checks.json +3 -0
- package/package.json +233 -100
- package/public/about.html +159 -0
- package/public/agent-manager.html +179 -0
- package/public/agents-cost-savings.html +153 -0
- package/public/ai-malpractice-prevention.html +818 -0
- package/public/assets/brand/github-social-preview.png +0 -0
- package/public/assets/brand/thumbgate-icon-512.png +0 -0
- package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
- package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
- package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
- package/public/assets/brand/thumbgate-logo-transparent.svg +28 -0
- package/public/assets/brand/thumbgate-mark-inline-v3.svg +18 -0
- package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
- package/public/assets/brand/thumbgate-mark-team.svg +26 -0
- package/public/assets/brand/thumbgate-mark.svg +21 -0
- package/public/assets/brand/thumbgate-wordmark.svg +20 -0
- package/public/assets/claude-thumbgate-statusbar.svg +8 -0
- package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
- package/public/assets/legal-intake-control-flow.svg +66 -0
- package/public/blog.html +12 -9
- package/public/brand/thumbgate-mark.svg +19 -0
- package/public/brand/thumbgate-og.svg +16 -0
- package/public/chatgpt-app.html +330 -0
- package/public/codex-enterprise.html +123 -0
- package/public/codex-plugin.html +5 -5
- package/public/compare.html +10 -10
- package/public/dashboard.html +1 -1
- package/public/diagnostic.html +345 -0
- package/public/federal.html +2 -2
- package/public/guide.html +14 -14
- package/public/index.html +81 -70
- package/public/install.html +193 -0
- package/public/js/buyer-intent.js +672 -0
- package/public/learn.html +4 -17
- package/public/numbers.html +2 -2
- package/public/pricing.html +392 -0
- package/public/pro.html +2 -2
- package/scripts/action-receipts.js +324 -0
- package/scripts/activation-quickstart.js +187 -0
- package/scripts/agent-operations-planner.js +621 -0
- package/scripts/agent-reward-model.js +13 -0
- package/scripts/ai-component-inventory.js +367 -0
- package/scripts/async-eval-observability.js +236 -0
- package/scripts/audit.js +65 -0
- package/scripts/aws-blocks-guardrails.js +272 -0
- package/scripts/bayes-optimal-gate.js +6 -1
- package/scripts/classifier-routing.js +130 -0
- package/scripts/cli-feedback.js +17 -1
- package/scripts/commercial-offer.js +3 -3
- package/scripts/dashboard-chat.js +332 -0
- package/scripts/entitlement.js +250 -0
- package/scripts/export-databricks-bundle.js +5 -0
- package/scripts/export-dpo-pairs.js +6 -0
- package/scripts/export-hf-dataset.js +5 -0
- package/scripts/feedback-aggregate.js +281 -0
- package/scripts/feedback-sanitizer.js +105 -0
- package/scripts/gates-engine.js +15 -3
- package/scripts/hook-stop-anti-claim.js +301 -0
- package/scripts/imperative-detector.js +85 -0
- package/scripts/install-shim.js +87 -0
- package/scripts/intervention-policy.js +13 -0
- package/scripts/mcp-oauth.js +293 -0
- package/scripts/noop-detect.js +285 -0
- package/scripts/parallel-workflow-orchestrator.js +293 -0
- package/scripts/plan-gate.js +243 -0
- package/scripts/plausible-domain-config.js +99 -0
- package/scripts/pr-manager.js +9 -22
- package/scripts/pro-local-dashboard.js +198 -0
- package/scripts/qa-scenario-planner.js +136 -0
- package/scripts/repeat-metric.js +137 -0
- package/scripts/risk-scorer.js +6 -0
- package/scripts/secret-fixture-tokens.js +61 -0
- package/scripts/secret-redaction.js +166 -0
- package/scripts/self-harness-optimizer.js +141 -0
- package/scripts/self-healing-check.js +193 -0
- package/scripts/self-protection.js +90 -0
- package/scripts/seo-gsd.js +2 -2
- package/scripts/silent-failure-cluster.js +531 -0
- package/scripts/statusline-cache-read.js +57 -0
- package/scripts/sync-telemetry-from-prod.js +374 -0
- package/scripts/thompson-sampling.js +11 -2
- package/scripts/tool-contract-validator.js +76 -0
- package/scripts/trajectory-scorer.js +63 -0
- package/scripts/verify-marketing-pages-deployed.js +212 -0
- package/scripts/visitor-journey.js +172 -0
- package/src/api/server.js +81 -10
- package/.claude-plugin/marketplace.json +0 -85
- package/adapters/chatgpt/openapi.yaml +0 -1707
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { getFeedbackPaths } = require('./feedback-loop');
|
|
6
|
+
const { ensureDir } = require('./fs-utils');
|
|
7
|
+
const { loadOptionalModule } = require('./private-core-boundary');
|
|
8
|
+
|
|
9
|
+
const launcher = loadOptionalModule(path.join(__dirname, 'hosted-job-launcher'), () => ({
|
|
10
|
+
launchManagedJob: () => {
|
|
11
|
+
throw new Error('Managed jobs require ThumbGate-Core.');
|
|
12
|
+
},
|
|
13
|
+
resumeHostedJob: () => {
|
|
14
|
+
throw new Error('Resuming hosted jobs requires ThumbGate-Core.');
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const runner = loadOptionalModule(path.join(__dirname, 'async-job-runner'), () => ({
|
|
19
|
+
readJobState: () => null,
|
|
20
|
+
listJobStates: () => [],
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const { launchManagedJob, resumeHostedJob } = launcher;
|
|
24
|
+
const { readJobState, listJobStates } = runner;
|
|
25
|
+
|
|
26
|
+
const DEFAULT_CONCURRENCY = 3;
|
|
27
|
+
const POLL_INTERVAL_MS = 200;
|
|
28
|
+
|
|
29
|
+
function nowIso() {
|
|
30
|
+
return new Date().toISOString();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Dynamically decompose a high-level objective into parallel, specialized subtasks.
|
|
35
|
+
* Supports rule-based fallback and can be extended to use LLM planning.
|
|
36
|
+
*/
|
|
37
|
+
function planWorkflow(objective) {
|
|
38
|
+
const obj = (objective || '').toLowerCase().trim();
|
|
39
|
+
const subtasks = [];
|
|
40
|
+
|
|
41
|
+
if (obj.includes('security') || obj.includes('audit') || obj.includes('leak') || obj.includes('secret')) {
|
|
42
|
+
subtasks.push({
|
|
43
|
+
name: 'scan_secrets',
|
|
44
|
+
tags: ['security', 'secret-scanner'],
|
|
45
|
+
stages: [
|
|
46
|
+
{
|
|
47
|
+
name: 'secret_scan',
|
|
48
|
+
command: 'node scripts/secret-scanner.js --json || true',
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
});
|
|
52
|
+
subtasks.push({
|
|
53
|
+
name: 'audit_dependencies',
|
|
54
|
+
tags: ['security', 'dependencies'],
|
|
55
|
+
stages: [
|
|
56
|
+
{
|
|
57
|
+
name: 'npm_audit',
|
|
58
|
+
command: 'npm audit --json || true',
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
});
|
|
62
|
+
subtasks.push({
|
|
63
|
+
name: 'check_permissions',
|
|
64
|
+
tags: ['security', 'credentials'],
|
|
65
|
+
stages: [
|
|
66
|
+
{
|
|
67
|
+
name: 'credential_gate_check',
|
|
68
|
+
command: 'node scripts/single-use-credential-gate.js plan || true',
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
});
|
|
72
|
+
} else if (obj.includes('performance') || obj.includes('benchmark') || obj.includes('bench')) {
|
|
73
|
+
subtasks.push({
|
|
74
|
+
name: 'benchmark_candidates',
|
|
75
|
+
tags: ['performance', 'bench'],
|
|
76
|
+
stages: [
|
|
77
|
+
{
|
|
78
|
+
name: 'run_bench',
|
|
79
|
+
command: 'npx thumbgate bench --json --min-score=90 || true',
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
});
|
|
83
|
+
subtasks.push({
|
|
84
|
+
name: 'check_budget',
|
|
85
|
+
tags: ['performance', 'budget'],
|
|
86
|
+
stages: [
|
|
87
|
+
{
|
|
88
|
+
name: 'budget_status',
|
|
89
|
+
command: 'node scripts/budget-guard.js --status || true',
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
});
|
|
93
|
+
} else {
|
|
94
|
+
// Default general-purpose fallback workflow: code search and check integrity
|
|
95
|
+
subtasks.push({
|
|
96
|
+
name: 'code_search',
|
|
97
|
+
tags: ['exploration'],
|
|
98
|
+
stages: [
|
|
99
|
+
{
|
|
100
|
+
name: 'search_fs',
|
|
101
|
+
command: 'node scripts/filesystem-search.js --query="pretool" --limit=5 || true',
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
});
|
|
105
|
+
subtasks.push({
|
|
106
|
+
name: 'check_integrity',
|
|
107
|
+
tags: ['integrity'],
|
|
108
|
+
stages: [
|
|
109
|
+
{
|
|
110
|
+
name: 'ops_integrity',
|
|
111
|
+
command: 'node scripts/operational-integrity.js --ci || true',
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
objective,
|
|
119
|
+
plannedAt: nowIso(),
|
|
120
|
+
subtasks: subtasks.map((task, idx) => ({
|
|
121
|
+
...task,
|
|
122
|
+
id: `subtask_${Date.now()}_${idx}_${Math.random().toString(36).slice(2, 6)}`,
|
|
123
|
+
autoImprove: false,
|
|
124
|
+
verificationMode: 'none',
|
|
125
|
+
recordFeedback: false,
|
|
126
|
+
})),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Execute a list of planned subtasks in parallel, respecting a concurrency limit.
|
|
132
|
+
* Polls active jobs until all complete, then consolidates the results.
|
|
133
|
+
*/
|
|
134
|
+
async function executeWorkflow(objective, options = {}) {
|
|
135
|
+
const plan = planWorkflow(objective);
|
|
136
|
+
const concurrency = Number(options.concurrency) || DEFAULT_CONCURRENCY;
|
|
137
|
+
const timeoutMs = Number(options.timeoutMs) || 60000; // 60s timeout safety
|
|
138
|
+
|
|
139
|
+
const { FEEDBACK_DIR } = getFeedbackPaths();
|
|
140
|
+
const workflowId = `wf_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
141
|
+
const workflowDir = path.join(FEEDBACK_DIR, 'workflows', workflowId);
|
|
142
|
+
ensureDir(workflowDir);
|
|
143
|
+
|
|
144
|
+
const activeJobs = new Map();
|
|
145
|
+
const queue = [...plan.subtasks];
|
|
146
|
+
const results = [];
|
|
147
|
+
const start = Date.now();
|
|
148
|
+
|
|
149
|
+
const runNext = () => {
|
|
150
|
+
while (activeJobs.size < concurrency && queue.length > 0) {
|
|
151
|
+
const task = queue.shift();
|
|
152
|
+
const launched = launchManagedJob(task, { cwd: options.cwd });
|
|
153
|
+
activeJobs.set(task.id, {
|
|
154
|
+
jobId: launched.jobId,
|
|
155
|
+
taskName: task.name,
|
|
156
|
+
launchedAt: Date.now(),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
runNext();
|
|
162
|
+
|
|
163
|
+
// Polling loop
|
|
164
|
+
await new Promise((resolve) => {
|
|
165
|
+
const interval = setInterval(() => {
|
|
166
|
+
let allDone = true;
|
|
167
|
+
|
|
168
|
+
for (const [taskId, info] of activeJobs.entries()) {
|
|
169
|
+
const jobState = readJobState(info.jobId);
|
|
170
|
+
if (!jobState) {
|
|
171
|
+
allDone = false;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const isTerminal = ['completed', 'failed', 'cancelled'].includes(jobState.status);
|
|
176
|
+
if (isTerminal) {
|
|
177
|
+
results.push({
|
|
178
|
+
taskId,
|
|
179
|
+
taskName: info.taskName,
|
|
180
|
+
jobId: info.jobId,
|
|
181
|
+
status: jobState.status,
|
|
182
|
+
context: jobState.currentContext,
|
|
183
|
+
stageHistory: jobState.stageHistory,
|
|
184
|
+
lastError: jobState.lastError,
|
|
185
|
+
});
|
|
186
|
+
activeJobs.delete(taskId);
|
|
187
|
+
runNext();
|
|
188
|
+
} else {
|
|
189
|
+
allDone = false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const elapsed = Date.now() - start;
|
|
194
|
+
if (allDone && queue.length === 0) {
|
|
195
|
+
clearInterval(interval);
|
|
196
|
+
resolve();
|
|
197
|
+
} else if (elapsed >= timeoutMs) {
|
|
198
|
+
clearInterval(interval);
|
|
199
|
+
// Timeout remaining active tasks
|
|
200
|
+
for (const [taskId, info] of activeJobs.entries()) {
|
|
201
|
+
results.push({
|
|
202
|
+
taskId,
|
|
203
|
+
taskName: info.taskName,
|
|
204
|
+
jobId: info.jobId,
|
|
205
|
+
status: 'timeout',
|
|
206
|
+
lastError: { message: `Subtask timed out after ${timeoutMs}ms`, code: 'TIMEOUT' },
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
resolve();
|
|
210
|
+
}
|
|
211
|
+
}, POLL_INTERVAL_MS);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const durationMs = Date.now() - start;
|
|
215
|
+
|
|
216
|
+
// Compile final markdown report
|
|
217
|
+
const reportPath = path.join(workflowDir, 'report.md');
|
|
218
|
+
const reportContent = compileWorkflowReport(plan, results, durationMs, workflowId);
|
|
219
|
+
fs.writeFileSync(reportPath, reportContent, 'utf8');
|
|
220
|
+
|
|
221
|
+
// Also save the raw execution results JSON
|
|
222
|
+
const resultsJsonPath = path.join(workflowDir, 'results.json');
|
|
223
|
+
fs.writeFileSync(resultsJsonPath, JSON.stringify({
|
|
224
|
+
workflowId,
|
|
225
|
+
objective,
|
|
226
|
+
durationMs,
|
|
227
|
+
plan,
|
|
228
|
+
results,
|
|
229
|
+
}, null, 2) + '\n', 'utf8');
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
workflowId,
|
|
233
|
+
objective,
|
|
234
|
+
durationMs,
|
|
235
|
+
reportPath,
|
|
236
|
+
results,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function compileWorkflowReport(plan, results, durationMs, workflowId) {
|
|
241
|
+
const timestamp = nowIso();
|
|
242
|
+
const totalSubtasks = plan.subtasks.length;
|
|
243
|
+
const completed = results.filter((r) => r.status === 'completed').length;
|
|
244
|
+
const failed = results.filter((r) => r.status === 'failed' || r.status === 'timeout').length;
|
|
245
|
+
|
|
246
|
+
let report = `# Dynamic Workflow Execution Report: ${workflowId}\n\n`;
|
|
247
|
+
report += `**Objective:** ${plan.objective}\n`;
|
|
248
|
+
report += `**Executed At:** ${timestamp}\n`;
|
|
249
|
+
report += `**Duration:** ${(durationMs / 1000).toFixed(2)}s\n`;
|
|
250
|
+
report += `**Status:** ${completed === totalSubtasks ? '✅ SUCCESS' : '⚠️ COMPLETED WITH FAILURES'}\n\n`;
|
|
251
|
+
|
|
252
|
+
report += `## Summary\n`;
|
|
253
|
+
report += `- Total planned subtasks: ${totalSubtasks}\n`;
|
|
254
|
+
report += `- Completed successfully: ${completed}\n`;
|
|
255
|
+
report += `- Failed/Timed out: ${failed}\n\n`;
|
|
256
|
+
|
|
257
|
+
report += `## Subtask Breakdown\n\n`;
|
|
258
|
+
|
|
259
|
+
for (const res of results) {
|
|
260
|
+
const taskPlan = plan.subtasks.find((t) => t.id === res.taskId) || {};
|
|
261
|
+
const commandUsed = taskPlan.stages && taskPlan.stages[0] ? taskPlan.stages[0].command : 'N/A';
|
|
262
|
+
|
|
263
|
+
report += `### ✦ Subtask: \`${res.taskName}\`\n`;
|
|
264
|
+
report += `- **Job ID:** \`${res.jobId}\`\n`;
|
|
265
|
+
report += `- **Status:** ${res.status === 'completed' ? '✅ COMPLETED' : '❌ ' + res.status.toUpperCase()}\n`;
|
|
266
|
+
report += `- **Command Run:** \`${commandUsed}\`\n`;
|
|
267
|
+
|
|
268
|
+
if (res.lastError) {
|
|
269
|
+
report += `- **Error:** \`${res.lastError.message}\` (Code: \`${res.lastError.code}\`)\n`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (res.context) {
|
|
273
|
+
report += `\n**Output Context Preview:**\n\`\`\`json\n`;
|
|
274
|
+
try {
|
|
275
|
+
// Try parsing output context as JSON for clean formatting
|
|
276
|
+
const parsed = JSON.parse(res.context);
|
|
277
|
+
report += JSON.stringify(parsed, null, 2);
|
|
278
|
+
} catch {
|
|
279
|
+
report += res.context.slice(0, 1000) + (res.context.length > 1000 ? '\n... (truncated)' : '');
|
|
280
|
+
}
|
|
281
|
+
report += `\n\`\`\`\n`;
|
|
282
|
+
}
|
|
283
|
+
report += `\n---\n\n`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return report;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
module.exports = {
|
|
290
|
+
planWorkflow,
|
|
291
|
+
executeWorkflow,
|
|
292
|
+
compileWorkflowReport,
|
|
293
|
+
};
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plan Gate — implementing the CodeRabbit "Planning-First" pattern.
|
|
6
|
+
*
|
|
7
|
+
* 1. (Static) Validates structured 'PLAN.md' / 'PRD' content (used in loop-closure).
|
|
8
|
+
* 2. (Dynamic) Intercepts high-risk tool calls during agent execution.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
const RISK_TOOLS = ['Bash', 'Write', 'Edit', 'Deploy'];
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Gate validators (Legacy / Loop Closure)
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
function countTableRows(content, sectionHeading) {
|
|
21
|
+
const sectionRegex = new RegExp(
|
|
22
|
+
`#+\\s*${sectionHeading}[^\\n]*\\n([\\s\\S]*?)(?=\\n#+\\s|$)`,
|
|
23
|
+
);
|
|
24
|
+
const match = content.match(sectionRegex);
|
|
25
|
+
if (!match) return 0;
|
|
26
|
+
|
|
27
|
+
const lines = match[1].split('\n').filter((l) => l.trim().startsWith('|'));
|
|
28
|
+
// Subtract header row and separator row
|
|
29
|
+
const dataRows = lines.filter(
|
|
30
|
+
(l) => !/^\|\s*-+/.test(l.trim()) && !/^\|\s*:?-+/.test(l.trim()),
|
|
31
|
+
);
|
|
32
|
+
// First row is the header
|
|
33
|
+
return Math.max(0, dataRows.length - 1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function countContracts(content) {
|
|
37
|
+
const sectionRegex = /#+\s*Contracts[^\n]*\n([\s\S]*?)(?=\n#+\s|$)/;
|
|
38
|
+
const match = content.match(sectionRegex);
|
|
39
|
+
if (!match) return 0;
|
|
40
|
+
|
|
41
|
+
const section = match[1];
|
|
42
|
+
// Find code blocks and look for interface/type keywords inside them
|
|
43
|
+
const codeBlockRegex = /```[\s\S]*?```/g;
|
|
44
|
+
let count = 0;
|
|
45
|
+
let blockMatch;
|
|
46
|
+
while ((blockMatch = codeBlockRegex.exec(section)) !== null) {
|
|
47
|
+
const block = blockMatch[0];
|
|
48
|
+
const interfaceMatches = block.match(/\b(interface|type)\s+\w+/g);
|
|
49
|
+
if (interfaceMatches) count += interfaceMatches.length;
|
|
50
|
+
}
|
|
51
|
+
return count;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function countValidationScenarios(content) {
|
|
55
|
+
const sectionRegex =
|
|
56
|
+
/#+\s*Validation\s+Checklist[^\n]*\n([\s\S]*?)(?=\n#+\s|$)/;
|
|
57
|
+
const match = content.match(sectionRegex);
|
|
58
|
+
if (!match) return 0;
|
|
59
|
+
|
|
60
|
+
const lines = match[1].split('\n');
|
|
61
|
+
return lines.filter((l) => /^\s*-\s*\[\s*\]/.test(l)).length;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getStatus(content) {
|
|
65
|
+
const match = content.match(/#+\s*Status[^\n]*\n\s*(\S+)/);
|
|
66
|
+
return match ? match[1].trim() : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validatePlan(content) {
|
|
70
|
+
const questionCount = countTableRows(content, 'Clarifying Questions Resolved');
|
|
71
|
+
const contractCount = countContracts(content);
|
|
72
|
+
const scenarioCount = countValidationScenarios(content);
|
|
73
|
+
const status = getStatus(content);
|
|
74
|
+
|
|
75
|
+
const gates = [
|
|
76
|
+
{
|
|
77
|
+
name: 'Clarifying Questions',
|
|
78
|
+
pass: questionCount >= 3,
|
|
79
|
+
detail: `${questionCount} questions resolved`,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'Contracts Defined',
|
|
83
|
+
pass: contractCount >= 1,
|
|
84
|
+
detail: `${contractCount} interface${contractCount !== 1 ? 's' : ''} found`,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'Validation Checklist',
|
|
88
|
+
pass: scenarioCount >= 2,
|
|
89
|
+
detail: `${scenarioCount} scenarios defined`,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'Status',
|
|
93
|
+
pass: status !== 'COMPLETE',
|
|
94
|
+
detail:
|
|
95
|
+
status === 'COMPLETE'
|
|
96
|
+
? 'COMPLETE (already finished — cannot re-approve)'
|
|
97
|
+
: `${status || 'UNKNOWN'} (not COMPLETE)`,
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
const allPass = gates.every((g) => g.pass);
|
|
102
|
+
return { gates, allPass };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function formatReport(result) {
|
|
106
|
+
const lines = result.gates.map(
|
|
107
|
+
(g) => `${g.pass ? '✅' : '❌'} ${g.name}: ${g.detail}`,
|
|
108
|
+
);
|
|
109
|
+
lines.push('');
|
|
110
|
+
lines.push(
|
|
111
|
+
result.allPass
|
|
112
|
+
? 'RESULT: PASS — all gates satisfied'
|
|
113
|
+
: 'RESULT: BLOCKED — resolve issues above before spawning agents',
|
|
114
|
+
);
|
|
115
|
+
return lines.join('\n');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Dynamic Gating (CodeRabbit Orchestration Pattern)
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Evaluates the planning state for the current tool call.
|
|
124
|
+
*/
|
|
125
|
+
function evaluatePlanGate(toolName, toolInput, options = {}) {
|
|
126
|
+
if (!RISK_TOOLS.includes(toolName)) return null;
|
|
127
|
+
|
|
128
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
129
|
+
const planPath = path.join(projectRoot, 'PLAN.md');
|
|
130
|
+
|
|
131
|
+
// Tier 1: Existence Check
|
|
132
|
+
if (!fs.existsSync(planPath)) {
|
|
133
|
+
return {
|
|
134
|
+
decision: 'warn',
|
|
135
|
+
gate: 'plan-gate-missing',
|
|
136
|
+
message: '⚠️ THUMBGATE: High-risk tool call without a PLAN.md. Please create a plan documenting your intent and assumptions.',
|
|
137
|
+
severity: 'high'
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Tier 2: Alignment Check (Simple)
|
|
142
|
+
const planContent = fs.readFileSync(planPath, 'utf8');
|
|
143
|
+
const action = toolName === 'Bash' ? toolInput.command : toolInput.filePath;
|
|
144
|
+
|
|
145
|
+
if (action && !planContent.toLowerCase().includes(path.basename(action).toLowerCase())) {
|
|
146
|
+
return {
|
|
147
|
+
decision: 'warn',
|
|
148
|
+
gate: 'plan-gate-drift',
|
|
149
|
+
message: `⚠️ THUMBGATE: Strategic Drift detected. The action "${action}" is not mentioned in your PLAN.md.`,
|
|
150
|
+
severity: 'medium'
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Tier 3: Implicit Assumption Extraction
|
|
155
|
+
const assumptions = extractAssumptions(planContent);
|
|
156
|
+
if (assumptions.length > 0) {
|
|
157
|
+
return {
|
|
158
|
+
decision: 'warn',
|
|
159
|
+
gate: 'plan-gate-assumptions',
|
|
160
|
+
message: '🔍 THUMBGATE: Explicitly verify these implicit assumptions before proceeding:\n- ' + assumptions.join('\n- '),
|
|
161
|
+
severity: 'medium'
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Tier 4: Self-Critique / Risk Mitigation Check (Tip #8)
|
|
166
|
+
const hasCritique = /(?:critique|self-critique|risk|mitigation|alternative|flaw|weakness|pitfall)/i.test(planContent);
|
|
167
|
+
if (!hasCritique) {
|
|
168
|
+
return {
|
|
169
|
+
decision: 'warn',
|
|
170
|
+
gate: 'plan-gate-critique-missing',
|
|
171
|
+
message: '🧐 THUMBGATE: No Self-Critique/Risk Analysis detected in your PLAN.md. Please add a "Critique", "Risks", or "Mitigations" section to evaluate potential flaws in this plan before executing high-risk tools.',
|
|
172
|
+
severity: 'medium'
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Scans plan content for "Assumes" or "Implicit" keywords.
|
|
181
|
+
*/
|
|
182
|
+
function extractAssumptions(content) {
|
|
183
|
+
const lines = content.split('\n');
|
|
184
|
+
const assumptions = [];
|
|
185
|
+
const regex = /(?:assume|assumption|implicit|pre-requisite|depends on)s?[:\-]?\s*(.*)/i;
|
|
186
|
+
|
|
187
|
+
for (const line of lines) {
|
|
188
|
+
const match = line.match(regex);
|
|
189
|
+
if (match && match[1].trim()) {
|
|
190
|
+
assumptions.push(match[1].trim());
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return assumptions.slice(0, 5);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Main
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
function run() {
|
|
201
|
+
const args = process.argv.slice(2);
|
|
202
|
+
const jsonFlag = args.includes('--json');
|
|
203
|
+
const filePath = args.find((a) => a !== '--json');
|
|
204
|
+
|
|
205
|
+
if (!filePath) {
|
|
206
|
+
console.error('Usage: node scripts/plan-gate.js <plan-file.md> [--json]');
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const resolved = path.resolve(filePath);
|
|
211
|
+
if (!fs.existsSync(resolved)) {
|
|
212
|
+
console.error(`File not found: ${resolved}`);
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const content = fs.readFileSync(resolved, 'utf-8');
|
|
217
|
+
const result = validatePlan(content);
|
|
218
|
+
|
|
219
|
+
if (jsonFlag) {
|
|
220
|
+
console.log(JSON.stringify(result, null, 2));
|
|
221
|
+
} else {
|
|
222
|
+
console.log(formatReport(result));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
process.exit(result.allPass ? 0 : 1);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Export for testing
|
|
229
|
+
module.exports = {
|
|
230
|
+
validatePlan,
|
|
231
|
+
formatReport,
|
|
232
|
+
countTableRows,
|
|
233
|
+
countContracts,
|
|
234
|
+
countValidationScenarios,
|
|
235
|
+
getStatus,
|
|
236
|
+
evaluatePlanGate,
|
|
237
|
+
extractAssumptions,
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// Run only when executed directly
|
|
241
|
+
if (require.main === module) {
|
|
242
|
+
run();
|
|
243
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const PRIMARY_PLAUSIBLE_DOMAIN = 'thumbgate.ai';
|
|
4
|
+
const FALLBACK_REGISTERED_PLAUSIBLE_DOMAIN = 'thumbgate-production.up.railway.app';
|
|
5
|
+
|
|
6
|
+
function splitDomains(value) {
|
|
7
|
+
return String(value || '')
|
|
8
|
+
.split(/[\s,]+/)
|
|
9
|
+
.map((domain) => domain.trim().toLowerCase())
|
|
10
|
+
.filter(Boolean);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeDomain(value) {
|
|
14
|
+
const input = String(value || '').trim();
|
|
15
|
+
if (!input) return '';
|
|
16
|
+
try {
|
|
17
|
+
return new URL(input.includes('://') ? input : `https://${input}`).hostname.toLowerCase();
|
|
18
|
+
} catch {
|
|
19
|
+
const withoutProtocol = input.replace(/^https?:\/\//i, '');
|
|
20
|
+
const hostnameAndPort = withoutProtocol.split('/')[0];
|
|
21
|
+
return hostnameAndPort.toLowerCase().split(':')[0];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getConfiguredRegisteredDomains(env = process.env) {
|
|
26
|
+
const configured = [
|
|
27
|
+
...splitDomains(env.PLAUSIBLE_SITE_ID),
|
|
28
|
+
...splitDomains(env.PLAUSIBLE_SITE_IDS),
|
|
29
|
+
...splitDomains(env.THUMBGATE_PLAUSIBLE_REGISTERED_DOMAINS),
|
|
30
|
+
...splitDomains(env.PLAUSIBLE_REGISTERED_DOMAINS),
|
|
31
|
+
].map(normalizeDomain).filter(Boolean);
|
|
32
|
+
|
|
33
|
+
return [...new Set([
|
|
34
|
+
FALLBACK_REGISTERED_PLAUSIBLE_DOMAIN,
|
|
35
|
+
...configured,
|
|
36
|
+
])];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isPlausibleDomainRegistered(domain, env = process.env) {
|
|
40
|
+
const normalized = normalizeDomain(domain);
|
|
41
|
+
if (!normalized) return false;
|
|
42
|
+
return getConfiguredRegisteredDomains(env).includes(normalized);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolvePlausibleDataDomain({ host = '', env = process.env } = {}) {
|
|
46
|
+
const explicit = normalizeDomain(env.THUMBGATE_PLAUSIBLE_DOMAIN);
|
|
47
|
+
if (explicit) return explicit;
|
|
48
|
+
|
|
49
|
+
const normalizedHost = normalizeDomain(host);
|
|
50
|
+
if (isPlausibleDomainRegistered(normalizedHost, env)) {
|
|
51
|
+
return normalizedHost;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Prevent local/loopback traffic from mapping to the production Plausible site domain
|
|
55
|
+
const isLocal = normalizedHost === 'localhost' ||
|
|
56
|
+
normalizedHost === '127.0.0.1' ||
|
|
57
|
+
normalizedHost === '::1' ||
|
|
58
|
+
normalizedHost.endsWith('.local') ||
|
|
59
|
+
normalizedHost.startsWith('192.168.') ||
|
|
60
|
+
normalizedHost.startsWith('10.');
|
|
61
|
+
if (isLocal) {
|
|
62
|
+
return 'localhost';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return FALLBACK_REGISTERED_PLAUSIBLE_DOMAIN;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function analyzePlausibleDomainCoverage({
|
|
69
|
+
emittedDomains = [],
|
|
70
|
+
registeredDomains = [],
|
|
71
|
+
primaryDomain = PRIMARY_PLAUSIBLE_DOMAIN,
|
|
72
|
+
} = {}) {
|
|
73
|
+
const emitted = [...new Set(emittedDomains.map(normalizeDomain).filter(Boolean))];
|
|
74
|
+
const registered = [...new Set(registeredDomains.map(normalizeDomain).filter(Boolean))];
|
|
75
|
+
const registeredSet = new Set(registered);
|
|
76
|
+
const missingEmittedDomains = emitted.filter((domain) => !registeredSet.has(domain));
|
|
77
|
+
const primaryRegistered = registeredSet.has(normalizeDomain(primaryDomain));
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
ok: missingEmittedDomains.length === 0 && primaryRegistered,
|
|
81
|
+
emittedDomains: emitted,
|
|
82
|
+
registeredDomains: registered,
|
|
83
|
+
missingEmittedDomains,
|
|
84
|
+
primaryDomain: normalizeDomain(primaryDomain),
|
|
85
|
+
primaryRegistered,
|
|
86
|
+
severity: missingEmittedDomains.length > 0 || !primaryRegistered ? 'critical' : 'ok',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = {
|
|
91
|
+
PRIMARY_PLAUSIBLE_DOMAIN,
|
|
92
|
+
FALLBACK_REGISTERED_PLAUSIBLE_DOMAIN,
|
|
93
|
+
splitDomains,
|
|
94
|
+
normalizeDomain,
|
|
95
|
+
getConfiguredRegisteredDomains,
|
|
96
|
+
isPlausibleDomainRegistered,
|
|
97
|
+
resolvePlausibleDataDomain,
|
|
98
|
+
analyzePlausibleDomainCoverage,
|
|
99
|
+
};
|