spec-wave 2.1.1

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 (175) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +327 -0
  3. package/README.zh-CN.md +327 -0
  4. package/RELEASING.md +52 -0
  5. package/assets/README.md +30 -0
  6. package/assets/ci/samples/README.md +76 -0
  7. package/assets/ci/samples/hgm-ingest.yml.example +42 -0
  8. package/assets/ci/samples/lint-wiki-delta.pin.yml.example +47 -0
  9. package/assets/ci/samples/lint-wiki-delta.yml.example +49 -0
  10. package/assets/ci/samples/pytest.yml.example +49 -0
  11. package/assets/ci/samples/quality.yml.example +56 -0
  12. package/assets/ci/samples/skills-validate.yml.example +38 -0
  13. package/assets/ci/samples/tech-graph.yml.example +55 -0
  14. package/assets/coding_wiki/templates/README.md +115 -0
  15. package/assets/coding_wiki/templates/_index.md +19 -0
  16. package/assets/coding_wiki/templates/context.md +18 -0
  17. package/assets/coding_wiki/templates/stable.md +26 -0
  18. package/assets/coding_wiki/templates/topics/wiki_layout.md +13 -0
  19. package/assets/coding_wiki/templates/topics/wikilinks_export.md +13 -0
  20. package/assets/coding_wiki/templates/volatile.md +25 -0
  21. package/assets/docs/POINTER_ONBOARDING.md +9 -0
  22. package/assets/docs/POINTER_RUNBOOK_wiki_delta.md +18 -0
  23. package/assets/docs/POINTER_SDD_HAT_FLOW.md +8 -0
  24. package/assets/docs/POINTER_USER_GUIDE.md +8 -0
  25. package/assets/graph/stubs/README.md +6 -0
  26. package/assets/graph/templates/00_main.graph.yaml +72 -0
  27. package/assets/graph/templates/00_main.md +85 -0
  28. package/assets/graph/templates/01_struct.md +38 -0
  29. package/assets/graph/templates/02_version.md +7 -0
  30. package/assets/graph/templates/10_flow_MAIN.graph.yaml +105 -0
  31. package/assets/graph/templates/10_flow_MAIN.md +103 -0
  32. package/assets/graph/templates/99_mermaid_protocol.md +156 -0
  33. package/assets/graph/templates/README.md +66 -0
  34. package/assets/harness/README.md +18 -0
  35. package/assets/harness/discipline-coverage.yaml +320 -0
  36. package/assets/harness/invokes/README.md +22 -0
  37. package/assets/harness/invokes/TEMPLATE_invoke.md +61 -0
  38. package/assets/harness/lifecycle.yaml +127 -0
  39. package/assets/harness/prompts/00-orchestrator.md +91 -0
  40. package/assets/harness/prompts/10-spec-requirements.md +63 -0
  41. package/assets/harness/prompts/10-task-requirements.md +70 -0
  42. package/assets/harness/prompts/20-spec-audit.md +50 -0
  43. package/assets/harness/prompts/20-task-audit.md +90 -0
  44. package/assets/harness/prompts/30-execute-code.md +64 -0
  45. package/assets/harness/prompts/40-self-check.md +65 -0
  46. package/assets/harness/prompts/FRAGMENT_00_delegate_only_v1_zh.md +19 -0
  47. package/assets/harness/prompts/FRAGMENT_30_gate_verify_v1_zh.md +34 -0
  48. package/assets/harness/prompts/FRAGMENT_30_invoke_block_v1_zh.md +28 -0
  49. package/assets/harness/prompts/FRAGMENT_hat_reanchor_v1_zh.md +19 -0
  50. package/assets/harness/prompts/README.md +67 -0
  51. package/assets/harness/prompts/TEMPLATE_30_gate_stop.md +36 -0
  52. package/assets/harness/templates/FRAGMENT_task_domain_infer_v1_zh.md +16 -0
  53. package/assets/harness/templates/ONTOLOGY_consumer_slice_v1.md +41 -0
  54. package/assets/harness/templates/QUICKREF_v1_zh.md +35 -0
  55. package/assets/harness/templates/README.md +50 -0
  56. package/assets/harness/templates/TASK_TEMPLATE.md +133 -0
  57. package/assets/harness/templates/TASK_done_README.md +76 -0
  58. package/assets/harness/templates/TASK_epic.md +103 -0
  59. package/assets/harness/templates/TASK_graph_bootstrap.md +115 -0
  60. package/assets/harness/templates/VIEW_done_by_domain.md +66 -0
  61. package/assets/harness/templates/VIEW_done_thin_pointer.md +16 -0
  62. package/assets/ide/adapters/AGENTS.md.fragment.example +33 -0
  63. package/assets/ide/adapters/CLAUDE.md.fragment.example +32 -0
  64. package/assets/ide/adapters/README.md +57 -0
  65. package/assets/ide/adapters/cursor-harness-starter.mdc.example +39 -0
  66. package/assets/ide/commands/claude/kit/apply-standards.md +10 -0
  67. package/assets/ide/commands/claude/kit/gate-status.md +10 -0
  68. package/assets/ide/commands/claude/kit/graph-check.md +8 -0
  69. package/assets/ide/commands/claude/kit/hat-00-delegate.md +9 -0
  70. package/assets/ide/commands/claude/kit/hat-10-spec.md +9 -0
  71. package/assets/ide/commands/claude/kit/hat-10-task.md +9 -0
  72. package/assets/ide/commands/claude/kit/hat-20-spec-audit.md +9 -0
  73. package/assets/ide/commands/claude/kit/hat-20-task-audit.md +9 -0
  74. package/assets/ide/commands/claude/kit/hat-reanchor.md +8 -0
  75. package/assets/ide/commands/claude/kit/init-guide.md +16 -0
  76. package/assets/ide/commands/claude/kit/sync-prompts-guide.md +9 -0
  77. package/assets/ide/commands/claude/kit/verify.md +16 -0
  78. package/assets/ide/commands/cursor/kit-apply-standards.md +12 -0
  79. package/assets/ide/commands/cursor/kit-gate-status.md +12 -0
  80. package/assets/ide/commands/cursor/kit-graph-check.md +10 -0
  81. package/assets/ide/commands/cursor/kit-hat-00-delegate.md +11 -0
  82. package/assets/ide/commands/cursor/kit-hat-10-spec.md +11 -0
  83. package/assets/ide/commands/cursor/kit-hat-10-task.md +11 -0
  84. package/assets/ide/commands/cursor/kit-hat-20-spec-audit.md +11 -0
  85. package/assets/ide/commands/cursor/kit-hat-20-task-audit.md +11 -0
  86. package/assets/ide/commands/cursor/kit-hat-reanchor.md +10 -0
  87. package/assets/ide/commands/cursor/kit-init-guide.md +18 -0
  88. package/assets/ide/commands/cursor/kit-sync-prompts-guide.md +11 -0
  89. package/assets/ide/commands/cursor/kit-verify.md +18 -0
  90. package/assets/ide/host-adapt/README.md +113 -0
  91. package/assets/ide/host-adapt/examples/mvp-hosts.yaml +78 -0
  92. package/assets/ide/host-adapt/host-adapt.schema.json +97 -0
  93. package/assets/ide/skills-orch/README.md +16 -0
  94. package/assets/ide/skills-orch/kit-apply-standards/SKILL.md +17 -0
  95. package/assets/ide/skills-orch/kit-gate-status/SKILL.md +17 -0
  96. package/assets/ide/skills-orch/kit-hat-reanchor/SKILL.md +15 -0
  97. package/assets/ide/skills-orch/kit-init-guide/SKILL.md +23 -0
  98. package/assets/ide/skills-orch/kit-verify/SKILL.md +23 -0
  99. package/assets/ontology.yaml +113 -0
  100. package/assets/skills/README.md +37 -0
  101. package/assets/skills/harness-00-delegate-only/SKILL.md +19 -0
  102. package/assets/skills/harness-10-spec/SKILL.md +63 -0
  103. package/assets/skills/harness-10-task/SKILL.md +70 -0
  104. package/assets/skills/harness-20-spec-audit/SKILL.md +50 -0
  105. package/assets/skills/harness-20-task-audit/SKILL.md +90 -0
  106. package/assets/skills/harness-20-task-audit/references/TEMPLATE_30_gate_stop.md +36 -0
  107. package/assets/skills/harness-hat-reanchor/SKILL.md +19 -0
  108. package/assets/standards/POINTER_workspace_truth_v1_zh.md +42 -0
  109. package/assets/standards/README.md +35 -0
  110. package/assets/standards/SOURCES_v1_zh.md +49 -0
  111. package/assets/standards/TEMPLATE_CODING_BASELINE_L1_v1_zh.md +88 -0
  112. package/assets/standards/TEMPLATE_CODING_BASELINE_L2_backend_v1_zh.md +75 -0
  113. package/assets/standards/TEMPLATE_CODING_BASELINE_L2_frontend_v1_zh.md +76 -0
  114. package/bin/dsh-coding-kit.js +7 -0
  115. package/bin/specgate.js +7 -0
  116. package/bin/validate_template_compliance.py +470 -0
  117. package/cordis.patch.yml +5 -0
  118. package/lib/cli-checks.d.ts +68 -0
  119. package/lib/cli-checks.js +812 -0
  120. package/lib/cli-checks.js.map +1 -0
  121. package/lib/cli-graph-hgm.d.ts +74 -0
  122. package/lib/cli-graph-hgm.js +416 -0
  123. package/lib/cli-graph-hgm.js.map +1 -0
  124. package/lib/cli-graph-yaml.d.ts +79 -0
  125. package/lib/cli-graph-yaml.js +602 -0
  126. package/lib/cli-graph-yaml.js.map +1 -0
  127. package/lib/cli-graph.d.ts +1 -0
  128. package/lib/cli-graph.js +199 -0
  129. package/lib/cli-graph.js.map +1 -0
  130. package/lib/cli-host.d.ts +24 -0
  131. package/lib/cli-host.js +1197 -0
  132. package/lib/cli-host.js.map +1 -0
  133. package/lib/cli-lifecycle.d.ts +2 -0
  134. package/lib/cli-lifecycle.js +401 -0
  135. package/lib/cli-lifecycle.js.map +1 -0
  136. package/lib/cli-refresh-ide-blocks.d.ts +66 -0
  137. package/lib/cli-refresh-ide-blocks.js +484 -0
  138. package/lib/cli-refresh-ide-blocks.js.map +1 -0
  139. package/lib/cli-shared.d.ts +74 -0
  140. package/lib/cli-shared.js +281 -0
  141. package/lib/cli-shared.js.map +1 -0
  142. package/lib/cli-skills.d.ts +52 -0
  143. package/lib/cli-skills.js +363 -0
  144. package/lib/cli-skills.js.map +1 -0
  145. package/lib/cli-status.d.ts +2 -0
  146. package/lib/cli-status.js +251 -0
  147. package/lib/cli-status.js.map +1 -0
  148. package/lib/cli-sync-prompts.d.ts +25 -0
  149. package/lib/cli-sync-prompts.js +158 -0
  150. package/lib/cli-sync-prompts.js.map +1 -0
  151. package/lib/cli-sync.d.ts +2 -0
  152. package/lib/cli-sync.js +124 -0
  153. package/lib/cli-sync.js.map +1 -0
  154. package/lib/cli-task-extra.d.ts +53 -0
  155. package/lib/cli-task-extra.js +408 -0
  156. package/lib/cli-task-extra.js.map +1 -0
  157. package/lib/cli-timeline.d.ts +28 -0
  158. package/lib/cli-timeline.js +68 -0
  159. package/lib/cli-timeline.js.map +1 -0
  160. package/lib/cli-wiki.d.ts +21 -0
  161. package/lib/cli-wiki.js +189 -0
  162. package/lib/cli-wiki.js.map +1 -0
  163. package/lib/cli.d.ts +22 -0
  164. package/lib/cli.js +1034 -0
  165. package/lib/cli.js.map +1 -0
  166. package/lib/host-contract.d.ts +23 -0
  167. package/lib/host-contract.js +143 -0
  168. package/lib/host-contract.js.map +1 -0
  169. package/lib/index.d.ts +18 -0
  170. package/lib/index.js +290 -0
  171. package/lib/index.js.map +1 -0
  172. package/lib/yaml.d.ts +4 -0
  173. package/lib/yaml.js +10 -0
  174. package/lib/yaml.js.map +1 -0
  175. package/package.json +87 -0
@@ -0,0 +1,812 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { extractSection, extractTaskSlug, fail, findWikiDeltaOutsideMetaSection, normalizeSlug, parseHarnessMeta, resolveTaskPath, STATUS_RE, resolveLayoutFile } from "./cli-shared.js";
5
+ import { WIKI_DELTA_LITERALS, WIKI_DELTA_PATHISH_RE } from "./cli-task-extra.js";
6
+ // DEF-003 阶段二 T5/T6:invoke hats 检查单一实现源(verify pre-30 硬闸与 task close 帽集合覆盖共用)。
7
+ const INVOKE_DIR_CANDIDATES = ['docs/harness/invokes/by-task', 'invokes/by-task'];
8
+ const INVOKE_HAT_TOKENS = new Set(['10', '20', '22', '30', '40', '50', '00', 'close']);
9
+ // pre-30 帽词表(FRAGMENT_30_gate_verify_v1_zh.md:required ∩ {10,20,00})
10
+ export const PRE30_HATS = ['10', '20', '00'];
11
+ // 文件名口径 invoke_YYYYMMDD_<hat>[_<hat>...]_<slug>.md:hat token 仅在日期后连续段(TEMPLATE_invoke.md),
12
+ // 合并文件(如 30_40)双计。
13
+ export function extractHatsFromInvokeName(name) {
14
+ const base = path.basename(name, '.md');
15
+ const parts = base.split('_');
16
+ if (parts.length < 4 || parts[0] !== 'invoke')
17
+ return [];
18
+ if (!/^\d{8}$/.test(parts[1]))
19
+ return [];
20
+ const hats = [];
21
+ for (let i = 2; i < parts.length; i += 1) {
22
+ const tok = parts[i].toLowerCase();
23
+ if (!INVOKE_HAT_TOKENS.has(tok))
24
+ break;
25
+ hats.push(tok);
26
+ }
27
+ return hats;
28
+ }
29
+ // required 集合解析:显式 required_invoke_hats 优先于 invoke_retention_profile;
30
+ // 缺省 default=10,30,40 · minimal=30 · full=10,20,30,40,00,CLOSE(与旧包 2.24.0 口径已核对一致:
31
+ // lib/task-meta.js INVOKE_RETENTION_PROFILES · 不含 22/50 · 1.6.0 修正前曾多列 22/50);
32
+ // 未知 profile 按 default 计并留痕于 source。
33
+ export function resolveRequiredInvokeHats(meta) {
34
+ const explicit = (meta.required_invoke_hats ?? '').trim();
35
+ if (explicit) {
36
+ const required = explicit
37
+ .split(/[,,\s]+/)
38
+ .map((s) => s.trim())
39
+ .filter(Boolean);
40
+ return { required, source: `required_invoke_hats=${explicit}` };
41
+ }
42
+ const profile = (meta.invoke_retention_profile ?? '').trim() || 'default';
43
+ if (profile === 'minimal')
44
+ return { required: ['30'], source: 'invoke_retention_profile=minimal' };
45
+ if (profile === 'full') {
46
+ return {
47
+ required: ['10', '20', '30', '40', '00', 'CLOSE'],
48
+ source: 'invoke_retention_profile=full',
49
+ };
50
+ }
51
+ const source = profile === 'default'
52
+ ? 'invoke_retention_profile=default(缺省)'
53
+ : `invoke_retention_profile=${profile}(未知值 · 按 default 计)`;
54
+ return { required: ['10', '30', '40'], source };
55
+ }
56
+ // 汇聚 docs/harness/invokes/by-task/<slug>/(及 invokes/by-task/ 备选)下全部 invoke 文件的帽 token。
57
+ export function collectInvokeHats(target, slug) {
58
+ const found = new Set();
59
+ const dirNames = new Set([slug, normalizeSlug(slug)]);
60
+ for (const rel of INVOKE_DIR_CANDIDATES) {
61
+ for (const dirName of dirNames) {
62
+ const dir = path.join(target, rel, dirName);
63
+ if (!existsSync(dir))
64
+ continue;
65
+ let names = [];
66
+ try {
67
+ names = readdirSync(dir);
68
+ }
69
+ catch {
70
+ continue;
71
+ }
72
+ for (const name of names) {
73
+ if (!name.endsWith('.md'))
74
+ continue;
75
+ for (const hat of extractHatsFromInvokeName(name))
76
+ found.add(hat);
77
+ }
78
+ }
79
+ }
80
+ return found;
81
+ }
82
+ export function missingInvokeHats(target, slug, hats) {
83
+ if (hats.length === 0)
84
+ return [];
85
+ const found = collectInvokeHats(target, slug);
86
+ return hats.filter((h) => !found.has(h.toLowerCase()));
87
+ }
88
+ // DEF-003 阶段二 T5(verify pre-30 硬闸真值源):required ∩ {10,20,00} 文件存在性。
89
+ // 缺 40 不挡 30(40 ∉ PRE30_HATS);minimal / 显式 required 无 pre-30 帽 → preRequired=∅ → 不挡。
90
+ export function checkPre30InvokeHats(target, absTask) {
91
+ const content = readFileSync(absTask, 'utf8');
92
+ const meta = parseHarnessMeta(content);
93
+ const { required, source } = resolveRequiredInvokeHats(meta);
94
+ const preRequired = required.filter((h) => PRE30_HATS.includes(h));
95
+ const slug = meta.task_slug ?? extractTaskSlug(absTask);
96
+ const missing = missingInvokeHats(target, slug, preRequired);
97
+ return { ok: missing.length === 0, missing, preRequired, source };
98
+ }
99
+ export const UNCHECKED_RE = /^\s*- \[ \]/;
100
+ export const CLOSE_STATUSES = new Set(['done', 'completed']);
101
+ // task 文件路径推导仓根(task close 无仓根参数 · 与 findReview 的 target 口径对齐):
102
+ // <root>/docs/(harness/)tasks/(active|done)/<file> → <root>;非常规布局回退 task 所在目录。
103
+ export function taskTargetRoot(absTask) {
104
+ const norm = absTask.split(path.sep).join('/');
105
+ const m = norm.match(/^(.*)\/docs\/(?:harness\/)?tasks\/(?:active|done)\/[^/]+$/);
106
+ if (m && m[1])
107
+ return m[1];
108
+ return path.dirname(absTask);
109
+ }
110
+ export function evalCloseSlug(absTask, content) {
111
+ const meta = parseHarnessMeta(content);
112
+ const fileSlug = extractTaskSlug(absTask);
113
+ if (!meta.task_slug)
114
+ return { status: 'fail', detail: 'Harness 元信息表缺 task_slug' };
115
+ if (normalizeSlug(meta.task_slug) !== normalizeSlug(fileSlug)) {
116
+ return {
117
+ status: 'fail',
118
+ detail: `slug 不一致: 文件名 ${fileSlug} ≠ 元信息 task_slug ${meta.task_slug}`,
119
+ };
120
+ }
121
+ return { status: 'pass', detail: `slug 一致(${meta.task_slug})` };
122
+ }
123
+ export function evalCloseSelfCheck(content) {
124
+ const selfCheck = extractSection(content, '### 自检结论', '\n##');
125
+ if (!selfCheck)
126
+ return { status: 'fail', detail: '缺 ### 自检结论 节' };
127
+ const substantive = selfCheck
128
+ .split('\n')
129
+ .slice(1)
130
+ .map((l) => l.trim())
131
+ .filter(Boolean)
132
+ .filter((l) => !PLACEHOLDER_RE.test(l));
133
+ if (substantive.length === 0)
134
+ return { status: 'fail', detail: '自检结论未回填(空或纯占位符)' };
135
+ return { status: 'pass', detail: '自检结论已回填' };
136
+ }
137
+ export function evalCloseAcceptance(content) {
138
+ const acceptance = extractSection(content, '## 验收标准', '\n##');
139
+ if (!acceptance)
140
+ return { status: 'fail', detail: '缺 ## 验收标准 节' };
141
+ const unchecked = acceptance.split('\n').filter((l) => UNCHECKED_RE.test(l));
142
+ if (unchecked.length > 0) {
143
+ return {
144
+ status: 'fail',
145
+ detail: `验收标准 ${unchecked.length} 项未勾选(或 --allow-unchecked 显式豁免)`,
146
+ };
147
+ }
148
+ return { status: 'pass', detail: '验收标准全部勾选' };
149
+ }
150
+ export function evalCloseStatus(content) {
151
+ const statusLine = content.split('\n').find((l) => STATUS_RE.test(l));
152
+ const status = statusLine ? statusLine.match(STATUS_RE)?.[1]?.toLowerCase() : null;
153
+ if (!status)
154
+ return { status: 'fail', detail: '未找到 > **状态** 行' };
155
+ if (!CLOSE_STATUSES.has(status)) {
156
+ return { status: 'fail', detail: `状态非 done/completed(当前: ${status})` };
157
+ }
158
+ return { status: 'pass', detail: `状态 ${status}` };
159
+ }
160
+ // close_invoke:帽集合覆盖(required_invoke_hats 显式优先 · profile 缺省 default=10,30,40 · 合并文件名双计)
161
+ export function evalCloseInvokeHats(absTask, content) {
162
+ const meta = parseHarnessMeta(content);
163
+ const { required, source } = resolveRequiredInvokeHats(meta);
164
+ const slug = meta.task_slug ?? extractTaskSlug(absTask);
165
+ const missing = missingInvokeHats(taskTargetRoot(absTask), slug, required);
166
+ if (missing.length > 0) {
167
+ return {
168
+ status: 'fail',
169
+ detail: `missing invoke hats: ${missing.join(',')}(${source} · 或 --allow-invoke-gap 豁免)`,
170
+ };
171
+ }
172
+ return { status: 'pass', detail: `invoke hats 齐(${source})` };
173
+ }
174
+ // close_review:R<n> 审查文存在性(findReview 与 verify / status / dry-run 同口径)
175
+ export function evalCloseReview(absTask) {
176
+ return findReview(taskTargetRoot(absTask), absTask)
177
+ ? { status: 'pass', detail: 'R<n> 审查文存在' }
178
+ : {
179
+ status: 'fail',
180
+ detail: 'missing R<n> review(docs/harness/reviews 与 reviews/ 均无 · 或 --allow-no-review 豁免)',
181
+ };
182
+ }
183
+ const GRAPH_DELTA_LITERALS = new Set(['none']);
184
+ // close_graph_delta(lifecycle.yaml 口径:缺字段 WARN 不挡;none 无 note / 路径不存在 BLOCK)
185
+ export function evalCloseGraphDelta(absTask, content) {
186
+ const meta = parseHarnessMeta(content);
187
+ const raw = (meta.graph_delta ?? '').trim();
188
+ if (!raw) {
189
+ return { status: 'warn', detail: '缺 graph_delta 字段(warn · 不挡 close · 建议补 path|none)' };
190
+ }
191
+ if (GRAPH_DELTA_LITERALS.has(raw)) {
192
+ if (!(meta.graph_delta_note ?? '').trim()) {
193
+ return { status: 'fail', detail: `graph_delta=${raw} 缺 graph_delta_note 理由` };
194
+ }
195
+ return { status: 'pass', detail: `graph_delta=${raw}(note 在)` };
196
+ }
197
+ if (!existsSync(path.resolve(taskTargetRoot(absTask), raw))) {
198
+ return { status: 'fail', detail: `graph_delta 指向不存在的图谱路径(相对仓根): ${raw}` };
199
+ }
200
+ return { status: 'pass', detail: `graph_delta 路径存在(${raw})` };
201
+ }
202
+ const KPI_TASK_SCORE_RE = /Task_KPI%:\s*\d+(?:\.\d+)?/;
203
+ const KPI_D_TABLE_RE = /^\|\s*D[1-5]\s*\|/m;
204
+ const KPI_FOUR_DIM_CELL_RE = /\|\s*[1-5]\s*\|/g;
205
+ // close_kpi:kpi_aggregator=CLOSE(缺省同 · TASK_TEMPLATE 默认 CLOSE)时 ### KPI 节须含可解析分数
206
+ // (Task_KPI%: N / D1–D5 表 / 四维 1–5 四格评分)。
207
+ export function evalCloseKpi(content) {
208
+ const meta = parseHarnessMeta(content);
209
+ const agg = (meta.kpi_aggregator ?? '').trim() || 'CLOSE';
210
+ if (agg !== 'CLOSE')
211
+ return { status: 'pass', detail: `kpi_aggregator=${agg}(非 CLOSE · 不闸)` };
212
+ const section = extractSection(content, '### KPI', '\n##');
213
+ if (!section) {
214
+ return { status: 'fail', detail: 'kpi_aggregator=CLOSE 但缺 ### KPI 节(或 --allow-kpi-gap 豁免)' };
215
+ }
216
+ const body = section.split('\n').slice(1).join('\n');
217
+ if (KPI_TASK_SCORE_RE.test(body))
218
+ return { status: 'pass', detail: 'KPI 可解析分数(Task_KPI%)' };
219
+ if (KPI_D_TABLE_RE.test(body))
220
+ return { status: 'pass', detail: 'KPI 可解析分数(D1–D5 表)' };
221
+ if (/四维/.test(body) && (body.match(KPI_FOUR_DIM_CELL_RE) ?? []).length >= 4) {
222
+ return { status: 'pass', detail: 'KPI 可解析分数(四维 1–5)' };
223
+ }
224
+ return {
225
+ status: 'fail',
226
+ detail: 'kpi_aggregator=CLOSE 但 ### KPI 节无可解析分数' +
227
+ '(Task_KPI%: N / D1–D5 表 / 四维 1–5 · 或 --allow-kpi-gap 豁免)',
228
+ };
229
+ }
230
+ // close_experience:experience_capture=required 硬闸(### 经验总结 须 ≥80 字或 ≥3 条列表;
231
+ // 整行圆括号占位/说明行不计入)。
232
+ export function evalCloseExperience(content) {
233
+ const meta = parseHarnessMeta(content);
234
+ const mode = (meta.experience_capture ?? '').trim();
235
+ if (mode !== 'required') {
236
+ return { status: 'pass', detail: `experience_capture=${mode || 'unset'}(非 required · 不闸)` };
237
+ }
238
+ const section = extractSection(content, '### 经验总结', '\n##');
239
+ if (!section) {
240
+ return {
241
+ status: 'fail',
242
+ detail: 'experience_capture=required 但缺 ### 经验总结 节(或 --allow-experience-gap 豁免)',
243
+ };
244
+ }
245
+ const substantive = section
246
+ .split('\n')
247
+ .slice(1)
248
+ .map((l) => l.trim())
249
+ .filter(Boolean)
250
+ .filter((l) => !/^(.*)$/.test(l));
251
+ const items = substantive.filter((l) => /^[-*]\s+/.test(l));
252
+ const textLen = substantive.join('').length;
253
+ if (textLen >= 80 || items.length >= 3) {
254
+ return { status: 'pass', detail: `经验总结已回填(${textLen} 字 / ${items.length} 条)` };
255
+ }
256
+ return {
257
+ status: 'fail',
258
+ detail: 'experience_capture=required 但经验总结未达标(须 ≥80 字或 ≥3 条列表 · 或 --allow-experience-gap 豁免)',
259
+ };
260
+ }
261
+ // close_wiki_delta(lifecycle.yaml 口径:缺字段 BLOCK;none|n/a 无 note / 路径不存在 BLOCK;
262
+ // 词表与 task lint-wiki-delta 同源 · WIKI_DELTA_LITERALS / WIKI_DELTA_PATHISH_RE)
263
+ export function evalCloseWikiDelta(absTask, content) {
264
+ const meta = parseHarnessMeta(content);
265
+ const raw = (meta.wiki_delta ?? '').trim();
266
+ if (!raw) {
267
+ return { status: 'fail', detail: '缺 wiki_delta 字段(须 path|none|n/a · 或 --allow-wiki-gap 豁免)' };
268
+ }
269
+ if (WIKI_DELTA_LITERALS.has(raw)) {
270
+ if (!(meta.wiki_delta_note ?? '').trim()) {
271
+ return {
272
+ status: 'fail',
273
+ detail: `wiki_delta=${raw} 缺 wiki_delta_note 理由(或 --allow-wiki-gap 豁免)`,
274
+ };
275
+ }
276
+ return { status: 'pass', detail: `wiki_delta=${raw}(note 在)` };
277
+ }
278
+ if (!WIKI_DELTA_PATHISH_RE.test(raw)) {
279
+ return { status: 'fail', detail: `wiki_delta 值非法: ${raw}(须 path|none|n/a)` };
280
+ }
281
+ if (!existsSync(path.resolve(taskTargetRoot(absTask), raw))) {
282
+ return {
283
+ status: 'fail',
284
+ detail: `wiki_delta 指向不存在的 wiki 路径(相对仓根): ${raw}(或 --allow-wiki-gap 豁免)`,
285
+ };
286
+ }
287
+ return { status: 'pass', detail: `wiki_delta 路径存在(${raw})` };
288
+ }
289
+ // close_wiki_promotion(PRD_DEF-003 后续棒 · 语义映射旧包 @cyning/harness@2.24.0
290
+ // lib/close-loop-gates.js evaluateWikiPromotionPointer):仅 experience_capture=required 且
291
+ // wiki_delta=path 时闸 —— ### 经验总结 节须含晋升指针(coding_wiki / wiki_promoted: / Wiki: /
292
+ // 与 wiki_delta 相同子串)。跳过口径(pass · 与旧包逐字对齐):未声明 experience_capture /
293
+ // ≠required / 无 wiki_delta(缺字段由 close_wiki_delta 挡)/ wiki_delta=none|n/a。
294
+ // 豁免旗标与 wiki_delta 共用 --allow-wiki-gap(lifecycle.yaml 登记 · 旧包同口径降旗面)。
295
+ // 与旧包差异:经验节标题沿用本包既有约定 ### 经验总结(同 evalCloseExperience 单一抽取口径;
296
+ // 旧包额外兼容 Experience/经验/lessons 标题)。
297
+ const WIKI_PROMO_LITERAL_RE = /^(none|n\/a)$/i;
298
+ export function evalCloseWikiPromotion(absTask, content) {
299
+ const meta = parseHarnessMeta(content);
300
+ const expMode = (meta.experience_capture ?? '').trim();
301
+ if (!expMode) {
302
+ return { status: 'pass', detail: '未声明 experience_capture · 跳过 wiki 晋升指针' };
303
+ }
304
+ if (expMode.toLowerCase() !== 'required') {
305
+ return {
306
+ status: 'pass',
307
+ detail: `experience_capture=${expMode}(非 required · 跳过 wiki 晋升指针)`,
308
+ };
309
+ }
310
+ const wikiRaw = (meta.wiki_delta ?? '').trim();
311
+ if (!wikiRaw) {
312
+ return { status: 'pass', detail: '无 wiki_delta · 晋升指针由 close_wiki_delta 处理' };
313
+ }
314
+ if (WIKI_PROMO_LITERAL_RE.test(wikiRaw)) {
315
+ return { status: 'pass', detail: `wiki_delta=${wikiRaw} · 不要求经验节 wiki 指针` };
316
+ }
317
+ const section = extractSection(content, '### 经验总结', '\n##');
318
+ if (!section) {
319
+ return {
320
+ status: 'fail',
321
+ detail: 'experience_capture=required 且 wiki_delta=path 时须有经验节并含 wiki 指针' +
322
+ '(或 --allow-wiki-gap 豁免)',
323
+ };
324
+ }
325
+ const rel = wikiRaw.replace(/^\.\/+/, '').replace(/\\/g, '/');
326
+ const ok = /coding_wiki/i.test(section) ||
327
+ /wiki_promoted\s*[::]/i.test(section) ||
328
+ /(?:^|\n)\s*Wiki\s*[::]/im.test(section) ||
329
+ (rel !== '' && section.includes(rel));
330
+ if (!ok) {
331
+ return {
332
+ status: 'fail',
333
+ detail: '经验节缺 wiki 晋升指针(须含 coding_wiki 路径 / wiki_promoted: / Wiki: /' +
334
+ ' 或与 wiki_delta 相同子串 · 或 --allow-wiki-gap 豁免)',
335
+ };
336
+ }
337
+ return { status: 'pass', detail: '经验节含 wiki 晋升指针' };
338
+ }
339
+ // ==== doc-health · close_pr_merged / close_hub_index(SPEC docs/spec/doc-health) ====
340
+ const HUB_CANDIDATES = [
341
+ 'docs/tasks/done/README.md',
342
+ 'docs/harness/tasks/done/README.md',
343
+ ];
344
+ /** 仓级 Hub 闸:`.coding-kit/local.json`(legacy `.cyning-harness/local.json`)的 `close_hub_gate`;缺省 true(默认开)。 */
345
+ export function isCloseHubGateEnabled(root) {
346
+ const localPath = resolveLayoutFile(root, 'local.json').abs;
347
+ if (!existsSync(localPath))
348
+ return true;
349
+ try {
350
+ const raw = JSON.parse(readFileSync(localPath, 'utf8'));
351
+ if (raw.close_hub_gate === false)
352
+ return false;
353
+ return true;
354
+ }
355
+ catch {
356
+ return true;
357
+ }
358
+ }
359
+ export function findHubFile(root) {
360
+ for (const rel of HUB_CANDIDATES) {
361
+ const abs = path.join(root, rel);
362
+ if (existsSync(abs))
363
+ return abs;
364
+ }
365
+ return null;
366
+ }
367
+ /** 探测 PR 合入态。测试可设 `DSH_CLOSE_PR_STATE=MERGED|OPEN|NONE` 旁路 gh。 */
368
+ export function resolvePrMergedState(root, relatedPr) {
369
+ const envState = (process.env.DSH_CLOSE_PR_STATE ?? '').trim().toUpperCase();
370
+ if (envState) {
371
+ if (envState === 'MERGED')
372
+ return { ok: true, state: 'MERGED', detail: `DSH_CLOSE_PR_STATE=MERGED` };
373
+ if (envState === 'OPEN' || envState === 'CLOSED' || envState === 'NONE') {
374
+ return { ok: false, state: envState, detail: `DSH_CLOSE_PR_STATE=${envState}` };
375
+ }
376
+ return { ok: false, state: envState, detail: `DSH_CLOSE_PR_STATE 非法: ${envState}` };
377
+ }
378
+ const args = relatedPr
379
+ ? ['pr', 'view', relatedPr.replace(/^#/, ''), '--json', 'state,url']
380
+ : ['pr', 'view', '--json', 'state,url'];
381
+ const r = spawnSync('gh', args, { cwd: root, encoding: 'utf8' });
382
+ if (r.error || r.status !== 0) {
383
+ const err = (r.stderr || r.stdout || r.error?.message || 'gh failed').trim();
384
+ return {
385
+ ok: false,
386
+ state: 'NONE',
387
+ detail: `无法解析关联 PR(gh 失败或无 PR): ${err.slice(0, 200)}`,
388
+ };
389
+ }
390
+ try {
391
+ const parsed = JSON.parse(r.stdout || '{}');
392
+ const state = (parsed.state ?? '').toUpperCase();
393
+ if (state === 'MERGED') {
394
+ return { ok: true, state, detail: `PR MERGED${parsed.url ? ` · ${parsed.url}` : ''}` };
395
+ }
396
+ return {
397
+ ok: false,
398
+ state: state || 'UNKNOWN',
399
+ detail: `PR 状态非 MERGED(当前: ${state || 'unknown'})`,
400
+ };
401
+ }
402
+ catch {
403
+ return { ok: false, state: 'NONE', detail: 'gh 输出无法解析 JSON' };
404
+ }
405
+ }
406
+ export function evalClosePrMerged(absTask, content) {
407
+ const meta = parseHarnessMeta(content);
408
+ const policy = (meta.close_pr_policy ?? '').trim().toLowerCase();
409
+ if (policy === 'exempt') {
410
+ if (!(meta.close_pr_exempt_note ?? '').trim()) {
411
+ return {
412
+ status: 'fail',
413
+ detail: 'close_pr_policy=exempt 但缺 close_pr_exempt_note(或 --allow-no-pr-merge 豁免)',
414
+ };
415
+ }
416
+ return { status: 'pass', detail: `close_pr_policy=exempt(${meta.close_pr_exempt_note})` };
417
+ }
418
+ const root = taskTargetRoot(absTask);
419
+ const related = (meta.related_pr ?? '').trim() || undefined;
420
+ const resolved = resolvePrMergedState(root, related);
421
+ if (resolved.ok)
422
+ return { status: 'pass', detail: resolved.detail };
423
+ return {
424
+ status: 'fail',
425
+ detail: `${resolved.detail}(或 --allow-no-pr-merge / close_pr_policy=exempt)`,
426
+ };
427
+ }
428
+ export function evalCloseHubIndex(absTask, _content) {
429
+ const root = taskTargetRoot(absTask);
430
+ if (!isCloseHubGateEnabled(root)) {
431
+ return { status: 'pass', detail: 'close_hub_gate=false(仓级关闭 · skip)' };
432
+ }
433
+ const hub = findHubFile(root);
434
+ if (!hub)
435
+ return { status: 'pass', detail: '无 Hub 文件 · skip close_hub_index' };
436
+ const base = path.basename(absTask);
437
+ const body = readFileSync(hub, 'utf8');
438
+ // 索引行:markdown 链接目标或纯文本含归档文件名
439
+ const linked = body.includes(base) ||
440
+ new RegExp(`\\]\\([^)]*${escapeRegExp(base)}\\)`).test(body) ||
441
+ new RegExp(`\\]\\(\\./[^)]*${escapeRegExp(base)}\\)`).test(body);
442
+ if (!linked) {
443
+ return {
444
+ status: 'fail',
445
+ detail: `Hub 缺本 task 索引行(须含 ${base} · Hub=${path.relative(root, hub)} · 或 --allow-no-hub)`,
446
+ };
447
+ }
448
+ return { status: 'pass', detail: `Hub 含索引(${base})` };
449
+ }
450
+ function escapeRegExp(s) {
451
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
452
+ }
453
+ /** docs/spec 根级裸 SPEC-*.md(warn · 不 BLOCK)。 */
454
+ export function listBareSpecFiles(root) {
455
+ const dir = path.join(root, 'docs', 'spec');
456
+ if (!existsSync(dir))
457
+ return [];
458
+ return readdirSync(dir)
459
+ .filter((f) => /^SPEC-.+\.md$/i.test(f))
460
+ .sort();
461
+ }
462
+ // close 守卫注册表(lifecycle.yaml close 转移登记顺序):未登记 id → null(= 未接线 · 调用方明示,
463
+ // R-TRUTH-1 禁止第三态;登记守卫全部已接线 —— close_wiki_promotion 于 PRD_DEF-003 后续棒接线,
464
+ // to_00 spec_reviews_retention 实现见本文件 evalSpecReviewsRetention(verify --spec 同一实现源);
465
+ // doc-health:close_pr_merged / close_hub_index)。
466
+ export function evalCloseGuard(guardId, absTask, content) {
467
+ switch (guardId) {
468
+ case 'close_invoke':
469
+ return evalCloseInvokeHats(absTask, content);
470
+ case 'close_self_check':
471
+ return evalCloseSelfCheck(content);
472
+ case 'close_acceptance':
473
+ return evalCloseAcceptance(content);
474
+ case 'close_slug':
475
+ return evalCloseSlug(absTask, content);
476
+ case 'close_status':
477
+ return evalCloseStatus(content);
478
+ case 'close_review':
479
+ return evalCloseReview(absTask);
480
+ case 'close_graph_delta':
481
+ return evalCloseGraphDelta(absTask, content);
482
+ case 'close_kpi':
483
+ return evalCloseKpi(content);
484
+ case 'close_experience':
485
+ return evalCloseExperience(content);
486
+ case 'close_wiki_delta':
487
+ return evalCloseWikiDelta(absTask, content);
488
+ case 'close_wiki_promotion':
489
+ return evalCloseWikiPromotion(absTask, content);
490
+ case 'close_pr_merged':
491
+ return evalClosePrMerged(absTask, content);
492
+ case 'close_hub_index':
493
+ return evalCloseHubIndex(absTask, content);
494
+ default:
495
+ return null;
496
+ }
497
+ }
498
+ const CHECKBOX_RE = /^\s*- \[[ xX]\]/m;
499
+ const ABS_PATH_RE = /(\/(?:Users|home|root)\/[^\s/`\\]|[A-Za-z]:\\Users\\[^\s`\\])/;
500
+ const KNOWN_STATUS_TOKENS = new Set([
501
+ 'draft',
502
+ 'pending',
503
+ 'in_progress',
504
+ 'active',
505
+ 'deferred',
506
+ 'done',
507
+ 'completed',
508
+ ]);
509
+ // 自检结论占位符(draft 期合法 · close 前须回填);cmdTaskClose 亦用
510
+ export const PLACEHOLDER_RE = /^([^)]*(回填|待填)[^)]*)$/;
511
+ // ==== verify --spec / spec_reviews_retention(PRD_DEF-003 后续棒 · 单一实现源) ====
512
+ // 语义映射旧包 @cyning/harness@2.24.0 lib/task-meta.js findSpecReview / shouldSkipSpecAudit /
513
+ // extractSpecSlug(lib/verify.js verifySpecTarget 消费)。与旧包差异(PR body 对照):
514
+ // ① 目录布局与 findReview 同口径扫 docs/harness/reviews 与 reviews/ 双路径(旧包仅 docs/harness/reviews);
515
+ // ② 旧包 --workspace-root 第二仓根旗标本包不支持(DEF-011 fail-fast 清单既有钉死)。
516
+ // SPEC slug 推导:Harness 元信息表 spec_slug 优先;回退文件名去 SPEC[-_] 前缀与 _v<n> 版本后缀。
517
+ export function extractSpecSlug(specFile, content) {
518
+ const meta = content ? parseHarnessMeta(content) : {};
519
+ if (meta.spec_slug)
520
+ return meta.spec_slug;
521
+ let base = path.basename(specFile, '.md');
522
+ base = base.replace(/^SPEC[-_]/i, '');
523
+ base = base.replace(/_v\d+$/, '');
524
+ return base;
525
+ }
526
+ // bugfix / 显式跳过 10-spec 审计:不要求 SPEC 审查文(lifecycle.yaml to_00 守卫 note 同口径)。
527
+ // 只认元信息表 / 文首 track 行——避免命中正文里对规则的说明文字(旧包口径逐字映射)。
528
+ export function shouldSkipSpecAudit(content) {
529
+ if (!content)
530
+ return false;
531
+ const meta = parseHarnessMeta(content);
532
+ if (String(meta.skip_spec_audit ?? '').toLowerCase() === 'true')
533
+ return true;
534
+ if (String(meta.track ?? '').toLowerCase() === 'bugfix')
535
+ return true;
536
+ if (/^>\s*\*\*track\*\*[::]\s*`?bugfix\b/im.test(content))
537
+ return true;
538
+ return false;
539
+ }
540
+ // SPEC 的 R<n> 审查文存在性(verify --spec 与 lifecycle dry-run to_00 同口径):
541
+ // 扫描 docs/harness/reviews 与 reviews/ 双路径(与 findReview 布局一致),命名兼容旧包三模式
542
+ // 1. spec_<slug>_audit_R<n>_*.md(推荐)
543
+ // 2. spec_<slug>_ACCEPT_R<n>_*.md
544
+ // 3. task_<slug>_spec_ACCEPT_R<n>_*.md
545
+ export function findSpecReview(target, specFile, content) {
546
+ const dirs = [path.join(target, 'docs/harness/reviews'), path.join(target, 'reviews')];
547
+ const stripVer = (s) => s.replace(/_v\d+$/, '');
548
+ const slugNorm = normalizeSlug(stripVer(extractSpecSlug(specFile, content)));
549
+ const PATTERNS = [
550
+ /^spec_(.+?)_audit_R\d+_.*\.md$/i,
551
+ /^spec_(.+?)_ACCEPT_R\d+_.*\.md$/i,
552
+ /^task_(.+?)_spec_ACCEPT_R\d+_.*\.md$/i,
553
+ ];
554
+ for (const reviewsDir of dirs) {
555
+ if (!existsSync(reviewsDir))
556
+ continue;
557
+ for (const name of readdirSync(reviewsDir)) {
558
+ for (const RE of PATTERNS) {
559
+ const m = name.match(RE);
560
+ if (!m)
561
+ continue;
562
+ if (normalizeSlug(stripVer(m[1])) === slugNorm)
563
+ return true;
564
+ break;
565
+ }
566
+ }
567
+ }
568
+ return false;
569
+ }
570
+ // spec_reviews_retention(lifecycle.yaml to_00 登记 · severity=block · allow=--allow-no-spec-review):
571
+ // dry-run 的 --task 在该转移下携带待签收 SPEC 路径(非 task 文件 · 守卫 note「不挂 verify --task」)。
572
+ export function evalSpecReviewsRetention(target, absSpec, content) {
573
+ if (shouldSkipSpecAudit(content)) {
574
+ return { status: 'pass', detail: 'skip SPEC review gate(bugfix / skip_spec_audit 元信息豁免)' };
575
+ }
576
+ return findSpecReview(target, absSpec, content)
577
+ ? {
578
+ status: 'pass',
579
+ detail: 'spec R<n> 审查文存在(docs/harness/reviews 或 reviews/ 命中 spec_*_audit_R<n>_* / *_ACCEPT_R<n>_*)',
580
+ }
581
+ : {
582
+ status: 'fail',
583
+ detail: 'missing spec R<n> review(docs/harness/reviews 与 reviews/ 均无 · 或 --allow-no-spec-review 豁免)',
584
+ };
585
+ }
586
+ // R<n> 审查文存在性(DEF-003 T4 真值源):扫描 docs/harness/reviews 与 reviews/ 双路径,
587
+ // 文件名口径 task_<slug>_audit_R<n>_*.md(slug 去 _v<n> 版本后缀)。
588
+ export function findReview(target, taskFile) {
589
+ const dirs = [path.join(target, 'docs/harness/reviews'), path.join(target, 'reviews')];
590
+ const stripVer = (s) => s.replace(/_v\d+$/, '');
591
+ const base = stripVer(path.basename(taskFile, '.md'));
592
+ const RE = /^(task_.+?)_audit_R\d+_.*\.md$/i;
593
+ for (const reviewsDir of dirs) {
594
+ if (!existsSync(reviewsDir))
595
+ continue;
596
+ for (const name of readdirSync(reviewsDir)) {
597
+ const m = name.match(RE);
598
+ if (!m)
599
+ continue;
600
+ if (stripVer(m[1]) === base)
601
+ return true;
602
+ }
603
+ }
604
+ return false;
605
+ }
606
+ function walkFiles(dir, depth, acc) {
607
+ if (depth < 0 || !existsSync(dir))
608
+ return;
609
+ let names;
610
+ try {
611
+ names = readdirSync(dir);
612
+ }
613
+ catch {
614
+ return;
615
+ }
616
+ for (const name of names) {
617
+ if (name === 'node_modules' || name.startsWith('.'))
618
+ continue;
619
+ const full = path.join(dir, name);
620
+ acc.push(full);
621
+ try {
622
+ if (statSync(full).isDirectory())
623
+ walkFiles(full, depth - 1, acc);
624
+ }
625
+ catch {
626
+ // 忽略瞬时文件
627
+ }
628
+ }
629
+ }
630
+ // D5 CI 测试步骤匹配模式(DEF-014):workflow 文本命中任一模式才算「CI 含 test 步骤」
631
+ const CI_TEST_STEP_PATTERNS = [
632
+ /\bpytest\b/,
633
+ /\bvitest\b/,
634
+ /\bjest\b/,
635
+ /\bnpm\s+(run\s+)?test\b/,
636
+ /\bpnpm\s+(run\s+)?test\b/,
637
+ /\byarn\s+test\b/,
638
+ /\bnode\s+--test\b/,
639
+ /\bgo\s+test\b/,
640
+ /\bcargo\s+test\b/,
641
+ /\btox\b/,
642
+ /\bunittest\b/,
643
+ /^\s*-?\s*name\s*:.*\btest\b/im,
644
+ ];
645
+ function workflowHasTestStep(text) {
646
+ return CI_TEST_STEP_PATTERNS.some((re) => re.test(text));
647
+ }
648
+ function listWorkflowFiles(ciDir) {
649
+ try {
650
+ return readdirSync(ciDir).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
651
+ }
652
+ catch {
653
+ return [];
654
+ }
655
+ }
656
+ // 收紧后的 D5 探测(DEF-014):强信号探针 + 测试文件名 + CI 含 test 步骤
657
+ function hasTestArtifacts(target) {
658
+ const probes = [
659
+ 'test',
660
+ 'tests',
661
+ 'spec',
662
+ 'specs',
663
+ '__tests__',
664
+ 'jest.config.js',
665
+ 'jest.config.ts',
666
+ 'vitest.config.js',
667
+ 'vitest.config.ts',
668
+ 'playwright.config.js',
669
+ 'playwright.config.ts',
670
+ 'cypress.config.js',
671
+ 'pytest.ini',
672
+ ];
673
+ for (const p of probes) {
674
+ if (existsSync(path.join(target, p)))
675
+ return true;
676
+ }
677
+ const found = [];
678
+ walkFiles(target, 3, found);
679
+ const testName = /\.(test|spec)\.(js|ts|mjs|cjs)$|_test\.py$|^test_.*\.py$/;
680
+ if (found.some((f) => testName.test(path.basename(f))))
681
+ return true;
682
+ const ciDir = path.join(target, '.github', 'workflows');
683
+ if (existsSync(ciDir)) {
684
+ for (const f of listWorkflowFiles(ciDir)) {
685
+ try {
686
+ if (workflowHasTestStep(readFileSync(path.join(ciDir, f), 'utf8')))
687
+ return true;
688
+ }
689
+ catch {
690
+ // 忽略不可读 workflow,继续检查其余文件
691
+ }
692
+ }
693
+ }
694
+ return false;
695
+ }
696
+ // 1.5.0 硬化(DEF-014 过渡结束):旧启发式(pyproject.toml / setup.py / 任意 workflow 存在)
697
+ // 不再放行 —— 新探测失败即 FAIL(verify BLOCKED exit 2 · audit FAIL exit 2)。
698
+ export function runTestCheck(target, taskFile) {
699
+ if (!taskFile)
700
+ return { ok: true, reason: '未指定 --task,跳过 D5' };
701
+ const abs = resolveTaskPath(target, taskFile);
702
+ if (!existsSync(abs))
703
+ return { ok: true, reason: 'task 文件不存在,跳过 D5' };
704
+ const content = readFileSync(abs, 'utf8');
705
+ const meta = parseHarnessMeta(content);
706
+ const strategy = (meta.test_strategy || '').trim();
707
+ if (strategy !== 'required') {
708
+ return { ok: true, reason: `test_strategy=${strategy || 'unset'},无需 D5 强检查` };
709
+ }
710
+ if (hasTestArtifacts(target)) {
711
+ return { ok: true, reason: 'test_strategy=required 且检测到测试/CI 制品' };
712
+ }
713
+ return {
714
+ ok: false,
715
+ reason: 'D5: test_strategy=required 但目标仓未声明测试路径或 CI 引用',
716
+ };
717
+ }
718
+ export function lintTaskFile(filePath, cwd) {
719
+ const abs = path.resolve(cwd, filePath);
720
+ if (!existsSync(abs))
721
+ fail(`task 文件不存在: ${filePath}`);
722
+ const content = readFileSync(abs, 'utf8');
723
+ const lines = content.split('\n');
724
+ const errors = [];
725
+ const warnings = [];
726
+ const meta = parseHarnessMeta(content);
727
+ if (!content.includes('## Harness 元信息')) {
728
+ errors.push({ rule: 'E1', message: '缺 ## Harness 元信息 节' });
729
+ }
730
+ else if (!meta.task_slug) {
731
+ errors.push({ rule: 'E1', message: 'Harness 元信息表缺 task_slug' });
732
+ }
733
+ const statusIdx = lines.findIndex((l) => STATUS_RE.test(l));
734
+ if (statusIdx === -1) {
735
+ errors.push({ rule: 'E2', message: '缺 > **状态** 行' });
736
+ }
737
+ else {
738
+ const token = lines[statusIdx].match(STATUS_RE)?.[1]?.toLowerCase();
739
+ if (token && !KNOWN_STATUS_TOKENS.has(token)) {
740
+ warnings.push({ rule: 'W1', message: `状态 token 不在已知词表: ${token}`, line: statusIdx + 1 });
741
+ }
742
+ }
743
+ const acceptance = extractSection(content, '## 验收标准', '\n##');
744
+ if (!acceptance) {
745
+ errors.push({ rule: 'E3', message: '缺 ## 验收标准 节' });
746
+ }
747
+ else if (!CHECKBOX_RE.test(acceptance)) {
748
+ errors.push({ rule: 'E3', message: '## 验收标准 节内无任何勾选项(- [ ] / - [x])' });
749
+ }
750
+ if (!lines.some((l) => /^#{2,4}\s.*(失败路径|failure_paths)/i.test(l))) {
751
+ errors.push({ rule: 'E4', message: '缺失败路径节(## 失败路径 或 failure_paths)' });
752
+ }
753
+ const selfCheck = extractSection(content, '### 自检结论', '\n##');
754
+ if (!selfCheck) {
755
+ errors.push({ rule: 'E5', message: '缺 ### 自检结论 节' });
756
+ }
757
+ else {
758
+ const substantive = selfCheck
759
+ .split('\n')
760
+ .slice(1)
761
+ .map((l) => l.trim())
762
+ .filter(Boolean)
763
+ .filter((l) => !PLACEHOLDER_RE.test(l));
764
+ if (substantive.length === 0) {
765
+ warnings.push({ rule: 'W3', message: '自检结论为占位符(draft 期合法 · close 前须回填)' });
766
+ }
767
+ }
768
+ lines.forEach((l, i) => {
769
+ if (ABS_PATH_RE.test(l)) {
770
+ errors.push({ rule: 'E6', message: `绝对本机路径: ${l.trim().slice(0, 100)}`, line: i + 1 });
771
+ }
772
+ });
773
+ if (meta.task_slug) {
774
+ const fileSlug = extractTaskSlug(abs);
775
+ if (normalizeSlug(meta.task_slug) !== normalizeSlug(fileSlug)) {
776
+ errors.push({
777
+ rule: 'E7',
778
+ message: `slug 不一致: 文件名 ${fileSlug} ≠ 元信息 task_slug ${meta.task_slug}`,
779
+ });
780
+ }
781
+ }
782
+ // E8(K2 · 与 close_wiki_delta 对齐 · 20 审裁定直接 error 不灰度 · 无 draft 豁免):
783
+ // 仅查存在性——词表(path|none|n/a)与路径存在性仍归 close 闸与 lint-wiki-delta --strict;
784
+ // 错节场景(字段写在其他节)文案指向正确节名并带行号(与 wiki_delta_wrong_section 同源 helper)。
785
+ if (content.includes('## Harness 元信息') && !(meta.wiki_delta ?? '').trim()) {
786
+ const wrong = findWikiDeltaOutsideMetaSection(content);
787
+ errors.push({
788
+ rule: 'E8',
789
+ message: wrong
790
+ ? `缺 wiki_delta 行:字段写在「${wrong.section}」节 L${wrong.line} · 须在 ## Harness 元信息 表格内(path|none|n/a)`
791
+ : '缺 wiki_delta 行(## Harness 元信息 表格内须含 wiki_delta: path|none|n/a · 与 close_wiki_delta 对齐)',
792
+ ...(wrong ? { line: wrong.line } : {}),
793
+ });
794
+ }
795
+ if (!content.includes('### 人工闸')) {
796
+ warnings.push({ rule: 'W2', message: '缺 ### 人工闸 节(轻量 task 可忽略本提醒)' });
797
+ }
798
+ if (!/^###\s+R0(\b|[^\d]|$)/m.test(content) && !/^#{2,3}\s+.*思考轮/m.test(content)) {
799
+ warnings.push({
800
+ rule: 'W4',
801
+ message: '无思考轮节(SPEC 承载 / bugfix 轨合法豁免 · 有节则查 R0–R5 与控制表)',
802
+ });
803
+ }
804
+ return {
805
+ ok: errors.length === 0,
806
+ errors,
807
+ warnings,
808
+ file: abs,
809
+ slug: meta.task_slug ?? extractTaskSlug(abs),
810
+ };
811
+ }
812
+ //# sourceMappingURL=cli-checks.js.map