@cyning/harness 2.1.1 → 2.2.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,20 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.2.0] - 2026-07-22
8
+
9
+ ### Added
10
+
11
+ - **`harness task close`**:受闸归档子命令(方案 A · invoke 留档机械闸)。5 项机械校验 —— invoke 留档(`docs/harness/invokes/by-task/<slug>/` 含 ≥1 个 `.md`)、`### 自检结论` 非占位符、`## 验收标准` 无未勾选项、文件名/元信息/invoke 目录 slug 一致、`> **状态**` ∈ {done, completed};任一失败 exit 2 + `CLOSE: BLOCKED` 且**不执行** mv。默认 dry-run(只检不 mv),`--yes` 才归档到 `active/` 同级 `done/`(basename 保留,`--target` 可覆盖)。
12
+ - **`harness task lint-done`**:CI 一致性兜底(方案 C)。diff `docs/tasks/done/` + `docs/harness/tasks/done/`(递归)slug 集合 vs `invokes/by-task/` 目录名;done 有而 invokes 无 exit 2 列缺失,反向仅 warn。
13
+ - `lib/task-close.js` · `lib/task-lint-done.js`;`lib/task-meta.js` 新增导出 `extractSection` · `extractTaskSlug`。
14
+ - Prompt 纪律同步:`harness/prompts/30-execute-code.md`(归档只能走 `task close` PASS)、`harness/invokes/TEMPLATE_invoke.md`(close 机械闸行)。
15
+
16
+ ### Notes
17
+
18
+ - 背景:ops-desk-api 连续 4 任务 30 漏落 invoke(2026-07-20 根因分析)——invoke 留档原是纯 Prompt 层纪律,本版补上机械闸。
19
+ - minor · 无 CLI breaking · `npm test` 91 全绿 · SPEC `docs/spec/SPEC-task-close-invoke-gate_v1.md`
20
+
7
21
  ## [2.1.1] - 2026-06-30
8
22
 
9
23
  ### Fixed
@@ -0,0 +1,148 @@
1
+ # SPEC:task close 命令 + invoke 留档机械闸(v1)
2
+
3
+ > **状态**:`done`(10-spec R0–R5 已回填 · 2026-07-22 · 待维护者签收 → 00 起草 task)
4
+ > **track**:`feature`
5
+ > **关联图谱**:无(纯 Harness 工具链 · 不触业务模块)
6
+ > **下游**:SPEC 签收 → 00 起草/重起草 task → 10-task
7
+ > **关联 task**:`Projects/docs/harness/tasks/active/task_cyning_harness_task_close_invoke_gate_v1.md`
8
+
9
+ ---
10
+
11
+ ## Harness 元信息
12
+
13
+ | 字段 | 值 |
14
+ |------|-----|
15
+ | **spec_slug** | `task-close-invoke-gate` |
16
+ | **test_strategy** | `required` |
17
+ | **test_strategy_note** | close/lint-done 均为机械闸逻辑,fail 路径必须可失败测试驱动 |
18
+ | **entry_invoke_10_spec** | `Projects/docs/harness/invokes/by-task/cyning-harness-task-close-invoke-gate/invoke_20260722_10_spec_task_close_invoke_gate.md` |
19
+ | **entry_invoke_00_draft** | 工作区 `docs/harness/prompts/PROMPT_00_draft_spec_or_task_v1_zh.md` |
20
+
21
+ ---
22
+
23
+ ## 1. 背景与目标
24
+
25
+ **现象**:invoke 留档是 Harness 明文纪律(`harness/prompts/30-execute-code.md`「invoke 快照落盘 `docs/harness/invokes/by-task/<task_slug>/`」),但 ops-desk-api 连续 4 个任务(llm-provider-v1 / auth-session-db-cookie / admin-github-sync / chat-websocket-v1)30 执行全部漏落(2026-07-20 根因分析)。
26
+
27
+ **根因(已源码核实)**:该纪律唯一出处是 Prompt 自然语言文本,零代码消费;`wizard/gate-check.sh` / `lib/verify.js` / `lib/audit.js` / `lib/task.js` 均不读 `invokes/by-task/`;且 `verify` 是 30 **开工前**的闸,invoke 是 30 **收尾**产物,检查点在时间线上错位 —— 正确挂点是「任务归档(active→done)」时刻,但 CLI 没有 close/done 命令,归档是手动 `mv`,工具链零感知。
28
+
29
+ **目标**:把「归档」从手动 `mv` 收编为受闸动作(`harness task close`,方案 A 主),并以 CI 集合 diff 兜底存量与漏网(方案 C)。漏落从「Agent 自觉」变为「机械可 fail」。
30
+
31
+ ---
32
+
33
+ ## 2. 范围
34
+
35
+ - **D1 · `lib/task-close.js`(新增)**:5 项机械校验(见 §4 验收对照)+ 归档执行(`mv`);结构化结果(ok / blockers[])。
36
+ - **D2 · CLI 路由(`lib/cli.js`)**:`task close --file PATH [--target PATH] [--yes] [--allow-unchecked]`;`CLOSE: PASS/BLOCKED` 末行协议;help 同步。
37
+ - **D3 · 方案 C(`lib/task-lint-done.js` + CLI `task lint-done`)**:done 任务 slug 集合 vs `invokes/by-task/` 目录名集合 diff;done 有而 invokes 无 → exit 2;反向仅 warn。
38
+ - **D4 · 文档同步**:`harness/prompts/30-execute-code.md` 交接物改为 close PASS 后方可归档;`harness/invokes/TEMPLATE_invoke.md` 补纪律行;`CHANGELOG.md` + 版本 **v2.2.0**(minor · 新增子命令)。
39
+ - **D5 · 试点**:正例(全链路 CLOSE: PASS)+ 反例(删 invoke → CLOSE: BLOCKED 且未 mv),两例 invoke 落盘。
40
+
41
+ ## 3. 非范围
42
+
43
+ - 历史 done 任务的 invoke 补录(ops-desk-api 已于 2026-07-20 人工补录 4 份)。
44
+ - 50 独立复检帽的机械闸;reviews/ 留档机械校验(后续可同模式扩展)。
45
+ - 自动 git commit / push / PR 创建。
46
+ - **归档自动重命名**(各仓惯例不一,见 R2 弃选项);由 `--target` 显式覆盖。
47
+ - 工作区 `_views/` 更新(编排仓特有,工具链不管)。
48
+
49
+ ---
50
+
51
+ ## 4. 验收标准
52
+
53
+ - [ ] `task close` 在以下情形分别 exit 2 + `CLOSE: BLOCKED · <原因>` 且**不执行** mv:
54
+ - [ ] invoke 目录缺失/为空(无 `.md`)
55
+ - [ ] `### 自检结论` 节缺失/为空/纯占位符
56
+ - [ ] `## 验收标准` 节存在未勾选 `- [ ]`(且无 `--allow-unchecked`)
57
+ - [ ] 文件名 slug ≠ 元信息表 `task_slug`,或 invoke 目录名 ≠ slug
58
+ - [ ] `> **状态**` 行首 token ∉ {`done`, `completed`}
59
+ - [ ] 目标 done 文件已存在(不覆盖)
60
+ - [ ] 源文件不在 `*/active/` 下且未给 `--target`
61
+ - [ ] 全部通过 + `--yes`:exit 0 + `CLOSE: PASS · <slug>` + 文件归档至同级 `done/`(basename 保留)
62
+ - [ ] 无 `--yes`:dry-run —— 只检不 mv,exit 0 时输出 dry-run 标注
63
+ - [ ] `task lint-done` 对 done/invokes 集合不一致 exit 2 并列出缺失 slug;invokes 多出仅 warn
64
+ - [ ] `npm test` 全绿(新增用例覆盖每条 fail 路径 + pass 路径)
65
+ - [ ] Prompt 库与 TEMPLATE_invoke 已同步新纪律;CHANGELOG 记 v2.2.0
66
+ - [ ] 试点仓正反两例 invoke 落盘
67
+
68
+ ---
69
+
70
+ ## 5. failure_paths
71
+
72
+ | 触发条件 | 系统行为 | 可重试 |
73
+ |----------|----------|--------|
74
+ | invoke 目录缺失/为空 | exit 2 · `CLOSE: BLOCKED · missing invoke snapshots` · 不 mv | 补录后重跑 |
75
+ | 自检结论未回填/占位符 | exit 2 · 不 mv | 40 回填后重跑 |
76
+ | 验收标准存在 `- [ ]` | exit 2(`--allow-unchecked` 时 warn 放行)· 不 mv | 勾选/豁免后重跑 |
77
+ | 状态非 done/completed | exit 2 · 列出当前状态 token · 不 mv | 改状态后重跑 |
78
+ | 目标 done 文件已存在 | exit 2 · 提示冲突 · 不覆盖 | 人工处理或用 `--target` |
79
+ | 源文件不在 `*/active/` 且无 `--target` | exit 2 · 拒绝(防对 done 文件二次 close) | 指定 `--target` |
80
+ | 业务仓未升级到新版本 | 旧版 CLI 无此命令 · 行为不变 | `upgrade` |
81
+
82
+ ---
83
+
84
+ ## 6. 依赖与引用
85
+
86
+ - 复用:`lib/task-meta.js`(`parseHarnessMeta` / `extractSection` 解析 md 元信息表与节)、`lib/paths.js`(`resolveTarget`)。
87
+ - 挂点先例:`lib/verify.js`(`VERIFY: PASS/BLOCKED` 末行协议 · exit 2 语义)。
88
+ - CLI 路由:`lib/cli.js` `cmdTask`(现仅 `check` 子命令,`close` / `lint-done` 为新增子路由)。
89
+ - Prompt 真值:`harness/prompts/30-execute-code.md`(invoke 落盘纪律行)、`harness/invokes/TEMPLATE_invoke.md`。
90
+ - 测试模式:`node --test test/*.test.js` + `test/fixtures/`。
91
+ - 根因分析原始出处:ops-desk-api 仓 2026-07-20 会话;补录 commit `f7845d9`。
92
+
93
+ ---
94
+
95
+ ## 7. 思考轮(10-spec 回填 · R0–R5)
96
+
97
+ ### R0 · 读入与约束
98
+
99
+ 读入:既有 task 草稿 v1(2026-07-20,维护者已定 A+C 方向);根因证据链(本次会话对 `lib/cli.js` / `lib/task.js` / `lib/verify.js` / `lib/task-meta.js` / `wizard/gate-check.sh` 源码核实,草稿证据属实);30 帽条文 invoke 纪律行;工作区与业务仓目录惯例实测。约束:HG-TASK-DRAFT / HG-AUDIT-R1 均 pending,本阶段只产出 SPEC + task 重起草,不触产品码。
100
+
101
+ ### R1 · 范围 / 非范围 / 场景
102
+
103
+ 角色与场景:① 业务仓 Agent 在 40 后执行 `task close` 归档(主场景);② CI / pre-merge 跑 `task lint-done` 防漏网(兜底场景);③ 维护者在编排仓(工作区)归档跨仓 task。**关键事实**:存在两套 task 目录约定 —— 业务仓 `docs/tasks/{active,done}`,工作区编排仓 `docs/harness/tasks/{active,done}`;且 done 命名惯例在实测中不一致(工作区 `task_<slug>_v1.md` 保名归档;ai-ink-brain-api-python `done_<slug>_YYYY-MM-DD.md`,另有 `done/chatbi/` 子目录)。→ close 的默认目标必须**从 `--file` 位置推导**(同级 `done/`、basename 保留),而非硬编码路径或命名。
104
+
105
+ ### R2 · 方案对比
106
+
107
+ | 方案 | 结论 | 理由 |
108
+ |------|------|------|
109
+ | A · `task close` 受闸归档 | **推荐(主)** | 挂点正确(归档时刻 invoke 已是既成事实);把手动 mv 收编为可 fail 动作 |
110
+ | B · verify 加 invoke 检查 | 弃 | 时间线错位:verify 在 30 开工前,invoke 在收尾,永远 fail |
111
+ | 仅 C · CI diff 兜底 | 弃(降级为兜底) | 事后检测,不能防单任务漏落;但存量/旁路 mv 仍需它 |
112
+ | 归档自动重命名(`task_<slug>_<YYYYMMDD>`) | 弃 | 实测各仓惯例不一(保名 / `done_` 前缀+连字符日期),机械重命名会制造不一致;保名 + `--target` 覆盖即可 |
113
+ | 方案 C 用 bash(wizard/*.sh) | 弃 | 仓内趋势:逻辑入 `lib/` + `node --test` 可单测;bash 仅薄壳。定为 `lib/task-lint-done.js` + `task lint-done` |
114
+ | close 交互确认(无 --yes 时 prompt) | 弃 | Agent 场景多为非 TTY;定为**默认 dry-run(只检不 mv)+ `--yes` 执行**,安全且脚本友好 |
115
+
116
+ ### R3 · 边界 / 失败语义 / 安全
117
+
118
+ - **状态行解析**(实测 13 个 active task 的状态行):词表含 `draft / in_progress / active / deferred / done`,个别无反引号(`> **状态**:done(…)`)。规则:取 `> **状态**` 行首个 `[a-z_]+` token(去反引号),close 仅接受 `done` / `completed`(`completed` 兼容早期草稿措辞);sidecar 不参与(md 为唯一真值,业务仓 task 未必有 sidecar)。
119
+ - **自检结论占位符**:节内容剔空后,每行匹配 `^([^)]*(回填|待填)[^)]*)$` 视为占位;无占位行且有实质内容才通过(覆盖 `(30/40 回填)`、`(30 回填)`、`(待填)` 变体)。
120
+ - **验收标准**:`## 验收标准` 节缺失 = fail(task 模板必含);`- [x]` / `- [X]` 均算已勾选;仅 `- [ ]` 阻塞。
121
+ - **slug 提取**(close 与 lint-done 共用):basename 去 `.md` → 去 `^(task_|done_)` 前缀 → 去 `_v\d+$` → 去 `_(YYYY-MM-DD|YYYYMMDD)$`;lint-done 递归扫描 done/ 一层以上子目录(实测存在 `done/chatbi/`)。
122
+ - **安全**:任何校验失败不 mv;目标已存在不覆盖;源文件不在 `*/active/` 且无 `--target` 拒绝执行(防对 done 文件二次 close);不碰 git。
123
+
124
+ ### R4 · 验收 / 可测性 / test_strategy
125
+
126
+ `test_strategy: required`。fixture 驱动:`test/task-close.test.js` 覆盖 §4 每条 fail 路径 + pass + dry-run;`test/task-lint-done.test.js` 覆盖三态(缺失 / 多余 warn / 一致)。沿用 `node --test` + `test/fixtures/` 现有模式,无新依赖。验收命令:`npm test` 全绿 + 试点仓正反两例实跑。
127
+
128
+ ### R5 · SPEC 签收就绪 · 是否可交 00 出 task
129
+
130
+ SPEC 自足:范围/非范围/验收/失败路径均可观测,方案对比与弃选留痕。**可交 00 重起草 task**(维护者已明示「SPEC→task 同会话」,且 task 草稿已由 00 创建,符合 10-spec 分界例外)。图谱:纯 Harness 工具链,无需 bootstrap。试点仓 `Ops-desk/ops-desk-api` 不在当前工作区 → task D5 定为「ops-desk-api 在场时优先,否则 fallback `harness-probe/`(manifest 2.1.1 · harness-only · 具备 docs/tasks + invokes/by-task)」。
131
+
132
+ ### 思考轮控制
133
+
134
+ | 字段 | 值 |
135
+ |------|-----|
136
+ | `actual_last_round` | `R5` |
137
+ | `early_stop` | `no` |
138
+ | `early_stop_reason` | — |
139
+ | `residual_risks` | ① 业务仓旧版 CLI 无 close 命令,纪律生效依赖 upgrade(D4 文档提示缓解);② 状态行/验收节解析依赖既有 task 文本惯例,异形 task 可能误 BLOCKED(以 fixture 覆盖 + `--allow-unchecked` 泄压);③ ops-desk-api 缺席时试点证据来自 fallback 仓 |
140
+ | `round_extension_note` | — |
141
+
142
+ ---
143
+
144
+ ## 修订记录
145
+
146
+ | 日期 | 摘要 |
147
+ |------|------|
148
+ | 2026-07-22 | 00 骨架 + 10-spec R0–R5 同会话回填(维护者委派)· 基于 task 草稿 v1 根因分析与本仓源码核实 |
@@ -44,6 +44,7 @@
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
48
  | commit | 落盘 + task 回填后再 commit(见 HANDOFF) |
48
49
 
49
50
  ---
@@ -21,6 +21,7 @@
21
21
  - `test_strategy: required` → **先** 可失败测试再改实现
22
22
  - 闸扫描通过后:运行 task **验证命令**;回填 `### 自检结论(执行者)`
23
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+)
24
25
 
25
26
  ## 禁止什么
26
27
 
package/lib/cli.js CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  } from './paths.js';
11
11
  import { runBash } from './run.js';
12
12
  import { checkTaskFile } from './task.js';
13
+ import { closeTaskFile } from './task-close.js';
14
+ import { lintDoneInvokes } from './task-lint-done.js';
13
15
  import { auditTarget } from './audit.js';
14
16
  import { mergePackageScripts } from './package-scripts.js';
15
17
  import {
@@ -41,6 +43,8 @@ function usage(version = 'unknown') {
41
43
  npx @cyning/harness verify [--target PATH] [--task FILE] [--graph] [--json] [--agent-hint] [--workspace-root PATH]
42
44
  npx @cyning/harness sync index [--target PATH]
43
45
  npx @cyning/harness task check --file PATH [--no-circular] [--registry DIR]...
46
+ npx @cyning/harness task close --file PATH [--target PATH] [--yes] [--allow-unchecked]
47
+ npx @cyning/harness task lint-done [--target PATH]
44
48
  npx @cyning/harness graph yaml compile --graph-id ID [--input DIR] [--output FILE]
45
49
  npx @cyning/harness graph yaml check --graph-id ID [--input DIR] [--graph-json FILE]
46
50
  npx @cyning/harness graph yaml compile --all [--input DIR]
@@ -58,6 +62,8 @@ function usage(version = 'unknown') {
58
62
  verify 30 前聚合:gate-check + audit D5 + S5 warn + 可选 --graph
59
63
  sync index 生成 .cyning-harness/invoke_index.json
60
64
  task check 校验 task.harness.v1.json sidecar · --no-circular 检测 depends_on 环
65
+ task close 受闸归档:5 项机械校验(invoke/自检结论/勾选/slug/状态)后 mv active→done(v2.2+)
66
+ task lint-done done 与 invokes/by-task slug 集合 diff · 缺失 exit 2(v2.2+)
61
67
  graph yaml Inform 图谱 YAML 编译 / 校验(v1.1+)
62
68
  graph ingest 扫描业务仓 → 追加 HGM 事件(v2.0+)
63
69
  graph snapshot 事件重放 → graph/snapshot.json(v2.0+)
@@ -489,12 +495,127 @@ async function cmdSync(args) {
489
495
 
490
496
  async function cmdTask(args) {
491
497
  const [sub, ...rest] = args;
492
- if (sub !== 'check') {
493
- const err = new Error(`task 子命令未知: ${sub ?? '(空)'}\n用法: task check --file PATH [--no-circular] [--registry DIR]...`);
498
+ if (sub === 'check') {
499
+ await cmdTaskCheck(rest);
500
+ return;
501
+ }
502
+ if (sub === 'close') {
503
+ await cmdTaskClose(rest);
504
+ return;
505
+ }
506
+ if (sub === 'lint-done') {
507
+ await cmdTaskLintDone(rest);
508
+ return;
509
+ }
510
+
511
+ const err = new Error(`task 子命令未知: ${sub ?? '(空)'}\n用法: task check --file PATH · task close --file PATH [--target PATH] [--yes] [--allow-unchecked] · task lint-done [--target PATH]`);
512
+ err.exitCode = 1;
513
+ throw err;
514
+ }
515
+
516
+ async function cmdTaskClose(args) {
517
+ if (args.includes('--help') || args.includes('-h')) {
518
+ console.log(`用法: npx @cyning/harness task close --file PATH [--target PATH] [--yes] [--allow-unchecked]
519
+
520
+ 受闸归档(方案 A · invoke 留档机械闸):
521
+ 5 项机械校验:invoke 留档 · 自检结论回填 · 验收标准勾选 · task_slug 一致 · 状态 done/completed
522
+ 默认 dry-run(只检不 mv);--yes 才执行 mv 到 active/ 同级 done/(basename 保留)
523
+ --target PATH 显式覆盖归档目标完整路径
524
+ --allow-unchecked 验收标准未勾选项 warn 放行
525
+ 任一校验失败 → exit 2 + CLOSE: BLOCKED · 不执行 mv。末行协议:CLOSE: PASS · <slug>。
526
+ `);
527
+ return;
528
+ }
529
+
530
+ const yes = args.includes('--yes');
531
+ const allowUnchecked = args.includes('--allow-unchecked');
532
+ let rest = args.filter((a) => a !== '--yes' && a !== '--allow-unchecked');
533
+ const { value: fileArg, rest: r1 } = takeOption(rest, '--file');
534
+ rest = r1;
535
+ const { value: targetArg, rest: r2 } = takeOption(rest, '--target');
536
+ rest = r2;
537
+
538
+ if (rest.length > 0) {
539
+ const err = new Error(`task close 未知参数: ${rest.join(' ')}`);
540
+ err.exitCode = 1;
541
+ throw err;
542
+ }
543
+ if (!fileArg) {
544
+ const err = new Error('task close 须指定 --file PATH');
494
545
  err.exitCode = 1;
495
546
  throw err;
496
547
  }
497
548
 
549
+ const result = closeTaskFile(fileArg, {
550
+ yes,
551
+ target: targetArg,
552
+ allowUnchecked,
553
+ cwd: process.cwd(),
554
+ });
555
+
556
+ for (const w of result.warnings) console.log(w);
557
+
558
+ if (!result.ok) {
559
+ console.log('阻塞清单:');
560
+ for (const b of result.blockers) console.log(` - ${b}`);
561
+ console.log(`CLOSE: BLOCKED · ${result.blockers[0]}`);
562
+ const err = new Error('');
563
+ err.exitCode = 2;
564
+ throw err;
565
+ }
566
+
567
+ if (result.dryRun) {
568
+ console.log(`mode: dry-run(未执行 mv · 加 --yes 执行)`);
569
+ console.log(`dest: ${result.dest}`);
570
+ } else {
571
+ console.log(`moved: ${result.file} → ${result.dest}`);
572
+ console.log('提示(未执行): ./scripts/graph-compile.sh · git commit');
573
+ }
574
+ console.log(`CLOSE: PASS · ${result.slug}`);
575
+ }
576
+
577
+ async function cmdTaskLintDone(args) {
578
+ if (args.includes('--help') || args.includes('-h')) {
579
+ console.log(`用法: npx @cyning/harness task lint-done [--target PATH]
580
+
581
+ 方案 C · CI 一致性兜底:diff 两个 slug 集合 ——
582
+ docs/tasks/done/ + docs/harness/tasks/done/(递归)解析出的 slug
583
+ vs docs/harness/invokes/by-task/ 目录名
584
+ done 有而 invokes 无 → exit 2 列缺失;invokes 多出仅 warn。
585
+ `);
586
+ return;
587
+ }
588
+
589
+ let rest = args;
590
+ const { value: targetArg, rest: r1 } = takeOption(rest, '--target');
591
+ rest = r1;
592
+
593
+ if (rest.length > 0) {
594
+ const err = new Error(`task lint-done 未知参数: ${rest.join(' ')}`);
595
+ err.exitCode = 1;
596
+ throw err;
597
+ }
598
+
599
+ const target = resolveTarget(process.cwd(), targetArg);
600
+ const result = lintDoneInvokes(target);
601
+
602
+ console.log(`目标: ${target}`);
603
+ console.log(`done slugs: ${result.doneCount} · invoke dirs: ${result.invokeCount}`);
604
+ for (const slug of result.extra) {
605
+ console.log(`warn: invokes 有而 done 无(进行中?): ${slug}`);
606
+ }
607
+ if (!result.ok) {
608
+ console.log('缺失 invoke 的 done 任务:');
609
+ for (const slug of result.missing) console.log(` - ${slug}`);
610
+ console.log(`LINT-DONE: FAIL · missing ${result.missing.length}`);
611
+ const err = new Error('');
612
+ err.exitCode = 2;
613
+ throw err;
614
+ }
615
+ console.log('LINT-DONE: PASS');
616
+ }
617
+
618
+ async function cmdTaskCheck(rest) {
498
619
  const noCircular = rest.includes('--no-circular');
499
620
  let filePath;
500
621
  const registryDirs = [];
@@ -0,0 +1,162 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractSection, extractTaskSlug, parseHarnessMeta } from './task-meta.js';
4
+
5
+ const PLACEHOLDER_RE = /^([^)]*(回填|待填)[^)]*)$/;
6
+ const STATUS_RE = /\*\*状态\*\*:?\s*`?([a-z_]+)/i;
7
+ const UNCHECKED_RE = /^\s*- \[ \]/;
8
+ const CLOSE_STATUSES = new Set(['done', 'completed']);
9
+
10
+ /**
11
+ * task close 机械校验 + 归档执行。
12
+ * 任一校验失败 → ok:false + blockers[],绝不 mv。
13
+ * 默认 dry-run(只检不 mv);yes=true 才执行归档。
14
+ */
15
+ export function closeTaskFile(filePath, options = {}) {
16
+ const {
17
+ yes = false,
18
+ target: targetArg,
19
+ allowUnchecked = false,
20
+ cwd = process.cwd(),
21
+ } = options;
22
+
23
+ const abs = path.resolve(cwd, filePath);
24
+ const blockers = [];
25
+ const warnings = [];
26
+
27
+ if (!fs.existsSync(abs)) {
28
+ return {
29
+ ok: false,
30
+ blockers: [`task 文件不存在: ${filePath}`],
31
+ warnings,
32
+ file: abs,
33
+ slug: extractTaskSlug(abs),
34
+ dest: null,
35
+ dryRun: !yes,
36
+ moved: false,
37
+ };
38
+ }
39
+
40
+ const content = fs.readFileSync(abs, 'utf8');
41
+ const meta = parseHarnessMeta(content);
42
+ const fileSlug = extractTaskSlug(abs);
43
+ const slug = meta.task_slug ?? null;
44
+
45
+ // 检查 4 · task_slug 一致
46
+ if (!slug) {
47
+ blockers.push('Harness 元信息表缺 task_slug');
48
+ } else if (slug !== fileSlug) {
49
+ blockers.push(`slug 不一致: 文件名 ${fileSlug} ≠ 元信息 task_slug ${slug}`);
50
+ }
51
+
52
+ // 布局推导:active/ 同级 done/ · repo root(docs 祖先)
53
+ const activeDir = path.dirname(abs);
54
+ const inActive = path.basename(activeDir) === 'active';
55
+ const repoRoot = findRepoRoot(activeDir);
56
+
57
+ // 检查 1 · invoke 留档
58
+ if (!repoRoot) {
59
+ blockers.push(`无法定位 repo root(${activeDir} 无 docs 祖先)`);
60
+ } else if (slug) {
61
+ const invokeDir = path.join(
62
+ repoRoot,
63
+ 'docs/harness/invokes/by-task',
64
+ slug,
65
+ );
66
+ const hasMd =
67
+ fs.existsSync(invokeDir) &&
68
+ fs.readdirSync(invokeDir).some((f) => f.endsWith('.md'));
69
+ if (!hasMd) {
70
+ blockers.push(
71
+ `missing invoke snapshots: ${path.relative(repoRoot, invokeDir)} 不存在或无 .md`,
72
+ );
73
+ }
74
+ }
75
+
76
+ // 检查 2 · 自检结论回填
77
+ const selfCheck = extractSection(content, '### 自检结论', '\n##');
78
+ if (!selfCheck) {
79
+ blockers.push('缺 ### 自检结论 节');
80
+ } else {
81
+ const bodyLines = selfCheck
82
+ .split('\n')
83
+ .slice(1)
84
+ .map((l) => l.trim())
85
+ .filter(Boolean);
86
+ const substantive = bodyLines.filter((l) => !PLACEHOLDER_RE.test(l));
87
+ if (substantive.length === 0) {
88
+ blockers.push('自检结论未回填(空或纯占位符)');
89
+ }
90
+ }
91
+
92
+ // 检查 3 · 验收标准勾选
93
+ const acceptance = extractSection(content, '## 验收标准', '\n##');
94
+ if (!acceptance) {
95
+ blockers.push('缺 ## 验收标准 节');
96
+ } else {
97
+ const unchecked = acceptance
98
+ .split('\n')
99
+ .filter((l) => UNCHECKED_RE.test(l));
100
+ if (unchecked.length > 0) {
101
+ if (allowUnchecked) {
102
+ warnings.push(`warn: 验收标准 ${unchecked.length} 项未勾选(--allow-unchecked 豁免)`);
103
+ } else {
104
+ blockers.push(
105
+ `验收标准 ${unchecked.length} 项未勾选(或 --allow-unchecked 显式豁免)`,
106
+ );
107
+ }
108
+ }
109
+ }
110
+
111
+ // 检查 5 · 状态字段
112
+ const statusLine = content.split('\n').find((l) => STATUS_RE.test(l));
113
+ const status = statusLine ? statusLine.match(STATUS_RE)[1].toLowerCase() : null;
114
+ if (!status) {
115
+ blockers.push('未找到 > **状态** 行');
116
+ } else if (!CLOSE_STATUSES.has(status)) {
117
+ blockers.push(`状态非 done/completed(当前: ${status})`);
118
+ }
119
+
120
+ // 归档目标
121
+ let dest = null;
122
+ if (targetArg) {
123
+ dest = path.resolve(cwd, targetArg);
124
+ } else if (!inActive) {
125
+ blockers.push('源文件不在 */active/ 且未指定 --target(拒绝对 done 文件二次 close)');
126
+ } else {
127
+ dest = path.join(path.dirname(activeDir), 'done', path.basename(abs));
128
+ }
129
+ if (dest && fs.existsSync(dest)) {
130
+ blockers.push(`目标已存在(不覆盖): ${dest}`);
131
+ }
132
+
133
+ const ok = blockers.length === 0;
134
+ let moved = false;
135
+ if (ok && yes && dest) {
136
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
137
+ fs.renameSync(abs, dest);
138
+ moved = true;
139
+ }
140
+
141
+ return {
142
+ ok,
143
+ blockers,
144
+ warnings,
145
+ file: abs,
146
+ slug: slug ?? fileSlug,
147
+ dest,
148
+ dryRun: !yes,
149
+ moved,
150
+ };
151
+ }
152
+
153
+ /** 从 startDir 上溯找到 basename === 'docs' 的祖先,返回其父(repo root);找不到返回 null */
154
+ function findRepoRoot(startDir) {
155
+ let cur = startDir;
156
+ while (true) {
157
+ if (path.basename(cur) === 'docs') return path.dirname(cur);
158
+ const parent = path.dirname(cur);
159
+ if (parent === cur) return null;
160
+ cur = parent;
161
+ }
162
+ }
@@ -0,0 +1,62 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractTaskSlug } from './task-meta.js';
4
+
5
+ const DONE_DIR_CANDIDATES = ['docs/tasks/done', 'docs/harness/tasks/done'];
6
+
7
+ /**
8
+ * 方案 C · done 任务 slug 集合 vs invokes/by-task 目录名集合 diff。
9
+ * done 有而 invokes 无 → ok:false(exit 2 由 CLI 决定);invokes 多出仅 extra(warn)。
10
+ */
11
+ export function lintDoneInvokes(target) {
12
+ const doneSlugs = new Map(); // slug → 相对路径(首个)
13
+
14
+ for (const rel of DONE_DIR_CANDIDATES) {
15
+ const dir = path.join(target, rel);
16
+ if (!fs.existsSync(dir)) continue;
17
+ for (const file of collectMarkdown(dir)) {
18
+ const slug = extractTaskSlug(path.basename(file));
19
+ if (!doneSlugs.has(slug)) {
20
+ doneSlugs.set(slug, path.relative(target, file).replace(/\\/g, '/'));
21
+ }
22
+ }
23
+ }
24
+
25
+ const invokeRoot = path.join(target, 'docs/harness/invokes/by-task');
26
+ const invokeSlugs = new Set();
27
+ if (fs.existsSync(invokeRoot)) {
28
+ for (const ent of fs.readdirSync(invokeRoot, { withFileTypes: true })) {
29
+ if (ent.isDirectory()) invokeSlugs.add(ent.name);
30
+ }
31
+ }
32
+
33
+ const missing = [...doneSlugs.keys()].filter((s) => !invokeSlugs.has(s)).sort();
34
+ const extra = [...invokeSlugs].filter((s) => !doneSlugs.has(s)).sort();
35
+
36
+ return {
37
+ ok: missing.length === 0,
38
+ missing,
39
+ missingFiles: missing.map((s) => doneSlugs.get(s)),
40
+ extra,
41
+ doneCount: doneSlugs.size,
42
+ invokeCount: invokeSlugs.size,
43
+ };
44
+ }
45
+
46
+ function collectMarkdown(dir) {
47
+ const out = [];
48
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
49
+ const full = path.join(dir, ent.name);
50
+ if (ent.isDirectory()) {
51
+ if (ent.name.startsWith('_') || ent.name.startsWith('.')) continue;
52
+ out.push(...collectMarkdown(full));
53
+ } else if (
54
+ ent.isFile() &&
55
+ ent.name.endsWith('.md') &&
56
+ !/^readme\.md$/i.test(ent.name)
57
+ ) {
58
+ out.push(full);
59
+ }
60
+ }
61
+ return out;
62
+ }
package/lib/task-meta.js CHANGED
@@ -206,7 +206,7 @@ export function listActiveTasks(target) {
206
206
  .sort();
207
207
  }
208
208
 
209
- function extractSection(content, startMarker, endMarker) {
209
+ export function extractSection(content, startMarker, endMarker) {
210
210
  const start = content.indexOf(startMarker);
211
211
  if (start === -1) return null;
212
212
 
@@ -218,6 +218,18 @@ function extractSection(content, startMarker, endMarker) {
218
218
  return content.slice(start, end);
219
219
  }
220
220
 
221
+ /**
222
+ * 从 task/done 文件名提取 slug:去 .md → 去 ^task_|done_ 前缀 → 去日期后缀 → 去 _vN 后缀。
223
+ * 兼容:task_<slug>_v1.md · done_<slug>_YYYY-MM-DD.md · done_<slug>_v1.md · <slug>.md
224
+ */
225
+ export function extractTaskSlug(fileName) {
226
+ let base = path.basename(fileName).replace(/\.md$/, '');
227
+ base = base.replace(/^(task_|done_)/, '');
228
+ base = base.replace(/_(\d{8}|\d{4}-\d{2}-\d{2})$/, '');
229
+ base = base.replace(/_v\d+$/, '');
230
+ return base;
231
+ }
232
+
221
233
  function normalizeCell(cell) {
222
234
  return cell.replace(/[`\\*]/g, '').trim();
223
235
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyning/harness",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "cyning-harness discipline package · init / upgrade / check CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",