@delegance/claude-autopilot 2.5.0 → 5.0.0-alpha.2
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/CHANGELOG.md +63 -0
- package/README.md +169 -106
- package/bin/_launcher.js +77 -0
- package/bin/claude-autopilot.js +3 -0
- package/bin/guardrail.js +3 -0
- package/package.json +23 -9
- package/presets/generic/guardrail.config.yaml +35 -0
- package/presets/generic/stack.md +40 -0
- package/presets/nextjs-supabase/{autopilot.config.yaml → guardrail.config.yaml} +7 -0
- package/scripts/autoregress.ts +27 -11
- package/skills/autopilot/SKILL.md +170 -0
- package/skills/claude-autopilot.md +80 -0
- package/skills/guardrail.md +39 -0
- package/skills/migrate/SKILL.md +83 -0
- package/src/adapters/council/claude.ts +41 -0
- package/src/adapters/council/openai.ts +40 -0
- package/src/adapters/council/types.ts +7 -0
- package/src/adapters/loader.ts +7 -7
- package/src/adapters/review-engine/auto.ts +2 -2
- package/src/adapters/review-engine/claude.ts +9 -11
- package/src/adapters/review-engine/codex.ts +9 -11
- package/src/adapters/review-engine/gemini.ts +9 -11
- package/src/adapters/review-engine/openai-compatible.ts +10 -12
- package/src/adapters/review-engine/parse-output.ts +32 -6
- package/src/adapters/review-engine/prompt-builder.ts +19 -0
- package/src/adapters/review-engine/types.ts +1 -1
- package/src/adapters/vcs-host/commit-status.ts +39 -0
- package/src/adapters/vcs-host/github.ts +2 -2
- package/src/cli/baseline.ts +125 -0
- package/src/cli/ci.ts +11 -8
- package/src/cli/costs.ts +2 -2
- package/src/cli/council.ts +96 -0
- package/src/cli/detector.ts +21 -5
- package/src/cli/explain.ts +197 -0
- package/src/cli/fix.ts +173 -111
- package/src/cli/hook.ts +72 -27
- package/src/cli/ignore-helper.ts +116 -0
- package/src/cli/index.ts +355 -31
- package/src/cli/init.ts +12 -12
- package/src/cli/lsp.ts +200 -0
- package/src/cli/mcp.ts +206 -0
- package/src/cli/pr-comment.ts +5 -5
- package/src/cli/pr-desc.ts +168 -0
- package/src/cli/pr-review-comments.ts +3 -3
- package/src/cli/pr.ts +76 -0
- package/src/cli/preflight.ts +109 -32
- package/src/cli/report.ts +186 -0
- package/src/cli/run.ts +140 -36
- package/src/cli/scan.ts +233 -0
- package/src/cli/setup.ts +121 -15
- package/src/cli/test-gen.ts +125 -0
- package/src/cli/triage.ts +137 -0
- package/src/cli/watch.ts +52 -31
- package/src/cli/worker.ts +109 -0
- package/src/core/cache/review-cache.ts +2 -2
- package/src/core/chunking/index.ts +2 -2
- package/src/core/config/loader.ts +10 -10
- package/src/core/config/preset-resolver.ts +6 -6
- package/src/core/config/schema.ts +103 -2
- package/src/core/config/types.ts +57 -2
- package/src/core/council/config.ts +71 -0
- package/src/core/council/context.ts +17 -0
- package/src/core/council/runner.ts +83 -0
- package/src/core/council/types.ts +45 -0
- package/src/core/detect/llm-key.ts +89 -0
- package/src/core/detect/workspaces.ts +103 -0
- package/src/core/errors.ts +4 -4
- package/src/core/fix/generator.ts +149 -0
- package/src/core/ignore/index.ts +4 -4
- package/src/core/mcp/concurrency.ts +16 -0
- package/src/core/mcp/handlers/fix-finding.ts +126 -0
- package/src/core/mcp/handlers/get-capabilities.ts +62 -0
- package/src/core/mcp/handlers/get-findings.ts +36 -0
- package/src/core/mcp/handlers/review-diff.ts +65 -0
- package/src/core/mcp/handlers/scan-files.ts +65 -0
- package/src/core/mcp/handlers/validate-fix.ts +41 -0
- package/src/core/mcp/run-store.ts +85 -0
- package/src/core/mcp/workspace.ts +35 -0
- package/src/core/persist/baseline.ts +112 -0
- package/src/core/persist/cost-log.ts +1 -1
- package/src/core/persist/findings-cache.ts +1 -1
- package/src/core/persist/triage.ts +112 -0
- package/src/core/phases/static-rules.ts +18 -5
- package/src/core/pipeline/review-phase.ts +65 -26
- package/src/core/pipeline/run.ts +42 -10
- package/src/core/runtime/lock.ts +2 -2
- package/src/core/runtime/state.ts +2 -2
- package/src/core/schema-alignment/detector.ts +59 -0
- package/src/core/schema-alignment/extractor/index.ts +24 -0
- package/src/core/schema-alignment/extractor/prisma.ts +21 -0
- package/src/core/schema-alignment/extractor/sql.ts +99 -0
- package/src/core/schema-alignment/llm-check.ts +91 -0
- package/src/core/schema-alignment/scanner.ts +107 -0
- package/src/core/schema-alignment/types.ts +43 -0
- package/src/core/shell.ts +3 -3
- package/src/core/static-rules/registry.ts +17 -8
- package/src/core/static-rules/rules/brand-tokens.ts +145 -0
- package/src/core/static-rules/rules/hardcoded-secrets.ts +27 -1
- package/src/core/static-rules/rules/insecure-redirect.ts +67 -0
- package/src/core/static-rules/rules/missing-auth.ts +70 -0
- package/src/core/static-rules/rules/schema-alignment.ts +132 -0
- package/src/core/static-rules/rules/sql-injection.ts +71 -0
- package/src/core/static-rules/rules/ssrf.ts +63 -0
- package/src/core/static-rules/tailwind-extractor.ts +38 -0
- package/src/core/test-gen/coverage-analyzer.ts +93 -0
- package/src/core/test-gen/framework-detector.ts +21 -0
- package/src/core/test-gen/test-writer.ts +33 -0
- package/src/core/ui/design-context-loader.ts +87 -0
- package/src/core/worker/client.ts +46 -0
- package/src/core/worker/lockfile.ts +38 -0
- package/src/core/worker/server.ts +81 -0
- package/src/formatters/junit.ts +52 -0
- package/src/formatters/sarif.ts +2 -2
- package/src/index.ts +1 -2
- package/tests/snapshots/baselines/src-formatters-sarif.json +4 -4
- package/tests/snapshots/index.json +3 -3
- package/tests/snapshots/src-formatters-sarif.snap.ts +1 -1
- package/tests/snapshots/src-snapshots-impact-selector.snap.ts +3 -3
- package/tests/snapshots/src-snapshots-import-scanner.snap.ts +3 -3
- package/tests/snapshots/src-snapshots-serializer.snap.ts +2 -2
- package/bin/autopilot.js +0 -20
- package/skills/autopilot.md +0 -157
- /package/presets/go/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
- /package/presets/python-fastapi/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
- /package/presets/rails-postgres/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
- /package/presets/t3/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
- /package/{src → scripts}/snapshots/impact-selector.ts +0 -0
- /package/{src → scripts}/snapshots/import-scanner.ts +0 -0
- /package/{src → scripts}/snapshots/serializer.ts +0 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readLock, writeLock, deleteLock, isWorkerAlive } from '../core/worker/lockfile.ts';
|
|
2
|
+
import { stopWorker, getWorkerStatus } from '../core/worker/client.ts';
|
|
3
|
+
import { startWorkerServer } from '../core/worker/server.ts';
|
|
4
|
+
import { loadConfig } from '../core/config/loader.ts';
|
|
5
|
+
import type { ReviewEngine } from '../adapters/review-engine/types.ts';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import * as fs from 'node:fs';
|
|
8
|
+
|
|
9
|
+
const C = { reset: '\x1b[0m', green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', dim: '\x1b[2m', bold: '\x1b[1m' };
|
|
10
|
+
|
|
11
|
+
export async function runWorker(sub: string | undefined, options: { cwd?: string; configPath?: string } = {}): Promise<number> {
|
|
12
|
+
const cwd = options.cwd ?? process.cwd();
|
|
13
|
+
const configPath = options.configPath ?? path.join(cwd, 'guardrail.config.yaml');
|
|
14
|
+
|
|
15
|
+
switch (sub) {
|
|
16
|
+
case 'start':
|
|
17
|
+
return workerStart(cwd, configPath);
|
|
18
|
+
case 'stop':
|
|
19
|
+
return workerStop(cwd);
|
|
20
|
+
case 'status':
|
|
21
|
+
return workerStatus(cwd);
|
|
22
|
+
default:
|
|
23
|
+
console.error(`${C.red}[guardrail worker] Unknown subcommand: "${sub ?? ''}". Use start|stop|status${C.reset}`);
|
|
24
|
+
return 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function workerStart(cwd: string, configPath: string): Promise<number> {
|
|
29
|
+
const existing = readLock(cwd);
|
|
30
|
+
if (existing && isWorkerAlive(existing)) {
|
|
31
|
+
console.log(`${C.yellow}[worker] Already running — pid ${existing.pid} port ${existing.port}${C.reset}`);
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let config = { configVersion: 1 as const };
|
|
36
|
+
if (fs.existsSync(configPath)) {
|
|
37
|
+
const loaded = await loadConfig(configPath);
|
|
38
|
+
if (loaded) config = loaded;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Lazy import to avoid loading review engine at CLI startup
|
|
42
|
+
const { loadAdapter } = await import('../adapters/loader.ts');
|
|
43
|
+
const { runReviewPhase } = await import('../core/pipeline/review-phase.ts');
|
|
44
|
+
|
|
45
|
+
const engineRef = (config as { reviewEngine?: unknown }).reviewEngine;
|
|
46
|
+
const ref = typeof engineRef === 'string' ? engineRef : (engineRef as { adapter?: string })?.adapter ?? 'auto';
|
|
47
|
+
const engineOptions = typeof engineRef === 'object' && engineRef !== null
|
|
48
|
+
? (engineRef as { options?: Record<string, unknown> }).options
|
|
49
|
+
: undefined;
|
|
50
|
+
|
|
51
|
+
const engine = await loadAdapter({
|
|
52
|
+
point: 'review-engine',
|
|
53
|
+
ref,
|
|
54
|
+
options: engineOptions,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const server = await startWorkerServer({
|
|
58
|
+
cwd,
|
|
59
|
+
onReview: async (files, cfg) => {
|
|
60
|
+
const result = await runReviewPhase({ touchedFiles: files, config: cfg, engine: engine as unknown as ReviewEngine });
|
|
61
|
+
return { findings: result.findings, usage: result.costUSD !== undefined ? { costUSD: result.costUSD } : undefined };
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
writeLock(cwd, { pid: process.pid, port: server.port, startedAt: new Date().toISOString() });
|
|
66
|
+
|
|
67
|
+
const cleanup = () => { deleteLock(cwd); server.close().then(() => process.exit(0)); };
|
|
68
|
+
process.on('SIGTERM', cleanup);
|
|
69
|
+
process.on('SIGINT', cleanup);
|
|
70
|
+
|
|
71
|
+
console.log(`${C.green}[worker] Started — pid ${process.pid} port ${server.port}${C.reset}`);
|
|
72
|
+
console.log(`${C.dim} guardrail run --use-worker # dispatch review chunks to this worker${C.reset}`);
|
|
73
|
+
|
|
74
|
+
await new Promise(() => {}); // keep alive
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function workerStop(cwd: string): Promise<number> {
|
|
79
|
+
const lock = readLock(cwd);
|
|
80
|
+
if (!lock) { console.log('[worker] No worker running'); return 0; }
|
|
81
|
+
if (!isWorkerAlive(lock)) { deleteLock(cwd); console.log('[worker] Stale lockfile removed'); return 0; }
|
|
82
|
+
await stopWorker(lock);
|
|
83
|
+
// Give it 3s to exit, then SIGTERM
|
|
84
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
85
|
+
if (isWorkerAlive(lock)) {
|
|
86
|
+
try { process.kill(lock.pid, 'SIGTERM'); } catch { /* already dead */ }
|
|
87
|
+
}
|
|
88
|
+
deleteLock(cwd);
|
|
89
|
+
console.log(`${C.green}[worker] Stopped${C.reset}`);
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function workerStatus(cwd: string): Promise<number> {
|
|
94
|
+
const lock = readLock(cwd);
|
|
95
|
+
if (!lock) { console.log('[worker] Not running'); return 1; }
|
|
96
|
+
if (!isWorkerAlive(lock)) { console.log(`[worker] Dead (stale lock — pid ${lock.pid})`); return 1; }
|
|
97
|
+
try {
|
|
98
|
+
const status = await getWorkerStatus(lock);
|
|
99
|
+
console.log(`[worker] Running`);
|
|
100
|
+
console.log(` pid: ${status.pid}`);
|
|
101
|
+
console.log(` port: ${status.port}`);
|
|
102
|
+
console.log(` jobs processed: ${status.jobsProcessed}`);
|
|
103
|
+
console.log(` uptime: ${Math.round(status.uptimeMs / 1000)}s`);
|
|
104
|
+
return 0;
|
|
105
|
+
} catch {
|
|
106
|
+
console.log(`[worker] Running (pid ${lock.pid} port ${lock.port}) — status endpoint unreachable`);
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -17,10 +17,10 @@ export interface ReviewCacheOptions {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
20
|
-
// Prefer env override, then ~/.
|
|
20
|
+
// Prefer env override, then ~/.guardrail-cache to survive across cwd changes and container restarts
|
|
21
21
|
const DEFAULT_CACHE_DIR = process.env.AUTOPILOT_CACHE_DIR
|
|
22
22
|
? path.join(process.env.AUTOPILOT_CACHE_DIR, 'reviews')
|
|
23
|
-
: path.join(os.homedir(), '.
|
|
23
|
+
: path.join(os.homedir(), '.guardrail-cache', 'reviews');
|
|
24
24
|
|
|
25
25
|
export class ReviewCache {
|
|
26
26
|
private readonly cacheDir: string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import type { ReviewEngine, ReviewInput } from '../../adapters/review-engine/types.ts';
|
|
4
|
-
import type {
|
|
4
|
+
import type { GuardrailConfig } from '../config/types.ts';
|
|
5
5
|
import { rankByRisk } from './risk-ranker.ts';
|
|
6
6
|
import { getFileDiffs, formatDiffContent } from '../git/diff-hunks.ts';
|
|
7
7
|
|
|
@@ -14,7 +14,7 @@ export interface ReviewChunk {
|
|
|
14
14
|
export interface BuildChunksInput {
|
|
15
15
|
touchedFiles: string[];
|
|
16
16
|
strategy: 'auto' | 'single-pass' | 'file-level' | 'diff' | 'auto-diff';
|
|
17
|
-
chunking?:
|
|
17
|
+
chunking?: GuardrailConfig['chunking'];
|
|
18
18
|
engine: ReviewEngine;
|
|
19
19
|
cwd?: string;
|
|
20
20
|
protectedPaths?: string[];
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import * as yaml from 'js-yaml';
|
|
3
3
|
import Ajv from 'ajv';
|
|
4
|
-
import {
|
|
5
|
-
import type {
|
|
6
|
-
import {
|
|
4
|
+
import { GuardrailError } from '../errors.ts';
|
|
5
|
+
import type { GuardrailConfig } from './types.ts';
|
|
6
|
+
import { GUARDRAIL_CONFIG_SCHEMA } from './schema.ts';
|
|
7
7
|
|
|
8
8
|
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
9
|
-
const validate = ajv.compile(
|
|
9
|
+
const validate = ajv.compile(GUARDRAIL_CONFIG_SCHEMA);
|
|
10
10
|
|
|
11
|
-
export async function loadConfig(path: string): Promise<
|
|
11
|
+
export async function loadConfig(path: string): Promise<GuardrailConfig> {
|
|
12
12
|
let content: string;
|
|
13
13
|
try {
|
|
14
14
|
content = await fs.readFile(path, 'utf8');
|
|
15
15
|
} catch (err) {
|
|
16
|
-
throw new
|
|
16
|
+
throw new GuardrailError(`Config file not found: ${path}`, {
|
|
17
17
|
code: 'user_input',
|
|
18
18
|
details: { path, cause: err instanceof Error ? err.message : String(err) },
|
|
19
19
|
});
|
|
@@ -23,7 +23,7 @@ export async function loadConfig(path: string): Promise<AutopilotConfig> {
|
|
|
23
23
|
try {
|
|
24
24
|
parsed = yaml.load(content);
|
|
25
25
|
} catch (err) {
|
|
26
|
-
throw new
|
|
26
|
+
throw new GuardrailError(`Invalid YAML in ${path}`, {
|
|
27
27
|
code: 'invalid_config',
|
|
28
28
|
details: { path, cause: err instanceof Error ? err.message : String(err) },
|
|
29
29
|
});
|
|
@@ -43,11 +43,11 @@ export async function loadConfig(path: string): Promise<AutopilotConfig> {
|
|
|
43
43
|
return `${loc}: ${e.message ?? 'invalid'}`;
|
|
44
44
|
});
|
|
45
45
|
const summary = errors.slice(0, 5).join('\n ');
|
|
46
|
-
throw new
|
|
47
|
-
`
|
|
46
|
+
throw new GuardrailError(
|
|
47
|
+
`guardrail.config.yaml is invalid:\n ${summary}${errors.length > 5 ? `\n …and ${errors.length - 5} more` : ''}`,
|
|
48
48
|
{ code: 'invalid_config', details: { path, errors } },
|
|
49
49
|
);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
return parsed as
|
|
52
|
+
return parsed as GuardrailConfig;
|
|
53
53
|
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import * as fs from 'node:fs/promises';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { loadConfig } from './loader.ts';
|
|
4
|
-
import {
|
|
5
|
-
import type {
|
|
4
|
+
import { GuardrailError } from '../errors.ts';
|
|
5
|
+
import type { GuardrailConfig } from './types.ts';
|
|
6
6
|
|
|
7
7
|
const PRESET_ROOT = path.resolve(process.cwd(), 'presets');
|
|
8
8
|
|
|
9
9
|
export interface ResolvedPreset {
|
|
10
10
|
name: string;
|
|
11
|
-
config:
|
|
11
|
+
config: GuardrailConfig;
|
|
12
12
|
stack: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -17,13 +17,13 @@ export async function resolvePreset(name: string): Promise<ResolvedPreset> {
|
|
|
17
17
|
try {
|
|
18
18
|
await fs.stat(presetDir);
|
|
19
19
|
} catch {
|
|
20
|
-
throw new
|
|
20
|
+
throw new GuardrailError(`Preset not found: ${name}`, {
|
|
21
21
|
code: 'invalid_config',
|
|
22
22
|
details: { name, presetDir },
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const config = await loadConfig(path.join(presetDir, '
|
|
26
|
+
const config = await loadConfig(path.join(presetDir, 'guardrail.config.yaml'));
|
|
27
27
|
let stack = '';
|
|
28
28
|
try {
|
|
29
29
|
stack = await fs.readFile(path.join(presetDir, 'stack.md'), 'utf8');
|
|
@@ -33,7 +33,7 @@ export async function resolvePreset(name: string): Promise<ResolvedPreset> {
|
|
|
33
33
|
return { name, config, stack };
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export function mergeConfigs(preset:
|
|
36
|
+
export function mergeConfigs(preset: GuardrailConfig, user: GuardrailConfig): GuardrailConfig {
|
|
37
37
|
return {
|
|
38
38
|
...preset,
|
|
39
39
|
...user,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const
|
|
1
|
+
export const GUARDRAIL_CONFIG_SCHEMA = {
|
|
2
2
|
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
3
3
|
type: 'object',
|
|
4
4
|
required: ['configVersion'],
|
|
@@ -64,10 +64,111 @@ export const AUTOPILOT_CONFIG_SCHEMA = {
|
|
|
64
64
|
},
|
|
65
65
|
additionalProperties: false,
|
|
66
66
|
},
|
|
67
|
-
|
|
67
|
+
policy: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
properties: {
|
|
70
|
+
failOn: { enum: ['critical', 'warning', 'note', 'none'] },
|
|
71
|
+
newOnly: { type: 'boolean' },
|
|
72
|
+
baselinePath: { type: 'string' },
|
|
73
|
+
},
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
},
|
|
76
|
+
pipeline: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
properties: {
|
|
79
|
+
runReviewOnStaticFail: { type: 'boolean' },
|
|
80
|
+
runReviewOnTestFail: { type: 'boolean' },
|
|
81
|
+
},
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
},
|
|
84
|
+
cost: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {
|
|
87
|
+
maxPerRun: { type: 'number' },
|
|
88
|
+
estimateBeforeRun: { type: 'boolean' },
|
|
89
|
+
pricing: { type: 'object' },
|
|
90
|
+
},
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
},
|
|
93
|
+
brand: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
colorsFrom: { type: 'string' },
|
|
97
|
+
colors: { type: 'array', items: { type: 'string' } },
|
|
98
|
+
fonts: { type: 'array', items: { type: 'string' } },
|
|
99
|
+
componentLibrary: {
|
|
100
|
+
oneOf: [
|
|
101
|
+
{ type: 'string' },
|
|
102
|
+
{
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: {
|
|
105
|
+
tokens: { type: 'string' },
|
|
106
|
+
guide: { type: 'string' },
|
|
107
|
+
},
|
|
108
|
+
additionalProperties: false,
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
additionalProperties: false,
|
|
114
|
+
},
|
|
115
|
+
'schema-alignment': {
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: {
|
|
118
|
+
enabled: { type: 'boolean' },
|
|
119
|
+
migrationGlobs: { type: 'array', items: { type: 'string', minLength: 1 } },
|
|
120
|
+
layerRoots: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
properties: {
|
|
123
|
+
types: { type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
124
|
+
api: { type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
125
|
+
ui: { type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
126
|
+
},
|
|
127
|
+
additionalProperties: false,
|
|
128
|
+
},
|
|
129
|
+
llmCheck: { type: 'boolean' },
|
|
130
|
+
severity: { enum: ['warning', 'error'] },
|
|
131
|
+
},
|
|
132
|
+
additionalProperties: false,
|
|
133
|
+
},
|
|
68
134
|
cache: { type: 'object' },
|
|
69
135
|
persistence: { type: 'object' },
|
|
70
136
|
concurrency: { type: 'object' },
|
|
137
|
+
council: {
|
|
138
|
+
type: 'object',
|
|
139
|
+
required: ['models', 'synthesizer'],
|
|
140
|
+
additionalProperties: false,
|
|
141
|
+
properties: {
|
|
142
|
+
models: {
|
|
143
|
+
type: 'array',
|
|
144
|
+
minItems: 2,
|
|
145
|
+
items: {
|
|
146
|
+
type: 'object',
|
|
147
|
+
required: ['adapter', 'model', 'label'],
|
|
148
|
+
additionalProperties: false,
|
|
149
|
+
properties: {
|
|
150
|
+
adapter: { type: 'string' },
|
|
151
|
+
model: { type: 'string' },
|
|
152
|
+
label: { type: 'string' },
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
synthesizer: {
|
|
157
|
+
type: 'object',
|
|
158
|
+
required: ['adapter', 'model', 'label'],
|
|
159
|
+
additionalProperties: false,
|
|
160
|
+
properties: {
|
|
161
|
+
adapter: { type: 'string' },
|
|
162
|
+
model: { type: 'string' },
|
|
163
|
+
label: { type: 'string' },
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
timeout_ms: { type: 'number' },
|
|
167
|
+
min_successful_responses: { type: 'number' },
|
|
168
|
+
parallel_input_max_tokens: { type: 'number' },
|
|
169
|
+
synthesis_input_max_tokens: { type: 'number' },
|
|
170
|
+
},
|
|
171
|
+
},
|
|
71
172
|
},
|
|
72
173
|
definitions: {
|
|
73
174
|
adapterRef: {
|
package/src/core/config/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SchemaAlignmentConfig } from '../schema-alignment/types.ts';
|
|
2
|
+
|
|
1
3
|
export interface AdapterReference {
|
|
2
4
|
adapter: string;
|
|
3
5
|
options?: Record<string, unknown>;
|
|
@@ -7,7 +9,7 @@ export type AdapterRef = string | AdapterReference;
|
|
|
7
9
|
|
|
8
10
|
export type StaticRuleReference = string | { adapter: string; options?: Record<string, unknown> };
|
|
9
11
|
|
|
10
|
-
export interface
|
|
12
|
+
export interface GuardrailConfig {
|
|
11
13
|
configVersion: 1;
|
|
12
14
|
preset?: string;
|
|
13
15
|
reviewEngine?: AdapterRef;
|
|
@@ -36,8 +38,61 @@ export interface AutopilotConfig {
|
|
|
36
38
|
parallelism?: number;
|
|
37
39
|
rateLimitBackoff?: 'exp' | 'linear' | 'none';
|
|
38
40
|
};
|
|
39
|
-
|
|
41
|
+
policy?: {
|
|
42
|
+
/** Severity threshold for exit code 1. Default: 'critical'. Use 'none' to always pass. */
|
|
43
|
+
failOn?: 'critical' | 'warning' | 'note' | 'none';
|
|
44
|
+
/** Only report findings not present in the committed baseline. Default: false. */
|
|
45
|
+
newOnly?: boolean;
|
|
46
|
+
/** Path to baseline file relative to cwd. Default: .guardrail-baseline.json */
|
|
47
|
+
baselinePath?: string;
|
|
48
|
+
};
|
|
49
|
+
pipeline?: {
|
|
50
|
+
/**
|
|
51
|
+
* When true, run the LLM review phase even if the static-rules phase reports `fail`
|
|
52
|
+
* (i.e. finds a critical). Default: true. Set to false to skip only the review
|
|
53
|
+
* phase on static-fail — the tests phase still runs regardless.
|
|
54
|
+
*
|
|
55
|
+
* Users that explicitly configure a review engine typically expect it to run — the
|
|
56
|
+
* bugs the LLM is best at (IDOR, TOCTOU, CORS, off-by-one, rate limits) often sit
|
|
57
|
+
* in the same commit as something a static rule already flagged. This flag only
|
|
58
|
+
* gates the review phase, mirroring `runReviewOnTestFail`.
|
|
59
|
+
*/
|
|
60
|
+
runReviewOnStaticFail?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* When true, run the LLM review phase even if the tests phase reports `fail`.
|
|
63
|
+
* Default: false — failing tests usually indicate broken code, not code to review.
|
|
64
|
+
* This flag only gates the review phase; the tests phase itself always runs.
|
|
65
|
+
*/
|
|
66
|
+
runReviewOnTestFail?: boolean;
|
|
67
|
+
};
|
|
68
|
+
cost?: {
|
|
69
|
+
/** Abort review phase if estimated spend exceeds this amount (USD). */
|
|
70
|
+
maxPerRun?: number;
|
|
71
|
+
/** Print token estimate before starting LLM review. Default: false. */
|
|
72
|
+
estimateBeforeRun?: boolean;
|
|
73
|
+
/** Per-model token price overrides (input/output per 1M tokens). */
|
|
74
|
+
pricing?: Record<string, { inputPer1M: number; outputPer1M: number }>;
|
|
75
|
+
};
|
|
76
|
+
brand?: {
|
|
77
|
+
/** Path to tailwind.config.{ts,js} — auto-extracts theme.colors as canonical palette */
|
|
78
|
+
colorsFrom?: string;
|
|
79
|
+
/** Explicit canonical color values (hex/rgb/hsl). Merged with colorsFrom. */
|
|
80
|
+
colors?: string[];
|
|
81
|
+
/** Canonical font family names */
|
|
82
|
+
fonts?: string[];
|
|
83
|
+
/** Path to design system component library (informational, for future LLM review) */
|
|
84
|
+
componentLibrary?: string | { tokens?: string; guide?: string };
|
|
85
|
+
};
|
|
86
|
+
'schema-alignment'?: SchemaAlignmentConfig;
|
|
40
87
|
cache?: Record<string, unknown>;
|
|
41
88
|
persistence?: Record<string, unknown>;
|
|
42
89
|
concurrency?: Record<string, unknown>;
|
|
90
|
+
council?: {
|
|
91
|
+
models: Array<{ adapter: string; model: string; label: string }>;
|
|
92
|
+
synthesizer: { adapter: string; model: string; label: string };
|
|
93
|
+
timeout_ms?: number;
|
|
94
|
+
min_successful_responses?: number;
|
|
95
|
+
parallel_input_max_tokens?: number;
|
|
96
|
+
synthesis_input_max_tokens?: number;
|
|
97
|
+
};
|
|
43
98
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// src/core/council/config.ts
|
|
2
|
+
import { GuardrailError } from '../errors.ts';
|
|
3
|
+
import type { CouncilConfig, CouncilModelEntry } from './types.ts';
|
|
4
|
+
|
|
5
|
+
const SUPPORTED_ADAPTERS = new Set(['claude', 'openai']);
|
|
6
|
+
|
|
7
|
+
export function parseCouncilConfig(raw: Record<string, unknown>): CouncilConfig {
|
|
8
|
+
const models = raw['models'] as Array<Record<string, string>> | undefined;
|
|
9
|
+
const synthRaw = raw['synthesizer'] as Record<string, string> | undefined;
|
|
10
|
+
const timeoutMs = (raw['timeout_ms'] as number | undefined) ?? 30000;
|
|
11
|
+
const minSuccessful = (raw['min_successful_responses'] as number | undefined) ?? 1;
|
|
12
|
+
const parallelInputMaxTokens = (raw['parallel_input_max_tokens'] as number | undefined) ?? 8000;
|
|
13
|
+
const synthesisInputMaxTokens = (raw['synthesis_input_max_tokens'] as number | undefined) ?? 12000;
|
|
14
|
+
|
|
15
|
+
if (!Array.isArray(models) || models.length < 2) {
|
|
16
|
+
throw new GuardrailError('council.models must have at least 2 entries', { code: 'invalid_config' });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!synthRaw?.['adapter'] || !synthRaw['model'] || !synthRaw['label']) {
|
|
20
|
+
throw new GuardrailError('council.synthesizer requires adapter, model, and label', { code: 'invalid_config' });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (timeoutMs < 5000) {
|
|
24
|
+
throw new GuardrailError(`council.timeout_ms must be >= 5000, got ${timeoutMs}`, { code: 'invalid_config' });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (minSuccessful < 1 || minSuccessful > models.length) {
|
|
28
|
+
throw new GuardrailError(
|
|
29
|
+
`council.min_successful_responses must be 1–${models.length}, got ${minSuccessful}`,
|
|
30
|
+
{ code: 'invalid_config' },
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const entry of [...models, synthRaw]) {
|
|
35
|
+
if (!SUPPORTED_ADAPTERS.has(entry['adapter']!)) {
|
|
36
|
+
throw new GuardrailError(
|
|
37
|
+
`council: unknown adapter "${entry['adapter']}" — supported: ${[...SUPPORTED_ADAPTERS].join(', ')}`,
|
|
38
|
+
{ code: 'invalid_config' },
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const seen = new Set<string>();
|
|
44
|
+
for (const m of models) {
|
|
45
|
+
if (seen.has(m['label']!)) {
|
|
46
|
+
throw new GuardrailError(`council.models: duplicate label "${m['label']}"`, { code: 'invalid_config' });
|
|
47
|
+
}
|
|
48
|
+
seen.add(m['label']!);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const parsedModels: CouncilModelEntry[] = models.map(m => ({
|
|
52
|
+
adapter: m['adapter'] as 'claude' | 'openai',
|
|
53
|
+
model: m['model']!,
|
|
54
|
+
label: m['label']!,
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
const synthesizer: CouncilModelEntry = {
|
|
58
|
+
adapter: synthRaw['adapter'] as 'claude' | 'openai',
|
|
59
|
+
model: synthRaw['model']!,
|
|
60
|
+
label: synthRaw['label']!,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
models: parsedModels,
|
|
65
|
+
synthesizer,
|
|
66
|
+
timeoutMs,
|
|
67
|
+
minSuccessfulResponses: minSuccessful,
|
|
68
|
+
parallelInputMaxTokens,
|
|
69
|
+
synthesisInputMaxTokens,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const CHARS_PER_TOKEN = 4;
|
|
2
|
+
|
|
3
|
+
export function windowContext(text: string, maxTokens: number): string {
|
|
4
|
+
const estimated = Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
5
|
+
if (estimated <= maxTokens) return text;
|
|
6
|
+
|
|
7
|
+
const maxChars = maxTokens * CHARS_PER_TOKEN;
|
|
8
|
+
// Reserve budget for the marker so the final output stays within maxTokens.
|
|
9
|
+
// Use a conservative upper bound of the formatted marker (the digit count of
|
|
10
|
+
// charsDropped is computed from text length to avoid circular dependency).
|
|
11
|
+
const markerOverhead = `<!-- [council: truncated ${text.length} chars] -->\n`.length;
|
|
12
|
+
const effectiveMaxChars = Math.max(0, maxChars - markerOverhead);
|
|
13
|
+
const charsDropped = text.length - effectiveMaxChars;
|
|
14
|
+
const marker = `<!-- [council: truncated ${charsDropped} chars] -->\n`;
|
|
15
|
+
process.stderr.write(`[council] context truncated: dropped ${charsDropped} chars to fit ${maxTokens} token budget\n`);
|
|
16
|
+
return marker + text.slice(charsDropped);
|
|
17
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import { windowContext } from './context.ts';
|
|
3
|
+
import type { CouncilConfig, CouncilResult, ModelResponse } from './types.ts';
|
|
4
|
+
import type { CouncilAdapter } from '../../adapters/council/types.ts';
|
|
5
|
+
|
|
6
|
+
async function consultWithTimeout(
|
|
7
|
+
adapter: CouncilAdapter,
|
|
8
|
+
prompt: string,
|
|
9
|
+
context: string,
|
|
10
|
+
timeoutMs: number,
|
|
11
|
+
): Promise<ModelResponse> {
|
|
12
|
+
const start = Date.now();
|
|
13
|
+
let timer: NodeJS.Timeout | undefined;
|
|
14
|
+
try {
|
|
15
|
+
const text = await Promise.race([
|
|
16
|
+
adapter.consult(prompt, context),
|
|
17
|
+
new Promise<never>((_, reject) => {
|
|
18
|
+
timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
|
|
19
|
+
}),
|
|
20
|
+
]);
|
|
21
|
+
return { label: adapter.label, status: 'ok', text, latencyMs: Date.now() - start };
|
|
22
|
+
} catch (err) {
|
|
23
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
24
|
+
return message === 'timeout'
|
|
25
|
+
? { label: adapter.label, status: 'timeout', error: 'timed out', latencyMs: Date.now() - start }
|
|
26
|
+
: { label: adapter.label, status: 'error', error: message, latencyMs: Date.now() - start };
|
|
27
|
+
} finally {
|
|
28
|
+
// Always clear the timer to avoid keeping the event loop alive after the
|
|
29
|
+
// adapter resolves/rejects. Long-running hosts (MCP server) would accumulate
|
|
30
|
+
// dangling timers for the full timeoutMs otherwise.
|
|
31
|
+
if (timer) clearTimeout(timer);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function runCouncil(
|
|
36
|
+
config: CouncilConfig,
|
|
37
|
+
adapters: CouncilAdapter[],
|
|
38
|
+
synthesizer: CouncilAdapter,
|
|
39
|
+
prompt: string,
|
|
40
|
+
contextDoc: string,
|
|
41
|
+
): Promise<CouncilResult> {
|
|
42
|
+
const run_id = crypto.randomUUID();
|
|
43
|
+
const context = windowContext(contextDoc, config.parallelInputMaxTokens);
|
|
44
|
+
|
|
45
|
+
const responses = await Promise.all(
|
|
46
|
+
adapters.map(a => consultWithTimeout(a, prompt, context, config.timeoutMs))
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const successful = responses.filter(r => r.status === 'ok');
|
|
50
|
+
|
|
51
|
+
if (successful.length < config.minSuccessfulResponses) {
|
|
52
|
+
return { schema_version: 1, run_id, status: 'failed', prompt, responses };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const responseSections = successful
|
|
56
|
+
.map(r => `### ${r.label}\n${r.text}`)
|
|
57
|
+
.join('\n\n');
|
|
58
|
+
|
|
59
|
+
const synthesisDoc = `${contextDoc}\n\n---\n\n${responseSections}`;
|
|
60
|
+
const synthesisCtx = windowContext(synthesisDoc, config.synthesisInputMaxTokens);
|
|
61
|
+
const synthesisPrompt = [
|
|
62
|
+
`You have received responses from multiple technical advisors on the following question:\n\n## Original Question\n\n${prompt}`,
|
|
63
|
+
`## Advisor Responses\n\n${responseSections}`,
|
|
64
|
+
'Based on these responses, provide a synthesis: areas of agreement, key disagreements, and your final recommendation.',
|
|
65
|
+
].join('\n\n');
|
|
66
|
+
|
|
67
|
+
// Synthesizer shares the same per-call timeout as model calls so a hung
|
|
68
|
+
// synthesizer API doesn't block the whole command indefinitely.
|
|
69
|
+
const synthResponse = await consultWithTimeout(
|
|
70
|
+
synthesizer,
|
|
71
|
+
synthesisPrompt,
|
|
72
|
+
synthesisCtx,
|
|
73
|
+
config.timeoutMs,
|
|
74
|
+
);
|
|
75
|
+
// status:'ok' means the synthesizer call itself completed without error.
|
|
76
|
+
// Empty text is valid (e.g. the --no-synthesize stub that intentionally
|
|
77
|
+
// returns ''); only treat actual failures/timeouts as partial.
|
|
78
|
+
if (synthResponse.status === 'ok') {
|
|
79
|
+
const synthesis = { label: synthesizer.label, text: synthResponse.text ?? '', latencyMs: synthResponse.latencyMs };
|
|
80
|
+
return { schema_version: 1, run_id, status: 'success', prompt, responses, synthesis };
|
|
81
|
+
}
|
|
82
|
+
return { schema_version: 1, run_id, status: 'partial', prompt, responses };
|
|
83
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// adapter is a closed union — extending to a new provider requires an intentional
|
|
2
|
+
// code change in config.ts and cli/council.ts
|
|
3
|
+
export interface CouncilModelEntry {
|
|
4
|
+
adapter: 'claude' | 'openai';
|
|
5
|
+
model: string;
|
|
6
|
+
label: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CouncilConfig {
|
|
10
|
+
models: CouncilModelEntry[];
|
|
11
|
+
synthesizer: CouncilModelEntry;
|
|
12
|
+
timeoutMs: number;
|
|
13
|
+
minSuccessfulResponses: number;
|
|
14
|
+
parallelInputMaxTokens: number;
|
|
15
|
+
synthesisInputMaxTokens: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ModelResponseStatus = 'ok' | 'timeout' | 'error';
|
|
19
|
+
|
|
20
|
+
export interface ModelResponse {
|
|
21
|
+
label: string;
|
|
22
|
+
status: ModelResponseStatus;
|
|
23
|
+
text?: string;
|
|
24
|
+
error?: string;
|
|
25
|
+
latencyMs: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SynthesisResponse {
|
|
29
|
+
label: string;
|
|
30
|
+
text: string;
|
|
31
|
+
latencyMs: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type CouncilStatus = 'success' | 'partial' | 'failed';
|
|
35
|
+
|
|
36
|
+
export interface CouncilResult {
|
|
37
|
+
// snake_case: wire-format field, consistent with MCP handler schema_version convention
|
|
38
|
+
schema_version: 1;
|
|
39
|
+
// snake_case: wire-format field
|
|
40
|
+
run_id: string;
|
|
41
|
+
status: CouncilStatus;
|
|
42
|
+
prompt: string;
|
|
43
|
+
responses: ModelResponse[];
|
|
44
|
+
synthesis?: SynthesisResponse;
|
|
45
|
+
}
|