@cyning/harness 2.11.1 → 2.12.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 CHANGED
@@ -4,6 +4,26 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.12.0] - 2026-07-26
8
+
9
+ ### Added
10
+
11
+ - **多帽 invoke 留档硬闸**:task 元信息 `required_invoke_hats` / `invoke_retention_profile`(`default`=`10,30,40` · `minimal`=`30` · `full`=`00,10,20,30,40,CLOSE`)。
12
+ - `task close` 检查 1 按集合覆盖校验;文件名 `30_40` 可同时满足 30 与 40;`--allow-invoke-gap` 豁免留痕。
13
+ - `verify --task` 对 invoke hats 缺口 **WARN**(不挡 30);`task lint` **W6** 提醒未设字段。
14
+ - `lib/task-meta.js`:`resolveRequiredInvokeHats` · `scanInvokeHats` · `evaluateInvokeHatsRetention` · `extractHatsFromInvokeFilename`。
15
+
16
+ ### Changed
17
+
18
+ - `lifecycle.yaml` close 守卫登记 `invoke_hats_retention`;`discipline-coverage.yaml` `as_of` → **2.12.0** · gaps `INVOKE-HATS`。
19
+ - TASK_TEMPLATE / TEMPLATE_invoke / 10·20·30 帽文同步字段与纪律。
20
+
21
+ ### Notes
22
+
23
+ - SPEC:`docs/spec/SPEC-invoke-hats-retention-gate_v1.md`
24
+ - **破坏性(close)**:无字段时按 default 要求 10+30+40;存量仅 30 的 active 须补档、改 `minimal` 或 `--allow-invoke-gap`
25
+ - minor · 建议业务仓 upgrade 后按 RUNBOOK 勾选 invoke 集合
26
+
7
27
  ## [2.11.1] - 2026-07-25
8
28
 
9
29
  ### Fixed
@@ -15,6 +35,7 @@
15
35
 
16
36
  - 摩擦报告:`Projects/docs/harness/guides/ANALYSIS_upgrade_wizard_friction_20260725_v1_zh.md`
17
37
  - patch · 2.3+ manifest 仅五字段:`version` / `preset` / `ide` / `from_version` / `upgraded_at`(`name` / `tech_graph_dir` / `tasks_dir` / `hooks` 等已废弃)
38
+ - **已发布**:`@cyning/harness@2.11.1`(npm `latest` · 2026-07-26)· tag `v2.11.1` 已推送
18
39
 
19
40
  ## [2.11.0] - 2026-07-25
20
41
 
@@ -187,7 +187,8 @@ Schema:[`schema/verify_result.v1.schema.json`](../schema/verify_result.v1.sche
187
187
  | `npx @cyning/harness init` | 首次安装模板与 manifest(可选 `--with-scripts`) |
188
188
  | `npx @cyning/harness upgrade` | 同步产品包更新(可加 `--gate-check`);v2.11.1+:`local.json` 在 apply 后写入;manifest 重写前 WARN 非标准字段(2.3+ 仅五字段) |
189
189
  | `npx @cyning/harness check` | 检查是否有新版本 |
190
- | `npx @cyning/harness verify` | 全量:双路径 + reviews(v2.9+);`--task`:30 前聚合;`--spec`:SPEC→00(v2.8+ · 互斥) |
190
+ | `npx @cyning/harness verify` | 全量:双路径 + reviews(v2.9+);`--task`:30 前聚合 + invoke hats WARN(v2.12+);`--spec`:SPEC→00(v2.8+ · 互斥) |
191
+ | `npx @cyning/harness task close` | 受闸归档;v2.12+ 按 `required_invoke_hats` / `invoke_retention_profile` 校验多帽 invoke(缺省 `10,30,40`;`--allow-invoke-gap`) |
191
192
  | `npx @cyning/harness lifecycle show` | 只读展示 `harness/lifecycle.yaml`(登记 · v2.7+) |
192
193
  | `npx @cyning/harness lifecycle dry-run` | 转移资格判定(引擎消费 yaml · v2.10+ · 旁路 · 非 G7) |
193
194
  | `npx @cyning/harness discipline show` | 只读展示 `harness/discipline-coverage.yaml`(v2.11+ · 非 audit UI) |
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **对外名**:PIP(过程实例投影)。对内历史文件名可含 `HGM` / `hgm_*`(路径兼容,正文不用曾称括注)。
4
4
 
5
- > **Track G · G1 / v2.0+**:PIP(过程实例投影)为 **v1.0 公理稳定后的可选增强**(proposal · 未实现)· 详见 [`../ROADMAP_v1_zh.md`](../ROADMAP_v1_zh.md) §2.0 · §2.2。
5
+ > **Track G · G1 / v2.0+**:PIP(过程实例投影)本地 JSONL + `graph ingest|snapshot|axioms` **已实现**;Neo4j / 远端同步仍提案 · 详见 [`../ROADMAP_v1_zh.md`](../ROADMAP_v1_zh.md) §2.0 · §2.2。
6
6
 
7
7
  | 文档 | 状态 | 说明 |
8
8
  | --- | --- | --- |
@@ -0,0 +1,59 @@
1
+ # SPEC:多帽 invoke 留档硬闸(可配置)(v1)
2
+
3
+ > **状态**:`signed`
4
+ > **track**:`feature`
5
+ > **关联图谱**:无(纯 Harness 工具链)
6
+ > **下游**:30 实现 → 40 → close
7
+
8
+ ---
9
+
10
+ ## Harness 元信息
11
+
12
+ | 字段 | 值 |
13
+ |------|-----|
14
+ | **spec_slug** | `invoke-hats-retention-gate` |
15
+ | **test_strategy** | `required` |
16
+ | **test_strategy_note** | close/lint/verify 集合校验与 profile 展开须可失败测试驱动 |
17
+ | **skip_spec_audit** | `true` |
18
+
19
+ ---
20
+
21
+ ## 1. 背景与目标
22
+
23
+ close 检查 1 仅要求 `by-task/<slug>/` ≥1 个 `.md`,业务仓理性只交 30。
24
+ 目标:按 task 元信息 `required_invoke_hats` / `invoke_retention_profile` 做**集合覆盖**硬闸;默认 `10,30,40`;40 允许与 30 合并文件名。
25
+
26
+ ## 2. 范围
27
+
28
+ - `lib/task-meta.js`:profile 预置、解析、文件名 hat 扫描、缺口计算
29
+ - `lib/task-close.js`:检查 1 升级;`--allow-invoke-gap`
30
+ - `lib/task-lint.js`:缺字段 WARN
31
+ - `lib/verify.js`:缺口 WARN(不挡 30)
32
+ - lifecycle / discipline / 模板 / CHANGELOG · **v2.12.0**
33
+
34
+ ## 3. 非范围
35
+
36
+ - 强制 CLOSE invoke 文件(关账= close 命令)
37
+ - 默认要求 20 invoke(reviews 已硬闸)
38
+ - 同步 Ink 全套 TEMPLATE-*-invoke.md
39
+ - 历史 done invoke 补录
40
+
41
+ ## 4. 验收标准
42
+
43
+ - [x] 无字段 → 默认要求 `10,30,40`;仅有 30 → CLOSE BLOCKED
44
+ - [x] `invoke_retention_profile: minimal` → 仅需 30
45
+ - [x] `required_invoke_hats` 优先于 profile
46
+ - [x] `invoke_*_30_40_*.md` 同时满足 30 与 40
47
+ - [x] `--allow-invoke-gap` 豁免并留痕
48
+ - [x] verify --task 对缺口 WARN、不挡 may_start_30
49
+ - [x] `npm test` 全绿
50
+
51
+ > **复检**(2026-07-26 · cyning-harness `task/invoke-hats-retention-gate`):`npm test` **187/187** 绿;行为与上表一致。非阻塞备注见对话复检报告。
52
+
53
+ ## 5. 预置
54
+
55
+ | profile | hats |
56
+ |---------|------|
57
+ | default | 10,30,40 |
58
+ | minimal | 30 |
59
+ | full | 00,10,20,30,40,CLOSE |
@@ -6,7 +6,7 @@
6
6
  # 禁止:把本文件当「每周全量重盘」流程;不做 audit --discipline UI(本波)
7
7
 
8
8
  version: "1"
9
- as_of_package_version: "2.11.0"
9
+ as_of_package_version: "2.12.0"
10
10
  scope: "Starter · harness/prompts (10/22/30/40 + FRAGMENT)+ TEMPLATE_invoke"
11
11
  source_audit: "rethink/2026-07-mechanization-rate · 02 转录 + 03 聚合 · 状态已按 G1–G4/N1–N4 刷新(非第二轮通读盘点)"
12
12
 
@@ -61,6 +61,11 @@ gaps:
61
61
  status: closed
62
62
  closed_in: "2.10.0"
63
63
  note: "Epic T2 · 旁路报告"
64
+ - id: INVOKE-HATS
65
+ title: 多帽 invoke 留档集合闸
66
+ status: closed
67
+ closed_in: "2.12.0"
68
+ note: "close evaluateInvokeHatsRetention · default 10,30,40"
64
69
 
65
70
  statements:
66
71
  - id: A1
@@ -105,11 +110,11 @@ statements:
105
110
  mechanism_quality: null
106
111
  - id: A6
107
112
  source: 30-execute-code.md
108
- summary: invoke 快照落盘 by-task/<slug>/
113
+ summary: invoke 快照落盘 by-task/<slug>/(按 required_invoke_hats 集合)
109
114
  status: mechanical
110
- mechanism: "M5 close + M6 lint-done"
115
+ mechanism: "M5 close evaluateInvokeHatsRetention + M6 lint-done"
111
116
  gap: null
112
- notes: null
117
+ notes: "v2.12.0 默认 10,30,40;profile minimal/full;--allow-invoke-gap"
113
118
  mechanism_quality: null
114
119
  - id: A7
115
120
  source: 30-execute-code.md
@@ -265,11 +270,11 @@ statements:
265
270
  mechanism_quality: null
266
271
  - id: F1
267
272
  source: TEMPLATE_invoke.md
268
- summary: 落盘路径 by-task/<task_slug>/
273
+ summary: 落盘路径 by-task/<task_slug>/ 且帽子集合覆盖
269
274
  status: mechanical
270
- mechanism: "M5/M6"
275
+ mechanism: "M5 evaluateInvokeHatsRetention / M6"
271
276
  gap: null
272
- notes: null
277
+ notes: "v2.12.0 文件名 hat token · 合并 30_40 可双计"
273
278
  mechanism_quality: null
274
279
  - id: F2
275
280
  source: TEMPLATE_invoke.md
@@ -9,7 +9,7 @@
9
9
 
10
10
  | 字段 | 值 |
11
11
  |------|-----|
12
- | hat_id | 10 / 22 / 30 / 40 / 50 / 00 / CLOSE |
12
+ | hat_id | 10 / 22 / 30 / 40 / 50 / 00 / CLOSE(合并可写 `30(含 40)`) |
13
13
  | task_slug | `<slug>` |
14
14
  | freeze_id | (可选) |
15
15
  | task_paths | `docs/tasks/active/task_….md` |
@@ -44,7 +44,8 @@
44
44
  | 同帽追问 | **不** 新增 invoke;沿用本节路径 |
45
45
  | 打回重开 | 新文件或 `_r2` 后缀 |
46
46
  | 与 reviews 分工 | reviews = 结论;invoke = **当时指令** |
47
- | close 机械闸 | `harness task close` 归档前校验 `by-task/<task_slug>/` 存在且含 ≥1 `.md`(v2.2+) |
47
+ | close 机械闸 | `harness task close` task 元信息 `required_invoke_hats` / `invoke_retention_profile` 校验帽集合覆盖(v2.12+;缺省 `10,30,40`;文件名含 `30_40` 可双计;`--allow-invoke-gap` 豁免) |
48
+ | 命名 | `invoke_YYYYMMDD_<hat>_<slug>.md` 或 `invoke_YYYYMMDD_30_40_<slug>.md` |
48
49
  | commit | 落盘 + task 回填后再 commit(见 HANDOFF) |
49
50
 
50
51
  ---
@@ -66,8 +66,14 @@ transitions:
66
66
  description: "task close · active→done 归档"
67
67
  guards:
68
68
  - id: close_invoke
69
- command_or_check: "task close 检查1 · invoke 快照存在"
69
+ command_or_check: "task close 检查1 · invoke hats 集合覆盖(required_invoke_hats / profile · v2.12+)"
70
70
  severity: block
71
+ allow_flag: "--allow-invoke-gap"
72
+ note: "缺省 default=10,30,40;minimal=30;full 含 CLOSE;40 可与 30 合并文件名"
73
+ - id: invoke_hats_retention
74
+ command_or_check: "evaluateInvokeHatsRetention · 同 close_invoke(登记别名)"
75
+ severity: block
76
+ allow_flag: "--allow-invoke-gap"
71
77
  - id: close_self_check
72
78
  command_or_check: "task close 检查2 · 自检结论已回填"
73
79
  severity: block
@@ -15,6 +15,7 @@
15
15
  - 补齐 **`failure_paths`**(触发 → 行为 → 可重试 → 用户可见)
16
16
  - 写清 **非范围**、**依赖**(相对路径链接)
17
17
  - 建议 `test_strategy` + `code_quality_bar`
18
+ - **选定 invoke 留档集合**(v2.12+):写 `invoke_retention_profile`(`default`=`10,30,40` / `minimal`=`30` / `full`)或显式 `required_invoke_hats`;并落盘本帽 `invoke_*_10_*.md`
18
19
  - 承接 **20-task-audit 审查**:按 `docs/harness/reviews/*_audit_*.md` 回填 task
19
20
 
20
21
  ## 禁止什么
@@ -48,7 +49,8 @@
48
49
  |------|------|
49
50
  | 2026-07-24 | V2 拆分收编:自 `10-requirements.md` 改名为 `10-task-requirements.md`(来源:工作区 `10-task-requirements.md` · 2026-06-12 v2) |
50
51
  | 2026-07-24 | G4:思考轮摘要节补 v2.6+ `task lint` 条件触发结构检查 |
52
+ | 2026-07-26 | v2.12:起草时选定 `required_invoke_hats` / `invoke_retention_profile` |
51
53
 
52
54
  ## 给 Cursor
53
55
 
54
- `Harness`、`10-task`、`验收`、`failure_paths`、`test_strategy`、`task lint`、`拒开工`
56
+ `Harness`、`10-task`、`验收`、`failure_paths`、`test_strategy`、`task lint`、`required_invoke_hats`、`拒开工`
@@ -62,7 +62,8 @@
62
62
 
63
63
  ## 交接物
64
64
 
65
- - 审查 md 路径 + invoke 快照(可选)
65
+ - 审查 md 路径(**必须** · `docs/harness/reviews/`)
66
+ - invoke 快照:仅当 task `required_invoke_hats` / profile **含 20** 时必须落盘;默认 default 集合不含 20(与 reviews 硬闸分工)
66
67
  - 按 HANDOFF 分仓 commit
67
68
 
68
69
  ## 修订记录
@@ -70,6 +71,7 @@
70
71
  | 日期 | 摘要 |
71
72
  |------|------|
72
73
  | 2026-07-24 | V2 拆分收编:自 `22-task-audit.md` 改名为 `20-task-audit.md`(来源:工作区 `20-task-audit.md` · 2026-06-21 v3 + 原包内人工闸联动节保留) |
74
+ | 2026-07-26 | v2.12:澄清 20 invoke 仅在 required 集合含 20 时强制;默认靠 reviews |
73
75
 
74
76
  ## 给 Cursor
75
77
 
@@ -20,8 +20,8 @@
20
20
  - 读 task **必读列表** + `AGENTS.md` + `_tech_graph/` + L2(涉码)
21
21
  - `test_strategy: required` → **先** 可失败测试再改实现
22
22
  - 闸扫描通过后:运行 task **验证命令**;回填 `### 自检结论(执行者)`
23
- - invoke 快照落盘 `docs/harness/invokes/by-task/<task_slug>/`
24
- - 归档(active→done)**只能**走 `npx @cyning/harness task close --file <task> --yes`:`CLOSE: PASS` 后方可归档;机械校验 invoke 留档 / 自检结论 / 验收勾选 / slug / 状态,任一不过 **不执行** mv(v2.2+)
23
+ - invoke 快照落盘 `docs/harness/invokes/by-task/<task_slug>/`(须覆盖 task 要求的 hats;40 可与 30 合并为 `invoke_*_30_40_*.md`)
24
+ - 归档(active→done)**只能**走 `npx @cyning/harness task close --file <task> --yes`:`CLOSE: PASS` 后方可归档;机械校验 **invoke hats 集合** / 自检结论 / 验收勾选 / slug / 状态 / R1 review,任一不过 **不执行** mv(v2.2+ · hats 集合 v2.12+)
25
25
 
26
26
  ## 禁止什么
27
27
 
@@ -19,6 +19,8 @@
19
19
  | **chain_prompt** | `docs/harness/prompts/PROMPT_cursor_task_chain_serial_v1.md`(嵌入后路径) |
20
20
  | **semi_auto** | **`false`**(禁止作总闸) |
21
21
  | **audit_profile** | `full` / `post_close` / `human_only` |
22
+ | **invoke_retention_profile** | `default` / `minimal` / `full`(10 起草时选定) |
23
+ | **required_invoke_hats** | `10,30,40`(显式列表优先于 profile;缺省=default) |
22
24
  | **git_branch** | `task/<slug>` |
23
25
  | **worktree_root** | (并行时填独立 worktree 目录) |
24
26
  | **experience_capture** | `required` / `recommended` / `not_applicable` |
package/lib/cli.js CHANGED
@@ -404,11 +404,11 @@ async function cmdVerify(args) {
404
404
  formatAgentHint,
405
405
  } = await import('./verify.js');
406
406
  if (args.includes('--help') || args.includes('-h')) {
407
- 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]
407
+ 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] [--allow-invoke-gap]
408
408
 
409
409
  无 --task/--spec:扫描 docs/tasks/active ∪ docs/harness/tasks/active(同 basename 优先 Starter);
410
410
  闸表 + 全量 reviews 存在性(v2.9+)· 不跑 task lint / D5 · --allow-no-review。
411
- --task:30 前聚合(gate + audit + reviews + lint WARN)· --allow-no-review / --allow-lint-fail。
411
+ --task:30 前聚合(gate + audit + reviews + lint WARN + invoke hats WARN)· --allow-no-review / --allow-lint-fail / --allow-invoke-gap
412
412
  --spec:SPEC→00 前查审查文存在性(v2.8+ · 与 --task 互斥)· --allow-no-spec-review。
413
413
  推荐:--spec <产品仓 SPEC> --workspace-root <含 docs/harness/reviews 的工作区根>
414
414
  --json / --agent-hint:Agent handoff。
@@ -431,6 +431,7 @@ async function cmdVerify(args) {
431
431
  const allowNoReview = rest.includes('--allow-no-review');
432
432
  const allowLintFail = rest.includes('--allow-lint-fail');
433
433
  const allowNoSpecReview = rest.includes('--allow-no-spec-review');
434
+ const allowInvokeGap = rest.includes('--allow-invoke-gap');
434
435
  rest = rest.filter(
435
436
  (a) =>
436
437
  a !== '--graph' &&
@@ -438,7 +439,8 @@ async function cmdVerify(args) {
438
439
  a !== '--agent-hint' &&
439
440
  a !== '--allow-no-review' &&
440
441
  a !== '--allow-lint-fail' &&
441
- a !== '--allow-no-spec-review',
442
+ a !== '--allow-no-spec-review' &&
443
+ a !== '--allow-invoke-gap',
442
444
  );
443
445
 
444
446
  if (rest.length > 0) {
@@ -469,7 +471,13 @@ async function cmdVerify(args) {
469
471
  });
470
472
  label = path.basename(specFile);
471
473
  } else {
472
- result = verifyTarget(target, { taskFile, graph, allowNoReview, allowLintFail });
474
+ result = verifyTarget(target, {
475
+ taskFile,
476
+ graph,
477
+ allowNoReview,
478
+ allowLintFail,
479
+ allowInvokeGap,
480
+ });
473
481
  handoff = buildVerifyHandoff(target, {
474
482
  taskFile,
475
483
  workspaceRoot: ws,
@@ -709,13 +717,16 @@ async function cmdTask(args) {
709
717
 
710
718
  async function cmdTaskClose(args) {
711
719
  if (args.includes('--help') || args.includes('-h')) {
712
- console.log(`用法: npx @cyning/harness task close --file PATH [--target PATH] [--yes] [--allow-unchecked]
720
+ console.log(`用法: npx @cyning/harness task close --file PATH [--target PATH] [--yes] [--allow-unchecked] [--allow-no-review] [--allow-invoke-gap]
713
721
 
714
- 受闸归档(方案 A · invoke 留档机械闸):
715
- 5 项机械校验:invoke 留档 · 自检结论回填 · 验收标准勾选 · task_slug 一致 · 状态 done/completed
722
+ 受闸归档(方案 A · invoke 留档机械闸 · v2.12+ 多帽集合):
723
+ 机械校验:invoke hats 覆盖(required_invoke_hats / profile)· 自检结论 · 验收勾选 · slug · 状态 · R1 review
716
724
  默认 dry-run(只检不 mv);--yes 才执行 mv 到 active/ 同级 done/(basename 保留)
717
- --target PATH 显式覆盖归档目标完整路径
718
- --allow-unchecked 验收标准未勾选项 warn 放行
725
+ --target PATH 显式覆盖归档目标完整路径
726
+ --allow-unchecked 验收标准未勾选项 warn 放行
727
+ --allow-no-review 缺 R<n> 审查文 warn 放行
728
+ --allow-invoke-gap 缺 required invoke hats warn 放行(v2.12+)
729
+ 缺省 required hats:10,30,40(可用 invoke_retention_profile=minimal|full 或显式列表覆盖)
719
730
  任一校验失败 → exit 2 + CLOSE: BLOCKED · 不执行 mv。末行协议:CLOSE: PASS · <slug>。
720
731
  `);
721
732
  return;
@@ -724,7 +735,14 @@ async function cmdTaskClose(args) {
724
735
  const yes = args.includes('--yes');
725
736
  const allowUnchecked = args.includes('--allow-unchecked');
726
737
  const allowNoReview = args.includes('--allow-no-review');
727
- let rest = args.filter((a) => a !== '--yes' && a !== '--allow-unchecked' && a !== '--allow-no-review');
738
+ const allowInvokeGap = args.includes('--allow-invoke-gap');
739
+ let rest = args.filter(
740
+ (a) =>
741
+ a !== '--yes' &&
742
+ a !== '--allow-unchecked' &&
743
+ a !== '--allow-no-review' &&
744
+ a !== '--allow-invoke-gap',
745
+ );
728
746
  const { value: fileArg, rest: r1 } = takeOption(rest, '--file');
729
747
  rest = r1;
730
748
  const { value: targetArg, rest: r2 } = takeOption(rest, '--target');
@@ -746,6 +764,7 @@ async function cmdTaskClose(args) {
746
764
  target: targetArg,
747
765
  allowUnchecked,
748
766
  allowNoReview,
767
+ allowInvokeGap,
749
768
  cwd: process.cwd(),
750
769
  });
751
770
 
package/lib/task-close.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  PLACEHOLDER_RE,
6
6
  STATUS_RE,
7
7
  UNCHECKED_RE,
8
+ evaluateInvokeHatsRetention,
8
9
  extractSection,
9
10
  extractTaskSlug,
10
11
  findReview,
@@ -23,6 +24,7 @@ export function closeTaskFile(filePath, options = {}) {
23
24
  target: targetArg,
24
25
  allowUnchecked = false,
25
26
  allowNoReview = false,
27
+ allowInvokeGap = false,
26
28
  cwd = process.cwd(),
27
29
  } = options;
28
30
 
@@ -60,7 +62,8 @@ export function closeTaskFile(filePath, options = {}) {
60
62
  const inActive = path.basename(activeDir) === 'active';
61
63
  const repoRoot = findRepoRoot(activeDir);
62
64
 
63
- // 检查 1 · invoke 留档
65
+ // 检查 1 · invoke 留档(v2.12+:按 required_invoke_hats / profile 集合覆盖)
66
+ let invokeHatsEval = null;
64
67
  if (!repoRoot) {
65
68
  blockers.push(`无法定位 repo root(${activeDir} 无 docs 祖先)`);
66
69
  } else if (slug) {
@@ -69,12 +72,29 @@ export function closeTaskFile(filePath, options = {}) {
69
72
  'docs/harness/invokes/by-task',
70
73
  slug,
71
74
  );
72
- const hasMd =
73
- fs.existsSync(invokeDir) &&
74
- fs.readdirSync(invokeDir).some((f) => f.endsWith('.md'));
75
- if (!hasMd) {
76
- blockers.push(
77
- `missing invoke snapshots: ${path.relative(repoRoot, invokeDir)} 不存在或无 .md`,
75
+ invokeHatsEval = evaluateInvokeHatsRetention(meta, invokeDir);
76
+ const relDir = path.relative(repoRoot, invokeDir).replace(/\\/g, '/');
77
+ if (invokeHatsEval.missingDir || invokeHatsEval.files.length === 0) {
78
+ const msg = `missing invoke snapshots: ${relDir} 不存在或无 .md(要求 hats: ${invokeHatsEval.required.join(',')})`;
79
+ if (allowInvokeGap) {
80
+ warnings.push(`warn: ${msg}(--allow-invoke-gap 豁免 · 留痕)`);
81
+ } else {
82
+ blockers.push(msg);
83
+ }
84
+ } else if (invokeHatsEval.missing.length > 0) {
85
+ const msg =
86
+ `missing invoke hats: ${invokeHatsEval.missing.join(',')} ` +
87
+ `(要求 ${invokeHatsEval.required.join(',')} · 已有 ${invokeHatsEval.covered.join(',') || '∅'} · ` +
88
+ `source=${invokeHatsEval.resolution.source}` +
89
+ `${invokeHatsEval.resolution.unknown_profile ? ` · unknown_profile=${invokeHatsEval.resolution.unknown_profile}` : ''})`;
90
+ if (allowInvokeGap) {
91
+ warnings.push(`warn: ${msg}(--allow-invoke-gap 豁免 · 留痕)`);
92
+ } else {
93
+ blockers.push(msg);
94
+ }
95
+ } else if (invokeHatsEval.resolution.unknown_profile) {
96
+ warnings.push(
97
+ `warn: unknown invoke_retention_profile '${invokeHatsEval.resolution.unknown_profile}' · 已回退 default`,
78
98
  );
79
99
  }
80
100
  }
@@ -167,6 +187,14 @@ export function closeTaskFile(filePath, options = {}) {
167
187
  dest,
168
188
  dryRun: !yes,
169
189
  moved,
190
+ invoke_hats: invokeHatsEval
191
+ ? {
192
+ required: invokeHatsEval.required,
193
+ covered: invokeHatsEval.covered,
194
+ missing: invokeHatsEval.missing,
195
+ source: invokeHatsEval.resolution.source,
196
+ }
197
+ : null,
170
198
  };
171
199
  }
172
200
 
package/lib/task-lint.js CHANGED
@@ -193,6 +193,33 @@ export function lintTaskFile(filePath, options = {}) {
193
193
  warnings.push(warn('W2', '缺 ### 人工闸 节(轻量 task 可忽略本提醒)'));
194
194
  }
195
195
 
196
+ // W6 · invoke 留档集合字段(v2.12+ · 缺则 close 按 default=10,30,40)
197
+ if (
198
+ meta.task_slug &&
199
+ !meta.required_invoke_hats &&
200
+ !meta.invoke_retention_profile
201
+ ) {
202
+ warnings.push(
203
+ warn(
204
+ 'W6',
205
+ '未设 required_invoke_hats / invoke_retention_profile · close 将按 default=10,30,40(紧急 fix 可写 minimal)',
206
+ ),
207
+ );
208
+ } else if (
209
+ meta.invoke_retention_profile &&
210
+ !['default', 'minimal', 'full'].includes(
211
+ String(meta.invoke_retention_profile).trim().toLowerCase(),
212
+ ) &&
213
+ !meta.required_invoke_hats
214
+ ) {
215
+ warnings.push(
216
+ warn(
217
+ 'W6',
218
+ `未知 invoke_retention_profile '${meta.invoke_retention_profile}' · 将回退 default`,
219
+ ),
220
+ );
221
+ }
222
+
196
223
  // G4 · 思考轮结构(条件触发)
197
224
  lintThinkingRounds(content, lines, errors, warnings);
198
225
 
package/lib/task-meta.js CHANGED
@@ -396,6 +396,158 @@ export function normalizeSlug(slug) {
396
396
  return String(slug).replace(/_/g, '-');
397
397
  }
398
398
 
399
+ /**
400
+ * invoke 留档 profile → 帽集合(v2.12+)。
401
+ * default=10,30,40 · minimal=30 · full=00,10,20,30,40,CLOSE
402
+ */
403
+ export const INVOKE_RETENTION_PROFILES = Object.freeze({
404
+ default: Object.freeze(['10', '30', '40']),
405
+ minimal: Object.freeze(['30']),
406
+ full: Object.freeze(['00', '10', '20', '30', '40', 'CLOSE']),
407
+ });
408
+
409
+ /** 已知 hat token(文件名扫描白名单) */
410
+ export const KNOWN_INVOKE_HAT_TOKENS = Object.freeze(
411
+ new Set([
412
+ '00',
413
+ '10',
414
+ '20',
415
+ '22',
416
+ '30',
417
+ '40',
418
+ '50',
419
+ 'CLOSE',
420
+ 'close',
421
+ ]),
422
+ );
423
+
424
+ /**
425
+ * 解析 required_invoke_hats / invoke_retention_profile。
426
+ * 显式列表优先;皆无 → default。
427
+ * @returns {{ hats: string[], profile: string|null, source: 'explicit'|'profile'|'default', unknown_profile?: string }}
428
+ */
429
+ export function resolveRequiredInvokeHats(meta = {}) {
430
+ const explicit = meta.required_invoke_hats;
431
+ if (explicit != null && String(explicit).trim() !== '') {
432
+ const hats = parseHatList(explicit);
433
+ return { hats, profile: meta.invoke_retention_profile ?? null, source: 'explicit' };
434
+ }
435
+
436
+ const profileRaw = meta.invoke_retention_profile;
437
+ if (profileRaw != null && String(profileRaw).trim() !== '') {
438
+ const key = String(profileRaw).trim().toLowerCase();
439
+ const preset = INVOKE_RETENTION_PROFILES[key];
440
+ if (preset) {
441
+ return { hats: [...preset], profile: key, source: 'profile' };
442
+ }
443
+ return {
444
+ hats: [...INVOKE_RETENTION_PROFILES.default],
445
+ profile: key,
446
+ source: 'default',
447
+ unknown_profile: key,
448
+ };
449
+ }
450
+
451
+ return {
452
+ hats: [...INVOKE_RETENTION_PROFILES.default],
453
+ profile: 'default',
454
+ source: 'default',
455
+ };
456
+ }
457
+
458
+ /**
459
+ * 从 invoke 文件名提取 hat token 集合。
460
+ * 兼容:invoke_YYYYMMDD_30_slug.md · invoke_YYYYMMDD_30_40_slug.md · invoke_*_CLOSE_*.md
461
+ * 亦认 22→20(V2 改名等价,计为 20)。
462
+ */
463
+ export function extractHatsFromInvokeFilename(fileName) {
464
+ const base = path.basename(fileName, '.md');
465
+ const hats = new Set();
466
+
467
+ if (/CLOSE/i.test(base)) {
468
+ hats.add('CLOSE');
469
+ }
470
+
471
+ // 去 invoke_ 前缀与可选日期段
472
+ let rest = base.replace(/^invoke[_-]?/i, '');
473
+ rest = rest.replace(/^\d{8}[_-]?/, '');
474
+ rest = rest.replace(/^\d{4}-\d{2}-\d{2}[_-]?/, '');
475
+
476
+ const parts = rest.split(/[_-]+/).filter(Boolean);
477
+ for (const p of parts) {
478
+ const upper = p.toUpperCase();
479
+ if (upper === 'CLOSE') {
480
+ hats.add('CLOSE');
481
+ continue;
482
+ }
483
+ if (KNOWN_INVOKE_HAT_TOKENS.has(p)) {
484
+ hats.add(normalizeHatToken(p));
485
+ }
486
+ }
487
+
488
+ // V2:22 与 20 等价(审 task)
489
+ if (hats.has('22')) {
490
+ hats.add('20');
491
+ }
492
+
493
+ return hats;
494
+ }
495
+
496
+ /**
497
+ * 扫描 by-task/<slug>/ 目录,聚合已覆盖 hat。
498
+ * @returns {{ covered: Set<string>, files: string[], missingDir: boolean }}
499
+ */
500
+ export function scanInvokeHats(invokeDir) {
501
+ if (!invokeDir || !fs.existsSync(invokeDir)) {
502
+ return { covered: new Set(), files: [], missingDir: true };
503
+ }
504
+ const files = fs
505
+ .readdirSync(invokeDir)
506
+ .filter((f) => f.endsWith('.md'));
507
+ const covered = new Set();
508
+ for (const f of files) {
509
+ for (const h of extractHatsFromInvokeFilename(f)) {
510
+ covered.add(h);
511
+ }
512
+ }
513
+ return { covered, files, missingDir: false };
514
+ }
515
+
516
+ /**
517
+ * 计算 required vs covered 缺口。
518
+ * @returns {{ required: string[], covered: string[], missing: string[], ok: boolean, resolution }}
519
+ */
520
+ export function evaluateInvokeHatsRetention(meta, invokeDir) {
521
+ const resolution = resolveRequiredInvokeHats(meta);
522
+ const { covered, files, missingDir } = scanInvokeHats(invokeDir);
523
+ const required = resolution.hats.map(normalizeHatToken);
524
+ const missing = required.filter((h) => !covered.has(h));
525
+ return {
526
+ required,
527
+ covered: [...covered].sort(),
528
+ missing,
529
+ ok: missing.length === 0 && !missingDir && files.length > 0,
530
+ missingDir,
531
+ files,
532
+ resolution,
533
+ };
534
+ }
535
+
536
+ function parseHatList(raw) {
537
+ return String(raw)
538
+ .split(/[,,\s]+/)
539
+ .map((s) => s.trim())
540
+ .filter(Boolean)
541
+ .map(normalizeHatToken);
542
+ }
543
+
544
+ function normalizeHatToken(token) {
545
+ const t = String(token).trim();
546
+ if (/^close$/i.test(t)) return 'CLOSE';
547
+ if (t === '22') return '22';
548
+ return t;
549
+ }
550
+
399
551
  function normalizeCell(cell) {
400
552
  return cell.replace(/[`\\*]/g, '').trim();
401
553
  }
package/lib/verify.js CHANGED
@@ -5,10 +5,12 @@ import { resolveHarnessRootForTarget, wizardScript } from './paths.js';
5
5
  import { auditTarget } from './audit.js';
6
6
  import {
7
7
  buildTaskHandoff,
8
+ evaluateInvokeHatsRetention,
8
9
  extractSpecSlug,
9
10
  findReview,
10
11
  findSpecReview,
11
12
  listActiveTasks,
13
+ parseHarnessMeta,
12
14
  shouldSkipSpecAudit,
13
15
  } from './task-meta.js';
14
16
  import { lintTaskFile } from './task-lint.js';
@@ -16,10 +18,17 @@ import { lintTaskFile } from './task-lint.js';
16
18
  /**
17
19
  * 30 前聚合验证:gate-check + audit D5(仅 --task)+ reviews 留档闸
18
20
  *(--task 与全量 · N4/v2.9+)+ task lint WARN(仅 --task · v2.7+)
21
+ * + invoke hats 缺口 WARN(仅 --task · v2.12+ · 不挡 30)
19
22
  * + S5 git-clean warn + 可选 --graph
20
23
  */
21
24
  export function verifyTarget(target, options = {}) {
22
- const { taskFile, graph, allowNoReview = false, allowLintFail = false } = options;
25
+ const {
26
+ taskFile,
27
+ graph,
28
+ allowNoReview = false,
29
+ allowLintFail = false,
30
+ allowInvokeGap = false,
31
+ } = options;
23
32
  const harnessRoot = resolveHarnessRootForTarget(target);
24
33
 
25
34
  // 1. gate-check(含 --graph)
@@ -70,6 +79,35 @@ export function verifyTarget(target, options = {}) {
70
79
  gateResult.stdout += `WARN: task lint FAIL · ${rules || 'errors'}(不挡 may_start_30 · 修结构或 --allow-lint-fail)\n`;
71
80
  }
72
81
  }
82
+
83
+ // 3d. invoke hats 缺口(v2.12+):WARN 不挡 30 · close 才 block
84
+ const absTask = path.isAbsolute(taskFile)
85
+ ? taskFile
86
+ : path.join(target, taskFile);
87
+ if (fs.existsSync(absTask)) {
88
+ const content = fs.readFileSync(absTask, 'utf8');
89
+ const meta = parseHarnessMeta(content);
90
+ const slug = meta.task_slug;
91
+ if (slug) {
92
+ const invokeDir = path.join(
93
+ target,
94
+ 'docs/harness/invokes/by-task',
95
+ slug,
96
+ );
97
+ const hatsEval = evaluateInvokeHatsRetention(meta, invokeDir);
98
+ if (hatsEval.missing.length > 0 || hatsEval.missingDir || hatsEval.files.length === 0) {
99
+ const detail =
100
+ hatsEval.missingDir || hatsEval.files.length === 0
101
+ ? `目录缺失/空 · 要求 ${hatsEval.required.join(',')}`
102
+ : `缺 ${hatsEval.missing.join(',')}(要求 ${hatsEval.required.join(',')} · 已有 ${hatsEval.covered.join(',') || '∅'})`;
103
+ if (allowInvokeGap) {
104
+ gateResult.stdout += `WARN: invoke hats gap suppressed(--allow-invoke-gap · 留痕)· ${detail}\n`;
105
+ } else {
106
+ gateResult.stdout += `WARN: invoke hats gap · ${detail}(不挡 may_start_30 · close 前须补齐或 --allow-invoke-gap)\n`;
107
+ }
108
+ }
109
+ }
110
+ }
73
111
  } else {
74
112
  // 3c. 全量 reviews 闸(N4 · v2.9+):不跑 lint / D5
75
113
  const taskFiles = listActiveTasks(target);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyning/harness",
3
- "version": "2.11.1",
3
+ "version": "2.12.0",
4
4
  "description": "cyning-harness discipline package · init / upgrade / check CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",