@dommaker/harness 0.17.1 → 0.19.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 (146) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +5 -4
  3. package/bin/harness.js +9 -2
  4. package/bin/install-precommit-hook.sh +76 -0
  5. package/dist/architecture/cross-project-checker.js +2 -2
  6. package/dist/architecture/cross-project-checker.js.map +1 -1
  7. package/dist/cli/commands/constraints-report.js +2 -2
  8. package/dist/cli/commands/constraints-report.js.map +1 -1
  9. package/dist/cli/commands/constraints-retire.d.ts +24 -6
  10. package/dist/cli/commands/constraints-retire.d.ts.map +1 -1
  11. package/dist/cli/commands/constraints-retire.js +96 -19
  12. package/dist/cli/commands/constraints-retire.js.map +1 -1
  13. package/dist/cli/commands/doc-freshness-check.js +1 -1
  14. package/dist/cli/commands/doc-freshness-check.js.map +1 -1
  15. package/dist/cli/commands/init.js +14 -14
  16. package/dist/cli/commands/init.js.map +1 -1
  17. package/dist/cli/commands/sync-docs/index.d.ts.map +1 -1
  18. package/dist/cli/commands/sync-docs/index.js +14 -6
  19. package/dist/cli/commands/sync-docs/index.js.map +1 -1
  20. package/dist/cli/commands/sync-docs/project-reader.d.ts.map +1 -1
  21. package/dist/cli/commands/sync-docs/project-reader.js +9 -7
  22. package/dist/cli/commands/sync-docs/project-reader.js.map +1 -1
  23. package/dist/cli/commands/update-user-model.d.ts +1 -0
  24. package/dist/cli/commands/update-user-model.d.ts.map +1 -1
  25. package/dist/cli/commands/update-user-model.js +12 -5
  26. package/dist/cli/commands/update-user-model.js.map +1 -1
  27. package/dist/cli/commands/validate.js +3 -3
  28. package/dist/cli/commands/validate.js.map +1 -1
  29. package/dist/cli/session-mining/text.js +1 -1
  30. package/dist/cli/session-mining/text.js.map +1 -1
  31. package/dist/core/constraints/agent-prompt-renderer.d.ts +32 -0
  32. package/dist/core/constraints/agent-prompt-renderer.d.ts.map +1 -0
  33. package/dist/core/constraints/agent-prompt-renderer.js +58 -0
  34. package/dist/core/constraints/agent-prompt-renderer.js.map +1 -0
  35. package/dist/core/constraints/check-cache.d.ts +48 -3
  36. package/dist/core/constraints/check-cache.d.ts.map +1 -1
  37. package/dist/core/constraints/check-cache.js +53 -5
  38. package/dist/core/constraints/check-cache.js.map +1 -1
  39. package/dist/core/constraints/definitions/iron-laws.js +2 -2
  40. package/dist/core/constraints/definitions/iron-laws.js.map +1 -1
  41. package/dist/core/constraints/index.d.ts +4 -0
  42. package/dist/core/constraints/index.d.ts.map +1 -1
  43. package/dist/core/constraints/index.js +7 -1
  44. package/dist/core/constraints/index.js.map +1 -1
  45. package/dist/core/constraints/injection-drift.d.ts +1 -1
  46. package/dist/core/constraints/injection-drift.d.ts.map +1 -1
  47. package/dist/core/constraints/injection-drift.js +1 -1
  48. package/dist/core/constraints/injection-drift.js.map +1 -1
  49. package/dist/core/effective-constraints.d.ts +2 -1
  50. package/dist/core/effective-constraints.d.ts.map +1 -1
  51. package/dist/core/effective-constraints.js +2 -1
  52. package/dist/core/effective-constraints.js.map +1 -1
  53. package/dist/core/project-config-loader.d.ts +2 -1
  54. package/dist/core/project-config-loader.d.ts.map +1 -1
  55. package/dist/core/project-config-loader.js +8 -2
  56. package/dist/core/project-config-loader.js.map +1 -1
  57. package/dist/hooks/bootstrap.js +1 -1
  58. package/dist/hooks/bootstrap.js.map +1 -1
  59. package/dist/index.d.ts +4 -0
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +8 -1
  62. package/dist/index.js.map +1 -1
  63. package/dist/knowledge/store.js +1 -1
  64. package/dist/knowledge/store.js.map +1 -1
  65. package/dist/knowledge/types.d.ts +2 -0
  66. package/dist/knowledge/types.d.ts.map +1 -1
  67. package/dist/knowledge/types.js.map +1 -1
  68. package/dist/safety/output-guardrail.js +2 -2
  69. package/dist/safety/output-guardrail.js.map +1 -1
  70. package/dist/spec/annotation-checker.js +1 -1
  71. package/dist/spec/annotation-checker.js.map +1 -1
  72. package/dist/types/project-config.d.ts +15 -0
  73. package/dist/types/project-config.d.ts.map +1 -1
  74. package/dist/utils/file-walk.d.ts +13 -1
  75. package/dist/utils/file-walk.d.ts.map +1 -1
  76. package/dist/utils/file-walk.js +16 -3
  77. package/dist/utils/file-walk.js.map +1 -1
  78. package/package.json +9 -5
  79. package/src/__tests__/checkpoint.test.ts +2 -2
  80. package/src/__tests__/clean-state-extra.test.ts +1 -1
  81. package/src/__tests__/constraint-doctor.test.ts +1 -1
  82. package/src/__tests__/doctor-extra.test.ts +0 -9
  83. package/src/__tests__/effective-constraints.test.ts +21 -0
  84. package/src/__tests__/hooks-pipeline.test.ts +1 -1
  85. package/src/__tests__/iron-laws.test.ts +11 -0
  86. package/src/__tests__/passes-gate-extension.test.ts +3 -3
  87. package/src/__tests__/passes-gate-extra.test.ts +1 -1
  88. package/src/__tests__/performance-analyzer.test.ts +0 -2
  89. package/src/__tests__/traces.test.ts +1 -2
  90. package/src/architecture/cross-project-checker.ts +2 -2
  91. package/src/cli/commands/CONTEXT.md +1 -1
  92. package/src/cli/commands/__tests__/analyze-sessions.test.ts +178 -0
  93. package/src/cli/commands/__tests__/check-drift.test.ts +1 -1
  94. package/src/cli/commands/__tests__/check-skip-output.test.ts +0 -2
  95. package/src/cli/commands/__tests__/command.test.ts +1 -1
  96. package/src/cli/commands/__tests__/constraints-report.test.ts +1 -1
  97. package/src/cli/commands/__tests__/constraints-retire.test.ts +165 -4
  98. package/src/cli/commands/__tests__/failure.test.ts +0 -1
  99. package/src/cli/commands/__tests__/init.test.ts +40 -0
  100. package/src/cli/commands/__tests__/release.test.ts +162 -0
  101. package/src/cli/commands/__tests__/status-extra.test.ts +0 -2
  102. package/src/cli/commands/__tests__/status.test.ts +0 -2
  103. package/src/cli/commands/__tests__/sync-docs.test.ts +90 -0
  104. package/src/cli/commands/__tests__/update-user-model.test.ts +205 -0
  105. package/src/cli/commands/constraints-report.ts +2 -2
  106. package/src/cli/commands/constraints-retire.ts +114 -21
  107. package/src/cli/commands/doc-freshness-check.ts +1 -1
  108. package/src/cli/commands/init.ts +14 -14
  109. package/src/cli/commands/sync-docs/index.ts +20 -7
  110. package/src/cli/commands/sync-docs/project-reader.ts +10 -8
  111. package/src/cli/commands/update-user-model.ts +15 -5
  112. package/src/cli/commands/validate.ts +3 -3
  113. package/src/cli/session-mining/text.ts +1 -1
  114. package/src/context/__tests__/progressive-loader.test.ts +6 -6
  115. package/src/core/CONTEXT.md +2 -2
  116. package/src/core/constraints/__tests__/agent-prompt-renderer.test.ts +129 -0
  117. package/src/core/constraints/__tests__/check-cache.test.ts +227 -0
  118. package/src/core/constraints/__tests__/injection-drift.test.ts +1 -1
  119. package/src/core/constraints/__tests__/interceptor.test.ts +3 -3
  120. package/src/core/constraints/agent-prompt-renderer.ts +71 -0
  121. package/src/core/constraints/check-cache.ts +75 -3
  122. package/src/core/constraints/definitions/iron-laws.ts +2 -2
  123. package/src/core/constraints/index.ts +10 -0
  124. package/src/core/constraints/injection-drift.ts +1 -1
  125. package/src/core/effective-constraints.ts +2 -1
  126. package/src/core/project-config-loader.ts +8 -2
  127. package/src/gates/__tests__/acceptance.test.ts +0 -1
  128. package/src/gates/__tests__/command.test.ts +1 -1
  129. package/src/hooks/bootstrap.ts +1 -1
  130. package/src/index.ts +8 -0
  131. package/src/knowledge/__tests__/doctor.test.ts +2 -2
  132. package/src/knowledge/__tests__/ingest.test.ts +2 -2
  133. package/src/knowledge/__tests__/migration.test.ts +0 -6
  134. package/src/knowledge/__tests__/store.test.ts +14 -1
  135. package/src/knowledge/store.ts +1 -1
  136. package/src/knowledge/types.ts +2 -0
  137. package/src/llm/__tests__/adapter.test.ts +1 -1
  138. package/src/monitoring/__tests__/knowledge-doctor.test.ts +0 -1
  139. package/src/monitoring/__tests__/performance-collector.test.ts +1 -2
  140. package/src/safety/output-guardrail.ts +2 -2
  141. package/src/spec/__tests__/annotation-checker.test.ts +0 -1
  142. package/src/spec/annotation-checker.ts +1 -1
  143. package/src/types/project-config.ts +12 -0
  144. package/src/utils/__tests__/file-walk.test.ts +53 -0
  145. package/src/utils/file-walk.ts +23 -3
  146. package/src/verification/__tests__/loop.test.ts +1 -1
@@ -4,7 +4,7 @@
4
4
  * 目标:覆盖 autoCommit、detectBugs 完整流程、异常分支
5
5
  */
6
6
 
7
- import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
7
+ import { describe, it, expect, beforeAll, afterAll } from '@jest/globals';
8
8
  import { CleanStateManager } from '../core/session/clean-state';
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
@@ -5,7 +5,7 @@
5
5
  import { describe, it, expect, beforeAll, afterAll, jest } from '@jest/globals';
6
6
  import { ConstraintDoctor } from '../monitoring/constraint-doctor';
7
7
  import type { TraceAnomaly } from '../types/trace';
8
- import type { LLMAdapter, Message, LLMOptions, SummarizeConfig } from '../llm/types';
8
+ import type { LLMAdapter, Message, LLMOptions } from '../llm/types';
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
 
@@ -5,25 +5,16 @@
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import * as os from 'os';
8
- import { TraceCollector } from '../monitoring/traces';
9
- import { TraceAnalyzer } from '../monitoring/trace-analyzer';
10
8
  import { ConstraintDoctor, createDoctor } from '../monitoring/constraint-doctor';
11
9
  import type { ExecutionTrace, TraceAnomaly } from '../types/trace';
12
10
 
13
11
  describe('ConstraintDoctor', () => {
14
12
  let tempDir: string;
15
- let collector: TraceCollector;
16
- let analyzer: TraceAnalyzer;
17
13
  let doctor: ConstraintDoctor;
18
14
 
19
15
  beforeEach(() => {
20
16
  tempDir = path.join(os.tmpdir(), `harness-traces-${Date.now()}`);
21
17
  fs.mkdirSync(tempDir, { recursive: true });
22
- collector = new TraceCollector({
23
- traceFile: path.join(tempDir, 'execution.log'),
24
- enabled: true,
25
- });
26
- analyzer = new TraceAnalyzer(collector);
27
18
  doctor = createDoctor();
28
19
  });
29
20
 
@@ -85,6 +85,27 @@ constraints:
85
85
  expect(constraints.length).toBe(BUILTIN_TOTAL - SCENE_PROMPTS.length - 2);
86
86
  });
87
87
 
88
+ it('config.yml 禁用:custom 约束同样从生效集移除(retire 落 config.yml 的退役路径)', () => {
89
+ const dir = setupProject(
90
+ `
91
+ constraints:
92
+ my_project_rule:
93
+ enabled: false
94
+ `,
95
+ `
96
+ custom_constraints:
97
+ my_project_rule:
98
+ level: iron_law
99
+ rule: MY RULE
100
+ message: 项目自定义
101
+ `
102
+ );
103
+ const constraints = getEffectiveConstraints(dir);
104
+ const ids = constraints.map(c => c.id);
105
+
106
+ expect(ids).not.toContain('my_project_rule');
107
+ });
108
+
88
109
  it('preset: relaxed 裁剪生效集', () => {
89
110
  const dir = setupProject(`preset: relaxed\n`);
90
111
  const constraints = getEffectiveConstraints(dir);
@@ -206,7 +206,7 @@ describe('HookPipeline', () => {
206
206
  registry.register(makeHook('enabled'));
207
207
  registry.register(makeHook('disabled', { enabled: false }));
208
208
 
209
- const result = await pipeline.run('before', ctx);
209
+ await pipeline.run('before', ctx);
210
210
 
211
211
  expect(ctx.calls).toEqual(['enabled']);
212
212
  expect(ctx.calls).not.toContain('disabled');
@@ -82,6 +82,17 @@ describe('Constraint System', () => {
82
82
  expect(PROMPTS['no_skill_without_test'].appliesTo).toEqual(['agent-skill']);
83
83
  expect(PROMPTS['no_model_for_deterministic'].appliesTo).toEqual(['llm-app']);
84
84
  });
85
+
86
+ it('no_completion_without_verification 不硬编码具体命令示例(#25:口径由项目声明)', () => {
87
+ const law = IRON_LAWS['no_completion_without_verification'];
88
+ expect(law.promptInjection).not.toMatch(/npm test|npm run build/);
89
+ expect(law.description).not.toMatch(/npm test|npm run build/);
90
+ expect(law.promptInjection).not.toContain('完整');
91
+ expect(law.description).not.toContain('完整');
92
+ expect(law.promptInjection).toContain('项目声明的测试');
93
+ expect(law.description).toContain('项目声明的测试');
94
+ expect(law.promptInjection).toContain('不得凭记忆声称完成');
95
+ });
85
96
  });
86
97
 
87
98
  describe('注册表闭环', () => {
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { describe, it, expect, beforeEach } from '@jest/globals';
8
8
  import { PassesGate, createPassesGate } from '../core/validators/passes-gate';
9
- import type { PassesGateExtension, TaskTestResult } from '../types/passes-gate';
9
+ import type { PassesGateExtension } from '../types/passes-gate';
10
10
 
11
11
  describe('PassesGate Extension', () => {
12
12
  let passesGate: PassesGate;
@@ -100,7 +100,7 @@ describe('PassesGate Extension', () => {
100
100
  const extension: PassesGateExtension = {
101
101
  name: 'puppeteer',
102
102
  description: 'Puppeteer E2E tests',
103
- run: async (workDir) => ({
103
+ run: async (_workDir) => ({
104
104
  passed: true,
105
105
  command: 'puppeteer',
106
106
  output: 'E2E passed',
@@ -116,7 +116,7 @@ describe('PassesGate Extension', () => {
116
116
  it('扩展 run 方法应返回 TaskTestResult', async () => {
117
117
  const extension: PassesGateExtension = {
118
118
  name: 'mock',
119
- run: async (workDir, task) => {
119
+ run: async (_workDir, _task) => {
120
120
  // 模拟测试运行
121
121
  return {
122
122
  passed: true,
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
6
6
  import { PassesGate, createPassesGate } from '../core/validators/passes-gate';
7
- import { mkdirSync, rmSync, writeFileSync, existsSync, mkdir } from 'fs';
7
+ import { mkdirSync, rmSync, writeFileSync } from 'fs';
8
8
  import { join } from 'path';
9
9
 
10
10
  describe('PassesGate - 补充覆盖', () => {
@@ -6,7 +6,6 @@ import { PerformanceAnalyzer, createPerformanceAnalyzer } from '../monitoring/pe
6
6
  import { PerformanceCollector } from '../monitoring/performance-collector';
7
7
  import type { PerformanceTrace, PerformanceSummary } from '../types/performance';
8
8
  import * as fs from 'fs';
9
- import * as path from 'path';
10
9
 
11
10
  // Mock fs
12
11
  jest.mock('fs', () => ({
@@ -25,7 +24,6 @@ jest.mock('../monitoring/performance-collector', () => ({
25
24
  }));
26
25
 
27
26
  const mockFs = fs as jest.Mocked<typeof fs>;
28
- const MockPerformanceCollector = PerformanceCollector as jest.MockedClass<typeof PerformanceCollector>;
29
27
 
30
28
  describe('PerformanceAnalyzer', () => {
31
29
  let analyzer: PerformanceAnalyzer;
@@ -7,7 +7,6 @@ import * as path from 'path';
7
7
  import * as os from 'os';
8
8
  import { TraceCollector } from '../monitoring/traces';
9
9
  import { TraceAnalyzer } from '../monitoring/trace-analyzer';
10
- import type { ExecutionTrace } from '../types/trace';
11
10
 
12
11
  describe('TraceCollector', () => {
13
12
  let tempDir: string;
@@ -175,7 +174,7 @@ describe('TraceCollector', () => {
175
174
  });
176
175
 
177
176
  test('should return 0 when backup dir does not exist', () => {
178
- const result = collector.cleanupOldFiles(30);
177
+ collector.cleanupOldFiles(30);
179
178
  // Dir exists (created by constructor), so this won't hit line 259.
180
179
  // Create a collector with a path in non-existent dir to test that branch.
181
180
  const tmpRoot = path.join(os.tmpdir(), `harness-traces-nonexistent-${Date.now()}`);
@@ -323,8 +323,8 @@ function isBreakingChange(change: ApiChange): boolean {
323
323
 
324
324
  function getDependents(project: string, config: CrossProjectConfig): string[] {
325
325
  return Object.entries(config.dependencies)
326
- .filter(([_, deps]) => deps.includes(project))
327
- .map(([name, _]) => name);
326
+ .filter(([, deps]) => deps.includes(project))
327
+ .map(([name]) => name);
328
328
  }
329
329
 
330
330
  interface ApiChange {
@@ -6,7 +6,7 @@ CLI 子命令实现:24 个顶层命令 + constraints 治理子命令 report/re
6
6
  ## 核心导出(24 顶层命令)
7
7
  check / validate / passes-gate / init / report / status / spec / acceptance / performance / security / contract / review / command / sync-docs / knowledge / sdd / failure / posteval-plan / release / analyze-sessions / update-user-model / constraints / doc-freshness-check / spec-baseline-check
8
8
 
9
- `constraints` 下挂治理子命令:`constraints report`(使用统计 + 退役候选诊断 + 配置健康 + 注入漂移,`--export` 脱敏)、`constraints retire`(交互选择 + 人确认退役,config.yml retired 元数据 + KnowledgeStore 沉淀 + CLAUDE.md 注入段同步)。
9
+ `constraints` 下挂治理子命令:`constraints report`(使用统计 + 退役候选诊断 + 配置健康 + 注入漂移,`--export` 脱敏)、`constraints retire`(交互选择 + 人确认退役;带 id 直达需显式 `--yes`(#24 人确认闸门),无 `--yes` 报错 + 非零退出码且不落盘;内置落 config.yml retired 元数据,custom custom-constraints.yml 条目 retired 段(#82 D6 一处真相)+ KnowledgeStore 沉淀 + CLAUDE.md 注入段同步)。
10
10
 
11
11
  ## 依赖关系
12
12
  - 依赖 `src/core/constraints/` 约束引擎
@@ -0,0 +1,178 @@
1
+ /**
2
+ * analyze-sessions 命令测试(O6)
3
+ *
4
+ * Seam:analyzeSessions(options) 公开入口。
5
+ * 隔离面:
6
+ * - os.homedir → 测试临时目录(memory 规则库落 TEST_HOME)
7
+ * - readTranscriptSessions → fixture 会话(extractCorrectionMatches/tokenize 等纯函数保持真实)
8
+ * - CLAUDE_TRANSCRIPTS_DIR → 测试临时目录(决定 transcripts 目录是否存在)
9
+ */
10
+
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { analyzeSessions } from '../analyze-sessions';
14
+ import { readTranscriptSessions, type MinedSession } from '../../session-mining';
15
+
16
+ const TEST_HOME = '/tmp/harness-analyze-test-home';
17
+
18
+ jest.mock('os', () => ({
19
+ ...jest.requireActual('os'),
20
+ homedir: () => '/tmp/harness-analyze-test-home',
21
+ }));
22
+
23
+ jest.mock('../../session-mining', () => ({
24
+ ...jest.requireActual('../../session-mining'),
25
+ readTranscriptSessions: jest.fn(),
26
+ }));
27
+
28
+ jest.mock('chalk', () => {
29
+ const id = (s: string) => s;
30
+ const chalkFn = Object.assign(id, {
31
+ gray: id,
32
+ blue: id,
33
+ green: id,
34
+ yellow: id,
35
+ cyan: id,
36
+ bold: id,
37
+ red: id,
38
+ });
39
+ return {
40
+ __esModule: true,
41
+ default: chalkFn,
42
+ gray: id,
43
+ blue: id,
44
+ green: id,
45
+ yellow: id,
46
+ cyan: id,
47
+ bold: id,
48
+ red: id,
49
+ };
50
+ });
51
+
52
+ const mockReadTranscriptSessions = readTranscriptSessions as jest.MockedFunction<
53
+ typeof readTranscriptSessions
54
+ >;
55
+
56
+ const TRANSCRIPTS_DIR = path.join(TEST_HOME, 'transcripts');
57
+
58
+ function mkSession(partial: Partial<MinedSession> = {}): MinedSession {
59
+ return {
60
+ id: 'session-1',
61
+ date: '2026-08-15',
62
+ mtimeMs: Date.now(),
63
+ turns: [
64
+ { role: 'user', content: '我不是说要用中文吗' },
65
+ { role: 'assistant', content: '收到,改用中文' },
66
+ ],
67
+ toolCalls: [],
68
+ ...partial,
69
+ };
70
+ }
71
+
72
+ function lastJsonOutput(consoleSpy: jest.SpyInstance): Record<string, unknown> {
73
+ const jsonLine = consoleSpy.mock.calls.map(c => c[0]).join('\n');
74
+ return JSON.parse(jsonLine);
75
+ }
76
+
77
+ describe('analyze-sessions command', () => {
78
+ let consoleSpy: jest.SpyInstance;
79
+
80
+ beforeEach(() => {
81
+ jest.clearAllMocks();
82
+ fs.rmSync(TEST_HOME, { recursive: true, force: true });
83
+ fs.mkdirSync(path.join(TEST_HOME, '.claude', 'projects', '-root-projects', 'memory'), { recursive: true });
84
+ fs.mkdirSync(TRANSCRIPTS_DIR, { recursive: true });
85
+ process.env.CLAUDE_TRANSCRIPTS_DIR = TRANSCRIPTS_DIR;
86
+ consoleSpy = jest.spyOn(console, 'log').mockImplementation();
87
+ });
88
+
89
+ afterEach(() => {
90
+ consoleSpy.mockRestore();
91
+ delete process.env.CLAUDE_TRANSCRIPTS_DIR;
92
+ });
93
+
94
+ test('transcripts 目录不存在:提示 No transcripts directory found', async () => {
95
+ process.env.CLAUDE_TRANSCRIPTS_DIR = path.join(TEST_HOME, 'missing-dir');
96
+
97
+ await analyzeSessions({});
98
+
99
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No transcripts directory found'));
100
+ expect(mockReadTranscriptSessions).not.toHaveBeenCalled();
101
+ });
102
+
103
+ test('窗口内无会话:提示 No sessions found(默认最近 7 天)', async () => {
104
+ mockReadTranscriptSessions.mockReturnValue([
105
+ mkSession({ id: 'old', mtimeMs: Date.now() - 30 * 86_400_000 }),
106
+ ]);
107
+
108
+ await analyzeSessions({});
109
+
110
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No sessions found in the last 7 days'));
111
+ });
112
+
113
+ test('--days 1:只统计最近 1 天(mtimeMs 窗口过滤)', async () => {
114
+ mockReadTranscriptSessions.mockReturnValue([
115
+ mkSession({ id: 'today' }),
116
+ mkSession({ id: 'two-days-ago', mtimeMs: Date.now() - 2 * 86_400_000 }),
117
+ ]);
118
+
119
+ await analyzeSessions({ days: 1 });
120
+
121
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Analyzing 1 sessions (last 1 days)'));
122
+ });
123
+
124
+ test('缺省 --days:窗口为 7 天,两天前会话仍计入', async () => {
125
+ mockReadTranscriptSessions.mockReturnValue([
126
+ mkSession({ id: 'today' }),
127
+ mkSession({ id: 'two-days-ago', mtimeMs: Date.now() - 2 * 86_400_000 }),
128
+ ]);
129
+
130
+ await analyzeSessions({});
131
+
132
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Analyzing 2 sessions (last 7 days)'));
133
+ });
134
+
135
+ test('--json:纠正句跨 3 会话聚合为 correction 候选', async () => {
136
+ mockReadTranscriptSessions.mockReturnValue([
137
+ mkSession({ id: 's1' }),
138
+ mkSession({ id: 's2' }),
139
+ mkSession({ id: 's3' }),
140
+ ]);
141
+
142
+ await analyzeSessions({ json: true });
143
+
144
+ const output = lastJsonOutput(consoleSpy);
145
+ expect(output.sessions).toBe(3);
146
+ expect(output.corrections).toBe(3);
147
+ expect(output.candidates).toEqual(
148
+ expect.arrayContaining([
149
+ expect.objectContaining({
150
+ source: 'correction',
151
+ frequency: 3,
152
+ pattern: '我不是说',
153
+ }),
154
+ ]),
155
+ );
156
+ });
157
+
158
+ test('--json:跨会话重复概念产出 ngram 候选', async () => {
159
+ const ngramText = { role: 'user', content: '数据库迁移方案的详细讨论' };
160
+ mockReadTranscriptSessions.mockReturnValue([
161
+ mkSession({
162
+ id: 's1',
163
+ turns: [ngramText, { role: 'assistant', content: '好的' }],
164
+ }),
165
+ mkSession({
166
+ id: 's2',
167
+ turns: [ngramText, { role: 'assistant', content: '明白' }],
168
+ }),
169
+ ]);
170
+
171
+ await analyzeSessions({ json: true });
172
+
173
+ const output = lastJsonOutput(consoleSpy);
174
+ const ngramCandidates = (output.candidates as Array<{ source: string }>)
175
+ .filter(c => c.source === 'ngram');
176
+ expect(ngramCandidates.length).toBeGreaterThan(0);
177
+ });
178
+ });
@@ -82,7 +82,7 @@ describe('check 命令注入漂移警告', () => {
82
82
  expect(output).toContain('⚠️⚠️');
83
83
  expect(output).toContain('agent 上下文中的规则与已安装 harness 版本不一致');
84
84
  expect(output).toContain('0.0.1-old');
85
- expect(output).toContain('npx harness init');
85
+ expect(output).toContain('npx @dommaker/harness init');
86
86
  // 不阻断:检查仍通过、未调用 process.exit
87
87
  expect(output).toContain('约束检查通过');
88
88
  expect(exitSpy).not.toHaveBeenCalled();
@@ -5,9 +5,7 @@
5
5
  */
6
6
 
7
7
  import { check } from '../check';
8
- import * as fs from 'fs';
9
8
  import { constraintChecker } from '../../../core/constraints/checker';
10
- import { ProjectConfigLoader } from '../../../core/project-config-loader';
11
9
 
12
10
  // Mock fs
13
11
  jest.mock('fs', () => ({
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { executeCommand } from '../command';
6
- import { createCommandGate, getCommandRiskLevel, DEFAULT_COMMAND_BLACKLIST } from '../../../gates';
6
+ import { createCommandGate, getCommandRiskLevel } from '../../../gates';
7
7
 
8
8
  jest.mock('../../../gates', () => ({
9
9
  createCommandGate: jest.fn(),
@@ -290,7 +290,7 @@ describe('constraintsReport 注入漂移小节(ADR-0001 决策 7)', () => {
290
290
  expect(output).toContain('0.0.1-old');
291
291
  expect(output).toContain(`缺失: ${originalLine}`);
292
292
  expect(output).toContain(`多余: ${editedLine}`);
293
- expect(output).toContain('npx harness init');
293
+ expect(output).toContain('npx @dommaker/harness init');
294
294
  });
295
295
 
296
296
  it('重复章节:输出重复章节提示', async () => {
@@ -40,6 +40,24 @@ function readConfig(root: string): any {
40
40
  return yaml.load(fs.readFileSync(path.join(root, '.harness', 'config.yml'), 'utf-8'));
41
41
  }
42
42
 
43
+ const CUSTOM_YML = `
44
+ custom_constraints:
45
+ my_custom_rule:
46
+ level: iron_law
47
+ rule: 禁止引入 X
48
+ message: X 已由平台能力替代
49
+ promptInjection: 禁止引入 X
50
+ `;
51
+
52
+ function writeCustom(root: string, yml: string): void {
53
+ fs.mkdirSync(path.join(root, '.harness'), { recursive: true });
54
+ fs.writeFileSync(path.join(root, '.harness', 'custom-constraints.yml'), yml, 'utf-8');
55
+ }
56
+
57
+ function readCustom(root: string): any {
58
+ return yaml.load(fs.readFileSync(path.join(root, '.harness', 'custom-constraints.yml'), 'utf-8'));
59
+ }
60
+
43
61
  let logSpy: jest.SpyInstance;
44
62
 
45
63
  beforeEach(() => {
@@ -184,12 +202,121 @@ describe('retireConstraint 执行逻辑', () => {
184
202
  retireConstraint(root, 'capability_sync', { now: FIXED_NOW });
185
203
  expect(getEffectiveConstraints(root).some(c => c.id === 'capability_sync')).toBe(false);
186
204
  });
205
+
206
+ it('custom 约束退役:落 custom-constraints.yml retired 段(不写 config.yml),生效集与注入段移除', () => {
207
+ const root = makeTmpProject();
208
+ writeCustom(root, CUSTOM_YML);
209
+ const before = renderConstraintsSection(getEffectiveConstraints(root), '0.0.0-test');
210
+ expect(before).toContain('**my_custom_rule**');
211
+ fs.writeFileSync(path.join(root, 'CLAUDE.md'), `# 项目\n\n## Governance Rules\n${before}\n其他内容\n`, 'utf-8');
212
+
213
+ const result = retireConstraint(root, 'my_custom_rule', { reason: '作用对象已从代码库消失', now: FIXED_NOW });
214
+
215
+ expect(result.status).toBe('retired');
216
+ expect(result.landing).toBe('custom-constraints.yml');
217
+ expect(result.claudeMdSynced).toBe(true);
218
+ // config.yml 不产生该 id 段
219
+ expect(fs.existsSync(path.join(root, '.harness', 'config.yml'))).toBe(false);
220
+ // yml 保留规则原文 + retired 元数据
221
+ const custom = readCustom(root).custom_constraints.my_custom_rule;
222
+ expect(custom.rule).toBe('禁止引入 X');
223
+ expect(custom.promptInjection).toBe('禁止引入 X');
224
+ expect(custom.retired.at).toBe(FIXED_NOW.toISOString());
225
+ expect(custom.retired.reason).toBe('作用对象已从代码库消失');
226
+ // 生效集移除
227
+ expect(getEffectiveConstraints(root).some(c => c.id === 'my_custom_rule')).toBe(false);
228
+ // 注入段同步移除
229
+ const after = fs.readFileSync(path.join(root, 'CLAUDE.md'), 'utf-8');
230
+ expect(after).not.toContain('**my_custom_rule**');
231
+ expect(after).toContain('其他内容');
232
+ // 打印的恢复提示指向 yml
233
+ printRetireResult(result);
234
+ const output = logSpy.mock.calls.map(c => String(c[0])).join('\n');
235
+ expect(output).toContain('custom_constraints.my_custom_rule.retired');
236
+ });
237
+
238
+ it('custom 重复退役:yml 已带 retired → already_retired,不覆盖原元数据', () => {
239
+ const root = makeTmpProject();
240
+ writeCustom(root, CUSTOM_YML);
241
+ const first = retireConstraint(root, 'my_custom_rule', { reason: '第一次', now: FIXED_NOW });
242
+ expect(first.status).toBe('retired');
243
+
244
+ const later = new Date('2026-08-09T00:00:00.000Z');
245
+ const second = retireConstraint(root, 'my_custom_rule', { reason: '第二次', now: later });
246
+ expect(second.status).toBe('already_retired');
247
+
248
+ const custom = readCustom(root).custom_constraints.my_custom_rule;
249
+ expect(custom.retired.at).toBe(FIXED_NOW.toISOString());
250
+ expect(custom.retired.reason).toBe('第一次');
251
+ });
252
+
253
+ it('custom 历史落点(config.yml enabled:false)仍判定 already_retired', () => {
254
+ const root = makeTmpProject();
255
+ writeCustom(root, CUSTOM_YML);
256
+ fs.writeFileSync(path.join(root, '.harness', 'config.yml'), 'constraints:\n my_custom_rule:\n enabled: false\n', 'utf-8');
257
+ const result = retireConstraint(root, 'my_custom_rule', { now: FIXED_NOW });
258
+ expect(result.status).toBe('already_retired');
259
+ });
260
+
261
+ it('custom 退役 KnowledgeStore 记录包含 promptInjection(规则原文完整)', () => {
262
+ const root = makeTmpProject();
263
+ writeCustom(root, CUSTOM_YML);
264
+ const result = retireConstraint(root, 'my_custom_rule', { reason: '由新机制覆盖', now: FIXED_NOW });
265
+ expect(result.status).toBe('retired');
266
+
267
+ const store = new FileKnowledgeStore({ baseDir: path.join(root, '.harness', 'knowledge') });
268
+ const entry = store.get('constraint-retired-my_custom_rule');
269
+ expect(entry).toBeDefined();
270
+ expect(entry!.content).toContain('禁止引入 X');
271
+ expect(entry!.content).toContain('promptInjection: 禁止引入 X');
272
+ });
273
+
274
+ it('custom 恢复:删除 yml retired 段后回到生效集', () => {
275
+ const root = makeTmpProject();
276
+ writeCustom(root, CUSTOM_YML);
277
+ retireConstraint(root, 'my_custom_rule', { now: FIXED_NOW });
278
+ expect(getEffectiveConstraints(root).some(c => c.id === 'my_custom_rule')).toBe(false);
279
+
280
+ const custom = readCustom(root);
281
+ delete custom.custom_constraints.my_custom_rule.retired;
282
+ fs.writeFileSync(path.join(root, '.harness', 'custom-constraints.yml'), yaml.dump(custom), 'utf-8');
283
+
284
+ expect(getEffectiveConstraints(root).some(c => c.id === 'my_custom_rule')).toBe(true);
285
+ });
187
286
  });
188
287
 
189
288
  describe('constraintsRetire 非交互直达', () => {
190
- it('iron 直达退役时打印额外警示', async () => {
289
+ let originalExitCode: typeof process.exitCode;
290
+
291
+ beforeEach(() => {
292
+ originalExitCode = process.exitCode;
293
+ process.exitCode = 0;
294
+ });
295
+
296
+ afterEach(() => {
297
+ process.exitCode = originalExitCode;
298
+ });
299
+
300
+ it('无 --yes 直达:报错 + 非零退出码 + 不落盘任何文件(#24 人确认闸门)', async () => {
191
301
  const root = makeTmpProject();
192
- await constraintsRetire('docs_freshness', { projectPath: root, reason: '直接退役' });
302
+ const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
303
+ try {
304
+ await constraintsRetire('docs_freshness', { projectPath: root, reason: '直接退役' });
305
+
306
+ const output = errSpy.mock.calls.map(c => String(c[0])).join('\n');
307
+ expect(output).toContain('--yes');
308
+ expect(output).toContain('交互');
309
+ expect(process.exitCode).toBe(1);
310
+ // 无副作用:任何文件都不落盘
311
+ expect(fs.existsSync(path.join(root, '.harness'))).toBe(false);
312
+ } finally {
313
+ errSpy.mockRestore();
314
+ }
315
+ });
316
+
317
+ it('--yes 直达 iron 退役:打印额外警示并落盘', async () => {
318
+ const root = makeTmpProject();
319
+ await constraintsRetire('docs_freshness', { projectPath: root, reason: '直接退役', yes: true });
193
320
 
194
321
  const output = logSpy.mock.calls.map(c => String(c[0])).join('\n');
195
322
  expect(output).toContain('Iron Law');
@@ -199,9 +326,21 @@ describe('constraintsRetire 非交互直达', () => {
199
326
  expect(config.constraints.docs_freshness.enabled).toBe(false);
200
327
  });
201
328
 
202
- it('未知 id 直达:明确提示', async () => {
329
+ it('--yes 直达非 iron(guideline)退役:不打印 iron 警示并落盘', async () => {
203
330
  const root = makeTmpProject();
204
- await constraintsRetire('ghost', { projectPath: root });
331
+ await constraintsRetire('no_hardcoded_credentials', { projectPath: root, reason: '直接退役', yes: true });
332
+
333
+ const output = logSpy.mock.calls.map(c => String(c[0])).join('\n');
334
+ expect(output).not.toContain('Iron Law');
335
+ expect(output).toContain('已退役');
336
+
337
+ const config = readConfig(root);
338
+ expect(config.constraints.no_hardcoded_credentials.enabled).toBe(false);
339
+ });
340
+
341
+ it('--yes 直达未知 id:明确提示', async () => {
342
+ const root = makeTmpProject();
343
+ await constraintsRetire('ghost', { projectPath: root, yes: true });
205
344
  const output = logSpy.mock.calls.map(c => String(c[0])).join('\n');
206
345
  expect(output).toContain('约束不存在');
207
346
  });
@@ -264,6 +403,28 @@ describe('runRetireInteractive 交互流程(注入 IO 流)', () => {
264
403
  expect(config.constraints.no_hardcoded_credentials.retired.reason).toBe('误报太多');
265
404
  });
266
405
 
406
+ it('无候选 → 手动输入 custom id → 确认执行 → 落 custom-constraints.yml', async () => {
407
+ const root = makeTmpProject();
408
+ writeCustom(root, CUSTOM_YML);
409
+ // 所有 check 约束给少量健康数据(低于一切候选阈值)
410
+ const healthy = getEffectiveConstraints(root)
411
+ .filter(c => c.kind === 'check')
412
+ .flatMap(c => [
413
+ { constraintId: c.id, result: 'pass' as const },
414
+ { constraintId: c.id, result: 'pass' as const, timestamp: 1700000001000 },
415
+ { constraintId: c.id, result: 'fail' as const, timestamp: 1700000002000 },
416
+ ]);
417
+ writeTraces(root, healthy);
418
+
419
+ const io = makeIo(['my_custom_rule', '作用对象消失', 'y']);
420
+ await runRetireInteractive(root, io);
421
+ io.done();
422
+
423
+ const custom = readCustom(root).custom_constraints.my_custom_rule;
424
+ expect(custom.retired.reason).toBe('作用对象消失');
425
+ expect(fs.existsSync(path.join(root, '.harness', 'config.yml'))).toBe(false);
426
+ });
427
+
267
428
  it('无候选 → 手动输入未知 id → 提示不存在并取消', async () => {
268
429
  const root = makeTmpProject();
269
430
  const healthy = getEffectiveConstraints(root)
@@ -13,7 +13,6 @@ jest.mock('../../../failure/recorder');
13
13
 
14
14
  describe('failure CLI commands', () => {
15
15
  const tempDir = path.join(process.cwd(), 'temp-test-failure-cli');
16
- const logFilePath = path.join(tempDir, '.harness', 'failures', 'failures.log');
17
16
  let consoleSpy: jest.SpyInstance;
18
17
  let consoleErrorSpy: jest.SpyInstance;
19
18
  let mockRecorderInstance: {