@cyning/harness 2.6.0 → 2.8.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/lib/cli.js CHANGED
@@ -41,7 +41,8 @@ function usage(version = 'unknown') {
41
41
  npx @cyning/harness check [--target PATH]
42
42
  npx @cyning/harness audit [--target PATH] [--task FILE]
43
43
  npx @cyning/harness gate-check [--target PATH] [--task FILE] [--graph] [--json]
44
- npx @cyning/harness verify [--target PATH] [--task FILE] [--graph] [--json] [--agent-hint] [--workspace-root PATH]
44
+ npx @cyning/harness verify [--target PATH] [--task FILE | --spec FILE] [--graph] [--json] [--agent-hint] [--workspace-root PATH]
45
+ npx @cyning/harness lifecycle show [--json]
45
46
  npx @cyning/harness sync index [--target PATH]
46
47
  npx @cyning/harness task check --file PATH [--no-circular] [--registry DIR]...
47
48
  npx @cyning/harness task close --file PATH [--target PATH] [--yes] [--allow-unchecked]
@@ -61,7 +62,8 @@ function usage(version = 'unknown') {
61
62
  check 读 manifest.json · 对比当前包版本是否有可升级版本
62
63
  audit ICVO 机械审计:复用 gate-check · D5 测试声明检查 · S5 git-clean 提醒
63
64
  gate-check 调用 wizard/gate-check.sh(业务仓无需 clone 即可用)
64
- verify 30 前聚合:gate-check + audit D5 + S5 warn + 可选 --graph
65
+ verify 30 前聚合(--task)或 SPEC→00 审查文闸(--spec · v2.8+)
66
+ lifecycle 只读展示 harness/lifecycle.yaml(状态/转移/守卫 · v2.7+)
65
67
  sync index 生成 .cyning-harness/invoke_index.json
66
68
  task check 校验 task.harness.v1.json sidecar · --no-circular 检测 depends_on 环
67
69
  task close 受闸归档:5 项机械校验(invoke/自检结论/勾选/slug/状态)后 mv active→done(v2.2+)
@@ -128,6 +130,10 @@ export async function runCli(argv) {
128
130
  await cmdVerify(rest);
129
131
  return;
130
132
  }
133
+ if (cmd === 'lifecycle') {
134
+ await cmdLifecycle(rest);
135
+ return;
136
+ }
131
137
  if (cmd === 'sync') {
132
138
  await cmdSync(rest);
133
139
  return;
@@ -384,15 +390,21 @@ async function cmdGateCheck(args) {
384
390
 
385
391
  async function cmdVerify(args) {
386
392
  // D3 · implemented in lib/verify.js
387
- const { verifyTarget, buildVerifyHandoff, formatAgentHint } = await import('./verify.js');
393
+ const {
394
+ verifyTarget,
395
+ verifySpecTarget,
396
+ buildVerifyHandoff,
397
+ buildSpecVerifyHandoff,
398
+ formatAgentHint,
399
+ } = await import('./verify.js');
388
400
  if (args.includes('--help') || args.includes('-h')) {
389
- console.log(`用法: npx @cyning/harness verify [--target PATH] [--task FILE] [--graph] [--json] [--agent-hint] [--workspace-root PATH]
401
+ console.log(`用法: npx @cyning/harness verify [--target PATH] [--task FILE | --spec FILE] [--graph] [--json] [--agent-hint] [--workspace-root PATH] [--allow-no-review] [--allow-lint-fail] [--allow-no-spec-review]
390
402
 
391
- 无 --task 时:扫描 docs/tasks/active/task_*.md 并逐项列出闸表(同 gate-check)。
392
- 任一 task 阻塞则 VERIFY: BLOCKED · exit 2;全部可 30 VERIFY: PASS
393
- --json:输出 Agent handoff JSON(may_start_30 · entry_invoke_30 · review_path 等)。
394
- --agent-hint:人类可读 handoff 摘要(可与 --json 同用)。
395
- --workspace-root:解析 task 中 Projects/ 前缀的 entry_invoke 路径。
403
+ 无 --task/--spec:扫描 docs/tasks/active/task_*.md 并逐项列出闸表(同 gate-check);不跑 task lint。
404
+ --task:30 前聚合(gate + audit + reviews + lint WARN)· --allow-no-review / --allow-lint-fail
405
+ --spec:SPEC→00 前查审查文存在性(v2.8+ · --task 互斥)· --allow-no-spec-review。
406
+ 推荐:--spec <产品仓 SPEC> --workspace-root <含 docs/harness/reviews 的工作区根>
407
+ --json / --agent-hint:Agent handoff。
396
408
  `);
397
409
  return;
398
410
  }
@@ -402,13 +414,25 @@ async function cmdVerify(args) {
402
414
  rest = r1;
403
415
  const { value: taskFile, rest: r2 } = takeOption(rest, '--task');
404
416
  rest = r2;
405
- const { value: workspaceRoot, rest: r3 } = takeOption(rest, '--workspace-root');
417
+ const { value: specFile, rest: r3 } = takeOption(rest, '--spec');
406
418
  rest = r3;
419
+ const { value: workspaceRoot, rest: r4 } = takeOption(rest, '--workspace-root');
420
+ rest = r4;
407
421
  const graph = rest.includes('--graph');
408
422
  const json = rest.includes('--json');
409
423
  const agentHint = rest.includes('--agent-hint');
410
424
  const allowNoReview = rest.includes('--allow-no-review');
411
- rest = rest.filter((a) => a !== '--graph' && a !== '--json' && a !== '--agent-hint' && a !== '--allow-no-review');
425
+ const allowLintFail = rest.includes('--allow-lint-fail');
426
+ const allowNoSpecReview = rest.includes('--allow-no-spec-review');
427
+ rest = rest.filter(
428
+ (a) =>
429
+ a !== '--graph' &&
430
+ a !== '--json' &&
431
+ a !== '--agent-hint' &&
432
+ a !== '--allow-no-review' &&
433
+ a !== '--allow-lint-fail' &&
434
+ a !== '--allow-no-spec-review',
435
+ );
412
436
 
413
437
  if (rest.length > 0) {
414
438
  const err = new Error(`verify 未知参数: ${rest.join(' ')}`);
@@ -416,8 +440,37 @@ async function cmdVerify(args) {
416
440
  throw err;
417
441
  }
418
442
 
443
+ if (taskFile && specFile) {
444
+ const err = new Error('verify:--task 与 --spec 互斥');
445
+ err.exitCode = 1;
446
+ throw err;
447
+ }
448
+
419
449
  const target = resolveTarget(process.cwd(), targetArg);
420
- const result = verifyTarget(target, { taskFile, graph, allowNoReview });
450
+ const ws = workspaceRoot ? path.resolve(workspaceRoot) : undefined;
451
+
452
+ let result;
453
+ let handoff;
454
+ let label;
455
+
456
+ if (specFile) {
457
+ result = verifySpecTarget(target, { specFile, workspaceRoot: ws, allowNoSpecReview });
458
+ handoff = buildSpecVerifyHandoff(target, {
459
+ specFile,
460
+ workspaceRoot: ws,
461
+ allowNoSpecReview,
462
+ });
463
+ label = path.basename(specFile);
464
+ } else {
465
+ result = verifyTarget(target, { taskFile, graph, allowNoReview, allowLintFail });
466
+ handoff = buildVerifyHandoff(target, {
467
+ taskFile,
468
+ workspaceRoot: ws,
469
+ allowNoReview,
470
+ allowLintFail,
471
+ });
472
+ label = taskFile ? path.basename(taskFile) : null;
473
+ }
421
474
 
422
475
  // forward gate-check/audit/graph stdout if any(--json 时仍透传机械闸表)
423
476
  if (result.stdout && !json) {
@@ -427,12 +480,6 @@ async function cmdVerify(args) {
427
480
  process.stderr.write(result.stdout);
428
481
  }
429
482
 
430
- const handoff = buildVerifyHandoff(target, {
431
- taskFile,
432
- workspaceRoot: workspaceRoot ? path.resolve(workspaceRoot) : undefined,
433
- allowNoReview,
434
- });
435
-
436
483
  if (json) {
437
484
  console.log(JSON.stringify(handoff, null, 2));
438
485
  }
@@ -440,15 +487,13 @@ async function cmdVerify(args) {
440
487
  console.log(formatAgentHint(handoff));
441
488
  }
442
489
 
490
+ const summary = label
491
+ ? `VERIFY: ${result.ok ? 'PASS' : 'BLOCKED'}${result.reason ? ` · ${result.reason}` : ''} · ${label}`
492
+ : `VERIFY: ${result.ok ? 'PASS' : 'BLOCKED'}${result.reason ? ` · ${result.reason}` : ''}`;
493
+
443
494
  if (!json && !agentHint) {
444
- const summary = taskFile
445
- ? `VERIFY: ${result.ok ? 'PASS' : 'BLOCKED'}${result.reason ? ` · ${result.reason}` : ''} · ${path.basename(taskFile)}`
446
- : `VERIFY: ${result.ok ? 'PASS' : 'BLOCKED'}${result.reason ? ` · ${result.reason}` : ''}`;
447
495
  console.log(summary);
448
496
  } else if (json || agentHint) {
449
- const summary = taskFile
450
- ? `VERIFY: ${result.ok ? 'PASS' : 'BLOCKED'}${result.reason ? ` · ${result.reason}` : ''} · ${path.basename(taskFile)}`
451
- : `VERIFY: ${result.ok ? 'PASS' : 'BLOCKED'}${result.reason ? ` · ${result.reason}` : ''}`;
452
497
  console.error(summary);
453
498
  }
454
499
 
@@ -458,6 +503,37 @@ async function cmdVerify(args) {
458
503
  }
459
504
  }
460
505
 
506
+ async function cmdLifecycle(args) {
507
+ const { loadLifecycle, formatLifecycleShow } = await import('./lifecycle.js');
508
+ const [sub, ...rest] = args;
509
+ if (!sub || sub === '--help' || sub === '-h' || args.includes('--help') || args.includes('-h')) {
510
+ console.log(`用法: npx @cyning/harness lifecycle show [--json]
511
+
512
+ 只读展示产品包 harness/lifecycle.yaml(状态 / 转移 / 守卫登记)。
513
+ 不做转移执行、不写盘(方向二文档先行 · v2.7+)。
514
+ `);
515
+ return;
516
+ }
517
+ if (sub !== 'show') {
518
+ const err = new Error(`lifecycle 子命令未知: ${sub}\n用法: lifecycle show [--json]`);
519
+ err.exitCode = 1;
520
+ throw err;
521
+ }
522
+ const json = rest.includes('--json');
523
+ const unknown = rest.filter((a) => a !== '--json');
524
+ if (unknown.length > 0) {
525
+ const err = new Error(`lifecycle show 未知参数: ${unknown.join(' ')}`);
526
+ err.exitCode = 1;
527
+ throw err;
528
+ }
529
+ const { data } = loadLifecycle();
530
+ if (json) {
531
+ console.log(JSON.stringify(data, null, 2));
532
+ } else {
533
+ console.log(formatLifecycleShow(data));
534
+ }
535
+ }
536
+
461
537
  async function cmdSync(args) {
462
538
  if (args.includes('--help') || args.includes('-h')) {
463
539
  console.log(`用法: npx @cyning/harness sync index [--target PATH]`);
@@ -0,0 +1,121 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import yaml from 'js-yaml';
4
+ import { resolveHarnessRoot } from './paths.js';
5
+
6
+ const SEVERITIES = new Set(['block', 'warn']);
7
+
8
+ /**
9
+ * 轻量结构校验(不引入 Ajv):对齐 schema/lifecycle.v1.schema.json 必填约束。
10
+ * @returns {{ ok: boolean, errors: string[] }}
11
+ */
12
+ export function validateLifecycle(data) {
13
+ const errors = [];
14
+ if (!data || typeof data !== 'object') {
15
+ return { ok: false, errors: ['lifecycle 根须为 object'] };
16
+ }
17
+ if (typeof data.version !== 'string' || !data.version.trim()) {
18
+ errors.push('缺 version(string)');
19
+ }
20
+ if (!Array.isArray(data.states) || data.states.length === 0) {
21
+ errors.push('缺 states[] 或为空');
22
+ } else {
23
+ data.states.forEach((s, i) => {
24
+ if (!s || typeof s.id !== 'string' || !s.id.trim()) {
25
+ errors.push(`states[${i}] 缺 id`);
26
+ }
27
+ });
28
+ }
29
+ if (!Array.isArray(data.transitions) || data.transitions.length === 0) {
30
+ errors.push('缺 transitions[] 或为空');
31
+ } else {
32
+ data.transitions.forEach((t, i) => {
33
+ if (!t || typeof t.id !== 'string' || !t.id.trim()) {
34
+ errors.push(`transitions[${i}] 缺 id`);
35
+ }
36
+ if (!Array.isArray(t?.from) || t.from.length === 0) {
37
+ errors.push(`transitions[${i}] 缺 from[]`);
38
+ }
39
+ if (typeof t?.to !== 'string' || !t.to.trim()) {
40
+ errors.push(`transitions[${i}] 缺 to`);
41
+ }
42
+ if (!Array.isArray(t?.guards)) {
43
+ errors.push(`transitions[${i}] 缺 guards[]`);
44
+ } else {
45
+ t.guards.forEach((g, j) => {
46
+ if (!g || typeof g.id !== 'string') {
47
+ errors.push(`transitions[${i}].guards[${j}] 缺 id`);
48
+ }
49
+ if (!g || typeof g.command_or_check !== 'string') {
50
+ errors.push(`transitions[${i}].guards[${j}] 缺 command_or_check`);
51
+ }
52
+ if (!g || !SEVERITIES.has(g.severity)) {
53
+ errors.push(
54
+ `transitions[${i}].guards[${j}] severity 须为 block|warn(当前: ${g?.severity})`,
55
+ );
56
+ }
57
+ });
58
+ }
59
+ });
60
+ }
61
+ return { ok: errors.length === 0, errors };
62
+ }
63
+
64
+ /**
65
+ * 加载并校验 lifecycle.yaml。
66
+ * @param {{ harnessRoot?: string, filePath?: string }} [options]
67
+ */
68
+ export function loadLifecycle(options = {}) {
69
+ const harnessRoot = options.harnessRoot ?? resolveHarnessRoot();
70
+ const filePath =
71
+ options.filePath ?? path.join(harnessRoot, 'harness', 'lifecycle.yaml');
72
+
73
+ if (!fs.existsSync(filePath)) {
74
+ const e = new Error(`lifecycle.yaml 不存在: ${filePath}`);
75
+ e.exitCode = 1;
76
+ throw e;
77
+ }
78
+
79
+ let data;
80
+ try {
81
+ data = yaml.load(fs.readFileSync(filePath, 'utf8'));
82
+ } catch (err) {
83
+ const e = new Error(`lifecycle.yaml 解析失败: ${err.message}`);
84
+ e.exitCode = 1;
85
+ throw e;
86
+ }
87
+
88
+ const v = validateLifecycle(data);
89
+ if (!v.ok) {
90
+ const e = new Error(`lifecycle.yaml 校验失败:\n - ${v.errors.join('\n - ')}`);
91
+ e.exitCode = 1;
92
+ e.validationErrors = v.errors;
93
+ throw e;
94
+ }
95
+
96
+ return { data, filePath, harnessRoot };
97
+ }
98
+
99
+ /**
100
+ * 人读表。
101
+ */
102
+ export function formatLifecycleShow(data) {
103
+ const lines = [
104
+ `lifecycle v${data.version}`,
105
+ '',
106
+ '## states',
107
+ ...data.states.map((s) => `- ${s.id}${s.note ? ` · ${s.note}` : ''}`),
108
+ '',
109
+ '## transitions',
110
+ ];
111
+ for (const t of data.transitions) {
112
+ lines.push(`### ${t.id} · ${t.from.join('|')} → ${t.to}${t.hat ? ` · hat=${t.hat}` : ''}`);
113
+ if (t.description) lines.push(` ${t.description}`);
114
+ for (const g of t.guards) {
115
+ const allow = g.allow_flag ? ` · allow=${g.allow_flag}` : '';
116
+ lines.push(` - [${g.severity}] ${g.id}: ${g.command_or_check}${allow}`);
117
+ }
118
+ lines.push('');
119
+ }
120
+ return lines.join('\n').trimEnd();
121
+ }
package/lib/task-meta.js CHANGED
@@ -157,6 +157,107 @@ export function findReviewPath(target, taskFile) {
157
157
  return findReview(target, taskFile).latest;
158
158
  }
159
159
 
160
+ /** 剥离文件名末尾 `_v\d+` */
161
+ function stripVerSuffix(s) {
162
+ return String(s).replace(/_v\d+$/, '');
163
+ }
164
+
165
+ /**
166
+ * 自 SPEC 路径/正文解析 slug(优先表 `spec_slug`)。
167
+ */
168
+ export function extractSpecSlug(specFile, content) {
169
+ const meta = content ? parseHarnessMeta(content) : {};
170
+ if (meta.spec_slug) return meta.spec_slug;
171
+
172
+ let base = path.basename(specFile, '.md');
173
+ base = base.replace(/^SPEC[-_]/i, '');
174
+ base = stripVerSuffix(base);
175
+ return base;
176
+ }
177
+
178
+ /**
179
+ * bugfix / 显式跳过 10-spec 审计:不要求 SPEC 审查文。
180
+ * 只认元信息表 / 文首 track 行——避免命中正文里对规则的说明文字。
181
+ */
182
+ export function shouldSkipSpecAudit(content) {
183
+ if (!content) return false;
184
+ const meta = parseHarnessMeta(content);
185
+ if (String(meta.skip_spec_audit || '').toLowerCase() === 'true') return true;
186
+ if (String(meta.track || '').toLowerCase() === 'bugfix') return true;
187
+ if (/^>\s*\*\*track\*\*[::]\s*`?bugfix\b/im.test(content)) return true;
188
+ return false;
189
+ }
190
+
191
+ /**
192
+ * 查找 SPEC 的 R<n> 审查文(verify --spec · N3)。
193
+ * 兼容命名:
194
+ * 1. spec_<slug>_audit_R<n>_*.md(推荐)
195
+ * 2. spec_<slug>_ACCEPT_R<n>_*.md
196
+ * 3. task_<slug>_spec_ACCEPT_R<n>_*.md
197
+ * 在 target 与可选 workspaceRoot 的 docs/harness/reviews/ 下搜索。
198
+ */
199
+ export function findSpecReview(specFile, options = {}) {
200
+ const { target = process.cwd(), workspaceRoot } = options;
201
+ const empty = { found: false, latest: null, rounds: [], matched_pattern: null };
202
+
203
+ const absSpec = path.isAbsolute(specFile)
204
+ ? specFile
205
+ : path.resolve(target, specFile);
206
+ if (!fs.existsSync(absSpec)) return empty;
207
+
208
+ const content = fs.readFileSync(absSpec, 'utf8');
209
+ const slugNorm = normalizeSlug(stripVerSuffix(extractSpecSlug(absSpec, content)));
210
+
211
+ const roots = [];
212
+ const pushRoot = (root) => {
213
+ if (!root) return;
214
+ const abs = path.resolve(root);
215
+ if (!roots.includes(abs)) roots.push(abs);
216
+ };
217
+ pushRoot(target);
218
+ pushRoot(workspaceRoot);
219
+
220
+ const PATTERNS = [
221
+ { id: 'spec_audit', re: /^spec_(.+?)_audit_R(\d+)_.*\.md$/i },
222
+ { id: 'spec_ACCEPT', re: /^spec_(.+?)_ACCEPT_R(\d+)_.*\.md$/i },
223
+ { id: 'task_spec_ACCEPT', re: /^task_(.+?)_spec_ACCEPT_R(\d+)_.*\.md$/i },
224
+ ];
225
+
226
+ const matches = [];
227
+ for (const root of roots) {
228
+ const reviewsDir = path.join(root, 'docs/harness/reviews');
229
+ if (!fs.existsSync(reviewsDir)) continue;
230
+ for (const name of fs.readdirSync(reviewsDir)) {
231
+ for (const pat of PATTERNS) {
232
+ const m = name.match(pat.re);
233
+ if (!m) continue;
234
+ if (normalizeSlug(stripVerSuffix(m[1])) !== slugNorm) continue;
235
+ matches.push({
236
+ name,
237
+ round: parseInt(m[2], 10),
238
+ mtime: fs.statSync(path.join(reviewsDir, name)).mtimeMs,
239
+ pattern: pat.id,
240
+ root,
241
+ rel: path.join('docs/harness/reviews', name).replace(/\\/g, '/'),
242
+ });
243
+ break;
244
+ }
245
+ }
246
+ }
247
+
248
+ if (matches.length === 0) return empty;
249
+
250
+ matches.sort((a, b) => b.round - a.round || b.mtime - a.mtime);
251
+ const latest = matches[0];
252
+ return {
253
+ found: true,
254
+ latest: latest.rel,
255
+ rounds: [...new Set(matches.map((x) => x.round))].sort((a, b) => a - b),
256
+ matched_pattern: latest.pattern,
257
+ reviews_root: latest.root,
258
+ };
259
+ }
260
+
160
261
  /**
161
262
  * 聚合单 task 的 Agent handoff 结果。
162
263
  */
package/lib/verify.js CHANGED
@@ -3,13 +3,22 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { resolveHarnessRootForTarget, wizardScript } from './paths.js';
5
5
  import { auditTarget } from './audit.js';
6
- import { buildTaskHandoff, findReview, listActiveTasks } from './task-meta.js';
6
+ import {
7
+ buildTaskHandoff,
8
+ extractSpecSlug,
9
+ findReview,
10
+ findSpecReview,
11
+ listActiveTasks,
12
+ shouldSkipSpecAudit,
13
+ } from './task-meta.js';
14
+ import { lintTaskFile } from './task-lint.js';
7
15
 
8
16
  /**
9
- * 30 前聚合验证:gate-check + audit D5(仅 --task)+ reviews 留档闸(仅 --task)+ S5 git-clean warn + 可选 --graph
17
+ * 30 前聚合验证:gate-check + audit D5(仅 --task)+ reviews 留档闸(仅 --task
18
+ * + task lint WARN(仅 --task · v2.7+)+ S5 git-clean warn + 可选 --graph
10
19
  */
11
20
  export function verifyTarget(target, options = {}) {
12
- const { taskFile, graph, allowNoReview = false } = options;
21
+ const { taskFile, graph, allowNoReview = false, allowLintFail = false } = options;
13
22
  const harnessRoot = resolveHarnessRootForTarget(target);
14
23
 
15
24
  // 1. gate-check(含 --graph)
@@ -49,12 +58,22 @@ export function verifyTarget(target, options = {}) {
49
58
  };
50
59
  }
51
60
  }
61
+
62
+ // 3b. task lint(N2 · v2.7+):severity=warn · 不改 exit / may_start_30
63
+ const lint = runTaskLintForVerify(target, taskFile);
64
+ if (!lint.ok) {
65
+ if (allowLintFail) {
66
+ gateResult.stdout += `WARN: task lint FAIL suppressed(--allow-lint-fail · 留痕)\n`;
67
+ } else {
68
+ const rules = lint.errors.map((e) => e.rule).join(',');
69
+ gateResult.stdout += `WARN: task lint FAIL · ${rules || 'errors'}(不挡 may_start_30 · 修结构或 --allow-lint-fail)\n`;
70
+ }
71
+ }
52
72
  }
53
73
 
54
74
  // 4. S5 git-clean:warn 不挡
55
75
  const gitResult = runGitCleanCheck(target);
56
76
  if (gitResult.dirty) {
57
- // 把 warn 拼到 stdout 里让 CLI 透传
58
77
  const warn = `WARN: 工作区未 clean(S5):建议 commit 后再执行 apply\n`;
59
78
  gateResult.stdout += warn;
60
79
  }
@@ -66,6 +85,129 @@ export function verifyTarget(target, options = {}) {
66
85
  };
67
86
  }
68
87
 
88
+ function runTaskLintForVerify(target, taskFile) {
89
+ try {
90
+ return lintTaskFile(taskFile, { cwd: target });
91
+ } catch (err) {
92
+ return {
93
+ ok: false,
94
+ errors: [{ rule: 'E_IO', message: err.message }],
95
+ warnings: [],
96
+ };
97
+ }
98
+ }
99
+
100
+ /**
101
+ * SPEC→00 前验证(N3 · v2.8+):仅查 SPEC 审查文存在性。
102
+ * 不跑 gate-check / audit D5 / task lint(与 --task 模式分离)。
103
+ */
104
+ export function verifySpecTarget(target, options = {}) {
105
+ const { specFile, workspaceRoot, allowNoSpecReview = false } = options;
106
+ let stdout = '';
107
+
108
+ if (!specFile) {
109
+ return {
110
+ ok: false,
111
+ exitCode: 1,
112
+ reason: 'verify --spec 须指定 SPEC 文件路径',
113
+ stdout,
114
+ };
115
+ }
116
+
117
+ const absSpec = path.isAbsolute(specFile)
118
+ ? specFile
119
+ : path.resolve(target, specFile);
120
+ if (!fs.existsSync(absSpec)) {
121
+ return {
122
+ ok: false,
123
+ exitCode: 1,
124
+ reason: `SPEC 文件不存在: ${specFile}`,
125
+ stdout,
126
+ };
127
+ }
128
+
129
+ const content = fs.readFileSync(absSpec, 'utf8');
130
+ if (shouldSkipSpecAudit(content)) {
131
+ stdout += `INFO: skip SPEC review gate(bugfix / skip_spec_audit)\n`;
132
+ return { ok: true, exitCode: 0, stdout, skipped: true };
133
+ }
134
+
135
+ const review = findSpecReview(absSpec, { target, workspaceRoot });
136
+ if (!review.found) {
137
+ if (allowNoSpecReview) {
138
+ stdout += `WARN: missing SPEC R<n> review(--allow-no-spec-review 豁免 · 留痕)\n`;
139
+ return { ok: true, exitCode: 0, stdout, skipped: false };
140
+ }
141
+ return {
142
+ ok: false,
143
+ exitCode: 2,
144
+ reason:
145
+ 'missing SPEC R<n> review(docs/harness/reviews/ 无 spec_*_audit_R / *_ACCEPT_R · 20-spec-audit 或 --allow-no-spec-review)',
146
+ stdout,
147
+ };
148
+ }
149
+
150
+ return { ok: true, exitCode: 0, stdout, skipped: false };
151
+ }
152
+
153
+ /** SPEC 模式 handoff(verify --spec --json) */
154
+ export function buildSpecVerifyHandoff(target, options = {}) {
155
+ const { specFile, workspaceRoot, allowNoSpecReview = false } = options;
156
+ const absSpec = path.isAbsolute(specFile)
157
+ ? specFile
158
+ : path.resolve(target, specFile);
159
+ const content = fs.existsSync(absSpec) ? fs.readFileSync(absSpec, 'utf8') : '';
160
+ const skipped = shouldSkipSpecAudit(content);
161
+ const review = skipped
162
+ ? { found: false, latest: null, rounds: [], matched_pattern: null }
163
+ : findSpecReview(absSpec, { target, workspaceRoot });
164
+
165
+ const reviewOk = skipped || review.found || allowNoSpecReview;
166
+ let blocked_reason = null;
167
+ if (!reviewOk) {
168
+ blocked_reason = 'missing SPEC R<n> review';
169
+ }
170
+
171
+ return {
172
+ schema_version: '1',
173
+ mode: 'spec',
174
+ verify_ok: reviewOk,
175
+ may_start_00: reviewOk,
176
+ spec: path.basename(specFile),
177
+ spec_path: specFile.replace(/\\/g, '/'),
178
+ spec_slug: content ? extractSpecSlug(absSpec, content) : null,
179
+ spec_review_found: Boolean(review.found),
180
+ spec_review_latest: review.latest,
181
+ spec_review_rounds: review.rounds ?? [],
182
+ spec_review_skipped: skipped,
183
+ spec_review_suppressed: Boolean(allowNoSpecReview && !review.found && !skipped),
184
+ matched_pattern: review.matched_pattern ?? null,
185
+ blocked_reason,
186
+ next_hat: reviewOk ? '00' : null,
187
+ };
188
+ }
189
+
190
+ /** 规范化 lint 结果供 handoff */
191
+ export function lintSnapshotForHandoff(target, taskFile, { allowLintFail = false } = {}) {
192
+ if (!taskFile) return null;
193
+ let result;
194
+ try {
195
+ result = lintTaskFile(taskFile, { cwd: target });
196
+ } catch (err) {
197
+ result = {
198
+ ok: false,
199
+ errors: [{ rule: 'E_IO', message: err.message }],
200
+ warnings: [],
201
+ };
202
+ }
203
+ return {
204
+ ok: result.ok,
205
+ errors: result.errors ?? [],
206
+ warnings: result.warnings ?? [],
207
+ suppressed: Boolean(allowLintFail && !result.ok),
208
+ };
209
+ }
210
+
69
211
  function runGateCheck(target, taskFile, graph, harnessRoot) {
70
212
  const script = wizardScript(harnessRoot, 'gate-check.sh');
71
213
  const args = [script, '--target', target];
@@ -171,13 +313,14 @@ function runGitCleanCheck(target) {
171
313
  * 构建 verify --json / --agent-hint 的 Agent handoff 载荷。
172
314
  */
173
315
  export function buildVerifyHandoff(target, options = {}) {
174
- const { taskFile, workspaceRoot, allowNoReview = false } = options;
316
+ const { taskFile, workspaceRoot, allowNoReview = false, allowLintFail = false } = options;
175
317
  const handoffOpts = { workspaceRoot };
176
318
 
177
319
  if (taskFile) {
178
320
  const handoff = buildTaskHandoff(target, taskFile, handoffOpts);
179
321
  const reviewOk = handoff.review_found || allowNoReview;
180
322
  const verifyOk = handoff.may_start_30 && reviewOk;
323
+ const lint = lintSnapshotForHandoff(target, taskFile, { allowLintFail });
181
324
  return {
182
325
  schema_version: '1',
183
326
  verify_ok: verifyOk,
@@ -185,6 +328,7 @@ export function buildVerifyHandoff(target, options = {}) {
185
328
  may_start_30: verifyOk,
186
329
  blocked_reason: handoff.blocked_reason ?? (reviewOk ? null : 'missing R<n> review'),
187
330
  next_hat: verifyOk ? '30' : null,
331
+ lint,
188
332
  };
189
333
  }
190
334
 
@@ -212,6 +356,16 @@ export function buildVerifyHandoff(target, options = {}) {
212
356
  export function formatAgentHint(payload) {
213
357
  const lines = ['=== Harness verify · agent-hint ==='];
214
358
 
359
+ if (payload.mode === 'spec') {
360
+ lines.push(`spec: ${payload.spec}`);
361
+ lines.push(` may_start_00: ${payload.may_start_00}`);
362
+ if (payload.blocked_reason) lines.push(` blocked: ${payload.blocked_reason}`);
363
+ if (payload.spec_review_latest) lines.push(` review: ${payload.spec_review_latest}`);
364
+ if (payload.spec_review_skipped) lines.push(` review: skipped`);
365
+ if (payload.next_hat) lines.push(` next_hat: ${payload.next_hat}`);
366
+ return lines.join('\n').trimEnd();
367
+ }
368
+
215
369
  const items = payload.tasks ?? [payload];
216
370
  for (const item of items) {
217
371
  lines.push(`task: ${item.task}`);
@@ -231,6 +385,11 @@ export function formatAgentHint(payload) {
231
385
  if (item.next_hat) {
232
386
  lines.push(` next_hat: ${item.next_hat}`);
233
387
  }
388
+ if (item.lint) {
389
+ const n = item.lint.errors?.length ?? 0;
390
+ const tag = item.lint.suppressed ? 'suppressed' : item.lint.ok ? 'ok' : `FAIL(${n})`;
391
+ lines.push(` lint: ${tag}`);
392
+ }
234
393
  for (const w of item.warnings ?? []) {
235
394
  lines.push(` warn: ${w}`);
236
395
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyning/harness",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "cyning-harness discipline package · init / upgrade / check CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",