@house365/comate 0.10.3 → 1.0.2-next.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/LICENSE +21 -21
- package/README.md +104 -64
- package/bin/cli.mjs +197 -190
- package/bin/doctor.mjs +29 -41
- package/bin/init-run.mjs +310 -368
- package/bin/init.mjs +53 -71
- package/bin/postinstall.mjs +56 -56
- package/docs/CHANGELOG-fix-simplification.md +145 -0
- package/docs/COMATE_BETA.md +90 -0
- package/docs/COMATE_MANUAL.md +239 -665
- package/docs/superpowers/plans/2026-10-14-review-fixes.md +48 -0
- package/docs/superpowers/plans/2026-10-14-workflow-quality.md +48 -0
- package/package.json +54 -55
- package/skills/comate/SKILL.md +42 -325
- package/skills/comate/reference/archive.md +50 -0
- package/skills/comate/reference/build.md +67 -0
- package/skills/comate/reference/fix.md +150 -0
- package/skills/comate/reference/open.md +162 -0
- package/skills/comate/reference/verify.md +123 -0
- package/skills/comate/scaffold/comate/README.md +18 -0
- package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/architecture/README.md +5 -6
- package/skills/comate/scaffold/comate/comate/config.json +10 -0
- package/skills/comate/scaffold/comate/knowledge-base/README.md +42 -0
- package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/integrations/_TEMPLATE/README.md +2 -2
- package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/integrations/_TEMPLATE/api-snapshot.md +47 -47
- package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/requirements/_templates/PRD.md +4 -4
- package/skills/comate/scripts/comate.mjs +147 -0
- package/skills/comate/scripts/lib/acceptance.mjs +112 -0
- package/skills/comate/scripts/lib/brief.mjs +50 -0
- package/skills/comate/scripts/lib/checks.mjs +178 -0
- package/skills/comate/scripts/lib/cmd-archive.mjs +152 -0
- package/skills/comate/scripts/lib/cmd-decide.mjs +116 -0
- package/skills/comate/scripts/lib/cmd-init.mjs +75 -0
- package/skills/comate/scripts/lib/cmd-loop.mjs +273 -0
- package/skills/comate/scripts/lib/cmd-open.mjs +161 -0
- package/skills/comate/scripts/lib/cmd-status.mjs +121 -0
- package/skills/comate/scripts/lib/common.mjs +85 -0
- package/skills/comate/scripts/lib/docs.mjs +45 -0
- package/skills/comate/scripts/lib/finish.mjs +108 -0
- package/skills/comate/scripts/lib/git.mjs +112 -0
- package/skills/comate/scripts/lib/md.mjs +85 -0
- package/skills/comate/scripts/lib/next.mjs +217 -0
- package/skills/comate/scripts/lib/paths.mjs +96 -0
- package/skills/comate/scripts/lib/render.mjs +46 -0
- package/skills/comate/scripts/lib/spec.mjs +106 -0
- package/skills/comate/scripts/lib/state.mjs +128 -0
- package/skills/comate/scripts/lib/tasks.mjs +53 -0
- package/skills/comate/scripts/lib/templates.mjs +58 -0
- package/skills/comate/scripts/lib/util.mjs +108 -0
- package/skills/comate-kb-integration/SKILL.md +128 -128
- package/skills/comate-kb-integration/scripts/fetch_rendered_doc.py +84 -84
- package/bin/cleanup-duplicate-superpowers.mjs +0 -119
- package/docs/COMATE_BEGINNER_GUIDE.md +0 -188
- package/docs/COMATE_INIT_DIRECTORY_GUIDE.md +0 -201
- package/docs/superpowers/plans/2026-09-13-fast-workflow.md +0 -62
- package/skills/comate/reference/decision-points.md +0 -103
- package/skills/comate/reference/dirty-worktree.md +0 -58
- package/skills/comate/reference/prerequisites.md +0 -89
- package/skills/comate/scripts/comate-lib.sh +0 -559
- package/skills/comate/scripts/comate.sh +0 -2147
- package/skills/comate/tests/comate.bats +0 -1019
- package/skills/comate/tests/run.sh +0 -101
- package/skills/comate-archive/SKILL.md +0 -63
- package/skills/comate-build/SKILL.md +0 -278
- package/skills/comate-design/SKILL.md +0 -178
- package/skills/comate-fix/SKILL.md +0 -195
- package/skills/comate-gate/SKILL.md +0 -319
- package/skills/comate-gate/scaffold/docs/README.md +0 -36
- package/skills/comate-gate/scaffold/docs/knowledge-base/README.md +0 -47
- package/skills/comate-grill/SKILL.md +0 -185
- package/skills/comate-open/SKILL.md +0 -240
- package/skills/comate-verify/SKILL.md +0 -212
- /package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/integrations/README.md +0 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Minimal markdown structure: headings (outside code fences), list items, tables.
|
|
2
|
+
|
|
3
|
+
const FENCE = /^\s*(```|~~~)/;
|
|
4
|
+
|
|
5
|
+
// Sections at one heading level: [{ title, body, line }]. Deeper headings stay in the body.
|
|
6
|
+
export function sections(text, level = 1) {
|
|
7
|
+
const head = new RegExp(`^${'#'.repeat(level)}\\s+(.+?)\\s*#*\\s*$`);
|
|
8
|
+
const out = [];
|
|
9
|
+
let cur = null;
|
|
10
|
+
let fenced = false;
|
|
11
|
+
text.split(/\r?\n/).forEach((line, i) => {
|
|
12
|
+
if (FENCE.test(line)) fenced = !fenced;
|
|
13
|
+
const m = fenced ? null : head.exec(line);
|
|
14
|
+
if (m) {
|
|
15
|
+
cur = { title: m[1].trim(), body: '', line: i + 1 };
|
|
16
|
+
out.push(cur);
|
|
17
|
+
} else if (cur) {
|
|
18
|
+
cur.body += `${line}\n`;
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const section = (text, title, level = 1) => sections(text, level).find((s) => s.title === title) ?? null;
|
|
25
|
+
|
|
26
|
+
export const stripComments = (body) => body.replace(/<!--[\s\S]*?-->/g, '');
|
|
27
|
+
export const isBlank = (body) => stripComments(body ?? '').trim() === '';
|
|
28
|
+
|
|
29
|
+
const PLACEHOLDER = /\b(TODO|TBD|FIXME)\b|待填写|待补充/i;
|
|
30
|
+
export const hasPlaceholder = (body) => PLACEHOLDER.test(stripComments(body ?? ''));
|
|
31
|
+
|
|
32
|
+
// Top-level list items; indented lines that follow belong to the item above.
|
|
33
|
+
export function listItems(body) {
|
|
34
|
+
const items = [];
|
|
35
|
+
let cur = null;
|
|
36
|
+
let fenced = false;
|
|
37
|
+
for (const line of stripComments(body).split(/\r?\n/)) {
|
|
38
|
+
if (FENCE.test(line)) fenced = !fenced;
|
|
39
|
+
const m = !fenced && /^(?:[-*+]|\d+[.)])\s+(.*\S)\s*$/.exec(line);
|
|
40
|
+
if (m) {
|
|
41
|
+
cur = { text: m[1] };
|
|
42
|
+
items.push(cur);
|
|
43
|
+
} else if (cur && /^\s+\S/.test(line)) {
|
|
44
|
+
cur.text += `\n${line.trim()}`;
|
|
45
|
+
} else if (line.trim() !== '') {
|
|
46
|
+
cur = null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return items.map((i) => i.text);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const cellsOf = (line) =>
|
|
53
|
+
line
|
|
54
|
+
.trim()
|
|
55
|
+
.replace(/^\|/, '')
|
|
56
|
+
.replace(/\|$/, '')
|
|
57
|
+
.split(/(?<!\\)\|/)
|
|
58
|
+
.map((c) => c.replace(/\\\|/g, '|').trim().replace(/^`(.*)`$/, '$1'));
|
|
59
|
+
|
|
60
|
+
// First table in body: { headers, rows: [{ header: cell }] } or null.
|
|
61
|
+
export function table(body) {
|
|
62
|
+
const lines = stripComments(body).split(/\r?\n/);
|
|
63
|
+
const start = lines.findIndex((l) => l.trim().startsWith('|'));
|
|
64
|
+
if (start === -1) return null;
|
|
65
|
+
const block = [];
|
|
66
|
+
for (let i = start; i < lines.length && lines[i].trim().startsWith('|'); i++) block.push(lines[i]);
|
|
67
|
+
if (block.length < 2) return null;
|
|
68
|
+
const headers = cellsOf(block[0]).map((h) => h.toLowerCase());
|
|
69
|
+
const rows = block
|
|
70
|
+
.slice(1)
|
|
71
|
+
.filter((l) => !/^\|?\s*:?-{2,}/.test(l.trim()))
|
|
72
|
+
.map((l) => {
|
|
73
|
+
const cells = cellsOf(l);
|
|
74
|
+
return Object.fromEntries(headers.map((h, i) => [h, cells[i] ?? '']));
|
|
75
|
+
});
|
|
76
|
+
return { headers, rows };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Body text normalised for comparison: trimmed lines, no blanks, no comments.
|
|
80
|
+
export const normalizeBody = (body) =>
|
|
81
|
+
stripComments(body)
|
|
82
|
+
.split(/\r?\n/)
|
|
83
|
+
.map((l) => l.trim())
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
.join('\n');
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// The next protocol: every command ends by telling the agent exactly
|
|
2
|
+
// what to do next. `continue` = run `command`; `await-user` = relay `message`
|
|
3
|
+
// and `alternatives` to the user and run the chosen one; `blocked` = fix what is
|
|
4
|
+
// listed; `done` = report and stop. The agent never infers the phase itself.
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
|
|
7
|
+
import { briefSummary } from './brief.mjs';
|
|
8
|
+
import { docDrift } from './docs.mjs';
|
|
9
|
+
import { finishEvidence } from './finish.mjs';
|
|
10
|
+
import { changedFiles, hasRemote } from './git.mjs';
|
|
11
|
+
import { nodeOf } from './state.mjs';
|
|
12
|
+
import { readText } from './util.mjs';
|
|
13
|
+
|
|
14
|
+
const C = (...args) => ['$COMATE', ...args];
|
|
15
|
+
const ex = (st) => `${st.version}:${st.step}`;
|
|
16
|
+
|
|
17
|
+
export function nextFor(ctx, cp, st, extra = {}) {
|
|
18
|
+
const node = nodeOf(st);
|
|
19
|
+
const base = {
|
|
20
|
+
disposition: 'continue', change: st.name, node, phase: st.phase, step: st.step, status: st.status,
|
|
21
|
+
version: st.version, iteration: st.loop.iteration, mode: st.mode,
|
|
22
|
+
action: '', reference: null, message: '', command: null, alternatives: [], template: null, dispatch: null,
|
|
23
|
+
warnings: [], done: extra.done ?? null, comate: ctx.script,
|
|
24
|
+
};
|
|
25
|
+
const n = st.name;
|
|
26
|
+
const drift = cp.archived || st.phase === 'open' ? [] : docDrift(ctx, cp, st);
|
|
27
|
+
const reviseReq = { label: '需求确实要变:回 open 修改并重新确认', command: C('decide', n, '--revise-requirements', '--expect', ex(st)) };
|
|
28
|
+
if (drift.length && node !== 'Done') {
|
|
29
|
+
return {
|
|
30
|
+
...base, disposition: 'blocked', action: 'resolve-drift', reference: 'reference/open.md',
|
|
31
|
+
warnings: drift,
|
|
32
|
+
message: '已确认的需求文档或源文件被改动。改动是误操作就还原文件(git checkout -- <文件>)后重跑 status;需求确实要变就回 open 重新确认。',
|
|
33
|
+
alternatives: [reviseReq],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
switch (node) {
|
|
38
|
+
case 'Drafting':
|
|
39
|
+
return {
|
|
40
|
+
...base, action: 'write-brief', reference: 'reference/open.md',
|
|
41
|
+
message: `澄清需求,写 ${cp.briefRel} 和 ${cp.specsRel}/<capability>/spec.md,然后校验并抽取验收项。`,
|
|
42
|
+
command: C('open', n, '--prepare'),
|
|
43
|
+
};
|
|
44
|
+
case 'AwaitConfirm':
|
|
45
|
+
return {
|
|
46
|
+
...base, disposition: 'await-user', action: 'confirm', reference: 'reference/open.md',
|
|
47
|
+
message: '向用户完整转述确认摘要(目标、范围、非目标、关键决定、全部验收项、删除的 Scenario、change 名称),用 AskUserQuestion 请用户确认。',
|
|
48
|
+
dispatch: { summary: confirmSummary(cp, st) },
|
|
49
|
+
alternatives: [
|
|
50
|
+
{ label: '确认,开始实现', command: C('open', n, '--confirm', '--expect', ex(st)) },
|
|
51
|
+
{ label: '需要调整:改 brief/spec(或 rename 改名)后重跑 open --prepare', command: C('open', n, '--prepare') },
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
case 'Building':
|
|
55
|
+
case 'Repairing': {
|
|
56
|
+
const repairing = node === 'Repairing';
|
|
57
|
+
const progress = repairing
|
|
58
|
+
? `修复重点:${st.loop.previous_unresolved.join(', ')}`
|
|
59
|
+
: `全部验收项(${st.acceptance.length} 项):${st.acceptance.map((a) => a.id).join(', ')}`;
|
|
60
|
+
const hint = st.loop.no_progress_count >= 2 ? '连续两轮没有进展:换一种修复思路,不要重复上一轮的改法。' : '';
|
|
61
|
+
return {
|
|
62
|
+
...base, action: repairing ? 'repair' : 'build', reference: 'reference/build.md',
|
|
63
|
+
message: [
|
|
64
|
+
repairing
|
|
65
|
+
? `修复轮 iteration ${st.loop.iteration}:${progress},实现并提交候选。`
|
|
66
|
+
: `按 brief 和 spec 实现${st.loop.iteration === 1 ? '全部' : '本轮'}验收项。`,
|
|
67
|
+
progress, hint, `实现完成后在 .comate/handoff.json 的 addressed 字段列出本轮已实现的验收项 ID,执行 command。`,
|
|
68
|
+
].filter(Boolean).join('\n'),
|
|
69
|
+
command: C('handoff', n, '--input', '.comate/handoff.json', '--expect', ex(st)),
|
|
70
|
+
template: { summary: '本轮改了什么', addressed: repairing ? st.loop.previous_unresolved : st.acceptance.map((a) => a.id), manual_checks: [{ id: 'A?', steps: '无法自动化的验收项的手工验证步骤' }], known_limits: [] },
|
|
71
|
+
dispatch: {
|
|
72
|
+
docs: { brief: cp.briefRel, specs: st.spec_targets.filter((t) => t.op !== 'remove').map((t) => `${cp.specsRel}/${t.capability}/spec.md`) },
|
|
73
|
+
acceptance: st.acceptance.map((a) => ({ id: a.id, kind: a.kind, source: a.source, text: a.text, result: a.result, reason: a.reason })),
|
|
74
|
+
failed_checks: repairing ? (st.candidate?.checks ?? []).filter((c) => !c.ok) : [],
|
|
75
|
+
verifier_summary: repairing ? st.verifier?.summary ?? null : null,
|
|
76
|
+
repairs: repairing ? st.loop.repairs?.entries ?? [] : [],
|
|
77
|
+
},
|
|
78
|
+
alternatives: [reviseReq],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
case 'Checking':
|
|
82
|
+
return { ...base, action: 'resume-checks', message: '检查命令执行被中断。重新执行检查:', command: C('handoff', n, '--recheck', '--expect', ex(st)) };
|
|
83
|
+
case 'AwaitVerifier':
|
|
84
|
+
return verifierNext(ctx, cp, st, base);
|
|
85
|
+
case 'AwaitAccept': {
|
|
86
|
+
const risks = st.verifier?.risks ?? [];
|
|
87
|
+
const alts = [
|
|
88
|
+
{ label: `接受并本地合并到 ${st.workspace.base_branch}`, command: C('decide', n, '--accept', '--finish', 'merge', '--expect', ex(st)) },
|
|
89
|
+
{ label: '接受并推送、创建 PR', command: C('decide', n, '--accept', '--finish', 'pr', '--expect', ex(st)) },
|
|
90
|
+
{ label: '接受并保留分支(稍后自己处理)', command: C('decide', n, '--accept', '--finish', 'keep', '--expect', ex(st)) },
|
|
91
|
+
{ label: '回去修改实现(需求不变)', command: C('decide', n, '--revise-implementation', '--note', '<要改什么>', '--expect', ex(st)) },
|
|
92
|
+
reviseReq,
|
|
93
|
+
];
|
|
94
|
+
if (!hasRemote(ctx)) alts.splice(1, 1);
|
|
95
|
+
if (st.workspace.branch === st.workspace.base_branch) alts.splice(0, 1);
|
|
96
|
+
return {
|
|
97
|
+
...base, disposition: 'await-user', action: 'accept', reference: 'reference/archive.md',
|
|
98
|
+
message: `${st.acceptance.length} 项验收全部通过。${risks.length ? `verifier 提出的风险:\n- ${risks.map((r) => (typeof r === 'string' ? r : r.text)).join('\n- ')}\n` : ''}一次问完:是否接受结果,以及收尾方式。`,
|
|
99
|
+
alternatives: alts,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
case 'Stuck':
|
|
103
|
+
return {
|
|
104
|
+
...base, disposition: 'await-user', action: 'resolve-stuck', reference: 'reference/verify.md',
|
|
105
|
+
message: `自动回修已停下:${st.loop.stop_reason}。仍未解决:${st.loop.previous_unresolved.join(', ') || '(检查命令失败)'}。向用户说明已尝试的修复与失败原因,请用户决定。`,
|
|
106
|
+
alternatives: [
|
|
107
|
+
{ label: '换思路再修(用户给方向)', command: C('decide', n, '--revise-implementation', '--note', '<新的修复方向>', '--expect', ex(st)) },
|
|
108
|
+
reviseReq,
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
case 'Blocked':
|
|
112
|
+
return {
|
|
113
|
+
...base, disposition: 'await-user', action: 'resolve-blocker', reference: 'reference/verify.md',
|
|
114
|
+
message: `验收被阻塞,需要用户或外部信息:\n- ${st.blockers.map((b) => `${b.id ? `${b.id}:` : ''}${b.reason}`).join('\n- ')}`,
|
|
115
|
+
alternatives: [
|
|
116
|
+
{ label: '已补充信息,重新验收', command: C('decide', n, '--resolve', '--note', '<补充的信息>', '--expect', ex(st)) },
|
|
117
|
+
reviseReq,
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
case 'Degraded':
|
|
121
|
+
return {
|
|
122
|
+
...base, disposition: 'await-user', action: 'accept-degraded', reference: 'reference/verify.md',
|
|
123
|
+
message: '没有可用的独立 verifier,只有自动检查通过。这不等于逐项验收,必须由用户明确接受。',
|
|
124
|
+
alternatives: [
|
|
125
|
+
{ label: '接受「仅自动检查」的结果并保留分支', command: C('decide', n, '--accept', '--finish', 'keep', '--expect', ex(st)) },
|
|
126
|
+
...(st.workspace.branch === st.workspace.base_branch ? [] : [{ label: '接受并本地合并', command: C('decide', n, '--accept', '--finish', 'merge', '--expect', ex(st)) }]),
|
|
127
|
+
{ label: '不接受:回去补充实现或测试', command: C('decide', n, '--revise-implementation', '--note', '<要补什么>', '--expect', ex(st)) },
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
case 'ArchiveReady':
|
|
131
|
+
return { ...base, action: 'archive', reference: 'reference/archive.md', message: `归档并执行收尾(${st.finish?.action ?? 'keep'})。`, command: C('archive', n) };
|
|
132
|
+
case 'Done': {
|
|
133
|
+
const ev = finishEvidence(ctx, st);
|
|
134
|
+
if (!ev.done) {
|
|
135
|
+
return {
|
|
136
|
+
...base, disposition: 'blocked', action: 'retry-finish', reference: 'reference/archive.md',
|
|
137
|
+
message: `归档${st.finish ? `已完成,收尾「${st.finish.action}」` : ''}未完成:${ev.message}处理后重试:`,
|
|
138
|
+
command: C('archive', n),
|
|
139
|
+
alternatives: st.finish && st.finish.action !== 'keep'
|
|
140
|
+
? [{ label: '改为保留分支(用户同意放弃这个收尾时)', command: C('decide', n, '--accept', '--finish', 'keep', '--expect', ex(st)) }]
|
|
141
|
+
: [],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return { ...base, disposition: 'done', action: 'report', message: `已归档到 ${st.archive?.dest ?? cp.rel}。${ev.message}` };
|
|
145
|
+
}
|
|
146
|
+
default:
|
|
147
|
+
return { ...base, disposition: 'blocked', action: 'doctor', message: '状态无法识别,运行 doctor。', command: C('doctor', n) };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function confirmSummary(cp, st) {
|
|
152
|
+
return {
|
|
153
|
+
name: st.name,
|
|
154
|
+
...briefSummary(readText(cp.brief) ?? ''),
|
|
155
|
+
acceptance: st.acceptance.map((a) => ({ id: a.id, kind: a.kind, source: a.source, text: a.text })),
|
|
156
|
+
removed_scenarios: st.removed_scenarios,
|
|
157
|
+
spec_targets: st.spec_targets.map((t) => `${t.capability}: ${t.op}`),
|
|
158
|
+
source_map: st.source_map,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function verifierNext(ctx, cp, st, base) {
|
|
163
|
+
const n = st.name;
|
|
164
|
+
const resultRel = `.comate/verify-result.json`;
|
|
165
|
+
const specs = st.spec_targets.filter((t) => t.op !== 'remove').map((t) => `${cp.specsRel}/${t.capability}/spec.md`);
|
|
166
|
+
const dispatch = {
|
|
167
|
+
role: '独立只读 verifier:未参与本次 build 的子 agent,不修改主工作区任何文件',
|
|
168
|
+
instructions: 'reference/verify.md',
|
|
169
|
+
token: st.verifier.token,
|
|
170
|
+
candidate: st.candidate.commit,
|
|
171
|
+
base_ref: st.workspace.base_ref,
|
|
172
|
+
docs: { brief: cp.briefRel, specs },
|
|
173
|
+
acceptance: st.acceptance.map((a) => ({ id: a.id, kind: a.kind, source: a.source, text: a.text })),
|
|
174
|
+
diff: { command: `git diff ${st.workspace.base_ref}...${st.candidate.commit}`, files: changedFiles(ctx, st.workspace.base_ref, st.candidate.commit) },
|
|
175
|
+
checks: st.candidate.checks,
|
|
176
|
+
handoff: st.candidate.handoff,
|
|
177
|
+
probe: { create: `node "${ctx.script}" probe ${n}`, remove: `node "${ctx.script}" probe ${n} --remove <dir>` },
|
|
178
|
+
result_file: resultRel,
|
|
179
|
+
};
|
|
180
|
+
return {
|
|
181
|
+
...base, action: 'dispatch-verifier', reference: 'reference/verify.md',
|
|
182
|
+
message: `派发一个未参与 build 的只读 verifier 子 agent,把 dispatch 原样交给它;它把结果写进 ${resultRel} 后执行 command。`,
|
|
183
|
+
command: C('verify', n, '--result', resultRel),
|
|
184
|
+
template: {
|
|
185
|
+
token: st.verifier.token, candidate: st.candidate.commit,
|
|
186
|
+
acceptance: st.acceptance.map((a) => ({ id: a.id, result: 'passed | failed | blocked', reason: '证据:实现位置、测试名与断言、或手工步骤与结果' })),
|
|
187
|
+
reverse_checks: [{ target: 'A?', mutation: '临时改错了什么', test: '跑的测试', failed_as_expected: true }],
|
|
188
|
+
risks: [], summary: '',
|
|
189
|
+
},
|
|
190
|
+
dispatch,
|
|
191
|
+
alternatives: [
|
|
192
|
+
{ label: 'verifier 执行出错(崩溃、超时、无结果)', command: C('verify', n, '--error', '<原因>') },
|
|
193
|
+
{ label: '平台没有子 agent 能力(降级,须用户明确接受)', command: C('verify', n, '--unavailable', '--expect', ex(st)) },
|
|
194
|
+
],
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const quote = (a) => (/^[A-Za-z0-9_./:@=$<>-]+$/.test(a) ? a : `"${a.replace(/"/g, '\\"')}"`);
|
|
199
|
+
export const commandText = (cmd) => cmd.map((a, i) => (i === 0 && a === '$COMATE' ? 'node "$COMATE"' : quote(a))).join(' ');
|
|
200
|
+
|
|
201
|
+
export function formatText(next) {
|
|
202
|
+
const out = [`STATE ${next.change} ${next.phase}/${next.step} v${next.version} iteration ${next.iteration}${next.mode === 'fix' ? ' [fix]' : ''}`];
|
|
203
|
+
if (next.done) out.push(`DONE ${next.done}`);
|
|
204
|
+
for (const w of next.warnings) out.push(`WARN ${w}`);
|
|
205
|
+
if (next.message) out.push(...next.message.split('\n').map((l, i) => `${i ? ' ' : 'NEXT '}${l}`));
|
|
206
|
+
if (next.command) out.push(` ${commandText(next.command)}`);
|
|
207
|
+
next.alternatives.forEach((a, i) => out.push(` ${i + 1}) ${a.label}`, ` ${commandText(a.command)}`));
|
|
208
|
+
if (next.dispatch?.summary) {
|
|
209
|
+
const s = next.dispatch.summary;
|
|
210
|
+
out.push('', `目标:${s.goal}`, `范围:${s.scope}`, `非目标:${s.non_goals}`, `决定:${s.decisions}`, '验收项:');
|
|
211
|
+
for (const a of s.acceptance) out.push(` ${a.id} [${a.kind}] ${a.text.split('\n')[0]} (${a.source})`);
|
|
212
|
+
if (s.removed_scenarios.length) out.push(`删除的 Scenario:${s.removed_scenarios.map((r) => `${r.capability}#${r.title}`).join('、')}`);
|
|
213
|
+
}
|
|
214
|
+
if (next.dispatch && !next.dispatch.summary) out.push(' (详细信息用 --json 读取 dispatch 与 template)');
|
|
215
|
+
if (next.reference) out.push(`READ ${next.reference}`);
|
|
216
|
+
return out.join('\n');
|
|
217
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Project layout. Everything comate manages lives under <repo>/comate/comate/;
|
|
2
|
+
// paths kept in state are repo-relative with forward slashes, so a state file is
|
|
3
|
+
// byte-identical whichever OS wrote it.
|
|
4
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { ENV, USAGE, die, git, toPosix } from './util.mjs';
|
|
8
|
+
|
|
9
|
+
export const BASE = 'comate/comate';
|
|
10
|
+
export const TEMP_DIR = '.comate';
|
|
11
|
+
|
|
12
|
+
export function makeContext(cwd, script) {
|
|
13
|
+
const top = git(cwd, 'rev-parse', '--show-toplevel');
|
|
14
|
+
if (!top) die(ENV, '当前目录不在 git 仓库里。comate 1.0 需要 git:先 git init,再运行 comate init。');
|
|
15
|
+
const root = toPosix(top);
|
|
16
|
+
return {
|
|
17
|
+
root,
|
|
18
|
+
script: toPosix(script),
|
|
19
|
+
base: BASE,
|
|
20
|
+
tempDir: TEMP_DIR,
|
|
21
|
+
configRel: `${BASE}/config.json`,
|
|
22
|
+
specsRel: `${BASE}/specs`,
|
|
23
|
+
changesRel: `${BASE}/changes`,
|
|
24
|
+
archiveRel: `${BASE}/archive`,
|
|
25
|
+
abs: (rel) => join(root, rel),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function requireInitialized(ctx) {
|
|
30
|
+
if (!existsSync(ctx.abs(ctx.configRel))) {
|
|
31
|
+
die(ENV, `未初始化:找不到 ${ctx.configRel}。在项目根运行 comate init。`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function changePaths(ctx, name, dirRel = `${ctx.changesRel}/${name}`) {
|
|
36
|
+
return {
|
|
37
|
+
name,
|
|
38
|
+
rel: dirRel,
|
|
39
|
+
dir: ctx.abs(dirRel),
|
|
40
|
+
stateRel: `${dirRel}/state.json`,
|
|
41
|
+
state: ctx.abs(`${dirRel}/state.json`),
|
|
42
|
+
briefRel: `${dirRel}/brief.md`,
|
|
43
|
+
brief: ctx.abs(`${dirRel}/brief.md`),
|
|
44
|
+
tasksRel: `${dirRel}/tasks.md`,
|
|
45
|
+
tasks: ctx.abs(`${dirRel}/tasks.md`),
|
|
46
|
+
specsRel: `${dirRel}/specs`,
|
|
47
|
+
specs: ctx.abs(`${dirRel}/specs`),
|
|
48
|
+
verificationRel: `${dirRel}/verification.md`,
|
|
49
|
+
verification: ctx.abs(`${dirRel}/verification.md`),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function validateName(name) {
|
|
54
|
+
if (!name) die(USAGE, 'change 名称不能为空');
|
|
55
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
|
|
56
|
+
die(USAGE, `change 名称 '${name}' 不合法:只允许小写字母、数字和连字符(kebab-case),如 add-email-login`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function activeChanges(ctx) {
|
|
61
|
+
const dir = ctx.abs(ctx.changesRel);
|
|
62
|
+
if (!existsSync(dir)) return [];
|
|
63
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
64
|
+
.filter((e) => e.isDirectory() && existsSync(join(dir, e.name, 'state.json')))
|
|
65
|
+
.map((e) => e.name)
|
|
66
|
+
.sort();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Newest archive/YYYY-MM-DD-<name> for an archived change, or null.
|
|
70
|
+
export function archivedDir(ctx, name) {
|
|
71
|
+
const dir = ctx.abs(ctx.archiveRel);
|
|
72
|
+
if (!existsSync(dir)) return null;
|
|
73
|
+
const hits = readdirSync(dir)
|
|
74
|
+
.filter((d) => /^\d{4}-\d{2}-\d{2}-/.test(d) && d.slice(11) === name)
|
|
75
|
+
.sort();
|
|
76
|
+
return hits.length ? `${ctx.archiveRel}/${hits.at(-1)}` : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Active first, then archive (archive/finish recovery runs against the moved dir).
|
|
80
|
+
export function locateChange(ctx, name) {
|
|
81
|
+
validateName(name);
|
|
82
|
+
const active = changePaths(ctx, name);
|
|
83
|
+
if (existsSync(active.state)) return { ...active, archived: false };
|
|
84
|
+
const moved = archivedDir(ctx, name);
|
|
85
|
+
if (moved && existsSync(ctx.abs(`${moved}/state.json`))) return { ...changePaths(ctx, name, moved), archived: true };
|
|
86
|
+
return die(USAGE, `找不到 change '${name}'。运行 node "$COMATE" status 查看现有 change。`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Capability directories under a specs dir (each holding spec.md).
|
|
90
|
+
export function capabilities(specsAbs) {
|
|
91
|
+
if (!existsSync(specsAbs)) return [];
|
|
92
|
+
return readdirSync(specsAbs, { withFileTypes: true })
|
|
93
|
+
.filter((e) => e.isDirectory() && existsSync(join(specsAbs, e.name, 'spec.md')))
|
|
94
|
+
.map((e) => e.name)
|
|
95
|
+
.sort();
|
|
96
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// verification.md is generated from state.json after every verify result. Hand
|
|
2
|
+
// edits are pointless: the next render overwrites them.
|
|
3
|
+
import { writeAtomic } from './util.mjs';
|
|
4
|
+
|
|
5
|
+
const esc = (s) => String(s ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
|
|
6
|
+
const RESULT = { passed: '✅ 通过', failed: '❌ 未通过', blocked: '⛔ 无法验证', pending: '… 待验收' };
|
|
7
|
+
|
|
8
|
+
export function renderVerification(st) {
|
|
9
|
+
const lines = [`# 验证报告:${st.name}`, '', '> 本文件由 comate 脚本根据 state.json 生成,手改无效。', ''];
|
|
10
|
+
const v = st.verifier;
|
|
11
|
+
const c = st.candidate;
|
|
12
|
+
const passed = st.acceptance.filter((a) => a.result === 'passed').length;
|
|
13
|
+
lines.push('## 结论', '');
|
|
14
|
+
if (st.step === 'degraded' || (st.phase === 'archive' && v && v.independent === false)) {
|
|
15
|
+
lines.push('降级:没有独立 verifier,只有自动检查,由用户明确接受。');
|
|
16
|
+
} else {
|
|
17
|
+
lines.push(`${passed}/${st.acceptance.length} 项验收通过(iteration ${st.loop.iteration})。`);
|
|
18
|
+
}
|
|
19
|
+
if (v?.summary) lines.push('', v.summary);
|
|
20
|
+
lines.push('', '## 候选', '');
|
|
21
|
+
lines.push(`- commit:\`${c?.commit ?? '-'}\``);
|
|
22
|
+
lines.push(`- 基线:\`${st.workspace.base_ref ?? '-'}\`(${st.workspace.base_branch ?? '-'})`);
|
|
23
|
+
if (c?.handoff?.summary) lines.push(`- builder 摘要:${esc(c.handoff.summary)}`);
|
|
24
|
+
lines.push('', '## 验收项', '', '| ID | 类型 | 来源 | 结果 | 理由 |', '|---|---|---|---|---|');
|
|
25
|
+
for (const a of st.acceptance) {
|
|
26
|
+
lines.push(`| ${a.id} | ${a.kind} | ${esc(a.source)} | ${RESULT[a.result] ?? a.result} | ${esc(a.reason)} |`);
|
|
27
|
+
}
|
|
28
|
+
lines.push('', '## 自动检查', '');
|
|
29
|
+
if (!c?.checks?.length) lines.push('无(项目配置为 none)。');
|
|
30
|
+
for (const k of c?.checks ?? []) {
|
|
31
|
+
lines.push(`- ${k.ok ? '✅' : '❌'} \`${k.cmd}\` — ${k.timed_out ? '超时' : `exit ${k.exit}`},${(k.ms / 1000).toFixed(1)}s`);
|
|
32
|
+
}
|
|
33
|
+
const reverse = v?.reverse_checks ?? [];
|
|
34
|
+
if (reverse.length) {
|
|
35
|
+
lines.push('', '## 反向验证', '', '| 对象 | 破坏方式 | 运行的测试 | 测试变红 |', '|---|---|---|---|');
|
|
36
|
+
for (const r of reverse) lines.push(`| ${esc(r.target)} | ${esc(r.mutation)} | ${esc(r.test)} | ${r.failed_as_expected ? '是' : '否'} |`);
|
|
37
|
+
}
|
|
38
|
+
if (v?.risks?.length) {
|
|
39
|
+
lines.push('', '## 风险与建议', '');
|
|
40
|
+
for (const r of v.risks) lines.push(`- ${typeof r === 'string' ? r : `${r.severity ?? 'note'}:${r.text ?? JSON.stringify(r)}`}`);
|
|
41
|
+
}
|
|
42
|
+
lines.push('', '## 核验方式', '', v?.independent === false ? '仅自动检查(verifier 不可用)' : '独立只读 verifier');
|
|
43
|
+
return `${lines.join('\n')}\n`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const writeVerification = (cp, st) => writeAtomic(cp.verification, renderVerification(st));
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// specs/<capability>/spec.md: the complete target behaviour of a capability
|
|
2
|
+
// (not a delta). Scenarios are `### Scenario: <title>`; OpenSpec's `####` level
|
|
3
|
+
// is also read so imported main specs parse the same way.
|
|
4
|
+
import { hasPlaceholder, normalizeBody, stripComments } from './md.mjs';
|
|
5
|
+
|
|
6
|
+
const SCENARIO = /^(#{3,4})\s+Scenario\s*[::]\s*(.+?)\s*#*\s*$/i;
|
|
7
|
+
const REQUIREMENT = /^(#{2,3})\s+Requirement\s*[::]\s*(.*?)\s*#*\s*$/i;
|
|
8
|
+
const ANY_HEADING = /^(#{1,6})\s/;
|
|
9
|
+
const FENCE = /^\s*(```|~~~)/;
|
|
10
|
+
|
|
11
|
+
export function parseSpec(text) {
|
|
12
|
+
const scenarios = [];
|
|
13
|
+
const requirements = [];
|
|
14
|
+
const requirementFor = new Map();
|
|
15
|
+
let curReq = null;
|
|
16
|
+
let curScenario = null;
|
|
17
|
+
let fenced = false;
|
|
18
|
+
|
|
19
|
+
stripComments(text)
|
|
20
|
+
.split(/\r?\n/)
|
|
21
|
+
.forEach((line, i) => {
|
|
22
|
+
if (FENCE.test(line)) fenced = !fenced;
|
|
23
|
+
|
|
24
|
+
const req = !fenced && REQUIREMENT.exec(line);
|
|
25
|
+
if (req) {
|
|
26
|
+
curReq = { title: req[2], level: req[1].length, body: '', line: i + 1 };
|
|
27
|
+
requirements.push(curReq);
|
|
28
|
+
curScenario = null;
|
|
29
|
+
} else if (!fenced) {
|
|
30
|
+
const m = SCENARIO.exec(line);
|
|
31
|
+
if (m) {
|
|
32
|
+
curScenario = { title: m[2].trim(), body: '', line: i + 1, requirement: null };
|
|
33
|
+
scenarios.push(curScenario);
|
|
34
|
+
requirementFor.set(curScenario, curReq);
|
|
35
|
+
} else if (ANY_HEADING.test(line)) {
|
|
36
|
+
const level = ANY_HEADING.exec(line)[1].length;
|
|
37
|
+
if (curReq && level > curReq.level) curReq.body += `${line}\n`;
|
|
38
|
+
else curReq = null;
|
|
39
|
+
curScenario = null;
|
|
40
|
+
} else if (curScenario) {
|
|
41
|
+
curScenario.body += `${line}\n`;
|
|
42
|
+
} else if (curReq) {
|
|
43
|
+
curReq.body += `${line}\n`;
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
if (curScenario) curScenario.body += `${line}\n`;
|
|
47
|
+
else if (curReq) curReq.body += `${line}\n`;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
for (const s of scenarios) {
|
|
52
|
+
s.body = normalizeBody(s.body);
|
|
53
|
+
const req = requirementFor.get(s);
|
|
54
|
+
s.requirement = req ? normalizeBody(`Requirement: ${req.title}\n${req.body}`) : null;
|
|
55
|
+
}
|
|
56
|
+
const reqBodies = requirements.map((r) => normalizeBody(r.body));
|
|
57
|
+
return { requirements: reqBodies.length, requirementBodies: reqBodies, scenarios };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 统一步骤解析:提取 WHEN/THEN/那么 等步骤,忽略代码块
|
|
61
|
+
function parseSteps(body) {
|
|
62
|
+
const steps = [];
|
|
63
|
+
const lines = body.split('\n');
|
|
64
|
+
let inFence = false;
|
|
65
|
+
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
68
|
+
inFence = !inFence;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (inFence) continue;
|
|
72
|
+
|
|
73
|
+
const step = line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '').trim();
|
|
74
|
+
const match = step.match(/^(?:\*\*(WHEN|THEN|GIVEN|那么|当|假设)\*\*|(WHEN|THEN|GIVEN|那么|当|假设))(?=\s|[::]|$)\s*[::]?\s*(.*)$/i);
|
|
75
|
+
if (match) {
|
|
76
|
+
steps.push({ keyword: (match[1] ?? match[2]).toUpperCase(), content: match[3].trim() });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return steps;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Problems that make a change spec unusable as an acceptance source.
|
|
84
|
+
export function checkChangeSpec(cap, text) {
|
|
85
|
+
const { scenarios } = parseSpec(text);
|
|
86
|
+
const problems = [];
|
|
87
|
+
if (scenarios.length === 0) {
|
|
88
|
+
problems.push(`specs/${cap}/spec.md 没有任何「### Scenario:」`);
|
|
89
|
+
}
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
for (const s of scenarios) {
|
|
92
|
+
if (seen.has(s.title)) problems.push(`specs/${cap}/spec.md 的 Scenario「${s.title}」重复`);
|
|
93
|
+
seen.add(s.title);
|
|
94
|
+
if (!s.body) problems.push(`specs/${cap}/spec.md 的 Scenario「${s.title}」没有正文`);
|
|
95
|
+
else {
|
|
96
|
+
const steps = parseSteps(s.body);
|
|
97
|
+
const thenSteps = steps.filter((st) => st.keyword === 'THEN' || st.keyword === '那么');
|
|
98
|
+
const hasThen = thenSteps.some((st) => st.content && !hasPlaceholder(st.content));
|
|
99
|
+
|
|
100
|
+
if (!hasThen) {
|
|
101
|
+
problems.push(`specs/${cap}/spec.md 的 Scenario「${s.title}」缺少 THEN(可检验的结果)`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return problems;
|
|
106
|
+
}
|