@fitlab-ai/agent-infra 0.7.2 → 0.7.4
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 +35 -787
- package/README.zh-CN.md +37 -762
- package/bin/cli.ts +1 -1
- package/dist/bin/cli.js +1 -1
- package/dist/lib/defaults.json +0 -1
- package/dist/lib/init.js +0 -3
- package/dist/lib/sandbox/commands/create.js +44 -3
- package/dist/lib/sandbox/commands/enter.js +13 -15
- package/dist/lib/sandbox/commands/list-running.js +36 -1
- package/dist/lib/sandbox/commands/ls.js +9 -4
- package/dist/lib/sandbox/commands/rm.js +99 -19
- package/dist/lib/sandbox/commands/start.js +36 -0
- package/dist/lib/sandbox/index.js +11 -1
- package/dist/lib/sandbox/readme-scaffold.js +6 -6
- package/dist/lib/table.js +11 -2
- package/dist/lib/task/artifacts.js +58 -0
- package/dist/lib/task/commands/cat.js +38 -0
- package/dist/lib/task/commands/files.js +47 -0
- package/dist/lib/task/commands/grep.js +143 -0
- package/dist/lib/task/commands/log.js +75 -0
- package/dist/lib/task/commands/ls.js +1 -1
- package/dist/lib/task/commands/show.js +5 -114
- package/dist/lib/task/commands/status.js +239 -0
- package/dist/lib/task/index.js +37 -0
- package/dist/lib/task/resolve-ref.js +150 -0
- package/dist/lib/task/short-id.js +10 -0
- package/dist/lib/update.js +25 -8
- package/lib/defaults.json +0 -1
- package/lib/init.ts +0 -10
- package/lib/sandbox/commands/create.ts +47 -4
- package/lib/sandbox/commands/enter.ts +33 -14
- package/lib/sandbox/commands/list-running.ts +43 -1
- package/lib/sandbox/commands/ls.ts +12 -4
- package/lib/sandbox/commands/rm.ts +128 -19
- package/lib/sandbox/commands/start.ts +61 -0
- package/lib/sandbox/index.ts +11 -1
- package/lib/sandbox/readme-scaffold.ts +6 -6
- package/lib/table.ts +14 -2
- package/lib/task/artifacts.ts +72 -0
- package/lib/task/commands/cat.ts +39 -0
- package/lib/task/commands/files.ts +53 -0
- package/lib/task/commands/grep.ts +147 -0
- package/lib/task/commands/log.ts +80 -0
- package/lib/task/commands/ls.ts +1 -1
- package/lib/task/commands/show.ts +5 -117
- package/lib/task/commands/status.ts +302 -0
- package/lib/task/index.ts +37 -0
- package/lib/task/resolve-ref.ts +160 -0
- package/lib/task/short-id.ts +10 -0
- package/lib/update.ts +28 -10
- package/package.json +1 -1
- package/templates/.agents/README.en.md +1 -0
- package/templates/.agents/README.zh-CN.md +1 -0
- package/templates/.agents/hooks/auto-resume.sh +21 -4
- package/templates/.agents/rules/README.en.md +41 -0
- package/templates/.agents/rules/README.zh-CN.md +40 -0
- package/templates/.agents/rules/debugging-guide.en.md +25 -0
- package/templates/.agents/rules/debugging-guide.zh-CN.md +25 -0
- package/templates/.agents/rules/next-step-output.en.md +6 -3
- package/templates/.agents/rules/next-step-output.zh-CN.md +6 -3
- package/templates/.agents/rules/pr-checks-commands.en.md +5 -0
- package/templates/.agents/rules/pr-checks-commands.github.en.md +62 -0
- package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +62 -0
- package/templates/.agents/rules/pr-checks-commands.zh-CN.md +5 -0
- package/templates/.agents/rules/pr-sync.github.en.md +7 -0
- package/templates/.agents/rules/pr-sync.github.zh-CN.md +7 -0
- package/templates/.agents/skills/analyze-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/block-task/SKILL.en.md +8 -1
- package/templates/.agents/skills/block-task/SKILL.zh-CN.md +8 -1
- package/templates/.agents/skills/cancel-task/SKILL.en.md +8 -1
- package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +8 -1
- package/templates/.agents/skills/check-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/check-task/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/close-codescan/SKILL.en.md +8 -1
- package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +8 -1
- package/templates/.agents/skills/close-dependabot/SKILL.en.md +8 -1
- package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +8 -1
- package/templates/.agents/skills/code-task/SKILL.en.md +3 -1
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +3 -1
- package/templates/.agents/skills/commit/SKILL.en.md +2 -3
- package/templates/.agents/skills/commit/SKILL.zh-CN.md +2 -3
- package/templates/.agents/skills/commit/reference/task-status-update.en.md +31 -23
- package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +31 -23
- package/templates/.agents/skills/complete-task/SKILL.en.md +36 -3
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +36 -3
- package/templates/.agents/skills/create-pr/SKILL.en.md +16 -7
- package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +16 -7
- package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +1 -0
- package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +1 -0
- package/templates/.agents/skills/create-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/import-codescan/SKILL.en.md +1 -1
- package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/import-dependabot/SKILL.en.md +1 -1
- package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/import-issue/SKILL.en.md +1 -1
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/plan-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/review-analysis/SKILL.en.md +1 -1
- package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/review-code/SKILL.en.md +1 -1
- package/templates/.agents/skills/review-code/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/review-plan/SKILL.en.md +1 -1
- package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +0 -1
- package/templates/.agents/skills/watch-pr/SKILL.en.md +131 -0
- package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +131 -0
- package/templates/.agents/skills/watch-pr/config/verify.json +22 -0
- package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +43 -0
- package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +43 -0
- package/templates/.agents/templates/task.en.md +1 -0
- package/templates/.agents/templates/task.zh-CN.md +1 -0
- package/templates/.agents/workflows/bug-fix.en.yaml +6 -4
- package/templates/.agents/workflows/bug-fix.zh-CN.yaml +5 -4
- package/templates/.agents/workflows/feature-development.en.yaml +6 -4
- package/templates/.agents/workflows/feature-development.zh-CN.yaml +5 -4
- package/templates/.agents/workflows/refactoring.en.yaml +6 -4
- package/templates/.agents/workflows/refactoring.zh-CN.yaml +5 -4
- package/templates/.claude/commands/watch-pr.en.md +8 -0
- package/templates/.claude/commands/watch-pr.zh-CN.md +8 -0
- package/templates/.gemini/commands/_project_/watch-pr.en.toml +8 -0
- package/templates/.gemini/commands/_project_/watch-pr.zh-CN.toml +8 -0
- package/templates/.opencode/commands/watch-pr.en.md +11 -0
- package/templates/.opencode/commands/watch-pr.zh-CN.md +11 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { resolveTaskRef } from '../resolve-ref.ts';
|
|
4
|
+
import { enumerateArtifacts, type Artifact } from '../artifacts.ts';
|
|
5
|
+
import { parseTaskFrontmatter, extractTitle, type Frontmatter } from '../frontmatter.ts';
|
|
6
|
+
import { loadShortIdByTaskId } from '../short-id.ts';
|
|
7
|
+
|
|
8
|
+
const USAGE = `Usage: ai task status <N | #N | TASK-id>
|
|
9
|
+
|
|
10
|
+
Prints an aggregated "health check" view for a task: header, metadata, an
|
|
11
|
+
artifacts summary, git branch state, and best-effort GitHub issue/PR status.
|
|
12
|
+
<ref> Bare numeric / '#N' short id, or a full TASK-YYYYMMDD-HHMMSS id.
|
|
13
|
+
|
|
14
|
+
Git and Platform rows are best-effort: a failed git/gh call degrades that row to
|
|
15
|
+
'-' without failing the command.
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const DASH = '-';
|
|
19
|
+
|
|
20
|
+
// Subprocess boundary: the single place this command shells out. Injectable so
|
|
21
|
+
// the collectors below can be unit-tested without spawning git/gh. Returns the
|
|
22
|
+
// command's stdout; throws (like execFileSync) on a non-zero exit or spawn error.
|
|
23
|
+
type Runner = (file: string, args: string[]) => string;
|
|
24
|
+
|
|
25
|
+
function makeRunner(cwd: string): Runner {
|
|
26
|
+
return (file, args) =>
|
|
27
|
+
execFileSync(file, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Run `run` and swallow any failure into null, so a single failing git/gh call
|
|
31
|
+
// degrades only its own field instead of aborting the whole view.
|
|
32
|
+
function tryRun(run: Runner, file: string, args: string[]): string | null {
|
|
33
|
+
try {
|
|
34
|
+
return run(file, args);
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Frontmatter keys shown in the Metadata section, in a fixed display order.
|
|
41
|
+
const METADATA_KEYS = [
|
|
42
|
+
'type',
|
|
43
|
+
'status',
|
|
44
|
+
'current_step',
|
|
45
|
+
'priority',
|
|
46
|
+
'effort',
|
|
47
|
+
'branch',
|
|
48
|
+
'assigned_to',
|
|
49
|
+
'created_at',
|
|
50
|
+
'updated_at',
|
|
51
|
+
'issue_number',
|
|
52
|
+
'pr_status'
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
function collectMetadata(fm: Frontmatter): [string, string][] {
|
|
56
|
+
return METADATA_KEYS.map((key) => [key, fm[key] ? fm[key]! : DASH]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Workflow stages in timeline order; artifacts are bucketed by filename prefix.
|
|
60
|
+
// `review-*` prefixes are matched before their bare counterparts so that, e.g.,
|
|
61
|
+
// `review-analysis.md` is never swallowed by the `analysis` bucket.
|
|
62
|
+
const STAGE_ORDER = [
|
|
63
|
+
'analysis',
|
|
64
|
+
'review-analysis',
|
|
65
|
+
'plan',
|
|
66
|
+
'review-plan',
|
|
67
|
+
'code',
|
|
68
|
+
'review-code',
|
|
69
|
+
'task',
|
|
70
|
+
'other'
|
|
71
|
+
] as const;
|
|
72
|
+
|
|
73
|
+
function stageOf(name: string): string {
|
|
74
|
+
const stem = name.replace(/\.md$/, '');
|
|
75
|
+
if (stem === 'task') return 'task';
|
|
76
|
+
if (stem.startsWith('review-analysis')) return 'review-analysis';
|
|
77
|
+
if (stem.startsWith('review-plan')) return 'review-plan';
|
|
78
|
+
if (stem.startsWith('review-code')) return 'review-code';
|
|
79
|
+
if (stem.startsWith('analysis')) return 'analysis';
|
|
80
|
+
if (stem.startsWith('plan')) return 'plan';
|
|
81
|
+
if (stem.startsWith('code')) return 'code';
|
|
82
|
+
return 'other';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Group artifacts by workflow stage, preserving the input order (mtime ascending
|
|
86
|
+
// from enumerateArtifacts) within each stage and dropping empty stages.
|
|
87
|
+
function groupArtifacts(artifacts: Artifact[]): { stage: string; files: string[] }[] {
|
|
88
|
+
const byStage = new Map<string, string[]>();
|
|
89
|
+
for (const artifact of artifacts) {
|
|
90
|
+
const stage = stageOf(artifact.name);
|
|
91
|
+
const bucket = byStage.get(stage);
|
|
92
|
+
if (bucket) bucket.push(artifact.name);
|
|
93
|
+
else byStage.set(stage, [artifact.name]);
|
|
94
|
+
}
|
|
95
|
+
const groups: { stage: string; files: string[] }[] = [];
|
|
96
|
+
for (const stage of STAGE_ORDER) {
|
|
97
|
+
const files = byStage.get(stage);
|
|
98
|
+
if (files && files.length > 0) groups.push({ stage, files });
|
|
99
|
+
}
|
|
100
|
+
return groups;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type GitInfo = {
|
|
104
|
+
current: string;
|
|
105
|
+
frontmatter: string;
|
|
106
|
+
match: string;
|
|
107
|
+
exists: string;
|
|
108
|
+
uncommitted: string;
|
|
109
|
+
aheadBehind: string;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// `frontmatterBranch` is the task.md `branch` field (caller passes '' when absent).
|
|
113
|
+
// It is read straight from frontmatter and never depends on a subprocess, so it
|
|
114
|
+
// keeps its value even when every git call fails. All other fields degrade to '-'
|
|
115
|
+
// on failure of their own command.
|
|
116
|
+
function collectGit(frontmatterBranch: string, run: Runner): GitInfo {
|
|
117
|
+
const frontmatter = frontmatterBranch ? frontmatterBranch : DASH;
|
|
118
|
+
|
|
119
|
+
let current = DASH;
|
|
120
|
+
const cur = tryRun(run, 'git', ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
121
|
+
if (cur !== null && cur.trim()) current = cur.trim();
|
|
122
|
+
|
|
123
|
+
let match = DASH;
|
|
124
|
+
if (current !== DASH && frontmatter !== DASH) {
|
|
125
|
+
match = current === frontmatter ? 'yes' : 'no';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let exists = DASH;
|
|
129
|
+
if (frontmatter !== DASH) {
|
|
130
|
+
const verified = tryRun(run, 'git', ['rev-parse', '--verify', '--quiet', `refs/heads/${frontmatter}`]);
|
|
131
|
+
exists = verified === null ? 'no' : 'yes';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let uncommitted = DASH;
|
|
135
|
+
const porcelain = tryRun(run, 'git', ['status', '--porcelain']);
|
|
136
|
+
if (porcelain !== null) {
|
|
137
|
+
const changed = porcelain.split('\n').filter((line) => line.trim() !== '');
|
|
138
|
+
uncommitted = changed.length === 0 ? 'clean' : `${changed.length} file(s)`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let aheadBehind = DASH;
|
|
142
|
+
if (frontmatter !== DASH) {
|
|
143
|
+
const counts = tryRun(run, 'git', [
|
|
144
|
+
'rev-list',
|
|
145
|
+
'--left-right',
|
|
146
|
+
'--count',
|
|
147
|
+
`${frontmatter}...${frontmatter}@{upstream}`
|
|
148
|
+
]);
|
|
149
|
+
if (counts !== null) {
|
|
150
|
+
const parts = counts.trim().split(/\s+/);
|
|
151
|
+
if (parts.length === 2) aheadBehind = `${parts[0]} ahead / ${parts[1]} behind`;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { current, frontmatter, match, exists, uncommitted, aheadBehind };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
type PlatformInfo = { issue: string; pr: string };
|
|
159
|
+
|
|
160
|
+
function collectPlatform(fm: Frontmatter, run: Runner): PlatformInfo {
|
|
161
|
+
let issue = DASH;
|
|
162
|
+
if (fm.issue_number && /^\d+$/.test(fm.issue_number)) {
|
|
163
|
+
const out = tryRun(run, 'gh', ['issue', 'view', fm.issue_number, '--json', 'state,labels']);
|
|
164
|
+
if (out !== null) {
|
|
165
|
+
try {
|
|
166
|
+
const data = JSON.parse(out);
|
|
167
|
+
const labels = Array.isArray(data.labels)
|
|
168
|
+
? data.labels.map((label: { name: string }) => label.name).join(', ')
|
|
169
|
+
: '';
|
|
170
|
+
issue = labels ? `${data.state} [${labels}]` : `${data.state}`;
|
|
171
|
+
} catch {
|
|
172
|
+
issue = DASH;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let pr = DASH;
|
|
178
|
+
if (fm.pr_status === 'created' && fm.pr_number && /^\d+$/.test(fm.pr_number)) {
|
|
179
|
+
const out = tryRun(run, 'gh', ['pr', 'view', fm.pr_number, '--json', 'state,statusCheckRollup']);
|
|
180
|
+
if (out !== null) {
|
|
181
|
+
try {
|
|
182
|
+
const data = JSON.parse(out);
|
|
183
|
+
const rollup = Array.isArray(data.statusCheckRollup) ? data.statusCheckRollup : [];
|
|
184
|
+
const passed = rollup.filter(
|
|
185
|
+
(check: { conclusion?: string; state?: string }) =>
|
|
186
|
+
check.conclusion === 'SUCCESS' || check.state === 'SUCCESS'
|
|
187
|
+
).length;
|
|
188
|
+
pr = rollup.length > 0 ? `${data.state}, checks: ${passed}/${rollup.length}` : `${data.state}`;
|
|
189
|
+
} catch {
|
|
190
|
+
pr = DASH;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { issue, pr };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
type StatusModel = {
|
|
199
|
+
taskId: string;
|
|
200
|
+
shortId: string;
|
|
201
|
+
title: string;
|
|
202
|
+
issueNumber: string;
|
|
203
|
+
metadata: [string, string][];
|
|
204
|
+
artifacts: { count: number; groups: { stage: string; files: string[] }[] };
|
|
205
|
+
git: GitInfo;
|
|
206
|
+
platform: PlatformInfo;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Indent each label/value pair by two spaces and pad labels to a common width so
|
|
210
|
+
// every section reads as an aligned "key value" block.
|
|
211
|
+
function renderPairs(rows: [string, string][]): string[] {
|
|
212
|
+
const width = rows.reduce((max, [label]) => Math.max(max, label.length), 0);
|
|
213
|
+
return rows.map(([label, value]) => ` ${label.padEnd(width)} ${value}`.trimEnd());
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function renderStatus(model: StatusModel): string[] {
|
|
217
|
+
const lines: string[] = [];
|
|
218
|
+
|
|
219
|
+
lines.push(`Task ${model.taskId} (${model.shortId})`);
|
|
220
|
+
if (model.title) lines.push(model.title);
|
|
221
|
+
|
|
222
|
+
lines.push('', 'Metadata', ...renderPairs(model.metadata));
|
|
223
|
+
|
|
224
|
+
lines.push('', `Artifacts (${model.artifacts.count})`);
|
|
225
|
+
if (model.artifacts.groups.length === 0) {
|
|
226
|
+
lines.push(' (none)');
|
|
227
|
+
} else {
|
|
228
|
+
lines.push(...renderPairs(model.artifacts.groups.map((group) => [group.stage, group.files.join(', ')])));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
lines.push(
|
|
232
|
+
'',
|
|
233
|
+
'Git',
|
|
234
|
+
...renderPairs([
|
|
235
|
+
['current', model.git.current],
|
|
236
|
+
['frontmatter', model.git.frontmatter],
|
|
237
|
+
['match', model.git.match],
|
|
238
|
+
['exists', model.git.exists],
|
|
239
|
+
['uncommitted', model.git.uncommitted],
|
|
240
|
+
['ahead/behind', model.git.aheadBehind]
|
|
241
|
+
])
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const issueLabel = model.issueNumber ? `issue #${model.issueNumber}` : 'issue';
|
|
245
|
+
lines.push(
|
|
246
|
+
'',
|
|
247
|
+
'Platform',
|
|
248
|
+
...renderPairs([
|
|
249
|
+
[issueLabel, model.platform.issue],
|
|
250
|
+
['pr', model.platform.pr]
|
|
251
|
+
])
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
return lines;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function status(args: string[] = []): void {
|
|
258
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
259
|
+
process.stdout.write(USAGE);
|
|
260
|
+
if (args.length === 0) process.exitCode = 1;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const resolved = resolveTaskRef(args[0]!);
|
|
265
|
+
if (!resolved.ok) {
|
|
266
|
+
process.stderr.write(`ai task status: ${resolved.message}\n`);
|
|
267
|
+
process.exitCode = 1;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const content = fs.readFileSync(resolved.taskMdPath, 'utf8');
|
|
272
|
+
const fm = parseTaskFrontmatter(content);
|
|
273
|
+
const run = makeRunner(resolved.repoRoot);
|
|
274
|
+
const artifacts = enumerateArtifacts(resolved.taskDir);
|
|
275
|
+
|
|
276
|
+
const model: StatusModel = {
|
|
277
|
+
taskId: resolved.taskId,
|
|
278
|
+
shortId: loadShortIdByTaskId(resolved.repoRoot).get(resolved.taskId) ?? DASH,
|
|
279
|
+
title: extractTitle(content),
|
|
280
|
+
issueNumber: fm.issue_number && /^\d+$/.test(fm.issue_number) ? fm.issue_number : '',
|
|
281
|
+
metadata: collectMetadata(fm),
|
|
282
|
+
artifacts: { count: artifacts.length, groups: groupArtifacts(artifacts) },
|
|
283
|
+
git: collectGit(fm.branch ?? '', run),
|
|
284
|
+
platform: collectPlatform(fm, run)
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
for (const line of renderStatus(model)) {
|
|
288
|
+
process.stdout.write(`${line}\n`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export {
|
|
293
|
+
status,
|
|
294
|
+
makeRunner,
|
|
295
|
+
collectMetadata,
|
|
296
|
+
groupArtifacts,
|
|
297
|
+
collectGit,
|
|
298
|
+
collectPlatform,
|
|
299
|
+
renderStatus,
|
|
300
|
+
METADATA_KEYS
|
|
301
|
+
};
|
|
302
|
+
export type { Runner, GitInfo, PlatformInfo, StatusModel };
|
package/lib/task/index.ts
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
const USAGE = `Usage: ai task <command> [options]
|
|
2
2
|
|
|
3
3
|
Commands:
|
|
4
|
+
cat <ref> <artifact | N> Print a task artifact (by name or number)
|
|
5
|
+
files <ref> List artifacts in a task dir (numbered)
|
|
6
|
+
grep <pattern> [ref] [artifact | N] Literal search across task artifacts (omit ref to scan all)
|
|
7
|
+
log <ref> Render a task's activity log as a timeline
|
|
4
8
|
ls [--all | --blocked | --completed] List tasks (default: active)
|
|
5
9
|
show <N | #N | TASK-id> Print a task.md
|
|
10
|
+
status <ref> Aggregated status view (metadata / artifacts / git / platform)
|
|
6
11
|
|
|
7
12
|
Examples:
|
|
13
|
+
ai task cat 11 analysis
|
|
14
|
+
ai task cat 11 3
|
|
15
|
+
ai task files 11
|
|
16
|
+
ai task grep resolveArtifact
|
|
17
|
+
ai task grep resolveArtifact 11
|
|
18
|
+
ai task log 11
|
|
8
19
|
ai task ls
|
|
9
20
|
ai task show 11
|
|
10
21
|
ai task show TASK-20260612-162737
|
|
22
|
+
ai task status 11
|
|
11
23
|
|
|
12
24
|
Run 'ai task <command> --help' for details.`;
|
|
13
25
|
|
|
@@ -36,6 +48,31 @@ export async function runTask(args: string[]): Promise<void> {
|
|
|
36
48
|
show(rest);
|
|
37
49
|
break;
|
|
38
50
|
}
|
|
51
|
+
case 'files': {
|
|
52
|
+
const { files } = await import('./commands/files.ts');
|
|
53
|
+
files(rest);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
case 'cat': {
|
|
57
|
+
const { cat } = await import('./commands/cat.ts');
|
|
58
|
+
cat(rest);
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
case 'grep': {
|
|
62
|
+
const { grep } = await import('./commands/grep.ts');
|
|
63
|
+
grep(rest);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
case 'log': {
|
|
67
|
+
const { log } = await import('./commands/log.ts');
|
|
68
|
+
log(rest);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
case 'status': {
|
|
72
|
+
const { status } = await import('./commands/status.ts');
|
|
73
|
+
status(rest);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
39
76
|
default:
|
|
40
77
|
process.stderr.write(`Unknown task command: ${subcommand}\n\n`);
|
|
41
78
|
process.stdout.write(`${USAGE}\n`);
|
|
@@ -0,0 +1,160 @@
|
|
|
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 TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
|
|
7
|
+
// Flat-structured workspace dirs that hold tasks under `{dir}/{taskId}/task.md`.
|
|
8
|
+
// Note: `archive` uses a three-level YYYY/MM/DD layout and is handled separately.
|
|
9
|
+
const FLAT_WORKSPACE_DIRS = ['active', 'blocked', 'completed'] as const;
|
|
10
|
+
|
|
11
|
+
type ResolveRefResult =
|
|
12
|
+
| {
|
|
13
|
+
ok: true;
|
|
14
|
+
repoRoot: string;
|
|
15
|
+
taskId: string;
|
|
16
|
+
taskDir: string;
|
|
17
|
+
taskMdPath: string;
|
|
18
|
+
}
|
|
19
|
+
| { ok: false; message: string };
|
|
20
|
+
|
|
21
|
+
function detectRepoRoot(): string {
|
|
22
|
+
try {
|
|
23
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
26
|
+
}).trim();
|
|
27
|
+
} catch {
|
|
28
|
+
throw new Error('ai task: current directory is not inside a git repository');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readShortIdLength(repoRoot: string): number {
|
|
33
|
+
try {
|
|
34
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8'));
|
|
35
|
+
const v = cfg?.task?.shortIdLength;
|
|
36
|
+
if (typeof v === 'number' && Number.isFinite(v) && v >= 1) return v;
|
|
37
|
+
} catch {
|
|
38
|
+
// fall through to default
|
|
39
|
+
}
|
|
40
|
+
return 2;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveShortIdToTaskId(arg: string, repoRoot: string): string {
|
|
44
|
+
const scriptPath = path.join(repoRoot, '.agents', 'scripts', 'task-short-id.js');
|
|
45
|
+
if (!fs.existsSync(scriptPath)) {
|
|
46
|
+
throw new Error(`task-short-id.js not found at ${scriptPath}`);
|
|
47
|
+
}
|
|
48
|
+
const result = spawnSync('node', [scriptPath, 'resolve', arg], {
|
|
49
|
+
encoding: 'utf8',
|
|
50
|
+
cwd: repoRoot
|
|
51
|
+
});
|
|
52
|
+
if (result.status !== 0) {
|
|
53
|
+
throw new Error((result.stderr || '').trim() || `failed to resolve '${arg}'`);
|
|
54
|
+
}
|
|
55
|
+
return result.stdout.trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function listSortedNumeric(dir: string, width: number): string[] {
|
|
59
|
+
if (!fs.existsSync(dir)) return [];
|
|
60
|
+
const pattern = new RegExp(`^\\d{${width}}$`);
|
|
61
|
+
return fs
|
|
62
|
+
.readdirSync(dir)
|
|
63
|
+
.filter((entry) => pattern.test(entry))
|
|
64
|
+
.sort()
|
|
65
|
+
.reverse();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findInArchive(repoRoot: string, taskId: string): string | null {
|
|
69
|
+
// archive-tasks SKILL writes to .agents/workspace/archive/YYYY/MM/DD/{taskId}/task.md
|
|
70
|
+
// where YYYY/MM/DD comes from completed_at (or updated_at fallback) — NOT from
|
|
71
|
+
// the task id's creation date. So we cannot derive the path from taskId alone;
|
|
72
|
+
// walk the bounded YYYY/MM/DD tree instead. Newest-first to favor recent archives.
|
|
73
|
+
const archiveDir = path.join(repoRoot, '.agents', 'workspace', 'archive');
|
|
74
|
+
for (const year of listSortedNumeric(archiveDir, 4)) {
|
|
75
|
+
const yearDir = path.join(archiveDir, year);
|
|
76
|
+
for (const month of listSortedNumeric(yearDir, 2)) {
|
|
77
|
+
const monthDir = path.join(yearDir, month);
|
|
78
|
+
for (const day of listSortedNumeric(monthDir, 2)) {
|
|
79
|
+
const candidate = path.join(monthDir, day, taskId, 'task.md');
|
|
80
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function findTaskMd(repoRoot: string, taskId: string): string | null {
|
|
88
|
+
for (const sub of FLAT_WORKSPACE_DIRS) {
|
|
89
|
+
const candidate = path.join(repoRoot, '.agents', 'workspace', sub, taskId, 'task.md');
|
|
90
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
91
|
+
}
|
|
92
|
+
return findInArchive(repoRoot, taskId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Enumerate every task directory under the flat workspace states
|
|
97
|
+
* (active / blocked / completed) — archive is intentionally excluded so a
|
|
98
|
+
* full-tree scan never pulls in cold data. Ordered by state, then task id
|
|
99
|
+
* ascending, giving callers a deterministic traversal.
|
|
100
|
+
*/
|
|
101
|
+
function enumerateTaskDirs(repoRoot: string): { taskId: string; taskDir: string }[] {
|
|
102
|
+
const out: { taskId: string; taskDir: string }[] = [];
|
|
103
|
+
for (const sub of FLAT_WORKSPACE_DIRS) {
|
|
104
|
+
const base = path.join(repoRoot, '.agents', 'workspace', sub);
|
|
105
|
+
if (!fs.existsSync(base)) continue;
|
|
106
|
+
for (const entry of fs.readdirSync(base).sort()) {
|
|
107
|
+
if (!TASK_ID_RE.test(entry)) continue;
|
|
108
|
+
const taskDir = path.join(base, entry);
|
|
109
|
+
if (!fs.existsSync(path.join(taskDir, 'task.md'))) continue;
|
|
110
|
+
out.push({ taskId: entry, taskDir });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Resolve a task ref (bare short id, `#N`, or `TASK-YYYYMMDD-HHMMSS`) to its
|
|
118
|
+
* task directory across active / blocked / completed / archive.
|
|
119
|
+
*
|
|
120
|
+
* The returned `message` on failure is command-agnostic (no `ai task <cmd>:`
|
|
121
|
+
* prefix); callers prepend their own prefix so each command keeps its existing
|
|
122
|
+
* stderr wording byte-for-byte.
|
|
123
|
+
*/
|
|
124
|
+
function resolveTaskRef(arg: string): ResolveRefResult {
|
|
125
|
+
const repoRoot = detectRepoRoot();
|
|
126
|
+
let taskId: string;
|
|
127
|
+
if (TASK_ID_RE.test(arg)) {
|
|
128
|
+
taskId = arg;
|
|
129
|
+
} else {
|
|
130
|
+
const shortIdLength = readShortIdLength(repoRoot);
|
|
131
|
+
const normalized = normalizeShortIdInput(arg, { shortIdLength });
|
|
132
|
+
if (normalized.kind === 'error') {
|
|
133
|
+
return { ok: false, message: normalized.message };
|
|
134
|
+
}
|
|
135
|
+
if (normalized.kind === 'pass') {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
message:
|
|
139
|
+
`'${arg}' is not a valid short id or TASK-id; ` +
|
|
140
|
+
`expected bare digits, '#N', or 'TASK-YYYYMMDD-HHMMSS'`
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
taskId = resolveShortIdToTaskId(normalized.value, repoRoot);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
return { ok: false, message: (e as Error).message };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const taskMdPath = findTaskMd(repoRoot, taskId);
|
|
150
|
+
if (!taskMdPath) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
message: `task ${taskId} not found in active / blocked / completed / archive`
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return { ok: true, repoRoot, taskId, taskDir: path.dirname(taskMdPath), taskMdPath };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export { resolveTaskRef, detectRepoRoot, enumerateTaskDirs, TASK_ID_RE };
|
|
160
|
+
export type { ResolveRefResult };
|
package/lib/task/short-id.ts
CHANGED
|
@@ -70,6 +70,16 @@ function loadShortIdByTaskId(repoRoot: string): Map<string, string> {
|
|
|
70
70
|
return map;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Resolve a branch to its active-task short id (`#NN`), or `null` when no
|
|
75
|
+
* active task is bound to that branch.
|
|
76
|
+
*
|
|
77
|
+
* Two-state semantics: this only consults the active registry
|
|
78
|
+
* (`active/.short-ids.json`) plus each `active/{taskId}/task.md`. Tasks moved
|
|
79
|
+
* to completed/blocked/cancelled/archive have already released their short id,
|
|
80
|
+
* so their branches return `null` — in `ai sandbox ls` that surfaces as `-`,
|
|
81
|
+
* meaning the sandbox is free to remove.
|
|
82
|
+
*/
|
|
73
83
|
function lookupShortIdByBranch(
|
|
74
84
|
branch: string,
|
|
75
85
|
repoRoot: string,
|
package/lib/update.ts
CHANGED
|
@@ -17,7 +17,8 @@ type UpdateConfig = {
|
|
|
17
17
|
org: string;
|
|
18
18
|
language: string;
|
|
19
19
|
platform?: { type?: string };
|
|
20
|
-
requiresPullRequest?: boolean;
|
|
20
|
+
requiresPullRequest?: boolean; // legacy field; read-only, migrated to prFlow then removed
|
|
21
|
+
prFlow?: 'required' | 'disabled';
|
|
21
22
|
sandbox?: Record<string, unknown>;
|
|
22
23
|
task?: { shortIdLength: number };
|
|
23
24
|
labels?: Record<string, unknown>;
|
|
@@ -27,7 +28,6 @@ type UpdateConfig = {
|
|
|
27
28
|
|
|
28
29
|
type Defaults = {
|
|
29
30
|
platform: { type: string };
|
|
30
|
-
requiresPullRequest: boolean;
|
|
31
31
|
sandbox: Record<string, unknown>;
|
|
32
32
|
task: { shortIdLength: number };
|
|
33
33
|
labels: Record<string, unknown>;
|
|
@@ -41,6 +41,25 @@ const defaults = JSON.parse(
|
|
|
41
41
|
const CONFIG_DIR = '.agents';
|
|
42
42
|
const CONFIG_PATH = path.join(CONFIG_DIR, '.airc.json');
|
|
43
43
|
|
|
44
|
+
// One-time migration of the legacy project-level PR switch to the three-state
|
|
45
|
+
// `prFlow` preference. `true` (the old default / "PR flow on") maps to the
|
|
46
|
+
// strong constraint `required`; `false` maps to `disabled`. A missing or
|
|
47
|
+
// already-migrated config is left untouched (idempotent). Returns the new
|
|
48
|
+
// prFlow value when a migration happened, otherwise null.
|
|
49
|
+
function migratePrFlow(config: UpdateConfig): 'required' | 'disabled' | null {
|
|
50
|
+
if (config.requiresPullRequest === true) {
|
|
51
|
+
delete config.requiresPullRequest;
|
|
52
|
+
config.prFlow = 'required';
|
|
53
|
+
return 'required';
|
|
54
|
+
}
|
|
55
|
+
if (config.requiresPullRequest === false) {
|
|
56
|
+
delete config.requiresPullRequest;
|
|
57
|
+
config.prFlow = 'disabled';
|
|
58
|
+
return 'disabled';
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
44
63
|
function isPathOwnedByOtherPlatform(relativePath: string, platformType: string): boolean {
|
|
45
64
|
const top = String(relativePath || '').replace(/\\/g, '/').replace(/^\.\//, '').split('/')[0] ?? '';
|
|
46
65
|
if (!top.startsWith('.')) return false;
|
|
@@ -195,7 +214,7 @@ async function cmdUpdate(): Promise<void> {
|
|
|
195
214
|
const sandboxAdded = !config.sandbox;
|
|
196
215
|
const taskAdded = !config.task;
|
|
197
216
|
const labelsAdded = !config.labels;
|
|
198
|
-
const
|
|
217
|
+
const prFlowMigrated = migratePrFlow(config);
|
|
199
218
|
let configChanged = changed;
|
|
200
219
|
|
|
201
220
|
if (platformAdded) {
|
|
@@ -218,8 +237,7 @@ async function cmdUpdate(): Promise<void> {
|
|
|
218
237
|
configChanged = true;
|
|
219
238
|
}
|
|
220
239
|
|
|
221
|
-
if (
|
|
222
|
-
config.requiresPullRequest = defaults.requiresPullRequest;
|
|
240
|
+
if (prFlowMigrated) {
|
|
223
241
|
configChanged = true;
|
|
224
242
|
}
|
|
225
243
|
|
|
@@ -233,7 +251,7 @@ async function cmdUpdate(): Promise<void> {
|
|
|
233
251
|
for (const entry of added.merged) {
|
|
234
252
|
ok(` merged: ${entry}`);
|
|
235
253
|
}
|
|
236
|
-
} else if (platformAdded || sandboxAdded || taskAdded || labelsAdded ||
|
|
254
|
+
} else if (platformAdded || sandboxAdded || taskAdded || labelsAdded || prFlowMigrated) {
|
|
237
255
|
if (platformAdded) {
|
|
238
256
|
info(`Default platform config added to ${CONFIG_PATH}.`);
|
|
239
257
|
}
|
|
@@ -246,8 +264,8 @@ async function cmdUpdate(): Promise<void> {
|
|
|
246
264
|
if (labelsAdded) {
|
|
247
265
|
info(`Default labels.in config added to ${CONFIG_PATH}.`);
|
|
248
266
|
}
|
|
249
|
-
if (
|
|
250
|
-
info(`
|
|
267
|
+
if (prFlowMigrated) {
|
|
268
|
+
info(`Migrated legacy requiresPullRequest to prFlow="${prFlowMigrated}" in ${CONFIG_PATH}.`);
|
|
251
269
|
}
|
|
252
270
|
} else {
|
|
253
271
|
info(`File registry changed in ${CONFIG_PATH}.`);
|
|
@@ -264,8 +282,8 @@ async function cmdUpdate(): Promise<void> {
|
|
|
264
282
|
if (hasNewEntries && platformAdded) {
|
|
265
283
|
info(`Default platform config added to ${CONFIG_PATH}.`);
|
|
266
284
|
}
|
|
267
|
-
if (hasNewEntries &&
|
|
268
|
-
info(`
|
|
285
|
+
if (hasNewEntries && prFlowMigrated) {
|
|
286
|
+
info(`Migrated legacy requiresPullRequest to prFlow="${prFlowMigrated}" in ${CONFIG_PATH}.`);
|
|
269
287
|
}
|
|
270
288
|
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
271
289
|
ok(`Updated ${CONFIG_PATH}`);
|
package/package.json
CHANGED
|
@@ -34,6 +34,7 @@ This dual-config approach ensures every AI tool receives appropriate project con
|
|
|
34
34
|
bug-fix.yaml # Bug fix workflow
|
|
35
35
|
code-review.yaml # Code review workflow
|
|
36
36
|
refactoring.yaml # Refactoring workflow
|
|
37
|
+
rules/ # Collaboration rule index (see rules/README.md)
|
|
37
38
|
workspace/ # Runtime workspace (git-ignored)
|
|
38
39
|
active/ # Currently active tasks
|
|
39
40
|
blocked/ # Blocked tasks
|
|
@@ -80,8 +80,25 @@ until curl -s -o /dev/null --max-time 3 "$PROBE_URL"; do
|
|
|
80
80
|
done
|
|
81
81
|
log "probe ok after ${waited}s (error=$error)"
|
|
82
82
|
|
|
83
|
-
# Inject
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
# Inject the resume message with a deliberately timing-insensitive sequence:
|
|
84
|
+
# 1. Escape leaves any non-input TUI state.
|
|
85
|
+
# 2. A 1s settle covers every known TUI escape timeout (vim 1000ms,
|
|
86
|
+
# xterm/readline 50ms) so the next bytes are delivered as fresh input
|
|
87
|
+
# instead of being folded into the escape sequence (the dropped-`U` race).
|
|
88
|
+
# 3. The text travels through a NAMED paste buffer pasted with bracketed
|
|
89
|
+
# paste (-p): the TUI ingests it as a single paste rather than per-character
|
|
90
|
+
# keypresses, so no leading char is eaten and the body is not read as a
|
|
91
|
+
# submit. The named buffer (-b) guarantees we paste exactly this text, and
|
|
92
|
+
# -d deletes it afterward so the user's anonymous paste stack is untouched.
|
|
93
|
+
# 4. Enter is a separate send-keys after the paste, so the submit signal is
|
|
94
|
+
# never merged into the pasted content (the must-press-Enter race).
|
|
95
|
+
# Every step stays non-blocking (2>/dev/null, exit 0 below) and logs a WARN on
|
|
96
|
+
# failure so the log can localize which tmux step broke.
|
|
97
|
+
log "tmux inject start (error=$error)"
|
|
98
|
+
tmux send-keys -t "$TMUX_PANE" Escape 2>/dev/null || log "WARN: tmux Escape failed (error=$error)"
|
|
99
|
+
sleep 1
|
|
100
|
+
tmux set-buffer -b auto-resume -- "$RESUME_TEXT" 2>/dev/null || log "WARN: tmux set-buffer failed (error=$error)"
|
|
101
|
+
tmux paste-buffer -t "$TMUX_PANE" -b auto-resume -p -d 2>/dev/null || log "WARN: tmux paste-buffer failed (error=$error)"
|
|
102
|
+
tmux send-keys -t "$TMUX_PANE" Enter 2>/dev/null || log "WARN: tmux Enter failed (error=$error)"
|
|
103
|
+
log "tmux inject done (error=$error)"
|
|
87
104
|
exit 0
|