@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.
Files changed (73) hide show
  1. package/bin/cli.ts +1 -1
  2. package/dist/bin/cli.js +1 -1
  3. package/dist/lib/builtin-tuis.js +45 -0
  4. package/dist/lib/defaults.json +3 -0
  5. package/dist/lib/init.js +62 -23
  6. package/dist/lib/prompt.js +49 -1
  7. package/dist/lib/sandbox/commands/enter.js +1 -1
  8. package/dist/lib/sandbox/commands/list-running.js +58 -13
  9. package/dist/lib/sandbox/commands/rebuild.js +3 -11
  10. package/dist/lib/sandbox/commands/rm.js +2 -0
  11. package/dist/lib/sandbox/image-prune.js +18 -0
  12. package/dist/lib/sandbox/task-resolver.js +18 -0
  13. package/dist/lib/update.js +59 -18
  14. package/lib/builtin-tuis.ts +55 -0
  15. package/lib/defaults.json +3 -0
  16. package/lib/init.ts +87 -35
  17. package/lib/prompt.ts +54 -1
  18. package/lib/sandbox/commands/enter.ts +1 -1
  19. package/lib/sandbox/commands/list-running.ts +69 -16
  20. package/lib/sandbox/commands/rebuild.ts +3 -12
  21. package/lib/sandbox/commands/rm.ts +3 -0
  22. package/lib/sandbox/image-prune.ts +23 -0
  23. package/lib/sandbox/task-resolver.ts +23 -1
  24. package/lib/update.ts +71 -30
  25. package/package.json +1 -1
  26. package/templates/.agents/README.en.md +32 -0
  27. package/templates/.agents/README.zh-CN.md +32 -0
  28. package/templates/.agents/rules/task-short-id.en.md +141 -0
  29. package/templates/.agents/rules/task-short-id.zh-CN.md +124 -0
  30. package/templates/.agents/scripts/task-short-id.js +713 -0
  31. package/templates/.agents/skills/analyze-task/SKILL.en.md +4 -0
  32. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +4 -1
  33. package/templates/.agents/skills/block-task/SKILL.en.md +12 -0
  34. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +12 -1
  35. package/templates/.agents/skills/cancel-task/SKILL.en.md +12 -0
  36. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +12 -1
  37. package/templates/.agents/skills/check-task/SKILL.en.md +4 -0
  38. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +4 -1
  39. package/templates/.agents/skills/close-codescan/SKILL.en.md +11 -0
  40. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +11 -0
  41. package/templates/.agents/skills/close-dependabot/SKILL.en.md +11 -0
  42. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +11 -0
  43. package/templates/.agents/skills/code-task/SKILL.en.md +4 -0
  44. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +4 -1
  45. package/templates/.agents/skills/commit/SKILL.en.md +4 -0
  46. package/templates/.agents/skills/commit/SKILL.zh-CN.md +4 -0
  47. package/templates/.agents/skills/complete-task/SKILL.en.md +12 -0
  48. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +12 -1
  49. package/templates/.agents/skills/create-pr/SKILL.en.md +4 -0
  50. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +4 -0
  51. package/templates/.agents/skills/create-task/SKILL.en.md +14 -0
  52. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +14 -1
  53. package/templates/.agents/skills/import-codescan/SKILL.en.md +14 -0
  54. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +14 -0
  55. package/templates/.agents/skills/import-dependabot/SKILL.en.md +14 -0
  56. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +14 -0
  57. package/templates/.agents/skills/import-issue/SKILL.en.md +14 -0
  58. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +14 -0
  59. package/templates/.agents/skills/plan-task/SKILL.en.md +4 -0
  60. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +4 -1
  61. package/templates/.agents/skills/restore-task/SKILL.en.md +12 -0
  62. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +12 -1
  63. package/templates/.agents/skills/review-analysis/SKILL.en.md +4 -0
  64. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +4 -1
  65. package/templates/.agents/skills/review-code/SKILL.en.md +4 -0
  66. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +4 -1
  67. package/templates/.agents/skills/review-plan/SKILL.en.md +4 -0
  68. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +4 -1
  69. package/templates/.agents/skills/update-agent-infra/SKILL.en.md +1 -0
  70. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +1 -0
  71. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +112 -21
  72. package/templates/.agents/templates/task.en.md +1 -0
  73. 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
@@ -21,6 +21,9 @@
21
21
  "disk": null
22
22
  }
23
23
  },
24
+ "task": {
25
+ "shortIdLength": 2
26
+ },
24
27
  "labels": {
25
28
  "in": {}
26
29
  },
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((entry) => !isPathOwnedByOtherPlatform(entry, platformType)),
66
- merged: (defaults.files.merged || []).filter((entry) => !isPathOwnedByOtherPlatform(entry, platformType)),
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
- renderFile(
264
- path.join(templateDir, '.claude', 'commands', claudeSrc),
265
- path.join('.claude', 'commands', 'update-agent-infra.md'),
266
- replacements
267
- );
268
- ok('Installed .claude/commands/update-agent-infra.md');
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
- renderFile(
272
- path.join(templateDir, '.gemini', 'commands', '_project_', geminiSrc),
273
- path.join('.gemini', 'commands', project, 'update-agent-infra.toml'),
274
- replacements
275
- );
276
- ok(`Installed .gemini/commands/${project}/update-agent-infra.toml`);
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
- renderFile(
280
- path.join(templateDir, '.opencode', 'commands', opencodeSrc),
281
- path.join('.opencode', 'commands', 'update-agent-infra.md'),
282
- replacements
283
- );
284
- ok('Installed .opencode/commands/update-agent-infra.md');
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
- console.log(' Next step: open this project in any AI TUI and run:');
324
- console.log('');
325
- console.log(' Claude Code / OpenCode: /update-agent-infra');
326
- console.log(` Gemini CLI: /${project}:update-agent-infra`);
327
- console.log(' Codex CLI: $update-agent-infra');
328
- console.log('');
329
- console.log(' This will render all templates and set up the full');
330
- console.log(' AI collaboration infrastructure.');
331
- console.log('');
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
- * Resolve a task short reference ('#N') to a branch name.
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
- * Precondition: callers MUST gate on isTaskShortRef(arg) === true before
107
- * constructing ctx and calling this function. Throws when arg is a valid
108
- * short ref but cannot be resolved (out of range, no running sandboxes,
109
- * etc.); the caller surfaces the error to the user.
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
- export function resolveTaskShortRef(
112
- arg: string,
113
- ctx: { running: SandboxRow[] }
114
- ): string {
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, runOkEngine, runSafeEngine, runVerboseEngine } from '../shell.ts';
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
  }