@sickr/cli 0.9.15 → 0.9.18
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/dist/cli.js +90 -4
- package/dist/providers.js +1 -1
- package/dist/reviewer.js +405 -0
- package/dist/run.js +18 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -12,8 +12,9 @@ import { AUTH_ENDPOINT, readCredentials, writeCredentials, clearCredentials, sta
|
|
|
12
12
|
import { ui, card, kv } from './ui.js';
|
|
13
13
|
import { AGENT_API_URL, clearAgentCredentials, disconnectAgent, fetchAgentStatus, pollAgentConnect, readAgentCredentials, rotateAgentKey, startAgentConnect, writeAgentCredentials, } from './agentAuth.js';
|
|
14
14
|
import { PROVIDERS, recordCommandFor } from './providers.js';
|
|
15
|
+
import { parseReviewArgs, reviewResultSummary, reviewerSettingsExample, runLocalReviewer } from './reviewer.js';
|
|
15
16
|
const REPLAY_ENDPOINT = process.env.SICKR_REPLAY_ENDPOINT ?? 'https://sickr.ai/api/replay';
|
|
16
|
-
const COMMANDS = ['init', 'record', 'open', 'list', 'share', 'stop', 'clear', 'login', 'logout', 'whoami', 'agent', 'live', 'run', 'replay', 'prime', 'workflow', 'start', 'status', 'help'];
|
|
17
|
+
const COMMANDS = ['init', 'record', 'open', 'list', 'share', 'stop', 'clear', 'login', 'logout', 'whoami', 'agent', 'live', 'run', 'review', 'replay', 'prime', 'workflow', 'start', 'status', 'help'];
|
|
17
18
|
export function parseCommand(argv) {
|
|
18
19
|
const c = argv[0];
|
|
19
20
|
return c && COMMANDS.includes(c) ? c : null;
|
|
@@ -59,6 +60,7 @@ export function runtimeConfigSummary(config) {
|
|
|
59
60
|
replay: 'Replay',
|
|
60
61
|
live: 'Live',
|
|
61
62
|
run: 'Run',
|
|
63
|
+
review: 'Review',
|
|
62
64
|
prime_workflow: 'Prime Workflow',
|
|
63
65
|
};
|
|
64
66
|
const provider = PROVIDERS[config.provider];
|
|
@@ -117,6 +119,15 @@ RUN ($12) - remote control from the browser.
|
|
|
117
119
|
sickr run <bin>
|
|
118
120
|
Flags: --mode auto (default) | --mode interactive
|
|
119
121
|
|
|
122
|
+
LOCAL REVIEWER - local model with constrained reviewer tools.
|
|
123
|
+
review Run an Ollama/Qwen reviewer in this workspace. The model
|
|
124
|
+
can read files, run configured readonly/test commands,
|
|
125
|
+
apply patches locally, and publish PR review feedback
|
|
126
|
+
only when --publish is passed.
|
|
127
|
+
sickr review --pr 42 --model qwen2.5-coder:7b
|
|
128
|
+
sickr review --pr 42 --publish
|
|
129
|
+
Config: .sickr/settings.json reviewer block.
|
|
130
|
+
|
|
120
131
|
PRIME WORKFLOW - governed ticket execution.
|
|
121
132
|
prime connect --agent-id <id> Approve this machine for a configured
|
|
122
133
|
Prime Workflow agent.
|
|
@@ -697,12 +708,21 @@ function providerFromValue(value) {
|
|
|
697
708
|
return null;
|
|
698
709
|
}
|
|
699
710
|
function productModeFromValue(value) {
|
|
700
|
-
if (value === 'prime_workflow' || value === 'run' || value === 'live' || value === 'replay')
|
|
711
|
+
if (value === 'prime_workflow' || value === 'review' || value === 'run' || value === 'live' || value === 'replay')
|
|
701
712
|
return value;
|
|
702
713
|
if (value === 'workflow' || value === 'prime')
|
|
703
714
|
return 'prime_workflow';
|
|
704
715
|
return null;
|
|
705
716
|
}
|
|
717
|
+
function reviewerDefaultMode(agent, runtime) {
|
|
718
|
+
const role = runtime.role ?? agent.role ?? runtime.agent_role ?? agent.agent_role;
|
|
719
|
+
if (role === 'reviewer')
|
|
720
|
+
return 'review';
|
|
721
|
+
const agentId = typeof agent.agent_id === 'string' ? agent.agent_id.toLowerCase() : '';
|
|
722
|
+
if (agentId.includes('reviewer') || agentId.endsWith('-review') || agentId.startsWith('review-'))
|
|
723
|
+
return 'review';
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
706
726
|
export function configuredRunInvocationFromStatus(status) {
|
|
707
727
|
const root = isRecord(status) ? status : {};
|
|
708
728
|
const agent = isRecord(root.agent) ? root.agent : {};
|
|
@@ -711,7 +731,7 @@ export function configuredRunInvocationFromStatus(status) {
|
|
|
711
731
|
: isRecord(root.runtime_config) ? root.runtime_config
|
|
712
732
|
: {};
|
|
713
733
|
const provider = providerFromValue(runtime.provider) ?? providerFromValue(agent.provider) ?? 'claude';
|
|
714
|
-
const productMode = productModeFromValue(runtime.product_mode) ?? productModeFromValue(runtime.mode) ?? productModeFromValue(agent.product_mode) ?? productModeFromValue(agent.mode) ?? 'run';
|
|
734
|
+
const productMode = productModeFromValue(runtime.product_mode) ?? productModeFromValue(runtime.mode) ?? productModeFromValue(agent.product_mode) ?? productModeFromValue(agent.mode) ?? reviewerDefaultMode(agent, runtime) ?? 'run';
|
|
715
735
|
const command = typeof runtime.command === 'string' ? runtime.command
|
|
716
736
|
: typeof agent.command === 'string' ? agent.command
|
|
717
737
|
: PROVIDERS[provider].defaultCommand;
|
|
@@ -719,11 +739,40 @@ export function configuredRunInvocationFromStatus(status) {
|
|
|
719
739
|
const model = typeof runtime.model === 'string' ? runtime.model
|
|
720
740
|
: typeof agent.model === 'string' ? agent.model
|
|
721
741
|
: null;
|
|
742
|
+
const workspace = typeof runtime.workspace === 'string' ? runtime.workspace
|
|
743
|
+
: typeof runtime.workspace_path === 'string' ? runtime.workspace_path
|
|
744
|
+
: typeof agent.workspace === 'string' ? agent.workspace
|
|
745
|
+
: typeof agent.workspace_path === 'string' ? agent.workspace_path
|
|
746
|
+
: undefined;
|
|
747
|
+
const pr = typeof runtime.pr === 'string' ? runtime.pr
|
|
748
|
+
: typeof runtime.pull_request === 'string' ? runtime.pull_request
|
|
749
|
+
: typeof agent.pr === 'string' ? agent.pr
|
|
750
|
+
: typeof agent.pull_request === 'string' ? agent.pull_request
|
|
751
|
+
: undefined;
|
|
752
|
+
const repo = typeof runtime.repo === 'string' ? runtime.repo
|
|
753
|
+
: typeof agent.repo === 'string' ? agent.repo
|
|
754
|
+
: undefined;
|
|
755
|
+
const testCommands = stringArray(runtime.test_commands) ?? stringArray(agent.test_commands) ?? undefined;
|
|
756
|
+
const readonlyCommands = stringArray(runtime.readonly_commands) ?? stringArray(agent.readonly_commands) ?? undefined;
|
|
722
757
|
const agentArgs = (provider === 'ollama' || provider === 'local') && model && configuredArgs.length === 0
|
|
723
758
|
? ['--model', model]
|
|
724
759
|
: configuredArgs;
|
|
725
760
|
const mode = runtime.pty_mode === 'interactive' || runtime.run_mode === 'interactive' || runtime.mode === 'interactive' || agent.pty_mode === 'interactive' ? 'interactive' : 'auto';
|
|
726
|
-
return {
|
|
761
|
+
return {
|
|
762
|
+
agent: command,
|
|
763
|
+
agentArgs,
|
|
764
|
+
mode,
|
|
765
|
+
productMode,
|
|
766
|
+
review: productMode === 'review' ? {
|
|
767
|
+
workspace,
|
|
768
|
+
model: model ?? undefined,
|
|
769
|
+
pr,
|
|
770
|
+
repo,
|
|
771
|
+
publish: typeof runtime.publish === 'boolean' ? runtime.publish : typeof agent.publish === 'boolean' ? agent.publish : undefined,
|
|
772
|
+
testCommands,
|
|
773
|
+
readonlyCommands,
|
|
774
|
+
} : undefined,
|
|
775
|
+
};
|
|
727
776
|
}
|
|
728
777
|
export async function resolveConfiguredRun(agentId, credentials = readAgentCredentials()) {
|
|
729
778
|
if (!credentials) {
|
|
@@ -1082,6 +1131,24 @@ async function handleStart(rest) {
|
|
|
1082
1131
|
await handleReplay([]);
|
|
1083
1132
|
return;
|
|
1084
1133
|
}
|
|
1134
|
+
if (configured.productMode === 'review') {
|
|
1135
|
+
if (!(await requireReplayPro('sickr start'))) {
|
|
1136
|
+
process.exit(3);
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
try {
|
|
1140
|
+
const result = await runLocalReviewer({
|
|
1141
|
+
...(configured.review ?? {}),
|
|
1142
|
+
...parseReviewArgs(rest),
|
|
1143
|
+
});
|
|
1144
|
+
process.stdout.write(`${reviewResultSummary(result)}\n`);
|
|
1145
|
+
}
|
|
1146
|
+
catch (e) {
|
|
1147
|
+
process.stderr.write(`sickr: reviewer failed (${e.message}).\n`);
|
|
1148
|
+
process.exit(5);
|
|
1149
|
+
}
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1085
1152
|
if (!(await requireReplayPro('sickr start'))) {
|
|
1086
1153
|
process.exit(3);
|
|
1087
1154
|
return;
|
|
@@ -1252,6 +1319,25 @@ async function main() {
|
|
|
1252
1319
|
await startLive(opts);
|
|
1253
1320
|
return;
|
|
1254
1321
|
}
|
|
1322
|
+
case 'review': {
|
|
1323
|
+
if (rest.includes('--example-settings')) {
|
|
1324
|
+
process.stdout.write(`${reviewerSettingsExample()}\n`);
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
if (!(await requireReplayPro('sickr review'))) {
|
|
1328
|
+
process.exit(3);
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
try {
|
|
1332
|
+
const result = await runLocalReviewer(parseReviewArgs(rest));
|
|
1333
|
+
process.stdout.write(`${reviewResultSummary(result)}\n`);
|
|
1334
|
+
}
|
|
1335
|
+
catch (e) {
|
|
1336
|
+
process.stderr.write(`sickr: reviewer failed (${e.message}).\n`);
|
|
1337
|
+
process.exit(5);
|
|
1338
|
+
}
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1255
1341
|
case 'run': {
|
|
1256
1342
|
// sickr run <agent> [--mode auto|interactive] [...agent-args] [--verbose]
|
|
1257
1343
|
// Wraps the agent in a PTY the CLI owns. Browser steers go
|
package/dist/providers.js
CHANGED
|
@@ -84,5 +84,5 @@ export function recordCommandFor(provider) {
|
|
|
84
84
|
return `npx @sickr/cli record${p.recordFlag ? ` ${p.recordFlag}` : ''}`;
|
|
85
85
|
}
|
|
86
86
|
export function modeRank(mode) {
|
|
87
|
-
return { replay: 0, live: 1, run: 2, prime_workflow: 3 }[mode];
|
|
87
|
+
return { replay: 0, live: 1, run: 2, review: 2, prime_workflow: 3 }[mode];
|
|
88
88
|
}
|
package/dist/reviewer.js
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
5
|
+
import { redact } from './redact.js';
|
|
6
|
+
export const DEFAULT_READONLY_COMMANDS = [
|
|
7
|
+
'pwd',
|
|
8
|
+
'ls',
|
|
9
|
+
'dir',
|
|
10
|
+
'find',
|
|
11
|
+
'rg',
|
|
12
|
+
'cat',
|
|
13
|
+
'type',
|
|
14
|
+
'sed',
|
|
15
|
+
'git status',
|
|
16
|
+
'git diff',
|
|
17
|
+
'git log',
|
|
18
|
+
'git show',
|
|
19
|
+
'git branch',
|
|
20
|
+
'npm ls',
|
|
21
|
+
'npm outdated',
|
|
22
|
+
'pnpm ls',
|
|
23
|
+
'yarn list',
|
|
24
|
+
'pip freeze',
|
|
25
|
+
'python -m pip show',
|
|
26
|
+
'go list',
|
|
27
|
+
'cargo tree',
|
|
28
|
+
];
|
|
29
|
+
export const DEFAULT_TEST_COMMANDS = ['npm test', 'pnpm test', 'yarn test', 'pytest', 'python -m pytest', 'go test ./...', 'cargo test'];
|
|
30
|
+
export function settingsPath(workspace) {
|
|
31
|
+
return join(workspace, '.sickr', 'settings.json');
|
|
32
|
+
}
|
|
33
|
+
function stringArray(value) {
|
|
34
|
+
return Array.isArray(value) && value.every((item) => typeof item === 'string') ? value : null;
|
|
35
|
+
}
|
|
36
|
+
function record(value) {
|
|
37
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {};
|
|
38
|
+
}
|
|
39
|
+
export function readReviewerSettings(workspace = process.cwd()) {
|
|
40
|
+
const path = settingsPath(workspace);
|
|
41
|
+
if (!existsSync(path))
|
|
42
|
+
return {};
|
|
43
|
+
const root = record(JSON.parse(readFileSync(path, 'utf8')));
|
|
44
|
+
const reviewer = record(root.reviewer);
|
|
45
|
+
return {
|
|
46
|
+
provider: reviewer.provider === 'local' ? 'local' : reviewer.provider === 'ollama' ? 'ollama' : undefined,
|
|
47
|
+
model: typeof reviewer.model === 'string' ? reviewer.model : undefined,
|
|
48
|
+
workspace: typeof reviewer.workspace === 'string' ? reviewer.workspace : undefined,
|
|
49
|
+
repo: typeof reviewer.repo === 'string' ? reviewer.repo : undefined,
|
|
50
|
+
pr: typeof reviewer.pr === 'string' ? reviewer.pr : undefined,
|
|
51
|
+
publish: typeof reviewer.publish === 'boolean' ? reviewer.publish : undefined,
|
|
52
|
+
maxToolRounds: typeof reviewer.max_tool_rounds === 'number' ? reviewer.max_tool_rounds : undefined,
|
|
53
|
+
readonlyCommands: stringArray(reviewer.readonly_commands) ?? undefined,
|
|
54
|
+
testCommands: stringArray(reviewer.test_commands) ?? undefined,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function resolveReviewerSettings(options = {}, baseWorkspace = process.cwd()) {
|
|
58
|
+
const fromFile = readReviewerSettings(baseWorkspace);
|
|
59
|
+
const workspace = resolve(options.workspace ?? fromFile.workspace ?? baseWorkspace);
|
|
60
|
+
return {
|
|
61
|
+
provider: fromFile.provider ?? 'ollama',
|
|
62
|
+
model: options.model ?? fromFile.model ?? process.env.SICKR_OLLAMA_MODEL ?? 'smollm2:135m',
|
|
63
|
+
workspace,
|
|
64
|
+
repo: options.repo ?? fromFile.repo,
|
|
65
|
+
pr: options.pr ?? fromFile.pr,
|
|
66
|
+
publish: options.publish ?? fromFile.publish ?? false,
|
|
67
|
+
maxToolRounds: options.maxToolRounds ?? fromFile.maxToolRounds ?? 8,
|
|
68
|
+
readonlyCommands: options.readonlyCommands ?? fromFile.readonlyCommands ?? DEFAULT_READONLY_COMMANDS,
|
|
69
|
+
testCommands: options.testCommands ?? fromFile.testCommands ?? DEFAULT_TEST_COMMANDS,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function assertInsideWorkspace(workspace, requestedPath) {
|
|
73
|
+
const root = resolve(workspace);
|
|
74
|
+
const target = resolve(root, requestedPath);
|
|
75
|
+
const rel = relative(root, target);
|
|
76
|
+
if (rel.startsWith('..') || isAbsolute(rel))
|
|
77
|
+
throw new Error(`path escapes workspace: ${requestedPath}`);
|
|
78
|
+
return target;
|
|
79
|
+
}
|
|
80
|
+
export function isReadonlyCommand(command, allowedPrefixes = DEFAULT_READONLY_COMMANDS) {
|
|
81
|
+
const normalized = command.trim().replace(/\s+/g, ' ');
|
|
82
|
+
if (!normalized)
|
|
83
|
+
return false;
|
|
84
|
+
if (/[|;&><`$]/.test(normalized))
|
|
85
|
+
return false;
|
|
86
|
+
if (/\b(env|printenv|set|export|curl|wget|rm|del|move|mv|cp|copy|npm install|pnpm add|yarn add|pip install|git push|git merge|git rebase)\b/i.test(normalized))
|
|
87
|
+
return false;
|
|
88
|
+
return allowedPrefixes.some((prefix) => normalized === prefix || normalized.startsWith(`${prefix} `));
|
|
89
|
+
}
|
|
90
|
+
export function isAllowedTestCommand(command, allowed = DEFAULT_TEST_COMMANDS) {
|
|
91
|
+
const normalized = command.trim().replace(/\s+/g, ' ');
|
|
92
|
+
return allowed.some((candidate) => normalized === candidate.trim().replace(/\s+/g, ' '));
|
|
93
|
+
}
|
|
94
|
+
const shellRunner = {
|
|
95
|
+
run(command, cwd) {
|
|
96
|
+
const result = spawnSync(command, {
|
|
97
|
+
cwd,
|
|
98
|
+
shell: true,
|
|
99
|
+
encoding: 'utf8',
|
|
100
|
+
timeout: 120_000,
|
|
101
|
+
maxBuffer: 1024 * 1024,
|
|
102
|
+
});
|
|
103
|
+
const output = redact(`${result.stdout ?? ''}${result.stderr ? `\n${result.stderr}` : ''}`).trim();
|
|
104
|
+
return {
|
|
105
|
+
tool: 'command',
|
|
106
|
+
status: result.status === 0 ? 'ok' : 'error',
|
|
107
|
+
detail: `exit=${result.status ?? 'signal'}\n${output}`.slice(0, 12_000),
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
function git(args, cwd) {
|
|
112
|
+
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString();
|
|
113
|
+
}
|
|
114
|
+
function safeGh(args, cwd) {
|
|
115
|
+
return execFileSync('gh', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString();
|
|
116
|
+
}
|
|
117
|
+
export function prepareReviewWorkspace(settings) {
|
|
118
|
+
if (!existsSync(settings.workspace))
|
|
119
|
+
throw new Error(`workspace does not exist: ${settings.workspace}`);
|
|
120
|
+
git(['rev-parse', '--show-toplevel'], settings.workspace);
|
|
121
|
+
const dirty = git(['status', '--porcelain'], settings.workspace).trim();
|
|
122
|
+
if (dirty) {
|
|
123
|
+
throw new Error('review workspace is dirty. Commit/stash manual changes or use a dedicated SICKR reviewer workspace.');
|
|
124
|
+
}
|
|
125
|
+
if (settings.pr) {
|
|
126
|
+
safeGh(['pr', 'checkout', settings.pr], settings.workspace);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export function changedFiles(workspace) {
|
|
130
|
+
const candidates = [
|
|
131
|
+
['diff', '--name-only', 'origin/main...HEAD'],
|
|
132
|
+
['diff', '--name-only', 'HEAD~1...HEAD'],
|
|
133
|
+
['diff', '--name-only'],
|
|
134
|
+
];
|
|
135
|
+
for (const args of candidates) {
|
|
136
|
+
try {
|
|
137
|
+
const files = git(args, workspace).split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
138
|
+
if (files.length > 0)
|
|
139
|
+
return Array.from(new Set(files));
|
|
140
|
+
}
|
|
141
|
+
catch { /* try next strategy */ }
|
|
142
|
+
}
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
export function executeToolRequest(req, settings, runner = shellRunner) {
|
|
146
|
+
try {
|
|
147
|
+
if (req.tool === 'list_changed_files') {
|
|
148
|
+
return { tool: req.tool, status: 'ok', detail: changedFiles(settings.workspace).join('\n') || '(no changed files)' };
|
|
149
|
+
}
|
|
150
|
+
if (req.tool === 'read_file') {
|
|
151
|
+
if (!req.path)
|
|
152
|
+
return { tool: req.tool, status: 'denied', detail: 'path is required' };
|
|
153
|
+
const target = assertInsideWorkspace(settings.workspace, req.path);
|
|
154
|
+
if (!existsSync(target))
|
|
155
|
+
return { tool: req.tool, status: 'error', detail: `file not found: ${req.path}` };
|
|
156
|
+
return { tool: req.tool, status: 'ok', detail: redact(readFileSync(target, 'utf8')).slice(0, 20_000) };
|
|
157
|
+
}
|
|
158
|
+
if (req.tool === 'run_readonly_command') {
|
|
159
|
+
const command = req.command ?? '';
|
|
160
|
+
if (!isReadonlyCommand(command, settings.readonlyCommands))
|
|
161
|
+
return { tool: req.tool, status: 'denied', detail: `command is not readonly-allowed: ${command}` };
|
|
162
|
+
return { ...runner.run(command, settings.workspace), tool: req.tool };
|
|
163
|
+
}
|
|
164
|
+
if (req.tool === 'run_test') {
|
|
165
|
+
const command = req.command ?? '';
|
|
166
|
+
if (!isAllowedTestCommand(command, settings.testCommands))
|
|
167
|
+
return { tool: req.tool, status: 'denied', detail: `test command is not configured: ${command}` };
|
|
168
|
+
return { ...runner.run(command, settings.workspace), tool: req.tool };
|
|
169
|
+
}
|
|
170
|
+
if (req.tool === 'apply_patch') {
|
|
171
|
+
if (!req.patch?.trim())
|
|
172
|
+
return { tool: req.tool, status: 'denied', detail: 'patch is required' };
|
|
173
|
+
const tmp = join(mkdtempSync(join(tmpdir(), 'sickr-review-patch-')), 'change.patch');
|
|
174
|
+
try {
|
|
175
|
+
writeFileSync(tmp, req.patch);
|
|
176
|
+
execFileSync('git', ['apply', '--check', tmp], { cwd: settings.workspace, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
177
|
+
execFileSync('git', ['apply', tmp], { cwd: settings.workspace, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
178
|
+
return { tool: req.tool, status: 'ok', detail: 'patch applied locally in reviewer workspace' };
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
rmSync(resolve(tmp, '..'), { recursive: true, force: true });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { tool: 'unknown', status: 'denied', detail: `unknown tool: ${req.tool}` };
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
return { tool: req.tool, status: 'error', detail: redact(e.message) };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
export function buildReviewerPrompt(settings, priorResults) {
|
|
191
|
+
const files = changedFiles(settings.workspace);
|
|
192
|
+
let diff = '';
|
|
193
|
+
try {
|
|
194
|
+
diff = git(['diff', '--stat'], settings.workspace);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
diff = '';
|
|
198
|
+
}
|
|
199
|
+
return [
|
|
200
|
+
'You are a SICKR reviewer agent. Review the PR like a senior code reviewer.',
|
|
201
|
+
'You may inspect files, run readonly commands, apply candidate patches locally, and run configured tests.',
|
|
202
|
+
'You must not request push, merge, arbitrary network access, or secret dumping.',
|
|
203
|
+
'Return exactly one JSON object. Do not wrap it in markdown.',
|
|
204
|
+
'Schema:',
|
|
205
|
+
'{"tool_requests":[{"tool":"list_changed_files|read_file|run_readonly_command|run_test|apply_patch","path":"src/file.ts","command":"npm test","patch":"unified diff","reason":"why"}],"decision":"approve|request_changes|comment|blocked","summary":"short","findings":[{"severity":"blocker|major|minor","file":"path","line":1,"body":"finding"}],"suggestions":[{"file":"path","body":"suggested change"}]}',
|
|
206
|
+
'If you need more information, return tool_requests and omit decision.',
|
|
207
|
+
'Only approve when no blocker/major findings remain and configured tests have passed or are not relevant.',
|
|
208
|
+
`Model: ${settings.model}`,
|
|
209
|
+
`Workspace: ${settings.workspace}`,
|
|
210
|
+
settings.pr ? `PR: ${settings.pr}` : 'PR: current branch',
|
|
211
|
+
`Changed files:\n${files.join('\n') || '(unknown)'}`,
|
|
212
|
+
`Diff stat:\n${diff || '(unavailable)'}`,
|
|
213
|
+
`Configured readonly commands: ${settings.readonlyCommands.join(', ')}`,
|
|
214
|
+
`Configured test commands: ${settings.testCommands.join(', ')}`,
|
|
215
|
+
`Tool results so far:\n${priorResults.map((r, i) => `#${i + 1} ${r.tool} ${r.status}\n${r.detail}`).join('\n\n') || '(none)'}`,
|
|
216
|
+
].join('\n\n');
|
|
217
|
+
}
|
|
218
|
+
export function parseModelReviewResponse(text) {
|
|
219
|
+
const trimmed = text.trim();
|
|
220
|
+
const start = trimmed.indexOf('{');
|
|
221
|
+
const end = trimmed.lastIndexOf('}');
|
|
222
|
+
if (start < 0 || end < start)
|
|
223
|
+
throw new Error('model did not return a JSON object');
|
|
224
|
+
const obj = JSON.parse(trimmed.slice(start, end + 1));
|
|
225
|
+
return obj;
|
|
226
|
+
}
|
|
227
|
+
export function renderReviewBody(response, toolResults) {
|
|
228
|
+
const decision = response.decision ?? 'comment';
|
|
229
|
+
const lines = [`SICKR local reviewer: ${decision}`, '', response.summary ?? 'Review completed.', ''];
|
|
230
|
+
if (response.findings?.length) {
|
|
231
|
+
lines.push('Findings:');
|
|
232
|
+
for (const finding of response.findings) {
|
|
233
|
+
const loc = finding.file ? `${finding.file}${finding.line ? `:${finding.line}` : ''}` : 'general';
|
|
234
|
+
lines.push(`- [${finding.severity ?? 'minor'}] ${loc}: ${finding.body}`);
|
|
235
|
+
}
|
|
236
|
+
lines.push('');
|
|
237
|
+
}
|
|
238
|
+
if (response.suggestions?.length) {
|
|
239
|
+
lines.push('Suggested changes:');
|
|
240
|
+
for (const suggestion of response.suggestions)
|
|
241
|
+
lines.push(`- ${suggestion.file ?? 'general'}: ${suggestion.body}`);
|
|
242
|
+
lines.push('');
|
|
243
|
+
}
|
|
244
|
+
const failedTools = toolResults.filter((r) => r.status !== 'ok');
|
|
245
|
+
if (failedTools.length) {
|
|
246
|
+
lines.push('Runner notes:');
|
|
247
|
+
for (const result of failedTools)
|
|
248
|
+
lines.push(`- ${result.tool}: ${result.status} - ${result.detail.split('\n')[0]}`);
|
|
249
|
+
}
|
|
250
|
+
return redact(lines.join('\n').trim());
|
|
251
|
+
}
|
|
252
|
+
async function callOllama(model, prompt) {
|
|
253
|
+
const host = process.env.OLLAMA_HOST ?? 'http://127.0.0.1:11434';
|
|
254
|
+
const res = await fetch(`${host.replace(/\/$/, '')}/api/generate`, {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
headers: { 'Content-Type': 'application/json' },
|
|
257
|
+
body: JSON.stringify({ model, prompt, stream: false, format: 'json' }),
|
|
258
|
+
});
|
|
259
|
+
if (!res.ok)
|
|
260
|
+
throw new Error(`ollama generate failed: ${res.status}`);
|
|
261
|
+
const json = await res.json();
|
|
262
|
+
if (!json.response)
|
|
263
|
+
throw new Error('ollama generate returned no response');
|
|
264
|
+
return json.response;
|
|
265
|
+
}
|
|
266
|
+
function publishReview(settings, result) {
|
|
267
|
+
if (!settings.pr)
|
|
268
|
+
throw new Error('--publish requires --pr <number|url>');
|
|
269
|
+
const tmp = join(mkdtempSync(join(tmpdir(), 'sickr-review-body-')), 'review.md');
|
|
270
|
+
try {
|
|
271
|
+
writeFileSync(tmp, result.body);
|
|
272
|
+
const args = ['pr', 'review', settings.pr, '--body-file', tmp];
|
|
273
|
+
if (result.decision === 'approve')
|
|
274
|
+
args.push('--approve');
|
|
275
|
+
else if (result.decision === 'request_changes')
|
|
276
|
+
args.push('--request-changes');
|
|
277
|
+
else
|
|
278
|
+
args.push('--comment');
|
|
279
|
+
safeGh(args, settings.workspace);
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
rmSync(resolve(tmp, '..'), { recursive: true, force: true });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
export async function runLocalReviewer(options = {}) {
|
|
286
|
+
const settings = resolveReviewerSettings(options);
|
|
287
|
+
prepareReviewWorkspace(settings);
|
|
288
|
+
const toolResults = [];
|
|
289
|
+
let response = {};
|
|
290
|
+
for (let round = 0; round < settings.maxToolRounds; round += 1) {
|
|
291
|
+
const prompt = buildReviewerPrompt(settings, toolResults);
|
|
292
|
+
response = parseModelReviewResponse(await callOllama(settings.model, prompt));
|
|
293
|
+
const requests = response.tool_requests ?? [];
|
|
294
|
+
if (requests.length === 0)
|
|
295
|
+
break;
|
|
296
|
+
for (const req of requests.slice(0, 5))
|
|
297
|
+
toolResults.push(executeToolRequest(req, settings));
|
|
298
|
+
}
|
|
299
|
+
const decision = response.decision ?? (toolResults.some((r) => r.status === 'error') ? 'blocked' : 'comment');
|
|
300
|
+
const body = renderReviewBody({ ...response, decision }, toolResults);
|
|
301
|
+
const result = { decision, body, published: false, toolResults };
|
|
302
|
+
if (settings.publish) {
|
|
303
|
+
publishReview(settings, result);
|
|
304
|
+
result.published = true;
|
|
305
|
+
}
|
|
306
|
+
return result;
|
|
307
|
+
}
|
|
308
|
+
export function parseReviewArgs(rest) {
|
|
309
|
+
const options = {};
|
|
310
|
+
const testCommands = [];
|
|
311
|
+
const readonlyCommands = [];
|
|
312
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
313
|
+
const arg = rest[i];
|
|
314
|
+
const next = rest[i + 1];
|
|
315
|
+
if ((arg === '--pr' || arg === '--pull-request') && next) {
|
|
316
|
+
options.pr = next;
|
|
317
|
+
i += 1;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (arg.startsWith('--pr=')) {
|
|
321
|
+
options.pr = arg.slice('--pr='.length);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (arg === '--model' && next) {
|
|
325
|
+
options.model = next;
|
|
326
|
+
i += 1;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (arg.startsWith('--model=')) {
|
|
330
|
+
options.model = arg.slice('--model='.length);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (arg === '--workspace' && next) {
|
|
334
|
+
options.workspace = next;
|
|
335
|
+
i += 1;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (arg.startsWith('--workspace=')) {
|
|
339
|
+
options.workspace = arg.slice('--workspace='.length);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (arg === '--repo' && next) {
|
|
343
|
+
options.repo = next;
|
|
344
|
+
i += 1;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (arg.startsWith('--repo=')) {
|
|
348
|
+
options.repo = arg.slice('--repo='.length);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (arg === '--publish') {
|
|
352
|
+
options.publish = true;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (arg === '--dry-run') {
|
|
356
|
+
options.publish = false;
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (arg === '--test' && next) {
|
|
360
|
+
testCommands.push(next);
|
|
361
|
+
i += 1;
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (arg.startsWith('--test=')) {
|
|
365
|
+
testCommands.push(arg.slice('--test='.length));
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (arg === '--readonly' && next) {
|
|
369
|
+
readonlyCommands.push(next);
|
|
370
|
+
i += 1;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (arg.startsWith('--readonly=')) {
|
|
374
|
+
readonlyCommands.push(arg.slice('--readonly='.length));
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (testCommands.length)
|
|
379
|
+
options.testCommands = testCommands;
|
|
380
|
+
if (readonlyCommands.length)
|
|
381
|
+
options.readonlyCommands = [...DEFAULT_READONLY_COMMANDS, ...readonlyCommands];
|
|
382
|
+
return options;
|
|
383
|
+
}
|
|
384
|
+
export function reviewerSettingsExample(workspace = process.cwd()) {
|
|
385
|
+
return JSON.stringify({
|
|
386
|
+
reviewer: {
|
|
387
|
+
provider: 'ollama',
|
|
388
|
+
model: 'qwen2.5-coder:7b',
|
|
389
|
+
workspace,
|
|
390
|
+
publish: false,
|
|
391
|
+
readonly_commands: DEFAULT_READONLY_COMMANDS,
|
|
392
|
+
test_commands: ['npm test'],
|
|
393
|
+
},
|
|
394
|
+
}, null, 2);
|
|
395
|
+
}
|
|
396
|
+
export function reviewResultSummary(result) {
|
|
397
|
+
return [
|
|
398
|
+
`sickr: reviewer decision=${result.decision}${result.published ? ' published=true' : ' published=false'}`,
|
|
399
|
+
'',
|
|
400
|
+
result.body,
|
|
401
|
+
].join('\n');
|
|
402
|
+
}
|
|
403
|
+
export function localReviewerCommandName() {
|
|
404
|
+
return `sickr-local-reviewer-${basename(process.cwd()) || 'workspace'}`;
|
|
405
|
+
}
|
package/dist/run.js
CHANGED
|
@@ -137,6 +137,23 @@ export function normalizeRunEventForRunner(event, identity) {
|
|
|
137
137
|
identity.sessions.add(event.session);
|
|
138
138
|
return event;
|
|
139
139
|
}
|
|
140
|
+
export function buildRunSessionEndMessage(reason, exitCode) {
|
|
141
|
+
const msg = { kind: 'end', reason };
|
|
142
|
+
if (typeof exitCode === 'number')
|
|
143
|
+
msg.exitCode = exitCode;
|
|
144
|
+
return JSON.stringify(msg);
|
|
145
|
+
}
|
|
146
|
+
export function trySendRunSessionEnd(socket, reason, exitCode) {
|
|
147
|
+
if (!socket || socket.readyState !== 1)
|
|
148
|
+
return false;
|
|
149
|
+
try {
|
|
150
|
+
socket.send(buildRunSessionEndMessage(reason, exitCode));
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
140
157
|
export function stripAnsi(input) {
|
|
141
158
|
return input.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, '');
|
|
142
159
|
}
|
|
@@ -550,6 +567,7 @@ export async function startRun(opts) {
|
|
|
550
567
|
}
|
|
551
568
|
catch { /* ignore */ }
|
|
552
569
|
hooklessSynth?.flushResponse();
|
|
570
|
+
trySendRunSessionEnd(ws, exitCode === 0 ? 'session_ended' : 'agent_exited', exitCode);
|
|
553
571
|
try {
|
|
554
572
|
ws?.close();
|
|
555
573
|
}
|