@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,268 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
13
|
+
import { isNotFound, runCaptured } from '@aibridge/proc';
|
|
14
|
+
import type { LocalContext } from '../../context.ts';
|
|
15
|
+
import type { ImageResult } from '../../driver.ts';
|
|
16
|
+
import { getDriver } from '../../drivers.ts';
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_IMAGE_GEN,
|
|
19
|
+
formatImageGenModelError,
|
|
20
|
+
formatUnknownModelError,
|
|
21
|
+
resolveModel,
|
|
22
|
+
supportsImageGen,
|
|
23
|
+
} from '../../models.ts';
|
|
24
|
+
|
|
25
|
+
export interface ImageGenFlags {
|
|
26
|
+
readonly model?: string;
|
|
27
|
+
readonly out?: string;
|
|
28
|
+
readonly size?: string;
|
|
29
|
+
readonly image?: string;
|
|
30
|
+
readonly quality?: string;
|
|
31
|
+
readonly timeout?: number;
|
|
32
|
+
readonly json: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MIN_REAL_BYTES_CODEX = 100_000;
|
|
36
|
+
const MIN_REAL_BYTES_GROK = 10_000;
|
|
37
|
+
|
|
38
|
+
export default async function imageGen(
|
|
39
|
+
this: LocalContext,
|
|
40
|
+
flags: ImageGenFlags,
|
|
41
|
+
prompt: string,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
const fail = (msg: string): void => {
|
|
44
|
+
this.process.stderr.write(`aibridge image-gen: ${msg}\n`);
|
|
45
|
+
this.process.exitCode = 1;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const inputSlug = flags.model ?? DEFAULT_IMAGE_GEN;
|
|
49
|
+
const model = resolveModel(inputSlug);
|
|
50
|
+
if (!model) return fail(formatUnknownModelError(inputSlug));
|
|
51
|
+
if (!supportsImageGen(model)) return fail(formatImageGenModelError(inputSlug, model));
|
|
52
|
+
|
|
53
|
+
if (model.spec.backend === 'codex' && model.effort) {
|
|
54
|
+
return fail(
|
|
55
|
+
`effort "-${model.effort}" has no effect on image-gen (gpt-image-2 renders, not the seat model); use ${DEFAULT_IMAGE_GEN}.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const quality = (flags.quality ?? 'high').toLowerCase();
|
|
60
|
+
if (!['low', 'medium', 'high'].includes(quality)) {
|
|
61
|
+
return fail(`invalid --quality "${flags.quality}" (use low | medium | high)`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let size: { w: number; h: number } | undefined;
|
|
65
|
+
if (flags.size !== undefined) {
|
|
66
|
+
const m = flags.size.match(/^(\d+)\s*x\s*(\d+)$/i);
|
|
67
|
+
if (!m) return fail(`invalid --size "${flags.size}" (expected e.g. 1024x1024)`);
|
|
68
|
+
size = { w: Number(m[1]), h: Number(m[2]) };
|
|
69
|
+
if (model.spec.backend === 'codex') {
|
|
70
|
+
const constraint = sizeConstraintError(size.w, size.h);
|
|
71
|
+
if (constraint) return fail(`invalid --size ${size.w}x${size.h}: ${constraint}`);
|
|
72
|
+
} else if (size.w < 1 || size.h < 1) {
|
|
73
|
+
return fail(`invalid --size ${size.w}x${size.h}: dimensions must be positive`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const timeoutSec = flags.timeout ?? 600;
|
|
78
|
+
const outPath = resolve(this.process.cwd(), flags.out ?? './aibridge-image.png');
|
|
79
|
+
|
|
80
|
+
const imagePaths: string[] = [];
|
|
81
|
+
if (flags.image !== undefined) {
|
|
82
|
+
for (const raw of flags.image
|
|
83
|
+
.split(',')
|
|
84
|
+
.map(s => s.trim())
|
|
85
|
+
.filter(Boolean)) {
|
|
86
|
+
const abs = resolve(this.process.cwd(), raw);
|
|
87
|
+
if (!existsSync(abs)) return fail(`reference image not found: ${raw}`);
|
|
88
|
+
imagePaths.push(abs);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const driver = getDriver(model.spec.backend);
|
|
93
|
+
if (!driver.generateImage) {
|
|
94
|
+
return fail(formatImageGenModelError(inputSlug, model));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const minBytes = model.spec.backend === 'codex' ? MIN_REAL_BYTES_CODEX : MIN_REAL_BYTES_GROK;
|
|
98
|
+
const work = mkdtempSync(join(tmpdir(), 'aibridge-imagegen-'));
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
let outcome: ImageResult = await driver.generateImage({
|
|
102
|
+
prompt,
|
|
103
|
+
workDir: work,
|
|
104
|
+
backendModel: model.spec.backendModel,
|
|
105
|
+
effort: model.effort,
|
|
106
|
+
quality,
|
|
107
|
+
size,
|
|
108
|
+
imagePaths,
|
|
109
|
+
timeoutSec,
|
|
110
|
+
forceful: false,
|
|
111
|
+
minBytes,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (model.spec.backend === 'codex' && outcome.kind === 'suspect') {
|
|
115
|
+
outcome = await driver.generateImage({
|
|
116
|
+
prompt,
|
|
117
|
+
workDir: work,
|
|
118
|
+
backendModel: model.spec.backendModel,
|
|
119
|
+
effort: model.effort,
|
|
120
|
+
quality,
|
|
121
|
+
size,
|
|
122
|
+
imagePaths,
|
|
123
|
+
timeoutSec,
|
|
124
|
+
forceful: true,
|
|
125
|
+
minBytes,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (outcome.kind === 'ok' && outcome.bytes < minBytes) {
|
|
130
|
+
outcome = { kind: 'suspect' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (outcome.kind === 'error') return fail(outcome.reason);
|
|
134
|
+
if (outcome.kind === 'suspect') {
|
|
135
|
+
return fail(
|
|
136
|
+
model.spec.backend === 'grok'
|
|
137
|
+
? 'grok produced no usable image. Check SuperGrok image access and re-run with a simpler prompt.'
|
|
138
|
+
: 'codex produced only a tiny/code-drawn image, not a real gpt-image-2 render. ' +
|
|
139
|
+
'Try --quality high or a clearer, simpler prompt.',
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const local = join(work, 'result.bin');
|
|
144
|
+
copyFileSync(outcome.path, local);
|
|
145
|
+
|
|
146
|
+
let dims = imageSize(local);
|
|
147
|
+
if (size && dims && (dims.width !== size.w || dims.height !== size.h)) {
|
|
148
|
+
const resized = await magick([local, '-resize', `${size.w}x${size.h}!`, local]);
|
|
149
|
+
if (resized) {
|
|
150
|
+
dims = imageSize(local) ?? dims;
|
|
151
|
+
} else {
|
|
152
|
+
this.process.stderr.write(
|
|
153
|
+
`aibridge image-gen: rendered ${dims.width}x${dims.height}, wanted ${size.w}x${size.h}, ` +
|
|
154
|
+
'and ImageMagick (magick/convert) is unavailable to resize.\n',
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const outExt = /\.png$/i.test(outPath) ? 'png' : /\.jpe?g$/i.test(outPath) ? 'jpg' : null;
|
|
160
|
+
const actualFmt = pngSize(local) ? 'png' : jpegSize(local) ? 'jpg' : null;
|
|
161
|
+
const needsConvert = outExt !== null && actualFmt !== null && outExt !== actualFmt;
|
|
162
|
+
if (!needsConvert || !(await magick([local, outPath]))) {
|
|
163
|
+
if (needsConvert) {
|
|
164
|
+
this.process.stderr.write(
|
|
165
|
+
`aibridge image-gen: render is ${actualFmt.toUpperCase()} but out path wants ` +
|
|
166
|
+
`${outExt.toUpperCase()}, and ImageMagick (magick/convert) is unavailable to convert; ` +
|
|
167
|
+
'writing the raw bytes as-is.\n',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
copyFileSync(local, outPath);
|
|
171
|
+
}
|
|
172
|
+
const bytes = statSync(outPath).size;
|
|
173
|
+
|
|
174
|
+
if (flags.json) {
|
|
175
|
+
this.process.stdout.write(
|
|
176
|
+
`${JSON.stringify({
|
|
177
|
+
out: outPath,
|
|
178
|
+
bytes,
|
|
179
|
+
width: dims?.width ?? null,
|
|
180
|
+
height: dims?.height ?? null,
|
|
181
|
+
sizeRequested: flags.size ?? null,
|
|
182
|
+
quality: model.spec.backend === 'codex' ? quality : null,
|
|
183
|
+
model: model.spec.slug,
|
|
184
|
+
backend: model.spec.backend,
|
|
185
|
+
real: true,
|
|
186
|
+
})}\n`,
|
|
187
|
+
);
|
|
188
|
+
} else {
|
|
189
|
+
const kb = Math.round(bytes / 1024);
|
|
190
|
+
const dimStr = dims ? `${dims.width}x${dims.height}, ` : '';
|
|
191
|
+
const qualityStr = model.spec.backend === 'codex' ? `, ${quality} quality` : '';
|
|
192
|
+
this.process.stdout.write(
|
|
193
|
+
`✓ Wrote ${outPath} (${dimStr}${kb} KB${qualityStr}, ${model.spec.slug})\n`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
} finally {
|
|
197
|
+
rmSync(work, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function sizeConstraintError(w: number, h: number): string | null {
|
|
202
|
+
if (w % 16 !== 0 || h % 16 !== 0) return 'each edge must be divisible by 16';
|
|
203
|
+
const long = Math.max(w, h);
|
|
204
|
+
const short = Math.min(w, h);
|
|
205
|
+
if (long / short > 3) return 'aspect ratio must be within 1:3–3:1';
|
|
206
|
+
if (long > 3840) return 'longest edge must be <= 3840px';
|
|
207
|
+
const px = w * h;
|
|
208
|
+
if (px < 655_360 || px > 8_294_400) return 'total pixels must be 655,360–8,294,400';
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function imageSize(path: string): { width: number; height: number } | null {
|
|
213
|
+
return pngSize(path) ?? jpegSize(path);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function pngSize(path: string): { width: number; height: number } | null {
|
|
217
|
+
try {
|
|
218
|
+
const fd = openSync(path, 'r');
|
|
219
|
+
const head = Buffer.alloc(24);
|
|
220
|
+
readSync(fd, head, 0, 24, 0);
|
|
221
|
+
closeSync(fd);
|
|
222
|
+
if (head.toString('latin1', 1, 4) !== 'PNG') return null;
|
|
223
|
+
if (head.toString('latin1', 12, 16) !== 'IHDR') return null;
|
|
224
|
+
return { width: head.readUInt32BE(16), height: head.readUInt32BE(20) };
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function jpegSize(path: string): { width: number; height: number } | null {
|
|
231
|
+
try {
|
|
232
|
+
const fd = openSync(path, 'r');
|
|
233
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
234
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
235
|
+
closeSync(fd);
|
|
236
|
+
if (n < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null;
|
|
237
|
+
let i = 2;
|
|
238
|
+
while (i + 9 < n) {
|
|
239
|
+
if (buf[i] !== 0xff) return null;
|
|
240
|
+
const marker = buf[i + 1];
|
|
241
|
+
if (marker === undefined) return null;
|
|
242
|
+
if (marker === 0xc0 || marker === 0xc1 || marker === 0xc2) {
|
|
243
|
+
const height = buf.readUInt16BE(i + 5);
|
|
244
|
+
const width = buf.readUInt16BE(i + 7);
|
|
245
|
+
return { width, height };
|
|
246
|
+
}
|
|
247
|
+
if (marker === 0xd9 || marker === 0xda) return null;
|
|
248
|
+
const len = buf.readUInt16BE(i + 2);
|
|
249
|
+
if (len < 2) return null;
|
|
250
|
+
i += 2 + len;
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function magick(args: readonly string[]): Promise<boolean> {
|
|
259
|
+
for (const tool of ['magick', 'convert']) {
|
|
260
|
+
try {
|
|
261
|
+
const r = await runCaptured(tool, [...args], { timeoutMs: 60_000 });
|
|
262
|
+
if (!r.timedOut && r.code === 0) return true;
|
|
263
|
+
} catch (err) {
|
|
264
|
+
if (!isNotFound(err)) throw err;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { buildCommand } from '@stricli/core';
|
|
2
|
+
import { DEFAULT_IMPLEMENTER, listModelHelpLines } from '../../models.ts';
|
|
3
|
+
import { positiveIntSeconds } from '../../parsers.ts';
|
|
4
|
+
import implementImpl from './impl.ts';
|
|
5
|
+
|
|
6
|
+
const fullDescription = [
|
|
7
|
+
'Reads an implementation plan file and delegates execution to a model.',
|
|
8
|
+
'',
|
|
9
|
+
'Available models (canonical slug):',
|
|
10
|
+
...listModelHelpLines(),
|
|
11
|
+
].join('\n');
|
|
12
|
+
|
|
13
|
+
export const implement = buildCommand({
|
|
14
|
+
func: implementImpl,
|
|
15
|
+
parameters: {
|
|
16
|
+
flags: {
|
|
17
|
+
model: {
|
|
18
|
+
kind: 'parsed',
|
|
19
|
+
parse: String,
|
|
20
|
+
optional: true,
|
|
21
|
+
brief: `Model slug (default: ${DEFAULT_IMPLEMENTER})`,
|
|
22
|
+
},
|
|
23
|
+
timeout: {
|
|
24
|
+
kind: 'parsed',
|
|
25
|
+
parse: positiveIntSeconds,
|
|
26
|
+
optional: true,
|
|
27
|
+
brief: 'Max seconds for implementation (default: 1800)',
|
|
28
|
+
},
|
|
29
|
+
preflight: {
|
|
30
|
+
kind: 'boolean',
|
|
31
|
+
default: true,
|
|
32
|
+
brief: 'Check model quota before running (use --no-preflight to skip)',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
positional: {
|
|
36
|
+
kind: 'tuple',
|
|
37
|
+
parameters: [
|
|
38
|
+
{
|
|
39
|
+
brief: 'Path to the plan file to implement',
|
|
40
|
+
parse: String,
|
|
41
|
+
placeholder: 'plan-file',
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
docs: {
|
|
47
|
+
brief: 'Execute an implementation plan',
|
|
48
|
+
fullDescription,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { existsSync } 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_IMPLEMENTER, formatUnknownModelError, resolveModel } from '../../models.ts';
|
|
7
|
+
import { preflightModel, renderPreflightRefusal } from '../../quotaPreflight.ts';
|
|
8
|
+
import { startRun } from '../../runlog.ts';
|
|
9
|
+
|
|
10
|
+
export interface ImplementFlags {
|
|
11
|
+
readonly model?: string;
|
|
12
|
+
readonly timeout?: number;
|
|
13
|
+
readonly preflight: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default async function implement(
|
|
17
|
+
this: LocalContext,
|
|
18
|
+
flags: ImplementFlags,
|
|
19
|
+
planFile: string,
|
|
20
|
+
): Promise<void> {
|
|
21
|
+
const inputSlug = flags.model ?? DEFAULT_IMPLEMENTER;
|
|
22
|
+
const model = resolveModel(inputSlug);
|
|
23
|
+
if (!model) {
|
|
24
|
+
this.process.stderr.write(`${formatUnknownModelError(inputSlug)}\n`);
|
|
25
|
+
this.process.exitCode = 2;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const cwd = this.process.cwd();
|
|
30
|
+
const absPlanPath = isAbsolute(planFile) ? planFile : resolve(cwd, planFile);
|
|
31
|
+
if (!existsSync(absPlanPath)) {
|
|
32
|
+
this.process.stderr.write(`aibridge implement: plan file "${absPlanPath}" not found\n`);
|
|
33
|
+
this.process.exitCode = 2;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (flags.preflight) {
|
|
38
|
+
const verdict = await preflightModel(model);
|
|
39
|
+
if (!verdict.ok) {
|
|
40
|
+
this.process.stderr.write(`${renderPreflightRefusal('implement', verdict)}\n`);
|
|
41
|
+
this.process.exitCode = 3;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (verdict.warning) this.process.stderr.write(`aibridge implement: ${verdict.warning}\n`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const timeoutSec = flags.timeout ?? 1800;
|
|
48
|
+
const run = startRun('implement', `${model.spec.slug}: ${planFile}`);
|
|
49
|
+
|
|
50
|
+
const implementPrompt =
|
|
51
|
+
`Read the implementation plan file at ${absPlanPath} and implement it EXACTLY.\n` +
|
|
52
|
+
`Edit only the files it names. Run the project's REAL typecheck and tests and fix until green.\n` +
|
|
53
|
+
`Do NOT commit, push, or delete unrelated files. Reply with a short summary (files changed + final typecheck/test results).`;
|
|
54
|
+
|
|
55
|
+
const outcome = await delegate({
|
|
56
|
+
model,
|
|
57
|
+
prompt: implementPrompt,
|
|
58
|
+
tools: true,
|
|
59
|
+
timeoutSec,
|
|
60
|
+
cwd,
|
|
61
|
+
run,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (!outcome.ok) {
|
|
65
|
+
this.process.stderr.write(`${outcome.message}\n`);
|
|
66
|
+
this.process.exitCode = 1;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const diffRes = await runCaptured('git', ['diff', '--stat'], { cwd });
|
|
71
|
+
const diffStat = diffRes.stdout.trim();
|
|
72
|
+
|
|
73
|
+
const statusRes = await runCaptured('git', ['status', '--porcelain'], { cwd });
|
|
74
|
+
let untrackedCount = 0;
|
|
75
|
+
if (statusRes.code === 0) {
|
|
76
|
+
for (const line of statusRes.stdout.split(/\r?\n/)) {
|
|
77
|
+
if (line.startsWith('??')) {
|
|
78
|
+
untrackedCount++;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (diffStat.length === 0 && untrackedCount === 0) {
|
|
84
|
+
this.process.stderr.write(
|
|
85
|
+
`aibridge implement: delegate completed but made zero working tree changes.\n`,
|
|
86
|
+
);
|
|
87
|
+
this.process.exitCode = 1;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const outputParts = [outcome.response, ''];
|
|
92
|
+
if (diffStat.length > 0) {
|
|
93
|
+
outputParts.push(diffStat);
|
|
94
|
+
}
|
|
95
|
+
outputParts.push(`untracked files: ${untrackedCount}`);
|
|
96
|
+
outputParts.push(`run: ${run.id}`);
|
|
97
|
+
|
|
98
|
+
this.process.stdout.write(`${outputParts.join('\n')}\n`);
|
|
99
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { buildCommand } from '@stricli/core';
|
|
2
|
+
import { DEFAULT_MODEL, listModelHelpLines } from '../../models.ts';
|
|
3
|
+
import { nonEmptyPrompt, positiveIntSeconds } from '../../parsers.ts';
|
|
4
|
+
import planImpl from './impl.ts';
|
|
5
|
+
|
|
6
|
+
const fullDescription = [
|
|
7
|
+
'Produce a detailed implementation plan for a task prompt.',
|
|
8
|
+
'',
|
|
9
|
+
'Available models (canonical slug):',
|
|
10
|
+
...listModelHelpLines(),
|
|
11
|
+
].join('\n');
|
|
12
|
+
|
|
13
|
+
export const plan = buildCommand({
|
|
14
|
+
func: planImpl,
|
|
15
|
+
parameters: {
|
|
16
|
+
flags: {
|
|
17
|
+
model: {
|
|
18
|
+
kind: 'parsed',
|
|
19
|
+
parse: String,
|
|
20
|
+
optional: true,
|
|
21
|
+
brief: `Model slug (default: ${DEFAULT_MODEL})`,
|
|
22
|
+
},
|
|
23
|
+
out: {
|
|
24
|
+
kind: 'parsed',
|
|
25
|
+
parse: String,
|
|
26
|
+
optional: true,
|
|
27
|
+
brief: 'Where to write the plan (default: <run.dir>/plan.md)',
|
|
28
|
+
},
|
|
29
|
+
timeout: {
|
|
30
|
+
kind: 'parsed',
|
|
31
|
+
parse: positiveIntSeconds,
|
|
32
|
+
optional: true,
|
|
33
|
+
brief: 'Max seconds for planning (default: 1800)',
|
|
34
|
+
},
|
|
35
|
+
preflight: {
|
|
36
|
+
kind: 'boolean',
|
|
37
|
+
default: true,
|
|
38
|
+
brief: 'Check model quota before running (use --no-preflight to skip)',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
positional: {
|
|
42
|
+
kind: 'tuple',
|
|
43
|
+
parameters: [
|
|
44
|
+
{
|
|
45
|
+
brief: 'Task prompt to expand into a detailed implementation plan',
|
|
46
|
+
parse: nonEmptyPrompt,
|
|
47
|
+
placeholder: 'task-prompt',
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
docs: {
|
|
53
|
+
brief: 'Produce a detailed implementation plan for a task prompt',
|
|
54
|
+
fullDescription,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
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 PlanFlags {
|
|
11
|
+
readonly model?: string;
|
|
12
|
+
readonly out?: string;
|
|
13
|
+
readonly timeout?: number;
|
|
14
|
+
readonly preflight: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function countOpenQuestions(markdown: string): number {
|
|
18
|
+
const headingIdx = markdown.search(/^## Open questions[ \t]*$/m);
|
|
19
|
+
if (headingIdx === -1) return 0;
|
|
20
|
+
|
|
21
|
+
const afterHeading = markdown.slice(headingIdx);
|
|
22
|
+
const lines = afterHeading.split(/\r?\n/);
|
|
23
|
+
lines.shift();
|
|
24
|
+
|
|
25
|
+
let count = 0;
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
if (/^## /.test(line)) break;
|
|
28
|
+
const trimmed = line.trim();
|
|
29
|
+
if (trimmed === 'None.' && count === 0) return 0;
|
|
30
|
+
if (/^[-*] /.test(trimmed)) {
|
|
31
|
+
count++;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return count;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function getPorcelainStatus(cwd: string): Promise<Set<string>> {
|
|
38
|
+
const res = await runCaptured('git', ['status', '--porcelain'], { cwd });
|
|
39
|
+
if (res.code !== 0) return new Set();
|
|
40
|
+
const set = new Set<string>();
|
|
41
|
+
for (const line of res.stdout.split(/\r?\n/)) {
|
|
42
|
+
if (line.trim().length > 0) {
|
|
43
|
+
set.add(line);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return set;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function extractPathFromPorcelainLine(line: string): string {
|
|
50
|
+
let content = line.slice(3).trim();
|
|
51
|
+
if (content.includes(' -> ')) {
|
|
52
|
+
const parts = content.split(' -> ');
|
|
53
|
+
content = (parts[parts.length - 1] ?? '').trim();
|
|
54
|
+
}
|
|
55
|
+
if (content.startsWith('"') && content.endsWith('"')) {
|
|
56
|
+
content = content.slice(1, -1);
|
|
57
|
+
}
|
|
58
|
+
return content;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default async function plan(
|
|
62
|
+
this: LocalContext,
|
|
63
|
+
flags: PlanFlags,
|
|
64
|
+
taskPrompt: string,
|
|
65
|
+
): Promise<void> {
|
|
66
|
+
const inputSlug = flags.model ?? DEFAULT_MODEL;
|
|
67
|
+
const model = resolveModel(inputSlug);
|
|
68
|
+
if (!model) {
|
|
69
|
+
this.process.stderr.write(`${formatUnknownModelError(inputSlug)}\n`);
|
|
70
|
+
this.process.exitCode = 2;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (flags.preflight) {
|
|
75
|
+
const verdict = await preflightModel(model);
|
|
76
|
+
if (!verdict.ok) {
|
|
77
|
+
this.process.stderr.write(`${renderPreflightRefusal('plan', verdict)}\n`);
|
|
78
|
+
this.process.exitCode = 3;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (verdict.warning) this.process.stderr.write(`aibridge plan: ${verdict.warning}\n`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const timeoutSec = flags.timeout ?? 1800;
|
|
85
|
+
const cwd = this.process.cwd();
|
|
86
|
+
const promptSnippet = taskPrompt.replace(/\r?\n/g, ' ').slice(0, 80);
|
|
87
|
+
const run = startRun('plan', `${model.spec.slug}: ${promptSnippet}`);
|
|
88
|
+
|
|
89
|
+
const absOutPath = flags.out
|
|
90
|
+
? isAbsolute(flags.out)
|
|
91
|
+
? flags.out
|
|
92
|
+
: resolve(cwd, flags.out)
|
|
93
|
+
: resolve(run.dir, 'plan.md');
|
|
94
|
+
|
|
95
|
+
const beforePorcelain = await getPorcelainStatus(cwd);
|
|
96
|
+
|
|
97
|
+
const plannerPrompt =
|
|
98
|
+
`You are a senior implementation planner. Study the real codebase with your tools at ${cwd}.\n` +
|
|
99
|
+
`Design module boundaries, interfaces, and naming. Name every file to touch and describe what changes; define clear verification gates.\n` +
|
|
100
|
+
`Write EXACTLY one file to the absolute path: ${absOutPath}\n` +
|
|
101
|
+
`Touch nothing else in the working tree.\n` +
|
|
102
|
+
`End the document with a section titled "## Open questions" (write "None." under it if you are confident and have no open questions).\n` +
|
|
103
|
+
`Do not commit or push.\n\n` +
|
|
104
|
+
`Task Prompt:\n${taskPrompt}`;
|
|
105
|
+
|
|
106
|
+
const outcome = await delegate({
|
|
107
|
+
model,
|
|
108
|
+
prompt: plannerPrompt,
|
|
109
|
+
tools: true,
|
|
110
|
+
timeoutSec,
|
|
111
|
+
cwd,
|
|
112
|
+
run,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (!outcome.ok) {
|
|
116
|
+
this.process.stderr.write(`${outcome.message}\n`);
|
|
117
|
+
this.process.exitCode = 1;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!existsSync(absOutPath)) {
|
|
122
|
+
this.process.stderr.write(`aibridge plan: plan file was not written to ${absOutPath}\n`);
|
|
123
|
+
this.process.exitCode = 1;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const planContent = readFileSync(absOutPath, 'utf8');
|
|
128
|
+
if (planContent.trim().length === 0) {
|
|
129
|
+
this.process.stderr.write(`aibridge plan: plan file at ${absOutPath} is empty\n`);
|
|
130
|
+
this.process.exitCode = 1;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!/^## Open questions/m.test(planContent)) {
|
|
135
|
+
this.process.stderr.write(
|
|
136
|
+
`aibridge plan: plan file at ${absOutPath} missing required "## Open questions" section\n`,
|
|
137
|
+
);
|
|
138
|
+
this.process.exitCode = 1;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const afterPorcelain = await getPorcelainStatus(cwd);
|
|
143
|
+
const normalizedOut = resolve(absOutPath);
|
|
144
|
+
const normalizedCwd = resolve(cwd);
|
|
145
|
+
const isOutInRepo = normalizedOut.startsWith(normalizedCwd);
|
|
146
|
+
|
|
147
|
+
const unexpectedPaths: string[] = [];
|
|
148
|
+
for (const line of afterPorcelain) {
|
|
149
|
+
if (!beforePorcelain.has(line)) {
|
|
150
|
+
const relPath = extractPathFromPorcelainLine(line);
|
|
151
|
+
const absPath = resolve(cwd, relPath);
|
|
152
|
+
if (isOutInRepo && absPath === normalizedOut) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
unexpectedPaths.push(relPath);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (unexpectedPaths.length > 0) {
|
|
160
|
+
this.process.stderr.write(
|
|
161
|
+
`aibridge plan: unexpected working tree changes beyond plan file:\n${unexpectedPaths.map(p => ` ${p}`).join('\n')}\n`,
|
|
162
|
+
);
|
|
163
|
+
this.process.exitCode = 1;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const openQuestions = countOpenQuestions(planContent);
|
|
168
|
+
|
|
169
|
+
this.process.stdout.write(
|
|
170
|
+
`plan: ${absOutPath}\nopen questions: ${openQuestions}\nrun: ${run.id}\n`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { countOpenQuestions } from './impl.ts';
|
|
3
|
+
|
|
4
|
+
describe('countOpenQuestions', () => {
|
|
5
|
+
it('returns 0 for None. section', () => {
|
|
6
|
+
const md = `# Title\n\n## Open questions\n\nNone.\n`;
|
|
7
|
+
expect(countOpenQuestions(md)).toBe(0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('returns 0 for empty section', () => {
|
|
11
|
+
const md = `# Title\n\n## Open questions\n\n`;
|
|
12
|
+
expect(countOpenQuestions(md)).toBe(0);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('counts bullet points correctly', () => {
|
|
16
|
+
const md = `# Title\n\n## Open questions\n- Question 1?\n* Question 2?\n\n## Next section\n`;
|
|
17
|
+
expect(countOpenQuestions(md)).toBe(2);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { buildCommand } from '@stricli/core';
|
|
2
|
+
import quotaImpl from './impl.ts';
|
|
3
|
+
|
|
4
|
+
const fullDescription = [
|
|
5
|
+
'agy: reads its cached OAuth token (~/.gemini/antigravity-cli/) and asks the',
|
|
6
|
+
'Cloud Code API for per-model remaining quota. EXHAUSTED means agy turns on',
|
|
7
|
+
'that model fail with an empty answer until the reset time.',
|
|
8
|
+
'codex: reads ~/.codex/auth.json and asks the ChatGPT usage endpoint for the',
|
|
9
|
+
'5-hour and weekly windows (used % + reset). No separate logins for either.',
|
|
10
|
+
'claude: shells out to `claude -p "/usage"` (the slow leg, ~5-10s) and parses',
|
|
11
|
+
'the session + weekly windows — no HTTP endpoint exists and we never touch',
|
|
12
|
+
'the Keychain; the claude CLI uses its own credentials.',
|
|
13
|
+
].join('\n');
|
|
14
|
+
|
|
15
|
+
export const quota = buildCommand({
|
|
16
|
+
func: quotaImpl,
|
|
17
|
+
parameters: {
|
|
18
|
+
flags: {
|
|
19
|
+
json: {
|
|
20
|
+
kind: 'boolean',
|
|
21
|
+
withNegated: false,
|
|
22
|
+
brief: 'Emit the raw snapshot as JSON',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
docs: {
|
|
27
|
+
brief: 'Show agy / codex / claude quota with reset times',
|
|
28
|
+
fullDescription,
|
|
29
|
+
},
|
|
30
|
+
});
|