@fitlab-ai/agent-infra 0.7.6 → 0.7.7

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 (106) hide show
  1. package/bin/cli.ts +11 -3
  2. package/dist/bin/cli.js +10 -3
  3. package/dist/lib/sandbox/commands/ls.js +4 -33
  4. package/dist/lib/sandbox/commands/show.js +67 -0
  5. package/dist/lib/sandbox/index.js +7 -0
  6. package/dist/lib/task/commands/issue-body.js +94 -0
  7. package/dist/lib/task/commands/log.js +92 -9
  8. package/dist/lib/task/index.js +8 -0
  9. package/dist/lib/task/issue-form.js +66 -0
  10. package/dist/lib/task/sections.js +44 -0
  11. package/lib/sandbox/commands/ls.ts +4 -36
  12. package/lib/sandbox/commands/show.ts +80 -0
  13. package/lib/sandbox/index.ts +7 -0
  14. package/lib/task/commands/issue-body.ts +102 -0
  15. package/lib/task/commands/log.ts +96 -9
  16. package/lib/task/index.ts +8 -0
  17. package/lib/task/issue-form.ts +77 -0
  18. package/lib/task/sections.ts +44 -0
  19. package/package.json +3 -2
  20. package/templates/.agents/README.en.md +3 -1
  21. package/templates/.agents/README.zh-CN.md +3 -1
  22. package/templates/.agents/rules/create-issue.github.en.md +20 -29
  23. package/templates/.agents/rules/create-issue.github.zh-CN.md +20 -29
  24. package/templates/.agents/rules/issue-pr-commands.github.en.md +6 -0
  25. package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +6 -0
  26. package/templates/.agents/rules/next-step-output.en.md +26 -1
  27. package/templates/.agents/rules/next-step-output.zh-CN.md +26 -1
  28. package/templates/.agents/rules/task-management.en.md +2 -0
  29. package/templates/.agents/rules/task-management.zh-CN.md +2 -0
  30. package/templates/.agents/skills/analyze-task/SKILL.en.md +3 -1
  31. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +3 -1
  32. package/templates/.agents/skills/archive-tasks/SKILL.en.md +3 -1
  33. package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +3 -1
  34. package/templates/.agents/skills/block-task/SKILL.en.md +3 -1
  35. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +3 -1
  36. package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -1
  37. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -1
  38. package/templates/.agents/skills/check-task/SKILL.en.md +30 -81
  39. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +30 -80
  40. package/templates/.agents/skills/close-codescan/SKILL.en.md +3 -1
  41. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +3 -1
  42. package/templates/.agents/skills/close-dependabot/SKILL.en.md +3 -1
  43. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +3 -1
  44. package/templates/.agents/skills/code-task/SKILL.en.md +3 -1
  45. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +3 -1
  46. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +7 -2
  47. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +7 -2
  48. package/templates/.agents/skills/code-task/scripts/detect-mode.js +6 -14
  49. package/templates/.agents/skills/commit/SKILL.en.md +27 -5
  50. package/templates/.agents/skills/commit/SKILL.zh-CN.md +27 -5
  51. package/templates/.agents/skills/complete-task/SKILL.en.md +3 -1
  52. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +3 -1
  53. package/templates/.agents/skills/create-pr/SKILL.en.md +3 -1
  54. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +3 -1
  55. package/templates/.agents/skills/create-release-note/SKILL.en.md +3 -1
  56. package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +3 -1
  57. package/templates/.agents/skills/create-task/SKILL.en.md +3 -1
  58. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +3 -1
  59. package/templates/.agents/skills/import-codescan/SKILL.en.md +3 -1
  60. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +3 -1
  61. package/templates/.agents/skills/import-dependabot/SKILL.en.md +3 -1
  62. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +3 -1
  63. package/templates/.agents/skills/import-issue/SKILL.en.md +19 -2
  64. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +19 -2
  65. package/templates/.agents/skills/init-labels/SKILL.en.md +3 -1
  66. package/templates/.agents/skills/init-labels/SKILL.zh-CN.md +3 -1
  67. package/templates/.agents/skills/init-milestones/SKILL.en.md +3 -1
  68. package/templates/.agents/skills/init-milestones/SKILL.zh-CN.md +3 -1
  69. package/templates/.agents/skills/plan-task/SKILL.en.md +3 -1
  70. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +3 -1
  71. package/templates/.agents/skills/post-release/SKILL.en.md +3 -1
  72. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +3 -1
  73. package/templates/.agents/skills/refine-title/SKILL.en.md +3 -1
  74. package/templates/.agents/skills/refine-title/SKILL.zh-CN.md +3 -1
  75. package/templates/.agents/skills/release/SKILL.en.md +3 -1
  76. package/templates/.agents/skills/release/SKILL.zh-CN.md +3 -1
  77. package/templates/.agents/skills/restore-task/SKILL.en.md +3 -1
  78. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +3 -1
  79. package/templates/.agents/skills/review-analysis/SKILL.en.md +6 -2
  80. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +6 -2
  81. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +1 -1
  82. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +1 -1
  83. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +1 -1
  84. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +1 -2
  85. package/templates/.agents/skills/review-code/SKILL.en.md +5 -2
  86. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +5 -2
  87. package/templates/.agents/skills/review-code/reference/output-templates.en.md +1 -1
  88. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +1 -1
  89. package/templates/.agents/skills/review-code/reference/report-template.en.md +1 -1
  90. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +1 -2
  91. package/templates/.agents/skills/review-plan/SKILL.en.md +6 -2
  92. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +6 -2
  93. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +1 -1
  94. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +1 -1
  95. package/templates/.agents/skills/review-plan/reference/report-template.en.md +1 -1
  96. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +1 -2
  97. package/templates/.agents/skills/test/SKILL.en.md +3 -1
  98. package/templates/.agents/skills/test/SKILL.zh-CN.md +1 -1
  99. package/templates/.agents/skills/test-integration/SKILL.en.md +3 -1
  100. package/templates/.agents/skills/test-integration/SKILL.zh-CN.md +3 -1
  101. package/templates/.agents/skills/update-agent-infra/SKILL.en.md +3 -1
  102. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +3 -1
  103. package/templates/.agents/skills/upgrade-dependency/SKILL.en.md +3 -1
  104. package/templates/.agents/skills/upgrade-dependency/SKILL.zh-CN.md +3 -1
  105. package/templates/.agents/skills/watch-pr/SKILL.en.md +3 -1
  106. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +3 -1
@@ -0,0 +1,80 @@
1
+ import fs from 'node:fs';
2
+ import * as p from '@clack/prompts';
3
+ import pc from 'picocolors';
4
+ import type { SandboxConfig } from '../config.ts';
5
+ import { loadConfig } from '../config.ts';
6
+ import { assertValidBranchName, worktreeDirCandidates } from '../constants.ts';
7
+ import { resolveBranchArg } from './list-running.ts';
8
+ import { resolveTools, toolConfigDirCandidates } from '../tools.ts';
9
+
10
+ const USAGE = `Usage: ai sandbox show <branch | TASK-id | N | '#N'>
11
+
12
+ Shows one sandbox's worktree path and per-tool state paths (Claude Code,
13
+ Codex, Gemini CLI, OpenCode). The argument follows the same contract as
14
+ 'ai sandbox exec' and 'ai sandbox start': N (bare) is the recommended form
15
+ for task short ids (e.g. 'ai sandbox show 11'); '#N', a TASK-id, or a plain
16
+ branch name are also accepted. Use 'ai sandbox ls' for the container list.`;
17
+
18
+ export type SandboxDetail = {
19
+ worktrees: string[];
20
+ toolStates: { name: string; entries: string[] }[];
21
+ };
22
+
23
+ function existingDirs(candidates: string[]): string[] {
24
+ const seen = new Set<string>();
25
+ const result: string[] = [];
26
+ for (const candidate of candidates) {
27
+ if (!seen.has(candidate) && fs.existsSync(candidate)) {
28
+ seen.add(candidate);
29
+ result.push(candidate);
30
+ }
31
+ }
32
+ return result;
33
+ }
34
+
35
+ export function collectSandboxDetail(config: SandboxConfig, branch: string): SandboxDetail {
36
+ const worktrees = existingDirs(worktreeDirCandidates(config, branch));
37
+ const toolStates = resolveTools(config).map((tool) => ({
38
+ name: tool.name,
39
+ entries: existingDirs(toolConfigDirCandidates(tool, config.project, branch))
40
+ }));
41
+ return { worktrees, toolStates };
42
+ }
43
+
44
+ export function show(args: string[] = []): void {
45
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
46
+ process.stdout.write(`${USAGE}\n`);
47
+ if (args.length === 0) {
48
+ process.exitCode = 1;
49
+ }
50
+ return;
51
+ }
52
+
53
+ const config = loadConfig();
54
+ const branch = resolveBranchArg(args[0]!, { repoRoot: config.repoRoot });
55
+ assertValidBranchName(branch);
56
+
57
+ const detail = collectSandboxDetail(config, branch);
58
+
59
+ p.intro(pc.cyan(`Sandbox detail for ${config.project} · ${branch}`));
60
+
61
+ p.log.step('Worktree');
62
+ if (detail.worktrees.length === 0) {
63
+ p.log.warn(' No worktree for this branch');
64
+ } else {
65
+ for (const worktree of detail.worktrees) {
66
+ process.stdout.write(` ${worktree}\n`);
67
+ }
68
+ }
69
+
70
+ for (const tool of detail.toolStates) {
71
+ p.log.step(`${tool.name} state`);
72
+ if (tool.entries.length === 0) {
73
+ p.log.warn(` No ${tool.name} sandbox state`);
74
+ continue;
75
+ }
76
+ for (const entry of tool.entries) {
77
+ process.stdout.write(` ${entry}\n`);
78
+ }
79
+ }
80
+ }
@@ -9,6 +9,8 @@ Commands:
9
9
  ls List sandboxes for the current project (the '#'
10
10
  column is a display-only row number; the 'SHORT'
11
11
  column shows the active task short id, '-' if none)
12
+ show <branch | TASK-id | N | '#N'>
13
+ Show one sandbox's worktree and per-tool state paths
12
14
  prune [--dry-run] Remove orphaned per-branch state dirs
13
15
  rebuild [--quiet] [--refresh]
14
16
  Rebuild the sandbox image (--refresh pulls base + tools)
@@ -56,6 +58,11 @@ export async function runSandbox(args: string[]): Promise<void> {
56
58
  ls(rest);
57
59
  break;
58
60
  }
61
+ case 'show': {
62
+ const { show } = await import('./commands/show.ts');
63
+ show(rest);
64
+ break;
65
+ }
59
66
  case 'prune': {
60
67
  const { prune } = await import('./commands/prune.ts');
61
68
  await prune(rest);
@@ -0,0 +1,102 @@
1
+ import fs from 'node:fs';
2
+ import { resolveTaskRef } from '../resolve-ref.ts';
3
+ import { extractTitle } from '../frontmatter.ts';
4
+ import { extractSection, findSectionHeading } from '../sections.ts';
5
+ import { renderTemplateBody, PLACEHOLDER } from '../issue-form.ts';
6
+ import type { TaskFields } from '../issue-form.ts';
7
+
8
+ const USAGE = `Usage: ai task issue-body <N | #N | TASK-id> [--template <path>]
9
+
10
+ Print a deterministic Issue body extracted from a task's task.md.
11
+ <ref> Bare numeric / '#N' short id, or a full TASK-YYYYMMDD-HHMMSS id.
12
+ --template <path> Render the final body for the given GitHub Issue Form (scenario A);
13
+ without it, print the default '描述 + 需求' body (scenario B).
14
+
15
+ Only the task title, '## 描述' and '## 需求' sections are ever emitted; the rest of
16
+ task.md (scaffolding sections, placeholders) is never written to the body.
17
+ `;
18
+
19
+ const DESCRIPTION_ALIASES = ['描述', 'Description'];
20
+ const REQUIREMENTS_ALIASES = ['需求', 'Requirements'];
21
+
22
+ /**
23
+ * Build the scenario B default body, mirroring whichever heading language the
24
+ * task.md actually uses, with empty sections falling back to `N/A`.
25
+ */
26
+ function buildDefaultBody(content: string): string {
27
+ const descHeading = findSectionHeading(content, DESCRIPTION_ALIASES);
28
+ const reqHeading = findSectionHeading(content, REQUIREMENTS_ALIASES);
29
+ const description = extractSection(content, DESCRIPTION_ALIASES) || PLACEHOLDER;
30
+ const requirements = extractSection(content, REQUIREMENTS_ALIASES) || PLACEHOLDER;
31
+ return `## ${descHeading}\n\n${description}\n\n## ${reqHeading}\n\n${requirements}\n`;
32
+ }
33
+
34
+ function readTaskFields(content: string): TaskFields {
35
+ return {
36
+ title: extractTitle(content),
37
+ description: extractSection(content, DESCRIPTION_ALIASES),
38
+ requirements: extractSection(content, REQUIREMENTS_ALIASES)
39
+ };
40
+ }
41
+
42
+ function issueBody(args: string[] = []): void {
43
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
44
+ process.stdout.write(USAGE);
45
+ if (args.length === 0) process.exitCode = 1;
46
+ return;
47
+ }
48
+
49
+ let ref: string | undefined;
50
+ let templatePath: string | undefined;
51
+ for (let i = 0; i < args.length; i += 1) {
52
+ const arg = args[i]!;
53
+ if (arg === '--template') {
54
+ templatePath = args[i + 1];
55
+ i += 1;
56
+ } else if (ref === undefined) {
57
+ ref = arg;
58
+ }
59
+ }
60
+
61
+ if (!ref) {
62
+ process.stderr.write('ai task issue-body: missing task ref\n');
63
+ process.exitCode = 1;
64
+ return;
65
+ }
66
+ if (templatePath === undefined && args.includes('--template')) {
67
+ process.stderr.write('ai task issue-body: --template requires a path\n');
68
+ process.exitCode = 1;
69
+ return;
70
+ }
71
+
72
+ const resolved = resolveTaskRef(ref);
73
+ if (!resolved.ok) {
74
+ process.stderr.write(`ai task issue-body: ${resolved.message}\n`);
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+
79
+ const content = fs.readFileSync(resolved.taskMdPath, 'utf8');
80
+
81
+ if (templatePath !== undefined) {
82
+ let formText: string;
83
+ try {
84
+ formText = fs.readFileSync(templatePath, 'utf8');
85
+ } catch (e) {
86
+ process.stderr.write(`ai task issue-body: cannot read template '${templatePath}': ${(e as Error).message}\n`);
87
+ process.exitCode = 1;
88
+ return;
89
+ }
90
+ try {
91
+ process.stdout.write(renderTemplateBody(formText, readTaskFields(content)));
92
+ } catch (e) {
93
+ process.stderr.write(`ai task issue-body: cannot render template '${templatePath}': ${(e as Error).message}\n`);
94
+ process.exitCode = 1;
95
+ }
96
+ return;
97
+ }
98
+
99
+ process.stdout.write(buildDefaultBody(content));
100
+ }
101
+
102
+ export { issueBody, buildDefaultBody };
@@ -10,12 +10,17 @@ completion time (or '(in progress)' while still running).
10
10
  <ref> Bare numeric / '#N' short id, or a full TASK-YYYYMMDD-HHMMSS id.
11
11
 
12
12
  Columns: # (row) / STEP / AGENT / STARTED / DONE / NOTE
13
+ A human-executed step shows AGENT as 'human' and, when it has no start marker,
14
+ a '-' STARTED placeholder. Review-step NOTE also carries two human counts in
15
+ the verdict list, right after blockers/major/minor: manual-verify (env-blocked)
16
+ and human-decision (current ledger stage total).
13
17
  `;
14
18
 
15
19
  const TABLE_HEADERS = ['#', 'STEP', 'AGENT', 'STARTED', 'DONE', 'NOTE'] as const;
16
20
 
17
21
  // The activity-log H2 heading is language-dependent (zh template / en template).
18
22
  const HEADING_RE = /^##\s+(活动日志|Activity Log)\s*$/;
23
+ const LEDGER_HEADING_RE = /^##\s+(审查分歧账本|Review Disagreement Ledger)\s*$/;
19
24
  const NEXT_H2_RE = /^##\s/;
20
25
  // `- {time} — **{step}** by {agent} — {note}` ; the separator is an em-dash
21
26
  // (U+2014). STEP/AGENT are non-greedy so a note that itself contains ' — ' or
@@ -24,6 +29,8 @@ const ENTRY_RE =
24
29
  /^- (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}) — \*\*(.+?)\*\* by (.+?) — (.*)$/;
25
30
 
26
31
  type LogEntry = { time: string; step: string; agent: string; note: string };
32
+ type ReviewStage = 'analysis' | 'plan' | 'code';
33
+ type LedgerRow = { stage: string; status: string };
27
34
 
28
35
  // One rendered row = one step instance. `started`/`done` are timestamps; an empty
29
36
  // `done` with a non-empty `started` means the step is still in flight, while an
@@ -35,6 +42,21 @@ type StepRow = { step: string; agent: string; started: string; done: string; not
35
42
  // without the suffix. Pairing therefore keys on the base action (including any
36
43
  // `(Round N)`), so every round and every repeated execution pairs on its own.
37
44
  const STARTED_SUFFIX_RE = /\s*\[started\]\s*$/;
45
+ // Short agent tokens that actually appear in activity logs for AI executors
46
+ // (workflow recommended_agents claude/codex/gemini/cursor + enabled TUI opencode;
47
+ // note these differ from the `.airc.json` long names claude-code/gemini-cli).
48
+ // Any other executor token (a human name, possibly CJK) is treated as human.
49
+ const KNOWN_AI_AGENTS = new Set(['claude', 'codex', 'gemini', 'opencode', 'cursor']);
50
+ const HUMAN_DECISION_STATUSES = new Set(['needs-human-decision', 'human-decided']);
51
+ const ENV_BLOCKED_RE = /\(\+\s*(\d+)\s+env-blocked\)/i;
52
+ // Same match plus any leading whitespace, so folding the count into the verdict
53
+ // text drops the redundant `(+ n env-blocked)` fragment without leaving a gap.
54
+ const ENV_BLOCKED_STRIP_RE = /\s*\(\+\s*\d+\s+env-blocked\)/i;
55
+ const REVIEW_STAGE_PREFIXES: { prefix: string; stage: ReviewStage }[] = [
56
+ { prefix: 'Review Analysis', stage: 'analysis' },
57
+ { prefix: 'Review Plan', stage: 'plan' },
58
+ { prefix: 'Review Code', stage: 'code' }
59
+ ];
38
60
 
39
61
  function parseActivityLog(content: string): { sectionFound: boolean; entries: LogEntry[] } {
40
62
  const lines = content.split('\n');
@@ -89,6 +111,68 @@ function pairEntries(entries: LogEntry[]): StepRow[] {
89
111
  return rows;
90
112
  }
91
113
 
114
+ function parseReviewLedger(content: string): LedgerRow[] {
115
+ const lines = content.split('\n');
116
+ let i = 0;
117
+ while (i < lines.length && !LEDGER_HEADING_RE.test(lines[i]!)) i += 1;
118
+ if (i >= lines.length) return [];
119
+
120
+ const rows: LedgerRow[] = [];
121
+ for (let j = i + 1; j < lines.length; j += 1) {
122
+ if (NEXT_H2_RE.test(lines[j]!)) break;
123
+ const line = lines[j]!.trim();
124
+ if (!line.startsWith('|')) continue;
125
+ const cells = line
126
+ .split('|')
127
+ .slice(1, -1)
128
+ .map((cell) => cell.trim());
129
+ if (cells.length < 6 || cells[0] === 'id' || /^-+$/.test(cells[0] ?? '')) continue;
130
+ rows.push({ stage: cells[1]!, status: cells[4]! });
131
+ }
132
+ return rows;
133
+ }
134
+
135
+ function countHumanDecisionsByStage(rows: LedgerRow[]): Map<ReviewStage, number> {
136
+ const counts = new Map<ReviewStage, number>();
137
+ for (const row of rows) {
138
+ if (!isReviewStage(row.stage) || !HUMAN_DECISION_STATUSES.has(row.status)) continue;
139
+ counts.set(row.stage, (counts.get(row.stage) ?? 0) + 1);
140
+ }
141
+ return counts;
142
+ }
143
+
144
+ function isReviewStage(stage: string): stage is ReviewStage {
145
+ return stage === 'analysis' || stage === 'plan' || stage === 'code';
146
+ }
147
+
148
+ function reviewStageForStep(step: string): ReviewStage | undefined {
149
+ return REVIEW_STAGE_PREFIXES.find(({ prefix }) => step.startsWith(prefix))?.stage;
150
+ }
151
+
152
+ function humanValidationCount(note: string): number {
153
+ const match = ENV_BLOCKED_RE.exec(note);
154
+ return match ? Number(match[1]) : 0;
155
+ }
156
+
157
+ // A step is human-executed when its agent token is not a known AI token. Take
158
+ // the first whitespace-delimited token and drop any trailing parenthetical
159
+ // annotation (e.g. `张三 (executed on host)` -> `张三`) before the lookup.
160
+ function isHumanAgent(agent: string): boolean {
161
+ const token = agent.trim().split(/\s+/)[0]?.replace(/\(.*$/, '') ?? '';
162
+ return token !== '' && !KNOWN_AI_AGENTS.has(token);
163
+ }
164
+
165
+ // Fold the two human counts into a review row's verdict NOTE: comma-joined, right
166
+ // after the blockers/major/minor list and before the ` → artifact` link, mirroring
167
+ // the review count line. The raw `(+ n env-blocked)` fragment is dropped so the
168
+ // env-blocked number is not shown twice (it becomes the manual-verify count).
169
+ function foldHumanCounts(note: string, decisions: number, envBlocked: number): string {
170
+ const base = note.replace(ENV_BLOCKED_STRIP_RE, '');
171
+ const group = `Manual-verify: ${envBlocked}, Human-decision: ${decisions}`;
172
+ const arrow = base.indexOf(' → ');
173
+ return arrow === -1 ? `${base}, ${group}` : `${base.slice(0, arrow)}, ${group}${base.slice(arrow)}`;
174
+ }
175
+
92
176
  function log(args: string[] = []): void {
93
177
  if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
94
178
  process.stdout.write(USAGE);
@@ -116,18 +200,21 @@ function log(args: string[] = []): void {
116
200
  return;
117
201
  }
118
202
  const steps = pairEntries(entries);
119
- const rows = steps.map((s, idx) => [
120
- String(idx + 1),
121
- s.step,
122
- s.agent,
123
- s.started,
124
- s.done || (s.started ? '(in progress)' : ''),
125
- s.note
126
- ]);
203
+ const humanDecisionCounts = countHumanDecisionsByStage(parseReviewLedger(content));
204
+ const rows = steps.map((s, idx) => {
205
+ const stage = reviewStageForStep(s.step);
206
+ const note = stage
207
+ ? foldHumanCounts(s.note, humanDecisionCounts.get(stage) ?? 0, humanValidationCount(s.note))
208
+ : s.note;
209
+ const human = isHumanAgent(s.agent);
210
+ const agent = human ? 'human' : s.agent;
211
+ const started = s.started || (human ? '-' : '');
212
+ return [String(idx + 1), s.step, agent, started, s.done || (s.started ? '(in progress)' : ''), note];
213
+ });
127
214
  for (const line of formatTable(TABLE_HEADERS, rows, { zebra: Boolean(process.stdout.isTTY) })) {
128
215
  process.stdout.write(`${line}\n`);
129
216
  }
130
217
  process.stdout.write(`Total: ${steps.length} steps\n`);
131
218
  }
132
219
 
133
- export { log, parseActivityLog, pairEntries };
220
+ export { log, parseActivityLog, pairEntries, isHumanAgent };
package/lib/task/index.ts CHANGED
@@ -4,6 +4,7 @@ Commands:
4
4
  cat <ref> <artifact | N> Print a task artifact (by name or number)
5
5
  files <ref> List artifacts in a task dir (numbered)
6
6
  grep <pattern> [ref] [artifact | N] Literal search across task artifacts (omit ref to scan all)
7
+ issue-body <ref> [--template <path>] Print a deterministic Issue body from task.md (描述 + 需求, or an Issue Form)
7
8
  log <ref> Render a task's activity log as a timeline
8
9
  ls [--all | --blocked | --completed] List tasks (default: active)
9
10
  show <N | #N | TASK-id> Print a task.md
@@ -15,6 +16,8 @@ Examples:
15
16
  ai task files 11
16
17
  ai task grep resolveArtifact
17
18
  ai task grep resolveArtifact 11
19
+ ai task issue-body 11
20
+ ai task issue-body 11 --template .github/ISSUE_TEMPLATE/05_other.yml
18
21
  ai task log 11
19
22
  ai task ls
20
23
  ai task show 11
@@ -53,6 +56,11 @@ export async function runTask(args: string[]): Promise<void> {
53
56
  grep(rest);
54
57
  break;
55
58
  }
59
+ case 'issue-body': {
60
+ const { issueBody } = await import('./commands/issue-body.ts');
61
+ issueBody(rest);
62
+ break;
63
+ }
56
64
  case 'log': {
57
65
  const { log } = await import('./commands/log.ts');
58
66
  log(rest);
@@ -0,0 +1,77 @@
1
+ import { parse } from 'yaml';
2
+
3
+ type TaskFields = {
4
+ title: string;
5
+ description: string;
6
+ requirements: string;
7
+ };
8
+
9
+ // Field id -> task value mapping (single source of truth, per HD-1).
10
+ // Only ids that map cleanly to title / description / requirements get a value;
11
+ // every other text field gets `N/A`. This deliberately tightens the older
12
+ // "suggested" mapping table (impact / context / alternatives / steps / expected
13
+ // no longer flow into requirements) so the requirements checklist is never
14
+ // pushed into an unrelated field.
15
+ const TITLE_IDS = new Set(['summary', 'title']);
16
+ const DESCRIPTION_IDS = new Set([
17
+ 'description',
18
+ 'problem',
19
+ 'what-happened',
20
+ 'question',
21
+ 'current-content',
22
+ 'issue-description',
23
+ 'detailed-description'
24
+ ]);
25
+ const REQUIREMENTS_IDS = new Set(['requirements', 'solution', 'suggested-content']);
26
+
27
+ // Only free-text field types carry task content; structural / static fields are skipped.
28
+ const TEXT_FIELD_TYPES = new Set(['input', 'textarea']);
29
+ const PLACEHOLDER = 'N/A';
30
+
31
+ function mapFieldValue(id: string, fields: TaskFields): string {
32
+ if (TITLE_IDS.has(id)) return fields.title;
33
+ if (DESCRIPTION_IDS.has(id)) return fields.description;
34
+ if (REQUIREMENTS_IDS.has(id)) return fields.requirements;
35
+ return '';
36
+ }
37
+
38
+ type IssueFormField = {
39
+ type?: unknown;
40
+ id?: unknown;
41
+ attributes?: { label?: unknown };
42
+ };
43
+
44
+ /**
45
+ * Render the final Issue body for a GitHub Issue Form (scenario A).
46
+ *
47
+ * Walks the form's `body[]` in order, skips `markdown` / `dropdown` /
48
+ * `checkboxes`, and renders each `input` / `textarea` as `### {label}` followed
49
+ * by the deterministically-mapped task value (or `N/A` when the field has no
50
+ * reliable source). The template structure is preserved; the whole task.md and
51
+ * its scaffolding sections are never emitted.
52
+ *
53
+ * Throws on unreadable / non-object YAML or a missing `body[]` list so the
54
+ * caller can fall back to the default body.
55
+ */
56
+ function renderTemplateBody(formText: string, fields: TaskFields): string {
57
+ const doc = parse(formText) as { body?: unknown } | null;
58
+ if (!doc || typeof doc !== 'object' || !Array.isArray(doc.body)) {
59
+ throw new Error('Issue Form has no body[] list');
60
+ }
61
+ const sections: string[] = [];
62
+ for (const raw of doc.body as IssueFormField[]) {
63
+ if (!raw || typeof raw !== 'object') continue;
64
+ const type = typeof raw.type === 'string' ? raw.type : '';
65
+ if (!TEXT_FIELD_TYPES.has(type)) continue;
66
+ const label = typeof raw.attributes?.label === 'string' ? raw.attributes.label.trim() : '';
67
+ if (!label) continue;
68
+ const id = typeof raw.id === 'string' ? raw.id : '';
69
+ const mapped = mapFieldValue(id, fields);
70
+ const value = mapped.trim() === '' ? PLACEHOLDER : mapped;
71
+ sections.push(`### ${label}\n\n${value}`);
72
+ }
73
+ return `${sections.join('\n\n')}\n`;
74
+ }
75
+
76
+ export { renderTemplateBody, mapFieldValue, PLACEHOLDER };
77
+ export type { TaskFields };
@@ -0,0 +1,44 @@
1
+ function escapeRegExp(value: string): string {
2
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3
+ }
4
+
5
+ /**
6
+ * Return the body of the first `## {alias}` section (any alias matches), from
7
+ * the heading line to the next `## ` heading or EOF. Lines are preserved
8
+ * verbatim (checkbox text is never normalized); only leading/trailing blank
9
+ * lines are trimmed. Returns '' when no alias heading is present.
10
+ */
11
+ function extractSection(content: string, aliases: string[]): string {
12
+ const lines = content.split('\n');
13
+ let start = -1;
14
+ for (let i = 0; i < lines.length; i += 1) {
15
+ const line = lines[i]!.trim();
16
+ if (aliases.some((alias) => new RegExp(`^##\\s+${escapeRegExp(alias)}\\s*$`).test(line))) {
17
+ start = i + 1;
18
+ break;
19
+ }
20
+ }
21
+ if (start === -1) return '';
22
+ let end = lines.length;
23
+ for (let i = start; i < lines.length; i += 1) {
24
+ if (/^##\s+/.test(lines[i]!)) {
25
+ end = i;
26
+ break;
27
+ }
28
+ }
29
+ return lines.slice(start, end).join('\n').replace(/^\n+/, '').replace(/\n+$/, '');
30
+ }
31
+
32
+ /**
33
+ * Return the heading alias that actually appears as a `## {alias}` line, so a
34
+ * rendered section can mirror the source language. Falls back to the first
35
+ * alias when none is present.
36
+ */
37
+ function findSectionHeading(content: string, aliases: string[]): string {
38
+ for (const alias of aliases) {
39
+ if (new RegExp(`^##\\s+${escapeRegExp(alias)}\\s*$`, 'm').test(content)) return alias;
40
+ }
41
+ return aliases[0]!;
42
+ }
43
+
44
+ export { extractSection, findSectionHeading };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fitlab-ai/agent-infra",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
4
4
  "description": "Bootstrap tool for AI multi-tool collaboration infrastructure — works with Claude Code, Codex, Gemini CLI, and OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,7 +47,8 @@
47
47
  "cross-spawn": "^7.0.6",
48
48
  "picocolors": "1.1.1",
49
49
  "semver": "^7.8.1",
50
- "smol-toml": "^1.6.1"
50
+ "smol-toml": "^1.6.1",
51
+ "yaml": "^2.9.0"
51
52
  },
52
53
  "scripts": {
53
54
  "build": "tsc -p tsconfig.json && node scripts/build.js && node scripts/build-inline.js",
@@ -163,11 +163,13 @@ Recommended frontmatter:
163
163
  ```yaml
164
164
  ---
165
165
  name: enforce-style
166
- description: "Apply the team style guide before code review"
166
+ description: "Apply the team style guide before code review. Use when you need to align the team's code style before review"
167
167
  args: "<task-id>" # optional
168
168
  ---
169
169
  ```
170
170
 
171
+ Write `description` as "one-line responsibility + scenario clause": after the short responsibility, append "Use when …" (the Chinese `SKILL.zh-CN.md` variant uses "当……时使用") as the cross-TUI trigger semantics, so Agent Skills-capable tools can self-discover the skill in natural conversation. Do **not** add a separate `triggers` (or similar) frontmatter field for this.
172
+
171
173
  After adding or updating a custom skill, run `update-agent-infra` again. The sync step detects non-built-in skills and generates matching commands for Claude Code, Gemini CLI, and OpenCode automatically.
172
174
 
173
175
  ### Shared skill sources
@@ -163,11 +163,13 @@
163
163
  ```yaml
164
164
  ---
165
165
  name: enforce-style
166
- description: "在代码审查前应用团队风格规范"
166
+ description: "在代码审查前应用团队风格规范。当需要在评审前统一团队代码风格时使用"
167
167
  args: "<task-id>" # 可选
168
168
  ---
169
169
  ```
170
170
 
171
+ `description` 采用「一句话职责 + 场景触发子句」写法:在简短职责描述后补充「当……时使用」(英文 `SKILL.en.md` 用「Use when …」),作为跨 TUI 的触发语义,供支持 Agent Skills 的工具在自然对话中自发现该 skill。**不要**为此新增 `triggers` 等额外 frontmatter 字段。
172
+
171
173
  新增或修改自定义 skill 后,再执行一次 `update-agent-infra`。同步过程会自动检测非内置 skill,并为 Claude Code、Gemini CLI、OpenCode 生成对应命令。
172
174
 
173
175
  ### 共享 skill 源
@@ -22,11 +22,11 @@ After `create-task` writes the local `task.md`, follow this rule to cascade Issu
22
22
 
23
23
  Pull the following from `task.md`:
24
24
 
25
- - Task title (the first `# ` heading, stripped of `任务:` / `Task:` prefixes)
26
- - The `## Description` / `## 描述` section
27
- - The `## Requirements` / `## 需求` section
25
+ - Task title (the first `# ` heading, stripped of `任务:` / `Task:` prefixes) — used to build the Issue title
28
26
  - frontmatter fields `type` and (optionally) `milestone`
29
27
 
28
+ > The Issue **body** is not extracted by hand here. It is generated deterministically in §3 by the `ai task issue-body` command from the `## 描述` / `## 需求` sections; callers must not assemble it themselves.
29
+
30
30
  Build the Issue title:
31
31
 
32
32
  | task.md `type` | Conventional Commits type |
@@ -41,45 +41,35 @@ Scope inference: read known module names from `.agents/.airc.json`'s `labels.in`
41
41
 
42
42
  ### 3. Build the Issue Body
43
43
 
44
+ > **Mechanization boundary (mandatory)**: the Issue body is always produced deterministically by the `ai task issue-body` command; callers only pass the command's stdout to `gh issue create` via `--body-file` and must **not** assemble, rewrite, or truncate the body themselves. The command emits only the task title / `## 描述` / `## 需求` content; every other task.md scaffolding section never enters the body.
45
+
44
46
  Issue Form detection: follow the "Issue Template Detection" section in `.agents/rules/issue-pr-commands.md` to scan `.github/ISSUE_TEMPLATE/*.yml` (excluding `config.yml`).
45
47
 
46
48
  #### Scenario A: A matching template was detected
47
49
 
48
50
  Pick the form whose `name` (or filename) best matches the task type (e.g., a task with `type: bugfix` prefers a form whose name contains `bug`); if no match, fall back to a generic form like `other.yml`; if none, take the first form in the directory.
49
51
 
50
- Populate the form by following the field-handling rules in `.agents/rules/issue-pr-commands.md` § "Issue Template Detection":
51
-
52
- - `textarea` / `input` fields: use `attributes.label` as a markdown heading and pull values from task.md
53
- - `markdown` fields: skip (these are description blurbs)
54
- - `dropdown` / `checkboxes` fields: skip
52
+ Once the form file `{form-path}` is chosen, let the command render the final body from that Issue Form and write it to the body file `{body-file}` for §5:
55
53
 
56
- Recommended field-to-source mapping:
57
-
58
- | Template field hint | Source in task.md |
59
- |---|---|
60
- | `summary`, `title` | task title |
61
- | `description`, `problem`, `what happened`, `issue-description`, `current-content` | task description |
62
- | `solution`, `requirements`, `steps`, `suggested-content`, `impact`, `context`, `alternatives`, `expected` | requirements list (preserve checked / unchecked state as-is) |
63
- | Other `textarea` / `input` fields | task description, or `N/A` if missing |
54
+ ```bash
55
+ ai task issue-body {task-id} --template "{form-path}" > "{body-file}"
56
+ ```
64
57
 
65
- Whenever task.md does not provide a usable value, write `N/A`.
58
+ The command skips `markdown` / `dropdown` / `checkboxes` fields and, for `input` / `textarea` fields, uses `attributes.label` as the heading and deterministically fills the task title / description / requirements by field `id`, writing `N/A` for fields with no reliable source (the field mapping table is the single source of truth inside the command and is not restated here). When the command exits non-zero (missing file / invalid YAML / no `body`), regenerate `{body-file}` with the Scenario B command instead.
66
59
 
67
60
  #### Scenario B: No template, or template parsing failed
68
61
 
69
- Fall back to the default body:
70
-
71
- ```markdown
72
- ## Description
62
+ Let the command emit the default body (only `## 描述` + `## 需求`, checkbox text preserved verbatim, missing sections filled with `N/A`) into `{body-file}`:
73
63
 
74
- {task description, or N/A if missing}
64
+ ```bash
65
+ ai task issue-body {task-id} > "{body-file}"
66
+ ```
75
67
 
76
- ## Requirements
68
+ #### Red line: never use the entire task.md as the body
77
69
 
78
- - [ ] {requirement-1}
79
- - [ ] {requirement-2}
80
- ```
70
+ In both Scenario A and B, the body may only come from `ai task issue-body` stdout and contains **only the description + requirements content** (Scenario A is the equivalent content mapped onto template fields).
81
71
 
82
- If the requirements list is empty, write `N/A` in that section.
72
+ Wrong example (❌ forbidden): pasting the entire task.md including scaffolding sections such as `## 分析` / `## 设计` / `## 实现备注` / `## 审查反馈` / `## 审查分歧账本` / `## 人工裁决` / `## 活动日志` / `## 完成检查清单` and `#XXX` placeholders directly as the Issue body. Those sections only go to the `sync-issue:{task-id}:task` comment, never the body.
83
73
 
84
74
  ### 4. Resolve labels / Issue Type / milestone
85
75
 
@@ -137,17 +127,18 @@ When a milestone is selected, pass the release line (or `General Backlog` or the
137
127
 
138
128
  ### 5. Call the GitHub CLI to Create the Issue
139
129
 
140
- Run the "Create Issue" command from `.agents/rules/issue-pr-commands.md`:
130
+ Run the "Create Issue" command from `.agents/rules/issue-pr-commands.md`; always use the `{body-file}` produced in §3, overriding the generic command's `--body`:
141
131
 
142
132
  ```bash
143
133
  gh issue create -R "$upstream_repo" \
144
134
  --title "{title}" \
145
- --body "{body}" \
135
+ --body-file "{body-file}" \
146
136
  --assignee @me \
147
137
  {label-args} \
148
138
  {milestone-arg}
149
139
  ```
150
140
 
141
+ - `{body-file}` is the body file produced in §3 by `ai task issue-body`; do **not** switch back to `--body` and assemble the body by hand
151
142
  - `{label-args}` is expanded from the result of §4 into multiple `--label "..."`; if empty, omit the entire argument
152
143
  - `{milestone-arg}` is only expanded to `--milestone "..."` when `has_triage=true` and milestone is non-empty; otherwise omit
153
144
  - `--assignee @me` requires no permission probe; on failure, skip silently