@fitlab-ai/agent-infra 0.7.7 → 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.
- package/README.md +2 -0
- package/README.zh-CN.md +2 -0
- package/bin/cli.ts +42 -15
- package/dist/bin/cli.js +46 -16
- package/dist/lib/decide.js +110 -0
- package/dist/lib/run/host.js +39 -0
- package/dist/lib/run/index.js +102 -0
- package/dist/lib/run/prompt.js +2 -0
- package/dist/lib/run/skills.js +29 -0
- package/dist/lib/run/tui.js +35 -0
- package/dist/lib/sandbox/capture.js +49 -0
- package/dist/lib/sandbox/commands/create.js +55 -6
- package/dist/lib/server/adapters/_contract.js +9 -0
- package/dist/lib/server/adapters/feishu/index.js +40 -0
- package/dist/lib/server/adapters/feishu/transport.js +95 -0
- package/dist/lib/server/auth.js +19 -0
- package/dist/lib/server/config.js +183 -0
- package/dist/lib/server/daemon.js +96 -0
- package/dist/lib/server/index.js +47 -0
- package/dist/lib/server/logger.js +34 -0
- package/dist/lib/server/plugin-loader.js +73 -0
- package/dist/lib/server/process-control.js +200 -0
- package/dist/lib/server/protocol.js +69 -0
- package/dist/lib/server/redact.js +12 -0
- package/dist/lib/server/runner.js +75 -0
- package/dist/lib/server/streamer.js +44 -0
- package/dist/lib/task/commands/decisions.js +258 -0
- package/dist/lib/task/commands/log.js +2 -27
- package/dist/lib/task/index.js +9 -0
- package/dist/lib/task/ledger.js +66 -0
- package/dist/lib/task/sections.js +30 -1
- package/lib/decide.ts +124 -0
- package/lib/run/host.ts +47 -0
- package/lib/run/index.ts +124 -0
- package/lib/run/prompt.ts +1 -0
- package/lib/run/skills.ts +34 -0
- package/lib/run/tui.ts +40 -0
- package/lib/sandbox/capture.ts +84 -0
- package/lib/sandbox/commands/create.ts +61 -9
- package/lib/server/adapters/_contract.ts +42 -0
- package/lib/server/adapters/feishu/index.ts +47 -0
- package/lib/server/adapters/feishu/transport.ts +128 -0
- package/lib/server/auth.ts +28 -0
- package/lib/server/config.ts +228 -0
- package/lib/server/daemon.ts +110 -0
- package/lib/server/index.ts +50 -0
- package/lib/server/logger.ts +48 -0
- package/lib/server/plugin-loader.ts +84 -0
- package/lib/server/process-control.ts +225 -0
- package/lib/server/protocol.ts +79 -0
- package/lib/server/redact.ts +12 -0
- package/lib/server/runner.ts +95 -0
- package/lib/server/server.schema.json +127 -0
- package/lib/server/streamer.ts +57 -0
- package/lib/task/commands/decisions.ts +272 -0
- package/lib/task/commands/log.ts +2 -25
- package/lib/task/index.ts +9 -0
- package/lib/task/ledger.ts +73 -0
- package/lib/task/sections.ts +30 -1
- package/package.json +7 -3
- package/templates/.agents/QUICKSTART.zh-CN.md +2 -2
- package/templates/.agents/rules/issue-fields.github.en.md +1 -1
- package/templates/.agents/rules/issue-fields.github.zh-CN.md +1 -1
- package/templates/.agents/rules/next-step-output.en.md +4 -0
- package/templates/.agents/rules/next-step-output.zh-CN.md +4 -0
- package/templates/.agents/rules/no-mid-flow-questions.en.md +7 -2
- package/templates/.agents/rules/no-mid-flow-questions.zh-CN.md +7 -2
- package/templates/.agents/rules/review-handshake.en.md +5 -1
- package/templates/.agents/rules/review-handshake.zh-CN.md +5 -1
- package/templates/.agents/rules/task-management.en.md +2 -2
- package/templates/.agents/rules/task-management.zh-CN.md +2 -2
- package/templates/.agents/scripts/lib/post-review-commit.js +15 -18
- package/templates/.agents/scripts/validate-artifact.js +8 -0
- package/templates/.agents/skills/analyze-task/SKILL.en.md +15 -3
- package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +15 -3
- package/templates/.agents/skills/analyze-task/config/verify.en.json +2 -1
- package/templates/.agents/skills/analyze-task/config/verify.zh-CN.json +2 -1
- package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
- package/templates/.agents/skills/code-task/SKILL.zh-CN.md +1 -1
- package/templates/.agents/skills/complete-task/SKILL.en.md +1 -0
- package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +1 -0
- package/templates/.agents/skills/complete-task/config/verify.en.json +2 -1
- package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +2 -1
- package/templates/.agents/skills/create-task/SKILL.en.md +1 -3
- package/templates/.agents/skills/create-task/SKILL.zh-CN.md +1 -3
- package/templates/.agents/skills/import-codescan/SKILL.en.md +0 -3
- package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +0 -3
- package/templates/.agents/skills/import-dependabot/SKILL.en.md +0 -1
- package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +0 -1
- package/templates/.agents/skills/import-issue/SKILL.en.md +1 -2
- package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +1 -2
- package/templates/.agents/skills/plan-task/SKILL.en.md +1 -2
- package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +1 -2
- package/templates/.agents/templates/task.en.md +3 -3
- package/templates/.agents/templates/task.zh-CN.md +3 -3
- package/templates/.github/workflows/metadata-sync.yml +0 -18
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { formatTable } from "../../table.js";
|
|
3
3
|
import { resolveTaskRef } from "../resolve-ref.js";
|
|
4
|
+
import { parseLedger, HUMAN_DECISION_STATUSES } from "../ledger.js";
|
|
4
5
|
const USAGE = `Usage: ai task log <N | #N | TASK-id>
|
|
5
6
|
|
|
6
7
|
Renders a task's activity log as a per-step status table. A step's start and
|
|
@@ -17,7 +18,6 @@ Columns: # (row) / STEP / AGENT / STARTED / DONE / NOTE
|
|
|
17
18
|
const TABLE_HEADERS = ['#', 'STEP', 'AGENT', 'STARTED', 'DONE', 'NOTE'];
|
|
18
19
|
// The activity-log H2 heading is language-dependent (zh template / en template).
|
|
19
20
|
const HEADING_RE = /^##\s+(活动日志|Activity Log)\s*$/;
|
|
20
|
-
const LEDGER_HEADING_RE = /^##\s+(审查分歧账本|Review Disagreement Ledger)\s*$/;
|
|
21
21
|
const NEXT_H2_RE = /^##\s/;
|
|
22
22
|
// `- {time} — **{step}** by {agent} — {note}` ; the separator is an em-dash
|
|
23
23
|
// (U+2014). STEP/AGENT are non-greedy so a note that itself contains ' — ' or
|
|
@@ -33,7 +33,6 @@ const STARTED_SUFFIX_RE = /\s*\[started\]\s*$/;
|
|
|
33
33
|
// note these differ from the `.airc.json` long names claude-code/gemini-cli).
|
|
34
34
|
// Any other executor token (a human name, possibly CJK) is treated as human.
|
|
35
35
|
const KNOWN_AI_AGENTS = new Set(['claude', 'codex', 'gemini', 'opencode', 'cursor']);
|
|
36
|
-
const HUMAN_DECISION_STATUSES = new Set(['needs-human-decision', 'human-decided']);
|
|
37
36
|
const ENV_BLOCKED_RE = /\(\+\s*(\d+)\s+env-blocked\)/i;
|
|
38
37
|
// Same match plus any leading whitespace, so folding the count into the verdict
|
|
39
38
|
// text drops the redundant `(+ n env-blocked)` fragment without leaving a gap.
|
|
@@ -102,30 +101,6 @@ function pairEntries(entries) {
|
|
|
102
101
|
}
|
|
103
102
|
return rows;
|
|
104
103
|
}
|
|
105
|
-
function parseReviewLedger(content) {
|
|
106
|
-
const lines = content.split('\n');
|
|
107
|
-
let i = 0;
|
|
108
|
-
while (i < lines.length && !LEDGER_HEADING_RE.test(lines[i]))
|
|
109
|
-
i += 1;
|
|
110
|
-
if (i >= lines.length)
|
|
111
|
-
return [];
|
|
112
|
-
const rows = [];
|
|
113
|
-
for (let j = i + 1; j < lines.length; j += 1) {
|
|
114
|
-
if (NEXT_H2_RE.test(lines[j]))
|
|
115
|
-
break;
|
|
116
|
-
const line = lines[j].trim();
|
|
117
|
-
if (!line.startsWith('|'))
|
|
118
|
-
continue;
|
|
119
|
-
const cells = line
|
|
120
|
-
.split('|')
|
|
121
|
-
.slice(1, -1)
|
|
122
|
-
.map((cell) => cell.trim());
|
|
123
|
-
if (cells.length < 6 || cells[0] === 'id' || /^-+$/.test(cells[0] ?? ''))
|
|
124
|
-
continue;
|
|
125
|
-
rows.push({ stage: cells[1], status: cells[4] });
|
|
126
|
-
}
|
|
127
|
-
return rows;
|
|
128
|
-
}
|
|
129
104
|
function countHumanDecisionsByStage(rows) {
|
|
130
105
|
const counts = new Map();
|
|
131
106
|
for (const row of rows) {
|
|
@@ -188,7 +163,7 @@ function log(args = []) {
|
|
|
188
163
|
return;
|
|
189
164
|
}
|
|
190
165
|
const steps = pairEntries(entries);
|
|
191
|
-
const humanDecisionCounts = countHumanDecisionsByStage(
|
|
166
|
+
const humanDecisionCounts = countHumanDecisionsByStage(parseLedger(content));
|
|
192
167
|
const rows = steps.map((s, idx) => {
|
|
193
168
|
const stage = reviewStageForStep(s.step);
|
|
194
169
|
const note = stage
|
package/dist/lib/task/index.js
CHANGED
|
@@ -2,6 +2,7 @@ const USAGE = `Usage: ai task <command> [options]
|
|
|
2
2
|
|
|
3
3
|
Commands:
|
|
4
4
|
cat <ref> <artifact | N> Print a task artifact (by name or number)
|
|
5
|
+
decisions, d <ref> [selector] List human-decision (HD-) items, or show one's detail
|
|
5
6
|
files <ref> List artifacts in a task dir (numbered)
|
|
6
7
|
grep <pattern> [ref] [artifact | N] Literal search across task artifacts (omit ref to scan all)
|
|
7
8
|
issue-body <ref> [--template <path>] Print a deterministic Issue body from task.md (描述 + 需求, or an Issue Form)
|
|
@@ -13,6 +14,8 @@ Commands:
|
|
|
13
14
|
Examples:
|
|
14
15
|
ai task cat 11 analysis
|
|
15
16
|
ai task cat 11 3
|
|
17
|
+
ai task decisions 11
|
|
18
|
+
ai task d 11 HD-3 --format markdown
|
|
16
19
|
ai task files 11
|
|
17
20
|
ai task grep resolveArtifact
|
|
18
21
|
ai task grep resolveArtifact 11
|
|
@@ -42,6 +45,12 @@ export async function runTask(args) {
|
|
|
42
45
|
cat(rest);
|
|
43
46
|
break;
|
|
44
47
|
}
|
|
48
|
+
case 'decisions':
|
|
49
|
+
case 'd': {
|
|
50
|
+
const { decisions } = await import("./commands/decisions.js");
|
|
51
|
+
decisions(rest);
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
45
54
|
case 'files': {
|
|
46
55
|
const { files } = await import("./commands/files.js");
|
|
47
56
|
files(rest);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Shared parser for the task.md `## 审查分歧账本` (Review Disagreement Ledger).
|
|
2
|
+
// Single source of truth for ledger row parsing across `ai task` commands
|
|
3
|
+
// (log, decisions) — see the plan's D1 constraint "no third parser". The gate
|
|
4
|
+
// parser in `.agents/scripts/validate-artifact.js` is a separate concern and
|
|
5
|
+
// must be kept semantically in sync by hand (noted in review-handshake.md).
|
|
6
|
+
// The ledger H2 heading is language-dependent (zh template / en template).
|
|
7
|
+
const LEDGER_HEADING_RE = /^##\s+(审查分歧账本|Review Disagreement Ledger)\s*$/;
|
|
8
|
+
const NEXT_H2_RE = /^##\s/;
|
|
9
|
+
// Terminal statuses the completion gates treat as resolved.
|
|
10
|
+
const LEDGER_TERMINAL = new Set(['confirmed', 'closed', 'human-decided']);
|
|
11
|
+
// Statuses that represent an executor-raised human-decision row (pending or done).
|
|
12
|
+
const HUMAN_DECISION_STATUSES = new Set(['needs-human-decision', 'human-decided']);
|
|
13
|
+
// Parse all rows of the disagreement ledger table. Skips the heading, the
|
|
14
|
+
// header row (`| id | ... |`) and the `|---|` separator; ignores non-`|` lines.
|
|
15
|
+
// Rows with fewer than 6 columns are skipped (mirrors the JS gate parser).
|
|
16
|
+
function parseLedger(content) {
|
|
17
|
+
const lines = content.split('\n');
|
|
18
|
+
let i = 0;
|
|
19
|
+
while (i < lines.length && !LEDGER_HEADING_RE.test(lines[i]))
|
|
20
|
+
i += 1;
|
|
21
|
+
if (i >= lines.length)
|
|
22
|
+
return [];
|
|
23
|
+
const rows = [];
|
|
24
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
25
|
+
if (NEXT_H2_RE.test(lines[j]))
|
|
26
|
+
break;
|
|
27
|
+
const line = lines[j].trim();
|
|
28
|
+
if (!line.startsWith('|'))
|
|
29
|
+
continue;
|
|
30
|
+
const cells = line
|
|
31
|
+
.split('|')
|
|
32
|
+
.slice(1, -1)
|
|
33
|
+
.map((cell) => cell.trim());
|
|
34
|
+
if (cells.length < 6)
|
|
35
|
+
continue;
|
|
36
|
+
if (cells[0] === 'id' || /^-+$/.test(cells[0] ?? ''))
|
|
37
|
+
continue;
|
|
38
|
+
rows.push({
|
|
39
|
+
id: cells[0],
|
|
40
|
+
stage: cells[1],
|
|
41
|
+
round: cells[2],
|
|
42
|
+
severity: cells[3],
|
|
43
|
+
status: cells[4],
|
|
44
|
+
evidence: cells[5]
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return rows;
|
|
48
|
+
}
|
|
49
|
+
// Allocate the next globally-unique human-decision id. Scans every `HD-<n>`
|
|
50
|
+
// already present in the ledger (across all stages) and returns `HD-{max+1}`,
|
|
51
|
+
// or `HD-1` when none exist. Global monotonic allocation prevents id collisions
|
|
52
|
+
// across the analysis / plan / code stages (plan PL-2).
|
|
53
|
+
function nextHdId(rows) {
|
|
54
|
+
let max = 0;
|
|
55
|
+
for (const row of rows) {
|
|
56
|
+
const m = /^HD-(\d+)$/.exec(row.id);
|
|
57
|
+
if (!m)
|
|
58
|
+
continue;
|
|
59
|
+
const n = Number.parseInt(m[1], 10);
|
|
60
|
+
if (n > max)
|
|
61
|
+
max = n;
|
|
62
|
+
}
|
|
63
|
+
return `HD-${max + 1}`;
|
|
64
|
+
}
|
|
65
|
+
export { parseLedger, nextHdId, LEDGER_TERMINAL, HUMAN_DECISION_STATUSES };
|
|
66
|
+
//# sourceMappingURL=ledger.js.map
|
|
@@ -40,5 +40,34 @@ function findSectionHeading(content, aliases) {
|
|
|
40
40
|
}
|
|
41
41
|
return aliases[0];
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Return the body of the first `### {headingPrefix}` sub-section, from the
|
|
45
|
+
* heading line (inclusive) to the next `### ` / `## ` heading or EOF. Used to
|
|
46
|
+
* pull a single `### HD-N` human-decision detail block out of an artifact. The
|
|
47
|
+
* prefix must be followed by a word boundary so `HD-1` does not match `HD-10`
|
|
48
|
+
* (e.g. `### HD-1`, `### HD-1:标题`, `### HD-1 [needs-human-decision]`). Leading
|
|
49
|
+
* and trailing blank lines are trimmed. Returns '' when no match is present.
|
|
50
|
+
*/
|
|
51
|
+
function extractSubSection(content, headingPrefix) {
|
|
52
|
+
const lines = content.split('\n');
|
|
53
|
+
const headRe = new RegExp(`^###\\s+${escapeRegExp(headingPrefix)}(?![\\w-])`);
|
|
54
|
+
let start = -1;
|
|
55
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
56
|
+
if (headRe.test(lines[i].trim())) {
|
|
57
|
+
start = i;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (start === -1)
|
|
62
|
+
return '';
|
|
63
|
+
let end = lines.length;
|
|
64
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
65
|
+
if (/^###?\s+/.test(lines[i])) {
|
|
66
|
+
end = i;
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return lines.slice(start, end).join('\n').replace(/^\n+/, '').replace(/\n+$/, '');
|
|
71
|
+
}
|
|
72
|
+
export { extractSection, findSectionHeading, extractSubSection };
|
|
44
73
|
//# sourceMappingURL=sections.js.map
|
package/lib/decide.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { VERSION } from './version.ts';
|
|
5
|
+
import { resolveTaskRef } from './task/resolve-ref.ts';
|
|
6
|
+
|
|
7
|
+
const TASK_ID_RE = /^TASK-\d{8}-\d{6}$/;
|
|
8
|
+
|
|
9
|
+
type DecideOptions = {
|
|
10
|
+
repoRoot?: string;
|
|
11
|
+
now?: () => string;
|
|
12
|
+
version?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function detectRepoRoot(): string {
|
|
16
|
+
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
17
|
+
encoding: 'utf8',
|
|
18
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
19
|
+
}).trim();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function defaultNow(): string {
|
|
23
|
+
return new Intl.DateTimeFormat('sv-SE', {
|
|
24
|
+
timeZoneName: 'longOffset',
|
|
25
|
+
year: 'numeric',
|
|
26
|
+
month: '2-digit',
|
|
27
|
+
day: '2-digit',
|
|
28
|
+
hour: '2-digit',
|
|
29
|
+
minute: '2-digit',
|
|
30
|
+
second: '2-digit',
|
|
31
|
+
hour12: false
|
|
32
|
+
})
|
|
33
|
+
.format(new Date())
|
|
34
|
+
.replace(' GMT', '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function taskPath(repoRoot: string, ref: string): string {
|
|
38
|
+
if (!TASK_ID_RE.test(ref)) {
|
|
39
|
+
const resolved = resolveTaskRef(ref);
|
|
40
|
+
if (!resolved.ok) throw new Error(resolved.message);
|
|
41
|
+
if (!resolved.taskDir.includes(`${path.join('.agents', 'workspace', 'active')}${path.sep}`)) {
|
|
42
|
+
throw new Error(`task ${resolved.taskId} is not active`);
|
|
43
|
+
}
|
|
44
|
+
return resolved.taskMdPath;
|
|
45
|
+
}
|
|
46
|
+
const candidate = path.join(repoRoot, '.agents', 'workspace', 'active', ref, 'task.md');
|
|
47
|
+
if (!fs.existsSync(candidate)) throw new Error(`active task not found: ${ref}`);
|
|
48
|
+
return candidate;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function replaceFrontmatterField(content: string, field: string, value: string): string {
|
|
52
|
+
const re = new RegExp(`^${field}:.*$`, 'm');
|
|
53
|
+
if (re.test(content)) return content.replace(re, `${field}: ${value}`);
|
|
54
|
+
return content.replace(/^---\n/, `---\n${field}: ${value}\n`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function replaceLedgerRow(content: string, hdId: string): { content: string; found: boolean; pending: boolean } {
|
|
58
|
+
const lines = content.split('\n');
|
|
59
|
+
let found = false;
|
|
60
|
+
let pending = false;
|
|
61
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
62
|
+
const line = lines[i] as string;
|
|
63
|
+
if (!line.trim().startsWith(`| ${hdId} |`)) continue;
|
|
64
|
+
found = true;
|
|
65
|
+
const cells = line.split('|').slice(1, -1).map((cell) => cell.trim());
|
|
66
|
+
if (cells[4] !== 'needs-human-decision') break;
|
|
67
|
+
pending = true;
|
|
68
|
+
cells[4] = 'human-decided';
|
|
69
|
+
cells[5] = `task.md#${hdId}`;
|
|
70
|
+
lines[i] = `| ${cells.join(' | ')} |`;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
return { content: lines.join('\n'), found, pending };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function appendUnderHeading(content: string, heading: string, block: string): string {
|
|
77
|
+
if (!content.includes(`${heading}\n`)) {
|
|
78
|
+
return `${content.trimEnd()}\n\n${heading}\n\n${block}\n`;
|
|
79
|
+
}
|
|
80
|
+
const idx = content.indexOf(`${heading}\n`) + heading.length + 1;
|
|
81
|
+
const before = content.slice(0, idx);
|
|
82
|
+
const after = content.slice(idx);
|
|
83
|
+
return `${before}\n${block}\n${after.replace(/^\n/, '')}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function decide(args: string[], options: DecideOptions = {}): Promise<number> {
|
|
87
|
+
const [taskRef, hdId, ...decisionParts] = args;
|
|
88
|
+
if (!taskRef || !hdId || decisionParts.length === 0) {
|
|
89
|
+
process.stderr.write('Usage: ai decide <task-ref> <HD-id> <decision>\n');
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const repoRoot = options.repoRoot ?? detectRepoRoot();
|
|
94
|
+
const file = taskPath(repoRoot, taskRef);
|
|
95
|
+
let content = fs.readFileSync(file, 'utf8');
|
|
96
|
+
const replaced = replaceLedgerRow(content, hdId);
|
|
97
|
+
if (!replaced.found) throw new Error(`${hdId} not found in review ledger`);
|
|
98
|
+
if (!replaced.pending) throw new Error(`${hdId} is not needs-human-decision`);
|
|
99
|
+
content = replaced.content;
|
|
100
|
+
const now = (options.now ?? defaultNow)();
|
|
101
|
+
content = replaceFrontmatterField(content, 'updated_at', now);
|
|
102
|
+
content = replaceFrontmatterField(content, 'agent_infra_version', options.version ?? VERSION);
|
|
103
|
+
const decision = decisionParts.join(' ');
|
|
104
|
+
content = appendUnderHeading(
|
|
105
|
+
content,
|
|
106
|
+
'## 人工裁决',
|
|
107
|
+
`### ${hdId}\n\n- **裁决时间**:${now}\n- **裁决结果**:${decision}`
|
|
108
|
+
);
|
|
109
|
+
content = appendUnderHeading(
|
|
110
|
+
content,
|
|
111
|
+
'## 活动日志',
|
|
112
|
+
`- ${now} — **Human Decision** by human — ${hdId} decided`
|
|
113
|
+
);
|
|
114
|
+
fs.writeFileSync(file, content);
|
|
115
|
+
return 0;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
process.stderr.write(`Error: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
118
|
+
return 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function cmdDecide(args: string[]): Promise<void> {
|
|
123
|
+
process.exitCode = await decide(args);
|
|
124
|
+
}
|
package/lib/run/host.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export type RunProcessResult = {
|
|
6
|
+
exitCode: number | null;
|
|
7
|
+
signal?: NodeJS.Signals | null;
|
|
8
|
+
stdout?: string;
|
|
9
|
+
stderr?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function resolveCommand(file: string): string {
|
|
13
|
+
if (process.platform !== 'win32' || path.extname(file)) {
|
|
14
|
+
return file;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const pathValue = process.env.Path || process.env.PATH || '';
|
|
18
|
+
const extensions = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
|
|
19
|
+
for (const dir of pathValue.split(path.delimiter).filter(Boolean)) {
|
|
20
|
+
for (const extension of extensions) {
|
|
21
|
+
const lowerCandidate = path.join(dir, `${file}${extension.toLowerCase()}`);
|
|
22
|
+
if (fs.existsSync(lowerCandidate)) return lowerCandidate;
|
|
23
|
+
const upperCandidate = path.join(dir, `${file}${extension.toUpperCase()}`);
|
|
24
|
+
if (fs.existsSync(upperCandidate)) return upperCandidate;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return file;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function needsShell(file: string): boolean {
|
|
32
|
+
return process.platform === 'win32' && /\.(?:bat|cmd)$/i.test(file);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function runHostCommand(command: string[]): Promise<RunProcessResult> {
|
|
36
|
+
const [file, ...args] = command;
|
|
37
|
+
if (!file) throw new Error('run: missing command');
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const resolvedFile = resolveCommand(file);
|
|
40
|
+
const child = spawn(resolvedFile, args, {
|
|
41
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
42
|
+
shell: needsShell(resolvedFile)
|
|
43
|
+
});
|
|
44
|
+
child.on('error', reject);
|
|
45
|
+
child.on('close', (exitCode, signal) => resolve({ exitCode, signal }));
|
|
46
|
+
});
|
|
47
|
+
}
|
package/lib/run/index.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { loadServerConfig } from '../server/config.ts';
|
|
2
|
+
import { resolveTaskBranch } from '../sandbox/task-resolver.ts';
|
|
3
|
+
import { runInSandbox } from '../sandbox/capture.ts';
|
|
4
|
+
import { buildTuiCommand, renderPrompt, selectTui } from './tui.ts';
|
|
5
|
+
import { getSkillRunSpec } from './skills.ts';
|
|
6
|
+
import { runHostCommand, type RunProcessResult } from './host.ts';
|
|
7
|
+
|
|
8
|
+
export type ParsedRunArgs = {
|
|
9
|
+
skill: string;
|
|
10
|
+
taskRef: string | null;
|
|
11
|
+
args: string[];
|
|
12
|
+
tui: string | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type SandboxRunRequest = {
|
|
16
|
+
taskRef: string;
|
|
17
|
+
branch: string;
|
|
18
|
+
command: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type RunSkillOptions = {
|
|
22
|
+
command?: Record<string, unknown>;
|
|
23
|
+
repoRoot?: string;
|
|
24
|
+
runHost?: (command: string[]) => Promise<RunProcessResult>;
|
|
25
|
+
runSandbox?: (request: SandboxRunRequest) => Promise<RunProcessResult>;
|
|
26
|
+
writeStdout?: (chunk: string) => void;
|
|
27
|
+
writeStderr?: (chunk: string) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const USAGE = `Usage: ai run <skill> [task-ref] [args...] [--tui <name>]
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
ai run create-task "describe the task" --tui codex
|
|
34
|
+
ai run code-task #7 --tui codex`;
|
|
35
|
+
|
|
36
|
+
function extractTui(args: string[]): { rest: string[]; tui: string | null } {
|
|
37
|
+
const rest: string[] = [];
|
|
38
|
+
let tui: string | null = null;
|
|
39
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
40
|
+
const arg = args[i] as string;
|
|
41
|
+
if (arg === '--tui') {
|
|
42
|
+
const value = args[i + 1];
|
|
43
|
+
if (!value) throw new Error('--tui requires a value');
|
|
44
|
+
tui = value;
|
|
45
|
+
i += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
rest.push(arg);
|
|
49
|
+
}
|
|
50
|
+
return { rest, tui };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function parseRunArgs(args: string[]): ParsedRunArgs {
|
|
54
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
55
|
+
throw new Error(USAGE);
|
|
56
|
+
}
|
|
57
|
+
const { rest, tui } = extractTui(args);
|
|
58
|
+
const [skill = '', maybeTaskRef, ...remaining] = rest;
|
|
59
|
+
const spec = getSkillRunSpec(skill);
|
|
60
|
+
if (!spec) throw new Error(`Unknown skill '${skill}'`);
|
|
61
|
+
if (spec.kind === 'create') {
|
|
62
|
+
const createArgs = rest.slice(1);
|
|
63
|
+
if (createArgs.length === 0) throw new Error('create-task requires a description');
|
|
64
|
+
return { skill, taskRef: null, args: createArgs, tui };
|
|
65
|
+
}
|
|
66
|
+
if (!maybeTaskRef) throw new Error(`${skill} requires a task-ref`);
|
|
67
|
+
return { skill, taskRef: maybeTaskRef, args: remaining, tui };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function assertAllowedByConfig(skill: string, commandConfig: Record<string, unknown>): void {
|
|
71
|
+
const allowed = commandConfig.allowedSkills;
|
|
72
|
+
if (!Array.isArray(allowed)) return;
|
|
73
|
+
if (!allowed.every((entry) => typeof entry === 'string')) {
|
|
74
|
+
throw new Error('command.allowedSkills must be an array of skill names');
|
|
75
|
+
}
|
|
76
|
+
if (!allowed.includes(skill)) {
|
|
77
|
+
throw new Error(`Skill '${skill}' is not allowed by command.allowedSkills`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function runSkill(args: string[], options: RunSkillOptions = {}): Promise<number> {
|
|
82
|
+
const parsed = parseRunArgs(args);
|
|
83
|
+
const config = options.command ? null : loadServerConfig({ rootDir: options.repoRoot });
|
|
84
|
+
const commandConfig = options.command ?? config?.command ?? {};
|
|
85
|
+
assertAllowedByConfig(parsed.skill, commandConfig);
|
|
86
|
+
const tui = selectTui(parsed.skill, { cliTui: parsed.tui, command: commandConfig });
|
|
87
|
+
const promptArgs = parsed.taskRef === null ? parsed.args : [parsed.taskRef, ...parsed.args];
|
|
88
|
+
const prompt = renderPrompt({ tui, skill: parsed.skill, args: promptArgs });
|
|
89
|
+
const [file, argv] = buildTuiCommand(tui, prompt);
|
|
90
|
+
const command = [file, ...argv];
|
|
91
|
+
|
|
92
|
+
if (parsed.taskRef === null) {
|
|
93
|
+
const result = await (options.runHost ?? runHostCommand)(command);
|
|
94
|
+
return result.exitCode ?? (result.signal ? 1 : 0);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const repoRoot = options.repoRoot ?? config?.repoRoot ?? process.cwd();
|
|
98
|
+
const branch = resolveTaskBranch(parsed.taskRef, repoRoot);
|
|
99
|
+
const runSandbox = options.runSandbox ?? ((request: SandboxRunRequest) => runInSandbox(request));
|
|
100
|
+
const result = await runSandbox({ taskRef: parsed.taskRef, branch, command });
|
|
101
|
+
if (result.stdout) {
|
|
102
|
+
(options.writeStdout ?? ((chunk: string) => process.stdout.write(chunk)))(result.stdout);
|
|
103
|
+
}
|
|
104
|
+
if (result.stderr) {
|
|
105
|
+
(options.writeStderr ?? ((chunk: string) => process.stderr.write(chunk)))(result.stderr);
|
|
106
|
+
}
|
|
107
|
+
return result.exitCode ?? (result.signal ? 1 : 0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function cmdRun(args: string[]): Promise<void> {
|
|
111
|
+
try {
|
|
112
|
+
const code = await runSkill(args);
|
|
113
|
+
process.exitCode = code;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
116
|
+
if (message.startsWith('Usage:')) {
|
|
117
|
+
process.stdout.write(`${message}\n`);
|
|
118
|
+
process.exitCode = args.length === 0 ? 1 : 0;
|
|
119
|
+
} else {
|
|
120
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
121
|
+
process.exitCode = 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { renderPrompt } from './tui.ts';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type SkillRunSpec =
|
|
2
|
+
| { kind: 'task'; skill: string; role: 'exec'; requiresSandbox: true }
|
|
3
|
+
| { kind: 'create'; skill: 'create-task'; role: 'exec'; requiresSandbox: false };
|
|
4
|
+
|
|
5
|
+
const TASK_SKILLS = new Set([
|
|
6
|
+
'analyze-task',
|
|
7
|
+
'block-task',
|
|
8
|
+
'cancel-task',
|
|
9
|
+
'code-task',
|
|
10
|
+
'commit',
|
|
11
|
+
'complete-task',
|
|
12
|
+
'create-pr',
|
|
13
|
+
'plan-task',
|
|
14
|
+
'review-analysis',
|
|
15
|
+
'review-code',
|
|
16
|
+
'review-plan',
|
|
17
|
+
'test',
|
|
18
|
+
'test-integration',
|
|
19
|
+
'watch-pr'
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export function getSkillRunSpec(skill: string): SkillRunSpec | null {
|
|
23
|
+
if (skill === 'create-task') {
|
|
24
|
+
return { kind: 'create', skill: 'create-task', role: 'exec', requiresSandbox: false };
|
|
25
|
+
}
|
|
26
|
+
if (TASK_SKILLS.has(skill)) {
|
|
27
|
+
return { kind: 'task', skill, role: 'exec', requiresSandbox: true };
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function allowedSkillNames(): string[] {
|
|
33
|
+
return ['create-task', ...TASK_SKILLS].sort();
|
|
34
|
+
}
|
package/lib/run/tui.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type TuiName = 'claude' | 'codex' | 'gemini' | 'opencode';
|
|
2
|
+
|
|
3
|
+
const TUI_NAMES = new Set(['claude', 'codex', 'gemini', 'opencode']);
|
|
4
|
+
|
|
5
|
+
export type CommandConfig = {
|
|
6
|
+
defaultTui?: unknown;
|
|
7
|
+
skillTuiDefaults?: unknown;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function isTuiName(value: unknown): value is TuiName {
|
|
11
|
+
return typeof value === 'string' && TUI_NAMES.has(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function selectTui(
|
|
15
|
+
skill: string,
|
|
16
|
+
options: { cliTui?: string | null; command?: CommandConfig }
|
|
17
|
+
): TuiName {
|
|
18
|
+
if (isTuiName(options.cliTui)) return options.cliTui;
|
|
19
|
+
const defaults = options.command?.skillTuiDefaults;
|
|
20
|
+
if (defaults && typeof defaults === 'object' && !Array.isArray(defaults)) {
|
|
21
|
+
const value = (defaults as Record<string, unknown>)[skill];
|
|
22
|
+
if (isTuiName(value)) return value;
|
|
23
|
+
}
|
|
24
|
+
if (isTuiName(options.command?.defaultTui)) return options.command.defaultTui;
|
|
25
|
+
return 'codex';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function renderPrompt(params: { tui: TuiName; skill: string; args: string[] }): string {
|
|
29
|
+
const suffix = [params.skill, ...params.args].join(' ').trim();
|
|
30
|
+
if (params.tui === 'codex') return `$${suffix}`;
|
|
31
|
+
if (params.tui === 'gemini') return `/agent-infra:${suffix}`;
|
|
32
|
+
return `/${suffix}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildTuiCommand(tui: TuiName, prompt: string): [string, string[]] {
|
|
36
|
+
if (tui === 'claude') return ['claude', ['--dangerously-skip-permissions', '--print', prompt]];
|
|
37
|
+
if (tui === 'gemini') return ['gemini', ['--approval-mode', 'yolo', '--prompt', prompt]];
|
|
38
|
+
if (tui === 'opencode') return ['opencode', ['run', '--dangerously-skip-permissions', prompt]];
|
|
39
|
+
return ['codex', ['exec', '--dangerously-bypass-approvals-and-sandbox', prompt]];
|
|
40
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { loadConfig } from './config.ts';
|
|
3
|
+
import { containerNameCandidates, sandboxBranchLabel, sandboxLabel } from './constants.ts';
|
|
4
|
+
import { detectEngine } from './engine.ts';
|
|
5
|
+
import { hostTimezoneEnvFlags, terminalEnvFlags } from './commands/enter.ts';
|
|
6
|
+
import {
|
|
7
|
+
fetchSandboxRows,
|
|
8
|
+
selectSandboxContainer,
|
|
9
|
+
startSandboxContainer,
|
|
10
|
+
type SandboxRow
|
|
11
|
+
} from './commands/list-running.ts';
|
|
12
|
+
|
|
13
|
+
export type SandboxCaptureRequest = {
|
|
14
|
+
taskRef: string;
|
|
15
|
+
branch: string;
|
|
16
|
+
command: string[];
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type SandboxCaptureResult = {
|
|
21
|
+
exitCode: number | null;
|
|
22
|
+
signal: NodeJS.Signals | null;
|
|
23
|
+
stdout: string;
|
|
24
|
+
stderr: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type SandboxCaptureOptions = {
|
|
28
|
+
engine?: string;
|
|
29
|
+
repoRoot?: string;
|
|
30
|
+
containerCandidates?: string[];
|
|
31
|
+
rows?: SandboxRow[];
|
|
32
|
+
startContainer?: (name: string) => void;
|
|
33
|
+
spawn?: (file: string, args: string[]) => Promise<SandboxCaptureResult>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
async function spawnCapture(file: string, args: string[]): Promise<SandboxCaptureResult> {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const child = spawn(file, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
39
|
+
let stdout = '';
|
|
40
|
+
let stderr = '';
|
|
41
|
+
child.stdout.setEncoding('utf8');
|
|
42
|
+
child.stderr.setEncoding('utf8');
|
|
43
|
+
child.stdout.on('data', (chunk) => {
|
|
44
|
+
stdout += chunk;
|
|
45
|
+
});
|
|
46
|
+
child.stderr.on('data', (chunk) => {
|
|
47
|
+
stderr += chunk;
|
|
48
|
+
});
|
|
49
|
+
child.on('error', reject);
|
|
50
|
+
child.on('close', (exitCode, signal) => resolve({ exitCode, signal, stdout, stderr }));
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function runInSandbox(
|
|
55
|
+
request: SandboxCaptureRequest,
|
|
56
|
+
options: SandboxCaptureOptions = {}
|
|
57
|
+
): Promise<SandboxCaptureResult> {
|
|
58
|
+
const config = options.engine ? null : loadConfig();
|
|
59
|
+
const engine = options.engine ?? detectEngine(config!);
|
|
60
|
+
const rows =
|
|
61
|
+
options.rows ??
|
|
62
|
+
(() => {
|
|
63
|
+
const fetched = fetchSandboxRows(engine, sandboxLabel(config!), sandboxBranchLabel(config!));
|
|
64
|
+
return [...fetched.running, ...fetched.nonRunning];
|
|
65
|
+
})();
|
|
66
|
+
const candidates = options.containerCandidates ?? containerNameCandidates(config!, request.branch);
|
|
67
|
+
const found = selectSandboxContainer(rows, candidates);
|
|
68
|
+
if (!found) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Sandbox for ${request.branch} not found. Create it first with ai sandbox create ${request.taskRef}.`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (!found.running) {
|
|
74
|
+
(options.startContainer ?? ((name: string) => startSandboxContainer(engine, name)))(found.name);
|
|
75
|
+
}
|
|
76
|
+
const dockerArgs = [
|
|
77
|
+
'exec',
|
|
78
|
+
...terminalEnvFlags(),
|
|
79
|
+
...hostTimezoneEnvFlags(),
|
|
80
|
+
found.name,
|
|
81
|
+
...request.command
|
|
82
|
+
];
|
|
83
|
+
return (options.spawn ?? spawnCapture)('docker', dockerArgs);
|
|
84
|
+
}
|