Skip to content

Commit 88e1cc9

Browse files
CodeCasterXclaudecodex
committed
feat(cli): add ai task decisions command for HD items
- Add `ai task decisions`/`d` to list and inspect human-decision (HD-) ledger items, backed by a shared `lib/task/ledger.ts` parser also used by `ai task log`; supports --all/--stage/--format and graceful degrade. - Standardize `## 人工裁决待办`/`### HD-N` detail blocks and globally unique HD-N allocation across no-mid-flow-questions, review-handshake, and the analyze/plan/code skills; weave the view command into the next-step pre-block; sync zh-CN/en templates. - Add unit + integration tests for the ledger parser and the command. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Codex <noreply@openai.com>
1 parent 945b036 commit 88e1cc9

25 files changed

Lines changed: 779 additions & 44 deletions

.agents/rules/next-step-output.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ Completed at: YYYY-MM-DD HH:mm:ss
7777
位置:task.md `## 审查分歧账本` 对应行 · 证据:{evidence}
7878
…(task.md `## 审查分歧账本` 中本阶段每个 status=needs-human-decision 行一条)
7979
80+
查看详情:
81+
- 全部待裁决项:ai task decisions {task-ref}(简写 ai task d {task-ref})
82+
- 单项完整背景/选项/影响/建议:ai task decisions {task-ref} <序号|HD-id>
83+
8084
完成裁决:
8185
1. 在 task.md `## 人工裁决` 段,逐项记录你对上述裁决项的裁定与理由。
8286
2. 把 `## 审查分歧账本` 中对应行的 status 由 `needs-human-decision` 翻为 `human-decided`。

.agents/rules/no-mid-flow-questions.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@
5555

5656
## 关键设计决策标记与落账
5757

58-
当未决问题属于需要人工裁定的关键设计决策时,执行方必须在该条目前标记 `[needs-human-decision]`,并按 `.agents/rules/review-handshake.md` 在 task.md `## 审查分歧账本` 写入 `HD-` 行。
58+
当未决问题属于需要人工裁定的关键设计决策时,执行方必须:
59+
60+
1. 把该决策的**详情块**(背景 / 选项 / 影响 / 推荐)写入产物的 `## 人工裁决待办` 段,标题形如 `### HD-N:<标题> [needs-human-decision]`;普通假设 / 未决问题仍分别写 `## 假设` / `## 未决问题``## Assumptions` / `## Open Questions`),`## 未决问题` 可保留一行指针指向对应 `### HD-N`
61+
2.`.agents/rules/review-handshake.md` 在 task.md `## 审查分歧账本` upsert 对应 `HD-` 行,evidence 指向稳定锚点 `<artifact>#HD-N``HD-N` 编号**全局唯一**(扫描账本已有 `HD-(\d+)` 取 max+1,跨 analysis / plan / code 单调递增、禁止复用),分配规则详见 review-handshake.md。
5962

6063
判定时同时使用以下检查:
6164

@@ -64,14 +67,16 @@
6467
- **小影响豁免**:若它只是局部、可逆、低成本的执行细节,写入 `## 假设` 即可,不升级为人工裁决。
6568
- **兜底**:无法判断是否关键时按关键处理;`review-*` 需要复核执行方是否漏标应升级的 `[needs-human-decision]`
6669

70+
> 用户可用 `ai task decisions <task-ref>`(简写 `ai task d`)查看某任务全部待裁决项及其详情块。
71+
6772
## 人工审查检查点语义
6873

6974
「强制性人工审查检查点」(mandatory human review checkpoint)的语义是:
7075

7176
- **产出后停止**:技能完成产物(如 `plan.md`)后立即结束本轮调用,等待用户主动触发下一个技能命令
7277
- **不是过程中暂停征求意见**:不允许在执行步骤之间插入「请问您倾向 A 还是 B?」之类的中断
7378

74-
如果在执行过程中发现需要用户裁定的关键决策,按上文「假设与未决问题」处理,记录到产物的「未决问题」/`Open Questions` 段落,由用户在审查检查点统一回应
79+
如果在执行过程中发现需要用户裁定的关键决策,按上文「关键设计决策标记与落账」处理:详情块写入产物 `## 人工裁决待办``### HD-N`、回写 `HD-` 账本行,由用户在审查检查点统一回应;普通未决问题仍写 `## 未决问题` / `Open Questions`
7580

7681
## 锚点位置
7782

.agents/rules/review-handshake.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,18 +67,22 @@
6767

6868
### 执行方自提人工裁决行
6969

70-
当执行方在产物 `## 未决问题` 中标记 `[needs-human-decision]` 时,必须在 task.md `## 审查分歧账本` upsert 对应 `HD-` 行:
70+
当执行方判定某项为需人工裁定的关键设计决策时,必须把详情块(背景 / 选项 / 影响 / 推荐)写入产物的 `## 人工裁决待办` 段,标题形如 `### HD-N:<标题> [needs-human-decision]`,并在 task.md `## 审查分歧账本` upsert 对应 `HD-` 行:
7171

7272
```markdown
7373
| HD-1 | plan | - | decision | needs-human-decision | plan.md#HD-1 |
7474
```
7575

76+
- `id``HD-N` 编号**全局唯一**。新增行时扫描账本中所有 `HD-(\d+)`,取最大值 + 1(账本无 `HD-` 行则从 `HD-1` 起);跨 `analysis` / `plan` / `code` 单调递增,**禁止复用**既有编号,避免按 `HD-id` 定位时歧义。
7677
- `stage` 填该决策产生的阶段:`analysis` / `plan` / `code`
7778
- `round``-`,因为它不是 review finding 的握手轮次。
7879
- `severity` 固定填 `decision`
7980
- `status` 初始填 `needs-human-decision`,因此会被现有 gate 阻塞。
81+
- `evidence` 指向稳定锚点 `<artifact>#HD-N`(如 `plan-r2.md#HD-1`),不依赖易漂移的行号。
8082
- 人工在 task.md `## 人工裁决` 段记录裁定后,把对应 `HD-` 行翻为 `human-decided``evidence` 指向该裁定记录。
8183

84+
> 查看:`ai task decisions <task-ref>`(简写 `ai task d`)列出全部待裁决项;`ai task decisions <task-ref> <序号|HD-id>` 展开单项详情块。该命令的账本解析与 `ai task log` 共用 `lib/task/ledger.ts``.agents/scripts/validate-artifact.js` 的 gate 解析器是独立实现,二者语义须手工保持同步。
85+
8286
## post-review commit 门禁(仅 code 阶段)
8387

8488
- `review-code` 在最高轮报告中记录 `审查基线提交`(R,`git rev-parse HEAD`)和 `审查差异指纹`(F,完整工作区 diff fingerprint)。

.agents/skills/analyze-task/SKILL.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,22 @@ tail .agents/workspace/active/{task-id}/task.md
183183
## 未决问题
184184

185185
> 如有需要人工裁定的未决问题,列在此处;没有则可省略本段。
186-
> 关键设计决策按 `.agents/rules/no-mid-flow-questions.md` 的判据标记 `[needs-human-decision]` 并回写 `HD-` 账本行
186+
> 普通未决问题列在本段;属关键设计决策的(按 `.agents/rules/no-mid-flow-questions.md` 判据),详情块改写入下方 `## 人工裁决待办``### HD-N`,本段仅保留一行指针
187187
188188
- {未决问题}
189189

190+
## 人工裁决待办
191+
192+
> 仅当本轮升级了 `[needs-human-decision]` 关键设计决策时写本段;没有则省略。
193+
> 每项一个 `### HD-N` 块(`HD-N` 全局唯一,见 `.agents/rules/review-handshake.md`),并在 task.md `## 审查分歧账本` upsert 对应 `HD-` 行(evidence 指向 `{analysis-artifact}#HD-N`)。
194+
195+
### HD-{N}:{标题} [needs-human-decision]
196+
197+
- **背景**:{为何成为待裁决项}
198+
- **选项**:{A / B / …}
199+
- **影响**:{范围 / 边界 / 先例}
200+
- **推荐**:{推荐选项及理由}
201+
190202
## 工作量和复杂度评估
191203
- 复杂度:{高/中/低}
192204
- 风险等级:{高/中/低}

.agents/skills/code-task/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ description: >
1313

1414
- 严格遵循最新方案产物:`plan.md``plan-r{N}.md`
1515
- 修复模式逐条核实最新 `review-code` 的发现:成立则修复,判定为不成立/幻觉则在报告中反驳并记入 unresolved;不擅自扩大到审查未列出的问题;env-blocked 项不在修复范围
16-
- 实现中遇到方案未覆盖的关键设计决策时,按 `.agents/rules/no-mid-flow-questions.md` 判据记录 `[needs-human-decision]` / `HD-` ,不中途提问或擅自扩范围
16+
- 实现中遇到方案未覆盖的关键设计决策时,按 `.agents/rules/no-mid-flow-questions.md` 判据,把详情块写入实现报告的 `## 人工裁决待办``### HD-N:<标题> [needs-human-decision]``HD-N` 全局唯一,见 `.agents/rules/review-handshake.md`)并回写 `HD-` 账本行,不中途提问或擅自扩范围
1717
- 绝不自动执行 `git add``git commit`
1818
- 每轮实现都创建新的实现产物,不覆盖旧文件
1919
- 执行本技能后,你**必须**立即更新 task.md

.agents/skills/plan-task/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ tail .agents/workspace/active/{task-id}/task.md
9898
- [ ] 定义验证策略(测试、手动检查)
9999
- [ ] 评估方案的影响和风险
100100

101-
遇到本轮新增的关键设计决策时,按 `.agents/rules/no-mid-flow-questions.md` 判据写入 `## 未决问题`并回写 `HD-` 账本行。
101+
遇到本轮新增的关键设计决策时,按 `.agents/rules/no-mid-flow-questions.md` 判据,把详情块(背景/选项/影响/推荐)写入方案产物的 `## 人工裁决待办``### HD-N:<标题> [needs-human-decision]``HD-N` 全局唯一,规则见 `.agents/rules/review-handshake.md`),并回写 `HD-` 账本行(evidence 指向 `{plan-artifact}#HD-N`);普通未决问题仍写 `## 未决问题`
102102

103103
**设计原则**
104104
1. **架构合理性**:选择结构正确的方案,改动大小不是首要依据。不要为了减少 diff 而在不合理的结构上叠加

lib/task/commands/decisions.ts

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
import { formatTable } from '../../table.ts';
4+
import { resolveTaskRef } from '../resolve-ref.ts';
5+
import { parseLedger, HUMAN_DECISION_STATUSES, type LedgerRow } from '../ledger.ts';
6+
import { extractSubSection } from '../sections.ts';
7+
8+
const USAGE = `Usage: ai task decisions <N | #N | TASK-id> [selector] [options]
9+
10+
Lists the human-decision (HD-) items recorded in a task's review disagreement
11+
ledger, or prints the full detail block for a single item. Read-only.
12+
13+
<ref> Bare numeric / '#N' short id, or a full TASK-YYYYMMDD-HHMMSS id.
14+
[selector] Ordinal (1-based) or HD id (e.g. 'HD-3') to show one item's detail.
15+
16+
Options:
17+
--all Include already-decided (human-decided) items, not just pending.
18+
--stage <s> Filter to one stage: analysis | plan | code.
19+
--format <fmt> Output format: text (default) | markdown.
20+
-h, --help Show this help.
21+
22+
Aliased as 'ai task d'.
23+
`;
24+
25+
const STAGES = new Set(['analysis', 'plan', 'code']);
26+
const FORMATS = new Set(['text', 'markdown']);
27+
const HD_ID_RE = /^HD-\d+$/;
28+
29+
function fail(message: string): void {
30+
process.stderr.write(`ai task decisions: ${message}\n`);
31+
process.exitCode = 1;
32+
}
33+
34+
type ParsedArgs = {
35+
positionals: string[];
36+
all: boolean;
37+
stage?: string;
38+
format: string;
39+
};
40+
41+
// Returns null and sets the exit code when an option is malformed.
42+
function parseArgs(args: string[]): ParsedArgs | null {
43+
const out: ParsedArgs = { positionals: [], all: false, format: 'text' };
44+
for (let i = 0; i < args.length; i += 1) {
45+
const a = args[i]!;
46+
if (a === '--all') {
47+
out.all = true;
48+
} else if (a === '--stage') {
49+
const v = args[i + 1];
50+
if (v === undefined) {
51+
fail('--stage requires a value (analysis|plan|code)');
52+
return null;
53+
}
54+
out.stage = v;
55+
i += 1;
56+
} else if (a.startsWith('--stage=')) {
57+
out.stage = a.slice('--stage='.length);
58+
} else if (a === '--format') {
59+
const v = args[i + 1];
60+
if (v === undefined) {
61+
fail('--format requires a value (text|markdown)');
62+
return null;
63+
}
64+
out.format = v;
65+
i += 1;
66+
} else if (a.startsWith('--format=')) {
67+
out.format = a.slice('--format='.length);
68+
} else if (a.startsWith('-')) {
69+
fail(`unknown option '${a}'`);
70+
return null;
71+
} else {
72+
out.positionals.push(a);
73+
}
74+
}
75+
return out;
76+
}
77+
78+
// Parse `<file>.md#anchor` evidence into its filename, when present.
79+
function evidenceFile(evidence: string): string | null {
80+
const m = /([\w.-]+\.md)#/.exec(evidence);
81+
return m ? m[1]! : null;
82+
}
83+
84+
function roundOf(file: string): number {
85+
const m = /-r(\d+)\.md$/.exec(file);
86+
return m ? Number.parseInt(m[1]!, 10) : 1;
87+
}
88+
89+
// Locate the `### HD-N` detail block for a row. Prefer the artifact named by the
90+
// row's evidence anchor; otherwise scan analysis/plan/code artifacts and return
91+
// the block from the highest-round file that contains it. Returns '' when none
92+
// is found (caller degrades gracefully — plan B3).
93+
function findDetailBlock(row: LedgerRow, taskDir: string): string {
94+
const hinted = evidenceFile(row.evidence);
95+
if (hinted) {
96+
const p = path.join(taskDir, hinted);
97+
if (fs.existsSync(p)) {
98+
const block = extractSubSection(fs.readFileSync(p, 'utf8'), row.id);
99+
if (block) return block;
100+
}
101+
}
102+
let best = '';
103+
let bestRound = -1;
104+
let entries: string[];
105+
try {
106+
entries = fs.readdirSync(taskDir);
107+
} catch {
108+
return '';
109+
}
110+
for (const file of entries) {
111+
if (!/^(analysis|plan|code)(-r\d+)?\.md$/.test(file)) continue;
112+
const block = extractSubSection(fs.readFileSync(path.join(taskDir, file), 'utf8'), row.id);
113+
if (block && roundOf(file) > bestRound) {
114+
best = block;
115+
bestRound = roundOf(file);
116+
}
117+
}
118+
return best;
119+
}
120+
121+
// Pull the `## 人工裁决` record lines that mention this HD id, so a decided item
122+
// shows the human's recorded ruling alongside its detail block.
123+
function findDecisionRecord(id: string, content: string): string[] {
124+
const lines = content.split('\n');
125+
let i = 0;
126+
while (i < lines.length && !/^##\s+(|Human Decisions?)\s*$/.test(lines[i]!)) i += 1;
127+
if (i >= lines.length) return [];
128+
const idRe = new RegExp(`(^|[^\\w-])${id}(?![\\w-])`);
129+
const out: string[] = [];
130+
for (let j = i + 1; j < lines.length; j += 1) {
131+
if (/^##\s/.test(lines[j]!)) break;
132+
if (lines[j]!.trim().startsWith('-') && idRe.test(lines[j]!)) out.push(lines[j]!);
133+
}
134+
return out;
135+
}
136+
137+
function titleOf(row: LedgerRow, taskDir: string): string {
138+
const block = findDetailBlock(row, taskDir);
139+
if (block) {
140+
return block
141+
.split('\n')[0]!
142+
.replace(/^###\s+/, '')
143+
.replace(/\s*\[needs-human-decision\]\s*$/, '')
144+
.trim();
145+
}
146+
return row.evidence || '(无详情)';
147+
}
148+
149+
function renderList(rows: LedgerRow[], format: string, taskDir: string): void {
150+
if (rows.length === 0) {
151+
process.stdout.write('无待裁决项。\n');
152+
return;
153+
}
154+
const headers = ['#', 'ID', 'STAGE', 'SEVERITY', 'STATUS', 'EVIDENCE', 'TITLE'];
155+
const data = rows.map((r, i) => [
156+
String(i + 1),
157+
r.id,
158+
r.stage,
159+
r.severity,
160+
r.status,
161+
r.evidence,
162+
titleOf(r, taskDir)
163+
]);
164+
if (format === 'markdown') {
165+
const sep = headers.map(() => '---');
166+
const md = [
167+
`| ${headers.join(' | ')} |`,
168+
`| ${sep.join(' | ')} |`,
169+
...data.map((row) => `| ${row.join(' | ')} |`)
170+
];
171+
process.stdout.write(`${md.join('\n')}\n`);
172+
return;
173+
}
174+
process.stdout.write(`${formatTable(headers, data).join('\n')}\n`);
175+
}
176+
177+
function renderDetail(
178+
rows: LedgerRow[],
179+
selector: string,
180+
format: string,
181+
taskDir: string,
182+
content: string
183+
): void {
184+
let row: LedgerRow | undefined;
185+
if (/^\d+$/.test(selector)) {
186+
const idx = Number.parseInt(selector, 10) - 1;
187+
if (idx < 0 || idx >= rows.length) {
188+
fail(`ordinal '${selector}' out of range (1..${rows.length})`);
189+
return;
190+
}
191+
row = rows[idx];
192+
} else {
193+
const want = selector.toUpperCase();
194+
const matches = rows.filter((r) => r.id.toUpperCase() === want);
195+
if (matches.length === 0) {
196+
fail(`no decision item matches '${selector}'`);
197+
return;
198+
}
199+
if (matches.length > 1) {
200+
fail(`duplicate id '${selector}' in ledger; select by ordinal instead`);
201+
return;
202+
}
203+
row = matches[0];
204+
}
205+
206+
const r = row!;
207+
const block = findDetailBlock(r, taskDir);
208+
const lines: string[] = [];
209+
if (format === 'markdown') {
210+
lines.push(`**${r.id}** (${r.stage}/${r.severity}) · status=\`${r.status}\` · evidence: \`${r.evidence}\``, '');
211+
} else {
212+
lines.push(`${r.id} (${r.stage}/${r.severity}) status=${r.status}`, `evidence: ${r.evidence}`, '');
213+
}
214+
if (block) {
215+
lines.push(block);
216+
} else {
217+
lines.push(
218+
`(详情块未找到:未在任务产物中定位到 \`### ${r.id}\` 锚点,可能为历史产物或尚未写入;evidence 指向 ${r.evidence})`
219+
);
220+
}
221+
if (r.status === 'human-decided') {
222+
const record = findDecisionRecord(r.id, content);
223+
if (record.length) {
224+
lines.push('', '人工裁定:', ...record);
225+
}
226+
}
227+
process.stdout.write(`${lines.join('\n')}\n`);
228+
}
229+
230+
function decisions(args: string[] = []): void {
231+
if (args[0] === '--help' || args[0] === '-h') {
232+
process.stdout.write(USAGE);
233+
return;
234+
}
235+
const parsed = parseArgs(args);
236+
if (!parsed) return;
237+
if (parsed.positionals.length === 0) {
238+
process.stdout.write(USAGE);
239+
process.exitCode = 1;
240+
return;
241+
}
242+
if (parsed.stage !== undefined && !STAGES.has(parsed.stage)) {
243+
fail(`invalid --stage '${parsed.stage}' (expected analysis|plan|code)`);
244+
return;
245+
}
246+
if (!FORMATS.has(parsed.format)) {
247+
fail(`invalid --format '${parsed.format}' (expected text|markdown)`);
248+
return;
249+
}
250+
251+
const resolved = resolveTaskRef(parsed.positionals[0]!);
252+
if (!resolved.ok) {
253+
fail(resolved.message);
254+
return;
255+
}
256+
257+
const content = fs.readFileSync(resolved.taskMdPath, 'utf8');
258+
let rows = parseLedger(content).filter((r) => HD_ID_RE.test(r.id));
259+
rows = rows.filter((r) =>
260+
parsed.all ? HUMAN_DECISION_STATUSES.has(r.status) : r.status === 'needs-human-decision'
261+
);
262+
if (parsed.stage !== undefined) rows = rows.filter((r) => r.stage === parsed.stage);
263+
264+
const selector = parsed.positionals[1];
265+
if (selector === undefined) {
266+
renderList(rows, parsed.format, resolved.taskDir);
267+
} else {
268+
renderDetail(rows, selector, parsed.format, resolved.taskDir, content);
269+
}
270+
}
271+
272+
export { decisions };

0 commit comments

Comments
 (0)