@fitlab-ai/agent-infra 0.4.4 → 0.5.0

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 (49) hide show
  1. package/README.md +16 -2
  2. package/README.zh-CN.md +16 -2
  3. package/bin/cli.js +19 -0
  4. package/lib/defaults.json +17 -0
  5. package/lib/init.js +1 -0
  6. package/lib/log.js +5 -10
  7. package/lib/merge.js +465 -0
  8. package/lib/sandbox/commands/create.js +1047 -0
  9. package/lib/sandbox/commands/enter.js +31 -0
  10. package/lib/sandbox/commands/ls.js +70 -0
  11. package/lib/sandbox/commands/rebuild.js +102 -0
  12. package/lib/sandbox/commands/rm.js +211 -0
  13. package/lib/sandbox/commands/vm.js +101 -0
  14. package/lib/sandbox/config.js +79 -0
  15. package/lib/sandbox/constants.js +113 -0
  16. package/lib/sandbox/dockerfile.js +95 -0
  17. package/lib/sandbox/engine.js +93 -0
  18. package/lib/sandbox/index.js +64 -0
  19. package/lib/sandbox/runtimes/ai-tools.dockerfile +26 -0
  20. package/lib/sandbox/runtimes/base.dockerfile +30 -0
  21. package/lib/sandbox/runtimes/java17.dockerfile +3 -0
  22. package/lib/sandbox/runtimes/java21.dockerfile +3 -0
  23. package/lib/sandbox/runtimes/node20.dockerfile +3 -0
  24. package/lib/sandbox/runtimes/node22.dockerfile +3 -0
  25. package/lib/sandbox/runtimes/python3.dockerfile +3 -0
  26. package/lib/sandbox/shell.js +48 -0
  27. package/lib/sandbox/task-resolver.js +35 -0
  28. package/lib/sandbox/tools.js +131 -0
  29. package/lib/update.js +16 -2
  30. package/package.json +5 -1
  31. package/templates/.agents/rules/commit-and-pr.md +30 -0
  32. package/templates/.agents/rules/commit-and-pr.zh-CN.md +30 -0
  33. package/templates/.agents/rules/issue-sync.md +12 -2
  34. package/templates/.agents/rules/issue-sync.zh-CN.md +12 -2
  35. package/templates/.agents/rules/task-management.md +28 -0
  36. package/templates/.agents/rules/task-management.zh-CN.md +28 -0
  37. package/templates/.agents/scripts/validate-artifact.js +40 -0
  38. package/templates/.agents/skills/archive-tasks/SKILL.md +6 -3
  39. package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +6 -3
  40. package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +91 -8
  41. package/templates/.agents/skills/create-task/SKILL.md +6 -0
  42. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +6 -0
  43. package/templates/.agents/skills/create-task/config/verify.json +1 -0
  44. package/templates/.agents/skills/import-issue/SKILL.md +2 -0
  45. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +2 -0
  46. package/templates/.agents/skills/import-issue/config/verify.json +1 -0
  47. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +18 -1
  48. package/templates/.agents/templates/task.md +5 -4
  49. package/templates/.agents/templates/task.zh-CN.md +5 -4
package/README.md CHANGED
@@ -133,10 +133,18 @@ npm install -g @fitlab-ai/agent-infra
133
133
  curl -fsSL https://raw.githubusercontent.com/fitlab-ai/agent-infra/main/install.sh | sh
134
134
  ```
135
135
 
136
+ **Option C - Homebrew (macOS)**
137
+
138
+ ```bash
139
+ brew install fitlab-ai/tap/agent-infra
140
+ ```
141
+
136
142
  ### Updating agent-infra
137
143
 
138
144
  ```bash
139
145
  npm update -g @fitlab-ai/agent-infra
146
+ # or, if installed via Homebrew:
147
+ brew upgrade agent-infra
140
148
  ```
141
149
 
142
150
  Check your current version:
@@ -171,6 +179,12 @@ Open the project in any AI TUI and run `update-agent-infra`:
171
179
 
172
180
  This detects the packaged template version and renders all managed files. The same command is used both for first-time setup and for future template upgrades.
173
181
 
182
+ ### Sandbox aliases and GitHub CLI
183
+
184
+ `ai sandbox create` now bootstraps the host-side aliases file at `~/.ai-sandbox-aliases` on first run. The generated file includes ready-to-edit yolo shortcuts for Claude, Codex, Gemini CLI, and OpenCode, and every sandbox syncs that file into `/home/devuser/.bash_aliases`.
185
+
186
+ The sandbox image also preinstalls `gh`. When `gh auth token` succeeds on the host, `ai sandbox create` injects the token into the container as `GH_TOKEN`, so `gh` commands work inside the sandbox without extra setup.
187
+
174
188
  <a id="architecture-overview"></a>
175
189
 
176
190
  ## Architecture Overview
@@ -179,7 +193,7 @@ agent-infra is intentionally simple: a bootstrap CLI creates the seed configurat
179
193
 
180
194
  ### End-to-End Flow
181
195
 
182
- 1. **Install** — `npm install -g @fitlab-ai/agent-infra` (or use the shell script wrapper)
196
+ 1. **Install** — `npm install -g @fitlab-ai/agent-infra` (or `brew install fitlab-ai/tap/agent-infra` on macOS, or use the shell script wrapper)
183
197
  2. **Initialize** — `ai init` in the project root to generate `.agents/.airc.json` and install the seed command
184
198
  3. **Render** — run `update-agent-infra` in any AI TUI to detect the bundled template version and generate all managed files
185
199
  4. **Develop** — use built-in skills to drive the full lifecycle: `analysis → design → implementation → review → fix → commit`
@@ -380,7 +394,7 @@ The generated `.agents/.airc.json` file is the central contract between the boot
380
394
  "project": "my-project",
381
395
  "org": "my-org",
382
396
  "language": "en",
383
- "templateVersion": "v0.4.4",
397
+ "templateVersion": "v0.5.0",
384
398
  "files": {
385
399
  "managed": [
386
400
  ".agents/workspace/README.md",
package/README.zh-CN.md CHANGED
@@ -133,10 +133,18 @@ npm install -g @fitlab-ai/agent-infra
133
133
  curl -fsSL https://raw.githubusercontent.com/fitlab-ai/agent-infra/main/install.sh | sh
134
134
  ```
135
135
 
136
+ **方式 C - Homebrew (macOS)**
137
+
138
+ ```bash
139
+ brew install fitlab-ai/tap/agent-infra
140
+ ```
141
+
136
142
  ### 更新 agent-infra
137
143
 
138
144
  ```bash
139
145
  npm update -g @fitlab-ai/agent-infra
146
+ # 或者通过 Homebrew 安装时:
147
+ brew upgrade agent-infra
140
148
  ```
141
149
 
142
150
  查看当前版本:
@@ -171,6 +179,12 @@ CLI 会收集项目元数据,向所有支持的 AI TUI 安装 `update-agent-in
171
179
 
172
180
  该命令会检测当前打包模板版本并渲染所有受管理文件。首次安装和后续升级都使用同一条命令。
173
181
 
182
+ ### 沙箱 aliases 与 GitHub CLI
183
+
184
+ `ai sandbox create` 在首次运行时会自动生成宿主机侧的 `~/.ai-sandbox-aliases`。该文件内置了 Claude、Codex、Gemini CLI 和 OpenCode 的 yolo 快捷命令模板,你可以直接修改;每次创建沙箱时,这个文件都会同步到容器内的 `/home/devuser/.bash_aliases`。
185
+
186
+ 沙箱镜像也会预装 `gh`。如果宿主机上的 `gh auth token` 能成功返回 token,`ai sandbox create` 会把它以 `GH_TOKEN` 环境变量注入容器,让你在沙箱里直接使用 `gh`,无需额外登录配置。
187
+
174
188
  <a id="architecture-overview"></a>
175
189
 
176
190
  ## 架构概览
@@ -179,7 +193,7 @@ agent-infra 的结构刻意保持简单:引导 CLI 负责生成种子配置,
179
193
 
180
194
  ### 端到端流程
181
195
 
182
- 1. **安装** — `npm install -g @fitlab-ai/agent-infra`(或使用 shell 脚本便捷封装)
196
+ 1. **安装** — `npm install -g @fitlab-ai/agent-infra`(或在 macOS 上使用 `brew install fitlab-ai/tap/agent-infra`,或使用 shell 脚本便捷封装)
183
197
  2. **初始化** — 在项目根目录运行 `ai init`,生成 `.agents/.airc.json` 并安装种子命令
184
198
  3. **渲染** — 在任意 AI TUI 中执行 `update-agent-infra`,检测当前打包模板版本并生成所有受管理文件
185
199
  4. **开发** — 使用内置 skill 驱动完整生命周期:`analysis → design → implementation → review → fix → commit`
@@ -380,7 +394,7 @@ import-issue #42 从 GitHub Issue 导入任务
380
394
  "project": "my-project",
381
395
  "org": "my-org",
382
396
  "language": "en",
383
- "templateVersion": "v0.4.4",
397
+ "templateVersion": "v0.5.0",
384
398
  "files": {
385
399
  "managed": [
386
400
  ".agents/workspace/README.md",
package/bin/cli.js CHANGED
@@ -14,7 +14,9 @@ const USAGE = `agent-infra - bootstrap AI collaboration infrastructure
14
14
 
15
15
  Usage:
16
16
  agent-infra init Initialize a new project with update-agent-infra seed command
17
+ agent-infra merge Merge archived tasks from another archive directory
17
18
  agent-infra update Update seed files and sync file registry for an existing project
19
+ agent-infra sandbox Manage Docker-based AI sandboxes
18
20
  agent-infra version Show version
19
21
  agent-infra help Show this help message
20
22
 
@@ -23,6 +25,7 @@ Shorthand: ai (e.g. ai init)
23
25
  Install methods:
24
26
  npm: npm install -g @fitlab-ai/agent-infra
25
27
  npx: npx @fitlab-ai/agent-infra init
28
+ brew: brew install fitlab-ai/tap/agent-infra (macOS)
26
29
  curl: curl -fsSL https://raw.githubusercontent.com/fitlab-ai/agent-infra/main/install.sh | sh (runs npm install -g internally)
27
30
 
28
31
  Examples:
@@ -49,6 +52,22 @@ switch (command) {
49
52
  });
50
53
  break;
51
54
  }
55
+ case 'merge': {
56
+ const { cmdMerge } = await import('../lib/merge.js');
57
+ await cmdMerge(process.argv.slice(3)).catch((e) => {
58
+ process.stderr.write(`Error: ${e.message}\n`);
59
+ process.exitCode = 1;
60
+ });
61
+ break;
62
+ }
63
+ case 'sandbox': {
64
+ const { runSandbox } = await import('../lib/sandbox/index.js');
65
+ await runSandbox(process.argv.slice(3)).catch((e) => {
66
+ process.stderr.write(`Error: ${e.message}\n`);
67
+ process.exitCode = 1;
68
+ });
69
+ break;
70
+ }
52
71
  case 'version': {
53
72
  console.log(`agent-infra ${VERSION}`);
54
73
  break;
package/lib/defaults.json CHANGED
@@ -1,4 +1,21 @@
1
1
  {
2
+ "sandbox": {
3
+ "runtimes": [
4
+ "node20"
5
+ ],
6
+ "tools": [
7
+ "claude-code",
8
+ "codex",
9
+ "opencode",
10
+ "gemini-cli"
11
+ ],
12
+ "dockerfile": null,
13
+ "vm": {
14
+ "cpu": null,
15
+ "memory": null,
16
+ "disk": null
17
+ }
18
+ },
2
19
  "labels": {
3
20
  "in": {}
4
21
  },
package/lib/init.js CHANGED
@@ -162,6 +162,7 @@ async function cmdInit() {
162
162
  org: orgName,
163
163
  language,
164
164
  templateVersion: VERSION,
165
+ sandbox: structuredClone(defaults.sandbox),
165
166
  labels: structuredClone(defaults.labels),
166
167
  files: structuredClone(defaults.files)
167
168
  };
package/lib/log.js CHANGED
@@ -1,27 +1,22 @@
1
- const isTTY = process.stdout.isTTY;
2
- const isTTYErr = process.stderr.isTTY;
3
-
4
- function color(code, text, tty) {
5
- return tty ? `\x1b[${code}m${text}\x1b[0m` : text;
6
- }
1
+ import pc from 'picocolors';
7
2
 
8
3
  function info(...args) {
9
4
  const msg = args.join(' ');
10
- process.stdout.write(` ${color('1;34', '>', isTTY)} ${msg}\n`);
5
+ process.stdout.write(` ${pc.bold(pc.blue('>'))} ${msg}\n`);
11
6
  }
12
7
 
13
8
  function ok(...args) {
14
9
  const msg = args.join(' ');
15
- process.stdout.write(` ${color('1;32', '\u2713', isTTY)} ${msg}\n`);
10
+ process.stdout.write(` ${pc.bold(pc.green('\u2713'))} ${msg}\n`);
16
11
  }
17
12
 
18
13
  function err(...args) {
19
14
  const msg = args.join(' ');
20
- process.stderr.write(` ${color('1;31', '\u2717', isTTYErr)} ${msg}\n`);
15
+ process.stderr.write(` ${pc.bold(pc.red('\u2717'))} ${msg}\n`);
21
16
  }
22
17
 
23
18
  function ask(text) {
24
- process.stdout.write(` ${color('1;33', '?', isTTY)} ${text}`);
19
+ process.stdout.write(` ${pc.bold(pc.yellow('?'))} ${text}`);
25
20
  }
26
21
 
27
22
  export { info, ok, err, ask };
package/lib/merge.js ADDED
@@ -0,0 +1,465 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { info, ok } from './log.js';
4
+
5
+ const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
6
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
7
+ const TITLE_RE = /^# (.+)$/m;
8
+ const DATE_FROM_PATH_RE = /(?:^|[/\\])(\d{4})[/\\](\d{2})[/\\](\d{2})(?:[/\\]|$)/;
9
+
10
+ function extractField(content, fieldName) {
11
+ const match = content.match(FRONTMATTER_RE);
12
+ if (!match) {
13
+ return null;
14
+ }
15
+
16
+ const lines = match[1].split(/\r?\n/);
17
+ const prefix = `${fieldName}:`;
18
+
19
+ for (const line of lines) {
20
+ if (!line.startsWith(prefix)) {
21
+ continue;
22
+ }
23
+
24
+ const value = line.slice(prefix.length).trim().replace(/^['"]|['"]$/g, '');
25
+ return value || null;
26
+ }
27
+
28
+ return null;
29
+ }
30
+
31
+ function extractTitle(content) {
32
+ const withoutFrontmatter = content.replace(FRONTMATTER_RE, '');
33
+ const match = withoutFrontmatter.match(TITLE_RE);
34
+ if (!match) {
35
+ return null;
36
+ }
37
+
38
+ return match[1]
39
+ .trim()
40
+ .replace(/^任务:/, '')
41
+ .replace(/^Task:\s*/, '')
42
+ .replace(/\\/g, '\\\\')
43
+ .replace(/\|/g, '\\|') || null;
44
+ }
45
+
46
+ function normalizeTaskRecord(taskDir, taskFile, dateParts) {
47
+ const taskId = path.basename(taskDir);
48
+ const content = fs.readFileSync(taskFile, 'utf8');
49
+ const completedAt = extractField(content, 'completed_at');
50
+ const updatedAt = extractField(content, 'updated_at');
51
+ const taskDate = completedAt || updatedAt || `${dateParts.year}-${dateParts.month}-${dateParts.day}`;
52
+ const title = extractTitle(content) || taskId;
53
+ const type = extractField(content, 'type') || 'unknown';
54
+
55
+ return {
56
+ taskId,
57
+ taskDir,
58
+ relativePath: `${dateParts.year}/${dateParts.month}/${dateParts.day}/${taskId}/`,
59
+ year: dateParts.year,
60
+ month: dateParts.month,
61
+ day: dateParts.day,
62
+ title,
63
+ type,
64
+ completedAt: taskDate
65
+ };
66
+ }
67
+
68
+ function fallbackDateParts(taskDir, content) {
69
+ const pathMatch = taskDir.match(DATE_FROM_PATH_RE);
70
+ if (pathMatch) {
71
+ return {
72
+ year: pathMatch[1],
73
+ month: pathMatch[2],
74
+ day: pathMatch[3]
75
+ };
76
+ }
77
+
78
+ const completedAt = extractField(content, 'completed_at');
79
+ const updatedAt = extractField(content, 'updated_at');
80
+ const source = completedAt || updatedAt;
81
+ const dateMatch = source?.match(/^(\d{4})-(\d{2})-(\d{2})/);
82
+
83
+ if (dateMatch) {
84
+ return {
85
+ year: dateMatch[1],
86
+ month: dateMatch[2],
87
+ day: dateMatch[3]
88
+ };
89
+ }
90
+
91
+ return null;
92
+ }
93
+
94
+ function scanSourceTasks(sourceDir) {
95
+ const tasks = [];
96
+ const years = fs.existsSync(sourceDir) ? fs.readdirSync(sourceDir, { withFileTypes: true }) : [];
97
+
98
+ for (const yearEntry of years) {
99
+ if (!yearEntry.isDirectory() || !/^\d{4}$/.test(yearEntry.name)) {
100
+ continue;
101
+ }
102
+
103
+ const yearDir = path.join(sourceDir, yearEntry.name);
104
+ for (const monthEntry of fs.readdirSync(yearDir, { withFileTypes: true })) {
105
+ if (!monthEntry.isDirectory() || !/^\d{2}$/.test(monthEntry.name)) {
106
+ continue;
107
+ }
108
+
109
+ const monthDir = path.join(yearDir, monthEntry.name);
110
+ for (const dayEntry of fs.readdirSync(monthDir, { withFileTypes: true })) {
111
+ if (!dayEntry.isDirectory() || !/^\d{2}$/.test(dayEntry.name)) {
112
+ continue;
113
+ }
114
+
115
+ const dayDir = path.join(monthDir, dayEntry.name);
116
+ for (const taskEntry of fs.readdirSync(dayDir, { withFileTypes: true })) {
117
+ if (!taskEntry.isDirectory() || !TASK_ID_RE.test(taskEntry.name)) {
118
+ continue;
119
+ }
120
+
121
+ const taskDir = path.join(dayDir, taskEntry.name);
122
+ const taskFile = path.join(taskDir, 'task.md');
123
+ if (!fs.existsSync(taskFile)) {
124
+ continue;
125
+ }
126
+
127
+ tasks.push(normalizeTaskRecord(taskDir, taskFile, {
128
+ year: yearEntry.name,
129
+ month: monthEntry.name,
130
+ day: dayEntry.name
131
+ }));
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ if (tasks.length > 0) {
138
+ return tasks;
139
+ }
140
+
141
+ // Fall back to a deeper scan if the source layout is unusual but still contains archived tasks.
142
+ const stack = [sourceDir];
143
+ while (stack.length > 0) {
144
+ const currentDir = stack.pop();
145
+ if (!currentDir || !fs.existsSync(currentDir)) {
146
+ continue;
147
+ }
148
+
149
+ for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
150
+ const entryPath = path.join(currentDir, entry.name);
151
+ if (!entry.isDirectory()) {
152
+ continue;
153
+ }
154
+
155
+ if (TASK_ID_RE.test(entry.name)) {
156
+ const taskFile = path.join(entryPath, 'task.md');
157
+ if (!fs.existsSync(taskFile)) {
158
+ continue;
159
+ }
160
+
161
+ const content = fs.readFileSync(taskFile, 'utf8');
162
+ const dateParts = fallbackDateParts(entryPath, content);
163
+ if (!dateParts) {
164
+ continue;
165
+ }
166
+
167
+ tasks.push(normalizeTaskRecord(entryPath, taskFile, dateParts));
168
+ continue;
169
+ }
170
+
171
+ stack.push(entryPath);
172
+ }
173
+ }
174
+
175
+ return tasks.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
176
+ }
177
+
178
+ function findTaskDirById(rootDir, taskId) {
179
+ if (!fs.existsSync(rootDir)) {
180
+ return null;
181
+ }
182
+
183
+ for (const yearEntry of fs.readdirSync(rootDir, { withFileTypes: true })) {
184
+ if (!yearEntry.isDirectory() || !/^\d{4}$/.test(yearEntry.name)) {
185
+ continue;
186
+ }
187
+
188
+ const yearDir = path.join(rootDir, yearEntry.name);
189
+ for (const monthEntry of fs.readdirSync(yearDir, { withFileTypes: true })) {
190
+ if (!monthEntry.isDirectory() || !/^\d{2}$/.test(monthEntry.name)) {
191
+ continue;
192
+ }
193
+
194
+ const monthDir = path.join(yearDir, monthEntry.name);
195
+ for (const dayEntry of fs.readdirSync(monthDir, { withFileTypes: true })) {
196
+ if (!dayEntry.isDirectory() || !/^\d{2}$/.test(dayEntry.name)) {
197
+ continue;
198
+ }
199
+
200
+ const candidate = path.join(monthDir, dayEntry.name, taskId);
201
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
202
+ return candidate;
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ return null;
209
+ }
210
+
211
+ function taskExistsInArchive(archiveDir, taskId) {
212
+ return findTaskDirById(archiveDir, taskId);
213
+ }
214
+
215
+ function formatManifestHeader(generatedAt) {
216
+ return [
217
+ '# Archive Manifest',
218
+ '',
219
+ '> Auto-generated by archive-tasks. Do not edit manually.',
220
+ `> Last updated: ${generatedAt}`,
221
+ ''
222
+ ];
223
+ }
224
+
225
+ function collectArchiveEntries(archiveDir) {
226
+ const entries = [];
227
+ if (!fs.existsSync(archiveDir)) {
228
+ return entries;
229
+ }
230
+
231
+ for (const yearEntry of fs.readdirSync(archiveDir, { withFileTypes: true })) {
232
+ if (!yearEntry.isDirectory() || !/^\d{4}$/.test(yearEntry.name)) {
233
+ continue;
234
+ }
235
+
236
+ const yearDir = path.join(archiveDir, yearEntry.name);
237
+ for (const monthEntry of fs.readdirSync(yearDir, { withFileTypes: true })) {
238
+ if (!monthEntry.isDirectory() || !/^\d{2}$/.test(monthEntry.name)) {
239
+ continue;
240
+ }
241
+
242
+ const monthDir = path.join(yearDir, monthEntry.name);
243
+ for (const dayEntry of fs.readdirSync(monthDir, { withFileTypes: true })) {
244
+ if (!dayEntry.isDirectory() || !/^\d{2}$/.test(dayEntry.name)) {
245
+ continue;
246
+ }
247
+
248
+ const dayDir = path.join(monthDir, dayEntry.name);
249
+ for (const taskEntry of fs.readdirSync(dayDir, { withFileTypes: true })) {
250
+ if (!taskEntry.isDirectory() || !TASK_ID_RE.test(taskEntry.name)) {
251
+ continue;
252
+ }
253
+
254
+ const taskDir = path.join(dayDir, taskEntry.name);
255
+ const taskFile = path.join(taskDir, 'task.md');
256
+ const relativePath = `${yearEntry.name}/${monthEntry.name}/${dayEntry.name}/${taskEntry.name}/`;
257
+ let title = taskEntry.name;
258
+ let type = 'unknown';
259
+ let completedAt = `${yearEntry.name}-${monthEntry.name}-${dayEntry.name}`;
260
+
261
+ if (fs.existsSync(taskFile)) {
262
+ const content = fs.readFileSync(taskFile, 'utf8');
263
+ title = extractTitle(content) || title;
264
+ type = extractField(content, 'type') || type;
265
+ completedAt = extractField(content, 'completed_at') || completedAt;
266
+ }
267
+
268
+ entries.push({
269
+ year: yearEntry.name,
270
+ month: monthEntry.name,
271
+ completedAt,
272
+ taskId: taskEntry.name,
273
+ title,
274
+ type,
275
+ relativePath
276
+ });
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ return entries;
283
+ }
284
+
285
+ function rebuildManifests(archiveDir) {
286
+ fs.mkdirSync(archiveDir, { recursive: true });
287
+ const entries = collectArchiveEntries(archiveDir);
288
+ const generatedAt = new Date().toISOString().slice(0, 19).replace('T', ' ');
289
+
290
+ removeManifestFiles(archiveDir);
291
+
292
+ const monthGroups = new Map();
293
+ const yearCounts = new Map();
294
+ const monthCounts = new Map();
295
+
296
+ for (const entry of entries) {
297
+ const monthKey = `${entry.year}\t${entry.month}`;
298
+ if (!monthGroups.has(monthKey)) {
299
+ monthGroups.set(monthKey, []);
300
+ }
301
+ monthGroups.get(monthKey).push(entry);
302
+ yearCounts.set(entry.year, (yearCounts.get(entry.year) || 0) + 1);
303
+ monthCounts.set(monthKey, (monthCounts.get(monthKey) || 0) + 1);
304
+ }
305
+
306
+ for (const [monthKey, monthEntries] of [...monthGroups.entries()].sort()) {
307
+ const [year, month] = monthKey.split('\t');
308
+ const monthManifestPath = path.join(archiveDir, year, month, 'manifest.md');
309
+ fs.mkdirSync(path.dirname(monthManifestPath), { recursive: true });
310
+
311
+ const sortedEntries = [...monthEntries].sort((left, right) => {
312
+ const completedCompare = right.completedAt.localeCompare(left.completedAt);
313
+ if (completedCompare !== 0) {
314
+ return completedCompare;
315
+ }
316
+ return right.taskId.localeCompare(left.taskId);
317
+ });
318
+
319
+ const lines = [
320
+ ...formatManifestHeader(generatedAt),
321
+ '| Task ID | Title | Type | Completed | Path |',
322
+ '| --- | --- | --- | --- | --- |'
323
+ ];
324
+
325
+ for (const entry of sortedEntries.slice(0, 1000)) {
326
+ lines.push(
327
+ `| ${entry.taskId} | ${entry.title} | ${entry.type} | ${entry.completedAt} | ${entry.relativePath} |`
328
+ );
329
+ }
330
+
331
+ if (sortedEntries.length > 1000) {
332
+ lines.push('', `> Showing 1000 of ${sortedEntries.length} entries.`);
333
+ }
334
+
335
+ fs.writeFileSync(monthManifestPath, `${lines.join('\n')}\n`, 'utf8');
336
+ }
337
+
338
+ for (const year of [...yearCounts.keys()].sort().reverse()) {
339
+ const yearManifestPath = path.join(archiveDir, year, 'manifest.md');
340
+ fs.mkdirSync(path.dirname(yearManifestPath), { recursive: true });
341
+
342
+ const lines = [
343
+ ...formatManifestHeader(generatedAt),
344
+ '| Month | Tasks | Manifest |',
345
+ '| --- | --- | --- |'
346
+ ];
347
+
348
+ for (const month of [...monthGroups.keys()]
349
+ .filter((key) => key.startsWith(`${year}\t`))
350
+ .map((key) => key.split('\t')[1])
351
+ .sort()
352
+ .reverse()) {
353
+ lines.push(
354
+ `| ${month} | ${monthCounts.get(`${year}\t${month}`)} | [${month}/manifest.md](${month}/manifest.md) |`
355
+ );
356
+ }
357
+
358
+ fs.writeFileSync(yearManifestPath, `${lines.join('\n')}\n`, 'utf8');
359
+ }
360
+
361
+ const rootLines = [
362
+ ...formatManifestHeader(generatedAt),
363
+ '| Year | Tasks | Manifest |',
364
+ '| --- | --- | --- |'
365
+ ];
366
+
367
+ for (const year of [...yearCounts.keys()].sort().reverse()) {
368
+ rootLines.push(
369
+ `| ${year} | ${yearCounts.get(year)} | [${year}/manifest.md](${year}/manifest.md) |`
370
+ );
371
+ }
372
+
373
+ fs.writeFileSync(path.join(archiveDir, 'manifest.md'), `${rootLines.join('\n')}\n`, 'utf8');
374
+ }
375
+
376
+ function removeManifestFiles(rootDir) {
377
+ if (!fs.existsSync(rootDir)) {
378
+ return;
379
+ }
380
+
381
+ for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
382
+ const entryPath = path.join(rootDir, entry.name);
383
+ if (entry.isDirectory()) {
384
+ if (entry.name.startsWith('TASK-')) {
385
+ continue;
386
+ }
387
+ removeManifestFiles(entryPath);
388
+ continue;
389
+ }
390
+
391
+ if (entry.isFile() && entry.name === 'manifest.md') {
392
+ fs.rmSync(entryPath, { force: true });
393
+ }
394
+ }
395
+ }
396
+
397
+ async function cmdMerge(args) {
398
+ const sourcePath = args[0];
399
+ if (!sourcePath) {
400
+ throw new Error('Usage: agent-infra merge <source-path>');
401
+ }
402
+
403
+ const resolvedSource = path.resolve(sourcePath);
404
+ if (!fs.existsSync(resolvedSource)) {
405
+ throw new Error(`Source path does not exist: ${sourcePath}`);
406
+ }
407
+
408
+ if (!fs.statSync(resolvedSource).isDirectory()) {
409
+ throw new Error(`Source path is not a directory: ${sourcePath}`);
410
+ }
411
+
412
+ const archiveDir = path.join(process.cwd(), '.agents', 'workspace', 'archive');
413
+ const sourceTasks = scanSourceTasks(resolvedSource);
414
+ const merged = [];
415
+ const skipped = [];
416
+
417
+ fs.mkdirSync(archiveDir, { recursive: true });
418
+
419
+ for (const task of sourceTasks) {
420
+ const existingTaskDir = taskExistsInArchive(archiveDir, task.taskId);
421
+ if (existingTaskDir) {
422
+ skipped.push({
423
+ taskId: task.taskId,
424
+ relativePath: path.relative(archiveDir, existingTaskDir).split(path.sep).join('/') + '/'
425
+ });
426
+ continue;
427
+ }
428
+
429
+ const destinationDir = path.join(archiveDir, task.relativePath);
430
+ fs.mkdirSync(path.dirname(destinationDir), { recursive: true });
431
+ fs.cpSync(task.taskDir, destinationDir, { recursive: true });
432
+ merged.push({
433
+ taskId: task.taskId,
434
+ relativePath: task.relativePath
435
+ });
436
+ }
437
+
438
+ rebuildManifests(archiveDir);
439
+
440
+ if (sourceTasks.length === 0) {
441
+ info(`No archived tasks found in ${sourcePath}`);
442
+ }
443
+
444
+ for (const task of merged) {
445
+ ok(`Merged ${task.taskId} -> ${task.relativePath}`);
446
+ }
447
+
448
+ for (const task of skipped) {
449
+ info(`Skipped ${task.taskId} (already exists at ${task.relativePath})`);
450
+ }
451
+
452
+ process.stdout.write('\n');
453
+ info('Merge summary');
454
+ info(`- Merged: ${merged.length}`);
455
+ info(`- Skipped: ${skipped.length}`);
456
+ }
457
+
458
+ export {
459
+ cmdMerge,
460
+ extractField,
461
+ extractTitle,
462
+ rebuildManifests,
463
+ scanSourceTasks,
464
+ taskExistsInArchive
465
+ };