@fitlab-ai/agent-infra 0.7.6 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -0
  3. package/bin/cli.ts +53 -18
  4. package/dist/bin/cli.js +56 -19
  5. package/dist/lib/decide.js +110 -0
  6. package/dist/lib/run/host.js +39 -0
  7. package/dist/lib/run/index.js +102 -0
  8. package/dist/lib/run/prompt.js +2 -0
  9. package/dist/lib/run/skills.js +29 -0
  10. package/dist/lib/run/tui.js +35 -0
  11. package/dist/lib/sandbox/capture.js +49 -0
  12. package/dist/lib/sandbox/commands/create.js +55 -6
  13. package/dist/lib/sandbox/commands/ls.js +4 -33
  14. package/dist/lib/sandbox/commands/show.js +67 -0
  15. package/dist/lib/sandbox/index.js +7 -0
  16. package/dist/lib/server/adapters/_contract.js +9 -0
  17. package/dist/lib/server/adapters/feishu/index.js +40 -0
  18. package/dist/lib/server/adapters/feishu/transport.js +95 -0
  19. package/dist/lib/server/auth.js +19 -0
  20. package/dist/lib/server/config.js +183 -0
  21. package/dist/lib/server/daemon.js +96 -0
  22. package/dist/lib/server/index.js +47 -0
  23. package/dist/lib/server/logger.js +34 -0
  24. package/dist/lib/server/plugin-loader.js +73 -0
  25. package/dist/lib/server/process-control.js +200 -0
  26. package/dist/lib/server/protocol.js +69 -0
  27. package/dist/lib/server/redact.js +12 -0
  28. package/dist/lib/server/runner.js +75 -0
  29. package/dist/lib/server/streamer.js +44 -0
  30. package/dist/lib/task/commands/decisions.js +258 -0
  31. package/dist/lib/task/commands/issue-body.js +94 -0
  32. package/dist/lib/task/commands/log.js +67 -9
  33. package/dist/lib/task/index.js +17 -0
  34. package/dist/lib/task/issue-form.js +66 -0
  35. package/dist/lib/task/ledger.js +66 -0
  36. package/dist/lib/task/sections.js +73 -0
  37. package/lib/decide.ts +124 -0
  38. package/lib/run/host.ts +47 -0
  39. package/lib/run/index.ts +124 -0
  40. package/lib/run/prompt.ts +1 -0
  41. package/lib/run/skills.ts +34 -0
  42. package/lib/run/tui.ts +40 -0
  43. package/lib/sandbox/capture.ts +84 -0
  44. package/lib/sandbox/commands/create.ts +61 -9
  45. package/lib/sandbox/commands/ls.ts +4 -36
  46. package/lib/sandbox/commands/show.ts +80 -0
  47. package/lib/sandbox/index.ts +7 -0
  48. package/lib/server/adapters/_contract.ts +42 -0
  49. package/lib/server/adapters/feishu/index.ts +47 -0
  50. package/lib/server/adapters/feishu/transport.ts +128 -0
  51. package/lib/server/auth.ts +28 -0
  52. package/lib/server/config.ts +228 -0
  53. package/lib/server/daemon.ts +110 -0
  54. package/lib/server/index.ts +50 -0
  55. package/lib/server/logger.ts +48 -0
  56. package/lib/server/plugin-loader.ts +84 -0
  57. package/lib/server/process-control.ts +225 -0
  58. package/lib/server/protocol.ts +79 -0
  59. package/lib/server/redact.ts +12 -0
  60. package/lib/server/runner.ts +95 -0
  61. package/lib/server/server.schema.json +127 -0
  62. package/lib/server/streamer.ts +57 -0
  63. package/lib/task/commands/decisions.ts +272 -0
  64. package/lib/task/commands/issue-body.ts +102 -0
  65. package/lib/task/commands/log.ts +73 -9
  66. package/lib/task/index.ts +17 -0
  67. package/lib/task/issue-form.ts +77 -0
  68. package/lib/task/ledger.ts +73 -0
  69. package/lib/task/sections.ts +73 -0
  70. package/package.json +9 -4
  71. package/templates/.agents/QUICKSTART.zh-CN.md +2 -2
  72. package/templates/.agents/README.en.md +3 -1
  73. package/templates/.agents/README.zh-CN.md +3 -1
  74. package/templates/.agents/rules/create-issue.github.en.md +20 -29
  75. package/templates/.agents/rules/create-issue.github.zh-CN.md +20 -29
  76. package/templates/.agents/rules/issue-fields.github.en.md +1 -1
  77. package/templates/.agents/rules/issue-fields.github.zh-CN.md +1 -1
  78. package/templates/.agents/rules/issue-pr-commands.github.en.md +6 -0
  79. package/templates/.agents/rules/issue-pr-commands.github.zh-CN.md +6 -0
  80. package/templates/.agents/rules/next-step-output.en.md +30 -1
  81. package/templates/.agents/rules/next-step-output.zh-CN.md +30 -1
  82. package/templates/.agents/rules/no-mid-flow-questions.en.md +7 -2
  83. package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +7 -2
  84. package/templates/.agents/rules/review-handshake.en.md +5 -1
  85. package/templates/.agents/rules/review-handshake.zh-CN.md +5 -1
  86. package/templates/.agents/rules/task-management.en.md +3 -1
  87. package/templates/.agents/rules/task-management.zh-CN.md +3 -1
  88. package/templates/.agents/scripts/lib/post-review-commit.js +15 -18
  89. package/templates/.agents/scripts/validate-artifact.js +8 -0
  90. package/templates/.agents/skills/analyze-task/SKILL.en.md +18 -4
  91. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +18 -4
  92. package/templates/.agents/skills/analyze-task/config/verify.en.json +2 -1
  93. package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +2 -1
  94. package/templates/.agents/skills/archive-tasks/SKILL.en.md +3 -1
  95. package/templates/.agents/skills/archive-tasks/SKILL.zh-CN.md +3 -1
  96. package/templates/.agents/skills/block-task/SKILL.en.md +3 -1
  97. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +3 -1
  98. package/templates/.agents/skills/cancel-task/SKILL.en.md +3 -1
  99. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +3 -1
  100. package/templates/.agents/skills/check-task/SKILL.en.md +30 -81
  101. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +30 -80
  102. package/templates/.agents/skills/close-codescan/SKILL.en.md +3 -1
  103. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +3 -1
  104. package/templates/.agents/skills/close-dependabot/SKILL.en.md +3 -1
  105. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +3 -1
  106. package/templates/.agents/skills/code-task/SKILL.en.md +4 -2
  107. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +4 -2
  108. package/templates/.agents/skills/code-task/reference/dual-mode.en.md +7 -2
  109. package/templates/.agents/skills/code-task/reference/dual-mode.zh-CN.md +7 -2
  110. package/templates/.agents/skills/code-task/scripts/detect-mode.js +6 -14
  111. package/templates/.agents/skills/commit/SKILL.en.md +27 -5
  112. package/templates/.agents/skills/commit/SKILL.zh-CN.md +27 -5
  113. package/templates/.agents/skills/complete-task/SKILL.en.md +4 -1
  114. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +4 -1
  115. package/templates/.agents/skills/complete-task/config/verify.en.json +2 -1
  116. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -1
  117. package/templates/.agents/skills/create-pr/SKILL.en.md +3 -1
  118. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +3 -1
  119. package/templates/.agents/skills/create-release-note/SKILL.en.md +3 -1
  120. package/templates/.agents/skills/create-release-note/SKILL.zh-CN.md +3 -1
  121. package/templates/.agents/skills/create-task/SKILL.en.md +4 -4
  122. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +4 -4
  123. package/templates/.agents/skills/import-codescan/SKILL.en.md +3 -4
  124. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +3 -4
  125. package/templates/.agents/skills/import-dependabot/SKILL.en.md +3 -2
  126. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +3 -2
  127. package/templates/.agents/skills/import-issue/SKILL.en.md +20 -4
  128. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +20 -4
  129. package/templates/.agents/skills/init-labels/SKILL.en.md +3 -1
  130. package/templates/.agents/skills/init-labels/SKILL.zh-CN.md +3 -1
  131. package/templates/.agents/skills/init-milestones/SKILL.en.md +3 -1
  132. package/templates/.agents/skills/init-milestones/SKILL.zh-CN.md +3 -1
  133. package/templates/.agents/skills/plan-task/SKILL.en.md +4 -3
  134. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +4 -3
  135. package/templates/.agents/skills/post-release/SKILL.en.md +3 -1
  136. package/templates/.agents/skills/post-release/SKILL.zh-CN.md +3 -1
  137. package/templates/.agents/skills/refine-title/SKILL.en.md +3 -1
  138. package/templates/.agents/skills/refine-title/SKILL.zh-CN.md +3 -1
  139. package/templates/.agents/skills/release/SKILL.en.md +3 -1
  140. package/templates/.agents/skills/release/SKILL.zh-CN.md +3 -1
  141. package/templates/.agents/skills/restore-task/SKILL.en.md +3 -1
  142. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +3 -1
  143. package/templates/.agents/skills/review-analysis/SKILL.en.md +6 -2
  144. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +6 -2
  145. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +1 -1
  146. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +1 -1
  147. package/templates/.agents/skills/review-analysis/reference/report-template.en.md +1 -1
  148. package/templates/.agents/skills/review-analysis/reference/report-template.zh-CN.md +1 -2
  149. package/templates/.agents/skills/review-code/SKILL.en.md +5 -2
  150. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +5 -2
  151. package/templates/.agents/skills/review-code/reference/output-templates.en.md +1 -1
  152. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +1 -1
  153. package/templates/.agents/skills/review-code/reference/report-template.en.md +1 -1
  154. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +1 -2
  155. package/templates/.agents/skills/review-plan/SKILL.en.md +6 -2
  156. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +6 -2
  157. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +1 -1
  158. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +1 -1
  159. package/templates/.agents/skills/review-plan/reference/report-template.en.md +1 -1
  160. package/templates/.agents/skills/review-plan/reference/report-template.zh-CN.md +1 -2
  161. package/templates/.agents/skills/test/SKILL.en.md +3 -1
  162. package/templates/.agents/skills/test/SKILL.zh-CN.md +1 -1
  163. package/templates/.agents/skills/test-integration/SKILL.en.md +3 -1
  164. package/templates/.agents/skills/test-integration/SKILL.zh-CN.md +3 -1
  165. package/templates/.agents/skills/update-agent-infra/SKILL.en.md +3 -1
  166. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +3 -1
  167. package/templates/.agents/skills/upgrade-dependency/SKILL.en.md +3 -1
  168. package/templates/.agents/skills/upgrade-dependency/SKILL.zh-CN.md +3 -1
  169. package/templates/.agents/skills/watch-pr/SKILL.en.md +3 -1
  170. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +3 -1
  171. package/templates/.agents/templates/task.en.md +3 -3
  172. package/templates/.agents/templates/task.zh-CN.md +3 -3
  173. package/templates/.github/workflows/metadata-sync.yml +0 -18
@@ -0,0 +1,95 @@
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ export type RunnerResult = {
6
+ exitCode: number | null;
7
+ signal: NodeJS.Signals | null;
8
+ stdout: string;
9
+ stderr: string;
10
+ };
11
+
12
+ export type RunnerOptions = {
13
+ spawn?: (file: string, args: string[], onChunk?: (chunk: string) => void | Promise<void>) => Promise<RunnerResult>;
14
+ onChunk?: (chunk: string) => void | Promise<void>;
15
+ };
16
+
17
+ function resolveCommand(file: string): string {
18
+ if (process.platform !== 'win32' || path.extname(file)) {
19
+ return file;
20
+ }
21
+
22
+ const pathValue = process.env.Path || process.env.PATH || '';
23
+ const extensions = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
24
+ for (const dir of pathValue.split(path.delimiter).filter(Boolean)) {
25
+ for (const extension of extensions) {
26
+ const lowerCandidate = path.join(dir, `${file}${extension.toLowerCase()}`);
27
+ if (fs.existsSync(lowerCandidate)) return lowerCandidate;
28
+ const upperCandidate = path.join(dir, `${file}${extension.toUpperCase()}`);
29
+ if (fs.existsSync(upperCandidate)) return upperCandidate;
30
+ }
31
+ }
32
+
33
+ return file;
34
+ }
35
+
36
+ function needsShell(file: string): boolean {
37
+ return process.platform === 'win32' && /\.(?:bat|cmd)$/i.test(file);
38
+ }
39
+
40
+ function spawnCapture(
41
+ file: string,
42
+ args: string[],
43
+ onChunk?: (chunk: string) => void | Promise<void>
44
+ ): Promise<RunnerResult> {
45
+ return new Promise((resolve, reject) => {
46
+ const resolvedFile = resolveCommand(file);
47
+ const child = spawn(resolvedFile, args, {
48
+ stdio: ['ignore', 'pipe', 'pipe'],
49
+ shell: needsShell(resolvedFile)
50
+ });
51
+ let stdout = '';
52
+ let stderr = '';
53
+ let settled = false;
54
+ let pendingChunks = Promise.resolve();
55
+
56
+ const rejectOnce = (error: unknown): void => {
57
+ if (settled) return;
58
+ settled = true;
59
+ reject(error);
60
+ };
61
+
62
+ const resolveOnce = (result: RunnerResult): void => {
63
+ if (settled) return;
64
+ settled = true;
65
+ resolve(result);
66
+ };
67
+
68
+ const enqueueChunk = (chunk: string): void => {
69
+ if (!onChunk) return;
70
+ pendingChunks = pendingChunks.then(() => onChunk(chunk));
71
+ pendingChunks.catch(rejectOnce);
72
+ };
73
+
74
+ child.stdout.setEncoding('utf8');
75
+ child.stderr.setEncoding('utf8');
76
+ child.stdout.on('data', (chunk) => {
77
+ stdout += chunk;
78
+ enqueueChunk(chunk);
79
+ });
80
+ child.stderr.on('data', (chunk) => {
81
+ stderr += chunk;
82
+ enqueueChunk(chunk);
83
+ });
84
+ child.on('error', rejectOnce);
85
+ child.on('close', (exitCode, signal) => {
86
+ pendingChunks
87
+ .then(() => resolveOnce({ exitCode, signal, stdout, stderr }))
88
+ .catch(rejectOnce);
89
+ });
90
+ });
91
+ }
92
+
93
+ export async function runAi(args: string[], options: RunnerOptions = {}): Promise<RunnerResult> {
94
+ return (options.spawn ?? spawnCapture)('ai', args, options.onChunk);
95
+ }
@@ -0,0 +1,127 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://github.com/fitlab-ai/agent-infra/lib/server/server.schema.json",
4
+ "title": "agent-infra server configuration",
5
+ "description": "Schema for .agents/server.json. Secrets (appSecret, tokens, passwords) must NOT be committed here — put them in .agents/server.local.json or AGENT_INFRA_SERVER_* environment variables.",
6
+ "type": "object",
7
+ "additionalProperties": true,
8
+ "properties": {
9
+ "$schema": {
10
+ "type": "string"
11
+ },
12
+ "log": {
13
+ "type": "object",
14
+ "additionalProperties": false,
15
+ "properties": {
16
+ "path": {
17
+ "type": "string",
18
+ "description": "Log file path. Defaults to ~/.agent-infra/logs/<project>/<repo-hash>/server.log (outside the repo; the repo-hash isolates checkouts that share a project). Relative paths resolve against the repo root; absolute paths are used as-is."
19
+ },
20
+ "rotateAtBytes": {
21
+ "type": "integer",
22
+ "minimum": 0,
23
+ "description": "Rotate the log to <path>.1 at startup if it already exceeds this size.",
24
+ "default": 52428800
25
+ }
26
+ }
27
+ },
28
+ "heartbeatMs": {
29
+ "type": "integer",
30
+ "minimum": 1,
31
+ "description": "Interval between daemon heartbeat log lines, in milliseconds.",
32
+ "default": 30000
33
+ },
34
+ "adapters": {
35
+ "type": "object",
36
+ "description": "Per-adapter configuration. Only adapters with enabled=true are loaded.",
37
+ "properties": {
38
+ "feishu": {
39
+ "type": "object",
40
+ "description": "Feishu (Lark) IM adapter. The appSecret must NOT be committed here — put it in .agents/server.local.json or AGENT_INFRA_SERVER_adapters__feishu__appSecret.",
41
+ "properties": {
42
+ "enabled": {
43
+ "type": "boolean",
44
+ "default": false
45
+ },
46
+ "appId": {
47
+ "type": "string",
48
+ "description": "Feishu self-built app App ID."
49
+ },
50
+ "domain": {
51
+ "type": "string",
52
+ "enum": ["feishu", "lark"],
53
+ "default": "feishu",
54
+ "description": "Open platform domain: feishu (default) or lark."
55
+ }
56
+ },
57
+ "required": ["enabled"]
58
+ }
59
+ },
60
+ "additionalProperties": {
61
+ "type": "object",
62
+ "properties": {
63
+ "enabled": {
64
+ "type": "boolean",
65
+ "default": false
66
+ }
67
+ },
68
+ "required": ["enabled"]
69
+ }
70
+ },
71
+ "command": {
72
+ "type": "object",
73
+ "description": "Command protocol defaults and skill allow-list.",
74
+ "additionalProperties": false,
75
+ "properties": {
76
+ "defaultTui": {
77
+ "type": "string",
78
+ "enum": ["claude", "codex", "gemini", "opencode"],
79
+ "default": "codex"
80
+ },
81
+ "skillTuiDefaults": {
82
+ "type": "object",
83
+ "additionalProperties": {
84
+ "type": "string",
85
+ "enum": ["claude", "codex", "gemini", "opencode"]
86
+ }
87
+ },
88
+ "allowedSkills": {
89
+ "type": "array",
90
+ "items": { "type": "string" }
91
+ }
92
+ }
93
+ },
94
+ "auth": {
95
+ "type": "object",
96
+ "description": "Adapter-qualified user roles. Keys look like feishu:ou_xxx.",
97
+ "additionalProperties": false,
98
+ "properties": {
99
+ "users": {
100
+ "type": "object",
101
+ "additionalProperties": {
102
+ "type": "object",
103
+ "additionalProperties": true,
104
+ "properties": {
105
+ "role": {
106
+ "type": "string",
107
+ "enum": ["read", "write", "exec"]
108
+ },
109
+ "name": { "type": "string" }
110
+ },
111
+ "required": ["role"]
112
+ }
113
+ }
114
+ }
115
+ },
116
+ "stream": {
117
+ "type": "object",
118
+ "additionalProperties": false,
119
+ "properties": {
120
+ "throttleMs": { "type": "integer", "minimum": 0, "default": 1500 },
121
+ "chunkChars": { "type": "integer", "minimum": 1, "default": 4000 },
122
+ "tailChars": { "type": "integer", "minimum": 1, "default": 8000 },
123
+ "timeoutMs": { "type": "integer", "minimum": 1, "default": 1800000 }
124
+ }
125
+ }
126
+ }
127
+ }
@@ -0,0 +1,57 @@
1
+ import { redactSecrets } from './redact.ts';
2
+ import type { RunnerResult } from './runner.ts';
3
+
4
+ export type StreamOptions = {
5
+ title: string;
6
+ chunkChars?: number;
7
+ throttleMs?: number;
8
+ };
9
+
10
+ function chunks(text: string, size: number): string[] {
11
+ if (text.length === 0) return [];
12
+ const out: string[] = [];
13
+ for (let i = 0; i < text.length; i += size) {
14
+ out.push(text.slice(i, i + size));
15
+ }
16
+ return out;
17
+ }
18
+
19
+ export async function streamCommand(
20
+ options: StreamOptions,
21
+ run: (emit?: (chunk: string) => Promise<void>) => Promise<RunnerResult>,
22
+ send: (text: string) => Promise<void>
23
+ ): Promise<RunnerResult> {
24
+ await send(`started ${options.title}`);
25
+ const size = options.chunkChars ?? 4000;
26
+ const throttleMs = options.throttleMs ?? 0;
27
+ let streamed = false;
28
+ let buffer = '';
29
+ let lastFlush = 0;
30
+
31
+ const flush = async (): Promise<void> => {
32
+ if (!buffer) return;
33
+ const text = redactSecrets(buffer);
34
+ buffer = '';
35
+ lastFlush = Date.now();
36
+ for (const chunk of chunks(text, size)) {
37
+ await send(chunk);
38
+ }
39
+ };
40
+
41
+ const emit = async (chunk: string): Promise<void> => {
42
+ streamed = true;
43
+ buffer += chunk;
44
+ const due = throttleMs === 0 || Date.now() - lastFlush >= throttleMs;
45
+ if (buffer.length >= size || due) {
46
+ await flush();
47
+ }
48
+ };
49
+
50
+ const result = await run(emit);
51
+ if (!streamed) {
52
+ buffer += [result.stdout, result.stderr].filter(Boolean).join('\n');
53
+ }
54
+ await flush();
55
+ await send(`finished ${options.title} exitCode=${result.exitCode} signal=${result.signal ?? 'null'}`);
56
+ return result;
57
+ }
@@ -0,0 +1,272 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { formatTable } from '../../table.ts';
4
+ import { resolveTaskRef } from '../resolve-ref.ts';
5
+ import { parseLedger, HUMAN_DECISION_STATUSES, type LedgerRow } from '../ledger.ts';
6
+ import { extractSubSection } from '../sections.ts';
7
+
8
+ const USAGE = `Usage: ai task decisions <N | #N | TASK-id> [selector] [options]
9
+
10
+ Lists the human-decision (HD-) items recorded in a task's review disagreement
11
+ ledger, or prints the full detail block for a single item. Read-only.
12
+
13
+ <ref> Bare numeric / '#N' short id, or a full TASK-YYYYMMDD-HHMMSS id.
14
+ [selector] Ordinal (1-based) or HD id (e.g. 'HD-3') to show one item's detail.
15
+
16
+ Options:
17
+ --all Include already-decided (human-decided) items, not just pending.
18
+ --stage <s> Filter to one stage: analysis | plan | code.
19
+ --format <fmt> Output format: text (default) | markdown.
20
+ -h, --help Show this help.
21
+
22
+ Aliased as 'ai task d'.
23
+ `;
24
+
25
+ const STAGES = new Set(['analysis', 'plan', 'code']);
26
+ const FORMATS = new Set(['text', 'markdown']);
27
+ const HD_ID_RE = /^HD-\d+$/;
28
+
29
+ function fail(message: string): void {
30
+ process.stderr.write(`ai task decisions: ${message}\n`);
31
+ process.exitCode = 1;
32
+ }
33
+
34
+ type ParsedArgs = {
35
+ positionals: string[];
36
+ all: boolean;
37
+ stage?: string;
38
+ format: string;
39
+ };
40
+
41
+ // Returns null and sets the exit code when an option is malformed.
42
+ function parseArgs(args: string[]): ParsedArgs | null {
43
+ const out: ParsedArgs = { positionals: [], all: false, format: 'text' };
44
+ for (let i = 0; i < args.length; i += 1) {
45
+ const a = args[i]!;
46
+ if (a === '--all') {
47
+ out.all = true;
48
+ } else if (a === '--stage') {
49
+ const v = args[i + 1];
50
+ if (v === undefined) {
51
+ fail('--stage requires a value (analysis|plan|code)');
52
+ return null;
53
+ }
54
+ out.stage = v;
55
+ i += 1;
56
+ } else if (a.startsWith('--stage=')) {
57
+ out.stage = a.slice('--stage='.length);
58
+ } else if (a === '--format') {
59
+ const v = args[i + 1];
60
+ if (v === undefined) {
61
+ fail('--format requires a value (text|markdown)');
62
+ return null;
63
+ }
64
+ out.format = v;
65
+ i += 1;
66
+ } else if (a.startsWith('--format=')) {
67
+ out.format = a.slice('--format='.length);
68
+ } else if (a.startsWith('-')) {
69
+ fail(`unknown option '${a}'`);
70
+ return null;
71
+ } else {
72
+ out.positionals.push(a);
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+
78
+ // Parse `<file>.md#anchor` evidence into its filename, when present.
79
+ function evidenceFile(evidence: string): string | null {
80
+ const m = /([\w.-]+\.md)#/.exec(evidence);
81
+ return m ? m[1]! : null;
82
+ }
83
+
84
+ function roundOf(file: string): number {
85
+ const m = /-r(\d+)\.md$/.exec(file);
86
+ return m ? Number.parseInt(m[1]!, 10) : 1;
87
+ }
88
+
89
+ // Locate the `### HD-N` detail block for a row. Prefer the artifact named by the
90
+ // row's evidence anchor; otherwise scan analysis/plan/code artifacts and return
91
+ // the block from the highest-round file that contains it. Returns '' when none
92
+ // is found (caller degrades gracefully — plan B3).
93
+ function findDetailBlock(row: LedgerRow, taskDir: string): string {
94
+ const hinted = evidenceFile(row.evidence);
95
+ if (hinted) {
96
+ const p = path.join(taskDir, hinted);
97
+ if (fs.existsSync(p)) {
98
+ const block = extractSubSection(fs.readFileSync(p, 'utf8'), row.id);
99
+ if (block) return block;
100
+ }
101
+ }
102
+ let best = '';
103
+ let bestRound = -1;
104
+ let entries: string[];
105
+ try {
106
+ entries = fs.readdirSync(taskDir);
107
+ } catch {
108
+ return '';
109
+ }
110
+ for (const file of entries) {
111
+ if (!/^(analysis|plan|code)(-r\d+)?\.md$/.test(file)) continue;
112
+ const block = extractSubSection(fs.readFileSync(path.join(taskDir, file), 'utf8'), row.id);
113
+ if (block && roundOf(file) > bestRound) {
114
+ best = block;
115
+ bestRound = roundOf(file);
116
+ }
117
+ }
118
+ return best;
119
+ }
120
+
121
+ // Pull the `## 人工裁决` record lines that mention this HD id, so a decided item
122
+ // shows the human's recorded ruling alongside its detail block.
123
+ function findDecisionRecord(id: string, content: string): string[] {
124
+ const lines = content.split('\n');
125
+ let i = 0;
126
+ while (i < lines.length && !/^##\s+(人工裁决|Human Decisions?)\s*$/.test(lines[i]!)) i += 1;
127
+ if (i >= lines.length) return [];
128
+ const idRe = new RegExp(`(^|[^\\w-])${id}(?![\\w-])`);
129
+ const out: string[] = [];
130
+ for (let j = i + 1; j < lines.length; j += 1) {
131
+ if (/^##\s/.test(lines[j]!)) break;
132
+ if (lines[j]!.trim().startsWith('-') && idRe.test(lines[j]!)) out.push(lines[j]!);
133
+ }
134
+ return out;
135
+ }
136
+
137
+ function titleOf(row: LedgerRow, taskDir: string): string {
138
+ const block = findDetailBlock(row, taskDir);
139
+ if (block) {
140
+ return block
141
+ .split('\n')[0]!
142
+ .replace(/^###\s+/, '')
143
+ .replace(/\s*\[needs-human-decision\]\s*$/, '')
144
+ .trim();
145
+ }
146
+ return row.evidence || '(无详情)';
147
+ }
148
+
149
+ function renderList(rows: LedgerRow[], format: string, taskDir: string): void {
150
+ if (rows.length === 0) {
151
+ process.stdout.write('无待裁决项。\n');
152
+ return;
153
+ }
154
+ const headers = ['#', 'ID', 'STAGE', 'SEVERITY', 'STATUS', 'EVIDENCE', 'TITLE'];
155
+ const data = rows.map((r, i) => [
156
+ String(i + 1),
157
+ r.id,
158
+ r.stage,
159
+ r.severity,
160
+ r.status,
161
+ r.evidence,
162
+ titleOf(r, taskDir)
163
+ ]);
164
+ if (format === 'markdown') {
165
+ const sep = headers.map(() => '---');
166
+ const md = [
167
+ `| ${headers.join(' | ')} |`,
168
+ `| ${sep.join(' | ')} |`,
169
+ ...data.map((row) => `| ${row.join(' | ')} |`)
170
+ ];
171
+ process.stdout.write(`${md.join('\n')}\n`);
172
+ return;
173
+ }
174
+ process.stdout.write(`${formatTable(headers, data).join('\n')}\n`);
175
+ }
176
+
177
+ function renderDetail(
178
+ rows: LedgerRow[],
179
+ selector: string,
180
+ format: string,
181
+ taskDir: string,
182
+ content: string
183
+ ): void {
184
+ let row: LedgerRow | undefined;
185
+ if (/^\d+$/.test(selector)) {
186
+ const idx = Number.parseInt(selector, 10) - 1;
187
+ if (idx < 0 || idx >= rows.length) {
188
+ fail(`ordinal '${selector}' out of range (1..${rows.length})`);
189
+ return;
190
+ }
191
+ row = rows[idx];
192
+ } else {
193
+ const want = selector.toUpperCase();
194
+ const matches = rows.filter((r) => r.id.toUpperCase() === want);
195
+ if (matches.length === 0) {
196
+ fail(`no decision item matches '${selector}'`);
197
+ return;
198
+ }
199
+ if (matches.length > 1) {
200
+ fail(`duplicate id '${selector}' in ledger; select by ordinal instead`);
201
+ return;
202
+ }
203
+ row = matches[0];
204
+ }
205
+
206
+ const r = row!;
207
+ const block = findDetailBlock(r, taskDir);
208
+ const lines: string[] = [];
209
+ if (format === 'markdown') {
210
+ lines.push(`**${r.id}** (${r.stage}/${r.severity}) · status=\`${r.status}\` · evidence: \`${r.evidence}\``, '');
211
+ } else {
212
+ lines.push(`${r.id} (${r.stage}/${r.severity}) status=${r.status}`, `evidence: ${r.evidence}`, '');
213
+ }
214
+ if (block) {
215
+ lines.push(block);
216
+ } else {
217
+ lines.push(
218
+ `(详情块未找到:未在任务产物中定位到 \`### ${r.id}\` 锚点,可能为历史产物或尚未写入;evidence 指向 ${r.evidence})`
219
+ );
220
+ }
221
+ if (r.status === 'human-decided') {
222
+ const record = findDecisionRecord(r.id, content);
223
+ if (record.length) {
224
+ lines.push('', '人工裁定:', ...record);
225
+ }
226
+ }
227
+ process.stdout.write(`${lines.join('\n')}\n`);
228
+ }
229
+
230
+ function decisions(args: string[] = []): void {
231
+ if (args[0] === '--help' || args[0] === '-h') {
232
+ process.stdout.write(USAGE);
233
+ return;
234
+ }
235
+ const parsed = parseArgs(args);
236
+ if (!parsed) return;
237
+ if (parsed.positionals.length === 0) {
238
+ process.stdout.write(USAGE);
239
+ process.exitCode = 1;
240
+ return;
241
+ }
242
+ if (parsed.stage !== undefined && !STAGES.has(parsed.stage)) {
243
+ fail(`invalid --stage '${parsed.stage}' (expected analysis|plan|code)`);
244
+ return;
245
+ }
246
+ if (!FORMATS.has(parsed.format)) {
247
+ fail(`invalid --format '${parsed.format}' (expected text|markdown)`);
248
+ return;
249
+ }
250
+
251
+ const resolved = resolveTaskRef(parsed.positionals[0]!);
252
+ if (!resolved.ok) {
253
+ fail(resolved.message);
254
+ return;
255
+ }
256
+
257
+ const content = fs.readFileSync(resolved.taskMdPath, 'utf8');
258
+ let rows = parseLedger(content).filter((r) => HD_ID_RE.test(r.id));
259
+ rows = rows.filter((r) =>
260
+ parsed.all ? HUMAN_DECISION_STATUSES.has(r.status) : r.status === 'needs-human-decision'
261
+ );
262
+ if (parsed.stage !== undefined) rows = rows.filter((r) => r.stage === parsed.stage);
263
+
264
+ const selector = parsed.positionals[1];
265
+ if (selector === undefined) {
266
+ renderList(rows, parsed.format, resolved.taskDir);
267
+ } else {
268
+ renderDetail(rows, selector, parsed.format, resolved.taskDir, content);
269
+ }
270
+ }
271
+
272
+ export { decisions };
@@ -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 };