@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,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 };
|
package/lib/task/commands/log.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { formatTable } from '../../table.ts';
|
|
3
3
|
import { resolveTaskRef } from '../resolve-ref.ts';
|
|
4
|
+
import { parseLedger, HUMAN_DECISION_STATUSES, type LedgerRow } from '../ledger.ts';
|
|
4
5
|
|
|
5
6
|
const USAGE = `Usage: ai task log <N | #N | TASK-id>
|
|
6
7
|
|
|
@@ -20,7 +21,6 @@ const TABLE_HEADERS = ['#', 'STEP', 'AGENT', 'STARTED', 'DONE', 'NOTE'] as const
|
|
|
20
21
|
|
|
21
22
|
// The activity-log H2 heading is language-dependent (zh template / en template).
|
|
22
23
|
const HEADING_RE = /^##\s+(活动日志|Activity Log)\s*$/;
|
|
23
|
-
const LEDGER_HEADING_RE = /^##\s+(审查分歧账本|Review Disagreement Ledger)\s*$/;
|
|
24
24
|
const NEXT_H2_RE = /^##\s/;
|
|
25
25
|
// `- {time} — **{step}** by {agent} — {note}` ; the separator is an em-dash
|
|
26
26
|
// (U+2014). STEP/AGENT are non-greedy so a note that itself contains ' — ' or
|
|
@@ -30,7 +30,6 @@ const ENTRY_RE =
|
|
|
30
30
|
|
|
31
31
|
type LogEntry = { time: string; step: string; agent: string; note: string };
|
|
32
32
|
type ReviewStage = 'analysis' | 'plan' | 'code';
|
|
33
|
-
type LedgerRow = { stage: string; status: string };
|
|
34
33
|
|
|
35
34
|
// One rendered row = one step instance. `started`/`done` are timestamps; an empty
|
|
36
35
|
// `done` with a non-empty `started` means the step is still in flight, while an
|
|
@@ -47,7 +46,6 @@ const STARTED_SUFFIX_RE = /\s*\[started\]\s*$/;
|
|
|
47
46
|
// note these differ from the `.airc.json` long names claude-code/gemini-cli).
|
|
48
47
|
// Any other executor token (a human name, possibly CJK) is treated as human.
|
|
49
48
|
const KNOWN_AI_AGENTS = new Set(['claude', 'codex', 'gemini', 'opencode', 'cursor']);
|
|
50
|
-
const HUMAN_DECISION_STATUSES = new Set(['needs-human-decision', 'human-decided']);
|
|
51
49
|
const ENV_BLOCKED_RE = /\(\+\s*(\d+)\s+env-blocked\)/i;
|
|
52
50
|
// Same match plus any leading whitespace, so folding the count into the verdict
|
|
53
51
|
// text drops the redundant `(+ n env-blocked)` fragment without leaving a gap.
|
|
@@ -111,27 +109,6 @@ function pairEntries(entries: LogEntry[]): StepRow[] {
|
|
|
111
109
|
return rows;
|
|
112
110
|
}
|
|
113
111
|
|
|
114
|
-
function parseReviewLedger(content: string): LedgerRow[] {
|
|
115
|
-
const lines = content.split('\n');
|
|
116
|
-
let i = 0;
|
|
117
|
-
while (i < lines.length && !LEDGER_HEADING_RE.test(lines[i]!)) i += 1;
|
|
118
|
-
if (i >= lines.length) return [];
|
|
119
|
-
|
|
120
|
-
const rows: LedgerRow[] = [];
|
|
121
|
-
for (let j = i + 1; j < lines.length; j += 1) {
|
|
122
|
-
if (NEXT_H2_RE.test(lines[j]!)) break;
|
|
123
|
-
const line = lines[j]!.trim();
|
|
124
|
-
if (!line.startsWith('|')) continue;
|
|
125
|
-
const cells = line
|
|
126
|
-
.split('|')
|
|
127
|
-
.slice(1, -1)
|
|
128
|
-
.map((cell) => cell.trim());
|
|
129
|
-
if (cells.length < 6 || cells[0] === 'id' || /^-+$/.test(cells[0] ?? '')) continue;
|
|
130
|
-
rows.push({ stage: cells[1]!, status: cells[4]! });
|
|
131
|
-
}
|
|
132
|
-
return rows;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
112
|
function countHumanDecisionsByStage(rows: LedgerRow[]): Map<ReviewStage, number> {
|
|
136
113
|
const counts = new Map<ReviewStage, number>();
|
|
137
114
|
for (const row of rows) {
|
|
@@ -200,7 +177,7 @@ function log(args: string[] = []): void {
|
|
|
200
177
|
return;
|
|
201
178
|
}
|
|
202
179
|
const steps = pairEntries(entries);
|
|
203
|
-
const humanDecisionCounts = countHumanDecisionsByStage(
|
|
180
|
+
const humanDecisionCounts = countHumanDecisionsByStage(parseLedger(content));
|
|
204
181
|
const rows = steps.map((s, idx) => {
|
|
205
182
|
const stage = reviewStageForStep(s.step);
|
|
206
183
|
const note = stage
|
package/lib/task/index.ts
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
|
|
@@ -46,6 +49,12 @@ export async function runTask(args: string[]): Promise<void> {
|
|
|
46
49
|
cat(rest);
|
|
47
50
|
break;
|
|
48
51
|
}
|
|
52
|
+
case 'decisions':
|
|
53
|
+
case 'd': {
|
|
54
|
+
const { decisions } = await import('./commands/decisions.ts');
|
|
55
|
+
decisions(rest);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
49
58
|
case 'files': {
|
|
50
59
|
const { files } = await import('./commands/files.ts');
|
|
51
60
|
files(rest);
|
|
@@ -0,0 +1,73 @@
|
|
|
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
|
+
|
|
7
|
+
// The ledger H2 heading is language-dependent (zh template / en template).
|
|
8
|
+
const LEDGER_HEADING_RE = /^##\s+(审查分歧账本|Review Disagreement Ledger)\s*$/;
|
|
9
|
+
const NEXT_H2_RE = /^##\s/;
|
|
10
|
+
|
|
11
|
+
type LedgerRow = {
|
|
12
|
+
id: string;
|
|
13
|
+
stage: string;
|
|
14
|
+
round: string;
|
|
15
|
+
severity: string;
|
|
16
|
+
status: string;
|
|
17
|
+
evidence: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Terminal statuses the completion gates treat as resolved.
|
|
21
|
+
const LEDGER_TERMINAL = new Set(['confirmed', 'closed', 'human-decided']);
|
|
22
|
+
// Statuses that represent an executor-raised human-decision row (pending or done).
|
|
23
|
+
const HUMAN_DECISION_STATUSES = new Set(['needs-human-decision', 'human-decided']);
|
|
24
|
+
|
|
25
|
+
// Parse all rows of the disagreement ledger table. Skips the heading, the
|
|
26
|
+
// header row (`| id | ... |`) and the `|---|` separator; ignores non-`|` lines.
|
|
27
|
+
// Rows with fewer than 6 columns are skipped (mirrors the JS gate parser).
|
|
28
|
+
function parseLedger(content: string): LedgerRow[] {
|
|
29
|
+
const lines = content.split('\n');
|
|
30
|
+
let i = 0;
|
|
31
|
+
while (i < lines.length && !LEDGER_HEADING_RE.test(lines[i]!)) i += 1;
|
|
32
|
+
if (i >= lines.length) return [];
|
|
33
|
+
|
|
34
|
+
const rows: LedgerRow[] = [];
|
|
35
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
36
|
+
if (NEXT_H2_RE.test(lines[j]!)) break;
|
|
37
|
+
const line = lines[j]!.trim();
|
|
38
|
+
if (!line.startsWith('|')) continue;
|
|
39
|
+
const cells = line
|
|
40
|
+
.split('|')
|
|
41
|
+
.slice(1, -1)
|
|
42
|
+
.map((cell) => cell.trim());
|
|
43
|
+
if (cells.length < 6) continue;
|
|
44
|
+
if (cells[0] === 'id' || /^-+$/.test(cells[0] ?? '')) continue;
|
|
45
|
+
rows.push({
|
|
46
|
+
id: cells[0]!,
|
|
47
|
+
stage: cells[1]!,
|
|
48
|
+
round: cells[2]!,
|
|
49
|
+
severity: cells[3]!,
|
|
50
|
+
status: cells[4]!,
|
|
51
|
+
evidence: cells[5]!
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return rows;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Allocate the next globally-unique human-decision id. Scans every `HD-<n>`
|
|
58
|
+
// already present in the ledger (across all stages) and returns `HD-{max+1}`,
|
|
59
|
+
// or `HD-1` when none exist. Global monotonic allocation prevents id collisions
|
|
60
|
+
// across the analysis / plan / code stages (plan PL-2).
|
|
61
|
+
function nextHdId(rows: readonly LedgerRow[]): string {
|
|
62
|
+
let max = 0;
|
|
63
|
+
for (const row of rows) {
|
|
64
|
+
const m = /^HD-(\d+)$/.exec(row.id);
|
|
65
|
+
if (!m) continue;
|
|
66
|
+
const n = Number.parseInt(m[1]!, 10);
|
|
67
|
+
if (n > max) max = n;
|
|
68
|
+
}
|
|
69
|
+
return `HD-${max + 1}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export { parseLedger, nextHdId, LEDGER_TERMINAL, HUMAN_DECISION_STATUSES };
|
|
73
|
+
export type { LedgerRow };
|
package/lib/task/sections.ts
CHANGED
|
@@ -41,4 +41,33 @@ function findSectionHeading(content: string, aliases: string[]): string {
|
|
|
41
41
|
return aliases[0]!;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Return the body of the first `### {headingPrefix}` sub-section, from the
|
|
46
|
+
* heading line (inclusive) to the next `### ` / `## ` heading or EOF. Used to
|
|
47
|
+
* pull a single `### HD-N` human-decision detail block out of an artifact. The
|
|
48
|
+
* prefix must be followed by a word boundary so `HD-1` does not match `HD-10`
|
|
49
|
+
* (e.g. `### HD-1`, `### HD-1:标题`, `### HD-1 [needs-human-decision]`). Leading
|
|
50
|
+
* and trailing blank lines are trimmed. Returns '' when no match is present.
|
|
51
|
+
*/
|
|
52
|
+
function extractSubSection(content: string, headingPrefix: string): string {
|
|
53
|
+
const lines = content.split('\n');
|
|
54
|
+
const headRe = new RegExp(`^###\\s+${escapeRegExp(headingPrefix)}(?![\\w-])`);
|
|
55
|
+
let start = -1;
|
|
56
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
57
|
+
if (headRe.test(lines[i]!.trim())) {
|
|
58
|
+
start = i;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (start === -1) 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
|
+
|
|
73
|
+
export { extractSection, findSectionHeading, extractSubSection };
|