@aibridge/cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +6 -0
- package/dist/context-BLjTHa41.mjs +1529 -0
- package/dist/index.d.mts +184 -0
- package/dist/index.mjs +2 -0
- package/package.json +53 -0
- package/src/app.exit-code.test.ts +91 -0
- package/src/app.ts +49 -0
- package/src/cli.ts +5 -0
- package/src/commands/image-gen/command.ts +77 -0
- package/src/commands/image-gen/impl.ts +268 -0
- package/src/commands/implement/command.ts +50 -0
- package/src/commands/implement/impl.ts +99 -0
- package/src/commands/plan/command.ts +56 -0
- package/src/commands/plan/impl.ts +172 -0
- package/src/commands/plan/plan.test.ts +19 -0
- package/src/commands/quota/command.ts +30 -0
- package/src/commands/quota/impl.ts +109 -0
- package/src/commands/review/command.ts +58 -0
- package/src/commands/review/impl.ts +211 -0
- package/src/commands/review/review.test.ts +54 -0
- package/src/commands/runs/command.ts +53 -0
- package/src/commands/runs/impl.ts +171 -0
- package/src/commands/subagent/command.ts +62 -0
- package/src/commands/subagent/impl.ts +87 -0
- package/src/context.ts +10 -0
- package/src/delegate.test.ts +180 -0
- package/src/delegate.ts +46 -0
- package/src/driver.ts +56 -0
- package/src/drivers.ts +44 -0
- package/src/exitCode.test.ts +44 -0
- package/src/exitCode.ts +24 -0
- package/src/flagMapping.test.ts +99 -0
- package/src/index.ts +37 -0
- package/src/models.test.ts +107 -0
- package/src/models.ts +159 -0
- package/src/parsers.ts +24 -0
- package/src/quotaPreflight.test.ts +178 -0
- package/src/quotaPreflight.ts +103 -0
- package/src/runlog.ts +195 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { type AgyQuotaSnapshot, fetchAgyQuota } from '@aibridge/agy';
|
|
2
|
+
import { type ClaudeQuotaSnapshot, fetchClaudeQuota } from '@aibridge/claude';
|
|
3
|
+
import { type CodexQuotaSnapshot, fetchCodexQuota } from '@aibridge/codex';
|
|
4
|
+
import type { LocalContext } from '../../context.ts';
|
|
5
|
+
|
|
6
|
+
export interface QuotaFlags {
|
|
7
|
+
readonly json: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function formatReset(resetTime: string | undefined): string {
|
|
11
|
+
if (!resetTime) return '-';
|
|
12
|
+
const ms = new Date(resetTime).getTime() - Date.now();
|
|
13
|
+
if (Number.isNaN(ms)) return resetTime;
|
|
14
|
+
if (ms <= 0) return 'now';
|
|
15
|
+
const mins = Math.round(ms / 60_000);
|
|
16
|
+
const rel = mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h${mins % 60}m`;
|
|
17
|
+
return `${new Date(resetTime).toLocaleTimeString()} (in ${rel})`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderAgy(ctx: LocalContext, snapshot: AgyQuotaSnapshot): void {
|
|
21
|
+
ctx.process.stdout.write('=== agy (Antigravity) — remaining per model group ===\n');
|
|
22
|
+
for (const group of snapshot.groups) {
|
|
23
|
+
ctx.process.stdout.write(`${group.displayName}\n`);
|
|
24
|
+
for (const b of group.buckets) {
|
|
25
|
+
const pct =
|
|
26
|
+
b.remainingFraction === 0 ? 'EXHAUSTED' : `${Math.round(b.remainingFraction * 100)}%`;
|
|
27
|
+
ctx.process.stdout.write(
|
|
28
|
+
` ${b.displayName.padEnd(18)} ${pct.padEnd(10)} ${formatReset(b.resetTime)}\n`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const exhausted = snapshot.models.filter(m => m.exhausted);
|
|
33
|
+
if (exhausted.length > 0) {
|
|
34
|
+
ctx.process.stdout.write(
|
|
35
|
+
`Exhausted models: ${[...new Set(exhausted.map(m => m.label))].join(', ')}\n`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderCodex(ctx: LocalContext, snapshot: CodexQuotaSnapshot): void {
|
|
41
|
+
const plan = snapshot.planType ? ` — plan: ${snapshot.planType}` : '';
|
|
42
|
+
const reached = snapshot.limitReached ? ' [LIMIT REACHED]' : '';
|
|
43
|
+
ctx.process.stdout.write(`=== codex (ChatGPT)${plan}${reached} — used per window ===\n`);
|
|
44
|
+
ctx.process.stdout.write(`${'WINDOW'.padEnd(10)} ${'USED'.padEnd(10)} RESET\n`);
|
|
45
|
+
for (const w of snapshot.windows) {
|
|
46
|
+
ctx.process.stdout.write(
|
|
47
|
+
`${w.window.padEnd(10)} ${`${w.usedPercent}%`.padEnd(10)} ${formatReset(w.resetAt)}\n`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderClaude(ctx: LocalContext, snapshot: ClaudeQuotaSnapshot): void {
|
|
53
|
+
ctx.process.stdout.write('=== claude (Claude Code subscription) — used per window ===\n');
|
|
54
|
+
ctx.process.stdout.write(`${'WINDOW'.padEnd(20)} ${'USED'.padEnd(10)} RESET\n`);
|
|
55
|
+
for (const w of snapshot.windows) {
|
|
56
|
+
ctx.process.stdout.write(
|
|
57
|
+
`${w.window.padEnd(20)} ${`${w.usedPercent}%`.padEnd(10)} ${w.resetsText || '-'}\n`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function renderSection<T>(
|
|
63
|
+
ctx: LocalContext,
|
|
64
|
+
result: PromiseSettledResult<T>,
|
|
65
|
+
title: string,
|
|
66
|
+
render: (ctx: LocalContext, snapshot: T) => void,
|
|
67
|
+
): void {
|
|
68
|
+
if (result.status === 'fulfilled') {
|
|
69
|
+
render(ctx, result.value);
|
|
70
|
+
} else {
|
|
71
|
+
ctx.process.stdout.write(
|
|
72
|
+
`=== ${title} ===\nunavailable: ${(result.reason as Error).message}\n`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default async function quotaImpl(this: LocalContext, flags: QuotaFlags): Promise<void> {
|
|
78
|
+
const [agy, codex, claude] = await Promise.allSettled([
|
|
79
|
+
fetchAgyQuota(),
|
|
80
|
+
fetchCodexQuota(),
|
|
81
|
+
fetchClaudeQuota(),
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const allFailed =
|
|
85
|
+
agy.status === 'rejected' && codex.status === 'rejected' && claude.status === 'rejected';
|
|
86
|
+
|
|
87
|
+
if (flags.json) {
|
|
88
|
+
this.process.stdout.write(
|
|
89
|
+
`${JSON.stringify(
|
|
90
|
+
{
|
|
91
|
+
agy: agy.status === 'fulfilled' ? agy.value : { error: String(agy.reason) },
|
|
92
|
+
codex: codex.status === 'fulfilled' ? codex.value : { error: String(codex.reason) },
|
|
93
|
+
claude: claude.status === 'fulfilled' ? claude.value : { error: String(claude.reason) },
|
|
94
|
+
},
|
|
95
|
+
null,
|
|
96
|
+
2,
|
|
97
|
+
)}\n`,
|
|
98
|
+
);
|
|
99
|
+
if (allFailed) this.process.exitCode = 1;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
renderSection(this, agy, 'agy (Antigravity)', renderAgy);
|
|
104
|
+
this.process.stdout.write('\n');
|
|
105
|
+
renderSection(this, codex, 'codex (ChatGPT)', renderCodex);
|
|
106
|
+
this.process.stdout.write('\n');
|
|
107
|
+
renderSection(this, claude, 'claude (Claude Code subscription)', renderClaude);
|
|
108
|
+
if (allFailed) this.process.exitCode = 1;
|
|
109
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { buildCommand } from '@stricli/core';
|
|
2
|
+
import { DEFAULT_MODEL, listModelHelpLines } from '../../models.ts';
|
|
3
|
+
import { positiveIntSeconds } from '../../parsers.ts';
|
|
4
|
+
import reviewImpl from './impl.ts';
|
|
5
|
+
|
|
6
|
+
const fullDescription = [
|
|
7
|
+
'Inspects code diffs or plan contracts and writes a review report.',
|
|
8
|
+
'',
|
|
9
|
+
'Available models (canonical slug):',
|
|
10
|
+
...listModelHelpLines(),
|
|
11
|
+
].join('\n');
|
|
12
|
+
|
|
13
|
+
export const review = buildCommand({
|
|
14
|
+
func: reviewImpl,
|
|
15
|
+
parameters: {
|
|
16
|
+
flags: {
|
|
17
|
+
model: {
|
|
18
|
+
kind: 'parsed',
|
|
19
|
+
parse: String,
|
|
20
|
+
optional: true,
|
|
21
|
+
brief: `Model slug (default: ${DEFAULT_MODEL})`,
|
|
22
|
+
},
|
|
23
|
+
plan: {
|
|
24
|
+
kind: 'parsed',
|
|
25
|
+
parse: String,
|
|
26
|
+
optional: true,
|
|
27
|
+
brief: 'Plan file for contract / over-reach check',
|
|
28
|
+
},
|
|
29
|
+
base: {
|
|
30
|
+
kind: 'parsed',
|
|
31
|
+
parse: String,
|
|
32
|
+
optional: true,
|
|
33
|
+
brief: 'Base git ref to diff against (default: HEAD)',
|
|
34
|
+
},
|
|
35
|
+
out: {
|
|
36
|
+
kind: 'parsed',
|
|
37
|
+
parse: String,
|
|
38
|
+
optional: true,
|
|
39
|
+
brief: 'Where to write the review report (default: <run.dir>/review.md)',
|
|
40
|
+
},
|
|
41
|
+
timeout: {
|
|
42
|
+
kind: 'parsed',
|
|
43
|
+
parse: positiveIntSeconds,
|
|
44
|
+
optional: true,
|
|
45
|
+
brief: 'Max seconds for review (default: 1200)',
|
|
46
|
+
},
|
|
47
|
+
preflight: {
|
|
48
|
+
kind: 'boolean',
|
|
49
|
+
default: true,
|
|
50
|
+
brief: 'Check model quota before running (use --no-preflight to skip)',
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
docs: {
|
|
55
|
+
brief: 'Review working tree diff or plan contract',
|
|
56
|
+
fullDescription,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { runCaptured } from '@aibridge/proc';
|
|
4
|
+
import type { LocalContext } from '../../context.ts';
|
|
5
|
+
import { delegate } from '../../delegate.ts';
|
|
6
|
+
import { DEFAULT_MODEL, formatUnknownModelError, resolveModel } from '../../models.ts';
|
|
7
|
+
import { preflightModel, renderPreflightRefusal } from '../../quotaPreflight.ts';
|
|
8
|
+
import { startRun } from '../../runlog.ts';
|
|
9
|
+
|
|
10
|
+
export interface ReviewFlags {
|
|
11
|
+
readonly model?: string;
|
|
12
|
+
readonly plan?: string;
|
|
13
|
+
readonly base?: string;
|
|
14
|
+
readonly out?: string;
|
|
15
|
+
readonly timeout?: number;
|
|
16
|
+
readonly preflight: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ReviewVerdictResult =
|
|
20
|
+
| { readonly kind: 'pass' }
|
|
21
|
+
| {
|
|
22
|
+
readonly kind: 'findings';
|
|
23
|
+
readonly critical: number;
|
|
24
|
+
readonly major: number;
|
|
25
|
+
readonly minor: number;
|
|
26
|
+
readonly formattedLine: string;
|
|
27
|
+
}
|
|
28
|
+
| { readonly kind: 'unparseable'; readonly rawLine: string };
|
|
29
|
+
|
|
30
|
+
function matchVerdictLine(line: string): ReviewVerdictResult | null {
|
|
31
|
+
if (/^PASS\b/i.test(line)) {
|
|
32
|
+
return { kind: 'pass' };
|
|
33
|
+
}
|
|
34
|
+
const match = line.match(
|
|
35
|
+
/^FINDINGS:\s*(?:(\d+)\s*critical,?\s*)?(?:(\d+)\s*major,?\s*)?(?:(\d+)\s*minor)?/i,
|
|
36
|
+
);
|
|
37
|
+
if (match) {
|
|
38
|
+
const critical = match[1] ? Number.parseInt(match[1], 10) : 0;
|
|
39
|
+
const major = match[2] ? Number.parseInt(match[2], 10) : 0;
|
|
40
|
+
const minor = match[3] ? Number.parseInt(match[3], 10) : 0;
|
|
41
|
+
const formattedLine = `FINDINGS: ${critical} critical, ${major} major, ${minor} minor`;
|
|
42
|
+
return { kind: 'findings', critical, major, minor, formattedLine };
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseReviewVerdict(response: string): ReviewVerdictResult {
|
|
48
|
+
const lines = response
|
|
49
|
+
.split(/\r?\n/)
|
|
50
|
+
.map(l => l.trim())
|
|
51
|
+
.filter(l => l.length > 0);
|
|
52
|
+
if (lines.length === 0) {
|
|
53
|
+
return { kind: 'unparseable', rawLine: '' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const first = matchVerdictLine(lines[0] as string);
|
|
57
|
+
if (first) return first;
|
|
58
|
+
const last = matchVerdictLine(lines[lines.length - 1] as string);
|
|
59
|
+
if (last) return last;
|
|
60
|
+
|
|
61
|
+
const embedded = [
|
|
62
|
+
...response.matchAll(/FINDINGS:\s*(\d+)\s*critical,?\s*(\d+)\s*major,?\s*(\d+)\s*minor/gi),
|
|
63
|
+
].at(-1);
|
|
64
|
+
if (embedded) {
|
|
65
|
+
const critical = Number.parseInt(embedded[1] as string, 10);
|
|
66
|
+
const major = Number.parseInt(embedded[2] as string, 10);
|
|
67
|
+
const minor = Number.parseInt(embedded[3] as string, 10);
|
|
68
|
+
return {
|
|
69
|
+
kind: 'findings',
|
|
70
|
+
critical,
|
|
71
|
+
major,
|
|
72
|
+
minor,
|
|
73
|
+
formattedLine: `FINDINGS: ${critical} critical, ${major} major, ${minor} minor`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { kind: 'unparseable', rawLine: lines[0] as string };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export default async function review(this: LocalContext, flags: ReviewFlags): Promise<void> {
|
|
81
|
+
const inputSlug = flags.model ?? DEFAULT_MODEL;
|
|
82
|
+
const model = resolveModel(inputSlug);
|
|
83
|
+
if (!model) {
|
|
84
|
+
this.process.stderr.write(`${formatUnknownModelError(inputSlug)}\n`);
|
|
85
|
+
this.process.exitCode = 2;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const cwd = this.process.cwd();
|
|
90
|
+
const baseRef = flags.base ?? 'HEAD';
|
|
91
|
+
|
|
92
|
+
let absPlanPath: string | undefined;
|
|
93
|
+
if (flags.plan) {
|
|
94
|
+
absPlanPath = isAbsolute(flags.plan) ? flags.plan : resolve(cwd, flags.plan);
|
|
95
|
+
if (!existsSync(absPlanPath)) {
|
|
96
|
+
this.process.stderr.write(`aibridge review: plan file "${absPlanPath}" not found\n`);
|
|
97
|
+
this.process.exitCode = 2;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const diffRes = await runCaptured('git', ['diff', '--quiet', baseRef], { cwd });
|
|
103
|
+
if (diffRes.code !== 0 && diffRes.code !== 1) {
|
|
104
|
+
const detail = diffRes.stderr.trim().split('\n')[0] ?? `exit code ${diffRes.code}`;
|
|
105
|
+
this.process.stderr.write(
|
|
106
|
+
`aibridge review: git diff failed for base "${baseRef}": ${detail}\n`,
|
|
107
|
+
);
|
|
108
|
+
this.process.exitCode = 2;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const hasDiff = diffRes.code === 1;
|
|
112
|
+
|
|
113
|
+
const statusRes = await runCaptured('git', ['status', '--porcelain'], { cwd });
|
|
114
|
+
const hasPorcelain = statusRes.code === 0 && statusRes.stdout.trim().length > 0;
|
|
115
|
+
|
|
116
|
+
const isDirty = hasDiff || hasPorcelain;
|
|
117
|
+
|
|
118
|
+
if (!isDirty && !absPlanPath) {
|
|
119
|
+
this.process.stderr.write(`aibridge review: nothing to review\n`);
|
|
120
|
+
this.process.exitCode = 2;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (flags.preflight) {
|
|
125
|
+
const verdict = await preflightModel(model);
|
|
126
|
+
if (!verdict.ok) {
|
|
127
|
+
this.process.stderr.write(`${renderPreflightRefusal('review', verdict)}\n`);
|
|
128
|
+
this.process.exitCode = 3;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (verdict.warning) this.process.stderr.write(`aibridge review: ${verdict.warning}\n`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const timeoutSec = flags.timeout ?? 1200;
|
|
135
|
+
const modeDetail = isDirty
|
|
136
|
+
? absPlanPath
|
|
137
|
+
? `diff + plan (${absPlanPath})`
|
|
138
|
+
: `diff (${baseRef})`
|
|
139
|
+
: `plan-only (${absPlanPath})`;
|
|
140
|
+
|
|
141
|
+
const run = startRun('review', `${model.spec.slug}: ${modeDetail}`);
|
|
142
|
+
|
|
143
|
+
const absOutPath = flags.out
|
|
144
|
+
? isAbsolute(flags.out)
|
|
145
|
+
? flags.out
|
|
146
|
+
: resolve(cwd, flags.out)
|
|
147
|
+
: resolve(run.dir, 'review.md');
|
|
148
|
+
|
|
149
|
+
let reviewPrompt: string;
|
|
150
|
+
if (isDirty) {
|
|
151
|
+
reviewPrompt =
|
|
152
|
+
`You are an expert code reviewer. Inspect the working tree diff against base '${baseRef}' and untracked files at ${cwd}.\n` +
|
|
153
|
+
(absPlanPath
|
|
154
|
+
? `Compare the implementation against the plan contract at ${absPlanPath}. Any file modified or feature added outside the plan contract counts as over-reach (severity: major unless harmful, then critical).\n`
|
|
155
|
+
: '') +
|
|
156
|
+
`Write your detailed review report to the file ${absOutPath}. For each finding, include file:line, severity (critical|major|minor), and rationale.\n` +
|
|
157
|
+
`Your final answer (last message) must consist of EXACTLY ONE VERDICT LINE:\n` +
|
|
158
|
+
`Either: "PASS"\n` +
|
|
159
|
+
`Or: "FINDINGS: <c> critical, <m> major, <n> minor"`;
|
|
160
|
+
} else {
|
|
161
|
+
reviewPrompt =
|
|
162
|
+
`You are an expert architecture reviewer. Inspect the plan contract file at ${absPlanPath}.\n` +
|
|
163
|
+
`Review the plan for soundness, missing edge cases, safety, and feasibility.\n` +
|
|
164
|
+
`Write your detailed review report to the file ${absOutPath}. For each finding, include severity (critical|major|minor) and rationale.\n` +
|
|
165
|
+
`Your final answer (last message) must consist of EXACTLY ONE VERDICT LINE:\n` +
|
|
166
|
+
`Either: "PASS"\n` +
|
|
167
|
+
`Or: "FINDINGS: <c> critical, <m> major, <n> minor"`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const outcome = await delegate({
|
|
171
|
+
model,
|
|
172
|
+
prompt: reviewPrompt,
|
|
173
|
+
tools: true,
|
|
174
|
+
timeoutSec,
|
|
175
|
+
cwd,
|
|
176
|
+
run,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (!outcome.ok) {
|
|
180
|
+
this.process.stderr.write(`${outcome.message}\n`);
|
|
181
|
+
this.process.exitCode = 1;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!existsSync(absOutPath) || readFileSync(absOutPath, 'utf8').trim().length === 0) {
|
|
186
|
+
this.process.stderr.write(`aibridge review: review file was not written to ${absOutPath}\n`);
|
|
187
|
+
this.process.exitCode = 1;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const verdictResult = parseReviewVerdict(outcome.response);
|
|
192
|
+
|
|
193
|
+
if (verdictResult.kind === 'unparseable') {
|
|
194
|
+
this.process.stderr.write(`aibridge review: could not parse a verdict line from the answer.\n`);
|
|
195
|
+
this.process.stdout.write(`${outcome.response}\nreview: ${absOutPath}\nrun: ${run.id}\n`);
|
|
196
|
+
this.process.exitCode = 1;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (verdictResult.kind === 'pass') {
|
|
201
|
+
this.process.stdout.write(`PASS\nreview: ${absOutPath}\nrun: ${run.id}\n`);
|
|
202
|
+
this.process.exitCode = 0;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
this.process.stdout.write(
|
|
207
|
+
`${verdictResult.formattedLine}\nreview: ${absOutPath}\nrun: ${run.id}\n`,
|
|
208
|
+
);
|
|
209
|
+
const isPassing = verdictResult.critical === 0 && verdictResult.major === 0;
|
|
210
|
+
this.process.exitCode = isPassing ? 0 : 1;
|
|
211
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseReviewVerdict } from './impl.ts';
|
|
3
|
+
|
|
4
|
+
describe('parseReviewVerdict', () => {
|
|
5
|
+
it('parses PASS verdict', () => {
|
|
6
|
+
const res = parseReviewVerdict('PASS\nFull report below...');
|
|
7
|
+
expect(res.kind).toBe('pass');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('parses FINDINGS verdict line', () => {
|
|
11
|
+
const res = parseReviewVerdict('FINDINGS: 0 critical, 1 major, 2 minor');
|
|
12
|
+
expect(res).toEqual({
|
|
13
|
+
kind: 'findings',
|
|
14
|
+
critical: 0,
|
|
15
|
+
major: 1,
|
|
16
|
+
minor: 2,
|
|
17
|
+
formattedLine: 'FINDINGS: 0 critical, 1 major, 2 minor',
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('parses FINDINGS verdict line with minor only', () => {
|
|
22
|
+
const res = parseReviewVerdict('FINDINGS: 3 minor');
|
|
23
|
+
expect(res).toEqual({
|
|
24
|
+
kind: 'findings',
|
|
25
|
+
critical: 0,
|
|
26
|
+
major: 0,
|
|
27
|
+
minor: 3,
|
|
28
|
+
formattedLine: 'FINDINGS: 0 critical, 0 major, 3 minor',
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('handles unparseable text', () => {
|
|
33
|
+
const res = parseReviewVerdict('The code looks mostly fine but has issues.');
|
|
34
|
+
expect(res.kind).toBe('unparseable');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('parses a verdict on the last line after narration', () => {
|
|
38
|
+
const res = parseReviewVerdict('Reading the diff now.\nWriting the report.\nPASS');
|
|
39
|
+
expect(res.kind).toBe('pass');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('parses a verdict embedded in a newline-free narration blob (observed grok behavior)', () => {
|
|
43
|
+
const res = parseReviewVerdict(
|
|
44
|
+
'Inspecting the diff and plan contract.Checking contract edges, then writing the report.FINDINGS: 0 critical, 5 major, 4 minor',
|
|
45
|
+
);
|
|
46
|
+
expect(res).toEqual({
|
|
47
|
+
kind: 'findings',
|
|
48
|
+
critical: 0,
|
|
49
|
+
major: 5,
|
|
50
|
+
minor: 4,
|
|
51
|
+
formattedLine: 'FINDINGS: 0 critical, 5 major, 4 minor',
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { buildCommand } from '@stricli/core';
|
|
2
|
+
import type { LocalContext } from '../../context.ts';
|
|
3
|
+
import runsImpl, { type RunsFlags } from './impl.ts';
|
|
4
|
+
|
|
5
|
+
const fullDescription =
|
|
6
|
+
'Lists recent runs, watches active runs, or displays logs for a specific run.';
|
|
7
|
+
|
|
8
|
+
async function runsCommand(this: LocalContext, flags: RunsFlags, idPrefix?: string): Promise<void> {
|
|
9
|
+
if (flags.watch && idPrefix !== undefined) {
|
|
10
|
+
this.process.stderr.write('aibridge runs: cannot specify <id> when using --watch\n');
|
|
11
|
+
this.process.exitCode = 2;
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (flags.watch && flags.json) {
|
|
15
|
+
this.process.stderr.write('aibridge runs: cannot specify --json when using --watch\n');
|
|
16
|
+
this.process.exitCode = 2;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
await runsImpl.call(this, flags, idPrefix);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const runs = buildCommand({
|
|
23
|
+
func: runsCommand,
|
|
24
|
+
parameters: {
|
|
25
|
+
flags: {
|
|
26
|
+
watch: {
|
|
27
|
+
kind: 'boolean',
|
|
28
|
+
withNegated: false,
|
|
29
|
+
brief: 'Watch running runs in real time (refresh every 2s)',
|
|
30
|
+
},
|
|
31
|
+
json: {
|
|
32
|
+
kind: 'boolean',
|
|
33
|
+
withNegated: false,
|
|
34
|
+
brief: 'Emit output in JSON Lines format (list mode only)',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
positional: {
|
|
38
|
+
kind: 'tuple',
|
|
39
|
+
parameters: [
|
|
40
|
+
{
|
|
41
|
+
brief: 'Run id prefix to inspect (defaults to listing recent runs)',
|
|
42
|
+
parse: String,
|
|
43
|
+
placeholder: 'id-prefix',
|
|
44
|
+
optional: true,
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
docs: {
|
|
50
|
+
brief: 'Monitor and inspect execution runs',
|
|
51
|
+
fullDescription,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { LocalContext } from '../../context.ts';
|
|
2
|
+
import { listRuns, type RunMeta, readRunLogs } from '../../runlog.ts';
|
|
3
|
+
|
|
4
|
+
export interface RunsFlags {
|
|
5
|
+
readonly watch: boolean;
|
|
6
|
+
readonly json: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function formatElapsed(startedAtStr: string, endedAtStr: string | null): string {
|
|
10
|
+
const start = new Date(startedAtStr).getTime();
|
|
11
|
+
const end = endedAtStr ? new Date(endedAtStr).getTime() : Date.now();
|
|
12
|
+
const diffSec = Math.max(0, Math.floor((end - start) / 1000));
|
|
13
|
+
if (diffSec < 60) {
|
|
14
|
+
return `${diffSec}s`;
|
|
15
|
+
}
|
|
16
|
+
const min = Math.floor(diffSec / 60);
|
|
17
|
+
const sec = diffSec % 60;
|
|
18
|
+
return `${min}m${sec}s`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getStatus(run: RunMeta): string {
|
|
22
|
+
if (run.status === 'running' && run.pid !== null) {
|
|
23
|
+
try {
|
|
24
|
+
process.kill(run.pid, 0);
|
|
25
|
+
} catch {
|
|
26
|
+
return 'stale';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return run.status;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default async function runs(
|
|
33
|
+
this: LocalContext,
|
|
34
|
+
flags: RunsFlags,
|
|
35
|
+
idPrefix?: string,
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
if (idPrefix !== undefined) {
|
|
38
|
+
const all = listRuns();
|
|
39
|
+
const matches = all.filter(r => r.id.startsWith(idPrefix));
|
|
40
|
+
if (matches.length === 0) {
|
|
41
|
+
this.process.stderr.write(`aibridge runs: no run matches prefix "${idPrefix}"\n`);
|
|
42
|
+
this.process.exitCode = 1;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (matches.length > 1) {
|
|
46
|
+
this.process.stderr.write(
|
|
47
|
+
`aibridge runs: ambiguous prefix "${idPrefix}" matches:\n${matches.map(m => ` ${m.id}`).join('\n')}\n`,
|
|
48
|
+
);
|
|
49
|
+
this.process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const target = matches[0];
|
|
53
|
+
if (target === undefined) return;
|
|
54
|
+
const logs = readRunLogs(target.id);
|
|
55
|
+
if (!logs) {
|
|
56
|
+
this.process.stderr.write(`aibridge runs: failed to read logs for run "${target.id}"\n`);
|
|
57
|
+
this.process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const status = getStatus(logs.meta).toUpperCase();
|
|
62
|
+
const elapsed = formatElapsed(logs.meta.startedAt, logs.meta.endedAt);
|
|
63
|
+
const summaryLines = [
|
|
64
|
+
`ID: ${logs.meta.id}`,
|
|
65
|
+
`COMMAND: ${logs.meta.command}`,
|
|
66
|
+
`STATUS: ${status}`,
|
|
67
|
+
`ELAPSED: ${elapsed}`,
|
|
68
|
+
`DETAIL: ${logs.meta.detail}`,
|
|
69
|
+
];
|
|
70
|
+
if (logs.meta.pid !== null) {
|
|
71
|
+
summaryLines.push(`PID: ${logs.meta.pid}`);
|
|
72
|
+
}
|
|
73
|
+
if (logs.meta.exitCode !== null) {
|
|
74
|
+
summaryLines.push(`EXIT: ${logs.meta.exitCode}`);
|
|
75
|
+
}
|
|
76
|
+
this.process.stdout.write(`${summaryLines.join('\n')}\n\n`);
|
|
77
|
+
|
|
78
|
+
const stdoutLines = logs.stdout.split('\n');
|
|
79
|
+
if (stdoutLines.length > 1 && stdoutLines[stdoutLines.length - 1] === '') {
|
|
80
|
+
stdoutLines.pop();
|
|
81
|
+
}
|
|
82
|
+
const lastStdout = stdoutLines.slice(-40).join('\n');
|
|
83
|
+
this.process.stdout.write(`${lastStdout}\n`);
|
|
84
|
+
|
|
85
|
+
if (logs.stderr.trim().length > 0) {
|
|
86
|
+
const stderrLines = logs.stderr.split('\n');
|
|
87
|
+
if (stderrLines.length > 1 && stderrLines[stderrLines.length - 1] === '') {
|
|
88
|
+
stderrLines.pop();
|
|
89
|
+
}
|
|
90
|
+
const lastStderr = stderrLines.slice(-10).join('\n');
|
|
91
|
+
this.process.stdout.write(`\n--- stderr (last 10 lines) ---\n${lastStderr}\n`);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (flags.watch) {
|
|
97
|
+
const update = () => {
|
|
98
|
+
this.process.stdout.write('\x1b[2J\x1b[H');
|
|
99
|
+
const timeStr = new Date().toLocaleTimeString();
|
|
100
|
+
this.process.stdout.write(`aibridge runs — ${timeStr} (ctrl-c to quit)\n\n`);
|
|
101
|
+
|
|
102
|
+
const runs = listRuns();
|
|
103
|
+
if (runs.length === 0) {
|
|
104
|
+
this.process.stdout.write('no runs yet\n');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const limit = runs.slice(0, 10);
|
|
109
|
+
this.process.stdout.write(
|
|
110
|
+
`${'STATUS'.padEnd(10)} ${'ID'.padEnd(35)} ${'ELAPSED'.padEnd(10)} DETAIL\n`,
|
|
111
|
+
);
|
|
112
|
+
for (const r of limit) {
|
|
113
|
+
const status = getStatus(r).toUpperCase();
|
|
114
|
+
const elapsed = formatElapsed(r.startedAt, r.endedAt);
|
|
115
|
+
const detail = r.detail.replace(/\r?\n/g, ' ');
|
|
116
|
+
const truncatedDetail = detail.length > 60 ? `${detail.slice(0, 57)}...` : detail;
|
|
117
|
+
this.process.stdout.write(
|
|
118
|
+
`${status.padEnd(10)} ${r.id.padEnd(35)} ${elapsed.padEnd(10)} ${truncatedDetail}\n`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const runningRuns = runs.filter(r => getStatus(r) === 'running');
|
|
123
|
+
for (const r of runningRuns) {
|
|
124
|
+
const logs = readRunLogs(r.id);
|
|
125
|
+
if (logs) {
|
|
126
|
+
this.process.stdout.write(`\n--- stdout: ${r.id} ---\n`);
|
|
127
|
+
const lines = logs.stdout.split('\n');
|
|
128
|
+
if (lines.length > 1 && lines[lines.length - 1] === '') {
|
|
129
|
+
lines.pop();
|
|
130
|
+
}
|
|
131
|
+
const lastSix = lines.slice(-6).join('\n');
|
|
132
|
+
this.process.stdout.write(`${lastSix}\n`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
update();
|
|
138
|
+
setInterval(update, 2000);
|
|
139
|
+
return new Promise<void>(() => {});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const runs = listRuns();
|
|
143
|
+
if (runs.length === 0) {
|
|
144
|
+
this.process.stdout.write('no runs yet\n');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (flags.json) {
|
|
149
|
+
const limit = runs.slice(0, 20);
|
|
150
|
+
for (const r of limit) {
|
|
151
|
+
const status = getStatus(r);
|
|
152
|
+
const withStatus = { ...r, status };
|
|
153
|
+
this.process.stdout.write(`${JSON.stringify(withStatus)}\n`);
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const limit = runs.slice(0, 20);
|
|
159
|
+
this.process.stdout.write(
|
|
160
|
+
`${'STATUS'.padEnd(10)} ${'ID'.padEnd(35)} ${'ELAPSED'.padEnd(10)} DETAIL\n`,
|
|
161
|
+
);
|
|
162
|
+
for (const r of limit) {
|
|
163
|
+
const status = getStatus(r).toUpperCase();
|
|
164
|
+
const elapsed = formatElapsed(r.startedAt, r.endedAt);
|
|
165
|
+
const detail = r.detail.replace(/\r?\n/g, ' ');
|
|
166
|
+
const truncatedDetail = detail.length > 60 ? `${detail.slice(0, 57)}...` : detail;
|
|
167
|
+
this.process.stdout.write(
|
|
168
|
+
`${status.padEnd(10)} ${r.id.padEnd(35)} ${elapsed.padEnd(10)} ${truncatedDetail}\n`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|