@fitlab-ai/agent-infra 0.7.3 → 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.
Files changed (41) hide show
  1. package/README.md +32 -790
  2. package/README.zh-CN.md +32 -763
  3. package/bin/cli.ts +1 -1
  4. package/dist/bin/cli.js +1 -1
  5. package/dist/lib/sandbox/commands/create.js +44 -3
  6. package/dist/lib/sandbox/commands/rm.js +99 -19
  7. package/dist/lib/sandbox/index.js +3 -1
  8. package/dist/lib/sandbox/readme-scaffold.js +6 -6
  9. package/dist/lib/task/artifacts.js +58 -0
  10. package/dist/lib/task/commands/cat.js +38 -0
  11. package/dist/lib/task/commands/files.js +47 -0
  12. package/dist/lib/task/commands/grep.js +143 -0
  13. package/dist/lib/task/commands/log.js +75 -0
  14. package/dist/lib/task/commands/show.js +5 -114
  15. package/dist/lib/task/commands/status.js +239 -0
  16. package/dist/lib/task/index.js +37 -0
  17. package/dist/lib/task/resolve-ref.js +150 -0
  18. package/lib/sandbox/commands/create.ts +47 -4
  19. package/lib/sandbox/commands/rm.ts +128 -19
  20. package/lib/sandbox/index.ts +3 -1
  21. package/lib/sandbox/readme-scaffold.ts +6 -6
  22. package/lib/task/artifacts.ts +72 -0
  23. package/lib/task/commands/cat.ts +39 -0
  24. package/lib/task/commands/files.ts +53 -0
  25. package/lib/task/commands/grep.ts +147 -0
  26. package/lib/task/commands/log.ts +80 -0
  27. package/lib/task/commands/show.ts +5 -117
  28. package/lib/task/commands/status.ts +302 -0
  29. package/lib/task/index.ts +37 -0
  30. package/lib/task/resolve-ref.ts +160 -0
  31. package/package.json +1 -1
  32. package/templates/.agents/README.en.md +1 -0
  33. package/templates/.agents/README.zh-CN.md +1 -0
  34. package/templates/.agents/rules/README.en.md +41 -0
  35. package/templates/.agents/rules/README.zh-CN.md +40 -0
  36. package/templates/.agents/rules/debugging-guide.en.md +25 -0
  37. package/templates/.agents/rules/debugging-guide.zh-CN.md +25 -0
  38. package/templates/.agents/skills/code-task/SKILL.en.md +2 -0
  39. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +2 -0
  40. package/templates/.agents/skills/watch-pr/SKILL.en.md +1 -1
  41. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +1 -1
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs';
2
+ import { formatTable } from '../../table.ts';
3
+ import { resolveTaskRef } from '../resolve-ref.ts';
4
+
5
+ const USAGE = `Usage: ai task log <N | #N | TASK-id>
6
+
7
+ Renders a task's activity log as a chronological timeline table.
8
+ <ref> Bare numeric / '#N' short id, or a full TASK-YYYYMMDD-HHMMSS id.
9
+
10
+ Columns: # (timeline position) / TIME / STEP / AGENT / NOTE
11
+ `;
12
+
13
+ const TABLE_HEADERS = ['#', 'TIME', 'STEP', 'AGENT', 'NOTE'] as const;
14
+
15
+ // The activity-log H2 heading is language-dependent (zh template / en template).
16
+ const HEADING_RE = /^##\s+(活动日志|Activity Log)\s*$/;
17
+ const NEXT_H2_RE = /^##\s/;
18
+ // `- {time} — **{step}** by {agent} — {note}` ; the separator is an em-dash
19
+ // (U+2014). STEP/AGENT are non-greedy so a note that itself contains ' — ' or
20
+ // '→' is not mis-split; NOTE greedily takes the rest of the line.
21
+ const ENTRY_RE =
22
+ /^- (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}) — \*\*(.+?)\*\* by (.+?) — (.*)$/;
23
+
24
+ type LogEntry = { time: string; step: string; agent: string; note: string };
25
+
26
+ function parseActivityLog(content: string): { sectionFound: boolean; entries: LogEntry[] } {
27
+ const lines = content.split('\n');
28
+ let i = 0;
29
+ while (i < lines.length && !HEADING_RE.test(lines[i]!)) i += 1;
30
+ if (i >= lines.length) return { sectionFound: false, entries: [] };
31
+ const parsed: { entry: LogEntry; epoch: number; order: number }[] = [];
32
+ for (let j = i + 1; j < lines.length; j += 1) {
33
+ if (NEXT_H2_RE.test(lines[j]!)) break;
34
+ const m = ENTRY_RE.exec(lines[j]!);
35
+ if (!m) continue; // skip blank / non-entry / malformed lines
36
+ parsed.push({
37
+ entry: { time: m[1]!, step: m[2]!, agent: m[3]!, note: m[4]! },
38
+ epoch: Date.parse(m[1]!.replace(' ', 'T')),
39
+ order: parsed.length
40
+ });
41
+ }
42
+ // Ascending by time; stable tie-break on original order for equal timestamps.
43
+ parsed.sort((a, b) => a.epoch - b.epoch || a.order - b.order);
44
+ return { sectionFound: true, entries: parsed.map((p) => p.entry) };
45
+ }
46
+
47
+ function log(args: string[] = []): void {
48
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
49
+ process.stdout.write(USAGE);
50
+ if (args.length === 0) process.exitCode = 1;
51
+ return;
52
+ }
53
+ const resolved = resolveTaskRef(args[0]!);
54
+ if (!resolved.ok) {
55
+ process.stderr.write(`ai task log: ${resolved.message}\n`);
56
+ process.exitCode = 1;
57
+ return;
58
+ }
59
+ const content = fs.readFileSync(resolved.taskMdPath, 'utf8');
60
+ const { sectionFound, entries } = parseActivityLog(content);
61
+ if (!sectionFound) {
62
+ process.stderr.write(
63
+ `ai task log: no activity log section ('## 活动日志' or '## Activity Log') found in task ${resolved.taskId}\n`
64
+ );
65
+ process.exitCode = 1;
66
+ return;
67
+ }
68
+ if (entries.length === 0) {
69
+ process.stderr.write(`ai task log: no activity log entries found in task ${resolved.taskId}\n`);
70
+ process.exitCode = 1;
71
+ return;
72
+ }
73
+ const rows = entries.map((e, idx) => [String(idx + 1), e.time, e.step, e.agent, e.note]);
74
+ for (const line of formatTable(TABLE_HEADERS, rows, { zebra: Boolean(process.stdout.isTTY) })) {
75
+ process.stdout.write(`${line}\n`);
76
+ }
77
+ process.stdout.write(`Total: ${entries.length} entries\n`);
78
+ }
79
+
80
+ export { log, parseActivityLog };
@@ -1,7 +1,5 @@
1
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';
2
+ import { resolveTaskRef } from '../resolve-ref.ts';
5
3
 
6
4
  const USAGE = `Usage: ai task show <N | #N | TASK-id>
7
5
 
@@ -11,129 +9,19 @@ Prints the task.md content for the matching task.
11
9
  TASK-YYYYMMDD-HHMMSS Locates a task in active / blocked / completed / archive.
12
10
  `;
13
11
 
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
12
  function show(args: string[] = []): void {
94
13
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
95
14
  process.stdout.write(USAGE);
96
15
  if (args.length === 0) process.exitCode = 1;
97
16
  return;
98
17
  }
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
- );
18
+ const resolved = resolveTaskRef(args[0]!);
19
+ if (!resolved.ok) {
20
+ process.stderr.write(`ai task show: ${resolved.message}\n`);
133
21
  process.exitCode = 1;
134
22
  return;
135
23
  }
136
- process.stdout.write(fs.readFileSync(taskMdPath, 'utf8'));
24
+ process.stdout.write(fs.readFileSync(resolved.taskMdPath, 'utf8'));
137
25
  }
138
26
 
139
27
  export { show };
@@ -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`);