@fitlab-ai/agent-infra 0.7.0 → 0.7.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/bin/cli.ts +1 -1
- package/dist/bin/cli.js +1 -1
- package/dist/lib/builtin-tuis.js +45 -0
- package/dist/lib/defaults.json +3 -0
- package/dist/lib/init.js +62 -23
- package/dist/lib/prompt.js +49 -1
- package/dist/lib/sandbox/commands/enter.js +1 -1
- package/dist/lib/sandbox/commands/list-running.js +58 -13
- package/dist/lib/sandbox/commands/rebuild.js +3 -11
- package/dist/lib/sandbox/commands/rm.js +2 -0
- package/dist/lib/sandbox/image-prune.js +18 -0
- package/dist/lib/sandbox/task-resolver.js +18 -0
- package/dist/lib/update.js +59 -18
- package/lib/builtin-tuis.ts +55 -0
- package/lib/defaults.json +3 -0
- package/lib/init.ts +87 -35
- package/lib/prompt.ts +54 -1
- package/lib/sandbox/commands/enter.ts +1 -1
- package/lib/sandbox/commands/list-running.ts +69 -16
- package/lib/sandbox/commands/rebuild.ts +3 -12
- package/lib/sandbox/commands/rm.ts +3 -0
- package/lib/sandbox/image-prune.ts +23 -0
- package/lib/sandbox/task-resolver.ts +23 -1
- package/lib/update.ts +71 -30
- package/package.json +1 -1
- package/templates/.agents/README.en.md +32 -0
- package/templates/.agents/README.zh-CN.md +32 -0
- package/templates/.agents/rules/task-short-id.en.md +141 -0
- package/templates/.agents/rules/task-short-id.zh-CN.md +124 -0
- package/templates/.agents/scripts/task-short-id.js +713 -0
- package/templates/.agents/skills/analyze-task/SKILL.en.md +4 -0
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +4 -1
- package/templates/.agents/skills/block-task/SKILL.en.md +12 -0
- package/templates/.agents/skills/block-task/SKILL.zh-CN.md +12 -1
- package/templates/.agents/skills/cancel-task/SKILL.en.md +12 -0
- package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +12 -1
- package/templates/.agents/skills/check-task/SKILL.en.md +4 -0
- package/templates/.agents/skills/check-task/SKILL.zh-CN.md +4 -1
- package/templates/.agents/skills/close-codescan/SKILL.en.md +11 -0
- package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +11 -0
- package/templates/.agents/skills/close-dependabot/SKILL.en.md +11 -0
- package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +11 -0
- package/templates/.agents/skills/code-task/SKILL.en.md +4 -0
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +4 -1
- package/templates/.agents/skills/commit/SKILL.en.md +4 -0
- package/templates/.agents/skills/commit/SKILL.zh-CN.md +4 -0
- package/templates/.agents/skills/complete-task/SKILL.en.md +12 -0
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +12 -1
- package/templates/.agents/skills/create-pr/SKILL.en.md +4 -0
- package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +4 -0
- package/templates/.agents/skills/create-task/SKILL.en.md +14 -0
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +14 -1
- package/templates/.agents/skills/import-codescan/SKILL.en.md +14 -0
- package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +14 -0
- package/templates/.agents/skills/import-dependabot/SKILL.en.md +14 -0
- package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +14 -0
- package/templates/.agents/skills/import-issue/SKILL.en.md +14 -0
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +14 -0
- package/templates/.agents/skills/plan-task/SKILL.en.md +4 -0
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +4 -1
- package/templates/.agents/skills/restore-task/SKILL.en.md +12 -0
- package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +12 -1
- package/templates/.agents/skills/review-analysis/SKILL.en.md +4 -0
- package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +4 -1
- package/templates/.agents/skills/review-code/SKILL.en.md +4 -0
- package/templates/.agents/skills/review-code/SKILL.zh-CN.md +4 -1
- package/templates/.agents/skills/review-plan/SKILL.en.md +4 -0
- package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +4 -1
- package/templates/.agents/skills/update-agent-infra/SKILL.en.md +1 -0
- package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +1 -0
- package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +112 -21
- package/templates/.agents/templates/task.en.md +1 -0
- package/templates/.agents/templates/task.zh-CN.md +1 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const BUILTIN_TUI_IDS = ['claude-code', 'codex', 'gemini-cli', 'opencode'] as const;
|
|
2
|
+
type BuiltinTUIId = (typeof BUILTIN_TUI_IDS)[number];
|
|
3
|
+
|
|
4
|
+
const BUILTIN_TUI_DISPLAY: Record<BuiltinTUIId, string> = {
|
|
5
|
+
'claude-code': 'Claude Code',
|
|
6
|
+
'codex': 'Codex',
|
|
7
|
+
'gemini-cli': 'Gemini CLI',
|
|
8
|
+
'opencode': 'OpenCode'
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const BUILTIN_TUI_OWNED_PATH_PREFIXES: Record<BuiltinTUIId, string[]> = {
|
|
12
|
+
'claude-code': ['.claude/'],
|
|
13
|
+
'codex': ['.codex/'],
|
|
14
|
+
'gemini-cli': ['.gemini/'],
|
|
15
|
+
'opencode': ['.opencode/']
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function isBuiltinTUIId(value: unknown): value is BuiltinTUIId {
|
|
19
|
+
return typeof value === 'string' && (BUILTIN_TUI_IDS as readonly string[]).includes(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveEnabledTUIs(value: unknown): Set<BuiltinTUIId> {
|
|
23
|
+
// Missing field / null / non-array → full set (backward compat for legacy
|
|
24
|
+
// .airc.json predating the `tuis` field).
|
|
25
|
+
if (!Array.isArray(value)) return new Set(BUILTIN_TUI_IDS);
|
|
26
|
+
// Empty array is a meaningful, user-set value: "no built-in TUI managed".
|
|
27
|
+
// This supports the customTUI-only project layout.
|
|
28
|
+
const set = new Set<BuiltinTUIId>();
|
|
29
|
+
for (const v of value) {
|
|
30
|
+
if (isBuiltinTUIId(v)) set.add(v);
|
|
31
|
+
}
|
|
32
|
+
return set;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isPathOwnedByDisabledTUI(rel: string, enabled: Set<BuiltinTUIId>): boolean {
|
|
36
|
+
const normalized = String(rel || '').replace(/\\/g, '/').replace(/^\.\//, '');
|
|
37
|
+
for (const tui of BUILTIN_TUI_IDS) {
|
|
38
|
+
if (enabled.has(tui)) continue;
|
|
39
|
+
for (const prefix of BUILTIN_TUI_OWNED_PATH_PREFIXES[tui]) {
|
|
40
|
+
const trimmed = prefix.replace(/\/$/, '');
|
|
41
|
+
if (normalized === trimmed || normalized.startsWith(prefix)) return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
BUILTIN_TUI_IDS,
|
|
49
|
+
BUILTIN_TUI_DISPLAY,
|
|
50
|
+
BUILTIN_TUI_OWNED_PATH_PREFIXES,
|
|
51
|
+
isBuiltinTUIId,
|
|
52
|
+
resolveEnabledTUIs,
|
|
53
|
+
isPathOwnedByDisabledTUI
|
|
54
|
+
};
|
|
55
|
+
export type { BuiltinTUIId };
|
package/lib/defaults.json
CHANGED
package/lib/init.ts
CHANGED
|
@@ -3,11 +3,18 @@ import path from 'node:path';
|
|
|
3
3
|
import { execSync } from 'node:child_process';
|
|
4
4
|
import { platform } from 'node:os';
|
|
5
5
|
import { info, ok, err } from './log.ts';
|
|
6
|
-
import { prompt, select, closePrompt } from './prompt.ts';
|
|
6
|
+
import { prompt, select, multiSelect, closePrompt } from './prompt.ts';
|
|
7
7
|
import { resolveTemplateDir } from './paths.ts';
|
|
8
8
|
import { renderFile, copySkillDir, KNOWN_PLATFORMS } from './render.ts';
|
|
9
9
|
import { enginesForPlatform } from './sandbox/engines/index.ts';
|
|
10
10
|
import { VERSION } from './version.ts';
|
|
11
|
+
import {
|
|
12
|
+
BUILTIN_TUI_IDS,
|
|
13
|
+
BUILTIN_TUI_DISPLAY,
|
|
14
|
+
isPathOwnedByDisabledTUI,
|
|
15
|
+
resolveEnabledTUIs
|
|
16
|
+
} from './builtin-tuis.ts';
|
|
17
|
+
import type { BuiltinTUIId } from './builtin-tuis.ts';
|
|
11
18
|
|
|
12
19
|
type FileRegistry = {
|
|
13
20
|
managed: string[];
|
|
@@ -23,6 +30,7 @@ type SourceEntry = {
|
|
|
23
30
|
type Defaults = {
|
|
24
31
|
files: FileRegistry;
|
|
25
32
|
sandbox: Record<string, unknown>;
|
|
33
|
+
task: { shortIdLength: number };
|
|
26
34
|
labels: Record<string, unknown>;
|
|
27
35
|
requiresPullRequest: boolean;
|
|
28
36
|
};
|
|
@@ -35,8 +43,10 @@ type AgentConfig = {
|
|
|
35
43
|
requiresPullRequest: boolean;
|
|
36
44
|
templateVersion: string;
|
|
37
45
|
sandbox: Record<string, unknown>;
|
|
46
|
+
task: { shortIdLength: number };
|
|
38
47
|
labels: Record<string, unknown>;
|
|
39
48
|
files: FileRegistry;
|
|
49
|
+
tuis: string[];
|
|
40
50
|
templates?: { sources: SourceEntry[] };
|
|
41
51
|
skills?: { sources: SourceEntry[] };
|
|
42
52
|
};
|
|
@@ -60,10 +70,15 @@ function isPathOwnedByOtherPlatform(relativePath: string, platformType: string):
|
|
|
60
70
|
return candidate !== platformType;
|
|
61
71
|
}
|
|
62
72
|
|
|
63
|
-
function buildDefaultFiles(platformType: string): FileRegistry {
|
|
73
|
+
function buildDefaultFiles(platformType: string, enabledTUIs: Set<BuiltinTUIId>): FileRegistry {
|
|
74
|
+
const ownedByDisabled = (entry: string) => isPathOwnedByDisabledTUI(entry, enabledTUIs);
|
|
64
75
|
return {
|
|
65
|
-
managed: (defaults.files.managed || []).filter(
|
|
66
|
-
|
|
76
|
+
managed: (defaults.files.managed || []).filter(
|
|
77
|
+
(entry) => !isPathOwnedByOtherPlatform(entry, platformType) && !ownedByDisabled(entry)
|
|
78
|
+
),
|
|
79
|
+
merged: (defaults.files.merged || []).filter(
|
|
80
|
+
(entry) => !isPathOwnedByOtherPlatform(entry, platformType) && !ownedByDisabled(entry)
|
|
81
|
+
),
|
|
67
82
|
ejected: structuredClone(defaults.files.ejected || [])
|
|
68
83
|
};
|
|
69
84
|
}
|
|
@@ -216,6 +231,20 @@ async function cmdInit(): Promise<void> {
|
|
|
216
231
|
);
|
|
217
232
|
const requiresPullRequest = requiresPRChoice !== 'no';
|
|
218
233
|
|
|
234
|
+
let enabledTUIs: string[];
|
|
235
|
+
try {
|
|
236
|
+
enabledTUIs = await multiSelect(
|
|
237
|
+
'Built-in TUI command files to install/manage',
|
|
238
|
+
BUILTIN_TUI_IDS.map((id) => ({ id, label: BUILTIN_TUI_DISPLAY[id] }))
|
|
239
|
+
);
|
|
240
|
+
} catch (e) {
|
|
241
|
+
err(e instanceof Error ? e.message : String(e));
|
|
242
|
+
closePrompt();
|
|
243
|
+
process.exitCode = 1;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const enabledTUISet = resolveEnabledTUIs(enabledTUIs);
|
|
247
|
+
|
|
219
248
|
const templateSources = parseLocalSources(await prompt(
|
|
220
249
|
'Template sources (optional, comma-separated local paths, e.g. ~/my-templates; Enter to skip)',
|
|
221
250
|
''
|
|
@@ -259,29 +288,35 @@ async function cmdInit(): Promise<void> {
|
|
|
259
288
|
);
|
|
260
289
|
ok('Installed .agents/skills/update-agent-infra/');
|
|
261
290
|
|
|
262
|
-
// install Claude command
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
291
|
+
// install Claude command (only if enabled)
|
|
292
|
+
if (enabledTUISet.has('claude-code')) {
|
|
293
|
+
renderFile(
|
|
294
|
+
path.join(templateDir, '.claude', 'commands', claudeSrc),
|
|
295
|
+
path.join('.claude', 'commands', 'update-agent-infra.md'),
|
|
296
|
+
replacements
|
|
297
|
+
);
|
|
298
|
+
ok('Installed .claude/commands/update-agent-infra.md');
|
|
299
|
+
}
|
|
269
300
|
|
|
270
|
-
// install Gemini command
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
301
|
+
// install Gemini command (only if enabled)
|
|
302
|
+
if (enabledTUISet.has('gemini-cli')) {
|
|
303
|
+
renderFile(
|
|
304
|
+
path.join(templateDir, '.gemini', 'commands', '_project_', geminiSrc),
|
|
305
|
+
path.join('.gemini', 'commands', project, 'update-agent-infra.toml'),
|
|
306
|
+
replacements
|
|
307
|
+
);
|
|
308
|
+
ok(`Installed .gemini/commands/${project}/update-agent-infra.toml`);
|
|
309
|
+
}
|
|
277
310
|
|
|
278
|
-
// install OpenCode command
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
311
|
+
// install OpenCode command (only if enabled)
|
|
312
|
+
if (enabledTUISet.has('opencode')) {
|
|
313
|
+
renderFile(
|
|
314
|
+
path.join(templateDir, '.opencode', 'commands', opencodeSrc),
|
|
315
|
+
path.join('.opencode', 'commands', 'update-agent-infra.md'),
|
|
316
|
+
replacements
|
|
317
|
+
);
|
|
318
|
+
ok('Installed .opencode/commands/update-agent-infra.md');
|
|
319
|
+
}
|
|
285
320
|
|
|
286
321
|
// generate .agents/.airc.json
|
|
287
322
|
const config: AgentConfig = {
|
|
@@ -292,8 +327,10 @@ async function cmdInit(): Promise<void> {
|
|
|
292
327
|
requiresPullRequest,
|
|
293
328
|
templateVersion: VERSION,
|
|
294
329
|
sandbox: structuredClone(defaults.sandbox),
|
|
330
|
+
task: structuredClone(defaults.task),
|
|
295
331
|
labels: structuredClone(defaults.labels),
|
|
296
|
-
files: buildDefaultFiles(platformType)
|
|
332
|
+
files: buildDefaultFiles(platformType, enabledTUISet),
|
|
333
|
+
tuis: enabledTUIs
|
|
297
334
|
};
|
|
298
335
|
|
|
299
336
|
if (sandboxEngine) {
|
|
@@ -320,15 +357,30 @@ async function cmdInit(): Promise<void> {
|
|
|
320
357
|
console.log('');
|
|
321
358
|
ok('Project initialized successfully!');
|
|
322
359
|
console.log('');
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
360
|
+
if (enabledTUISet.size === 0) {
|
|
361
|
+
console.log(' No built-in TUI selected.');
|
|
362
|
+
console.log(` Configure "customTUIs" in ${configPath} before running update-agent-infra.`);
|
|
363
|
+
console.log('');
|
|
364
|
+
} else {
|
|
365
|
+
console.log(' Next step: open this project in any AI TUI and run:');
|
|
366
|
+
console.log('');
|
|
367
|
+
const claudeOrOpencode: string[] = [];
|
|
368
|
+
if (enabledTUISet.has('claude-code')) claudeOrOpencode.push('Claude Code');
|
|
369
|
+
if (enabledTUISet.has('opencode')) claudeOrOpencode.push('OpenCode');
|
|
370
|
+
if (claudeOrOpencode.length > 0) {
|
|
371
|
+
console.log(` ${claudeOrOpencode.join(' / ')}: /update-agent-infra`);
|
|
372
|
+
}
|
|
373
|
+
if (enabledTUISet.has('gemini-cli')) {
|
|
374
|
+
console.log(` Gemini CLI: /${project}:update-agent-infra`);
|
|
375
|
+
}
|
|
376
|
+
if (enabledTUISet.has('codex')) {
|
|
377
|
+
console.log(' Codex CLI: $update-agent-infra');
|
|
378
|
+
}
|
|
379
|
+
console.log('');
|
|
380
|
+
console.log(' This will render all templates and set up the full');
|
|
381
|
+
console.log(' AI collaboration infrastructure.');
|
|
382
|
+
console.log('');
|
|
383
|
+
}
|
|
332
384
|
}
|
|
333
385
|
|
|
334
386
|
export { cmdInit };
|
package/lib/prompt.ts
CHANGED
|
@@ -86,6 +86,59 @@ async function select(question: string, choices: string[], defaultValue?: string
|
|
|
86
86
|
return trimmed;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
async function multiSelect(
|
|
90
|
+
question: string,
|
|
91
|
+
choices: { id: string; label: string }[]
|
|
92
|
+
): Promise<string[]> {
|
|
93
|
+
process.stdout.write(` ${question}:\n`);
|
|
94
|
+
const idWidth = Math.max(...choices.map((c) => c.id.length));
|
|
95
|
+
choices.forEach((c, i) => {
|
|
96
|
+
process.stdout.write(` ${i + 1}) ${c.id.padEnd(idWidth)} (${c.label})\n`);
|
|
97
|
+
});
|
|
98
|
+
ask('Enter comma-separated numbers or ids to keep, or "none" to select nothing [default: all]: ');
|
|
99
|
+
|
|
100
|
+
setupInterface();
|
|
101
|
+
|
|
102
|
+
const line = await nextLine();
|
|
103
|
+
// Strictly distinguish bare Enter (null/empty string) from whitespace input.
|
|
104
|
+
if (line === null || line === '') return choices.map((c) => c.id);
|
|
105
|
+
// Explicit empty selection: "none" means deliberately zero built-in choices.
|
|
106
|
+
if (line.trim().toLowerCase() === 'none') return [];
|
|
107
|
+
|
|
108
|
+
const tokens = line.split(',').map((t) => t.trim());
|
|
109
|
+
if (tokens.some((t) => t === '')) {
|
|
110
|
+
throw new Error(`Invalid selection input: "${line}" (empty token)`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const idSet = new Set(choices.map((c) => c.id));
|
|
114
|
+
const seenIds = new Set<string>();
|
|
115
|
+
for (const t of tokens) {
|
|
116
|
+
let resolvedId: string | undefined;
|
|
117
|
+
if (/^[0-9]+$/.test(t)) {
|
|
118
|
+
const n = Number.parseInt(t, 10);
|
|
119
|
+
if (n < 1 || n > choices.length) {
|
|
120
|
+
throw new Error(`Selection out of range: "${t}" (expected 1..${choices.length})`);
|
|
121
|
+
}
|
|
122
|
+
resolvedId = choices[n - 1]!.id;
|
|
123
|
+
} else if (idSet.has(t)) {
|
|
124
|
+
resolvedId = t;
|
|
125
|
+
} else {
|
|
126
|
+
throw new Error(`Unknown TUI selection token: "${t}"`);
|
|
127
|
+
}
|
|
128
|
+
if (seenIds.has(resolvedId)) {
|
|
129
|
+
throw new Error(`Duplicate selection: "${t}" resolves to already-selected "${resolvedId}"`);
|
|
130
|
+
}
|
|
131
|
+
seenIds.add(resolvedId);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Normalize to prompt order: users can type tokens in any order, but the
|
|
135
|
+
// persisted array follows the canonical choices order to keep .airc.json
|
|
136
|
+
// diffs stable. An empty result here is impossible (tokens.length > 0 and
|
|
137
|
+
// every token resolves to an id), so no separate empty guard is needed —
|
|
138
|
+
// explicit "none" handled above.
|
|
139
|
+
return choices.map((c) => c.id).filter((id) => seenIds.has(id));
|
|
140
|
+
}
|
|
141
|
+
|
|
89
142
|
function closePrompt(): void {
|
|
90
143
|
if (_rl) {
|
|
91
144
|
_rl.close();
|
|
@@ -94,4 +147,4 @@ function closePrompt(): void {
|
|
|
94
147
|
}
|
|
95
148
|
}
|
|
96
149
|
|
|
97
|
-
export { prompt, select, closePrompt };
|
|
150
|
+
export { prompt, select, multiSelect, closePrompt };
|
|
@@ -123,7 +123,7 @@ export async function enter(args: string[]): Promise<number> {
|
|
|
123
123
|
let branch: string;
|
|
124
124
|
if (isTaskShortRef(firstArg)) {
|
|
125
125
|
const { running } = fetchSandboxRows(engine, sandboxLabel(config), sandboxBranchLabel(config));
|
|
126
|
-
branch = resolveTaskShortRef(firstArg, { running });
|
|
126
|
+
branch = resolveTaskShortRef(firstArg, { running, repoRoot: config.repoRoot });
|
|
127
127
|
} else {
|
|
128
128
|
branch = resolveTaskBranch(firstArg, config.repoRoot);
|
|
129
129
|
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
1
4
|
import { runSafeEngine } from '../shell.ts';
|
|
2
5
|
|
|
3
6
|
export type SandboxRow = {
|
|
@@ -94,29 +97,56 @@ export function isTaskShortRef(arg: string): boolean {
|
|
|
94
97
|
return /^#\d+$/.test(arg);
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
type RegistryLookup =
|
|
101
|
+
| { status: 'miss' }
|
|
102
|
+
| { status: 'hit'; branch: string };
|
|
103
|
+
|
|
97
104
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* Current implementation: treats the digits as a 1-based index into the
|
|
101
|
-
* supplied running-sandbox list (ls view order). This is the *only*
|
|
102
|
-
* resolution path until the global task-short-id registry lands in a
|
|
103
|
-
* follow-up task; do NOT read task.md or scan .agents/workspace/ from this
|
|
104
|
-
* helper here.
|
|
105
|
+
* Try to resolve a short ref against the global task-short-id registry.
|
|
105
106
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
107
|
+
* Tri-state semantics (review-code Round 1 M-1 fix):
|
|
108
|
+
* - 'miss' → script reports no entry (or registry script missing). Caller may fall back.
|
|
109
|
+
* - 'hit' → registry resolved to a task id and branch is found in task.md.
|
|
110
|
+
* - throws → registry hit but task.md is missing or branch metadata is unparseable;
|
|
111
|
+
* surfacing this error is critical — never silently fall back to running index.
|
|
110
112
|
*/
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
function tryResolveFromRegistry(arg: string, repoRoot: string): RegistryLookup {
|
|
114
|
+
const scriptPath = path.join(repoRoot, '.agents', 'scripts', 'task-short-id.js');
|
|
115
|
+
if (!fs.existsSync(scriptPath)) return { status: 'miss' };
|
|
116
|
+
const result = spawnSync('node', [scriptPath, 'resolve', arg], { encoding: 'utf8', cwd: repoRoot });
|
|
117
|
+
if (result.status !== 0) return { status: 'miss' };
|
|
118
|
+
const taskId = (result.stdout || '').trim();
|
|
119
|
+
if (!/^TASK-\d{8}-\d{6}$/.test(taskId)) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Registry returned malformed task id for '${arg}': ${JSON.stringify(taskId)}`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
for (const sub of ['active', 'completed', 'blocked', 'archive']) {
|
|
125
|
+
const taskMdPath = path.join(repoRoot, '.agents', 'workspace', sub, taskId, 'task.md');
|
|
126
|
+
if (!fs.existsSync(taskMdPath)) continue;
|
|
127
|
+
const content = fs.readFileSync(taskMdPath, 'utf8');
|
|
128
|
+
const fm = content.match(/^branch:\s*(.+)$/m);
|
|
129
|
+
if (fm?.[1]?.trim()) {
|
|
130
|
+
return { status: 'hit', branch: fm[1].trim().replace(/^(["'])(.*)\1$/, '$2') };
|
|
131
|
+
}
|
|
132
|
+
const ctx = content.match(/^- \*\*(?:分支|Branch)\*\*:[ \t]*`?([^`\n]+)`?$/m);
|
|
133
|
+
if (ctx?.[1]?.trim()) {
|
|
134
|
+
return { status: 'hit', branch: ctx[1].trim().replace(/^(["'])(.*)\1$/, '$2') };
|
|
135
|
+
}
|
|
136
|
+
throw new Error(
|
|
137
|
+
`Short ref '${arg}' resolved to task ${taskId} but task.md has no branch field`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Short ref '${arg}' resolved to task ${taskId} but task.md was not found under any workspace dir`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function resolveByRunningIndex(arg: string, running: SandboxRow[]): string {
|
|
115
146
|
const n = Number(arg.slice(1));
|
|
116
147
|
if (n < 1) {
|
|
117
148
|
throw new Error(`Invalid sandbox index '${arg}': must be >= 1`);
|
|
118
149
|
}
|
|
119
|
-
const { running } = ctx;
|
|
120
150
|
if (running.length === 0) {
|
|
121
151
|
throw new Error(`No running sandbox to reference with '${arg}'`);
|
|
122
152
|
}
|
|
@@ -133,3 +163,26 @@ export function resolveTaskShortRef(
|
|
|
133
163
|
}
|
|
134
164
|
return row.branch;
|
|
135
165
|
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Resolve a task short reference ('#N') to a branch name for the sandbox entrypoint.
|
|
169
|
+
*
|
|
170
|
+
* Resolution order (sandbox fallback mode, plan-r7 C2):
|
|
171
|
+
* 1. Try the global task-short-id registry under repoRoot. If hit, look up the
|
|
172
|
+
* branch from the matching task.md.
|
|
173
|
+
* 2. Fallback to the running-sandbox list index (preserves the #414 ls-index
|
|
174
|
+
* behaviour; long-term contract per analysis-r5).
|
|
175
|
+
*
|
|
176
|
+
* Precondition: callers MUST gate on isTaskShortRef(arg) === true.
|
|
177
|
+
*/
|
|
178
|
+
export function resolveTaskShortRef(
|
|
179
|
+
arg: string,
|
|
180
|
+
ctx: { running: SandboxRow[]; repoRoot?: string }
|
|
181
|
+
): string {
|
|
182
|
+
if (ctx.repoRoot) {
|
|
183
|
+
const lookup = tryResolveFromRegistry(arg, ctx.repoRoot);
|
|
184
|
+
if (lookup.status === 'hit') return lookup.branch;
|
|
185
|
+
// 'miss' falls through to ls-index fallback (preserves #414 behaviour); 'hit-but-invalid' already threw above.
|
|
186
|
+
}
|
|
187
|
+
return resolveByRunningIndex(arg, ctx.running);
|
|
188
|
+
}
|
|
@@ -7,7 +7,8 @@ import type { SandboxConfig } from '../config.ts';
|
|
|
7
7
|
import { prepareDockerfile } from '../dockerfile.ts';
|
|
8
8
|
import { sandboxImageConfigLabel, sandboxLabel } from '../constants.ts';
|
|
9
9
|
import { detectEngine, ensureDocker } from '../engine.ts';
|
|
10
|
-
import { runEngine,
|
|
10
|
+
import { runEngine, runSafeEngine, runVerboseEngine } from '../shell.ts';
|
|
11
|
+
import { pruneSandboxDanglingImages } from '../image-prune.ts';
|
|
11
12
|
import {
|
|
12
13
|
imageSignatureFields,
|
|
13
14
|
resolveTools,
|
|
@@ -89,12 +90,6 @@ export function buildArgs(
|
|
|
89
90
|
return args;
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
function removeImageIfPresent(imageName: string, engine: string): void {
|
|
93
|
-
if (runOkEngine(engine, 'docker', ['image', 'inspect', imageName])) {
|
|
94
|
-
runEngine(engine, 'docker', ['rmi', imageName]);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
93
|
export async function rebuild(args: string[]): Promise<void> {
|
|
99
94
|
const { values } = parseArgs({
|
|
100
95
|
args,
|
|
@@ -126,17 +121,12 @@ export async function rebuild(args: string[]): Promise<void> {
|
|
|
126
121
|
try {
|
|
127
122
|
if (quiet) {
|
|
128
123
|
const spinner = p.spinner();
|
|
129
|
-
spinner.start(`Removing old image ${config.imageName}...`);
|
|
130
|
-
removeImageIfPresent(config.imageName, engine);
|
|
131
|
-
spinner.stop('Old image removed');
|
|
132
124
|
spinner.start('Building image...');
|
|
133
125
|
runEngine(engine, 'docker', buildArgs(config, tools, preparedDockerfile.path, imageSignature, { engine, refresh }), {
|
|
134
126
|
cwd: config.repoRoot
|
|
135
127
|
});
|
|
136
128
|
spinner.stop(pc.green('Sandbox image rebuilt'));
|
|
137
129
|
} else {
|
|
138
|
-
p.log.step(`Removing old image ${config.imageName}`);
|
|
139
|
-
removeImageIfPresent(config.imageName, engine);
|
|
140
130
|
p.log.step('Building image');
|
|
141
131
|
runVerboseEngine(
|
|
142
132
|
engine,
|
|
@@ -146,6 +136,7 @@ export async function rebuild(args: string[]): Promise<void> {
|
|
|
146
136
|
);
|
|
147
137
|
p.log.success(pc.green('Sandbox image rebuilt'));
|
|
148
138
|
}
|
|
139
|
+
pruneSandboxDanglingImages(config, engine);
|
|
149
140
|
} finally {
|
|
150
141
|
preparedDockerfile.cleanup();
|
|
151
142
|
}
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
worktreeDirCandidates
|
|
16
16
|
} from '../constants.ts';
|
|
17
17
|
import { ENGINES, detectEngine, engineDisplayName, isManagedEngine, stopManagedVm } from '../engine.ts';
|
|
18
|
+
import { pruneSandboxDanglingImages } from '../image-prune.ts';
|
|
18
19
|
import { removeManagedDir, removeWorktreeDir } from '../managed-fs.ts';
|
|
19
20
|
import { runOk, runSafe, runSafeEngine } from '../shell.ts';
|
|
20
21
|
import { resolveTaskBranch } from '../task-resolver.ts';
|
|
@@ -208,6 +209,8 @@ async function rmAll(config: SandboxConfig, tools: SandboxTool[]): Promise<void>
|
|
|
208
209
|
runSafeEngine(engine, 'docker', ['rmi', config.imageName]);
|
|
209
210
|
}
|
|
210
211
|
|
|
212
|
+
pruneSandboxDanglingImages(config, engine);
|
|
213
|
+
|
|
211
214
|
if (isManagedEngine(engine)) {
|
|
212
215
|
if (engine === ENGINES.WSL2) {
|
|
213
216
|
p.log.warn('Windows uses Docker Desktop with WSL2. Stop it from Docker Desktop or run "wsl --shutdown" manually.');
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import type { SandboxConfig } from './config.ts';
|
|
3
|
+
import { sandboxLabel } from './constants.ts';
|
|
4
|
+
import { runEngine } from './shell.ts';
|
|
5
|
+
|
|
6
|
+
export function pruneSandboxDanglingImages(
|
|
7
|
+
config: Pick<SandboxConfig, 'project'>,
|
|
8
|
+
engine: string
|
|
9
|
+
): void {
|
|
10
|
+
try {
|
|
11
|
+
runEngine(engine, 'docker', [
|
|
12
|
+
'image',
|
|
13
|
+
'prune',
|
|
14
|
+
'-f',
|
|
15
|
+
'--filter',
|
|
16
|
+
`label=${sandboxLabel(config)}`
|
|
17
|
+
]);
|
|
18
|
+
} catch {
|
|
19
|
+
p.log.warn(
|
|
20
|
+
`Failed to prune dangling sandbox images (label=${sandboxLabel(config)}); leaving them in place.`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -1,9 +1,27 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
|
|
4
5
|
const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
|
|
6
|
+
const SHORT_ID_RE = /^#\d+$/;
|
|
5
7
|
const WORKSPACE_DIRS = ['active', 'completed', 'blocked', 'archive'];
|
|
6
8
|
|
|
9
|
+
function resolveShortIdStrict(arg: string, repoRoot: string): string {
|
|
10
|
+
const scriptPath = path.join(repoRoot, '.agents', 'scripts', 'task-short-id.js');
|
|
11
|
+
if (!fs.existsSync(scriptPath)) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`Short id '${arg}' provided but task-short-id.js script is missing at ${scriptPath}`
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
const result = spawnSync('node', [scriptPath, 'resolve', arg], { encoding: 'utf8', cwd: repoRoot });
|
|
17
|
+
if (result.status !== 0) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`Short id '${arg}' not found in active task registry: ${(result.stderr || '').trim()}`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return result.stdout.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
7
25
|
function stripQuotes(value: string): string {
|
|
8
26
|
return value.replace(/^(["'])(.*)\1$/, '$2');
|
|
9
27
|
}
|
|
@@ -33,10 +51,14 @@ function resolveBranchFromTaskContent(content: string, taskId: string): string {
|
|
|
33
51
|
}
|
|
34
52
|
|
|
35
53
|
export function resolveTaskBranch(arg: string, repoRoot: string): string {
|
|
54
|
+
if (SHORT_ID_RE.test(arg)) {
|
|
55
|
+
const taskId = resolveShortIdStrict(arg, repoRoot);
|
|
56
|
+
const content = readTaskContent(repoRoot, taskId);
|
|
57
|
+
return resolveBranchFromTaskContent(content, taskId);
|
|
58
|
+
}
|
|
36
59
|
if (!TASK_ID_RE.test(arg)) {
|
|
37
60
|
return arg;
|
|
38
61
|
}
|
|
39
|
-
|
|
40
62
|
const content = readTaskContent(repoRoot, arg);
|
|
41
63
|
return resolveBranchFromTaskContent(content, arg);
|
|
42
64
|
}
|