@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 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 { agent: command, agentArgs, mode, productMode };
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
  }
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sickr/cli",
3
- "version": "0.9.15",
3
+ "version": "0.9.18",
4
4
  "type": "module",
5
5
  "description": "npx @sickr/cli - replay, live look, and workflow orchestration for AI coding agents.",
6
6
  "bin": {