@fitlab-ai/agent-infra 0.7.0 → 0.7.2

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 (156) hide show
  1. package/bin/cli.ts +12 -1
  2. package/dist/bin/cli.js +13 -1
  3. package/dist/lib/builtin-tuis.js +45 -0
  4. package/dist/lib/defaults.json +3 -0
  5. package/dist/lib/init.js +62 -23
  6. package/dist/lib/prompt.js +49 -1
  7. package/dist/lib/sandbox/commands/create.js +10 -2
  8. package/dist/lib/sandbox/commands/enter.js +8 -7
  9. package/dist/lib/sandbox/commands/list-running.js +62 -28
  10. package/dist/lib/sandbox/commands/ls.js +20 -22
  11. package/dist/lib/sandbox/commands/rebuild.js +3 -11
  12. package/dist/lib/sandbox/commands/rm.js +2 -0
  13. package/dist/lib/sandbox/image-prune.js +18 -0
  14. package/dist/lib/sandbox/index.js +7 -3
  15. package/dist/lib/sandbox/task-resolver.js +18 -0
  16. package/dist/lib/sandbox/tools.js +1 -1
  17. package/dist/lib/table.js +29 -0
  18. package/dist/lib/task/commands/ls.js +122 -0
  19. package/dist/lib/task/commands/show.js +135 -0
  20. package/dist/lib/task/frontmatter.js +32 -0
  21. package/dist/lib/task/index.js +41 -0
  22. package/dist/lib/task/short-id.js +80 -0
  23. package/dist/lib/update.js +59 -18
  24. package/lib/builtin-tuis.ts +55 -0
  25. package/lib/defaults.json +3 -0
  26. package/lib/init.ts +87 -35
  27. package/lib/prompt.ts +54 -1
  28. package/lib/sandbox/commands/create.ts +11 -2
  29. package/lib/sandbox/commands/enter.ts +8 -7
  30. package/lib/sandbox/commands/list-running.ts +70 -31
  31. package/lib/sandbox/commands/ls.ts +25 -25
  32. package/lib/sandbox/commands/rebuild.ts +3 -12
  33. package/lib/sandbox/commands/rm.ts +3 -0
  34. package/lib/sandbox/image-prune.ts +23 -0
  35. package/lib/sandbox/index.ts +7 -3
  36. package/lib/sandbox/task-resolver.ts +23 -1
  37. package/lib/sandbox/tools.ts +1 -1
  38. package/lib/table.ts +32 -0
  39. package/lib/task/commands/ls.ts +138 -0
  40. package/lib/task/commands/show.ts +139 -0
  41. package/lib/task/frontmatter.ts +30 -0
  42. package/lib/task/index.ts +44 -0
  43. package/lib/task/short-id.ts +97 -0
  44. package/lib/update.ts +71 -30
  45. package/package.json +1 -1
  46. package/templates/.agents/README.en.md +32 -0
  47. package/templates/.agents/README.zh-CN.md +32 -0
  48. package/templates/.agents/hooks/auto-resume.sh +87 -0
  49. package/templates/.agents/rules/create-issue.github.en.md +1 -1
  50. package/templates/.agents/rules/create-issue.github.zh-CN.md +1 -1
  51. package/templates/.agents/rules/milestone-inference.github.en.md +4 -1
  52. package/templates/.agents/rules/milestone-inference.github.zh-CN.md +4 -1
  53. package/templates/.agents/rules/next-step-output.en.md +59 -0
  54. package/templates/.agents/rules/next-step-output.zh-CN.md +59 -0
  55. package/templates/.agents/rules/task-short-id.en.md +133 -0
  56. package/templates/.agents/rules/task-short-id.zh-CN.md +105 -0
  57. package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +17 -0
  58. package/templates/.agents/scripts/task-short-id.js +556 -0
  59. package/templates/.agents/skills/analyze-task/SKILL.en.md +13 -11
  60. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +13 -12
  61. package/templates/.agents/skills/analyze-task/config/verify.en.json +1 -1
  62. package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +1 -1
  63. package/templates/.agents/skills/block-task/SKILL.en.md +17 -5
  64. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +17 -6
  65. package/templates/.agents/skills/block-task/config/verify.json +1 -1
  66. package/templates/.agents/skills/cancel-task/SKILL.en.md +17 -5
  67. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +17 -6
  68. package/templates/.agents/skills/cancel-task/config/verify.json +1 -1
  69. package/templates/.agents/skills/check-task/SKILL.en.md +15 -9
  70. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +15 -10
  71. package/templates/.agents/skills/close-codescan/SKILL.en.md +16 -5
  72. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +16 -5
  73. package/templates/.agents/skills/close-dependabot/SKILL.en.md +16 -5
  74. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +16 -5
  75. package/templates/.agents/skills/code-task/SKILL.en.md +13 -5
  76. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +14 -6
  77. package/templates/.agents/skills/code-task/config/verify.en.json +2 -1
  78. package/templates/.agents/skills/code-task/config/verify.zh-CN.json +2 -1
  79. package/templates/.agents/skills/code-task/reference/fix-mode.en.md +10 -5
  80. package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +10 -5
  81. package/templates/.agents/skills/code-task/reference/output-template.en.md +3 -3
  82. package/templates/.agents/skills/code-task/reference/output-template.zh-CN.md +3 -3
  83. package/templates/.agents/skills/code-task/reference/report-template.en.md +8 -0
  84. package/templates/.agents/skills/code-task/reference/report-template.zh-CN.md +8 -0
  85. package/templates/.agents/skills/commit/SKILL.en.md +5 -1
  86. package/templates/.agents/skills/commit/SKILL.zh-CN.md +5 -1
  87. package/templates/.agents/skills/commit/reference/task-status-update.en.md +9 -9
  88. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +9 -9
  89. package/templates/.agents/skills/complete-task/SKILL.en.md +17 -1
  90. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +17 -2
  91. package/templates/.agents/skills/complete-task/config/verify.en.json +1 -1
  92. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +1 -1
  93. package/templates/.agents/skills/create-pr/SKILL.en.md +9 -5
  94. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +9 -5
  95. package/templates/.agents/skills/create-pr/config/verify.json +2 -1
  96. package/templates/.agents/skills/create-pr/reference/comment-publish.en.md +1 -1
  97. package/templates/.agents/skills/create-pr/reference/comment-publish.zh-CN.md +1 -1
  98. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +3 -3
  99. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +3 -3
  100. package/templates/.agents/skills/create-task/SKILL.en.md +29 -15
  101. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +29 -16
  102. package/templates/.agents/skills/create-task/config/verify.json +1 -1
  103. package/templates/.agents/skills/import-codescan/SKILL.en.md +20 -6
  104. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +20 -6
  105. package/templates/.agents/skills/import-codescan/config/verify.json +1 -1
  106. package/templates/.agents/skills/import-dependabot/SKILL.en.md +20 -6
  107. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +20 -6
  108. package/templates/.agents/skills/import-dependabot/config/verify.json +1 -1
  109. package/templates/.agents/skills/import-issue/SKILL.en.md +19 -5
  110. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +19 -5
  111. package/templates/.agents/skills/plan-task/SKILL.en.md +13 -11
  112. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +13 -12
  113. package/templates/.agents/skills/plan-task/config/verify.en.json +1 -1
  114. package/templates/.agents/skills/plan-task/config/verify.zh-CN.json +1 -1
  115. package/templates/.agents/skills/restore-task/SKILL.en.md +12 -0
  116. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +12 -1
  117. package/templates/.agents/skills/review-analysis/SKILL.en.md +7 -1
  118. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +7 -2
  119. package/templates/.agents/skills/review-analysis/config/verify.en.json +3 -2
  120. package/templates/.agents/skills/review-analysis/config/verify.zh-CN.json +3 -2
  121. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +15 -15
  122. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +15 -15
  123. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +7 -1
  124. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +7 -1
  125. package/templates/.agents/skills/review-analysis/reference/review-criteria.en.md +2 -0
  126. package/templates/.agents/skills/review-analysis/reference/review-criteria.zh-CN.md +2 -0
  127. package/templates/.agents/skills/review-code/SKILL.en.md +8 -1
  128. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +8 -2
  129. package/templates/.agents/skills/review-code/config/verify.en.json +3 -2
  130. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +3 -2
  131. package/templates/.agents/skills/review-code/reference/output-templates.en.md +9 -9
  132. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +9 -9
  133. package/templates/.agents/skills/review-code/reference/report-template.en.md +7 -1
  134. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +7 -1
  135. package/templates/.agents/skills/review-code/reference/review-criteria.en.md +2 -0
  136. package/templates/.agents/skills/review-code/reference/review-criteria.zh-CN.md +2 -0
  137. package/templates/.agents/skills/review-plan/SKILL.en.md +7 -1
  138. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +7 -2
  139. package/templates/.agents/skills/review-plan/config/verify.en.json +3 -2
  140. package/templates/.agents/skills/review-plan/config/verify.zh-CN.json +3 -2
  141. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +15 -15
  142. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +15 -15
  143. package/templates/.agents/skills/review-plan/reference/report-template.en.md +7 -1
  144. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +7 -1
  145. package/templates/.agents/skills/review-plan/reference/review-criteria.en.md +2 -0
  146. package/templates/.agents/skills/review-plan/reference/review-criteria.zh-CN.md +2 -0
  147. package/templates/.agents/skills/update-agent-infra/SKILL.en.md +1 -0
  148. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +1 -0
  149. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +112 -21
  150. package/templates/.agents/workflows/bug-fix.en.yaml +1 -1
  151. package/templates/.agents/workflows/bug-fix.zh-CN.yaml +1 -1
  152. package/templates/.agents/workflows/feature-development.en.yaml +1 -1
  153. package/templates/.agents/workflows/feature-development.zh-CN.yaml +1 -1
  154. package/templates/.agents/workflows/refactoring.en.yaml +1 -1
  155. package/templates/.agents/workflows/refactoring.zh-CN.yaml +1 -1
  156. package/templates/.claude/settings.json +11 -0
@@ -0,0 +1,23 @@
1
+ import * as p from '@clack/prompts';
2
+ import type { SandboxConfig } from './config.ts';
3
+ import { sandboxLabel } from './constants.ts';
4
+ import { runEngine } from './shell.ts';
5
+
6
+ export function pruneSandboxDanglingImages(
7
+ config: Pick<SandboxConfig, 'project'>,
8
+ engine: string
9
+ ): void {
10
+ try {
11
+ runEngine(engine, 'docker', [
12
+ 'image',
13
+ 'prune',
14
+ '-f',
15
+ '--filter',
16
+ `label=${sandboxLabel(config)}`
17
+ ]);
18
+ } catch {
19
+ p.log.warn(
20
+ `Failed to prune dangling sandbox images (label=${sandboxLabel(config)}); leaving them in place.`
21
+ );
22
+ }
23
+ }
@@ -2,9 +2,13 @@ const USAGE = `Usage: ai sandbox <command> [options]
2
2
 
3
3
  Commands:
4
4
  create <branch> [base] Create a sandbox (VM + image + worktree + container)
5
- exec <branch | '#N'> [cmd...]
6
- Enter sandbox or run a command (use leftmost '#' column from 'ls')
7
- ls List sandboxes for the current project
5
+ exec <branch | TASK-id | N | '#N'> [cmd...]
6
+ Enter sandbox or run a command. N (bare) is the
7
+ recommended form for task short ids (e.g.
8
+ 'ai sandbox exec 11'); '#N' is also accepted.
9
+ ls List sandboxes for the current project (the '#'
10
+ column is a display-only row number; the 'SHORT'
11
+ column shows the active task short id, '-' if none)
8
12
  prune [--dry-run] Remove orphaned per-branch state dirs
9
13
  rebuild [--quiet] [--refresh]
10
14
  Rebuild the sandbox image (--refresh pulls base + tools)
@@ -1,9 +1,27 @@
1
+ import { spawnSync } from 'node:child_process';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
 
4
5
  const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
6
+ const SHORT_ID_RE = /^#?\d+$/;
5
7
  const WORKSPACE_DIRS = ['active', 'completed', 'blocked', 'archive'];
6
8
 
9
+ function resolveShortIdStrict(arg: string, repoRoot: string): string {
10
+ const scriptPath = path.join(repoRoot, '.agents', 'scripts', 'task-short-id.js');
11
+ if (!fs.existsSync(scriptPath)) {
12
+ throw new Error(
13
+ `Short id '${arg}' provided but task-short-id.js script is missing at ${scriptPath}`
14
+ );
15
+ }
16
+ const result = spawnSync('node', [scriptPath, 'resolve', arg], { encoding: 'utf8', cwd: repoRoot });
17
+ if (result.status !== 0) {
18
+ throw new Error(
19
+ `Short id '${arg}' not found in active task registry: ${(result.stderr || '').trim()}`
20
+ );
21
+ }
22
+ return result.stdout.trim();
23
+ }
24
+
7
25
  function stripQuotes(value: string): string {
8
26
  return value.replace(/^(["'])(.*)\1$/, '$2');
9
27
  }
@@ -33,10 +51,14 @@ function resolveBranchFromTaskContent(content: string, taskId: string): string {
33
51
  }
34
52
 
35
53
  export function resolveTaskBranch(arg: string, repoRoot: string): string {
54
+ if (SHORT_ID_RE.test(arg)) {
55
+ const taskId = resolveShortIdStrict(arg, repoRoot);
56
+ const content = readTaskContent(repoRoot, taskId);
57
+ return resolveBranchFromTaskContent(content, taskId);
58
+ }
36
59
  if (!TASK_ID_RE.test(arg)) {
37
60
  return arg;
38
61
  }
39
-
40
62
  const content = readTaskContent(repoRoot, arg);
41
63
  return resolveBranchFromTaskContent(content, arg);
42
64
  }
@@ -35,7 +35,7 @@ function createBuiltinTools(home: string, project: string): Record<string, Sandb
35
35
  'claude-code': {
36
36
  id: 'claude-code',
37
37
  name: 'Claude Code',
38
- install: { type: 'npm', cmd: '@anthropic-ai/claude-code@stable' },
38
+ install: { type: 'npm', cmd: '@anthropic-ai/claude-code@latest' },
39
39
  sandboxBase: hostJoin(home, '.agent-infra', 'sandboxes', 'claude-code'),
40
40
  containerMount: '/home/devuser/.claude',
41
41
  versionCmd: 'claude --version',
package/lib/table.ts ADDED
@@ -0,0 +1,32 @@
1
+ function formatTable(
2
+ headers: readonly string[],
3
+ rows: readonly (readonly string[])[]
4
+ ): string[] {
5
+ const columnCount = headers.length;
6
+ const widths = headers.map((header, i) => {
7
+ const headerLen = header.length;
8
+ let max = headerLen;
9
+ for (const row of rows) {
10
+ const cell = row[i] ?? '';
11
+ if (cell.length > max) max = cell.length;
12
+ }
13
+ return max;
14
+ });
15
+
16
+ const renderRow = (values: readonly string[]): string => {
17
+ const parts: string[] = [];
18
+ for (let i = 0; i < columnCount; i += 1) {
19
+ const cell = values[i] ?? '';
20
+ if (i === columnCount - 1) {
21
+ parts.push(cell);
22
+ } else {
23
+ parts.push(cell.padEnd(widths[i]!));
24
+ }
25
+ }
26
+ return parts.join(' ').trimEnd();
27
+ };
28
+
29
+ return [renderRow(headers), ...rows.map((row) => renderRow(row))];
30
+ }
31
+
32
+ export { formatTable };
@@ -0,0 +1,138 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { formatTable } from '../../table.ts';
5
+ import { parseTaskFrontmatter, extractTitle } from '../frontmatter.ts';
6
+ import { loadShortIdByTaskId } from '../short-id.ts';
7
+
8
+ const USAGE = `Usage: ai task ls [--all | --blocked | --completed]
9
+
10
+ Lists tasks under .agents/workspace/. Defaults to active tasks only.
11
+ --all Include active + blocked + completed (excludes archive)
12
+ --blocked Only blocked tasks
13
+ --completed Only completed tasks
14
+
15
+ Columns: # (display-only row number) / SHORT (task short id, usable as an argument) / type / status / current_step / branch / title
16
+ `;
17
+
18
+ const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
19
+ const TABLE_HEADERS = ['#', 'SHORT', 'TYPE', 'STATUS', 'STEP', 'BRANCH', 'TITLE'] as const;
20
+
21
+ type Selection = ('active' | 'blocked' | 'completed')[];
22
+
23
+ function detectRepoRoot(): string {
24
+ try {
25
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], {
26
+ encoding: 'utf8',
27
+ stdio: ['pipe', 'pipe', 'pipe']
28
+ }).trim();
29
+ } catch {
30
+ throw new Error('ai task: current directory is not inside a git repository');
31
+ }
32
+ }
33
+
34
+ type ParseResult =
35
+ | { ok: true; selection: Selection }
36
+ | { ok: false; message: string };
37
+
38
+ function parseSelection(args: string[]): ParseResult {
39
+ const positional = args.filter((a) => !a.startsWith('--'));
40
+ if (positional.length > 0) {
41
+ return {
42
+ ok: false,
43
+ message: `ai task ls: unexpected positional argument(s): ${positional.join(' ')}`
44
+ };
45
+ }
46
+ const flags = args.filter((a) => a.startsWith('--'));
47
+ if (flags.length === 0) return { ok: true, selection: ['active'] };
48
+ if (flags.length > 1) {
49
+ return {
50
+ ok: false,
51
+ message: 'ai task ls: pass at most one of --all / --blocked / --completed'
52
+ };
53
+ }
54
+ switch (flags[0]) {
55
+ case '--all':
56
+ return { ok: true, selection: ['active', 'blocked', 'completed'] };
57
+ case '--blocked':
58
+ return { ok: true, selection: ['blocked'] };
59
+ case '--completed':
60
+ return { ok: true, selection: ['completed'] };
61
+ default:
62
+ return { ok: false, message: `ai task ls: unknown flag: ${flags[0]}` };
63
+ }
64
+ }
65
+
66
+ type TaskRow = {
67
+ shortId: string;
68
+ type: string;
69
+ status: string;
70
+ step: string;
71
+ branch: string;
72
+ title: string;
73
+ };
74
+
75
+ function collectTasks(repoRoot: string, state: 'active' | 'blocked' | 'completed'): TaskRow[] {
76
+ const dir = path.join(repoRoot, '.agents', 'workspace', state);
77
+ if (!fs.existsSync(dir)) return [];
78
+ // Short ids live only in the registry and only for active tasks; archived
79
+ // (blocked/completed) tasks have released their short id and render '-'.
80
+ const shortIdByTaskId = state === 'active' ? loadShortIdByTaskId(repoRoot) : new Map<string, string>();
81
+ const rows: TaskRow[] = [];
82
+ for (const entry of fs.readdirSync(dir).sort()) {
83
+ if (!TASK_ID_RE.test(entry)) continue;
84
+ const taskMdPath = path.join(dir, entry, 'task.md');
85
+ if (!fs.existsSync(taskMdPath)) continue;
86
+ const content = fs.readFileSync(taskMdPath, 'utf8');
87
+ const fm = parseTaskFrontmatter(content);
88
+ const title = extractTitle(content);
89
+ const shortId = shortIdByTaskId.get(entry) ?? '-';
90
+ rows.push({
91
+ shortId,
92
+ type: fm.type ?? '-',
93
+ status: fm.status ?? state,
94
+ step: fm.current_step ?? '-',
95
+ branch: fm.branch ?? '-',
96
+ title: title || fm.id || entry
97
+ });
98
+ }
99
+ return rows;
100
+ }
101
+
102
+ function ls(args: string[] = []): void {
103
+ if (args[0] === '--help' || args[0] === '-h') {
104
+ process.stdout.write(USAGE);
105
+ return;
106
+ }
107
+ const result = parseSelection(args);
108
+ if (!result.ok) {
109
+ process.stderr.write(`${result.message}\n`);
110
+ process.exitCode = 1;
111
+ return;
112
+ }
113
+ const { selection } = result;
114
+ const repoRoot = detectRepoRoot();
115
+ const rows: TaskRow[] = [];
116
+ for (const state of selection) {
117
+ rows.push(...collectTasks(repoRoot, state));
118
+ }
119
+ if (rows.length === 0) {
120
+ process.stdout.write(`No tasks under .agents/workspace/${selection.join('|')}\n`);
121
+ return;
122
+ }
123
+ const tableRows = rows.map((r, i) => [
124
+ String(i + 1),
125
+ r.shortId,
126
+ r.type,
127
+ r.status,
128
+ r.step,
129
+ r.branch,
130
+ r.title
131
+ ]);
132
+ for (const line of formatTable(TABLE_HEADERS, tableRows)) {
133
+ process.stdout.write(`${line}\n`);
134
+ }
135
+ process.stdout.write(`Total: ${rows.length} tasks\n`);
136
+ }
137
+
138
+ export { ls };
@@ -0,0 +1,139 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync, spawnSync } from 'node:child_process';
4
+ import { normalizeShortIdInput } from '../short-id.ts';
5
+
6
+ const USAGE = `Usage: ai task show <N | #N | TASK-id>
7
+
8
+ Prints the task.md content for the matching task.
9
+ N (bare numeric) Recommended; resolves the active short id via the registry.
10
+ '#N' Compatibility form for old commands.
11
+ TASK-YYYYMMDD-HHMMSS Locates a task in active / blocked / completed / archive.
12
+ `;
13
+
14
+ const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
15
+ // Flat-structured workspace dirs that hold tasks under `{dir}/{taskId}/task.md`.
16
+ // Note: `archive` uses a three-level YYYY/MM/DD layout and is handled separately.
17
+ const FLAT_WORKSPACE_DIRS = ['active', 'blocked', 'completed'] as const;
18
+
19
+ function detectRepoRoot(): string {
20
+ try {
21
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], {
22
+ encoding: 'utf8',
23
+ stdio: ['pipe', 'pipe', 'pipe']
24
+ }).trim();
25
+ } catch {
26
+ throw new Error('ai task: current directory is not inside a git repository');
27
+ }
28
+ }
29
+
30
+ function readShortIdLength(repoRoot: string): number {
31
+ try {
32
+ const cfg = JSON.parse(fs.readFileSync(path.join(repoRoot, '.agents', '.airc.json'), 'utf8'));
33
+ const v = cfg?.task?.shortIdLength;
34
+ if (typeof v === 'number' && Number.isFinite(v) && v >= 1) return v;
35
+ } catch {
36
+ // fall through to default
37
+ }
38
+ return 2;
39
+ }
40
+
41
+ function resolveShortIdToTaskId(arg: string, repoRoot: string): string {
42
+ const scriptPath = path.join(repoRoot, '.agents', 'scripts', 'task-short-id.js');
43
+ if (!fs.existsSync(scriptPath)) {
44
+ throw new Error(`task-short-id.js not found at ${scriptPath}`);
45
+ }
46
+ const result = spawnSync('node', [scriptPath, 'resolve', arg], {
47
+ encoding: 'utf8',
48
+ cwd: repoRoot
49
+ });
50
+ if (result.status !== 0) {
51
+ throw new Error((result.stderr || '').trim() || `failed to resolve '${arg}'`);
52
+ }
53
+ return result.stdout.trim();
54
+ }
55
+
56
+ function listSortedNumeric(dir: string, width: number): string[] {
57
+ if (!fs.existsSync(dir)) return [];
58
+ const pattern = new RegExp(`^\\d{${width}}$`);
59
+ return fs
60
+ .readdirSync(dir)
61
+ .filter((entry) => pattern.test(entry))
62
+ .sort()
63
+ .reverse();
64
+ }
65
+
66
+ function findInArchive(repoRoot: string, taskId: string): string | null {
67
+ // archive-tasks SKILL writes to .agents/workspace/archive/YYYY/MM/DD/{taskId}/task.md
68
+ // where YYYY/MM/DD comes from completed_at (or updated_at fallback) — NOT from
69
+ // the task id's creation date. So we cannot derive the path from taskId alone;
70
+ // walk the bounded YYYY/MM/DD tree instead. Newest-first to favor recent archives.
71
+ const archiveDir = path.join(repoRoot, '.agents', 'workspace', 'archive');
72
+ for (const year of listSortedNumeric(archiveDir, 4)) {
73
+ const yearDir = path.join(archiveDir, year);
74
+ for (const month of listSortedNumeric(yearDir, 2)) {
75
+ const monthDir = path.join(yearDir, month);
76
+ for (const day of listSortedNumeric(monthDir, 2)) {
77
+ const candidate = path.join(monthDir, day, taskId, 'task.md');
78
+ if (fs.existsSync(candidate)) return candidate;
79
+ }
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ function findTaskMd(repoRoot: string, taskId: string): string | null {
86
+ for (const sub of FLAT_WORKSPACE_DIRS) {
87
+ const candidate = path.join(repoRoot, '.agents', 'workspace', sub, taskId, 'task.md');
88
+ if (fs.existsSync(candidate)) return candidate;
89
+ }
90
+ return findInArchive(repoRoot, taskId);
91
+ }
92
+
93
+ function show(args: string[] = []): void {
94
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
95
+ process.stdout.write(USAGE);
96
+ if (args.length === 0) process.exitCode = 1;
97
+ return;
98
+ }
99
+ const repoRoot = detectRepoRoot();
100
+ const arg = args[0]!;
101
+ let taskId: string;
102
+ if (TASK_ID_RE.test(arg)) {
103
+ taskId = arg;
104
+ } else {
105
+ const shortIdLength = readShortIdLength(repoRoot);
106
+ const normalized = normalizeShortIdInput(arg, { shortIdLength });
107
+ if (normalized.kind === 'error') {
108
+ process.stderr.write(`ai task show: ${normalized.message}\n`);
109
+ process.exitCode = 1;
110
+ return;
111
+ }
112
+ if (normalized.kind === 'pass') {
113
+ process.stderr.write(
114
+ `ai task show: '${arg}' is not a valid short id or TASK-id; ` +
115
+ `expected bare digits, '#N', or 'TASK-YYYYMMDD-HHMMSS'\n`
116
+ );
117
+ process.exitCode = 1;
118
+ return;
119
+ }
120
+ try {
121
+ taskId = resolveShortIdToTaskId(normalized.value, repoRoot);
122
+ } catch (e) {
123
+ process.stderr.write(`ai task show: ${(e as Error).message}\n`);
124
+ process.exitCode = 1;
125
+ return;
126
+ }
127
+ }
128
+ const taskMdPath = findTaskMd(repoRoot, taskId);
129
+ if (!taskMdPath) {
130
+ process.stderr.write(
131
+ `ai task show: task ${taskId} not found in active / blocked / completed / archive\n`
132
+ );
133
+ process.exitCode = 1;
134
+ return;
135
+ }
136
+ process.stdout.write(fs.readFileSync(taskMdPath, 'utf8'));
137
+ }
138
+
139
+ export { show };
@@ -0,0 +1,30 @@
1
+ type Frontmatter = Record<string, string>;
2
+
3
+ function parseTaskFrontmatter(content: string): Frontmatter {
4
+ const result: Frontmatter = {};
5
+ if (!content.startsWith('---')) return result;
6
+ const end = content.indexOf('\n---', 3);
7
+ if (end === -1) return result;
8
+ const body = content.slice(3, end);
9
+ for (const rawLine of body.split('\n')) {
10
+ const line = rawLine.replace(/\r$/, '');
11
+ if (!line.trim()) continue;
12
+ const colon = line.indexOf(':');
13
+ if (colon === -1) continue;
14
+ const key = line.slice(0, colon).trim();
15
+ const value = line.slice(colon + 1).trim();
16
+ if (key) result[key] = value;
17
+ }
18
+ return result;
19
+ }
20
+
21
+ function extractTitle(content: string): string {
22
+ for (const line of content.split('\n')) {
23
+ const m = /^#\s+(?:任务[::]?\s*)?(.+)$/.exec(line.trim());
24
+ if (m && m[1]) return m[1].trim();
25
+ }
26
+ return '';
27
+ }
28
+
29
+ export { parseTaskFrontmatter, extractTitle };
30
+ export type { Frontmatter };
@@ -0,0 +1,44 @@
1
+ const USAGE = `Usage: ai task <command> [options]
2
+
3
+ Commands:
4
+ ls [--all | --blocked | --completed] List tasks (default: active)
5
+ show <N | #N | TASK-id> Print a task.md
6
+
7
+ Examples:
8
+ ai task ls
9
+ ai task show 11
10
+ ai task show TASK-20260612-162737
11
+
12
+ Run 'ai task <command> --help' for details.`;
13
+
14
+ export async function runTask(args: string[]): Promise<void> {
15
+ const [subcommand, ...rest] = args;
16
+
17
+ if (!subcommand) {
18
+ process.stdout.write(`${USAGE}\n`);
19
+ process.exitCode = 1;
20
+ return;
21
+ }
22
+
23
+ if (subcommand === '--help' || subcommand === '-h' || subcommand === 'help') {
24
+ process.stdout.write(`${USAGE}\n`);
25
+ return;
26
+ }
27
+
28
+ switch (subcommand) {
29
+ case 'ls': {
30
+ const { ls } = await import('./commands/ls.ts');
31
+ ls(rest);
32
+ break;
33
+ }
34
+ case 'show': {
35
+ const { show } = await import('./commands/show.ts');
36
+ show(rest);
37
+ break;
38
+ }
39
+ default:
40
+ process.stderr.write(`Unknown task command: ${subcommand}\n\n`);
41
+ process.stdout.write(`${USAGE}\n`);
42
+ process.exitCode = 1;
43
+ }
44
+ }
@@ -0,0 +1,97 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ const REGISTRY_NAME = '.short-ids.json';
5
+
6
+ type NormalizeResult =
7
+ | { kind: 'shortId'; value: string }
8
+ | { kind: 'pass'; value: string }
9
+ | { kind: 'error'; message: string };
10
+
11
+ type NormalizeOpts = { shortIdLength: number };
12
+
13
+ function normalizeShortIdInput(input: string, opts: NormalizeOpts): NormalizeResult {
14
+ const L = opts.shortIdLength;
15
+ const m = /^#?(\d+)$/.exec(input);
16
+ if (!m) {
17
+ return { kind: 'pass', value: input };
18
+ }
19
+ const n = Number(m[1]);
20
+ if (n === 0) {
21
+ return {
22
+ kind: 'error',
23
+ message: `short id '${input}' is invalid (#${'0'.repeat(L)} is reserved)`
24
+ };
25
+ }
26
+ const max = Math.pow(10, L) - 1;
27
+ if (n > max) {
28
+ return {
29
+ kind: 'error',
30
+ message: `short id ${n} exceeds shortIdLength=${L} capacity (max=${max}); archive tasks or raise task.shortIdLength in .agents/.airc.json`
31
+ };
32
+ }
33
+ return { kind: 'shortId', value: `#${String(n).padStart(L, '0')}` };
34
+ }
35
+
36
+ type RegistrySchema = {
37
+ version: number;
38
+ ids: Record<string, string>;
39
+ };
40
+
41
+ function readRegistry(repoRoot: string): RegistrySchema | null {
42
+ const registryPath = path.join(repoRoot, '.agents', 'workspace', 'active', REGISTRY_NAME);
43
+ if (!fs.existsSync(registryPath)) return null;
44
+ try {
45
+ const raw = fs.readFileSync(registryPath, 'utf8');
46
+ const data = JSON.parse(raw) as RegistrySchema;
47
+ if (!data || typeof data !== 'object' || !data.ids) return null;
48
+ return data;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function readBranchFromTaskMd(repoRoot: string, taskId: string): string | null {
55
+ const taskMdPath = path.join(repoRoot, '.agents', 'workspace', 'active', taskId, 'task.md');
56
+ if (!fs.existsSync(taskMdPath)) return null;
57
+ const content = fs.readFileSync(taskMdPath, 'utf8');
58
+ const m = content.match(/^branch:\s*(.+)$/m);
59
+ if (!m || !m[1]) return null;
60
+ return m[1].trim().replace(/^(["'])(.*)\1$/, '$2');
61
+ }
62
+
63
+ function loadShortIdByTaskId(repoRoot: string): Map<string, string> {
64
+ const registry = readRegistry(repoRoot);
65
+ const map = new Map<string, string>();
66
+ if (!registry) return map;
67
+ for (const [key, taskId] of Object.entries(registry.ids)) {
68
+ map.set(taskId, `#${key}`);
69
+ }
70
+ return map;
71
+ }
72
+
73
+ function lookupShortIdByBranch(
74
+ branch: string,
75
+ repoRoot: string,
76
+ _opts?: { shortIdLength?: number }
77
+ ): string | null {
78
+ const registry = readRegistry(repoRoot);
79
+ if (!registry) return null;
80
+ const matches: string[] = [];
81
+ for (const [key, taskId] of Object.entries(registry.ids)) {
82
+ const taskBranch = readBranchFromTaskMd(repoRoot, taskId);
83
+ if (taskBranch && taskBranch === branch) {
84
+ matches.push(`#${key}`);
85
+ }
86
+ }
87
+ if (matches.length === 0) return null;
88
+ if (matches.length > 1) {
89
+ process.stderr.write(
90
+ `Warning: branch '${branch}' is bound to multiple active tasks: ${matches.join(', ')}; using ${matches[0]}\n`
91
+ );
92
+ }
93
+ return matches[0]!;
94
+ }
95
+
96
+ export { normalizeShortIdInput, lookupShortIdByBranch, loadShortIdByTaskId };
97
+ export type { NormalizeResult, NormalizeOpts };