@house365/comate 0.6.0 → 0.7.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 CHANGED
@@ -61,6 +61,21 @@ cd your-project && comate init
61
61
 
62
62
  输入 `/comate` 自动探测当前阶段并分派,或直接调用某个阶段 skill。状态记录在每个 change 的 `openspec/changes/<name>/.comate.yaml` 中,支持上下文压缩后断点恢复。
63
63
 
64
+ ## Token 用量:`comate usage`
65
+
66
+ 查看**当前项目**的 token 消耗与估算成本(封装 [ccusage](https://github.com/ryoppippi/ccusage),随 comate 一起装):
67
+
68
+ ```bash
69
+ comate usage # 本项目按会话(session)统计
70
+ comate usage daily # 本项目按天
71
+ comate usage --global # 全部项目(不做项目隔离)
72
+ comate usage --json -s 2026-08-01 # 其余参数原样透传给 ccusage
73
+ ```
74
+
75
+ 默认只统计当前工作目录这一个项目:Claude Code 数据按 `~/.claude/projects/<项目>/` 隔离,Codex 数据按会话记录里的 `cwd` 过滤,两者都不会混入其它项目的用量。加 `--global` 看全量,或用 `comate usage codex ...` 单看某个 agent。
76
+
77
+ > 粒度到项目/会话级;不做 open/design/build 阶段级归因(那需要在状态机打时间戳,属后续增强)。
78
+
64
79
  ## 目录结构
65
80
 
66
81
  ```
package/bin/cli.mjs CHANGED
@@ -21,12 +21,18 @@ if (args[0] === 'doctor') {
21
21
  process.exit(doctor(args.slice(1)));
22
22
  }
23
23
 
24
+ if (args[0] === 'usage') {
25
+ const { usage } = await import('./usage.mjs');
26
+ process.exit(await usage(args.slice(1), { pkgRoot }));
27
+ }
28
+
24
29
  if (args[0] === '--help' || args[0] === '-h') {
25
30
  console.log(`comate — OpenSpec + Superpowers 规约驱动的开发编排器
26
31
 
27
32
  用法:
28
33
  comate init 安装 comate 和依赖到用户级,并在当前项目创建 openspec/
29
34
  comate doctor 健康检查
35
+ comate usage 查看本项目 token 用量(--global 看全部;参数透传给 ccusage)
30
36
 
31
37
  装好后在 agent 中输入 /comate 开始使用。`);
32
38
  process.exit(0);
package/bin/doctor.mjs CHANGED
@@ -3,8 +3,12 @@
3
3
  // ponytail: comate.sh already implements every check. This only locates the
4
4
  // installed copy so the user doesn't have to remember the path.
5
5
  import { spawnSync } from 'node:child_process';
6
- import { existsSync } from 'node:fs';
7
- import { delimiter, join, resolve } from 'node:path';
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { createRequire } from 'node:module';
8
+ import { delimiter, dirname, join, resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ import { atLeast, capture, npmInvocation } from './init.mjs';
8
12
 
9
13
  // Where the skills CLI puts things. `init` installs user-level, but a project
10
14
  // copy shadows it for the agent, so check cwd first and report on the same one
@@ -67,5 +71,85 @@ export function doctor(argv) {
67
71
  console.error(`无法执行 bash (${bash}) — comate 的脚本需要 bash。${error.message}`);
68
72
  return 1;
69
73
  }
74
+
75
+ // ccusage is a node dependency (for `comate usage`), not a bash-side tool, so
76
+ // it lives here rather than in comate.sh's doctor. A miss is a warning, never
77
+ // a failure: usage falls back to `npx ccusage@latest`, so it still works.
78
+ reportCcusage();
79
+
80
+ // Version check is the only place a user learns a newer comate exists: the
81
+ // npm package doesn't self-update and `~/.agents/skills` keeps the copied
82
+ // skills until `comate init` is re-run. Never blocks or fails doctor — a
83
+ // network miss / offline just skips the line (see checkForUpdate).
84
+ reportUpdate();
85
+
70
86
  return status ?? 1;
71
87
  }
88
+
89
+ // resolvable = shipped as comate's pinned dependency; the common, happy case
90
+ // after `npm i -g @house365/comate`. Otherwise point at the two ways out.
91
+ export function ccusageResolvable(pkgRoot = defaultPkgRoot()) {
92
+ try {
93
+ createRequire(join(pkgRoot, 'package.json')).resolve('ccusage/package.json');
94
+ return true;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ function reportCcusage() {
101
+ if (ccusageResolvable()) {
102
+ console.log(' ✓ ccusage present (comate usage 可用)');
103
+ } else {
104
+ console.log(
105
+ ' ⚠ ccusage not found — reinstall with npm i -g @house365/comate ' +
106
+ '(comate usage 会回退到 npx ccusage@latest)',
107
+ );
108
+ }
109
+ }
110
+
111
+ // Read {name, version} from the installed package.json. Both come from the same
112
+ // file so the check keys off whatever package this binary actually belongs to —
113
+ // no hardcoded name/version to drift when the package is renamed or bumped.
114
+ export function localPackage(pkgRoot = defaultPkgRoot()) {
115
+ try {
116
+ const { name, version } = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8'));
117
+ return name && version ? { name, version } : null;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ // Compare the installed version against npm's latest. Returns null (silent) on
124
+ // anything uncertain — no package.json, no npm, offline, timeout, unparseable
125
+ // output — because a *diagnostic* command must never hang or error on a network
126
+ // miss. `fetchLatest` is injectable so this is unit-testable without the network.
127
+ export function checkForUpdate({
128
+ pkg = localPackage(),
129
+ fetchLatest = defaultFetchLatest,
130
+ } = {}) {
131
+ if (!pkg) return null;
132
+ const latest = fetchLatest(pkg.name);
133
+ if (!latest || !/^\d+\.\d+\.\d+/.test(latest)) return null;
134
+ // outdated when current is NOT at least latest (atLeast is >=).
135
+ return { name: pkg.name, current: pkg.version, latest, outdated: !atLeast(pkg.version, latest) };
136
+ }
137
+
138
+ // `npm view <pkg> version` — capture() already caps at 15s and returns null on
139
+ // any non-zero exit, so offline/timeout falls through to a skipped check.
140
+ function defaultFetchLatest(name) {
141
+ const { command, args, shell } = npmInvocation(['view', name, 'version']);
142
+ return capture(command, args, { shell: Boolean(shell) });
143
+ }
144
+
145
+ function reportUpdate(info = checkForUpdate()) {
146
+ if (!info || !info.outdated) return; // up to date or couldn't tell → say nothing
147
+ console.log(
148
+ ` ⬆ 有新版 comate:${info.current} → ${info.latest}。升级:` +
149
+ `npm i -g ${info.name}@latest 然后重跑 comate init`,
150
+ );
151
+ }
152
+
153
+ function defaultPkgRoot() {
154
+ return resolve(dirname(fileURLToPath(import.meta.url)), '..');
155
+ }
package/bin/usage.mjs ADDED
@@ -0,0 +1,252 @@
1
+ // `comate usage` — show this project's Claude Code / Codex token usage by
2
+ // wrapping ccusage.
3
+ //
4
+ // Why a wrapper and not just `npx ccusage`: ccusage aggregates every project
5
+ // globally and there is no "just this repo" flag. Scoping has to be done per
6
+ // agent, because the two store data differently:
7
+ //
8
+ // Claude Code — one dir per project: ~/.claude/projects/<encoded-cwd>/*.jsonl.
9
+ // Honours CLAUDE_CONFIG_DIR. So: a throwaway root whose
10
+ // projects/ holds a single symlink to this project's dir.
11
+ // Codex — flat by date: ~/.codex/sessions/YYYY/MM/DD/*.jsonl, project
12
+ // identified only by a `cwd` inside each file's session_meta.
13
+ // Honours CODEX_HOME. So: filter files by cwd, symlink the
14
+ // matches into a throwaway sessions/ dir.
15
+ //
16
+ // Both env vars are set to the scoped roots even when a root is empty — an empty
17
+ // scoped root reports zero for that agent, which is correct, whereas leaving the
18
+ // var unset would let ccusage fall back to the global dir and leak every other
19
+ // project's usage in. `--global` skips all scoping and forwards straight through.
20
+ import { spawnSync } from 'node:child_process';
21
+ import {
22
+ existsSync,
23
+ mkdtempSync,
24
+ mkdirSync,
25
+ openSync,
26
+ readSync,
27
+ closeSync,
28
+ readdirSync,
29
+ rmSync,
30
+ linkSync,
31
+ copyFileSync,
32
+ } from 'node:fs';
33
+ import { createRequire } from 'node:module';
34
+ import { tmpdir } from 'node:os';
35
+ import { basename, dirname, join, relative } from 'node:path';
36
+
37
+ // Claude Code encodes a project's cwd into a directory name by replacing every
38
+ // path separator and the drive colon with a dash: D:\a\b -> D--a-b, /a/b -> -a-b.
39
+ // Verified against a real ~/.claude/projects entry on Windows.
40
+ export function encodeProjectDir(cwd) {
41
+ return cwd.replace(/[:\\/]/g, '-');
42
+ }
43
+
44
+ function home() {
45
+ return process.env.HOME ?? process.env.USERPROFILE ?? '';
46
+ }
47
+
48
+ // Where Claude Code keeps this project's transcripts. CLAUDE_CONFIG_DIR wins if
49
+ // the user has relocated the config dir, matching ccusage's own resolution.
50
+ export function claudeDataDir(cwd = process.cwd(), env = process.env) {
51
+ const configRoot = env.CLAUDE_CONFIG_DIR || join(home(), '.claude');
52
+ return join(configRoot, 'projects', encodeProjectDir(cwd));
53
+ }
54
+
55
+ // Codex's sessions root. CODEX_HOME wins, else ~/.codex.
56
+ export function codexSessionsDir(env = process.env) {
57
+ const root = env.CODEX_HOME || join(home(), '.codex');
58
+ return join(root, 'sessions');
59
+ }
60
+
61
+ // Read just the head of a file — enough to catch the session_meta record, which
62
+ // Codex writes first. Avoids slurping multi-MB transcripts to read one cwd.
63
+ function readHead(file, bytes = 8192) {
64
+ const fd = openSync(file, 'r');
65
+ try {
66
+ const buf = Buffer.alloc(bytes);
67
+ const n = readSync(fd, buf, 0, bytes, 0);
68
+ return buf.toString('utf8', 0, n);
69
+ } finally {
70
+ closeSync(fd);
71
+ }
72
+ }
73
+
74
+ // The cwd recorded in a Codex session file, or null. session_meta is the first
75
+ // record; its payload.cwd (older: top-level cwd) is the project path.
76
+ export function codexFileCwd(file, read = readHead) {
77
+ const head = read(file);
78
+ const line = head.split('\n').find((l) => l.includes('session_meta'));
79
+ if (!line) return null;
80
+ try {
81
+ const o = JSON.parse(line);
82
+ return o.payload?.cwd ?? o.cwd ?? null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ // Every *.jsonl under a Codex sessions dir whose recorded cwd matches this
89
+ // project. Bounded head-reads only; returns [] if the sessions dir is absent.
90
+ export function codexFilesForCwd(sessionsDir, cwd, { read = readHead } = {}) {
91
+ if (!existsSync(sessionsDir)) return [];
92
+ const out = [];
93
+ const walk = (dir) => {
94
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
95
+ const p = join(dir, e.name);
96
+ if (e.isDirectory()) walk(p);
97
+ else if (e.name.endsWith('.jsonl') && codexFileCwd(p, read) === cwd) out.push(p);
98
+ }
99
+ };
100
+ walk(sessionsDir);
101
+ return out;
102
+ }
103
+
104
+ // Prefer the pinned ccusage dependency (offline, version-locked); fall back to
105
+ // npx when resolution fails (e.g. a hoisting layout we can't see). Mirrors
106
+ // init.mjs's skillsInvocation so the two behave the same across install shapes.
107
+ export function ccusageInvocation(pkgRoot, args) {
108
+ try {
109
+ const require = createRequire(join(pkgRoot, 'package.json'));
110
+ const manifest = require('ccusage/package.json');
111
+ const rel = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin.ccusage;
112
+ const entry = join(dirnameOf(require.resolve('ccusage/package.json')), rel);
113
+ return { command: process.execPath, args: [entry, ...args] };
114
+ } catch {
115
+ return {
116
+ command: process.platform === 'win32' ? 'npx.cmd' : 'npx',
117
+ args: ['--yes', 'ccusage@latest', ...args],
118
+ shell: process.platform === 'win32',
119
+ };
120
+ }
121
+ }
122
+
123
+ function dirnameOf(file) {
124
+ return file.slice(0, Math.max(file.lastIndexOf('/'), file.lastIndexOf('\\')));
125
+ }
126
+
127
+ // Materialise one source file into the scoped tree. Hardlink first — it is
128
+ // instant, uses no extra disk, and (unlike a symlink or junction) is a real
129
+ // directory entry that ccusage's globber traverses on Windows, where symlinked
130
+ // and junctioned dirs are silently skipped. Copy is the cross-volume fallback
131
+ // (hardlinks can't span drives). Creates parent dirs as needed.
132
+ function linkFile(src, dst) {
133
+ mkdirSync(dirname(dst), { recursive: true });
134
+ try {
135
+ linkSync(src, dst);
136
+ } catch {
137
+ copyFileSync(src, dst);
138
+ }
139
+ }
140
+
141
+ // Every *.jsonl under a dir, as [absolutePath, relativePath] pairs so the scoped
142
+ // copy can preserve Claude's per-project subdir layout.
143
+ function jsonlFiles(dir, base = dir, out = []) {
144
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
145
+ const p = join(dir, e.name);
146
+ if (e.isDirectory()) jsonlFiles(p, base, out);
147
+ else if (e.name.endsWith('.jsonl')) out.push([p, relative(base, p)]);
148
+ }
149
+ return out;
150
+ }
151
+
152
+ // Build a throwaway config root scoped to this project, for both agents. Always
153
+ // returns { root, env, cleanup }: env carries CLAUDE_CONFIG_DIR + CODEX_HOME
154
+ // pointing at the scoped roots, so unset agents report zero rather than leaking
155
+ // global data. `found` says whether any agent had data for this project.
156
+ //
157
+ // Files are hardlinked (see linkFile) rather than the dir symlinked, because
158
+ // ccusage does not traverse symlinked/junctioned directories on Windows.
159
+ export function buildScopedEnv(
160
+ { claudeDir, codexSessionsDir: codexDir, cwd },
161
+ { mkdtemp = defaultMkdtemp, linkFn = linkFile, codexFiles = codexFilesForCwd } = {},
162
+ ) {
163
+ const root = mkdtemp();
164
+
165
+ // Claude: hardlink this project's transcripts under projects/proj/, keeping
166
+ // any subdir structure so ccusage sees them exactly as it would in place.
167
+ const claudeConfig = join(root, 'claude');
168
+ const claudeProj = join(claudeConfig, 'projects', 'proj');
169
+ mkdirSync(claudeProj, { recursive: true });
170
+ let found = false;
171
+ if (claudeDir && existsSync(claudeDir)) {
172
+ for (const [abs, rel] of jsonlFiles(claudeDir)) {
173
+ linkFn(abs, join(claudeProj, rel));
174
+ found = true;
175
+ }
176
+ }
177
+
178
+ // Codex: hardlink only the session files whose cwd matches this project,
179
+ // flattened under sessions/ (ccusage globs recursively, so layout is moot).
180
+ const codexHome = join(root, 'codex');
181
+ const codexSessions = join(codexHome, 'sessions');
182
+ mkdirSync(codexSessions, { recursive: true });
183
+ const matches = codexDir ? codexFiles(codexDir, cwd) : [];
184
+ matches.forEach((f, i) => linkFn(f, join(codexSessions, `${i}-${basename(f)}`)));
185
+ if (matches.length > 0) found = true;
186
+
187
+ return {
188
+ root,
189
+ found,
190
+ env: { ...process.env, CLAUDE_CONFIG_DIR: claudeConfig, CODEX_HOME: codexHome },
191
+ cleanup: () => rmSync(root, { recursive: true, force: true }),
192
+ };
193
+ }
194
+
195
+ function defaultMkdtemp() {
196
+ return mkdtempSync(join(tmpdir(), 'comate-usage-'));
197
+ }
198
+
199
+ // Split comate's own flags (--global) from everything else, which is forwarded
200
+ // to ccusage untouched. Default subcommand is `session` — the per-conversation
201
+ // view is what "how much did this project cost" usually means.
202
+ export function parseArgs(argv) {
203
+ const global = argv.includes('--global');
204
+ const rest = argv.filter((a) => a !== '--global');
205
+ const hasCommand = rest.length > 0 && !rest[0].startsWith('-');
206
+ const forwarded = hasCommand ? rest : ['session', ...rest];
207
+ return { global, forwarded };
208
+ }
209
+
210
+ export async function usage(argv, { pkgRoot = defaultPkgRoot() } = {}) {
211
+ const { global, forwarded } = parseArgs(argv);
212
+
213
+ let scoped = null;
214
+ let env = process.env;
215
+ if (!global) {
216
+ const cwd = process.cwd();
217
+ scoped = buildScopedEnv({
218
+ claudeDir: claudeDataDir(cwd),
219
+ codexSessionsDir: codexSessionsDir(),
220
+ cwd,
221
+ });
222
+ if (!scoped.found) {
223
+ scoped.cleanup();
224
+ console.error(
225
+ '本项目暂无 Claude Code / Codex 会话记录。\n' +
226
+ '用 comate usage --global 查看全部项目的用量。',
227
+ );
228
+ return 1;
229
+ }
230
+ env = scoped.env;
231
+ }
232
+
233
+ try {
234
+ const { command, args, shell } = ccusageInvocation(pkgRoot, forwarded);
235
+ const { status, error } = spawnSync(command, args, {
236
+ stdio: 'inherit',
237
+ env,
238
+ ...(shell ? { shell: true } : {}),
239
+ });
240
+ if (error) {
241
+ console.error(`无法运行 ccusage:${error.message}\n试试 npx ccusage@latest`);
242
+ return 1;
243
+ }
244
+ return status ?? 1;
245
+ } finally {
246
+ scoped?.cleanup();
247
+ }
248
+ }
249
+
250
+ function defaultPkgRoot() {
251
+ return dirnameOf(dirnameOf(import.meta.url.replace(/^file:\/\/\/?/, '')));
252
+ }
@@ -43,7 +43,7 @@ comate 是一个规约驱动、AI 辅助的开发编排器。它将 **OpenSpec**
43
43
 
44
44
  流程链:(gate ->) open -> design -> build -> verify -> archive
45
45
 
46
- **阶段 0(可选,PRD 门禁与拆分)**:需求来自一份**原始 PRD**时,先过 `/comate-gate`:审阅 PRD 质量、百分制评分、建阻断台账,门禁通过前禁止建任何 change;通过后按依赖/整体性/独立性/规模把 PRD 拆成 1 或 N 个 change(粒度由用户定),逐个交给 `/comate-open`。门禁状态钉在 PRD 同级 `<PRD>.gate.yaml`(机器真相源),评审报告为 `PRD-REVIEW-<PRD名>.md`(人类可读)。PRD 通过门禁后被改会因哈希漂移自动失效(stale),需重跑 gate。需求来自对话时无需此阶段。
46
+ **阶段 0(可选,PRD 门禁与拆分)**:需求来自一份**原始 PRD**时,先过 `/comate-gate`:审阅 PRD 质量、百分制评分、建阻断台账,门禁通过前禁止建任何 change;通过后按依赖/整体性/独立性/规模把 PRD 拆成 1 或 N 个 change(粒度由用户定),逐个交给 `/comate-open`。门禁状态钉在 PRD 同级 `PRD-<NNN>.gate.yaml`(如 `PRD-001.gate.yaml`,机器真相源),评审报告为 `PRD-<NNN>-REVIEW.md`(如 `PRD-001-REVIEW.md`,人类可读)。PRD 通过门禁后被改会因哈希漂移自动失效(stale),需重跑 gate。需求来自对话时无需此阶段。
47
47
 
48
48
  单次 `/comate` 调用从检测到的阶段开始,当退出条件满足时推进到下一阶段。智能体会自动推进后续阶段,但必须在决策阻塞点暂停,直到收到用户确认。
49
49
 
@@ -72,6 +72,8 @@ comate 是一个规约驱动、AI 辅助的开发编排器。它将 **OpenSpec**
72
72
  | `/comate-grill` | 工具 | Claude | 决策台账(逐决策点澄清,附推荐 + 用户答案) |
73
73
  | `/comate-kb-integration` | 工具 | Claude | 第三方对接档案(从官方文档 URL 生成 integrations 目录档案 + 索引行) |
74
74
 
75
+ 此外,终端命令 `comate usage` 查看当前项目的 token 用量与估算成本(见 §2.7)。
76
+
75
77
  ---
76
78
 
77
79
  ## 2. 安装与前置条件
@@ -172,6 +174,29 @@ skills/
172
174
 
173
175
  ---
174
176
 
177
+ ### 2.7 Token 用量:`comate usage`
178
+
179
+ 终端命令,查看**当前项目**的 token 消耗与估算成本。封装 [ccusage](https://github.com/ryoppippi/ccusage)——它作为 comate 的依赖随包安装,`comate doctor` 会检测其可用性(缺失时回退 `npx ccusage@latest`)。
180
+
181
+ ```bash
182
+ comate usage # 本项目按会话(session)统计(默认)
183
+ comate usage daily # 本项目按天
184
+ comate usage --global # 全部项目,不做项目隔离
185
+ comate usage codex daily # 单看某个 agent
186
+ comate usage --json -s 2026-08-01 # 其余参数原样透传给 ccusage
187
+ ```
188
+
189
+ **项目隔离原理**:ccusage 默认全局聚合、无项目过滤。comate 在临时目录里为当前项目重建一个 scoped 配置根,用 `CLAUDE_CONFIG_DIR` + `CODEX_HOME` 指过去,命令结束即清理:
190
+
191
+ - **Claude Code**:数据本就按 `~/.claude/projects/<编码 cwd>/` 分目录,硬链接本项目那一个目录的 `*.jsonl` 进 scoped 根。
192
+ - **Codex**:数据按日期平铺(`~/.codex/sessions/YYYY/MM/DD/`),项目路径藏在每个文件的 `session_meta.cwd`,故按 `cwd` 过滤后硬链接命中的文件。
193
+
194
+ 用硬链接而非目录 symlink/junction:Windows 下 ccusage 的文件扫描不遍历 symlink/junction 目录,硬链接则表现为真实文件。硬链接跨盘失败时回退复制。两个 agent 的 scoped 根都会设,未命中的 agent 报 0 而非泄漏全局数据。
195
+
196
+ **边界**:粒度到项目/会话级,不做 open/design/build 阶段级归因(需在状态机流转处打时间戳,属后续增强)。
197
+
198
+ ---
199
+
175
200
  ## 3. 快速开始
176
201
 
177
202
  ### 3.1 基本用法
@@ -561,7 +586,7 @@ bash "$COMATE" doctor
561
586
 
562
587
  ### gate - PRD 门禁(阶段 0)
563
588
 
564
- 对**原始 PRD** 做质量门禁,通过前禁止从该 PRD 建 change。状态钉在 PRD 同级 `<PRD>.gate.yaml`(独立于 change 的 `.comate.yaml`),评审报告为 `PRD-REVIEW-<PRD名>.md`。由 `/comate-gate` 驱动。
589
+ 对**原始 PRD** 做质量门禁,通过前禁止从该 PRD 建 change。状态钉在 PRD 同级 `PRD-<NNN>.gate.yaml`(如 `PRD-001.gate.yaml`,独立于 change 的 `.comate.yaml`),评审报告为 `PRD-<NNN>-REVIEW.md`(如 `PRD-001-REVIEW.md`)。由 `/comate-gate` 驱动。
565
590
 
566
591
  | 命令 | 描述 |
567
592
  |---------|-------------|
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@house365/comate",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "comate —— OpenSpec + Superpowers 规约驱动的 AI 开发编排器(Agent Skills)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,11 +41,12 @@
41
41
  "node": ">=20"
42
42
  },
43
43
  "dependencies": {
44
+ "ccusage": "^20.0.19",
44
45
  "skills": "^1.5.21"
45
46
  },
46
47
  "scripts": {
47
48
  "postinstall": "node bin/postinstall.mjs",
48
- "test": "node bin/init.test.mjs && bash skills/comate/tests/run.sh",
49
+ "test": "node bin/init.test.mjs && node bin/usage.test.mjs && bash skills/comate/tests/run.sh",
49
50
  "doctor": "cd skills && bash comate/scripts/comate.sh doctor",
50
51
  "prepublish-check": "node scripts/prepublish-check.mjs",
51
52
  "pack-check": "node scripts/pack-check.mjs",
@@ -460,23 +460,40 @@ declare -A GATE_ENUMS=(
460
460
  [gate_status]="reviewing blocked passed passed-with-tbd stale"
461
461
  )
462
462
 
463
+ # gate_stem_for <prd> — derive the short stem used to name a PRD's gate.yaml
464
+ # and review report. A conforming PRD file is `PRD-<NNN>-<title>.md`, whose
465
+ # gate/report drop the title and key off `PRD-<NNN>` (so one dir with
466
+ # PRD-001/PRD-002 gets PRD-001.gate.yaml / PRD-002.gate.yaml). Non-conforming
467
+ # names (no PRD-NNN prefix) fall back to the full basename so callers still get
468
+ # a stable, collision-free stem instead of an error.
469
+ gate_stem_for() {
470
+ local base
471
+ base=$(basename "$1")
472
+ base="${base%.markdown}"
473
+ base="${base%.md}"
474
+ if [[ "$base" =~ ^([Pp][Rr][Dd]-[0-9]+) ]]; then
475
+ echo "${BASH_REMATCH[1]}"
476
+ else
477
+ echo "$base"
478
+ fi
479
+ }
480
+
463
481
  # gate_path_for <prd> — echo the gate.yaml path adjacent to the PRD.
464
- # Strips a trailing .md/.markdown and appends .gate.yaml.
482
+ # Named `<stem>.gate.yaml` where stem is PRD-<NNN> (see gate_stem_for).
465
483
  gate_path_for() {
466
- local prd="$1" base
467
- base="${prd%.markdown}"
468
- base="${base%.md}"
469
- echo "${base}.gate.yaml"
484
+ local prd="$1" dir stem
485
+ dir=$(dirname "$prd")
486
+ stem=$(gate_stem_for "$prd")
487
+ echo "${dir}/${stem}.gate.yaml"
470
488
  }
471
489
 
472
490
  # gate_report_for <prd> — echo the review report path (PRD same dir).
491
+ # Named `<stem>-REVIEW.md` where stem is PRD-<NNN> (see gate_stem_for).
473
492
  gate_report_for() {
474
- local prd="$1" dir base
493
+ local prd="$1" dir stem
475
494
  dir=$(dirname "$prd")
476
- base=$(basename "$prd")
477
- base="${base%.markdown}"
478
- base="${base%.md}"
479
- echo "${dir}/PRD-REVIEW-${base}.md"
495
+ stem=$(gate_stem_for "$prd")
496
+ echo "${dir}/${stem}-REVIEW.md"
480
497
  }
481
498
 
482
499
  # gate_yaml_set <file> <key> <value> — gate-file analogue of yaml_set: validate
@@ -454,13 +454,25 @@ seed_prd() {
454
454
  local prd; prd=$(seed_prd)
455
455
  run comate gate "$prd" review
456
456
  [ "$status" -eq 0 ]
457
- [ -f "docs/knowledge-base/requirements/test/PRD-001-demo.gate.yaml" ]
457
+ [ -f "docs/knowledge-base/requirements/test/PRD-001.gate.yaml" ]
458
+ }
459
+
460
+ @test "gate names key off PRD-NNN stem, dropping title" {
461
+ local prd; prd=$(seed_prd)
462
+ run comate gate "$prd" review
463
+ [ "$status" -eq 0 ]
464
+ # gate.yaml and review report drop the -demo title, key off PRD-001
465
+ [ -f "docs/knowledge-base/requirements/test/PRD-001.gate.yaml" ]
466
+ [ ! -f "docs/knowledge-base/requirements/test/PRD-001-demo.gate.yaml" ]
467
+ [[ "$output" == *"PRD-001-REVIEW.md"* ]]
468
+ grep -q "review_report: docs/knowledge-base/requirements/test/PRD-001-REVIEW.md" \
469
+ "docs/knowledge-base/requirements/test/PRD-001.gate.yaml"
458
470
  }
459
471
 
460
472
  @test "gate review writes reviewing status and pins prd_hash" {
461
473
  local prd; prd=$(seed_prd)
462
474
  comate gate "$prd" review >/dev/null
463
- local gate="docs/knowledge-base/requirements/test/PRD-001-demo.gate.yaml"
475
+ local gate="docs/knowledge-base/requirements/test/PRD-001.gate.yaml"
464
476
  run comate gate "$prd" status
465
477
  [ "$status" -eq 0 ]
466
478
  [[ "$output" == *"reviewing"* ]]
@@ -502,7 +514,7 @@ seed_prd() {
502
514
  comate gate "$prd" set score 90 >/dev/null
503
515
  comate gate "$prd" set blocking_open 0 >/dev/null
504
516
  printf '# 评审报告\n' > \
505
- "docs/knowledge-base/requirements/test/PRD-REVIEW-PRD-001-demo.md"
517
+ "docs/knowledge-base/requirements/test/PRD-001-REVIEW.md"
506
518
  run comate gate "$prd" pass
507
519
  [ "$status" -eq 0 ]
508
520
  run comate gate "$prd" check
@@ -515,7 +527,7 @@ seed_prd() {
515
527
  comate gate "$prd" set score 65 >/dev/null
516
528
  comate gate "$prd" set blocking_open 0 >/dev/null
517
529
  printf '# 评审报告\n' > \
518
- "docs/knowledge-base/requirements/test/PRD-REVIEW-PRD-001-demo.md"
530
+ "docs/knowledge-base/requirements/test/PRD-001-REVIEW.md"
519
531
  run comate gate "$prd" pass --tbd
520
532
  [ "$status" -eq 0 ]
521
533
  run comate gate "$prd" status
@@ -528,7 +540,7 @@ seed_prd() {
528
540
  comate gate "$prd" set score 90 >/dev/null
529
541
  comate gate "$prd" set blocking_open 0 >/dev/null
530
542
  printf '# 评审报告\n' > \
531
- "docs/knowledge-base/requirements/test/PRD-REVIEW-PRD-001-demo.md"
543
+ "docs/knowledge-base/requirements/test/PRD-001-REVIEW.md"
532
544
  comate gate "$prd" pass >/dev/null
533
545
  # mutate PRD after passing
534
546
  printf '\n额外内容\n' >> "$prd"
@@ -543,14 +555,14 @@ seed_prd() {
543
555
  comate gate "$prd" set score 90 >/dev/null
544
556
  comate gate "$prd" set blocking_open 0 >/dev/null
545
557
  printf '# 评审报告\n' > \
546
- "docs/knowledge-base/requirements/test/PRD-REVIEW-PRD-001-demo.md"
558
+ "docs/knowledge-base/requirements/test/PRD-001-REVIEW.md"
547
559
  comate gate "$prd" pass >/dev/null
548
560
  printf '\n改动\n' >> "$prd"
549
561
  run comate gate "$prd" status
550
562
  [ "$status" -eq 0 ]
551
563
  [[ "$output" == *"stale"* ]]
552
564
  # gate.yaml should still say passed (status is computed, not written by status cmd)
553
- local gate="docs/knowledge-base/requirements/test/PRD-001-demo.gate.yaml"
565
+ local gate="docs/knowledge-base/requirements/test/PRD-001.gate.yaml"
554
566
  local stored; stored=$(grep '^gate_status:' "$gate" | awk '{print $2}')
555
567
  [ "$stored" = "passed" ]
556
568
  }
@@ -561,11 +573,11 @@ seed_prd() {
561
573
  comate gate "$prd" set score 90 >/dev/null
562
574
  comate gate "$prd" set blocking_open 0 >/dev/null
563
575
  printf '# 报告\n' > \
564
- "docs/knowledge-base/requirements/test/PRD-REVIEW-PRD-001-demo.md"
576
+ "docs/knowledge-base/requirements/test/PRD-001-REVIEW.md"
565
577
  comate gate "$prd" pass >/dev/null
566
578
  run comate gate "$prd" spawned login-flow
567
579
  [ "$status" -eq 0 ]
568
- local gate="docs/knowledge-base/requirements/test/PRD-001-demo.gate.yaml"
580
+ local gate="docs/knowledge-base/requirements/test/PRD-001.gate.yaml"
569
581
  local sc; sc=$(grep '^spawned_changes:' "$gate" | sed 's/spawned_changes: //')
570
582
  [[ "$sc" == *"login-flow"* ]]
571
583
  }
@@ -576,11 +588,11 @@ seed_prd() {
576
588
  comate gate "$prd" set score 90 >/dev/null
577
589
  comate gate "$prd" set blocking_open 0 >/dev/null
578
590
  printf '# 报告\n' > \
579
- "docs/knowledge-base/requirements/test/PRD-REVIEW-PRD-001-demo.md"
591
+ "docs/knowledge-base/requirements/test/PRD-001-REVIEW.md"
580
592
  comate gate "$prd" pass >/dev/null
581
593
  comate gate "$prd" spawned login-flow >/dev/null
582
594
  comate gate "$prd" spawned logout-flow >/dev/null
583
- local gate="docs/knowledge-base/requirements/test/PRD-001-demo.gate.yaml"
595
+ local gate="docs/knowledge-base/requirements/test/PRD-001.gate.yaml"
584
596
  local sc; sc=$(grep '^spawned_changes:' "$gate" | sed 's/spawned_changes: //')
585
597
  [[ "$sc" == *"login-flow"* ]]
586
598
  [[ "$sc" == *"logout-flow"* ]]
@@ -605,7 +617,7 @@ seed_prd() {
605
617
  comate gate "$prd" set score 90 >/dev/null
606
618
  comate gate "$prd" set blocking_open 0 >/dev/null
607
619
  printf '# 报告\n' > \
608
- "docs/knowledge-base/requirements/test/PRD-REVIEW-PRD-001-demo.md"
620
+ "docs/knowledge-base/requirements/test/PRD-001-REVIEW.md"
609
621
  comate gate "$prd" pass >/dev/null
610
622
  printf '\n改动\n' >> "$prd"
611
623
  # re-run review after PRD change — should reset to reviewing
@@ -17,7 +17,7 @@ description: "comate 阶段 0:PRD 门禁与拆分。用 /comate-gate 调用。
17
17
 
18
18
  ## 核心铁律
19
19
 
20
- - **门禁状态是可验证的文件真相源**:钉在 PRD 同级 `<PRD>.gate.yaml`。评审报告 `PRD-REVIEW-<PRD名>.md` 是给人看的派生物,**不是**状态源。
20
+ - **门禁状态是可验证的文件真相源**:钉在 PRD 同级 `<PRD-NNN>.gate.yaml`(如 `PRD-001.gate.yaml`)。评审报告 `<PRD-NNN>-REVIEW.md`(如 `PRD-001-REVIEW.md`)是给人看的派生物,**不是**状态源。
21
21
  - **AskUserQuestion 是真阻塞点**:文字提示、推荐方案、默认值、历史偏好、用户未反对,都**不能**替代明确回答。
22
22
  - **只审不改**:门禁只暴露 PRD 问题,不替 PRD 补内容、不替产品拍板。
23
23
  - **门禁未通过禁止建 change**:`gate_status ∈ {passed, passed-with-tbd}` 且 PRD 哈希未漂移,才允许 comate-open 建 change(由 `gate check` 硬强制)。
@@ -59,6 +59,26 @@ bash "$COMATE" gate <prd> check # comate-open 建 change 前的
59
59
  - 读取后重点提取:**功能清单**(各功能项 + 优先级 P0/P1/P2)、**范围(In/Out)**、**第三方依赖**、**验收标准**、文档状态。
60
60
  - 若 PRD 明显未定稿(状态字段非「已定稿」等),用 **AskUserQuestion** 提示可能未冻结并暂停确认;确认前只审阅,不建 gate。
61
61
 
62
+ ### 1.5 命名规范化(前置,定位到 PRD 即检测)
63
+
64
+ 用户一旦指到 `docs/knowledge-base/requirements/` 下的文件,**先检测命名是否规范,再进门禁**——因为 gate.yaml 与评审报告的名字都从 PRD 的 `PRD-<NNN>` 前缀派生,名字不规范会连带派生错。comate 只有 PRD 一层(无 MRD/FRD、无固定负责人),只查这三样:
65
+
66
+ | 对象 | 规范 | 反例 → 规范名 |
67
+ |------|------|--------------|
68
+ | 功能目录 | `<YYYYMMDD-功能名>/` | `图片素材管理/` → `20260614-图片素材管理/` |
69
+ | PRD 文件 | 目录内 `PRD-<三位序号>-<功能名>.md`(序号紧跟类型在前) | `图片素材管理_PRD_V1.md` → `PRD-001-图片素材管理.md`;`PRD-1.md` → `PRD-001-图片素材管理.md` |
70
+ | 派生文件 | `PRD-<NNN>.gate.yaml`、`PRD-<NNN>-REVIEW.md`(脚本自动按 PRD-NNN 前缀生成,无需手动命名) | 旧式 `PRD-001-图片素材管理.gate.yaml`、`PRD-REVIEW-PRD-001-图片素材管理.md` → 迁移到新名 |
71
+
72
+ **检测到不规范时**(顺序很重要,避免断链):
73
+
74
+ 1. 先 **Grep** 全仓搜旧文件名/旧路径的所有引用(正文、其它文档、`.comate.yaml` 的 `source_manifest`、gate.yaml 的 `prd_path`/`review_report`)。
75
+ 2. 用 **AskUserQuestion** 给出「旧名 → 建议新名」让用户确认(改名影响引用,不自作主张;功能名/日期推断不出时一并问)。
76
+ 3. 确认后 **`git mv`** 改名(目录、PRD 文件;已存在的旧 gate.yaml/报告一并 `git mv` 到新名),保留 git 历史,**不要** Write 新文件+删旧。
77
+ 4. **Edit** 把第 1 步搜到的引用全部改成新名/新路径。
78
+ 5. 再 **Grep** 一次旧名确认零残留。
79
+
80
+ 已规范或用户确认改名后,才进第 2 步门禁——此时 `gate review` 生成的 gate.yaml/报告自然是新名。若 PRD 名已规范,本步无操作,直接进门禁。
81
+
62
82
  ### 2. 门禁:质量审阅 + 评分(不可跳过)
63
83
 
64
84
  先初始化 gate(钉当前 PRD 哈希、拿到报告路径):
@@ -88,7 +108,7 @@ bash "$COMATE" gate <prd> review
88
108
  - **仓库事实**(已有架构约定、字段、接口、对接档案):先用 Read/Glob/rg 核实并引用来源,不丢给用户决定。
89
109
  - **产品决策**(范围、优先级、用户可见行为、状态流转、权限、数据口径、失败处理、验收规则):必须用 **AskUserQuestion** 问用户,不得用推荐方案/历史偏好/「待确认」代替确认。
90
110
 
91
- **写评审报告(必生成)** 到 `gate review` 回显的 `review_report` 路径(PRD 同级 `PRD-REVIEW-<PRD名>.md`)。内容含:评分 + 评级 + 一句话总评、问题台账表(`ID` / 维度 / 严重程度 / 原文依据 / 影响章节 / 是否阻断 / 建议 / 产品回填列)、逐条问题。若无问题,明确写「审阅通过,未发现阻断项」。
111
+ **写评审报告(必生成)** 到 `gate review` 回显的 `review_report` 路径(PRD 同级 `<PRD-NNN>-REVIEW.md`,如 `PRD-001-REVIEW.md`)。内容含:评分 + 评级 + 一句话总评、问题台账表(`ID` / 维度 / 严重程度 / 原文依据 / 影响章节 / 是否阻断 / 建议 / 产品回填列)、逐条问题。若无问题,明确写「审阅通过,未发现阻断项」。
92
112
 
93
113
  **阻断规则**:影响范围、用户行为、状态流转、数据契约、权限、异常处理或验收标准的问题标「阻断」。把当前未关闭的阻断问题数写入 gate:
94
114
 
@@ -8,9 +8,9 @@
8
8
  docs/knowledge-base/requirements/
9
9
  ├─ _templates/ PRD 模板
10
10
  └─ <YYYYMMDD-功能名>/ 一个功能一个目录,日期=该功能起始日 ──┐
11
- ├─ PRD-001-<功能名>.md 产品需求文档 │
12
- ├─ PRD-001-<功能名>.gate.yaml 门禁状态(机器真相源) │
13
- ├─ PRD-REVIEW-PRD-001-<功能名>.md 门禁评审报告(人类可读) │
11
+ ├─ PRD-001-<功能名>.md 产品需求文档 │
12
+ ├─ PRD-001.gate.yaml 门禁状态(机器真相源) │
13
+ ├─ PRD-001-REVIEW.md 门禁评审报告(人类可读) │
14
14
  └─ CHANGES-MAP.md (多 change 时)功能项→change 映射 │
15
15
  ├─→ /comate-gate (门禁+拆分)
16
16
  docs/knowledge-base/integrations/ │ ↓
@@ -24,7 +24,7 @@ comate 采用**简化需求流**:只保留 **PRD**(产品需求文档),通过 `/
24
24
  1. **门禁**:审阅 PRD 质量、百分制评分、建阻断台账。阻断问题未清零,禁止建任何 change。
25
25
  2. **拆分**:按依赖/整体性/独立性/规模判定该 PRD 变成 1 或 N 个 comate change(粒度由用户定),逐个交给 `/comate-open`。
26
26
 
27
- PRD 门禁状态钉在同级 `<PRD名>.gate.yaml`(机器真相源,含 `gate_status` / `score` / `blocking_open` / `prd_hash` / `spawned_changes`)。评审报告为 `PRD-REVIEW-<PRD名>.md`(人类可读,含问题台账)。PRD 通过门禁后被改会因哈希漂移自动失效(`stale`),需重跑 gate。
27
+ PRD 门禁状态钉在同级 `PRD-<三位序号>.gate.yaml`(机器真相源,含 `gate_status` / `score` / `blocking_open` / `prd_hash` / `spawned_changes`)。评审报告为 `PRD-<三位序号>-REVIEW.md`(人类可读,含问题台账)。PRD 通过门禁后被改会因哈希漂移自动失效(`stale`),需重跑 gate。
28
28
 
29
29
  扇出的 comate change 内部用 proposal / design / tasks 承接 PRD 的功能项内容——**不再产独立 FRD 文档**,PRD 直接作为 change 的源文档被 `sources track` 钉住。
30
30
 
@@ -32,8 +32,8 @@ PRD 门禁状态钉在同级 `<PRD名>.gate.yaml`(机器真相源,含 `gate_stat
32
32
 
33
33
  - 功能目录: `<YYYYMMDD-功能名>/`,日期为该功能需求起始日,功能名用中文短名,如 `20260805-积分商城/`
34
34
  - PRD: 目录内 `PRD-<三位序号>-<功能名>.md`,功能名用目录功能名,如 `PRD-001-积分商城.md`
35
- - gate: 自动生成,`PRD-<三位序号>-<功能名>.gate.yaml`,与 PRD 同级
36
- - 评审报告: 自动生成,`PRD-REVIEW-PRD-<三位序号>-<功能名>.md`,与 PRD 同级
35
+ - gate: 自动生成,`PRD-<三位序号>.gate.yaml`(去 title,只用 PRD-NNN),与 PRD 同级,如 `PRD-001.gate.yaml`
36
+ - 评审报告: 自动生成,`PRD-<三位序号>-REVIEW.md`,与 PRD 同级,如 `PRD-001-REVIEW.md`
37
37
 
38
38
  ## 横向 (integrations/)
39
39