@fitlab-ai/agent-infra 0.4.5 → 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.
- package/README.md +16 -2
- package/README.zh-CN.md +16 -2
- package/bin/cli.js +19 -0
- package/lib/defaults.json +17 -0
- package/lib/init.js +1 -0
- package/lib/log.js +5 -10
- package/lib/merge.js +465 -0
- package/lib/sandbox/commands/create.js +1047 -0
- package/lib/sandbox/commands/enter.js +31 -0
- package/lib/sandbox/commands/ls.js +70 -0
- package/lib/sandbox/commands/rebuild.js +102 -0
- package/lib/sandbox/commands/rm.js +211 -0
- package/lib/sandbox/commands/vm.js +101 -0
- package/lib/sandbox/config.js +79 -0
- package/lib/sandbox/constants.js +113 -0
- package/lib/sandbox/dockerfile.js +95 -0
- package/lib/sandbox/engine.js +93 -0
- package/lib/sandbox/index.js +64 -0
- package/lib/sandbox/runtimes/ai-tools.dockerfile +26 -0
- package/lib/sandbox/runtimes/base.dockerfile +30 -0
- package/lib/sandbox/runtimes/java17.dockerfile +3 -0
- package/lib/sandbox/runtimes/java21.dockerfile +3 -0
- package/lib/sandbox/runtimes/node20.dockerfile +3 -0
- package/lib/sandbox/runtimes/node22.dockerfile +3 -0
- package/lib/sandbox/runtimes/python3.dockerfile +3 -0
- package/lib/sandbox/shell.js +48 -0
- package/lib/sandbox/task-resolver.js +35 -0
- package/lib/sandbox/tools.js +131 -0
- package/lib/update.js +16 -2
- package/package.json +5 -1
- package/templates/.agents/scripts/validate-artifact.js +40 -0
- package/templates/.agents/skills/archive-tasks/SKILL.md +6 -3
- package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +6 -3
- package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +91 -8
- package/templates/.agents/skills/create-task/SKILL.md +6 -0
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +6 -0
- package/templates/.agents/skills/create-task/config/verify.json +1 -0
- package/templates/.agents/skills/import-issue/SKILL.md +2 -0
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +2 -0
- package/templates/.agents/skills/import-issue/config/verify.json +1 -0
- package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +18 -1
- package/templates/.agents/templates/task.md +5 -4
- 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.
|
|
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
|
|
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.
|
|
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
package/lib/log.js
CHANGED
|
@@ -1,27 +1,22 @@
|
|
|
1
|
-
|
|
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(` ${
|
|
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(` ${
|
|
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(` ${
|
|
15
|
+
process.stderr.write(` ${pc.bold(pc.red('\u2717'))} ${msg}\n`);
|
|
21
16
|
}
|
|
22
17
|
|
|
23
18
|
function ask(text) {
|
|
24
|
-
process.stdout.write(` ${
|
|
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
|
+
};
|