@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/CHANGELOG.md +31 -1
- package/docs/ONBOARDING.md +15 -1
- package/docs/USER_GUIDE_v1.0_zh.md +14 -2
- package/docs/rethink/2026-07-mechanization-rate/01_big_directions.md +1 -0
- package/docs/spec/SPEC-lifecycle-and-verify-lint_v1.md +174 -0
- package/docs/spec/SPEC-spec-reviews-retention-gate_v1.md +192 -0
- package/harness/lifecycle.yaml +83 -0
- package/harness/prompts/20-spec-audit.md +1 -0
- package/lib/cli.js +100 -24
- package/lib/lifecycle.js +121 -0
- package/lib/task-meta.js +101 -0
- package/lib/verify.js +164 -5
- package/package.json +1 -1
- package/schema/lifecycle.v1.schema.json +63 -0
- package/schema/verify_result.v1.schema.json +10 -0
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
|
|
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 {
|
|
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
|
|
392
|
-
|
|
393
|
-
--
|
|
394
|
-
--
|
|
395
|
-
--
|
|
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:
|
|
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
|
-
|
|
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
|
|
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]`);
|
package/lib/lifecycle.js
ADDED
|
@@ -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 {
|
|
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
|
|
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
|
}
|