@fitlab-ai/agent-infra 0.7.1 → 0.7.3

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 (165) hide show
  1. package/README.md +7 -1
  2. package/README.zh-CN.md +9 -3
  3. package/bin/cli.ts +11 -0
  4. package/dist/bin/cli.js +12 -0
  5. package/dist/lib/defaults.json +0 -1
  6. package/dist/lib/init.js +0 -3
  7. package/dist/lib/sandbox/commands/create.js +10 -2
  8. package/dist/lib/sandbox/commands/enter.js +17 -18
  9. package/dist/lib/sandbox/commands/list-running.js +56 -32
  10. package/dist/lib/sandbox/commands/ls.js +27 -24
  11. package/dist/lib/sandbox/commands/start.js +36 -0
  12. package/dist/lib/sandbox/index.js +15 -3
  13. package/dist/lib/sandbox/task-resolver.js +1 -1
  14. package/dist/lib/sandbox/tools.js +1 -1
  15. package/dist/lib/table.js +38 -0
  16. package/dist/lib/task/commands/ls.js +122 -0
  17. package/dist/lib/task/commands/show.js +135 -0
  18. package/dist/lib/task/frontmatter.js +32 -0
  19. package/dist/lib/task/index.js +41 -0
  20. package/dist/lib/task/short-id.js +90 -0
  21. package/dist/lib/update.js +25 -8
  22. package/lib/defaults.json +0 -1
  23. package/lib/init.ts +0 -10
  24. package/lib/sandbox/commands/create.ts +11 -2
  25. package/lib/sandbox/commands/enter.ts +40 -20
  26. package/lib/sandbox/commands/list-running.ts +65 -37
  27. package/lib/sandbox/commands/ls.ts +35 -27
  28. package/lib/sandbox/commands/start.ts +61 -0
  29. package/lib/sandbox/index.ts +15 -3
  30. package/lib/sandbox/task-resolver.ts +1 -1
  31. package/lib/sandbox/tools.ts +1 -1
  32. package/lib/table.ts +44 -0
  33. package/lib/task/commands/ls.ts +138 -0
  34. package/lib/task/commands/show.ts +139 -0
  35. package/lib/task/frontmatter.ts +30 -0
  36. package/lib/task/index.ts +44 -0
  37. package/lib/task/short-id.ts +107 -0
  38. package/lib/update.ts +28 -10
  39. package/package.json +1 -1
  40. package/templates/.agents/hooks/auto-resume.sh +104 -0
  41. package/templates/.agents/rules/create-issue.github.en.md +1 -1
  42. package/templates/.agents/rules/create-issue.github.zh-CN.md +1 -1
  43. package/templates/.agents/rules/milestone-inference.github.en.md +4 -1
  44. package/templates/.agents/rules/milestone-inference.github.zh-CN.md +4 -1
  45. package/templates/.agents/rules/next-step-output.en.md +62 -0
  46. package/templates/.agents/rules/next-step-output.zh-CN.md +62 -0
  47. package/templates/.agents/rules/pr-checks-commands.en.md +5 -0
  48. package/templates/.agents/rules/pr-checks-commands.github.en.md +62 -0
  49. package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +62 -0
  50. package/templates/.agents/rules/pr-checks-commands.zh-CN.md +5 -0
  51. package/templates/.agents/rules/pr-sync.github.en.md +7 -0
  52. package/templates/.agents/rules/pr-sync.github.zh-CN.md +7 -0
  53. package/templates/.agents/rules/task-short-id.en.md +54 -62
  54. package/templates/.agents/rules/task-short-id.zh-CN.md +35 -54
  55. package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +17 -0
  56. package/templates/.agents/scripts/task-short-id.js +32 -189
  57. package/templates/.agents/skills/analyze-task/SKILL.en.md +10 -12
  58. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +10 -12
  59. package/templates/.agents/skills/analyze-task/config/verify.en.json +1 -1
  60. package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +1 -1
  61. package/templates/.agents/skills/block-task/SKILL.en.md +13 -6
  62. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +13 -6
  63. package/templates/.agents/skills/block-task/config/verify.json +1 -1
  64. package/templates/.agents/skills/cancel-task/SKILL.en.md +13 -6
  65. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +13 -6
  66. package/templates/.agents/skills/cancel-task/config/verify.json +1 -1
  67. package/templates/.agents/skills/check-task/SKILL.en.md +12 -10
  68. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +12 -10
  69. package/templates/.agents/skills/close-codescan/SKILL.en.md +13 -6
  70. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +13 -6
  71. package/templates/.agents/skills/close-dependabot/SKILL.en.md +13 -6
  72. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +13 -6
  73. package/templates/.agents/skills/code-task/SKILL.en.md +10 -6
  74. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +11 -6
  75. package/templates/.agents/skills/code-task/config/verify.en.json +2 -1
  76. package/templates/.agents/skills/code-task/config/verify.zh-CN.json +2 -1
  77. package/templates/.agents/skills/code-task/reference/fix-mode.en.md +10 -5
  78. package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +10 -5
  79. package/templates/.agents/skills/code-task/reference/output-template.en.md +3 -3
  80. package/templates/.agents/skills/code-task/reference/output-template.zh-CN.md +3 -3
  81. package/templates/.agents/skills/code-task/reference/report-template.en.md +8 -0
  82. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +8 -0
  83. package/templates/.agents/skills/commit/SKILL.en.md +3 -4
  84. package/templates/.agents/skills/commit/SKILL.zh-CN.md +3 -4
  85. package/templates/.agents/skills/commit/reference/task-status-update.en.md +37 -29
  86. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +37 -29
  87. package/templates/.agents/skills/complete-task/SKILL.en.md +41 -4
  88. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +41 -4
  89. package/templates/.agents/skills/complete-task/config/verify.en.json +1 -1
  90. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +1 -1
  91. package/templates/.agents/skills/create-pr/SKILL.en.md +20 -11
  92. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +20 -11
  93. package/templates/.agents/skills/create-pr/config/verify.json +2 -1
  94. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +2 -1
  95. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +2 -1
  96. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +3 -3
  97. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +3 -3
  98. package/templates/.agents/skills/create-task/SKILL.en.md +17 -17
  99. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +17 -17
  100. package/templates/.agents/skills/create-task/config/verify.json +1 -1
  101. package/templates/.agents/skills/import-codescan/SKILL.en.md +8 -8
  102. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +8 -8
  103. package/templates/.agents/skills/import-codescan/config/verify.json +1 -1
  104. package/templates/.agents/skills/import-dependabot/SKILL.en.md +8 -8
  105. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +8 -8
  106. package/templates/.agents/skills/import-dependabot/config/verify.json +1 -1
  107. package/templates/.agents/skills/import-issue/SKILL.en.md +7 -7
  108. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +7 -7
  109. package/templates/.agents/skills/plan-task/SKILL.en.md +10 -12
  110. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +10 -12
  111. package/templates/.agents/skills/plan-task/config/verify.en.json +1 -1
  112. package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -1
  113. package/templates/.agents/skills/restore-task/SKILL.en.md +1 -1
  114. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +1 -1
  115. package/templates/.agents/skills/review-analysis/SKILL.en.md +4 -2
  116. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +4 -2
  117. package/templates/.agents/skills/review-analysis/config/verify.en.json +3 -2
  118. package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +3 -2
  119. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +15 -15
  120. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +15 -15
  121. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +7 -1
  122. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +7 -1
  123. package/templates/.agents/skills/review-analysis/reference/review-criteria.en.md +2 -0
  124. package/templates/.agents/skills/review-analysis/reference/review-criteria.zh-CN.md +2 -0
  125. package/templates/.agents/skills/review-code/SKILL.en.md +5 -2
  126. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +5 -2
  127. package/templates/.agents/skills/review-code/config/verify.en.json +3 -2
  128. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +3 -2
  129. package/templates/.agents/skills/review-code/reference/output-templates.en.md +9 -9
  130. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +9 -9
  131. package/templates/.agents/skills/review-code/reference/report-template.en.md +7 -1
  132. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +7 -1
  133. package/templates/.agents/skills/review-code/reference/review-criteria.en.md +2 -0
  134. package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +2 -0
  135. package/templates/.agents/skills/review-plan/SKILL.en.md +4 -2
  136. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +4 -2
  137. package/templates/.agents/skills/review-plan/config/verify.en.json +3 -2
  138. package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +3 -2
  139. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +15 -15
  140. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +15 -15
  141. package/templates/.agents/skills/review-plan/reference/report-template.en.md +7 -1
  142. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +7 -1
  143. package/templates/.agents/skills/review-plan/reference/review-criteria.en.md +2 -0
  144. package/templates/.agents/skills/review-plan/reference/review-criteria.zh-CN.md +2 -0
  145. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +0 -1
  146. package/templates/.agents/skills/watch-pr/SKILL.en.md +131 -0
  147. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +131 -0
  148. package/templates/.agents/skills/watch-pr/config/verify.json +22 -0
  149. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +43 -0
  150. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +43 -0
  151. package/templates/.agents/templates/task.en.md +1 -1
  152. package/templates/.agents/templates/task.zh-CN.md +1 -1
  153. package/templates/.agents/workflows/bug-fix.en.yaml +7 -5
  154. package/templates/.agents/workflows/bug-fix.zh-CN.yaml +6 -5
  155. package/templates/.agents/workflows/feature-development.en.yaml +7 -5
  156. package/templates/.agents/workflows/feature-development.zh-CN.yaml +6 -5
  157. package/templates/.agents/workflows/refactoring.en.yaml +7 -5
  158. package/templates/.agents/workflows/refactoring.zh-CN.yaml +6 -5
  159. package/templates/.claude/commands/watch-pr.en.md +8 -0
  160. package/templates/.claude/commands/watch-pr.zh-CN.md +8 -0
  161. package/templates/.claude/settings.json +11 -0
  162. package/templates/.gemini/commands/_project_/watch-pr.en.toml +8 -0
  163. package/templates/.gemini/commands/_project_/watch-pr.zh-CN.toml +8 -0
  164. package/templates/.opencode/commands/watch-pr.en.md +11 -0
  165. package/templates/.opencode/commands/watch-pr.zh-CN.md +11 -0
@@ -1,7 +1,8 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { runSafeEngine } from '../shell.ts';
4
+ import { runSafeEngine, runVerboseEngine } from '../shell.ts';
5
+ import { resolveTaskBranch } from '../task-resolver.ts';
5
6
 
6
7
  export type SandboxRow = {
7
8
  name: string;
@@ -88,13 +89,14 @@ export function fetchSandboxRows(
88
89
  }
89
90
 
90
91
  /**
91
- * Returns true iff `arg` is a syntactically valid task short reference ('#N').
92
+ * Returns true iff `arg` is a syntactically valid task short reference.
93
+ * Accepts both bare numeric ('11') and '#'-prefixed ('#11') forms.
92
94
  * Zero IO. Callers MUST use this as the gate before constructing any context
93
95
  * for resolveTaskShortRef — that way non-matching arguments (e.g. '#abc',
94
96
  * '#1.5', '#') never trigger sandbox list IO.
95
97
  */
96
98
  export function isTaskShortRef(arg: string): boolean {
97
- return /^#\d+$/.test(arg);
99
+ return /^#?\d+$/.test(arg);
98
100
  }
99
101
 
100
102
  type RegistryLookup =
@@ -113,6 +115,9 @@ type RegistryLookup =
113
115
  function tryResolveFromRegistry(arg: string, repoRoot: string): RegistryLookup {
114
116
  const scriptPath = path.join(repoRoot, '.agents', 'scripts', 'task-short-id.js');
115
117
  if (!fs.existsSync(scriptPath)) return { status: 'miss' };
118
+ // Strip leading '#' when forwarding bare-numeric input through the script's CLI.
119
+ // (Script accepts both forms, but this avoids shell quoting confusion in error
120
+ // messages echoed back to the user.)
116
121
  const result = spawnSync('node', [scriptPath, 'resolve', arg], { encoding: 'utf8', cwd: repoRoot });
117
122
  if (result.status !== 0) return { status: 'miss' };
118
123
  const taskId = (result.stdout || '').trim();
@@ -142,47 +147,70 @@ function tryResolveFromRegistry(arg: string, repoRoot: string): RegistryLookup {
142
147
  );
143
148
  }
144
149
 
145
- function resolveByRunningIndex(arg: string, running: SandboxRow[]): string {
146
- const n = Number(arg.slice(1));
147
- if (n < 1) {
148
- throw new Error(`Invalid sandbox index '${arg}': must be >= 1`);
149
- }
150
- if (running.length === 0) {
151
- throw new Error(`No running sandbox to reference with '${arg}'`);
152
- }
153
- if (n > running.length) {
154
- throw new Error(
155
- `No running sandbox at index '${arg}' (only ${running.length} running)`
156
- );
157
- }
158
- const row = running[n - 1]!;
159
- if (!row.branch) {
160
- throw new Error(
161
- `Cannot resolve branch for sandbox '${arg}' (container '${row.name}' missing branch label)`
162
- );
163
- }
164
- return row.branch;
165
- }
166
-
167
150
  /**
168
- * Resolve a task short reference ('#N') to a branch name for the sandbox entrypoint.
151
+ /**
152
+ * Resolve a task short reference (bare 'N' or '#N') to a branch name for the
153
+ * sandbox entrypoint.
169
154
  *
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).
155
+ * Resolution: registry-only. Look up the short id in the global task-short-id
156
+ * registry under repoRoot; if hit, read the branch from the matching task.md.
157
+ * On miss (registry empty or short id absent), throw with an actionable
158
+ * message instead of falling back to a container's row position in
159
+ * 'ai sandbox ls' output — that fallback would make the same syntax mean
160
+ * different things depending on `docker ps` state.
175
161
  *
176
162
  * Precondition: callers MUST gate on isTaskShortRef(arg) === true.
177
163
  */
178
164
  export function resolveTaskShortRef(
179
165
  arg: string,
180
- ctx: { running: SandboxRow[]; repoRoot?: string }
166
+ ctx: { repoRoot: string }
181
167
  ): 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.
168
+ const lookup = tryResolveFromRegistry(arg, ctx.repoRoot);
169
+ if (lookup.status === 'hit') return lookup.branch;
170
+ throw new Error(
171
+ `short ref '${arg}' is not in the active task registry. ` +
172
+ `'#N' and bare N resolve only via the registry (not by row position in 'ai sandbox ls'); ` +
173
+ `use a task short id (e.g. 'ai sandbox exec 11'), a TASK-id, or a branch name.`
174
+ );
175
+ }
176
+
177
+ /**
178
+ * Resolve a sandbox command argument (`<branch | TASK-id | N | '#N'>`) to a
179
+ * branch name, mirroring `ai sandbox exec` so that `start` and `exec` share one
180
+ * input contract. Short refs go through the registry-only resolver (which throws
181
+ * an actionable error on a miss); everything else flows through resolveTaskBranch
182
+ * (plain branch names pass through unchanged, TASK-ids resolve via task.md).
183
+ * Callers still run assertValidBranchName on the result.
184
+ */
185
+ export function resolveBranchArg(arg: string, ctx: { repoRoot: string }): string {
186
+ return isTaskShortRef(arg)
187
+ ? resolveTaskShortRef(arg, ctx)
188
+ : resolveTaskBranch(arg, ctx.repoRoot);
189
+ }
190
+
191
+ /**
192
+ * Pick the sandbox container row whose name matches one of the candidate
193
+ * container names (covers both the '..' and legacy '-' branch sanitizations).
194
+ * Pure: no IO. Returns null when no row matches.
195
+ */
196
+ export function selectSandboxContainer(
197
+ rows: SandboxRow[],
198
+ candidates: string[]
199
+ ): SandboxRow | null {
200
+ return rows.find((row) => candidates.includes(row.name)) ?? null;
201
+ }
202
+
203
+ /**
204
+ * Start an existing (stopped) sandbox container by name. Throws a distinct,
205
+ * actionable error when `docker start` fails, so callers can tell "start failed"
206
+ * apart from "container not found".
207
+ */
208
+ export function startSandboxContainer(engine: string, name: string): void {
209
+ try {
210
+ runVerboseEngine(engine, 'docker', ['start', name]);
211
+ } catch (error) {
212
+ throw new Error(
213
+ `Failed to start sandbox container '${name}': ${error instanceof Error ? error.message : 'unknown error'}`
214
+ );
186
215
  }
187
- return resolveByRunningIndex(arg, ctx.running);
188
216
  }
@@ -6,40 +6,38 @@ import { loadConfig } from '../config.ts';
6
6
  import { sandboxBranchLabel, sandboxLabel } from '../constants.ts';
7
7
  import { detectEngine } from '../engine.ts';
8
8
  import { resolveTools, toolProjectDirCandidates } from '../tools.ts';
9
+ import { formatTable } from '../../table.ts';
10
+ import { lookupShortIdByBranch } from '../../task/short-id.ts';
9
11
  import { fetchSandboxRows } from './list-running.ts';
10
12
 
11
13
  export { containerListFormat, parseLabels } from './list-running.ts';
12
14
 
13
15
  const USAGE = `Usage: ai sandbox ls
14
16
 
15
- Lists all containers for the current project. The leftmost '#' column
16
- numbers running sandboxes; use it as "ai sandbox exec '#N'" to enter one.
17
- Quote '#N' to avoid shell '#' comment handling.`;
17
+ Lists all containers for the current project. The '#' column is a
18
+ display-only row number; the 'SHORT' column shows the active task short
19
+ id bound to each container's branch (via
20
+ .agents/workspace/active/.short-ids.json), or '-' if no active task is
21
+ bound. Pass the SHORT value to "ai sandbox exec" (e.g. 'ai sandbox exec 11').
22
+ A '-' means no active task is bound to that branch, so the sandbox is free
23
+ to remove with "ai sandbox rm <branch>".`;
18
24
 
19
- const CONTAINER_TABLE_HEADERS = ['#', 'NAMES', 'STATUS', 'BRANCH'] as const;
25
+ const CONTAINER_TABLE_HEADERS = ['#', 'SHORT', 'NAMES', 'STATUS', 'BRANCH'] as const;
20
26
 
21
27
  type ContainerTableRow = {
22
- index: string;
28
+ row: string;
29
+ shortId: string;
23
30
  name: string;
24
31
  status: string;
25
32
  branch: string;
26
33
  };
27
34
 
28
- export function formatContainerTable(rows: ContainerTableRow[]): string[] {
29
- const columns = rows.map((row) => [row.index, row.name, row.status, row.branch] as const);
30
- const widths = [
31
- Math.max(CONTAINER_TABLE_HEADERS[0].length, ...rows.map((row) => row.index.length)),
32
- Math.max(CONTAINER_TABLE_HEADERS[1].length, ...rows.map((row) => row.name.length)),
33
- Math.max(CONTAINER_TABLE_HEADERS[2].length, ...rows.map((row) => row.status.length)),
34
- Math.max(CONTAINER_TABLE_HEADERS[3].length, ...rows.map((row) => row.branch.length))
35
- ] as const;
36
- const renderRow = (values: readonly [string, string, string, string]): string =>
37
- `${values[0].padEnd(widths[0])} ${values[1].padEnd(widths[1])} ${values[2].padEnd(widths[2])} ${values[3]}`.trimEnd();
38
-
39
- return [
40
- renderRow(CONTAINER_TABLE_HEADERS),
41
- ...columns.map((column) => renderRow(column))
42
- ];
35
+ export function formatContainerTable(rows: ContainerTableRow[], zebra = false): string[] {
36
+ return formatTable(
37
+ CONTAINER_TABLE_HEADERS,
38
+ rows.map((r) => [r.row, r.shortId, r.name, r.status, r.branch]),
39
+ { zebra }
40
+ );
43
41
  }
44
42
 
45
43
  function listChildren(dir: string): string[] {
@@ -69,15 +67,25 @@ export function ls(args: string[] = []): void {
69
67
  if (ordered.length === 0) {
70
68
  p.log.warn(' No sandbox containers');
71
69
  } else {
72
- const tableRows: ContainerTableRow[] = ordered.map((row) => ({
73
- index: row.index === null ? '' : String(row.index),
74
- name: row.name,
75
- status: row.status,
76
- branch: row.branch
77
- }));
78
- for (const line of formatContainerTable(tableRows)) {
70
+ const tableRows: ContainerTableRow[] = ordered.map((container, i) => {
71
+ const shortId = container.branch ? lookupShortIdByBranch(container.branch, config.repoRoot) : null;
72
+ return {
73
+ row: String(i + 1),
74
+ shortId: shortId ?? '-',
75
+ name: container.name,
76
+ status: container.status,
77
+ branch: container.branch
78
+ };
79
+ });
80
+ for (const line of formatContainerTable(tableRows, Boolean(process.stdout.isTTY))) {
79
81
  process.stdout.write(` ${line}\n`);
80
82
  }
83
+ process.stdout.write(` Total: ${ordered.length} containers\n`);
84
+ if (tableRows.some((r) => r.shortId === '-')) {
85
+ process.stdout.write(
86
+ ` SHORT '-' = no active task bound; that sandbox is free to remove with 'ai sandbox rm <branch>'.\n`
87
+ );
88
+ }
81
89
  }
82
90
 
83
91
  p.log.step('Worktrees');
@@ -0,0 +1,61 @@
1
+ import { loadConfig } from '../config.ts';
2
+ import {
3
+ assertValidBranchName,
4
+ containerNameCandidates,
5
+ sandboxBranchLabel,
6
+ sandboxLabel
7
+ } from '../constants.ts';
8
+ import { detectEngine } from '../engine.ts';
9
+ import {
10
+ fetchSandboxRows,
11
+ resolveBranchArg,
12
+ selectSandboxContainer,
13
+ startSandboxContainer
14
+ } from './list-running.ts';
15
+
16
+ const USAGE = `Usage: ai sandbox start <branch | TASK-id | N | '#N'>
17
+
18
+ Start an existing sandbox container that has stopped (for example after the
19
+ Docker daemon was restarted or replaced). The container must already exist:
20
+ if none is found, run 'ai sandbox create <branch>' first. A container that is
21
+ already running is left untouched.`;
22
+
23
+ export async function start(args: string[]): Promise<void> {
24
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
25
+ process.stdout.write(`${USAGE}\n`);
26
+ if (args.length === 0) {
27
+ process.exitCode = 1;
28
+ }
29
+ return;
30
+ }
31
+
32
+ const [firstArg = ''] = args;
33
+ const config = loadConfig();
34
+ const engine = detectEngine(config);
35
+ const branch = resolveBranchArg(firstArg, { repoRoot: config.repoRoot });
36
+ assertValidBranchName(branch);
37
+
38
+ const { running, nonRunning } = fetchSandboxRows(
39
+ engine,
40
+ sandboxLabel(config),
41
+ sandboxBranchLabel(config)
42
+ );
43
+ const found = selectSandboxContainer(
44
+ [...running, ...nonRunning],
45
+ containerNameCandidates(config, branch)
46
+ );
47
+
48
+ if (!found) {
49
+ throw new Error(
50
+ `No sandbox container for branch '${branch}'. Run 'ai sandbox create ${branch}' to create one.`
51
+ );
52
+ }
53
+
54
+ if (found.running) {
55
+ process.stdout.write(`Sandbox '${found.name}' is already running.\n`);
56
+ return;
57
+ }
58
+
59
+ startSandboxContainer(engine, found.name);
60
+ process.stdout.write(`Started sandbox '${found.name}'.\n`);
61
+ }
@@ -2,9 +2,16 @@ const USAGE = `Usage: ai sandbox <command> [options]
2
2
 
3
3
  Commands:
4
4
  create <branch> [base] Create a sandbox (VM + image + worktree + container)
5
- exec <branch | '#N'> [cmd...]
6
- Enter sandbox or run a command (use leftmost '#' column from 'ls')
7
- ls List sandboxes for the current project
5
+ exec <branch | TASK-id | N | '#N'> [cmd...]
6
+ Enter sandbox or run a command. N (bare) is the
7
+ recommended form for task short ids (e.g.
8
+ 'ai sandbox exec 11'); '#N' is also accepted.
9
+ start <branch | TASK-id | N | '#N'>
10
+ Start an existing stopped sandbox container
11
+ (e.g. after the Docker daemon restarted)
12
+ ls List sandboxes for the current project (the '#'
13
+ column is a display-only row number; the 'SHORT'
14
+ column shows the active task short id, '-' if none)
8
15
  prune [--dry-run] Remove orphaned per-branch state dirs
9
16
  rebuild [--quiet] [--refresh]
10
17
  Rebuild the sandbox image (--refresh pulls base + tools)
@@ -50,6 +57,11 @@ export async function runSandbox(args: string[]): Promise<void> {
50
57
  }
51
58
  break;
52
59
  }
60
+ case 'start': {
61
+ const { start } = await import('./commands/start.ts');
62
+ await start(rest);
63
+ break;
64
+ }
53
65
  case 'ls': {
54
66
  const { ls } = await import('./commands/ls.ts');
55
67
  ls(rest);
@@ -3,7 +3,7 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
5
5
  const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
6
- const SHORT_ID_RE = /^#\d+$/;
6
+ const SHORT_ID_RE = /^#?\d+$/;
7
7
  const WORKSPACE_DIRS = ['active', 'completed', 'blocked', 'archive'];
8
8
 
9
9
  function resolveShortIdStrict(arg: string, repoRoot: string): string {
@@ -35,7 +35,7 @@ function createBuiltinTools(home: string, project: string): Record<string, Sandb
35
35
  'claude-code': {
36
36
  id: 'claude-code',
37
37
  name: 'Claude Code',
38
- install: { type: 'npm', cmd: '@anthropic-ai/claude-code@stable' },
38
+ install: { type: 'npm', cmd: '@anthropic-ai/claude-code@latest' },
39
39
  sandboxBase: hostJoin(home, '.agent-infra', 'sandboxes', 'claude-code'),
40
40
  containerMount: '/home/devuser/.claude',
41
41
  versionCmd: 'claude --version',
package/lib/table.ts ADDED
@@ -0,0 +1,44 @@
1
+ import pc from 'picocolors';
2
+
3
+ function formatTable(
4
+ headers: readonly string[],
5
+ rows: readonly (readonly string[])[],
6
+ options: { zebra?: boolean } = {}
7
+ ): string[] {
8
+ const { zebra = false } = options;
9
+ const columnCount = headers.length;
10
+ const widths = headers.map((header, i) => {
11
+ const headerLen = header.length;
12
+ let max = headerLen;
13
+ for (const row of rows) {
14
+ const cell = row[i] ?? '';
15
+ if (cell.length > max) max = cell.length;
16
+ }
17
+ return max;
18
+ });
19
+
20
+ const renderRow = (values: readonly string[]): string => {
21
+ const parts: string[] = [];
22
+ for (let i = 0; i < columnCount; i += 1) {
23
+ const cell = values[i] ?? '';
24
+ if (i === columnCount - 1) {
25
+ parts.push(cell);
26
+ } else {
27
+ parts.push(cell.padEnd(widths[i]!));
28
+ }
29
+ }
30
+ return parts.join(' ').trimEnd();
31
+ };
32
+
33
+ const dataLines = rows.map((row, i) => {
34
+ const line = renderRow(row);
35
+ // Zebra stripes: dim even-numbered data rows (rows 2, 4, 6... -> 0-based
36
+ // odd index). The header and odd rows are left untouched. When zebra is
37
+ // off, pc.dim is never called, so the output is byte-identical to before.
38
+ return zebra && i % 2 === 1 ? pc.dim(line) : line;
39
+ });
40
+
41
+ return [renderRow(headers), ...dataLines];
42
+ }
43
+
44
+ export { formatTable };
@@ -0,0 +1,138 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { formatTable } from '../../table.ts';
5
+ import { parseTaskFrontmatter, extractTitle } from '../frontmatter.ts';
6
+ import { loadShortIdByTaskId } from '../short-id.ts';
7
+
8
+ const USAGE = `Usage: ai task ls [--all | --blocked | --completed]
9
+
10
+ Lists tasks under .agents/workspace/. Defaults to active tasks only.
11
+ --all Include active + blocked + completed (excludes archive)
12
+ --blocked Only blocked tasks
13
+ --completed Only completed tasks
14
+
15
+ Columns: # (display-only row number) / SHORT (task short id, usable as an argument) / type / status / current_step / branch / title
16
+ `;
17
+
18
+ const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
19
+ const TABLE_HEADERS = ['#', 'SHORT', 'TYPE', 'STATUS', 'STEP', 'BRANCH', 'TITLE'] as const;
20
+
21
+ type Selection = ('active' | 'blocked' | 'completed')[];
22
+
23
+ function detectRepoRoot(): string {
24
+ try {
25
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], {
26
+ encoding: 'utf8',
27
+ stdio: ['pipe', 'pipe', 'pipe']
28
+ }).trim();
29
+ } catch {
30
+ throw new Error('ai task: current directory is not inside a git repository');
31
+ }
32
+ }
33
+
34
+ type ParseResult =
35
+ | { ok: true; selection: Selection }
36
+ | { ok: false; message: string };
37
+
38
+ function parseSelection(args: string[]): ParseResult {
39
+ const positional = args.filter((a) => !a.startsWith('--'));
40
+ if (positional.length > 0) {
41
+ return {
42
+ ok: false,
43
+ message: `ai task ls: unexpected positional argument(s): ${positional.join(' ')}`
44
+ };
45
+ }
46
+ const flags = args.filter((a) => a.startsWith('--'));
47
+ if (flags.length === 0) return { ok: true, selection: ['active'] };
48
+ if (flags.length > 1) {
49
+ return {
50
+ ok: false,
51
+ message: 'ai task ls: pass at most one of --all / --blocked / --completed'
52
+ };
53
+ }
54
+ switch (flags[0]) {
55
+ case '--all':
56
+ return { ok: true, selection: ['active', 'blocked', 'completed'] };
57
+ case '--blocked':
58
+ return { ok: true, selection: ['blocked'] };
59
+ case '--completed':
60
+ return { ok: true, selection: ['completed'] };
61
+ default:
62
+ return { ok: false, message: `ai task ls: unknown flag: ${flags[0]}` };
63
+ }
64
+ }
65
+
66
+ type TaskRow = {
67
+ shortId: string;
68
+ type: string;
69
+ status: string;
70
+ step: string;
71
+ branch: string;
72
+ title: string;
73
+ };
74
+
75
+ function collectTasks(repoRoot: string, state: 'active' | 'blocked' | 'completed'): TaskRow[] {
76
+ const dir = path.join(repoRoot, '.agents', 'workspace', state);
77
+ if (!fs.existsSync(dir)) return [];
78
+ // Short ids live only in the registry and only for active tasks; archived
79
+ // (blocked/completed) tasks have released their short id and render '-'.
80
+ const shortIdByTaskId = state === 'active' ? loadShortIdByTaskId(repoRoot) : new Map<string, string>();
81
+ const rows: TaskRow[] = [];
82
+ for (const entry of fs.readdirSync(dir).sort()) {
83
+ if (!TASK_ID_RE.test(entry)) continue;
84
+ const taskMdPath = path.join(dir, entry, 'task.md');
85
+ if (!fs.existsSync(taskMdPath)) continue;
86
+ const content = fs.readFileSync(taskMdPath, 'utf8');
87
+ const fm = parseTaskFrontmatter(content);
88
+ const title = extractTitle(content);
89
+ const shortId = shortIdByTaskId.get(entry) ?? '-';
90
+ rows.push({
91
+ shortId,
92
+ type: fm.type ?? '-',
93
+ status: fm.status ?? state,
94
+ step: fm.current_step ?? '-',
95
+ branch: fm.branch ?? '-',
96
+ title: title || fm.id || entry
97
+ });
98
+ }
99
+ return rows;
100
+ }
101
+
102
+ function ls(args: string[] = []): void {
103
+ if (args[0] === '--help' || args[0] === '-h') {
104
+ process.stdout.write(USAGE);
105
+ return;
106
+ }
107
+ const result = parseSelection(args);
108
+ if (!result.ok) {
109
+ process.stderr.write(`${result.message}\n`);
110
+ process.exitCode = 1;
111
+ return;
112
+ }
113
+ const { selection } = result;
114
+ const repoRoot = detectRepoRoot();
115
+ const rows: TaskRow[] = [];
116
+ for (const state of selection) {
117
+ rows.push(...collectTasks(repoRoot, state));
118
+ }
119
+ if (rows.length === 0) {
120
+ process.stdout.write(`No tasks under .agents/workspace/${selection.join('|')}\n`);
121
+ return;
122
+ }
123
+ const tableRows = rows.map((r, i) => [
124
+ String(i + 1),
125
+ r.shortId,
126
+ r.type,
127
+ r.status,
128
+ r.step,
129
+ r.branch,
130
+ r.title
131
+ ]);
132
+ for (const line of formatTable(TABLE_HEADERS, tableRows, { zebra: Boolean(process.stdout.isTTY) })) {
133
+ process.stdout.write(`${line}\n`);
134
+ }
135
+ process.stdout.write(`Total: ${rows.length} tasks\n`);
136
+ }
137
+
138
+ export { ls };
@@ -0,0 +1,139 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync, spawnSync } from 'node:child_process';
4
+ import { normalizeShortIdInput } from '../short-id.ts';
5
+
6
+ const USAGE = `Usage: ai task show <N | #N | TASK-id>
7
+
8
+ Prints the task.md content for the matching task.
9
+ N (bare numeric) Recommended; resolves the active short id via the registry.
10
+ '#N' Compatibility form for old commands.
11
+ TASK-YYYYMMDD-HHMMSS Locates a task in active / blocked / completed / archive.
12
+ `;
13
+
14
+ const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
15
+ // Flat-structured workspace dirs that hold tasks under `{dir}/{taskId}/task.md`.
16
+ // Note: `archive` uses a three-level YYYY/MM/DD layout and is handled separately.
17
+ const FLAT_WORKSPACE_DIRS = ['active', 'blocked', 'completed'] as const;
18
+
19
+ function detectRepoRoot(): string {
20
+ try {
21
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], {
22
+ encoding: 'utf8',
23
+ stdio: ['pipe', 'pipe', 'pipe']
24
+ }).trim();
25
+ } catch {
26
+ throw new Error('ai task: current directory is not inside a git repository');
27
+ }
28
+ }
29
+
30
+ function readShortIdLength(repoRoot: string): number {
31
+ try {
32
+ const cfg = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8'));
33
+ const v = cfg?.task?.shortIdLength;
34
+ if (typeof v === 'number' && Number.isFinite(v) && v >= 1) return v;
35
+ } catch {
36
+ // fall through to default
37
+ }
38
+ return 2;
39
+ }
40
+
41
+ function resolveShortIdToTaskId(arg: string, repoRoot: string): string {
42
+ const scriptPath = path.join(repoRoot, '.agents', 'scripts', 'task-short-id.js');
43
+ if (!fs.existsSync(scriptPath)) {
44
+ throw new Error(`task-short-id.js not found at ${scriptPath}`);
45
+ }
46
+ const result = spawnSync('node', [scriptPath, 'resolve', arg], {
47
+ encoding: 'utf8',
48
+ cwd: repoRoot
49
+ });
50
+ if (result.status !== 0) {
51
+ throw new Error((result.stderr || '').trim() || `failed to resolve '${arg}'`);
52
+ }
53
+ return result.stdout.trim();
54
+ }
55
+
56
+ function listSortedNumeric(dir: string, width: number): string[] {
57
+ if (!fs.existsSync(dir)) return [];
58
+ const pattern = new RegExp(`^\\d{${width}}$`);
59
+ return fs
60
+ .readdirSync(dir)
61
+ .filter((entry) => pattern.test(entry))
62
+ .sort()
63
+ .reverse();
64
+ }
65
+
66
+ function findInArchive(repoRoot: string, taskId: string): string | null {
67
+ // archive-tasks SKILL writes to .agents/workspace/archive/YYYY/MM/DD/{taskId}/task.md
68
+ // where YYYY/MM/DD comes from completed_at (or updated_at fallback) — NOT from
69
+ // the task id's creation date. So we cannot derive the path from taskId alone;
70
+ // walk the bounded YYYY/MM/DD tree instead. Newest-first to favor recent archives.
71
+ const archiveDir = path.join(repoRoot, '.agents', 'workspace', 'archive');
72
+ for (const year of listSortedNumeric(archiveDir, 4)) {
73
+ const yearDir = path.join(archiveDir, year);
74
+ for (const month of listSortedNumeric(yearDir, 2)) {
75
+ const monthDir = path.join(yearDir, month);
76
+ for (const day of listSortedNumeric(monthDir, 2)) {
77
+ const candidate = path.join(monthDir, day, taskId, 'task.md');
78
+ if (fs.existsSync(candidate)) return candidate;
79
+ }
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ function findTaskMd(repoRoot: string, taskId: string): string | null {
86
+ for (const sub of FLAT_WORKSPACE_DIRS) {
87
+ const candidate = path.join(repoRoot, '.agents', 'workspace', sub, taskId, 'task.md');
88
+ if (fs.existsSync(candidate)) return candidate;
89
+ }
90
+ return findInArchive(repoRoot, taskId);
91
+ }
92
+
93
+ function show(args: string[] = []): void {
94
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
95
+ process.stdout.write(USAGE);
96
+ if (args.length === 0) process.exitCode = 1;
97
+ return;
98
+ }
99
+ const repoRoot = detectRepoRoot();
100
+ const arg = args[0]!;
101
+ let taskId: string;
102
+ if (TASK_ID_RE.test(arg)) {
103
+ taskId = arg;
104
+ } else {
105
+ const shortIdLength = readShortIdLength(repoRoot);
106
+ const normalized = normalizeShortIdInput(arg, { shortIdLength });
107
+ if (normalized.kind === 'error') {
108
+ process.stderr.write(`ai task show: ${normalized.message}\n`);
109
+ process.exitCode = 1;
110
+ return;
111
+ }
112
+ if (normalized.kind === 'pass') {
113
+ process.stderr.write(
114
+ `ai task show: '${arg}' is not a valid short id or TASK-id; ` +
115
+ `expected bare digits, '#N', or 'TASK-YYYYMMDD-HHMMSS'\n`
116
+ );
117
+ process.exitCode = 1;
118
+ return;
119
+ }
120
+ try {
121
+ taskId = resolveShortIdToTaskId(normalized.value, repoRoot);
122
+ } catch (e) {
123
+ process.stderr.write(`ai task show: ${(e as Error).message}\n`);
124
+ process.exitCode = 1;
125
+ return;
126
+ }
127
+ }
128
+ const taskMdPath = findTaskMd(repoRoot, taskId);
129
+ if (!taskMdPath) {
130
+ process.stderr.write(
131
+ `ai task show: task ${taskId} not found in active / blocked / completed / archive\n`
132
+ );
133
+ process.exitCode = 1;
134
+ return;
135
+ }
136
+ process.stdout.write(fs.readFileSync(taskMdPath, 'utf8'));
137
+ }
138
+
139
+ export { show };