thumbgate 1.5.0 โ 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +230 -228
- package/adapters/README.md +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +4 -2
- package/adapters/mcp/server-stdio.js +34 -3
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +21 -8
- package/bin/postinstall.js +25 -17
- package/config/evals/agent-safety-eval.json +131 -0
- package/config/github-about.json +5 -2
- package/config/specs/agent-safety.json +79 -0
- package/package.json +44 -8
- package/public/compare.html +3 -3
- package/public/guide.html +2 -2
- package/public/index.html +230 -98
- package/scripts/auto-wire-hooks.js +77 -27
- package/scripts/bot-detection.js +165 -0
- package/scripts/cli-feedback.js +6 -2
- package/scripts/commercial-offer.js +4 -4
- package/scripts/dashboard.js +152 -2
- package/scripts/decision-trace.js +354 -0
- package/scripts/feedback-loop.js +4 -8
- package/scripts/rate-limiter.js +77 -24
- package/scripts/sales-pipeline.js +681 -0
- package/scripts/session-episode-store.js +329 -0
- package/scripts/session-health-sensor.js +242 -0
- package/scripts/spec-gate.js +362 -0
- package/scripts/statusline.sh +6 -9
- package/skills/thumbgate/SKILL.md +1 -1
- package/src/api/server.js +368 -12
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Spec Gate โ proactive correctness enforcement for agent actions.
|
|
6
|
+
*
|
|
7
|
+
* Prevention rules are reactive (learned from past failures). Spec gates are
|
|
8
|
+
* proactive: operators define "correct" upfront as a lightweight spec, and
|
|
9
|
+
* gates enforce it from the start of a session.
|
|
10
|
+
*
|
|
11
|
+
* Spec format (JSON):
|
|
12
|
+
* {
|
|
13
|
+
* "name": "deployment-safety",
|
|
14
|
+
* "constraints": [
|
|
15
|
+
* { "id": "no-force-push", "scope": "bash", "deny": "git push -f|--force", "reason": "..." },
|
|
16
|
+
* { "id": "no-secrets", "scope": "content", "deny": "AKIA[A-Z0-9]{16}", "reason": "..." }
|
|
17
|
+
* ],
|
|
18
|
+
* "invariants": [
|
|
19
|
+
* { "id": "tests-pass", "require": "npm test", "before": "git commit", "reason": "..." }
|
|
20
|
+
* ]
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* Integration: feeds into gates-engine as an additive spec layer alongside
|
|
24
|
+
* default gates and auto-promoted prevention rules.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const crypto = require('node:crypto');
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
const { readJsonl, appendJsonl, ensureParentDir } = require('./fs-utils');
|
|
31
|
+
const { resolveFeedbackDir } = require('./feedback-paths');
|
|
32
|
+
|
|
33
|
+
const SPEC_DIR = path.join(__dirname, '..', 'config', 'specs');
|
|
34
|
+
const SPEC_AUDIT_FILE = 'spec-gate-audit.jsonl';
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Spec Loading
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
function loadSpec(specPath) {
|
|
41
|
+
const resolved = path.resolve(specPath);
|
|
42
|
+
const raw = fs.readFileSync(resolved, 'utf8');
|
|
43
|
+
const spec = JSON.parse(raw);
|
|
44
|
+
return validateSpec(spec, resolved);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function loadSpecDir(dirPath = SPEC_DIR) {
|
|
48
|
+
if (!fs.existsSync(dirPath)) return [];
|
|
49
|
+
return fs.readdirSync(dirPath)
|
|
50
|
+
.filter((f) => f.endsWith('.json'))
|
|
51
|
+
.map((f) => {
|
|
52
|
+
try {
|
|
53
|
+
return loadSpec(path.join(dirPath, f));
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validateSpec(spec, sourcePath = null) {
|
|
62
|
+
if (!spec || typeof spec !== 'object') {
|
|
63
|
+
throw new Error('Spec must be a JSON object.');
|
|
64
|
+
}
|
|
65
|
+
const name = normalizeText(spec.name, 120);
|
|
66
|
+
if (!name) throw new Error('Spec requires a "name" field.');
|
|
67
|
+
|
|
68
|
+
const constraints = Array.isArray(spec.constraints)
|
|
69
|
+
? spec.constraints.map((c) => validateConstraint(c)).filter(Boolean)
|
|
70
|
+
: [];
|
|
71
|
+
const invariants = Array.isArray(spec.invariants)
|
|
72
|
+
? spec.invariants.map((inv) => validateInvariant(inv)).filter(Boolean)
|
|
73
|
+
: [];
|
|
74
|
+
|
|
75
|
+
if (constraints.length === 0 && invariants.length === 0) {
|
|
76
|
+
throw new Error('Spec must have at least one constraint or invariant.');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
name,
|
|
81
|
+
description: normalizeText(spec.description, 500) || '',
|
|
82
|
+
version: normalizeText(spec.version, 20) || '1',
|
|
83
|
+
sourcePath: sourcePath || null,
|
|
84
|
+
constraints,
|
|
85
|
+
invariants,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function validateConstraint(raw) {
|
|
90
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
91
|
+
const id = normalizeText(raw.id, 80);
|
|
92
|
+
const deny = normalizeText(raw.deny, 500);
|
|
93
|
+
if (!id || !deny) return null;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
id,
|
|
97
|
+
scope: normalizeText(raw.scope, 40) || 'any',
|
|
98
|
+
deny,
|
|
99
|
+
reason: normalizeText(raw.reason, 500) || 'Blocked by spec constraint.',
|
|
100
|
+
severity: normalizeSeverity(raw.severity),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validateInvariant(raw) {
|
|
105
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
106
|
+
const id = normalizeText(raw.id, 80);
|
|
107
|
+
const require_ = normalizeText(raw.require, 200);
|
|
108
|
+
const before = normalizeText(raw.before, 200);
|
|
109
|
+
if (!id || !require_ || !before) return null;
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
id,
|
|
113
|
+
require: require_,
|
|
114
|
+
before,
|
|
115
|
+
reason: normalizeText(raw.reason, 500) || 'Invariant not satisfied.',
|
|
116
|
+
severity: normalizeSeverity(raw.severity),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Constraint Evaluation
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
function evaluateConstraints(spec, { tool, command, content, sandbox } = {}) {
|
|
125
|
+
const results = [];
|
|
126
|
+
const input = buildEvaluationInput({ tool, command, content, sandbox });
|
|
127
|
+
|
|
128
|
+
for (const constraint of spec.constraints) {
|
|
129
|
+
const matched = matchConstraint(constraint, input);
|
|
130
|
+
results.push({
|
|
131
|
+
specName: spec.name,
|
|
132
|
+
constraintId: constraint.id,
|
|
133
|
+
type: 'constraint',
|
|
134
|
+
passed: !matched,
|
|
135
|
+
reason: matched ? constraint.reason : null,
|
|
136
|
+
severity: constraint.severity,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return results;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function evaluateInvariants(spec, { action, sessionActions = [] } = {}) {
|
|
144
|
+
const results = [];
|
|
145
|
+
|
|
146
|
+
for (const inv of spec.invariants) {
|
|
147
|
+
if (!actionMatches(action, inv.before)) continue;
|
|
148
|
+
const satisfied = sessionActions.some((a) => actionMatches(a, inv.require));
|
|
149
|
+
results.push({
|
|
150
|
+
specName: spec.name,
|
|
151
|
+
invariantId: inv.id,
|
|
152
|
+
type: 'invariant',
|
|
153
|
+
passed: satisfied,
|
|
154
|
+
reason: satisfied ? null : inv.reason,
|
|
155
|
+
severity: inv.severity,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return results;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function evaluateAction(specs, context = {}) {
|
|
163
|
+
const allResults = [];
|
|
164
|
+
|
|
165
|
+
for (const spec of specs) {
|
|
166
|
+
const constraintResults = evaluateConstraints(spec, context);
|
|
167
|
+
const invariantResults = evaluateInvariants(spec, context);
|
|
168
|
+
allResults.push(...constraintResults, ...invariantResults);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const blocked = allResults.filter((r) => !r.passed);
|
|
172
|
+
return {
|
|
173
|
+
allowed: blocked.length === 0,
|
|
174
|
+
results: allResults,
|
|
175
|
+
blocked,
|
|
176
|
+
blockedCount: blocked.length,
|
|
177
|
+
totalChecked: allResults.length,
|
|
178
|
+
evaluatedAt: new Date().toISOString(),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Gate Config Generation
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
function specToGateConfigs(spec) {
|
|
187
|
+
return spec.constraints.map((c) => ({
|
|
188
|
+
id: `spec:${spec.name}:${c.id}`,
|
|
189
|
+
layer: 'Spec',
|
|
190
|
+
pattern: c.deny,
|
|
191
|
+
action: 'block',
|
|
192
|
+
message: `[Spec: ${spec.name}] ${c.reason}`,
|
|
193
|
+
severity: c.severity,
|
|
194
|
+
source: 'spec',
|
|
195
|
+
specName: spec.name,
|
|
196
|
+
specVersion: spec.version,
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function allSpecsToGateConfigs(specs) {
|
|
201
|
+
return specs.flatMap(specToGateConfigs);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Audit Trail
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
function getAuditPath({ feedbackDir } = {}) {
|
|
209
|
+
const dir = feedbackDir || resolveFeedbackDir();
|
|
210
|
+
return path.join(dir, SPEC_AUDIT_FILE);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function recordSpecAudit(evaluation, context = {}, options = {}) {
|
|
214
|
+
const entry = {
|
|
215
|
+
id: `specaudit_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`,
|
|
216
|
+
timestamp: new Date().toISOString(),
|
|
217
|
+
allowed: evaluation.allowed,
|
|
218
|
+
blockedCount: evaluation.blockedCount,
|
|
219
|
+
totalChecked: evaluation.totalChecked,
|
|
220
|
+
blocked: evaluation.blocked,
|
|
221
|
+
tool: context.tool || null,
|
|
222
|
+
command: normalizeText(context.command, 200) || null,
|
|
223
|
+
action: normalizeText(context.action, 200) || null,
|
|
224
|
+
};
|
|
225
|
+
appendJsonl(getAuditPath(options), entry);
|
|
226
|
+
return entry;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function loadSpecAudit(options = {}) {
|
|
230
|
+
return readJsonl(getAuditPath(options));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function summarizeSpecAudit(entries) {
|
|
234
|
+
let totalChecks = 0;
|
|
235
|
+
let totalBlocked = 0;
|
|
236
|
+
const bySpec = new Map();
|
|
237
|
+
const byConstraint = new Map();
|
|
238
|
+
|
|
239
|
+
for (const entry of entries) {
|
|
240
|
+
totalChecks += entry.totalChecked || 0;
|
|
241
|
+
totalBlocked += entry.blockedCount || 0;
|
|
242
|
+
for (const block of entry.blocked || []) {
|
|
243
|
+
const specKey = block.specName || 'unknown';
|
|
244
|
+
bySpec.set(specKey, (bySpec.get(specKey) || 0) + 1);
|
|
245
|
+
const cKey = block.constraintId || block.invariantId || 'unknown';
|
|
246
|
+
byConstraint.set(cKey, (byConstraint.get(cKey) || 0) + 1);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
totalEvaluations: entries.length,
|
|
252
|
+
totalChecks,
|
|
253
|
+
totalBlocked,
|
|
254
|
+
blockRate: entries.length > 0 ? Math.round((totalBlocked / Math.max(totalChecks, 1)) * 100) : 0,
|
|
255
|
+
topBlockedSpecs: Array.from(bySpec.entries())
|
|
256
|
+
.sort(([, a], [, b]) => b - a)
|
|
257
|
+
.slice(0, 10)
|
|
258
|
+
.map(([name, count]) => ({ name, count })),
|
|
259
|
+
topBlockedConstraints: Array.from(byConstraint.entries())
|
|
260
|
+
.sort(([, a], [, b]) => b - a)
|
|
261
|
+
.slice(0, 10)
|
|
262
|
+
.map(([id, count]) => ({ id, count })),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// Helpers
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
function buildEvaluationInput({ tool, command, content, sandbox } = {}) {
|
|
271
|
+
return {
|
|
272
|
+
bash: normalizeText(command, 2000) || '',
|
|
273
|
+
content: normalizeText(content, 5000) || '',
|
|
274
|
+
tool: normalizeText(tool, 80) || '',
|
|
275
|
+
sandbox: normalizeText(sandbox, 2000) || '',
|
|
276
|
+
combined: [command, content, tool, sandbox].filter(Boolean).join(' '),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function matchConstraint(constraint, input) {
|
|
281
|
+
try {
|
|
282
|
+
const regex = new RegExp(constraint.deny, 'i');
|
|
283
|
+
const scope = constraint.scope || 'any';
|
|
284
|
+
if (scope === 'bash') return regex.test(input.bash);
|
|
285
|
+
if (scope === 'content') return regex.test(input.content);
|
|
286
|
+
if (scope === 'tool') return regex.test(input.tool);
|
|
287
|
+
if (scope === 'sandbox') return regex.test(input.sandbox);
|
|
288
|
+
return regex.test(input.combined);
|
|
289
|
+
} catch {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function actionMatches(action, pattern) {
|
|
295
|
+
if (!action || !pattern) return false;
|
|
296
|
+
try {
|
|
297
|
+
return new RegExp(pattern, 'i').test(String(action));
|
|
298
|
+
} catch {
|
|
299
|
+
return String(action).toLowerCase().includes(String(pattern).toLowerCase());
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function normalizeText(value, maxLength = 500) {
|
|
304
|
+
if (value === undefined || value === null) return null;
|
|
305
|
+
const text = String(value).trim();
|
|
306
|
+
return text ? text.slice(0, maxLength) : null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function normalizeSeverity(value) {
|
|
310
|
+
const valid = ['critical', 'warning', 'info'];
|
|
311
|
+
const normalized = normalizeText(value, 20);
|
|
312
|
+
return normalized && valid.includes(normalized) ? normalized : 'critical';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// CLI
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
function isCliInvocation(argv = process.argv) {
|
|
320
|
+
const invokedPath = argv[1];
|
|
321
|
+
return invokedPath ? path.resolve(invokedPath) === __filename : false;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (isCliInvocation()) {
|
|
325
|
+
const command = process.argv[2] || 'check';
|
|
326
|
+
const specDir = process.argv[3] || SPEC_DIR;
|
|
327
|
+
|
|
328
|
+
if (command === 'check') {
|
|
329
|
+
const specs = loadSpecDir(specDir);
|
|
330
|
+
console.log(JSON.stringify({
|
|
331
|
+
specsLoaded: specs.length,
|
|
332
|
+
totalConstraints: specs.reduce((n, s) => n + s.constraints.length, 0),
|
|
333
|
+
totalInvariants: specs.reduce((n, s) => n + s.invariants.length, 0),
|
|
334
|
+
specs: specs.map((s) => ({ name: s.name, version: s.version, constraints: s.constraints.length, invariants: s.invariants.length })),
|
|
335
|
+
}, null, 2));
|
|
336
|
+
} else if (command === 'gates') {
|
|
337
|
+
const specs = loadSpecDir(specDir);
|
|
338
|
+
const gates = allSpecsToGateConfigs(specs);
|
|
339
|
+
console.log(JSON.stringify({ version: 1, gates }, null, 2));
|
|
340
|
+
} else if (command === 'audit') {
|
|
341
|
+
const entries = loadSpecAudit();
|
|
342
|
+
console.log(JSON.stringify(summarizeSpecAudit(entries), null, 2));
|
|
343
|
+
} else {
|
|
344
|
+
console.error(`Unknown command: ${command}. Use: check, gates, audit`);
|
|
345
|
+
process.exit(1);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
module.exports = {
|
|
350
|
+
SPEC_DIR,
|
|
351
|
+
allSpecsToGateConfigs,
|
|
352
|
+
evaluateAction,
|
|
353
|
+
evaluateConstraints,
|
|
354
|
+
evaluateInvariants,
|
|
355
|
+
loadSpec,
|
|
356
|
+
loadSpecAudit,
|
|
357
|
+
loadSpecDir,
|
|
358
|
+
recordSpecAudit,
|
|
359
|
+
specToGateConfigs,
|
|
360
|
+
summarizeSpecAudit,
|
|
361
|
+
validateSpec,
|
|
362
|
+
};
|
package/scripts/statusline.sh
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# ThumbGate Status Line for Claude Code
|
|
2
|
+
# ThumbGate Status Line for Claude Code and Codex
|
|
3
3
|
# Shows ThumbGate feedback stats + package version/tier at a glance.
|
|
4
|
-
# Installed by: npx thumbgate init --agent claude-code
|
|
4
|
+
# Installed by: npx thumbgate init --agent claude-code|codex
|
|
5
5
|
|
|
6
6
|
# Resolve script directory safely (CodeQL: no uncontrolled paths)
|
|
7
7
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
|
@@ -152,22 +152,19 @@ osc_link() {
|
|
|
152
152
|
local url="$1"
|
|
153
153
|
local label="$2"
|
|
154
154
|
case "$url" in
|
|
155
|
-
|
|
155
|
+
"") printf '%s' "$label" ;;
|
|
156
156
|
*) printf '\033]8;;%s\007%s\033]8;;\007' "$url" "$label" ;;
|
|
157
157
|
esac
|
|
158
158
|
return 0
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
UP_LINK="$(osc_link "$UP_URL" "๐")"
|
|
162
|
+
DOWN_LINK="$(osc_link "$DOWN_URL" "๐")"
|
|
163
163
|
DASHBOARD_LINK="$(osc_link "$DASHBOARD_URL" "$DASHBOARD_LABEL")"
|
|
164
164
|
LESSONS_LINK="$(osc_link "$LESSONS_URL" "$LESSONS_LABEL")"
|
|
165
165
|
LATEST_LESSON_LINK=""
|
|
166
166
|
if [ -n "$LESSON_LABEL" ]; then
|
|
167
167
|
_DISPLAY_LINK="$LESSON_LINK"
|
|
168
|
-
case "$_DISPLAY_LINK" in
|
|
169
|
-
*localhost*|*127.0.0.1*) _DISPLAY_LINK="" ;;
|
|
170
|
-
esac
|
|
171
168
|
if [ -n "$LESSON_TEXT" ]; then
|
|
172
169
|
LATEST_LESSON_LINK="$(osc_link "$_DISPLAY_LINK" "${LESSON_LABEL}: ${LESSON_TEXT}")"
|
|
173
170
|
else
|
|
@@ -182,7 +179,7 @@ if [ "$UP" = "0" ] && [ "$DOWN" = "0" ]; then
|
|
|
182
179
|
[ -n "$LATEST_LESSON_LINK" ] && LINE="${LINE} ยท ${D}${LATEST_LESSON_LINK}${RST}"
|
|
183
180
|
printf '%b\n' "$LINE"
|
|
184
181
|
else
|
|
185
|
-
LINE="${LINE} ยท ${G}${BD}${UP}${RST}${
|
|
182
|
+
LINE="${LINE} ยท ${G}${BD}${UP}${RST}${UP_LINK} ${R}${BD}${DOWN}${RST}${DOWN_LINK} ${ARROW}"
|
|
186
183
|
|
|
187
184
|
# Control Tower alerts (if any)
|
|
188
185
|
[ "${SLO_V:-0}" -gt 0 ] && LINE="${LINE} ${R}${SLO_V} SLO${RST}"
|
|
@@ -92,7 +92,7 @@ Bounded retrieval of relevant feedback history for the current task. The agent g
|
|
|
92
92
|
| Dashboard | - | Yes | Yes |
|
|
93
93
|
| DPO export | - | Yes | Yes |
|
|
94
94
|
| Seats | 1 | 1 | Per-seat |
|
|
95
|
-
| Price | $0 | $19/mo | $
|
|
95
|
+
| Price | $0 | $19/mo | $49/seat/mo |
|
|
96
96
|
|
|
97
97
|
Start a 7-day free trial of Pro: <https://buy.stripe.com/fZu9AT3Ug6zcdWh0XN3sI08>
|
|
98
98
|
|