@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
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
4
|
+
import { loadServerConfig } from "./config.js";
|
|
5
|
+
import { runDaemon } from "./daemon.js";
|
|
6
|
+
export function buildStopCommand(pid, platform) {
|
|
7
|
+
if (platform === 'win32') {
|
|
8
|
+
return { kind: 'exec', command: 'taskkill', args: ['/PID', String(pid), '/T', '/F'] };
|
|
9
|
+
}
|
|
10
|
+
return { kind: 'signal', signal: 'SIGTERM' };
|
|
11
|
+
}
|
|
12
|
+
// Liveness check that treats an exited-but-unreaped daemon as dead.
|
|
13
|
+
//
|
|
14
|
+
// The daemon is spawned detached and re-parented to init when `ai server start`
|
|
15
|
+
// exits. After it terminates, `process.kill(pid, 0)` still succeeds until init
|
|
16
|
+
// reaps the zombie. On Linux we therefore also check /proc state so status/stop
|
|
17
|
+
// /start don't treat an already-exited daemon as running. macOS reaps orphans
|
|
18
|
+
// via launchd and Windows has no zombies, so the kill(pid, 0) result is enough.
|
|
19
|
+
export function isProcessAlive(pid) {
|
|
20
|
+
try {
|
|
21
|
+
process.kill(pid, 0);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
// EPERM means the process exists but we may not signal it → still alive.
|
|
25
|
+
return error.code === 'EPERM';
|
|
26
|
+
}
|
|
27
|
+
if (process.platform === 'linux') {
|
|
28
|
+
try {
|
|
29
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
30
|
+
// The process state is the first token after the parenthesised comm.
|
|
31
|
+
const state = stat.slice(stat.lastIndexOf(')') + 1).trim().charAt(0);
|
|
32
|
+
if (state === 'Z')
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false; // /proc entry vanished → not alive
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
function readPid(pidFile) {
|
|
42
|
+
try {
|
|
43
|
+
const raw = fs.readFileSync(pidFile, 'utf8').trim();
|
|
44
|
+
const pid = Number.parseInt(raw, 10);
|
|
45
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function removePidFile(pidFile) {
|
|
52
|
+
try {
|
|
53
|
+
fs.unlinkSync(pidFile);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Already gone.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function delay(ms) {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
setTimeout(resolve, ms);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function enabledAdapterNames(config) {
|
|
65
|
+
return Object.entries(config.adapters)
|
|
66
|
+
.filter(([, adapter]) => adapter?.enabled === true)
|
|
67
|
+
.map(([name]) => name);
|
|
68
|
+
}
|
|
69
|
+
export async function start({ foreground = false } = {}) {
|
|
70
|
+
const config = loadServerConfig();
|
|
71
|
+
const pidPath = config.pidFile;
|
|
72
|
+
// Zombie PID cleanup: a stale PID file from a crashed daemon must not block a
|
|
73
|
+
// fresh start.
|
|
74
|
+
const existing = readPid(pidPath);
|
|
75
|
+
if (existing !== null && isProcessAlive(existing)) {
|
|
76
|
+
process.stdout.write(`server already running (pid ${existing})\n`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (existing !== null) {
|
|
80
|
+
removePidFile(pidPath);
|
|
81
|
+
}
|
|
82
|
+
if (foreground) {
|
|
83
|
+
await runDaemon();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const cliEntry = process.argv[1];
|
|
87
|
+
if (!cliEntry) {
|
|
88
|
+
throw new Error('server: unable to determine CLI entry point for daemon spawn');
|
|
89
|
+
}
|
|
90
|
+
// Re-spawn ourselves detached. process.execArgv is forwarded so the dev path
|
|
91
|
+
// (node --experimental-strip-types ./bin/cli.ts) and the built path
|
|
92
|
+
// (node dist/bin/cli.js) both work.
|
|
93
|
+
const child = spawn(process.execPath, [...process.execArgv, cliEntry, 'server', '__daemon'], {
|
|
94
|
+
detached: true,
|
|
95
|
+
stdio: 'ignore'
|
|
96
|
+
});
|
|
97
|
+
child.unref();
|
|
98
|
+
if (typeof child.pid !== 'number') {
|
|
99
|
+
throw new Error('server: failed to spawn daemon process');
|
|
100
|
+
}
|
|
101
|
+
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
|
|
102
|
+
fs.writeFileSync(pidPath, `${child.pid}\n`);
|
|
103
|
+
process.stdout.write(`server started (pid ${child.pid})\n`);
|
|
104
|
+
}
|
|
105
|
+
export async function stop() {
|
|
106
|
+
const config = loadServerConfig();
|
|
107
|
+
const pid = readPid(config.pidFile);
|
|
108
|
+
if (pid === null) {
|
|
109
|
+
process.stdout.write('server is not running (no pid file)\n');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!isProcessAlive(pid)) {
|
|
113
|
+
removePidFile(config.pidFile);
|
|
114
|
+
process.stdout.write('server is not running (removed stale pid file)\n');
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const command = buildStopCommand(pid, process.platform);
|
|
118
|
+
if (command.kind === 'exec') {
|
|
119
|
+
execFileSync(command.command, command.args);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
process.kill(pid, command.signal);
|
|
123
|
+
const deadline = Date.now() + 5000;
|
|
124
|
+
while (isProcessAlive(pid) && Date.now() < deadline) {
|
|
125
|
+
await delay(100);
|
|
126
|
+
}
|
|
127
|
+
if (isProcessAlive(pid)) {
|
|
128
|
+
process.kill(pid, 'SIGKILL');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
removePidFile(config.pidFile);
|
|
132
|
+
process.stdout.write(`server stopped (pid ${pid})\n`);
|
|
133
|
+
}
|
|
134
|
+
export function status() {
|
|
135
|
+
const config = loadServerConfig();
|
|
136
|
+
const pid = readPid(config.pidFile);
|
|
137
|
+
if (pid === null || !isProcessAlive(pid)) {
|
|
138
|
+
process.stdout.write('server: stopped\n');
|
|
139
|
+
if (pid !== null) {
|
|
140
|
+
process.stdout.write(` (stale pid file references pid ${pid})\n`);
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
let startedAt = 'unknown';
|
|
145
|
+
try {
|
|
146
|
+
startedAt = fs.statSync(config.pidFile).mtime.toISOString();
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Leave as unknown.
|
|
150
|
+
}
|
|
151
|
+
const adapters = enabledAdapterNames(config);
|
|
152
|
+
process.stdout.write(`server: running\n` +
|
|
153
|
+
` pid: ${pid}\n` +
|
|
154
|
+
` started: ${startedAt}\n` +
|
|
155
|
+
` adapters: ${adapters.length > 0 ? adapters.join(', ') : '(none)'}\n` +
|
|
156
|
+
` pid file: ${config.pidFile}\n` +
|
|
157
|
+
` log: ${config.log.path}\n`);
|
|
158
|
+
}
|
|
159
|
+
export async function logs({ follow = false } = {}) {
|
|
160
|
+
const config = loadServerConfig();
|
|
161
|
+
const logPath = config.log.path;
|
|
162
|
+
if (!fs.existsSync(logPath)) {
|
|
163
|
+
process.stdout.write('server: no log file yet\n');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const initial = fs.readFileSync(logPath, 'utf8');
|
|
167
|
+
process.stdout.write(initial);
|
|
168
|
+
if (!follow)
|
|
169
|
+
return;
|
|
170
|
+
let position = Buffer.byteLength(initial);
|
|
171
|
+
const watcher = fs.watch(logPath, () => {
|
|
172
|
+
try {
|
|
173
|
+
const { size } = fs.statSync(logPath);
|
|
174
|
+
if (size < position)
|
|
175
|
+
position = 0; // truncated or rotated
|
|
176
|
+
if (size > position) {
|
|
177
|
+
const fd = fs.openSync(logPath, 'r');
|
|
178
|
+
try {
|
|
179
|
+
const buffer = Buffer.alloc(size - position);
|
|
180
|
+
fs.readSync(fd, buffer, 0, buffer.length, position);
|
|
181
|
+
process.stdout.write(buffer.toString('utf8'));
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
fs.closeSync(fd);
|
|
185
|
+
}
|
|
186
|
+
position = size;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Transient read error during rotation; ignore and wait for next event.
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
await new Promise((resolve) => {
|
|
194
|
+
process.on('SIGINT', () => {
|
|
195
|
+
watcher.close();
|
|
196
|
+
resolve();
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
//# sourceMappingURL=process-control.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { VERSION } from "../version.js";
|
|
2
|
+
import { getSkillRunSpec } from "../run/skills.js";
|
|
3
|
+
const TASK_READ = new Set(['ls', 'list', 'status', 'show', 'log', 'decisions']);
|
|
4
|
+
const SANDBOX_READ = new Set(['ls', 'list', 'show']);
|
|
5
|
+
const SANDBOX_WRITE = new Set(['create', 'start']);
|
|
6
|
+
function splitWords(text) {
|
|
7
|
+
return text.trim().split(/\s+/).filter(Boolean);
|
|
8
|
+
}
|
|
9
|
+
function taskSubcommand(subcommand) {
|
|
10
|
+
return subcommand === 'list' ? 'ls' : subcommand;
|
|
11
|
+
}
|
|
12
|
+
export function commandHelp() {
|
|
13
|
+
return [
|
|
14
|
+
`agent-infra ${VERSION}`,
|
|
15
|
+
'Built-ins: /help, /ping, /version',
|
|
16
|
+
'Read: /sandbox ls|show|vm status, /task decisions|log|ls|show|status',
|
|
17
|
+
'Write: /sandbox create|start',
|
|
18
|
+
'Exec: /decide <task-ref> <HD-id> <decision>, /run create-task <description>, /run <skill> <task-ref> ...'
|
|
19
|
+
].join('\n');
|
|
20
|
+
}
|
|
21
|
+
export function parseCommand(text) {
|
|
22
|
+
const trimmed = text.trim();
|
|
23
|
+
if (!trimmed.startsWith('/'))
|
|
24
|
+
return { kind: 'ignore' };
|
|
25
|
+
const words = splitWords(trimmed);
|
|
26
|
+
const [command = '', subcommand = '', ...rest] = words;
|
|
27
|
+
if (command === '/help')
|
|
28
|
+
return { kind: 'builtin', name: 'help', role: 'read', args: rest };
|
|
29
|
+
if (command === '/ping')
|
|
30
|
+
return { kind: 'builtin', name: 'ping', role: 'read', args: rest };
|
|
31
|
+
if (command === '/version')
|
|
32
|
+
return { kind: 'builtin', name: 'version', role: 'read', args: rest };
|
|
33
|
+
if (command === '/decide') {
|
|
34
|
+
return { kind: 'ai', role: 'exec', argv: ['decide', subcommand, ...rest] };
|
|
35
|
+
}
|
|
36
|
+
if (command === '/task') {
|
|
37
|
+
if (!TASK_READ.has(subcommand))
|
|
38
|
+
return { kind: 'error', message: 'Unknown /task command' };
|
|
39
|
+
return { kind: 'ai', role: 'read', argv: ['task', taskSubcommand(subcommand), ...rest] };
|
|
40
|
+
}
|
|
41
|
+
if (command === '/sandbox') {
|
|
42
|
+
if (subcommand === 'vm') {
|
|
43
|
+
if (rest[0] !== 'status')
|
|
44
|
+
return { kind: 'error', message: 'Only /sandbox vm status is allowed' };
|
|
45
|
+
return { kind: 'ai', role: 'read', argv: ['sandbox', 'vm', 'status'] };
|
|
46
|
+
}
|
|
47
|
+
if (SANDBOX_READ.has(subcommand)) {
|
|
48
|
+
return { kind: 'ai', role: 'read', argv: ['sandbox', taskSubcommand(subcommand), ...rest] };
|
|
49
|
+
}
|
|
50
|
+
if (SANDBOX_WRITE.has(subcommand)) {
|
|
51
|
+
return { kind: 'ai', role: 'write', argv: ['sandbox', subcommand, ...rest] };
|
|
52
|
+
}
|
|
53
|
+
if (subcommand === 'rm') {
|
|
54
|
+
return {
|
|
55
|
+
kind: 'error',
|
|
56
|
+
message: '/sandbox rm is not available from IM because it requires interactive confirmation'
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return { kind: 'error', message: 'Unknown /sandbox command' };
|
|
60
|
+
}
|
|
61
|
+
if (command === '/run') {
|
|
62
|
+
const spec = getSkillRunSpec(subcommand);
|
|
63
|
+
if (!spec)
|
|
64
|
+
return { kind: 'error', message: `Unknown skill: ${subcommand}` };
|
|
65
|
+
return { kind: 'ai', role: spec.role, argv: ['run', subcommand, ...rest] };
|
|
66
|
+
}
|
|
67
|
+
return { kind: 'error', message: `Unknown command: ${command}` };
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=protocol.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const SECRET_PATTERNS = [
|
|
2
|
+
/\b(token|secret|password|passwd|api[_-]?key)=([^\s]+)/gi,
|
|
3
|
+
/\b(Bearer\s+)[A-Za-z0-9._~+/=-]+/g
|
|
4
|
+
];
|
|
5
|
+
export function redactSecrets(text) {
|
|
6
|
+
let out = text;
|
|
7
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
8
|
+
out = out.replace(pattern, (_match, prefix) => `${prefix}=<redacted>`);
|
|
9
|
+
}
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=redact.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
function resolveCommand(file) {
|
|
5
|
+
if (process.platform !== 'win32' || path.extname(file)) {
|
|
6
|
+
return file;
|
|
7
|
+
}
|
|
8
|
+
const pathValue = process.env.Path || process.env.PATH || '';
|
|
9
|
+
const extensions = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
|
|
10
|
+
for (const dir of pathValue.split(path.delimiter).filter(Boolean)) {
|
|
11
|
+
for (const extension of extensions) {
|
|
12
|
+
const lowerCandidate = path.join(dir, `${file}${extension.toLowerCase()}`);
|
|
13
|
+
if (fs.existsSync(lowerCandidate))
|
|
14
|
+
return lowerCandidate;
|
|
15
|
+
const upperCandidate = path.join(dir, `${file}${extension.toUpperCase()}`);
|
|
16
|
+
if (fs.existsSync(upperCandidate))
|
|
17
|
+
return upperCandidate;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return file;
|
|
21
|
+
}
|
|
22
|
+
function needsShell(file) {
|
|
23
|
+
return process.platform === 'win32' && /\.(?:bat|cmd)$/i.test(file);
|
|
24
|
+
}
|
|
25
|
+
function spawnCapture(file, args, onChunk) {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const resolvedFile = resolveCommand(file);
|
|
28
|
+
const child = spawn(resolvedFile, args, {
|
|
29
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
30
|
+
shell: needsShell(resolvedFile)
|
|
31
|
+
});
|
|
32
|
+
let stdout = '';
|
|
33
|
+
let stderr = '';
|
|
34
|
+
let settled = false;
|
|
35
|
+
let pendingChunks = Promise.resolve();
|
|
36
|
+
const rejectOnce = (error) => {
|
|
37
|
+
if (settled)
|
|
38
|
+
return;
|
|
39
|
+
settled = true;
|
|
40
|
+
reject(error);
|
|
41
|
+
};
|
|
42
|
+
const resolveOnce = (result) => {
|
|
43
|
+
if (settled)
|
|
44
|
+
return;
|
|
45
|
+
settled = true;
|
|
46
|
+
resolve(result);
|
|
47
|
+
};
|
|
48
|
+
const enqueueChunk = (chunk) => {
|
|
49
|
+
if (!onChunk)
|
|
50
|
+
return;
|
|
51
|
+
pendingChunks = pendingChunks.then(() => onChunk(chunk));
|
|
52
|
+
pendingChunks.catch(rejectOnce);
|
|
53
|
+
};
|
|
54
|
+
child.stdout.setEncoding('utf8');
|
|
55
|
+
child.stderr.setEncoding('utf8');
|
|
56
|
+
child.stdout.on('data', (chunk) => {
|
|
57
|
+
stdout += chunk;
|
|
58
|
+
enqueueChunk(chunk);
|
|
59
|
+
});
|
|
60
|
+
child.stderr.on('data', (chunk) => {
|
|
61
|
+
stderr += chunk;
|
|
62
|
+
enqueueChunk(chunk);
|
|
63
|
+
});
|
|
64
|
+
child.on('error', rejectOnce);
|
|
65
|
+
child.on('close', (exitCode, signal) => {
|
|
66
|
+
pendingChunks
|
|
67
|
+
.then(() => resolveOnce({ exitCode, signal, stdout, stderr }))
|
|
68
|
+
.catch(rejectOnce);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export async function runAi(args, options = {}) {
|
|
73
|
+
return (options.spawn ?? spawnCapture)('ai', args, options.onChunk);
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=runner.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { redactSecrets } from "./redact.js";
|
|
2
|
+
function chunks(text, size) {
|
|
3
|
+
if (text.length === 0)
|
|
4
|
+
return [];
|
|
5
|
+
const out = [];
|
|
6
|
+
for (let i = 0; i < text.length; i += size) {
|
|
7
|
+
out.push(text.slice(i, i + size));
|
|
8
|
+
}
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
export async function streamCommand(options, run, send) {
|
|
12
|
+
await send(`started ${options.title}`);
|
|
13
|
+
const size = options.chunkChars ?? 4000;
|
|
14
|
+
const throttleMs = options.throttleMs ?? 0;
|
|
15
|
+
let streamed = false;
|
|
16
|
+
let buffer = '';
|
|
17
|
+
let lastFlush = 0;
|
|
18
|
+
const flush = async () => {
|
|
19
|
+
if (!buffer)
|
|
20
|
+
return;
|
|
21
|
+
const text = redactSecrets(buffer);
|
|
22
|
+
buffer = '';
|
|
23
|
+
lastFlush = Date.now();
|
|
24
|
+
for (const chunk of chunks(text, size)) {
|
|
25
|
+
await send(chunk);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const emit = async (chunk) => {
|
|
29
|
+
streamed = true;
|
|
30
|
+
buffer += chunk;
|
|
31
|
+
const due = throttleMs === 0 || Date.now() - lastFlush >= throttleMs;
|
|
32
|
+
if (buffer.length >= size || due) {
|
|
33
|
+
await flush();
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const result = await run(emit);
|
|
37
|
+
if (!streamed) {
|
|
38
|
+
buffer += [result.stdout, result.stderr].filter(Boolean).join('\n');
|
|
39
|
+
}
|
|
40
|
+
await flush();
|
|
41
|
+
await send(`finished ${options.title} exitCode=${result.exitCode} signal=${result.signal ?? 'null'}`);
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=streamer.js.map
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { formatTable } from "../../table.js";
|
|
4
|
+
import { resolveTaskRef } from "../resolve-ref.js";
|
|
5
|
+
import { parseLedger, HUMAN_DECISION_STATUSES } from "../ledger.js";
|
|
6
|
+
import { extractSubSection } from "../sections.js";
|
|
7
|
+
const USAGE = `Usage: ai task decisions <N | #N | TASK-id> [selector] [options]
|
|
8
|
+
|
|
9
|
+
Lists the human-decision (HD-) items recorded in a task's review disagreement
|
|
10
|
+
ledger, or prints the full detail block for a single item. Read-only.
|
|
11
|
+
|
|
12
|
+
<ref> Bare numeric / '#N' short id, or a full TASK-YYYYMMDD-HHMMSS id.
|
|
13
|
+
[selector] Ordinal (1-based) or HD id (e.g. 'HD-3') to show one item's detail.
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--all Include already-decided (human-decided) items, not just pending.
|
|
17
|
+
--stage <s> Filter to one stage: analysis | plan | code.
|
|
18
|
+
--format <fmt> Output format: text (default) | markdown.
|
|
19
|
+
-h, --help Show this help.
|
|
20
|
+
|
|
21
|
+
Aliased as 'ai task d'.
|
|
22
|
+
`;
|
|
23
|
+
const STAGES = new Set(['analysis', 'plan', 'code']);
|
|
24
|
+
const FORMATS = new Set(['text', 'markdown']);
|
|
25
|
+
const HD_ID_RE = /^HD-\d+$/;
|
|
26
|
+
function fail(message) {
|
|
27
|
+
process.stderr.write(`ai task decisions: ${message}\n`);
|
|
28
|
+
process.exitCode = 1;
|
|
29
|
+
}
|
|
30
|
+
// Returns null and sets the exit code when an option is malformed.
|
|
31
|
+
function parseArgs(args) {
|
|
32
|
+
const out = { positionals: [], all: false, format: 'text' };
|
|
33
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
34
|
+
const a = args[i];
|
|
35
|
+
if (a === '--all') {
|
|
36
|
+
out.all = true;
|
|
37
|
+
}
|
|
38
|
+
else if (a === '--stage') {
|
|
39
|
+
const v = args[i + 1];
|
|
40
|
+
if (v === undefined) {
|
|
41
|
+
fail('--stage requires a value (analysis|plan|code)');
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
out.stage = v;
|
|
45
|
+
i += 1;
|
|
46
|
+
}
|
|
47
|
+
else if (a.startsWith('--stage=')) {
|
|
48
|
+
out.stage = a.slice('--stage='.length);
|
|
49
|
+
}
|
|
50
|
+
else if (a === '--format') {
|
|
51
|
+
const v = args[i + 1];
|
|
52
|
+
if (v === undefined) {
|
|
53
|
+
fail('--format requires a value (text|markdown)');
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
out.format = v;
|
|
57
|
+
i += 1;
|
|
58
|
+
}
|
|
59
|
+
else if (a.startsWith('--format=')) {
|
|
60
|
+
out.format = a.slice('--format='.length);
|
|
61
|
+
}
|
|
62
|
+
else if (a.startsWith('-')) {
|
|
63
|
+
fail(`unknown option '${a}'`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
out.positionals.push(a);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
// Parse `<file>.md#anchor` evidence into its filename, when present.
|
|
73
|
+
function evidenceFile(evidence) {
|
|
74
|
+
const m = /([\w.-]+\.md)#/.exec(evidence);
|
|
75
|
+
return m ? m[1] : null;
|
|
76
|
+
}
|
|
77
|
+
function roundOf(file) {
|
|
78
|
+
const m = /-r(\d+)\.md$/.exec(file);
|
|
79
|
+
return m ? Number.parseInt(m[1], 10) : 1;
|
|
80
|
+
}
|
|
81
|
+
// Locate the `### HD-N` detail block for a row. Prefer the artifact named by the
|
|
82
|
+
// row's evidence anchor; otherwise scan analysis/plan/code artifacts and return
|
|
83
|
+
// the block from the highest-round file that contains it. Returns '' when none
|
|
84
|
+
// is found (caller degrades gracefully — plan B3).
|
|
85
|
+
function findDetailBlock(row, taskDir) {
|
|
86
|
+
const hinted = evidenceFile(row.evidence);
|
|
87
|
+
if (hinted) {
|
|
88
|
+
const p = path.join(taskDir, hinted);
|
|
89
|
+
if (fs.existsSync(p)) {
|
|
90
|
+
const block = extractSubSection(fs.readFileSync(p, 'utf8'), row.id);
|
|
91
|
+
if (block)
|
|
92
|
+
return block;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
let best = '';
|
|
96
|
+
let bestRound = -1;
|
|
97
|
+
let entries;
|
|
98
|
+
try {
|
|
99
|
+
entries = fs.readdirSync(taskDir);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return '';
|
|
103
|
+
}
|
|
104
|
+
for (const file of entries) {
|
|
105
|
+
if (!/^(analysis|plan|code)(-r\d+)?\.md$/.test(file))
|
|
106
|
+
continue;
|
|
107
|
+
const block = extractSubSection(fs.readFileSync(path.join(taskDir, file), 'utf8'), row.id);
|
|
108
|
+
if (block && roundOf(file) > bestRound) {
|
|
109
|
+
best = block;
|
|
110
|
+
bestRound = roundOf(file);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return best;
|
|
114
|
+
}
|
|
115
|
+
// Pull the `## 人工裁决` record lines that mention this HD id, so a decided item
|
|
116
|
+
// shows the human's recorded ruling alongside its detail block.
|
|
117
|
+
function findDecisionRecord(id, content) {
|
|
118
|
+
const lines = content.split('\n');
|
|
119
|
+
let i = 0;
|
|
120
|
+
while (i < lines.length && !/^##\s+(人工裁决|Human Decisions?)\s*$/.test(lines[i]))
|
|
121
|
+
i += 1;
|
|
122
|
+
if (i >= lines.length)
|
|
123
|
+
return [];
|
|
124
|
+
const idRe = new RegExp(`(^|[^\\w-])${id}(?![\\w-])`);
|
|
125
|
+
const out = [];
|
|
126
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
127
|
+
if (/^##\s/.test(lines[j]))
|
|
128
|
+
break;
|
|
129
|
+
if (lines[j].trim().startsWith('-') && idRe.test(lines[j]))
|
|
130
|
+
out.push(lines[j]);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
function titleOf(row, taskDir) {
|
|
135
|
+
const block = findDetailBlock(row, taskDir);
|
|
136
|
+
if (block) {
|
|
137
|
+
return block
|
|
138
|
+
.split('\n')[0]
|
|
139
|
+
.replace(/^###\s+/, '')
|
|
140
|
+
.replace(/\s*\[needs-human-decision\]\s*$/, '')
|
|
141
|
+
.trim();
|
|
142
|
+
}
|
|
143
|
+
return row.evidence || '(无详情)';
|
|
144
|
+
}
|
|
145
|
+
function renderList(rows, format, taskDir) {
|
|
146
|
+
if (rows.length === 0) {
|
|
147
|
+
process.stdout.write('无待裁决项。\n');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const headers = ['#', 'ID', 'STAGE', 'SEVERITY', 'STATUS', 'EVIDENCE', 'TITLE'];
|
|
151
|
+
const data = rows.map((r, i) => [
|
|
152
|
+
String(i + 1),
|
|
153
|
+
r.id,
|
|
154
|
+
r.stage,
|
|
155
|
+
r.severity,
|
|
156
|
+
r.status,
|
|
157
|
+
r.evidence,
|
|
158
|
+
titleOf(r, taskDir)
|
|
159
|
+
]);
|
|
160
|
+
if (format === 'markdown') {
|
|
161
|
+
const sep = headers.map(() => '---');
|
|
162
|
+
const md = [
|
|
163
|
+
`| ${headers.join(' | ')} |`,
|
|
164
|
+
`| ${sep.join(' | ')} |`,
|
|
165
|
+
...data.map((row) => `| ${row.join(' | ')} |`)
|
|
166
|
+
];
|
|
167
|
+
process.stdout.write(`${md.join('\n')}\n`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
process.stdout.write(`${formatTable(headers, data).join('\n')}\n`);
|
|
171
|
+
}
|
|
172
|
+
function renderDetail(rows, selector, format, taskDir, content) {
|
|
173
|
+
let row;
|
|
174
|
+
if (/^\d+$/.test(selector)) {
|
|
175
|
+
const idx = Number.parseInt(selector, 10) - 1;
|
|
176
|
+
if (idx < 0 || idx >= rows.length) {
|
|
177
|
+
fail(`ordinal '${selector}' out of range (1..${rows.length})`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
row = rows[idx];
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
const want = selector.toUpperCase();
|
|
184
|
+
const matches = rows.filter((r) => r.id.toUpperCase() === want);
|
|
185
|
+
if (matches.length === 0) {
|
|
186
|
+
fail(`no decision item matches '${selector}'`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (matches.length > 1) {
|
|
190
|
+
fail(`duplicate id '${selector}' in ledger; select by ordinal instead`);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
row = matches[0];
|
|
194
|
+
}
|
|
195
|
+
const r = row;
|
|
196
|
+
const block = findDetailBlock(r, taskDir);
|
|
197
|
+
const lines = [];
|
|
198
|
+
if (format === 'markdown') {
|
|
199
|
+
lines.push(`**${r.id}** (${r.stage}/${r.severity}) · status=\`${r.status}\` · evidence: \`${r.evidence}\``, '');
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
lines.push(`${r.id} (${r.stage}/${r.severity}) status=${r.status}`, `evidence: ${r.evidence}`, '');
|
|
203
|
+
}
|
|
204
|
+
if (block) {
|
|
205
|
+
lines.push(block);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
lines.push(`(详情块未找到:未在任务产物中定位到 \`### ${r.id}\` 锚点,可能为历史产物或尚未写入;evidence 指向 ${r.evidence})`);
|
|
209
|
+
}
|
|
210
|
+
if (r.status === 'human-decided') {
|
|
211
|
+
const record = findDecisionRecord(r.id, content);
|
|
212
|
+
if (record.length) {
|
|
213
|
+
lines.push('', '人工裁定:', ...record);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
217
|
+
}
|
|
218
|
+
function decisions(args = []) {
|
|
219
|
+
if (args[0] === '--help' || args[0] === '-h') {
|
|
220
|
+
process.stdout.write(USAGE);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const parsed = parseArgs(args);
|
|
224
|
+
if (!parsed)
|
|
225
|
+
return;
|
|
226
|
+
if (parsed.positionals.length === 0) {
|
|
227
|
+
process.stdout.write(USAGE);
|
|
228
|
+
process.exitCode = 1;
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (parsed.stage !== undefined && !STAGES.has(parsed.stage)) {
|
|
232
|
+
fail(`invalid --stage '${parsed.stage}' (expected analysis|plan|code)`);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (!FORMATS.has(parsed.format)) {
|
|
236
|
+
fail(`invalid --format '${parsed.format}' (expected text|markdown)`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const resolved = resolveTaskRef(parsed.positionals[0]);
|
|
240
|
+
if (!resolved.ok) {
|
|
241
|
+
fail(resolved.message);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const content = fs.readFileSync(resolved.taskMdPath, 'utf8');
|
|
245
|
+
let rows = parseLedger(content).filter((r) => HD_ID_RE.test(r.id));
|
|
246
|
+
rows = rows.filter((r) => parsed.all ? HUMAN_DECISION_STATUSES.has(r.status) : r.status === 'needs-human-decision');
|
|
247
|
+
if (parsed.stage !== undefined)
|
|
248
|
+
rows = rows.filter((r) => r.stage === parsed.stage);
|
|
249
|
+
const selector = parsed.positionals[1];
|
|
250
|
+
if (selector === undefined) {
|
|
251
|
+
renderList(rows, parsed.format, resolved.taskDir);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
renderDetail(rows, selector, parsed.format, resolved.taskDir, content);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
export { decisions };
|
|
258
|
+
//# sourceMappingURL=decisions.js.map
|