@house365/comate 0.10.2 → 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.
Files changed (74) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +104 -64
  3. package/bin/cli.mjs +197 -190
  4. package/bin/doctor.mjs +29 -41
  5. package/bin/init-run.mjs +310 -368
  6. package/bin/init.mjs +53 -71
  7. package/bin/postinstall.mjs +56 -56
  8. package/docs/CHANGELOG-fix-simplification.md +145 -0
  9. package/docs/COMATE_BETA.md +90 -0
  10. package/docs/COMATE_MANUAL.md +239 -665
  11. package/docs/superpowers/plans/2026-10-14-review-fixes.md +48 -0
  12. package/docs/superpowers/plans/2026-10-14-workflow-quality.md +48 -0
  13. package/package.json +54 -55
  14. package/skills/comate/SKILL.md +42 -325
  15. package/skills/comate/reference/archive.md +50 -0
  16. package/skills/comate/reference/build.md +67 -0
  17. package/skills/comate/reference/fix.md +150 -0
  18. package/skills/comate/reference/open.md +162 -0
  19. package/skills/comate/reference/verify.md +123 -0
  20. package/skills/comate/scaffold/comate/README.md +18 -0
  21. package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/architecture/README.md +5 -6
  22. package/skills/comate/scaffold/comate/comate/config.json +10 -0
  23. package/skills/comate/scaffold/comate/knowledge-base/README.md +42 -0
  24. package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/integrations/_TEMPLATE/README.md +2 -2
  25. package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/integrations/_TEMPLATE/api-snapshot.md +47 -47
  26. package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/requirements/_templates/PRD.md +4 -4
  27. package/skills/comate/scripts/comate.mjs +147 -0
  28. package/skills/comate/scripts/lib/acceptance.mjs +112 -0
  29. package/skills/comate/scripts/lib/brief.mjs +50 -0
  30. package/skills/comate/scripts/lib/checks.mjs +178 -0
  31. package/skills/comate/scripts/lib/cmd-archive.mjs +152 -0
  32. package/skills/comate/scripts/lib/cmd-decide.mjs +116 -0
  33. package/skills/comate/scripts/lib/cmd-init.mjs +75 -0
  34. package/skills/comate/scripts/lib/cmd-loop.mjs +273 -0
  35. package/skills/comate/scripts/lib/cmd-open.mjs +161 -0
  36. package/skills/comate/scripts/lib/cmd-status.mjs +121 -0
  37. package/skills/comate/scripts/lib/common.mjs +85 -0
  38. package/skills/comate/scripts/lib/docs.mjs +45 -0
  39. package/skills/comate/scripts/lib/finish.mjs +108 -0
  40. package/skills/comate/scripts/lib/git.mjs +112 -0
  41. package/skills/comate/scripts/lib/md.mjs +85 -0
  42. package/skills/comate/scripts/lib/next.mjs +217 -0
  43. package/skills/comate/scripts/lib/paths.mjs +96 -0
  44. package/skills/comate/scripts/lib/render.mjs +46 -0
  45. package/skills/comate/scripts/lib/spec.mjs +106 -0
  46. package/skills/comate/scripts/lib/state.mjs +128 -0
  47. package/skills/comate/scripts/lib/tasks.mjs +53 -0
  48. package/skills/comate/scripts/lib/templates.mjs +58 -0
  49. package/skills/comate/scripts/lib/util.mjs +108 -0
  50. package/skills/comate-kb-integration/SKILL.md +128 -128
  51. package/skills/comate-kb-integration/scripts/fetch_rendered_doc.py +84 -84
  52. package/bin/cleanup-duplicate-superpowers.mjs +0 -119
  53. package/docs/CLEANUP_SUPERPOWERS.md +0 -128
  54. package/docs/COMATE_BEGINNER_GUIDE.md +0 -188
  55. package/docs/COMATE_INIT_DIRECTORY_GUIDE.md +0 -201
  56. package/docs/SUPERPOWERS_FIX_SUMMARY.md +0 -114
  57. package/skills/comate/reference/decision-points.md +0 -103
  58. package/skills/comate/reference/dirty-worktree.md +0 -58
  59. package/skills/comate/reference/prerequisites.md +0 -89
  60. package/skills/comate/scripts/comate-lib.sh +0 -559
  61. package/skills/comate/scripts/comate.sh +0 -2147
  62. package/skills/comate/tests/comate.bats +0 -1019
  63. package/skills/comate/tests/run.sh +0 -101
  64. package/skills/comate-archive/SKILL.md +0 -63
  65. package/skills/comate-build/SKILL.md +0 -278
  66. package/skills/comate-design/SKILL.md +0 -178
  67. package/skills/comate-fix/SKILL.md +0 -195
  68. package/skills/comate-gate/SKILL.md +0 -316
  69. package/skills/comate-gate/scaffold/docs/README.md +0 -36
  70. package/skills/comate-gate/scaffold/docs/knowledge-base/README.md +0 -47
  71. package/skills/comate-grill/SKILL.md +0 -185
  72. package/skills/comate-open/SKILL.md +0 -230
  73. package/skills/comate-verify/SKILL.md +0 -212
  74. /package/skills/{comate-gate/scaffold/docs → comate/scaffold/comate}/knowledge-base/integrations/README.md +0 -0
@@ -14,8 +14,8 @@
14
14
  |------|------|
15
15
  | 服务标识 | <目录名,如 oceanengine> |
16
16
  | 对接目的 | <为什么对接它,支撑哪个需求> |
17
- | 关联需求 | docs/knowledge-base/requirements/<YYYYMMDD-功能名>/PRD-001-<功能名>.md |
18
- | 关联变更 | openspec/changes/<变更名>/ |
17
+ | 关联需求 | comate/knowledge-base/requirements/<YYYYMMDD-功能名>/PRD-001-<功能名>.md |
18
+ | 关联变更 | comate/comate/changes/<变更名>/ |
19
19
  | 官方文档 | <URL> |
20
20
  | 我方负责人 | <name> |
21
21
  | 对方联系人 | <姓名/渠道,非必填> |
@@ -1,47 +1,47 @@
1
- # <平台名> 接口摘录 (API Snapshot)
2
-
3
- | 作者 | 更新时间 | 原文档地址 |
4
- |------|---------|-----------|
5
- | <建档/最近修改人,默认 git 用户名> | <YYYY-MM-DD,改动即更新> | <官方文档主 URL> |
6
-
7
- > 只摘录**我方实际用到**的接口与字段,不复制官方全文。
8
- > 标题正下方的「作者/更新时间/原文档地址」三项**必填**(本档案是我方二开文档,须可追溯回官方原文)。
9
- > 官方文档: <URL>
10
- > 抓取日期: <YYYY-MM-DD> ← 每次对照官方更新时改这里
11
-
12
- ---
13
-
14
- ## <接口1: 如 获取 access_token>
15
-
16
- **请求**
17
- ```
18
- POST https://<host>/oauth/token
19
- Content-Type: application/json
20
- ```
21
-
22
- | 参数 | 类型 | 必填 | 说明 |
23
- |------|------|------|------|
24
- | app_key | string | 是 | 见 README 凭证位置 |
25
- | grant_type | string | 是 | 固定 client_credentials |
26
-
27
- **响应**
28
- ```json
29
- {
30
- "access_token": "<string>",
31
- "expires_in": 7200
32
- }
33
- ```
34
-
35
- | 字段 | 说明 | 我方注意点 |
36
- |------|------|-----------|
37
- | expires_in | 有效期(秒) | ⚠️ 实测 2h,需提前刷新 |
38
-
39
- **错误码/异常**
40
-
41
- | code/status | 含义 | 我方处理 |
42
- |-------------|------|----------|
43
- | <...> | <...> | <重试/告警/提示用户/忽略> |
44
-
45
- ---
46
-
47
- ## <接口2: ...>
1
+ # <平台名> 接口摘录 (API Snapshot)
2
+
3
+ | 作者 | 更新时间 | 原文档地址 |
4
+ |------|---------|-----------|
5
+ | <建档/最近修改人,默认 git 用户名> | <YYYY-MM-DD,改动即更新> | <官方文档主 URL> |
6
+
7
+ > 只摘录**我方实际用到**的接口与字段,不复制官方全文。
8
+ > 标题正下方的「作者/更新时间/原文档地址」三项**必填**(本档案是我方二开文档,须可追溯回官方原文)。
9
+ > 官方文档: <URL>
10
+ > 抓取日期: <YYYY-MM-DD> ← 每次对照官方更新时改这里
11
+
12
+ ---
13
+
14
+ ## <接口1: 如 获取 access_token>
15
+
16
+ **请求**
17
+ ```
18
+ POST https://<host>/oauth/token
19
+ Content-Type: application/json
20
+ ```
21
+
22
+ | 参数 | 类型 | 必填 | 说明 |
23
+ |------|------|------|------|
24
+ | app_key | string | 是 | 见 README 凭证位置 |
25
+ | grant_type | string | 是 | 固定 client_credentials |
26
+
27
+ **响应**
28
+ ```json
29
+ {
30
+ "access_token": "<string>",
31
+ "expires_in": 7200
32
+ }
33
+ ```
34
+
35
+ | 字段 | 说明 | 我方注意点 |
36
+ |------|------|-----------|
37
+ | expires_in | 有效期(秒) | ⚠️ 实测 2h,需提前刷新 |
38
+
39
+ **错误码/异常**
40
+
41
+ | code/status | 含义 | 我方处理 |
42
+ |-------------|------|----------|
43
+ | <...> | <...> | <重试/告警/提示用户/忽略> |
44
+
45
+ ---
46
+
47
+ ## <接口2: ...>
@@ -1,7 +1,7 @@
1
1
  # PRD-<序号>-<功能名>: <产品标题>
2
2
 
3
- > 复制本文件到需求目录,按项目约定命名,删除本行与所有 `<...>` 占位说明。
4
- > PRD = 产品需求文档,回答「做成什么样」。本 PRD 由 `/comate-gate` 直接读取:先过质量门禁(审阅、评分、阻断检查),通过后按依赖和独立性拆成 1 或 N 个 comate change。
3
+ > 复制本文件到需求目录,按项目约定命名,删除本行与所有 `<...>` 占位说明。
4
+ > PRD = 产品需求文档,回答「做成什么样」。本 PRD 由 `/comate` 直接读取:澄清未决策项后,按依赖和独立性拆成 1 或 N 个 comate change 并写出规约。
5
5
 
6
6
  | 字段 | 内容 |
7
7
  |------|------|
@@ -39,7 +39,7 @@
39
39
 
40
40
  ### 功能清单
41
41
 
42
- > `/comate-gate` 据此清单判定拆分粒度:有依赖的功能项归同一 change,独立的可拆开。请标清优先级。
42
+ > `/comate` 据此清单判定拆分粒度:有依赖的功能项归同一 change,独立的可拆开。请标清优先级。
43
43
 
44
44
  | 功能 | 说明 | 优先级 (P0/P1/P2) |
45
45
  |------|------|------------------|
@@ -65,7 +65,7 @@
65
65
  |--------|------|------|--------------|--------|
66
66
  | <平台名> | <本产品中用它做什么> | <限流/权限/回调/稳定性等> | <超时/未配置/返回错误如何处理> | <name> |
67
67
 
68
- > 对接档案: docs/knowledge-base/integrations/<平台名>/README.md
68
+ > 对接档案: comate/knowledge-base/integrations/<平台名>/README.md
69
69
 
70
70
  ## 数据与来源
71
71
 
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env node
2
+ // comate.mjs — the single entry of the comate 1.0 state machine. Zero
3
+ // dependencies, Node >= 20. Flow: open -> build <-> verify -> archive.
4
+ // Every command ends with a next (text, or --json for agents).
5
+ //
6
+ // Exit codes: 0 OK | 1 BLOCK (a guard refused; the next says what to
7
+ // fix) | 2 USAGE | 3 ENV | 4 CORRUPT
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ import { cmdArchive } from './lib/cmd-archive.mjs';
11
+ import { cmdDecide } from './lib/cmd-decide.mjs';
12
+ import { cmdInit } from './lib/cmd-init.mjs';
13
+ import { cmdHandoff, cmdProbe, cmdVerify } from './lib/cmd-loop.mjs';
14
+ import { cmdNew, cmdOpen, cmdRename, cmdSpec } from './lib/cmd-open.mjs';
15
+ import { cmdDoctor, cmdImportSpecs, cmdStatus } from './lib/cmd-status.mjs';
16
+ import { load } from './lib/common.mjs';
17
+ import { nextFor, formatText } from './lib/next.mjs';
18
+ import { makeContext } from './lib/paths.mjs';
19
+ import { BLOCK, ComateError, OK, USAGE, die, log } from './lib/util.mjs';
20
+
21
+ const SCRIPT = fileURLToPath(import.meta.url);
22
+
23
+ // command -> [handler, allowed options]
24
+ const COMMANDS = {
25
+ init: [cmdInit, []],
26
+ status: [cmdStatus, []],
27
+ new: [cmdNew, ['mode', 'source', 'base']],
28
+ open: [cmdOpen, ['prepare', 'confirm', 'expect']],
29
+ rename: [cmdRename, []],
30
+ spec: [cmdSpec, []],
31
+ handoff: [cmdHandoff, ['input', 'expect', 'recheck']],
32
+ verify: [cmdVerify, ['result', 'error', 'unavailable', 'expect']],
33
+ probe: [cmdProbe, ['remove']],
34
+ decide: [cmdDecide, ['expect', 'accept', 'finish', 'revise-implementation', 'revise-requirements', 'resolve', 'upgrade', 'note']],
35
+ archive: [cmdArchive, ['dry-run']],
36
+ doctor: [cmdDoctor, ['repair']],
37
+ 'import-specs': [cmdImportSpecs, ['dry-run']],
38
+ };
39
+ const VALUED = new Set(['mode', 'source', 'base', 'expect', 'input', 'result', 'error', 'remove', 'finish', 'note']);
40
+
41
+ export function parseArgs(argv) {
42
+ const a = { _: [], flags: {}, values: {}, sources: [], used: [] };
43
+ for (let i = 0; i < argv.length; i++) {
44
+ const tok = argv[i];
45
+ if (!tok.startsWith('--')) {
46
+ a._.push(tok);
47
+ continue;
48
+ }
49
+ const eq = tok.indexOf('=');
50
+ const key = eq === -1 ? tok.slice(2) : tok.slice(2, eq);
51
+ a.used.push(key);
52
+ if (!VALUED.has(key)) {
53
+ if (eq !== -1) die(USAGE, `--${key} 不接受值`);
54
+ a.flags[key] = true;
55
+ continue;
56
+ }
57
+ const value = eq !== -1 ? tok.slice(eq + 1) : argv[++i];
58
+ if (value === undefined) die(USAGE, `--${key} 需要一个值`);
59
+ if (key === 'source') a.sources.push(value);
60
+ else a.values[key] = value;
61
+ }
62
+ return a;
63
+ }
64
+
65
+ const USAGE_TEXT = `comate 1.0 — open → build ⇄ verify → archive
66
+
67
+ init 初始化 comate 项目结构
68
+ status [<name>] [--json] 当前位置 + 下一步(只读)
69
+ new <name> [--mode full|fix] [--source <file>]... [--base <branch>]
70
+ open <name> --prepare 校验 brief/spec,抽取验收项
71
+ open <name> --confirm --expect <v>:confirm 用户确认后进入 build
72
+ rename <name> <new> 仅 open 阶段
73
+ spec remove|restore <name> <capability> 登记/取消删除一个主能力
74
+ handoff <name> --input <json> --expect <v>:<step> 提交候选实现并跑检查
75
+ verify <name> --result <json> | --error "<原因>" | --unavailable --expect <v>:await-verifier
76
+ probe <name> [--remove <dir>] 反向验证用的临时 worktree
77
+ decide <name> --expect <v>:<step> --accept --finish keep|merge|pr|push
78
+ | --revise-implementation --note "<…>"
79
+ | --revise-requirements [--note "<…>"]
80
+ | --resolve --note "<…>" | --upgrade
81
+ archive <name> [--dry-run]
82
+ doctor [--repair]
83
+ import-specs [--dry-run] openspec/specs -> comate/comate/specs
84
+
85
+ 退出码:0 成功 | 1 被守卫拦截 | 2 用法错误 | 3 环境缺失 | 4 状态损坏
86
+ 所有命令加 --json 输出机器可读结果。`;
87
+
88
+ export async function main(argv) {
89
+ const [cmd, ...rest] = argv;
90
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
91
+ process.stdout.write(`${USAGE_TEXT}\n`);
92
+ return cmd ? OK : USAGE;
93
+ }
94
+ let json = rest.includes('--json');
95
+ try {
96
+ const entry = COMMANDS[cmd];
97
+ if (!entry) die(USAGE, `未知命令 '${cmd}'。运行 node "$COMATE" help 查看用法。`);
98
+ const a = parseArgs(rest.filter((t) => t !== '--json'));
99
+ const bad = a.used.filter((k) => !entry[1].includes(k));
100
+ if (bad.length) die(USAGE, `${cmd} 不支持选项:${bad.map((k) => `--${k}`).join(' ')}`);
101
+ let ctx = null;
102
+ try {
103
+ ctx = makeContext(process.cwd(), SCRIPT);
104
+ } catch (err) {
105
+ if (cmd !== 'doctor' && cmd !== 'init') throw err;
106
+ }
107
+ const res = entry[0](ctx, a);
108
+ return emit(ctx, res, json);
109
+ } catch (err) {
110
+ if (!(err instanceof ComateError)) throw err;
111
+ return fail(err, json);
112
+ }
113
+ }
114
+
115
+ function emit(ctx, res, json) {
116
+ if (res.lines) {
117
+ process.stdout.write(json ? `${JSON.stringify(res.json ?? {}, null, 2)}\n` : `${res.lines.join('\n')}\n`);
118
+ return res.exit ?? OK;
119
+ }
120
+ const next = nextFor(ctx, res.cp, res.st, { done: res.done });
121
+ process.stdout.write(json ? `${JSON.stringify(next, null, 2)}\n` : `${formatText(next)}\n`);
122
+ return OK;
123
+ }
124
+
125
+ // A refused command still prints the latest next, so the agent knows
126
+ // exactly what to fix and which command to run afterwards.
127
+ function fail(err, json) {
128
+ let next = null;
129
+ if (err.code === BLOCK && err.change) {
130
+ try {
131
+ const ctx = makeContext(process.cwd(), SCRIPT);
132
+ const { cp, st } = load(ctx, err.change);
133
+ next = nextFor(ctx, cp, st);
134
+ } catch {
135
+ next = null;
136
+ }
137
+ }
138
+ if (json) {
139
+ process.stdout.write(`${JSON.stringify({ error: { code: err.code, message: err.message }, next: next }, null, 2)}\n`);
140
+ } else {
141
+ log.fail(err.message);
142
+ if (next) process.stdout.write(`${formatText(next)}\n`);
143
+ }
144
+ return err.code;
145
+ }
146
+
147
+ process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,112 @@
1
+ // Acceptance items A1…An come from brief examples, target spec Scenarios, and
2
+ // Scenarios removed from the main spec. Unchanged Scenarios are regressions.
3
+ import { acceptanceExamples } from './brief.mjs';
4
+ import { section, sections, table } from './md.mjs';
5
+ import { parseSpec } from './spec.mjs';
6
+
7
+ const firstLine = (s, n = 80) => {
8
+ const line = s.split('\n')[0].trim();
9
+ return line.length > n ? `${line.slice(0, n)}…` : line;
10
+ };
11
+
12
+ // changeSpecs: [{ cap, text }] sorted; mainSpecs: { cap: text|null }; removals: [cap]
13
+ export function collectAcceptance({ briefText, changeSpecs, mainSpecs, removals }) {
14
+ const acceptance = [];
15
+ const push = (item) => acceptance.push({ id: `A${acceptance.length + 1}`, ...item, result: 'pending', reason: null });
16
+ const pushRemoved = (cap, s) => push({
17
+ kind: 'removed',
18
+ source: `specs/${cap}/spec.md#${s.title}`,
19
+ title: `移除:${s.title}`,
20
+ text: [`旧 Scenario:${s.title}`, s.requirement, s.body, '期望:该行为已移除或按 brief 中的迁移规则处理'].filter(Boolean).join('\n'),
21
+ });
22
+
23
+ acceptanceExamples(briefText).forEach((text, i) => {
24
+ push({ kind: 'changed', source: `brief.md#验收示例/${i + 1}`, title: firstLine(text), text });
25
+ });
26
+
27
+ const removed = [];
28
+ for (const { cap, text } of changeSpecs) {
29
+ const mainParsed = mainSpecs[cap] ? parseSpec(mainSpecs[cap]) : { requirementBodies: [], scenarios: [] };
30
+ const ownParsed = parseSpec(text);
31
+ const byTitle = new Map(mainParsed.scenarios.map((s) => [s.title, { body: s.body, requirement: s.requirement }]));
32
+ for (const s of ownParsed.scenarios) {
33
+ const main = byTitle.get(s.title);
34
+ const unchanged = main && main.body === s.body && main.requirement === s.requirement;
35
+ const kind = unchanged ? 'regression' : 'changed';
36
+ push({ kind, source: `specs/${cap}/spec.md#${s.title}`, title: s.title, text: [s.title, s.requirement, s.body].filter(Boolean).join('\n') });
37
+ }
38
+ const kept = new Set(ownParsed.scenarios.map((s) => s.title));
39
+ for (const s of mainParsed.scenarios) if (!kept.has(s.title)) {
40
+ removed.push({ capability: cap, title: s.title });
41
+ pushRemoved(cap, s);
42
+ }
43
+ }
44
+ for (const cap of removals) {
45
+ for (const s of mainSpecs[cap] ? parseSpec(mainSpecs[cap]).scenarios : []) {
46
+ removed.push({ capability: cap, title: s.title });
47
+ pushRemoved(cap, s);
48
+ }
49
+ }
50
+ return { acceptance, removed };
51
+ }
52
+
53
+ const COLUMNS = ['source_item', 'location', 'read_status', 'spec_location', 'coverage', 'note'];
54
+ const READ = ['complete', 'partial', 'unavailable'];
55
+ const COVERAGE = ['covered', 'needs-clarification', 'background', 'non-goal', 'superseded'];
56
+
57
+ export function coverageTable(briefText) {
58
+ const scope = section(briefText, '范围');
59
+ const sub = scope && sections(scope.body, 2).find((s) => s.title === '来源覆盖');
60
+ return sub ? table(sub.body) : null;
61
+ }
62
+
63
+ // "specs/auth/spec.md#Scenario: 密码错误" / "`comate/.../specs/auth/spec.md#x`" -> canonical source.
64
+ function canonical(loc) {
65
+ let s = loc.trim().replace(/^`|`$/g, '');
66
+ const at = Math.max(s.lastIndexOf('specs/'), s.lastIndexOf('brief.md'));
67
+ if (at > 0) s = s.slice(at);
68
+ return s.replace(/#\s*Scenario\s*[::]\s*/i, '#').replace(/\s+$/, '');
69
+ }
70
+
71
+ // Validates the「来源覆盖」table and derives source_item -> acceptance IDs.
72
+ export function checkCoverage(briefText, acceptance, hasSources) {
73
+ const t = coverageTable(briefText);
74
+ if (!t) {
75
+ return hasSources
76
+ ? { problems: ['需求来自文件(new --source)时,「# 范围」下必须有「## 来源覆盖」表,逐段登记源文件内容'], sourceMap: {} }
77
+ : { problems: [], sourceMap: {} };
78
+ }
79
+ const problems = [];
80
+ const missing = COLUMNS.filter((c) => !t.headers.includes(c));
81
+ if (missing.length) return { problems: [`来源覆盖表缺少列:${missing.join('、')}`], sourceMap: {} };
82
+ if (t.rows.length === 0) problems.push('来源覆盖表没有任何行');
83
+
84
+ const bySource = new Map(acceptance.map((a) => [a.source, a.id]));
85
+ const sourceMap = {};
86
+ const seen = new Set();
87
+ for (const row of t.rows) {
88
+ const item = row.source_item;
89
+ const at = `来源覆盖 ${item || '(空 source_item)'}`;
90
+ if (!/^S\d+$/.test(item)) problems.push(`${at}:source_item 应为 S1、S2…`);
91
+ if (seen.has(item)) problems.push(`${at}:source_item 重复`);
92
+ seen.add(item);
93
+ if (!row.location) problems.push(`${at}:location 为空`);
94
+ if (!READ.includes(row.read_status)) problems.push(`${at}:read_status 应为 ${READ.join(' / ')}`);
95
+ else if (row.read_status !== 'complete') problems.push(`${at}:read_status=${row.read_status},源内容必须完整读完`);
96
+ if (!COVERAGE.includes(row.coverage)) problems.push(`${at}:coverage 应为 ${COVERAGE.join(' / ')}`);
97
+ if (row.coverage === 'needs-clarification') problems.push(`${at}:仍需澄清,确认前必须解决`);
98
+ if (row.coverage === 'superseded' && !row.note) problems.push(`${at}:superseded 必须在 note 写明被什么替代`);
99
+ if (row.coverage !== 'covered') continue;
100
+
101
+ const locs = row.spec_location.split(/[,,;;、]|<br\s*\/?>/i).map((l) => l.trim()).filter(Boolean);
102
+ if (!locs.length) problems.push(`${at}:covered 必须填 spec_location`);
103
+ const ids = [];
104
+ for (const loc of locs) {
105
+ const id = bySource.get(canonical(loc));
106
+ if (id) ids.push(id);
107
+ else problems.push(`${at}:spec_location「${loc}」没有对应任何验收项(应形如 specs/<cap>/spec.md#<Scenario 标题> 或 brief.md#验收示例/<序号>)`);
108
+ }
109
+ if (ids.length) sourceMap[item] = [...new Set(ids)];
110
+ }
111
+ return { problems, sourceMap };
112
+ }
@@ -0,0 +1,50 @@
1
+ // brief.md: why, what, key decisions and acceptance examples. Seven chapters in
2
+ // full mode, three in fix mode; every required chapter must carry real content.
3
+ import { hasPlaceholder, isBlank, listItems, section, sections, stripComments } from './md.mjs';
4
+
5
+ export const FULL_CHAPTERS = ['目标', '范围', '非目标', '验收示例', '约束与不变量', '决定', '待决问题'];
6
+ export const FIX_CHAPTERS = ['目标', '验收示例', '决定'];
7
+
8
+ export const requiredChapters = (mode) => (mode === 'fix' ? FIX_CHAPTERS : FULL_CHAPTERS);
9
+
10
+ export function checkBrief(text, mode) {
11
+ const problems = [];
12
+ const all = sections(text, 1);
13
+ for (const title of requiredChapters(mode)) {
14
+ const hits = all.filter((s) => s.title === title);
15
+ if (hits.length === 0) problems.push(`缺少一级章节「# ${title}」`);
16
+ else if (hits.length > 1) problems.push(`章节「# ${title}」出现了 ${hits.length} 次`);
17
+ else if (isBlank(hits[0].body)) problems.push(`章节「${title}」为空(模板注释不算内容)`);
18
+ }
19
+ for (const s of all) {
20
+ if (hasPlaceholder(s.body)) problems.push(`章节「${s.title}」含占位符(TODO / TBD / FIXME / 待填写 / 待补充)`);
21
+ }
22
+ const examples = section(text, '验收示例');
23
+ if (examples && !isBlank(examples.body) && acceptanceExamples(text).length === 0) {
24
+ problems.push('「验收示例」里没有列表项:每条验收写成一个 `- ` 开头的列表项');
25
+ }
26
+ const blocking = stripComments(text).match(/\[blocking\][^\n]*/gi) ?? [];
27
+ for (const b of blocking) problems.push(`仍有阻断问题未决:${b.trim()}`);
28
+ return problems;
29
+ }
30
+
31
+ export const acceptanceExamples = (text) => listItems(section(text, '验收示例')?.body ?? '');
32
+
33
+ // "无行为变更:<理由>" in 范围 (fix mode: anywhere), for a change that ships no spec.
34
+ export function noBehaviorReason(text, mode) {
35
+ const scope = mode === 'fix' ? stripComments(text) : stripComments(section(text, '范围')?.body ?? '');
36
+ const m = /无行为变更\s*[::]\s*(.+)/.exec(scope);
37
+ if (!m) return null;
38
+ const reason = m[1].trim();
39
+ return reason.length >= 6 && !hasPlaceholder(reason) ? reason : null;
40
+ }
41
+
42
+ const clip = (s, n = 800) => {
43
+ const t = stripComments(s ?? '').trim();
44
+ return t.length > n ? `${t.slice(0, n)}…` : t;
45
+ };
46
+
47
+ export function briefSummary(text) {
48
+ const get = (t) => clip(section(text, t)?.body);
49
+ return { goal: get('目标'), scope: get('范围'), non_goals: get('非目标'), decisions: get('决定') };
50
+ }
@@ -0,0 +1,178 @@
1
+ // Project config and the check plan (build_command / verify_command) that the
2
+ // script runs itself after every handoff. Results are summarised into
3
+ // state.json; no log files are kept.
4
+ import { spawnSync } from 'node:child_process';
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ import { ENV, die, readText } from './util.mjs';
9
+
10
+ export const DEFAULT_CONFIG = {
11
+ language: 'zh-CN',
12
+ build_command: null,
13
+ verify_command: null,
14
+ check_timeout_seconds: 900,
15
+ max_verify_failures: 5,
16
+ finish: 'ask',
17
+ auto_finish_action: null,
18
+ protected_branches: ['main', 'master'],
19
+ };
20
+
21
+ export function loadConfig(ctx) {
22
+ const text = readText(ctx.abs(ctx.configRel));
23
+ if (text === null) return { ...DEFAULT_CONFIG };
24
+ let raw;
25
+ try {
26
+ raw = JSON.parse(text);
27
+ } catch (err) {
28
+ return die(ENV, `${ctx.configRel} 不是合法 JSON:${err.message}`);
29
+ }
30
+ const cfg = { ...DEFAULT_CONFIG, ...raw };
31
+ const bad = [];
32
+ if (!['ask', 'auto'].includes(cfg.finish)) bad.push(`finish 应为 ask / auto`);
33
+ if (cfg.auto_finish_action !== null && !['keep', 'merge', 'push', 'pr'].includes(cfg.auto_finish_action)) {
34
+ bad.push('auto_finish_action 应为 null / keep / merge / push / pr');
35
+ }
36
+ if (!Number.isInteger(cfg.max_verify_failures) || cfg.max_verify_failures < 1) bad.push('max_verify_failures 应为正整数');
37
+ if (!Array.isArray(cfg.protected_branches)) bad.push('protected_branches 应为数组');
38
+ if (bad.length) die(ENV, `${ctx.configRel} 配置错误:\n - ${bad.join('\n - ')}`);
39
+ return cfg;
40
+ }
41
+
42
+ const NPM_PLACEHOLDER = /no test specified/;
43
+
44
+ function packageScripts(root) {
45
+ try {
46
+ return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).scripts ?? {};
47
+ } catch {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ // Include referenced npm scripts and lifecycle hooks, so a stable wrapper
53
+ // command cannot hide a changed test entry point. Test source files may evolve.
54
+ function commandProfile(cmd, scripts) {
55
+ const definitions = new Map();
56
+ const commands = [cmd];
57
+ let complete = true;
58
+ for (let i = 0; i < commands.length; i++) {
59
+ const refs = [...commands[i].matchAll(/(?:^|&&|;|\|)\s*npm(?:\.cmd)?\s+([^;&|\n]+)/g)];
60
+ const invocations = [...commands[i].matchAll(/\bnpm(?:\.cmd)?(?=\s|$)/g)];
61
+ if (invocations.length !== refs.length) complete = false;
62
+ for (const ref of refs) {
63
+ const args = ref[1].match(/"[^"]*"|'[^']*'|[^\s]+/g) ?? [];
64
+ while (/^(?:--silent|-s|--quiet|-q|--no-audit|--no-fund|--loglevel=\w+)$/.test(args[0] ?? '')) args.shift();
65
+ const action = args.shift();
66
+ const raw = action === 'run' || action === 'run-script' ? args.shift() : ['test', 't'].includes(action) ? 'test' : null;
67
+ const name = raw?.replace(/^(["'])(.*)\1$/, '$2');
68
+ if (!name || /[\s$%`\\"']/.test(name) || /--(?:prefix|workspace|workspaces)\b|(?:^|\s)-w/.test(ref[1])) {
69
+ complete = false;
70
+ continue;
71
+ }
72
+ for (const key of [name, `pre${name}`, `post${name}`]) {
73
+ if (definitions.has(key)) continue;
74
+ const body = typeof scripts[key] === 'string' ? scripts[key] : null;
75
+ definitions.set(key, body);
76
+ if (body !== null) commands.push(body);
77
+ }
78
+ }
79
+ }
80
+ return { commands, complete, scripts: [...definitions].sort(([a], [b]) => a.localeCompare(b)).map(([name, cmd]) => ({ name, cmd })) };
81
+ }
82
+
83
+ const NON_TEST = /\bnode\s+(?:--check|-c)\b|\btsc\b|\b(?:vite|webpack|next)\s+build\b|\b(?:eslint|prettier)\b|\b(?:cargo|go)\s+build\b|\b(?:mvn|mvnw)(?:\.cmd)?\b[^\n]*\b(?:compile|package)\b/i;
84
+ const SKIP_OR_MASK = /no test specified|--(?:passWithNoTests|skipTests|listTests|showConfig|collect-only|test-skip-pattern|help|version)\b|(?:^|\s)-h(?=\s|$)|\b(?:node|jest|vitest)\s+[^;&|\n]*-v(?=\s|$)|-DskipTests\b|\bvitest\s+list\b|process\.exit\(\s*0\s*\)/i;
85
+ const TEST_RUNNER = /^(?:npx\s+(?:--no-install\s+)?)?(?:node\s+[^\n]*--test(?=\s|$)|(?:vitest|jest|mocha|ava|pytest)(?=\s|$)|(?:cargo|go)\s+test(?=\s|$)|(?:mvn|mvnw)(?:\.cmd)?\s+[^\n]*\btest(?=\s|$))/i;
86
+
87
+ function checkKind(stage, declared, profile) {
88
+ const commands = profile.commands;
89
+ const executable = commands.map((cmd) => cmd.replace(/"[^"\n]*"|'[^'\n]*'/g, 'quoted'));
90
+ if (stage !== 'verify' || declared === 'check' || commands.some((cmd) => SKIP_OR_MASK.test(cmd)) || executable.some((cmd) => /[;|]|(?<!&)&(?!&)/.test(cmd))) return 'check';
91
+ // Unsupported npm forms may address another package; they need independent verification.
92
+ if (!profile.complete) return 'unknown';
93
+ const segments = executable.flatMap((cmd) => cmd.split(/&&|\n/).map((part) => part.trim()));
94
+ if (segments.some((cmd) => TEST_RUNNER.test(cmd) && !NON_TEST.test(cmd))) return 'test';
95
+ if (commands.some((cmd) => NON_TEST.test(cmd))) return 'check';
96
+ if (declared === 'test') return 'test';
97
+ return 'unknown';
98
+ }
99
+
100
+ function detect(root, stage) {
101
+ const cmds = [];
102
+ const pkg = join(root, 'package.json');
103
+ if (existsSync(pkg)) {
104
+ const scripts = packageScripts(root);
105
+ if (stage === 'build' && scripts.build) cmds.push('npm run build');
106
+ if (stage === 'verify' && scripts.test && !NPM_PLACEHOLDER.test(scripts.test)) cmds.push('npm test');
107
+ }
108
+ const mvnw = process.platform === 'win32' ? 'mvnw.cmd' : './mvnw';
109
+ const mvn = existsSync(join(root, mvnw)) ? mvnw : 'mvn';
110
+ if (existsSync(join(root, 'pom.xml'))) cmds.push(stage === 'build' ? `${mvn} -q compile` : `${mvn} -q test`);
111
+ if (existsSync(join(root, 'Cargo.toml'))) cmds.push(stage === 'build' ? 'cargo build' : 'cargo test');
112
+ if (existsSync(join(root, 'go.mod'))) cmds.push(stage === 'build' ? 'go build ./...' : 'go test ./...');
113
+ return cmds;
114
+ }
115
+
116
+ // -> { plan: [{ stage, cmd, kind, scripts }], problems: [] }. `none` = the project truly has
117
+ // nothing to run for that stage; null = detect (npm / Maven / Cargo / Go).
118
+ export function resolveChecks(ctx, cfg) {
119
+ const plan = [];
120
+ const problems = [];
121
+ const scripts = packageScripts(ctx.root);
122
+ for (const stage of ['build', 'verify']) {
123
+ const v = cfg[`${stage}_command`];
124
+ if (v === 'none') continue;
125
+ const cmds = v === null || v === undefined ? detect(ctx.root, stage) : Array.isArray(v) ? v : [v];
126
+ if (cmds.length === 0 && stage === 'verify') {
127
+ problems.push(
128
+ `没有配置 verify_command,也没探测到测试命令。在 ${ctx.configRel} 写上 "verify_command": "<命令>",项目确实没有测试时写 "none"`,
129
+ );
130
+ }
131
+ for (const entry of cmds) {
132
+ const object = entry !== null && typeof entry === 'object' && !Array.isArray(entry);
133
+ const cmd = object ? entry.cmd : entry;
134
+ const declared = object ? entry.kind : undefined;
135
+ if (typeof cmd !== 'string' || !cmd.trim() || (declared !== undefined && !['test', 'check'].includes(declared))) {
136
+ problems.push(`${stage}_command 每项必须是非空命令字符串或 { cmd, kind: "test" | "check" }`);
137
+ continue;
138
+ }
139
+ const profile = commandProfile(cmd, scripts);
140
+ plan.push({ stage, cmd, kind: checkKind(stage, declared, profile), scripts: profile.scripts });
141
+ }
142
+ }
143
+ return { plan, problems };
144
+ }
145
+
146
+ export const verifyPlan = (plan) => plan.filter((c) => c.stage === 'verify').map(({ cmd, kind, scripts }) => ({ cmd, kind, scripts }));
147
+
148
+ const SECRET = [
149
+ /((?:api[_-]?key|secret|token|password|passwd|authorization)\s*[:=]\s*)\S+/gi,
150
+ /\b(gh[pousr]_|sk-|xox[baprs]-|npm_)[A-Za-z0-9_-]{10,}/g,
151
+ /\bAKIA[0-9A-Z]{16}\b/g,
152
+ ];
153
+ const redact = (s) => SECRET.reduce((acc, re) => acc.replace(re, (m, p1) => (p1 && /[:=]\s*$/.test(p1) ? `${p1}***` : '***')), s);
154
+
155
+ function tail(out, lines) {
156
+ const all = out.replace(/\x1b\[[0-9;]*m/g, '').split(/\r?\n/).filter((l) => l.trim() !== '');
157
+ return redact(all.slice(-lines).join('\n'));
158
+ }
159
+
160
+ export function runChecks(ctx, cfg, plan) {
161
+ const timeout = (cfg.check_timeout_seconds ?? 900) * 1000;
162
+ return plan.map(({ stage, cmd, kind }) => {
163
+ const started = Date.now();
164
+ const r = spawnSync(cmd, {
165
+ cwd: ctx.root,
166
+ shell: true,
167
+ encoding: 'utf8',
168
+ timeout,
169
+ maxBuffer: 256 * 1024 * 1024,
170
+ env: { ...process.env, CI: process.env.CI ?? '1' },
171
+ });
172
+ const timedOut = r.error?.code === 'ETIMEDOUT' || (r.signal !== null && Date.now() - started >= timeout);
173
+ const exit = r.error && !timedOut ? -1 : r.status ?? -1;
174
+ const ok = exit === 0 && !timedOut;
175
+ const out = `${r.stdout ?? ''}${r.stderr ?? ''}${r.error && !timedOut ? `\n${r.error.message}` : ''}`;
176
+ return { stage, cmd, kind, exit, timed_out: timedOut, ok, ms: Date.now() - started, tail: tail(out, ok ? 5 : 50) };
177
+ });
178
+ }