@cyning/harness 2.8.0 → 2.10.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/lifecycle.js CHANGED
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import yaml from 'js-yaml';
4
4
  import { resolveHarnessRoot } from './paths.js';
5
+ import { findReview, parseHumanGates } from './task-meta.js';
5
6
 
6
7
  const SEVERITIES = new Set(['block', 'warn']);
7
8
 
@@ -119,3 +120,224 @@ export function formatLifecycleShow(data) {
119
120
  }
120
121
  return lines.join('\n').trimEnd();
121
122
  }
123
+
124
+ /** 本波已接线的守卫(其余 unevaluated) */
125
+ const GUARD_ADAPTERS = {
126
+ 'HG-AUDIT-R1': evaluateHgAuditR1,
127
+ reviews_retention: evaluateReviewsRetention,
128
+ };
129
+
130
+ function findRepoRootFromTask(absTask) {
131
+ let cur = path.dirname(absTask);
132
+ while (true) {
133
+ if (path.basename(cur) === 'docs') return path.dirname(cur);
134
+ const parent = path.dirname(cur);
135
+ if (parent === cur) return null;
136
+ cur = parent;
137
+ }
138
+ }
139
+
140
+ function flagsAllow(flags, allowFlag) {
141
+ if (allowFlag === '--allow-no-review') return Boolean(flags.allowNoReview);
142
+ if (allowFlag === '--allow-lint-fail') return Boolean(flags.allowLintFail);
143
+ if (allowFlag === '--allow-no-spec-review') return Boolean(flags.allowNoSpecReview);
144
+ return false;
145
+ }
146
+
147
+ function evaluateHgAuditR1({ taskPath }) {
148
+ const content = fs.readFileSync(taskPath, 'utf8');
149
+ const gates = parseHumanGates(content);
150
+ const audit = gates.find((g) => g.id === 'HG-AUDIT-R1');
151
+ if (!audit) {
152
+ return { status: 'fail', detail: '闸表无 HG-AUDIT-R1 行' };
153
+ }
154
+ if (audit.status === 'approved') {
155
+ return { status: 'pass', detail: 'HG-AUDIT-R1=approved' };
156
+ }
157
+ return {
158
+ status: 'fail',
159
+ detail: `HG-AUDIT-R1=${audit.status}(须 approved)`,
160
+ };
161
+ }
162
+
163
+ function evaluateReviewsRetention({ taskPath, target, flags }) {
164
+ const review = findReview(target, taskPath);
165
+ if (review.found) {
166
+ return { status: 'pass', detail: review.latest };
167
+ }
168
+ if (flags.allowNoReview) {
169
+ return {
170
+ status: 'warn',
171
+ detail: 'missing R<n> review(--allow-no-review)',
172
+ };
173
+ }
174
+ return { status: 'fail', detail: 'missing R<n> review' };
175
+ }
176
+
177
+ /**
178
+ * 转移 dry-run(引擎最小骨架 · v2.10+)。
179
+ * 默认不写盘;无 --apply。
180
+ *
181
+ * @param {{
182
+ * transitionId: string,
183
+ * fromState: string,
184
+ * taskPath?: string,
185
+ * harnessRoot?: string,
186
+ * flags?: { allowNoReview?: boolean, allowLintFail?: boolean, allowNoSpecReview?: boolean },
187
+ * cwd?: string,
188
+ * }} options
189
+ */
190
+ export function dryRunTransition(options = {}) {
191
+ const {
192
+ transitionId,
193
+ fromState,
194
+ taskPath,
195
+ harnessRoot,
196
+ flags = {},
197
+ cwd = process.cwd(),
198
+ } = options;
199
+
200
+ const { data } = loadLifecycle({ harnessRoot });
201
+ const knownIds = data.transitions.map((t) => t.id);
202
+ const transition = data.transitions.find((t) => t.id === transitionId);
203
+
204
+ if (!transition) {
205
+ return {
206
+ engine: 'lifecycle-dry-run',
207
+ lifecycle_doc_version: data.version,
208
+ transition_id: transitionId ?? null,
209
+ from: fromState ?? null,
210
+ to: null,
211
+ hat: null,
212
+ structure_ok: false,
213
+ guards: [],
214
+ blocked: true,
215
+ unevaluated_count: 0,
216
+ detail: `未知 transition: ${transitionId}(已知: ${knownIds.join(', ')})`,
217
+ exitCode: 2,
218
+ };
219
+ }
220
+
221
+ if (!fromState || !transition.from.includes(fromState)) {
222
+ return {
223
+ engine: 'lifecycle-dry-run',
224
+ lifecycle_doc_version: data.version,
225
+ transition_id: transition.id,
226
+ from: fromState ?? null,
227
+ to: transition.to,
228
+ hat: transition.hat ?? null,
229
+ structure_ok: false,
230
+ guards: [],
231
+ blocked: true,
232
+ unevaluated_count: 0,
233
+ detail: `from "${fromState}" ∉ ${JSON.stringify(transition.from)}`,
234
+ exitCode: 2,
235
+ };
236
+ }
237
+
238
+ const absTask = taskPath
239
+ ? path.isAbsolute(taskPath)
240
+ ? taskPath
241
+ : path.resolve(cwd, taskPath)
242
+ : null;
243
+
244
+ if (taskPath && absTask && !fs.existsSync(absTask)) {
245
+ const e = new Error(`--task 不可读: ${taskPath}`);
246
+ e.exitCode = 1;
247
+ throw e;
248
+ }
249
+
250
+ const canEval = Boolean(absTask);
251
+ const target = absTask ? findRepoRootFromTask(absTask) ?? cwd : cwd;
252
+ const guards = [];
253
+
254
+ for (const g of transition.guards) {
255
+ const adapter = GUARD_ADAPTERS[g.id];
256
+ if (!canEval || !adapter) {
257
+ guards.push({
258
+ id: g.id,
259
+ severity: g.severity,
260
+ status: 'unevaluated',
261
+ detail: !canEval ? '无 --task · 未求值' : '本波未接线 adapter',
262
+ allow_flag: g.allow_flag ?? null,
263
+ });
264
+ continue;
265
+ }
266
+
267
+ const evaluated = adapter({
268
+ taskPath: absTask,
269
+ target,
270
+ flags,
271
+ guard: g,
272
+ cwd,
273
+ });
274
+
275
+ let status = evaluated.status;
276
+ let detail = evaluated.detail ?? null;
277
+
278
+ if (
279
+ status === 'fail' &&
280
+ g.allow_flag &&
281
+ flagsAllow(flags, g.allow_flag)
282
+ ) {
283
+ status = 'warn';
284
+ detail = `${detail ?? 'fail'}(${g.allow_flag} 豁免)`;
285
+ }
286
+
287
+ guards.push({
288
+ id: g.id,
289
+ severity: g.severity,
290
+ status,
291
+ detail,
292
+ allow_flag: g.allow_flag ?? null,
293
+ });
294
+ }
295
+
296
+ const unevaluated_count = guards.filter((g) => g.status === 'unevaluated').length;
297
+ const blocked = guards.some(
298
+ (g) => g.severity === 'block' && g.status === 'fail',
299
+ );
300
+
301
+ return {
302
+ engine: 'lifecycle-dry-run',
303
+ lifecycle_doc_version: data.version,
304
+ transition_id: transition.id,
305
+ from: fromState,
306
+ to: transition.to,
307
+ hat: transition.hat ?? null,
308
+ structure_ok: true,
309
+ guards,
310
+ blocked,
311
+ unevaluated_count,
312
+ exitCode: blocked ? 2 : 0,
313
+ };
314
+ }
315
+
316
+ /**
317
+ * 人读 dry-run 报告。
318
+ */
319
+ export function formatLifecycleDryRun(report) {
320
+ const lines = [
321
+ '=== Harness lifecycle dry-run ===',
322
+ `engine: ${report.engine}`,
323
+ `lifecycle: v${report.lifecycle_doc_version}`,
324
+ `transition: ${report.transition_id} · ${report.from} → ${report.to}${report.hat ? ` · hat=${report.hat}` : ''}`,
325
+ `structure_ok: ${report.structure_ok}`,
326
+ `blocked: ${report.blocked}`,
327
+ `unevaluated_count: ${report.unevaluated_count}`,
328
+ ];
329
+ if (report.detail) lines.push(`detail: ${report.detail}`);
330
+ if (report.unevaluated_count > 0) {
331
+ lines.push('WARN: 存在未求值守卫 · unevaluated ≠ pass(勿当作已通过)');
332
+ }
333
+ lines.push('', '## guards');
334
+ for (const g of report.guards ?? []) {
335
+ const d = g.detail ? ` · ${g.detail}` : '';
336
+ lines.push(`- [${g.severity}] ${g.id}: ${g.status}${d}`);
337
+ }
338
+ lines.push('');
339
+ lines.push(
340
+ '注: dry-run 为旁路资格报告 · 不替代 verify / gate-check 作为 30 硬闸 · 非 G7 runner',
341
+ );
342
+ return lines.join('\n');
343
+ }
package/lib/task-meta.js CHANGED
@@ -310,17 +310,25 @@ export function buildTaskHandoff(target, taskFile, options = {}) {
310
310
  }
311
311
 
312
312
  /**
313
- * 扫描 target 下 active task 列表。
313
+ * 扫描 target 下 active task 列表(N4 · v2.9+)。
314
+ * 双路径:docs/tasks/active ∪ docs/harness/tasks/active;
315
+ * 同 basename 优先 Starter;排序稳定。
314
316
  */
315
317
  export function listActiveTasks(target) {
316
- const activeDir = path.join(target, 'docs/tasks/active');
317
- if (!fs.existsSync(activeDir)) return [];
318
-
319
- return fs
320
- .readdirSync(activeDir)
321
- .filter((f) => f.startsWith('task_') && f.endsWith('.md'))
322
- .map((f) => path.join('docs/tasks/active', f).replace(/\\/g, '/'))
323
- .sort();
318
+ const dirs = ['docs/tasks/active', 'docs/harness/tasks/active'];
319
+ const byBasename = new Map();
320
+
321
+ for (const dir of dirs) {
322
+ const abs = path.join(target, dir);
323
+ if (!fs.existsSync(abs)) continue;
324
+ for (const f of fs.readdirSync(abs)) {
325
+ if (!f.startsWith('task_') || !f.endsWith('.md')) continue;
326
+ if (byBasename.has(f)) continue;
327
+ byBasename.set(f, path.join(dir, f).replace(/\\/g, '/'));
328
+ }
329
+ }
330
+
331
+ return [...byBasename.values()].sort();
324
332
  }
325
333
 
326
334
  function escapeRegExp(s) {
package/lib/verify.js CHANGED
@@ -14,8 +14,9 @@ import {
14
14
  import { lintTaskFile } from './task-lint.js';
15
15
 
16
16
  /**
17
- * 30 前聚合验证:gate-check + audit D5(仅 --task)+ reviews 留档闸(仅 --task)
18
- * + task lint WARN(仅 --task · v2.7+)+ S5 git-clean warn + 可选 --graph
17
+ * 30 前聚合验证:gate-check + audit D5(仅 --task)+ reviews 留档闸
18
+ *(--task 与全量 · N4/v2.9+)+ task lint WARN(仅 --task · v2.7+)
19
+ * + S5 git-clean warn + 可选 --graph
19
20
  */
20
21
  export function verifyTarget(target, options = {}) {
21
22
  const { taskFile, graph, allowNoReview = false, allowLintFail = false } = options;
@@ -69,6 +70,26 @@ export function verifyTarget(target, options = {}) {
69
70
  gateResult.stdout += `WARN: task lint FAIL · ${rules || 'errors'}(不挡 may_start_30 · 修结构或 --allow-lint-fail)\n`;
70
71
  }
71
72
  }
73
+ } else {
74
+ // 3c. 全量 reviews 闸(N4 · v2.9+):不跑 lint / D5
75
+ const taskFiles = listActiveTasks(target);
76
+ const missing = [];
77
+ for (const tf of taskFiles) {
78
+ if (!findReview(target, tf).found) missing.push(path.basename(tf));
79
+ }
80
+ if (missing.length > 0) {
81
+ const detail = `${missing.length}/${taskFiles.length} tasks · ${missing.join(', ')}`;
82
+ if (allowNoReview) {
83
+ gateResult.stdout += `WARN: missing R<n> review(--allow-no-review 豁免 · 留痕)· ${detail}\n`;
84
+ } else {
85
+ return {
86
+ ok: false,
87
+ exitCode: 2,
88
+ reason: `missing R<n> review(${detail})`,
89
+ stdout: gateResult.stdout,
90
+ };
91
+ }
92
+ }
72
93
  }
73
94
 
74
95
  // 4. S5 git-clean:warn 不挡
@@ -335,14 +356,19 @@ export function buildVerifyHandoff(target, options = {}) {
335
356
  const taskFiles = listActiveTasks(target);
336
357
  const tasks = taskFiles.map((tf) => {
337
358
  const handoff = buildTaskHandoff(target, tf, handoffOpts);
359
+ const reviewOk = handoff.review_found || allowNoReview;
360
+ const verifyOk = handoff.may_start_30 && reviewOk;
338
361
  return {
339
362
  schema_version: '1',
340
- verify_ok: handoff.may_start_30,
363
+ verify_ok: verifyOk,
341
364
  ...handoff,
365
+ may_start_30: verifyOk,
366
+ blocked_reason: handoff.blocked_reason ?? (reviewOk ? null : 'missing R<n> review'),
367
+ next_hat: verifyOk ? '30' : null,
342
368
  };
343
369
  });
344
370
 
345
- const verifyOk = tasks.length > 0 && tasks.every((t) => t.may_start_30);
371
+ const verifyOk = tasks.length > 0 && tasks.every((t) => t.verify_ok);
346
372
  return {
347
373
  schema_version: '1',
348
374
  verify_ok: verifyOk,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyning/harness",
3
- "version": "2.8.0",
3
+ "version": "2.10.0",
4
4
  "description": "cyning-harness discipline package · init / upgrade / check CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,55 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://cyning.dev/schemas/cyning-harness/discipline-coverage.v1.json",
4
+ "title": "cyning-harness Discipline Coverage (mechanization matrix)",
5
+ "description": "Starter 子集机械化率资产 · 随包版本维护 · 非 audit UI(v2.10+)",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["version", "as_of_package_version", "scope", "statements"],
9
+ "properties": {
10
+ "version": { "type": "string", "minLength": 1 },
11
+ "as_of_package_version": { "type": "string", "minLength": 1 },
12
+ "scope": { "type": "string", "minLength": 1 },
13
+ "source_audit": { "type": "string" },
14
+ "statements": {
15
+ "type": "array",
16
+ "minItems": 1,
17
+ "items": {
18
+ "type": "object",
19
+ "additionalProperties": false,
20
+ "required": ["id", "source", "summary", "status"],
21
+ "properties": {
22
+ "id": { "type": "string", "minLength": 1 },
23
+ "source": { "type": "string", "minLength": 1 },
24
+ "summary": { "type": "string", "minLength": 1 },
25
+ "status": {
26
+ "type": "string",
27
+ "enum": ["mechanical", "partial", "prompt-only"]
28
+ },
29
+ "mechanism": { "type": ["string", "null"] },
30
+ "gap": { "type": ["string", "null"] },
31
+ "notes": { "type": ["string", "null"] },
32
+ "mechanism_quality": { "type": ["string", "null"] }
33
+ }
34
+ }
35
+ },
36
+ "gaps": {
37
+ "type": "array",
38
+ "items": {
39
+ "type": "object",
40
+ "additionalProperties": false,
41
+ "required": ["id", "title", "status"],
42
+ "properties": {
43
+ "id": { "type": "string", "minLength": 1 },
44
+ "title": { "type": "string", "minLength": 1 },
45
+ "status": {
46
+ "type": "string",
47
+ "enum": ["open", "closed", "deferred"]
48
+ },
49
+ "closed_in": { "type": ["string", "null"] },
50
+ "note": { "type": ["string", "null"] }
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://cyning.dev/schemas/cyning-harness/lifecycle.v1.json",
4
4
  "title": "cyning-harness Task Lifecycle (minimal)",
5
- "description": "状态/转移/守卫登记 · 只读真值 · 非引擎(v2.7+)",
5
+ "description": "状态/转移/守卫登记真值 · lifecycle dry-run 引擎消费(yaml ≠ 引擎 · v2.7+ 登记 · v2.10+ dry-run)",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": ["version", "states", "transitions"],
@@ -174,6 +174,29 @@ if [[ "$JSON_MODE" != "1" ]]; then
174
174
  echo ""
175
175
  fi
176
176
 
177
+ # N4 · v2.9+:双路径发现(Starter ∪ Extended);同 basename 优先 Starter
178
+ # 输出绝对路径列表到 TASK_LIST(bash 3.2 兼容 · 无关联数组)
179
+ collect_active_tasks() {
180
+ TASK_LIST=()
181
+ local dir tf base existing already
182
+ for dir in "$TARGET/docs/tasks/active" "$TARGET/docs/harness/tasks/active"; do
183
+ [[ -d "$dir" ]] || continue
184
+ for tf in "$dir"/task_*.md; do
185
+ [[ -f "$tf" ]] || continue
186
+ base="$(basename "$tf")"
187
+ already=0
188
+ for existing in "${TASK_LIST[@]+"${TASK_LIST[@]}"}"; do
189
+ if [[ "$(basename "$existing")" == "$base" ]]; then
190
+ already=1
191
+ break
192
+ fi
193
+ done
194
+ [[ "$already" == "1" ]] && continue
195
+ TASK_LIST+=("$tf")
196
+ done
197
+ done
198
+ }
199
+
177
200
  BLOCKED=0
178
201
 
179
202
  if [[ "$GRAPH_MODE" == "1" ]]; then
@@ -191,26 +214,16 @@ if [[ "$GRAPH_MODE" == "1" ]]; then
191
214
  fi
192
215
  graph_for_task "$TASK_FILE"
193
216
  else
194
- ACTIVE_DIR="$TARGET/docs/tasks/active"
195
- if [[ ! -d "$ACTIVE_DIR" ]]; then
217
+ collect_active_tasks
218
+ if [[ ${#TASK_LIST[@]} -eq 0 ]]; then
196
219
  if [[ "$JSON_MODE" == "1" ]]; then
197
220
  printf ']\n'
198
221
  else
199
- echo "⚠️ $ACTIVE_DIR" >&2
222
+ echo "无 active task_*.md(docs/tasks/active ∪ docs/harness/tasks/active)"
200
223
  fi
201
224
  exit 0
202
225
  fi
203
- TASK_FILES=("$ACTIVE_DIR"/task_*.md)
204
- if [[ ! -e "${TASK_FILES[0]}" ]]; then
205
- if [[ "$JSON_MODE" == "1" ]]; then
206
- printf ']\n'
207
- else
208
- echo "无 active task_*.md"
209
- fi
210
- exit 0
211
- fi
212
- for tf in "$ACTIVE_DIR"/task_*.md; do
213
- [[ -f "$tf" ]] || continue
226
+ for tf in "${TASK_LIST[@]}"; do
214
227
  if [[ "$JSON_MODE" == "1" ]]; then
215
228
  [[ "$local_first" == "1" ]] || printf ','
216
229
  local_first=0
@@ -234,15 +247,16 @@ if [[ -n "$TASK_FILE" ]]; then
234
247
  fi
235
248
  check_one_task "$TASK_FILE" || BLOCKED=1
236
249
  else
237
- ACTIVE_DIR="$TARGET/docs/tasks/active"
238
- [[ -d "$ACTIVE_DIR" ]] || { echo "无 $ACTIVE_DIR" >&2; exit 1; }
239
- TASK_FILES=("$ACTIVE_DIR"/task_*.md)
240
- if [[ ! -e "${TASK_FILES[0]}" ]]; then
250
+ collect_active_tasks
251
+ if [[ ${#TASK_LIST[@]} -eq 0 ]]; then
252
+ if [[ ! -d "$TARGET/docs/tasks/active" && ! -d "$TARGET/docs/harness/tasks/active" ]]; then
253
+ echo "无 docs/tasks/active 且无 docs/harness/tasks/active" >&2
254
+ exit 1
255
+ fi
241
256
  echo "无 active task_*.md"
242
257
  exit 0
243
258
  fi
244
- for tf in "$ACTIVE_DIR"/task_*.md; do
245
- [[ -f "$tf" ]] || continue
259
+ for tf in "${TASK_LIST[@]}"; do
246
260
  check_one_task "$tf" || BLOCKED=1
247
261
  done
248
262
  fi