@dommaker/harness 1.8.0 → 1.9.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.
Files changed (79) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/README.md +1 -1
  3. package/dist/cli/commands/check.d.ts +8 -0
  4. package/dist/cli/commands/check.d.ts.map +1 -1
  5. package/dist/cli/commands/check.js +10 -11
  6. package/dist/cli/commands/check.js.map +1 -1
  7. package/dist/cli/commands/status.d.ts +8 -0
  8. package/dist/cli/commands/status.d.ts.map +1 -1
  9. package/dist/cli/commands/status.js +5 -7
  10. package/dist/cli/commands/status.js.map +1 -1
  11. package/dist/cli/commands/sync-docs/capabilities-syncer.d.ts +22 -0
  12. package/dist/cli/commands/sync-docs/capabilities-syncer.d.ts.map +1 -1
  13. package/dist/cli/commands/sync-docs/capabilities-syncer.js +113 -8
  14. package/dist/cli/commands/sync-docs/capabilities-syncer.js.map +1 -1
  15. package/dist/cli/commands/sync-docs/index.d.ts.map +1 -1
  16. package/dist/cli/commands/sync-docs/index.js +36 -4
  17. package/dist/cli/commands/sync-docs/index.js.map +1 -1
  18. package/dist/cli/state-io.d.ts +34 -0
  19. package/dist/cli/state-io.d.ts.map +1 -0
  20. package/dist/cli/state-io.js +71 -0
  21. package/dist/cli/state-io.js.map +1 -0
  22. package/dist/hooks/bootstrap.d.ts +9 -12
  23. package/dist/hooks/bootstrap.d.ts.map +1 -1
  24. package/dist/hooks/bootstrap.js +9 -31
  25. package/dist/hooks/bootstrap.js.map +1 -1
  26. package/dist/hooks/index.d.ts +5 -7
  27. package/dist/hooks/index.d.ts.map +1 -1
  28. package/dist/hooks/index.js +4 -10
  29. package/dist/hooks/index.js.map +1 -1
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +3 -7
  33. package/dist/index.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/CONTEXT.md +2 -2
  36. package/src/__tests__/public-exports.test.ts +30 -4
  37. package/src/__tests__/public-type-surface.test.ts +0 -8
  38. package/src/cli/__tests__/state-io.test.ts +50 -0
  39. package/src/cli/commands/CONTEXT.md +4 -0
  40. package/src/cli/commands/__tests__/check-read-count.test.ts +3 -0
  41. package/src/cli/commands/__tests__/check.test.ts +20 -8
  42. package/src/cli/commands/__tests__/registry.test.ts +39 -3
  43. package/src/cli/commands/__tests__/status-extra.test.ts +25 -8
  44. package/src/cli/commands/__tests__/status.test.ts +46 -18
  45. package/src/cli/commands/__tests__/sync-docs-table-layout.test.ts +259 -0
  46. package/src/cli/commands/check.ts +20 -18
  47. package/src/cli/commands/status.ts +12 -7
  48. package/src/cli/commands/sync-docs/capabilities-syncer.ts +139 -9
  49. package/src/cli/commands/sync-docs/index.ts +41 -4
  50. package/src/cli/state-io.ts +53 -0
  51. package/src/hooks/CONTEXT.md +12 -22
  52. package/src/hooks/__tests__/bootstrap.test.ts +21 -70
  53. package/src/hooks/bootstrap.ts +9 -48
  54. package/src/hooks/index.ts +5 -20
  55. package/src/index.ts +1 -13
  56. package/dist/hooks/config.d.ts +0 -30
  57. package/dist/hooks/config.d.ts.map +0 -1
  58. package/dist/hooks/config.js +0 -34
  59. package/dist/hooks/config.js.map +0 -1
  60. package/dist/hooks/pipeline.d.ts +0 -36
  61. package/dist/hooks/pipeline.d.ts.map +0 -1
  62. package/dist/hooks/pipeline.js +0 -133
  63. package/dist/hooks/pipeline.js.map +0 -1
  64. package/dist/hooks/registry.d.ts +0 -70
  65. package/dist/hooks/registry.d.ts.map +0 -1
  66. package/dist/hooks/registry.js +0 -141
  67. package/dist/hooks/registry.js.map +0 -1
  68. package/dist/hooks/types.d.ts +0 -111
  69. package/dist/hooks/types.d.ts.map +0 -1
  70. package/dist/hooks/types.js +0 -9
  71. package/dist/hooks/types.js.map +0 -1
  72. package/src/__tests__/hooks-pipeline.test.ts +0 -201
  73. package/src/hooks/__tests__/config.test.ts +0 -19
  74. package/src/hooks/__tests__/pipeline.test.ts +0 -90
  75. package/src/hooks/__tests__/registry.test.ts +0 -115
  76. package/src/hooks/config.ts +0 -33
  77. package/src/hooks/pipeline.ts +0 -155
  78. package/src/hooks/registry.ts +0 -166
  79. package/src/hooks/types.ts +0 -118
package/src/CONTEXT.md CHANGED
@@ -34,7 +34,7 @@
34
34
 
35
35
  | 术语 | 定义 |
36
36
  |------|------|
37
- | 插件 | harness 扩展点统称 = hook / checker / 门禁(Gate) / 命令(CLI);非运行时插件容器——harness 是文件驱动 CLI、无常驻进程 |
37
+ | 插件 | harness 扩展点统称 = checker / 门禁(Gate) / 命令(CLI);非运行时插件容器——harness 是文件驱动 CLI、无常驻进程。hook 一族已退出扩展点统称(ADR-0027/#170 删 hooks 管线面,`src/hooks/` 只剩 bootstrap 组合根) |
38
38
  | Gate(门禁) | 统一守卫接口 `Gate{id, order, evaluate(ctx)}` → `GateDecision`;统一的是决策协议(id/order/三态),执行细节私有 |
39
39
  | GateDecision | 三态决策 `deny \| abstain \| ask`;deny 单调(下游不可改回 allow)、ask 枚举预留 fail-closed = deny |
40
40
  | GateResult | 报告结构(gate/passed/message/details/timestamp/duration),保留为报告层,不作决策 |
@@ -47,7 +47,7 @@
47
47
  | 飞轮指标(flywheel) | 知识条目的引用与消费度量(refCoverage / avgRefs / consumptionHitRate);唯一实现 `knowledge/flywheel-metrics.ts`(包内,不进导出面),canonical 分子为过滤 synthetic 后的 genuine refs,audit D6 / knowledge stats / knowledge health 共消费(ADR-0013,#81) |
48
48
  | 测试夹具(project fixture) | 临时项目根 + 落盘声明;唯一实现 `test-setup/project-fixture.ts`(测试层,不进导出面)——`config` 槽是 `.harness/config.yml` 落点唯一正本(字符串原样落盘,畸形/脏配置用例依赖此口径),`traces` 槽/`writeProjectTraces` 是 trace 落盘唯一正本(路径锚定 `DEFAULT_TRACE_FILE`、序列化走 `appendJsonl` 生产写链,harness#108),其余文件走 `files` 相对路径;非缺省根必须经 `parentDir` **显式** opt-out(cwd 锚定用例语义),回收经 `mkdtemp-cleanup` 劫持(harness#90) |
49
49
  | 运行级观察面(run env) | 一次 `harness check` 运行内对项目上行数据的只读视图,口径 = **同一文件至多读一次、跑完即弃、不做进程级全局**;唯一实现 `core/constraints/run-env.ts`,承载 `rawConfig()` / `customConstraints(file)` / `capabilities(population)` / `traceTail(limit)` / `sourceRoots()` 五个观察口(配置与能力表的读取原进程级缓存已按 ADR-0023 决策 2 撤销),与 git 证据(#87/ADR-0021)同形:入口构造、沿调用链显式传递。与 `CheckEnv` 的分工是构造顺序决定的——`CheckEnv` 含 `context`,而 `context` 是经本观察面读文件算出的产物,故观察面只承载「不需要 context 就能造」的部分,`CheckEnv` 以 `extends RunEnv` 从它派生(ADR-0023);`RunEnv` / `RunTarget`(= `RunEnv \| 项目根路径`)经 `./core` 以类型面导出,供消费方共享同一份读取。**只读是硬契约**:运行期状态文件的写口不走本面,走 StateIO(#148) |
50
- | 状态文件 IO(StateIO) | harness 自身运行期状态文件(`.harness/.state.json`)读写的可注入接缝:`read()`/`write()` 两方法、默认真实 fs 实现、命令可选参数注入(照 `CommandIO` 模式,但不扩 `CommandIO`);收编 check 智能提示与 status 两个写者,写语义统一为**读-改-写**(现状 `status` 不读就整文件重写,会把 `check` 写的 `shownHints` 抹掉 提示去重失效)。决策已于 2026-09-16 当人面确认为 **ADR-0026**(票面原写 ADR-0024,该号已被 #135 占用),**代码未落**,实现票 #148;与运行级观察面(只读、项目上行数据)分工:本面管 harness 自身状态、含写 |
50
+ | 状态文件 IO(StateIO) | harness 自身运行期状态文件(`.harness/.state.json`)读写的可注入接缝:`read()`/`write()` 两方法、默认真实 fs 实现 `fileStateIO(projectPath)`、命令可选参数注入(照 `CommandIO` 模式,但不扩 `CommandIO`);唯一实现 `src/cli/state-io.ts`(cli 层,不进包根导出面),收编 check 智能提示与 status 两个写者,写语义统一为**读-改-写**(此前 `status` 不读就整文件重写、把 `check` 写的 `shownHints` 抹掉致提示去重失效,ADR-0026 已修)。决策 = **ADR-0026**,实现票 #148;与运行级观察面(只读、项目上行数据)分工:本面管 harness 自身状态、含写 |
51
51
 
52
52
  ## 注意事项
53
53
  - 公共包,禁止硬编码业务路径
@@ -44,8 +44,6 @@ const EXPECTED_RUNTIME_EXPORTS = [
44
44
  'FileKnowledgeStore',
45
45
  'GATE_DEFINITIONS',
46
46
  'GUIDELINES',
47
- 'HookPipeline',
48
- 'HookRegistry',
49
47
  'IRON_LAWS',
50
48
  'KnowledgeAudit',
51
49
  'KnowledgeHealthScorer',
@@ -71,7 +69,6 @@ const EXPECTED_RUNTIME_EXPORTS = [
71
69
  'TraceAnalyzer',
72
70
  'TraceCollector',
73
71
  'assertGateRegistryClosed',
74
- 'assertHookRegistryClosed',
75
72
  'bootstrapHarness',
76
73
  'bootstrapHarnessSync',
77
74
  'checkBeforeExecution',
@@ -117,7 +114,6 @@ const EXPECTED_RUNTIME_EXPORTS = [
117
114
  'resolveGlobs',
118
115
  'runGates',
119
116
  'sanitizeExternalContent',
120
- 'toErrorStrategy',
121
117
  'validateAllSpecs',
122
118
  'validateSpec',
123
119
  'verifyContractPresence',
@@ -189,4 +185,34 @@ describe('包根类型面(ADR-0022 关联类型随迁)', () => {
189
185
  expect(liveCheck.allowed).toBe(true);
190
186
  expect(liveTask.command).toBe('npm test');
191
187
  });
188
+
189
+ /**
190
+ * ADR-0027(#170)管线面删除的可达性负钉:4 值符号 + 8 类型。
191
+ *
192
+ * 两道全量清单闸钉的是「码与名单不符」,本钉直接钉「barrel 源里不再出现这些名字」——
193
+ * 回灌时红名指到 barrel 本身,不必从清单 diff 反推。barrel 源形状即全可达面:
194
+ * `export *` 已被 `sub-barrels-explicit.test.ts` 禁到 src 下全部目录 barrel,
195
+ * 且 `./hooks` 不在 `package.json` 的 `exports` 内(无子路径入口可绕)。
196
+ */
197
+ const ADR0027_DELETED_SYMBOLS = [
198
+ 'HookRegistry',
199
+ 'HookPipeline',
200
+ 'assertHookRegistryClosed',
201
+ 'toErrorStrategy',
202
+ 'HookDefinition',
203
+ 'HookConfig',
204
+ 'EffectiveHook',
205
+ 'HookErrorStrategy',
206
+ 'HookExecutionRecord',
207
+ 'HookPhase',
208
+ 'HookResult',
209
+ 'PipelineResult',
210
+ ];
211
+
212
+ for (const rel of ['../hooks/index.ts', '../index.ts']) {
213
+ it(`${rel} 不再提及 ADR-0027 已删的管线面符号(保留面 bootstrapHarness* 不受影响)`, () => {
214
+ const source = fs.readFileSync(path.join(__dirname, rel), 'utf-8');
215
+ expect(ADR0027_DELETED_SYMBOLS.filter((name) => source.includes(name))).toEqual([]);
216
+ });
217
+ }
192
218
  });
@@ -170,7 +170,6 @@ const PUBLISHED_ENTRY_TYPES: Record<string, string[]> = {
170
170
  'DocRegexCountCheck',
171
171
  'DocsSyncConfig',
172
172
  'EffectiveConfigLint',
173
- 'EffectiveHook',
174
173
  'ErrorClassificationRule',
175
174
  'ErrorClassifierConfig',
176
175
  'EventHandler',
@@ -189,12 +188,6 @@ const PUBLISHED_ENTRY_TYPES: Record<string, string[]> = {
189
188
  'GovernanceConfig',
190
189
  'GrepCountActual',
191
190
  'HarnessBootstrap',
192
- 'HookConfig',
193
- 'HookDefinition',
194
- 'HookErrorStrategy',
195
- 'HookExecutionRecord',
196
- 'HookPhase',
197
- 'HookResult',
198
191
  'ImportInfo',
199
192
  'IndexEntry',
200
193
  'IngestOptions',
@@ -217,7 +210,6 @@ const PUBLISHED_ENTRY_TYPES: Record<string, string[]> = {
217
210
  'PerformanceGateConfig',
218
211
  'PerformanceThresholds',
219
212
  'PhaseFormatResult',
220
- 'PipelineResult',
221
213
  'ProjectConfig',
222
214
  'QueryBudget',
223
215
  'QueryFilter',
@@ -0,0 +1,50 @@
1
+ /**
2
+ * fileStateIO 直测(ADR-0026):真实 fs、临时目录,不 mock。
3
+ *
4
+ * 命令侧注入面见 check.test.ts / status.test.ts 的内存假件;
5
+ * 本套件只钉缺省实现自身的语义:缺失 → {}、写-读回环、损坏照现状抛、路径锚 projectPath。
6
+ */
7
+
8
+ import * as fs from 'fs';
9
+ import * as os from 'os';
10
+ import * as path from 'path';
11
+ import { fileStateIO } from '../state-io';
12
+
13
+ describe('fileStateIO(真实 fs 缺省实现,ADR-0026)', () => {
14
+ let dir: string;
15
+
16
+ beforeEach(() => {
17
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-state-io-'));
18
+ });
19
+
20
+ afterEach(() => {
21
+ fs.rmSync(dir, { recursive: true, force: true });
22
+ });
23
+
24
+ it('文件缺失 → read() 返回 {}', () => {
25
+ expect(fileStateIO(dir).read()).toEqual({});
26
+ });
27
+
28
+ it('空文件(0 字节)→ read() 返回 {}(ADR-0026 决策 1「缺失/空」口径)', () => {
29
+ fs.mkdirSync(path.join(dir, '.harness'), { recursive: true });
30
+ fs.writeFileSync(path.join(dir, '.harness', '.state.json'), '');
31
+
32
+ expect(fileStateIO(dir).read()).toEqual({});
33
+ });
34
+
35
+ it('写-读回环:write() 自动建目录,落点锚 projectPath 下的 .harness/.state.json', () => {
36
+ const stateIO = fileStateIO(dir);
37
+ stateIO.write({ shownHints: ['trace_50'], lastStatusRun: '2026-09-17T00:00:00.000Z' });
38
+
39
+ const onDisk = path.join(dir, '.harness', '.state.json');
40
+ expect(fs.existsSync(onDisk)).toBe(true);
41
+ expect(stateIO.read()).toEqual({ shownHints: ['trace_50'], lastStatusRun: '2026-09-17T00:00:00.000Z' });
42
+ });
43
+
44
+ it('损坏文件照现状抛(不兜底成 {},ADR-0026 决策 4)', () => {
45
+ fs.mkdirSync(path.join(dir, '.harness'), { recursive: true });
46
+ fs.writeFileSync(path.join(dir, '.harness', '.state.json'), 'not json');
47
+
48
+ expect(() => fileStateIO(dir).read()).toThrow();
49
+ });
50
+ });
@@ -10,6 +10,7 @@ H5(#44)起:
10
10
  ## 核心导出
11
11
  - `COMMAND_DEFINITIONS`(definitions.ts)— 全部非门禁命令定义(纯数据模块、零闭包,禁止 import 命令实现;ADR-0010)
12
12
  - 命令契约(`src/cli/command-contract.ts`,上层目录)— `CommandResult` / `CommandKind` / `CommandIO` / `processIO` / `captureIO` / `lastJsonOutput` / `log` / `logError`
13
+ - 状态文件接缝(`src/cli/state-io.ts`,上层目录)— `.harness/.state.json` 读-改-写的唯一入口 `StateIO`(`read()`/`write()`)+ 状态类型 `HarnessState` + 缺省真实 fs 实现 `fileStateIO(projectPath)`;`check` / `status` 经可选 `stateIO` 参数注入(ADR-0026,harness#148)
13
14
  - 门禁命令共享面(`src/cli/gate-command.ts`,上层目录)— `GateDecision → CommandResult` 的唯一映射 `gateCommandResult`,加 ✓/✗ 输出骨架 `reportGateDecision` 与出错横幅 `reportGateError`。同一句失败措辞此前抄在 6 个 handler 里(「`<id>` gate denied」×6 / 「`<id>` gate error」×4),门禁特有的指标行经 `onPass`/`onFail` 闭包传入(架构评审候选1)
14
15
  - 脚手架落盘面(`src/cli/commands/scaffold.ts`)— 受管文件(managed file)三态判定 `writeManagedFile` / 一组落盘 `runPlan` + 9 个站点工厂(含对外文案);模板正文住 `scaffold-templates.ts`,落盘注入面 `ScaffoldFileSystem` 可换内存替身(harness#132);CI 站点工厂 `harnessCheckCiFile` 带平台维度(github / gitlab,harness#143);两道 Git hook 工厂 `preCommitHookFile` / `prePushHookFile`(后者 harness#144,带可执行位)
15
16
  - knowledge 投影面(`src/cli/commands/knowledge-view.ts`)— 11 个 knowledge 子操作的**display model** 渲染与出口:`emitKnowledgeView`(json/人读唯一分派 + 退出码)、`announce`(取数期进度行,`--json` 下静默)、`TONE_STYLES` 角色→样式单表、维度/规则 label 与 `toneForMaturity`/`toneForScore`/`toneForSeverity` 三张映射、`resolveKnowledgeBaseDir` + `openKnowledgeStore`(路径兜底与 store 构造单点)(harness#133)
@@ -46,6 +47,8 @@ H5(#44)起:
46
47
  - **一次命令运行内,同一份数据至多取一次(harness#146,#140 A 票;与 ADR-0023 同族,但不扩 `RunEnv` 公共面)**:`spec-baseline-check` 的三条验证路径(文件存在性 / 依赖 / 代码模式)一律从命令级共享索引 `createBaselineIndex(projectPath)` 取数,不再各自裸调 `fs`——全仓 `.ts`/`.js` 内容扫描一遍建成 `路径 → 内容` 表、`package.json` 读解析一次(**读取失败态同样入库**,不逐条重试)、同一落点的存在性探测只 stat 一次;逐条前置与每个关键词只在索引上重放判定。索引**懒建**(没有前置需要某类数据时该类读取一次都不发生)、**运行结束即弃**(不跨命令复用:本命令是独立命令而非 `harness check` 的子步骤,「同一口径取一次」命令内自足即够,#140 triage 裁决 5)。改前代价 = 前置条件数 × 关键词数 × 源文件数,改后与条数无关,判定/证据文案/stdout/退出码逐字不变。**驻留上界(harness#162)**:索引容量有双上限 `SOURCE_INDEX_MAX_ENTRIES = 5000` / `SOURCE_INDEX_MAX_BYTES = 64MB`——超任一上限即放弃驻留、**回落逐文件流式读**(每个关键词重扫一遍,读完即弃),峰值内存由 O(全仓源码) 回到 O(单文件),正常规模仓保留「一次遍历」收益;取数面相应由 `sourceContents(): Map` 改为 `countFilesContaining(keyword)`,容量策略全部收在索引内部。机器可检:`__tests__/spec-baseline-read-count.test.ts`(形状照 `check-read-count.test.ts`:`readFileSync`·只读 `openSync`·`existsSync`·`readdirSync` 记件 + 整张读取表与目录扫描表逐条冻结 + 「同一份数据的同一口径至多取一次」+ N×K 缩放不变式 + 懒建不扫 + 失败态入库)+ `__tests__/spec-baseline-index-cap.test.ts`(驻留上界闸:上限内每文件至多读一遍、超条目/字节上限回落流式且判定不变)
47
48
  - **先判定「本次要什么」再决定「读什么」,且清单顺序是对外面(harness#147,#140 B 票)**:`sync-docs/index.ts` 把 CAPABILITIES.md 的**格式判定放在源码扫描之前**——capability-listing 格式的判定面是计数、不做条目级比对,模块清单在本次运行里无人消费,于是整棵源码树**一份 `.ts` 内容都不读**(改造前是无条件全树逐文件 `await readFile`,只为首行注释)。要描述的分支(file-table)照旧取描述,但 `project-reader.ts` 分两步:先按目录遍历序定清单,再经 `extractFileDescriptions()` **并发**取数(上限固定 16,按票下裁决不做成配置面)并**按原索引回填**。清单顺序对外可见(`--check` 逐行比对生成的表格、人看 diff),票下裁决是「顺序逐字保持现状、不改字典序」,故「按并发完成顺序 append」属行为漂移而非优化。读盘次数之外一切不变:判定、漂移检测、写回内容逐字保持。机器可检:`__tests__/sync-docs-read-count.test.ts`(形状照 `check-read-count.test.ts`——listing 模式 `.ts` 内容读 = 0 + file-table 反证每份文件恰读一次 + 整张读取表冻结)、`sync-docs/__tests__/module-descriptions.test.ts`(替身把「起始序」与「完成序」刻意错开:串位或重排即红,另钉上限)、`__tests__/sync-docs-module-order.test.ts`(生成表格行序 = 目录遍历序、两次运行逐字复现)
48
49
 
50
+ - **撤登记要整行删,排版判定与修复共用一份正本(harness#171)**:`capabilities-syncer.ts` 删幽灵行此前是 `content.replace(/^\|…$/gm, '')`——`m` 下 `^…$` 只框住行内容、**不含行尾换行**,删完留一个空行;CommonMark 以空行断表,于是每撤一次登记就把一张能力表多切一刀(studio 存量攒到 40 处)。同函数末尾的 `\n{3,}→\n\n` 救不了它:删一行只产两个换行,够不着阈值(删相邻两行也不变好——只是把两刀并成一刀)。现行形状:① 删除按行 split/filter/join,行尾随之消失(CRLF 一并处理,正则尾上加 `\r?` 保持与旧 `m` 相同的匹配面);② 排版收拢正本是 `normalizeCapabilitiesTableLayout(content)`——规则①删两侧都是表格行的空行段、规则②收掉无数据行的表头/分隔行,**表格外的段落空行不动**,且**两条规则互相制造触发点**(①吃掉两张空表之间那个空行后,②在同一轮只收得掉后一张),故**跑到不动点**:一次 `sync-docs` 必须把 `--check` 报出来的全清掉,否则下游 CI 修完还红;③ 原有的一处 `\n{3,}→\n\n` 由「增删之后」移到**收拢之后**(收掉一张夹在两段散文之间的空表本身会新产 `\n\n\n`,留在前面就漏折叠),并删掉增删分支内那份重复折叠。围栏代码块(``` / ~~~)内的行在**排版收拢一侧整体豁免**(CAPABILITIES.md 是手写文档,块内长得像表格的示例行不是脏行:不给豁免时一块「表头+分隔行」示例被判空表删掉正文、`--check` 还为此判红)——但**幽灵行删除一侧刻意不豁免**,两侧口径故意不同:登记条目由 `core/constraints/capabilities-parser` 全文扫描得出(ADR-0009),围栏内的行同样算登记项,只让删除认围栏而条目不认,块内示例引用的已删文件会永远报成幽灵条目且 `--check` 修不掉(机器可检:「围栏内示例行引用已删文件」用例同时钉收敛与「只吃掉那一行、块内其余正文留着」)。让两侧同认围栏 = 改 ADR-0009 的登记面并连带影响 docs_freshness 判定,另票裁决。`--check` 的判定面就是「它返回的内容与入参是否不同」,写模式在**增删之后**调用收拢(先收拢会误删「本轮要往空表里补新行」的表头),故 check 报得出、fix 必清得掉(不重演 2026-08-04 studio PR #44 的「check 永不收敛」)。机器可检:`__tests__/sync-docs-table-layout.test.ts`(13 例:中间行/末行/目录条目行/CRLF 四种撤登形状 + 存量脏行 check 判红后写模式转绿 + 空表收掉 + 两张空表相邻一次收干净 + 收拢后不留三连换行 + 四条反向闸「有新行不误删表头」「表格外空行不动」「代码块内空表不被收」「代码块内表格样式行不被收拢」)
51
+
49
52
  ## 注意事项
50
53
  - 新增命令需同步更新 CLAUDE.md / CAPABILITIES.md / src/CONTEXT.md
51
54
  - **scaffold 三态模型(harness#132 已落地)**:init/validate 的「目标在不在场→写或告知」判定(9 站点 = init 7 + validate 2,#132 收口时 8 处)收口为 scaffold 模块。术语:**managed file**(受管文件)= harness 模板拥有正本、用户可持有的落盘文件;三态 = `created`(不在场→落盘)/ `exists`(在场→告知跳过,**不细分内容是否等于模板**,内容比较是独立特性)/ `manual`(在场→打印片段请用户手工合并)。纪律:scaffold 不持有覆盖/跳过策略(`--no-git-hooks`/`--no-github-actions` 由命令层翻成 plan 列表,scaffold 只见 plan);模板留代码内住 `scaffold-templates.ts`,不落 `templates/`(其无运行时消费者、integrity 清单不覆盖);**打印给用户的片段必须是落盘正文的一部分**(GH Actions 站点冲突分支打印完整 workflow 全文而非 job 片段,#103 判据);`--print-snippets` 的片段视图同判据且**由正本裁剪派生**(harness#153:`GITHUB_ACTIONS_SNIPPET` = `HARNESS_CHECK_WORKFLOW` 的 `jobs:` 段之后,形状与 GitLab 侧「打印的是落盘正文的尾巴」对仗。它曾是手抄的第二份文本,比落盘正本少 `validate` / `passes-gate` 两道门禁——照抄的用户拿到比同版本 `harness init` 弱一半的 CI;引导语随之改准为「以下正文取自 harness-check.yml 的 job 段」,`--print-snippets` 旗帜本身保留);gitlab 形同判据由 harness#157 补齐——`printSnippets` 收 `governanceLevel`、打 `renderGitLabCiJobs(governanceLevel)`,与落盘 / 冲突分支同一个正本函数(此前打无 level 的 `GITLAB_CI_SNIPPET`,`-g` 档照抄的用户静默少拿治理与 docs 新鲜度两个任务);CLAUDE.md/AGENTS.md 标记化幂等写(读-改-写形状)不纳入 scaffold;死选项 `-t/--type` 已随本票删除。GitLab CI 接线决议见下条;pre-push 站点已随 harness#144 落地,见「本地防线两道」条
@@ -75,3 +78,4 @@ H5(#44)起:
75
78
  - **本目录零 `process.exit` / `process.exitCode`**:kind → 退出码的唯一映射在 `bin/harness.js`(`ok`/`skip` → 0,`fail`/`usage-error` → 1,未知 kind fail-closed)。历史上两处条件式(`command --level` 按严重级、门禁按 passed)已由命令侧译成 kind;`sync-docs --check` 的漂移改由实现返回 `fail`(定义表的 `afterRun` 逃生门随之废除)
76
79
  - **输出不得直接用 console**:流式打印走 `log(io, …)` / `logError(io, …)`(`util.format` + 换行,与 console 逐字节等价,见 `src/cli/__tests__/command-contract.test.ts`);测试断言输出用 `captureIO()`,不再 `spyOn(process, 'exit')`;`--json` 输出断言用 `lastJsonOutput(io)`(JSON.parse 正本,harness#108)。例外:`constraints retire` 交互正文仍走 console(其 `RetireIO` 只注入 readline 流),退役结果打印已接注入流
77
80
  - **#95 同型扫荡结论**:两门禁站点已修(`passes-gate` 执行/证据落 projectPath、`acceptance` 去掉 `tasksPath: './tasks.yml'` 相对默认值)。逐点判定后的豁免四处,理由与站点原文一起冻结在 `__tests__/project-path-convention.test.ts` 的豁免表:`release` 的 `pkgPath`(定义表本无 `-p`,pkgPath 就是该命令唯一的根且已逐个传给每条 `run(cmd, pkgPath)`,不构成半失效;给发布流水线新增 `-p` 属新能力)、`command` 的 `evaluate({ projectPath: process.cwd(), … })`(`CommandGate` 只做命令串正则判定、零文件读写,且该命令定义表里没有 `-p`,取 cwd 仅为满足 `GateContext` 形状,架构评审候选1)、`constraints` 的 `join(process.cwd(), 'package.json')`(报的是 harness 自身包版本,主锚 `__dirname`,cwd 仅兜底)、`core/spec/validator.ts` 的 `schemaPath: './specs/schemas'`(**确是同型病灶**:`validateAll(projectPath)` 用 projectPath 找 spec 文件、却用 cwd 找 schema;但 `validateFile`/`loadSchema` 签名里没有根,补齐要穿透整个 spec 域,留待 spec 单票收口)
81
+ - **另知:CAPABILITIES.md 排版面两处已知限制(harness#171 未修)**:① 收拢的围栏豁免只认 ` ``` ` / `~~~` 标记行,**4 空格以上缩进的代码块不豁免**(触发条件:手写 CAPABILITIES.md 用缩进而非围栏放表格示例,块内空行会被当排版脏行收掉)——缩进 ≥4 在 CommonMark 里与「缩进表格」本身歧义,现有豁免取保守侧(宁可少动),要收口得连 `TABLE_ROW_REGEX` 的缩进上界一起定;② **module 模式下整张表被收空后,下一次写会走「无表格」分支自动补一整张目录表**(`updateCapabilitiesFile` 的 `existingFiles.length === 0` else 分支),与同文件「目录条目需人工策划登记」的约定冲突——非 #171 引入(撤登记前留下的「空行 + 表头」形状经 `parseCapabilitiesFiles` 同样解析为空集,实测两版行为一致),且登记面认不认围栏要连 ADR-0009 一起裁决,另票。
@@ -90,6 +90,8 @@ function fixtureRepo(): string {
90
90
  write(dir, 'src/existing.ts', 'export const a = 1;\n');
91
91
  write(dir, 'src/nested/deep.ts', 'export const d = 1;\n');
92
92
  write(dir, '.harness/logs/traces.log', '{"constraintId":"fixture","result":"pass"}\n');
93
+ // ADR-0026:状态文件进夹具,智能提示的状态读取随之进计数表(一次运行至多读一次)
94
+ write(dir, '.harness/.state.json', '{}');
93
95
  git(dir, 'add', '.');
94
96
  git(dir, 'commit', '-q', '-m', 'baseline');
95
97
  // 改一个已登记的源文件并 stage → 触发条件 module_modification
@@ -139,6 +141,7 @@ describe('一次 check 的文件读取计数闸(ADR-0023 决策 5 ①)', ()
139
141
  ['.harness/custom-constraints.yml', 1],
140
142
  ['CAPABILITIES.md', 1],
141
143
  ['.harness/logs/traces.log', 2],
144
+ ['.harness/.state.json', 1], // ADR-0026:智能提示经 StateIO 读状态,恰一次
142
145
  // —— 已知例外(改动需明写理由)——
143
146
  // traces.log = 2:head 50(智能提示的阈值判定,决策 3)与 tail 20(有无失败证据,
144
147
  // 决策 4)是两个不同窗口的**有界**读;并成一个窗口就等于回到整读。
@@ -18,6 +18,7 @@ import { execFileSync } from 'child_process';
18
18
 
19
19
  import { check, listLaws } from '../check';
20
20
  import { captureIO, type CapturingIO } from '../../command-contract';
21
+ import type { HarnessState, StateIO } from '../../state-io';
21
22
  import { DEFAULT_TRACE_FILE } from '../../../types/trace';
22
23
  import {
23
24
  createGitEvidence,
@@ -92,6 +93,19 @@ function projectTraces(
92
93
  .map(line => JSON.parse(line));
93
94
  }
94
95
 
96
+ /**
97
+ * 内存 StateIO 假件(ADR-0026):状态相关测试经注入面驱动,不碰真文件系统。
98
+ * `snapshot()` 给断言用——读的是假件当前持有的状态,不是磁盘。
99
+ */
100
+ function memoryStateIO(initial: HarnessState = {}): StateIO & { snapshot(): HarnessState } {
101
+ let state = initial;
102
+ return {
103
+ read: () => state,
104
+ write: (next: HarnessState) => { state = next; },
105
+ snapshot: () => state,
106
+ };
107
+ }
108
+
95
109
  /** 计数 git 证据:既记录证据方法请求,也记录实际 spawn 的 git 命令(执行走真 adapter) */
96
110
  function recordingEvidence(projectPath: string): {
97
111
  evidence: GitEvidence;
@@ -482,27 +496,25 @@ describe('check command(真 git fixture)', () => {
482
496
  });
483
497
  });
484
498
 
485
- describe('智能提示(真 traces.log 与状态文件)', () => {
499
+ describe('智能提示(真 traces.log + 注入 StateIO 假件)', () => {
486
500
  it('记录数首次达到 50 时提示,并落盘已提示状态', async () => {
487
501
  const dir = gitRepo();
488
502
  passTraces(dir, 50);
503
+ const stateIO = memoryStateIO();
489
504
 
490
- await check({ preset: 'standard', staged: true, projectPath: dir, trigger: 'manual' }, io);
505
+ await check({ preset: 'standard', staged: true, projectPath: dir, trigger: 'manual', stateIO }, io);
491
506
 
492
507
  expect(io.outText()).toContain('记录已足够,运行 harness status 查看统计');
493
508
  expect(io.outText()).toContain('────────────────');
494
- const state = JSON.parse(
495
- fs.readFileSync(path.join(dir, '.harness', '.state.json'), 'utf-8')
496
- );
497
- expect(state.shownHints).toContain('trace_50');
509
+ expect(stateIO.snapshot().shownHints).toContain('trace_50');
498
510
  });
499
511
 
500
512
  it('已提示过的不再重复提示', async () => {
501
513
  const dir = gitRepo();
502
514
  passTraces(dir, 50);
503
- write(dir, '.harness/.state.json', JSON.stringify({ shownHints: ['trace_50'] }));
515
+ const stateIO = memoryStateIO({ shownHints: ['trace_50'] });
504
516
 
505
- await check({ preset: 'standard', staged: true, projectPath: dir, trigger: 'manual' }, io);
517
+ await check({ preset: 'standard', staged: true, projectPath: dir, trigger: 'manual', stateIO }, io);
506
518
 
507
519
  expect(io.outText()).not.toContain('记录已足够');
508
520
  expect(io.outText()).not.toContain('────────────────');
@@ -15,6 +15,7 @@ import * as path from 'path';
15
15
  import { spawnSync } from 'child_process';
16
16
  import { COMMAND_DEFINITIONS, type CommandDefinition, type CommandImplRef } from '../definitions';
17
17
  import { GATE_DEFINITIONS } from '../../../gates/definitions';
18
+ import { FileKnowledgeStore } from '../../../knowledge/store';
18
19
 
19
20
  function collectRefs(defs: CommandDefinition[]): CommandImplRef[] {
20
21
  const refs: CommandImplRef[] = [];
@@ -189,8 +190,17 @@ const hasDist = fs.existsSync(distCommands);
189
190
  * 经 NODE_OPTIONS=--require 预加载探针 spawn bin(进程退出时打印加载的
190
191
  * 命令实现模块清单)。不用 node -e(commander 在 -e 下走 eval 分支,
191
192
  * argv 解析不同,会误判位置参数)。
193
+ *
194
+ * env 用于把命令指向临时夹具(见「别名子命令」用例),第二参随调用方并入。
195
+ * maxBuffer 显式抬高:spawnSync 缺省 1MB,子进程 stdout 超限是被 ENOBUFS **杀掉**
196
+ * 而非报错——status 变 null、stderr 一句解释都没有,看着像路由崩了。2026-09-20 本机
197
+ * 发版即以此形状暴露(真实库 221 条 / 1.35MB JSON)。抬高上限只是别再吞掉诊断信息,
198
+ * **数据面隔离仍由各用例的夹具负责**,不拿它当免罪符。
192
199
  */
193
- function runWithModuleProbe(argv: string[]): { status: number | null; implModules: string[]; stdout: string; stderr: string } {
200
+ function runWithModuleProbe(
201
+ argv: string[],
202
+ extraEnv: Record<string, string> = {},
203
+ ): { status: number | null; implModules: string[]; stdout: string; stderr: string } {
194
204
  const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-lazy-probe-'));
195
205
  const probeFile = path.join(probeDir, 'probe.js');
196
206
  fs.writeFileSync(probeFile, [
@@ -204,7 +214,8 @@ function runWithModuleProbe(argv: string[]): { status: number | null; implModule
204
214
  cwd: repoRoot,
205
215
  encoding: 'utf-8',
206
216
  timeout: 30000,
207
- env: { ...process.env, NODE_OPTIONS: `--require ${probeFile}` },
217
+ maxBuffer: 32 * 1024 * 1024,
218
+ env: { ...process.env, NODE_OPTIONS: `--require ${probeFile}`, ...extraEnv },
208
219
  });
209
220
  const match = r.stderr.match(/IMPL_MODULES=(.*)/);
210
221
  return {
@@ -270,7 +281,32 @@ smoke('bin/harness.js 端到端(dist 存在时)', () => {
270
281
  });
271
282
 
272
283
  it('别名子命令解析到同一实现(knowledge ls = list,候选7)', () => {
273
- const r = runWithModuleProbe(['knowledge', 'ls', '--json']);
284
+ // 数据面隔离:本用例判的是**路由**(别名 ls 与主名 list 命中同一实现、只加载 knowledge
285
+ // 侧两个模块),知识库内容纯属旁证。不指夹具时它读的是操作机的真实库(本机实测 221 条 /
286
+ // 1.35MB JSON),而 CI 全新 checkout 上该库为空——两侧取数规模差三个数量级,大的一侧
287
+ // stdout 越过 spawnSync 缺省缓冲直接被杀掉(形状见 runWithModuleProbe 注释)。
288
+ // 夹具目录由 src/test-setup/mkdtemp-cleanup.ts 在 afterAll 统一回收。
289
+ const kbDir = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-smoke-kb-'));
290
+ new FileKnowledgeStore({ baseDir: kbDir }).save({
291
+ id: 'SMOKE-001',
292
+ type: 'guideline',
293
+ title: 'Smoke Fixture Entry',
294
+ content: '别名路由用例的夹具条目,只为了让 --json 出口有真实记录可数',
295
+ maturity: 'verified',
296
+ layer: 'project',
297
+ created: new Date().toISOString(),
298
+ lastReferenced: new Date().toISOString(),
299
+ contributors: ['tester'],
300
+ projects: ['smoke'],
301
+ tags: [],
302
+ applicablePhases: [],
303
+ sourceReferences: [],
304
+ referencedBy: [],
305
+ executionResults: [],
306
+ consumptionMode: 'reference',
307
+ origin: 'system',
308
+ });
309
+ const r = runWithModuleProbe(['knowledge', 'ls', '--json'], { KNOWLEDGE_BASE_DIR: kbDir });
274
310
  expect(r.status).toBe(0);
275
311
  expect(JSON.parse(r.stdout)).toHaveProperty('total');
276
312
  expect(r.implModules).toEqual([
@@ -3,10 +3,12 @@
3
3
  *
4
4
  * ADR-0020 起不再 mock TraceAnalyzer/TraceCollector:异常判定走真实纯函数,
5
5
  * 用例喂能真的判出异常的 trace(passRate < 0.3 → low_pass_rate)。
6
+ * ADR-0026 起状态文件读写经注入的 StateIO 假件,不碰(被 mock 的)fs。
6
7
  */
7
8
 
8
- import { status } from '../status';
9
- import { captureIO, type CapturingIO } from '../../command-contract';
9
+ import { status, type StatusOptions } from '../status';
10
+ import { captureIO, type CapturingIO, type CommandResult } from '../../command-contract';
11
+ import type { HarnessState, StateIO } from '../../state-io';
10
12
  import * as fs from 'fs';
11
13
  import type { ExecutionTrace } from '../../../types/trace';
12
14
 
@@ -46,6 +48,21 @@ beforeEach(() => {
46
48
  io = captureIO();
47
49
  });
48
50
 
51
+ /** 内存 StateIO 假件(ADR-0026):缺省 fileStateIO 在全 mock 的 fs 下会吃到 trace 假数据 */
52
+ function memoryStateIO(initial: HarnessState = {}): StateIO & { snapshot(): HarnessState } {
53
+ let state = initial;
54
+ return {
55
+ read: () => state,
56
+ write: (next: HarnessState) => { state = next; },
57
+ snapshot: () => state,
58
+ };
59
+ }
60
+
61
+ /** 全部用例经注入假件调用 status */
62
+ function runStatus(options: StatusOptions = {}): Promise<CommandResult> {
63
+ return status({ stateIO: memoryStateIO(), ...options }, io);
64
+ }
65
+
49
66
  describe('status command - 补充覆盖', () => {
50
67
 
51
68
  beforeEach(() => {
@@ -64,7 +81,7 @@ describe('status command - 补充覆盖', () => {
64
81
  ...repeats('no_completion_without_verification', 2, 'fail'),
65
82
  ]));
66
83
 
67
- await status({ anomalies: true }, io);
84
+ await runStatus({ anomalies: true });
68
85
 
69
86
  const output = io.outText();
70
87
  expect(output).toContain('发现 2 个异常');
@@ -78,7 +95,7 @@ describe('status command - 补充覆盖', () => {
78
95
  { constraintId: 'test', result: 'pass' },
79
96
  ]));
80
97
 
81
- await status({ anomalies: true }, io);
98
+ await runStatus({ anomalies: true });
82
99
 
83
100
  expect(io.outText()).toContain('✅ 未发现异常');
84
101
  });
@@ -91,7 +108,7 @@ describe('status command - 补充覆盖', () => {
91
108
  { constraintId: 'no_completion_without_verification' },
92
109
  ]));
93
110
 
94
- await status({}, io);
111
+ await runStatus();
95
112
 
96
113
  const output = io.outText();
97
114
  expect(output).toContain('📈 约束统计:');
@@ -104,7 +121,7 @@ describe('status command - 补充覆盖', () => {
104
121
  { constraintId: 'capability_sync', level: 'guideline', result: 'pass' },
105
122
  ]));
106
123
 
107
- await status({}, io);
124
+ await runStatus();
108
125
 
109
126
  expect(io.outText()).toContain('🟡 Guidelines:');
110
127
  });
@@ -118,7 +135,7 @@ describe('status command - 补充覆盖', () => {
118
135
  { constraintId: 'capability_sync', level: 'guideline' },
119
136
  ]));
120
137
 
121
- await status({ detail: true }, io);
138
+ await runStatus({ detail: true });
122
139
 
123
140
  const output = io.outText();
124
141
  expect(output).toContain('📈 约束统计:');
@@ -134,7 +151,7 @@ describe('status command - 补充覆盖', () => {
134
151
  ['invalid json', JSON.stringify({ constraintId: 'valid', level: 'iron_law', timestamp: 1, result: 'pass' }), 'also invalid'].join('\n')
135
152
  );
136
153
 
137
- await status({}, io);
154
+ await runStatus();
138
155
 
139
156
  // 应该成功处理,不会抛出异常:坏行进 stderr 告知,合法行进统计
140
157
  const output = io.outText();
@@ -3,10 +3,12 @@
3
3
  *
4
4
  * ADR-0020 起不再 mock TraceAnalyzer/TraceCollector:统计与异常由 trace-analyzer
5
5
  * 的模块级纯函数真算,用例喂合法 trace 行、断言真实输出。
6
+ * ADR-0026 起状态文件读写经注入的 StateIO 假件,不碰(被 mock 的)fs。
6
7
  */
7
8
 
8
- import { status } from '../status';
9
- import { captureIO, type CapturingIO } from '../../command-contract';
9
+ import { status, type StatusOptions } from '../status';
10
+ import { captureIO, type CapturingIO, type CommandResult } from '../../command-contract';
11
+ import type { HarnessState, StateIO } from '../../state-io';
10
12
  import * as fs from 'fs';
11
13
  import type { ExecutionTrace } from '../../../types/trace';
12
14
 
@@ -42,10 +44,27 @@ function repeats(constraintId: string, n: number, result: ExecutionTrace['result
42
44
  }
43
45
 
44
46
  let io: CapturingIO;
47
+ let stateIO: ReturnType<typeof memoryStateIO>;
45
48
  beforeEach(() => {
46
49
  io = captureIO();
50
+ stateIO = memoryStateIO();
47
51
  });
48
52
 
53
+ /** 内存 StateIO 假件(ADR-0026):状态读写走注入面,缺省 fileStateIO 在全 mock 的 fs 下会吃到 trace 假数据 */
54
+ function memoryStateIO(initial: HarnessState = {}): StateIO & { snapshot(): HarnessState } {
55
+ let state = initial;
56
+ return {
57
+ read: () => state,
58
+ write: (next: HarnessState) => { state = next; },
59
+ snapshot: () => state,
60
+ };
61
+ }
62
+
63
+ /** 全部用例经注入假件调用 status(stateIO 可被单例覆盖以预置状态) */
64
+ function runStatus(options: StatusOptions = {}): Promise<CommandResult> {
65
+ return status({ stateIO, ...options }, io);
66
+ }
67
+
49
68
  describe('status command', () => {
50
69
 
51
70
  beforeEach(() => {
@@ -59,7 +78,7 @@ describe('status command', () => {
59
78
  describe('未初始化情况', () => {
60
79
  it('应该显示未初始化提示', async () => {
61
80
  mockFs.existsSync.mockReturnValue(false);
62
- await status({}, io);
81
+ await runStatus();
63
82
  expect(io.outText()).toContain('未初始化');
64
83
  });
65
84
  });
@@ -71,7 +90,7 @@ describe('status command', () => {
71
90
  .mockReturnValueOnce(true) // .harness dir
72
91
  .mockReturnValueOnce(false); // traces file
73
92
 
74
- await status({}, io);
93
+ await runStatus();
75
94
  expect(io.outText()).toContain('暂无 Trace');
76
95
  });
77
96
  });
@@ -83,7 +102,7 @@ describe('status command', () => {
83
102
  { constraintId: 'test2', level: 'guideline' },
84
103
  ]));
85
104
 
86
- await status({}, io);
105
+ await runStatus();
87
106
  expect(io.outText()).toContain('记录数: 2 条');
88
107
  expect(io.outText()).toContain('📈 约束统计:');
89
108
  });
@@ -93,7 +112,7 @@ describe('status command', () => {
93
112
  { constraintId: 'no_bypass_checkpoint' },
94
113
  ]));
95
114
 
96
- await status({ detail: true }, io);
115
+ await runStatus({ detail: true });
97
116
  expect(io.outText()).toContain('🔴 Iron Laws:');
98
117
  expect(io.outText()).toContain('✅ no_bypass_checkpoint');
99
118
  expect(io.outText()).toContain('检查: 1 | 通过: 100% | 失败: 0%');
@@ -106,7 +125,7 @@ describe('status command', () => {
106
125
  { constraintId: 'test1', result: 'fail' },
107
126
  ]));
108
127
 
109
- await status({ anomalies: true }, io);
128
+ await runStatus({ anomalies: true });
110
129
  expect(io.outText()).toContain('发现 1 个异常');
111
130
  expect(io.outText()).toContain(' test1');
112
131
  expect(io.outText()).toContain('类型: low_pass_rate');
@@ -120,17 +139,26 @@ describe('status command', () => {
120
139
  { constraintId: 'test1', result: 'fail' },
121
140
  ]));
122
141
 
123
- await status({ detail: true }, io);
142
+ await runStatus({ detail: true });
124
143
  expect(io.outText()).toContain('检查: 5 | 通过: 80% | 失败: 20%');
125
144
  });
126
145
  });
127
146
 
128
147
  describe('状态文件更新', () => {
129
- it('应该更新 .state.json', async () => {
148
+ it('应该经 StateIO 写入 lastStatusRun', async () => {
149
+ mockFs.readFileSync.mockReturnValue(traceFile([{ constraintId: 'test' }]));
150
+
151
+ await runStatus();
152
+ expect(stateIO.snapshot().lastStatusRun).toBeTruthy();
153
+ });
154
+
155
+ it('读-改-写:已有的 shownHints 不被 status 抹掉(ADR-0026 决策 2 的行为修复)', async () => {
130
156
  mockFs.readFileSync.mockReturnValue(traceFile([{ constraintId: 'test' }]));
157
+ stateIO = memoryStateIO({ shownHints: ['trace_50'] });
131
158
 
132
- await status({}, io);
133
- expect(mockFs.writeFileSync).toHaveBeenCalled();
159
+ await runStatus();
160
+ expect(stateIO.snapshot().shownHints).toEqual(['trace_50']);
161
+ expect(stateIO.snapshot().lastStatusRun).toBeTruthy();
134
162
  });
135
163
  });
136
164
 
@@ -140,7 +168,7 @@ describe('status command', () => {
140
168
  { constraintId: 'test_guide', level: 'guideline' },
141
169
  ]));
142
170
 
143
- await status({}, io);
171
+ await runStatus();
144
172
  expect(io.outText()).toContain('🟡 Guidelines:');
145
173
  expect(io.outText()).toContain('✅ test_guide');
146
174
  });
@@ -150,7 +178,7 @@ describe('status command', () => {
150
178
  it('应该显示未发现异常当无异常', async () => {
151
179
  mockFs.readFileSync.mockReturnValue(traceFile([{ constraintId: 'test' }]));
152
180
 
153
- await status({ anomalies: true }, io);
181
+ await runStatus({ anomalies: true });
154
182
  expect(io.outText()).toContain('✅ 未发现异常');
155
183
  });
156
184
 
@@ -159,7 +187,7 @@ describe('status command', () => {
159
187
  { constraintId: 'test', result: 'fail' },
160
188
  ]));
161
189
 
162
- await status({ anomalies: true }, io);
190
+ await runStatus({ anomalies: true });
163
191
  expect(io.outText()).toContain('当前值: 0');
164
192
  expect(io.outText()).toContain('阈值: 0.3');
165
193
  expect(io.outText()).toContain('下一步建议');
@@ -170,7 +198,7 @@ describe('status command', () => {
170
198
  it('应该显示良好建议当 trace >= 100', async () => {
171
199
  mockFs.readFileSync.mockReturnValue(traceFile(repeats('test', 100, 'pass')));
172
200
 
173
- await status({}, io);
201
+ await runStatus();
174
202
  expect(io.outText()).toContain('记录数: 100 条');
175
203
  expect(io.outText()).toContain('状态良好');
176
204
  });
@@ -178,7 +206,7 @@ describe('status command', () => {
178
206
  it('应该显示积累数据建议当 trace < 100', async () => {
179
207
  mockFs.readFileSync.mockReturnValue(traceFile([{ constraintId: 'test' }]));
180
208
 
181
- await status({}, io);
209
+ await runStatus();
182
210
  expect(io.outText()).toContain('继续积累数据');
183
211
  });
184
212
 
@@ -187,7 +215,7 @@ describe('status command', () => {
187
215
  { constraintId: 'test', result: 'fail' },
188
216
  ]));
189
217
 
190
- await status({ anomalies: true }, io);
218
+ await runStatus({ anomalies: true });
191
219
  expect(io.outText()).toContain('harness status --detail');
192
220
  });
193
221
  });
@@ -199,7 +227,7 @@ describe('status command', () => {
199
227
  ...repeats('test_guide', 2, 'fail'),
200
228
  ].map(t => ({ ...t, level: 'guideline' as const }))));
201
229
 
202
- await status({ detail: true }, io);
230
+ await runStatus({ detail: true });
203
231
  expect(io.outText()).toContain('⚠️ test_guide');
204
232
  expect(io.outText()).toContain('检查: 5 | 通过: 60% | 失败: 40%');
205
233
  });