@dommaker/harness 0.17.0 → 0.18.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 (117) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +5 -4
  3. package/bin/harness.js +3 -1
  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/validate.js +3 -3
  24. package/dist/cli/commands/validate.js.map +1 -1
  25. package/dist/cli/session-mining/text.js +1 -1
  26. package/dist/cli/session-mining/text.js.map +1 -1
  27. package/dist/core/constraints/definitions/iron-laws.js +2 -2
  28. package/dist/core/constraints/definitions/iron-laws.js.map +1 -1
  29. package/dist/core/constraints/injection-drift.d.ts +1 -1
  30. package/dist/core/constraints/injection-drift.d.ts.map +1 -1
  31. package/dist/core/constraints/injection-drift.js +1 -1
  32. package/dist/core/constraints/injection-drift.js.map +1 -1
  33. package/dist/core/effective-constraints.d.ts +2 -1
  34. package/dist/core/effective-constraints.d.ts.map +1 -1
  35. package/dist/core/effective-constraints.js +2 -1
  36. package/dist/core/effective-constraints.js.map +1 -1
  37. package/dist/core/project-config-loader.d.ts +2 -1
  38. package/dist/core/project-config-loader.d.ts.map +1 -1
  39. package/dist/core/project-config-loader.js +9 -2
  40. package/dist/core/project-config-loader.js.map +1 -1
  41. package/dist/hooks/bootstrap.js +1 -1
  42. package/dist/hooks/bootstrap.js.map +1 -1
  43. package/dist/knowledge/store.js +1 -1
  44. package/dist/knowledge/store.js.map +1 -1
  45. package/dist/knowledge/types.d.ts +2 -0
  46. package/dist/knowledge/types.d.ts.map +1 -1
  47. package/dist/knowledge/types.js.map +1 -1
  48. package/dist/safety/output-guardrail.js +2 -2
  49. package/dist/safety/output-guardrail.js.map +1 -1
  50. package/dist/spec/annotation-checker.js +1 -1
  51. package/dist/spec/annotation-checker.js.map +1 -1
  52. package/dist/types/project-config.d.ts +23 -0
  53. package/dist/types/project-config.d.ts.map +1 -1
  54. package/dist/utils/file-walk.d.ts +13 -1
  55. package/dist/utils/file-walk.d.ts.map +1 -1
  56. package/dist/utils/file-walk.js +16 -3
  57. package/dist/utils/file-walk.js.map +1 -1
  58. package/package.json +6 -2
  59. package/src/__tests__/checkpoint.test.ts +2 -2
  60. package/src/__tests__/clean-state-extra.test.ts +1 -1
  61. package/src/__tests__/constraint-doctor.test.ts +1 -1
  62. package/src/__tests__/doctor-extra.test.ts +0 -9
  63. package/src/__tests__/effective-constraints.test.ts +21 -0
  64. package/src/__tests__/hooks-pipeline.test.ts +1 -1
  65. package/src/__tests__/iron-laws.test.ts +11 -0
  66. package/src/__tests__/passes-gate-extension.test.ts +3 -3
  67. package/src/__tests__/passes-gate-extra.test.ts +1 -1
  68. package/src/__tests__/performance-analyzer.test.ts +0 -2
  69. package/src/__tests__/project-config-loader-extra.test.ts +37 -0
  70. package/src/__tests__/traces.test.ts +1 -2
  71. package/src/architecture/cross-project-checker.ts +2 -2
  72. package/src/cli/commands/CONTEXT.md +1 -1
  73. package/src/cli/commands/__tests__/check-drift.test.ts +1 -1
  74. package/src/cli/commands/__tests__/check-skip-output.test.ts +0 -2
  75. package/src/cli/commands/__tests__/command.test.ts +1 -1
  76. package/src/cli/commands/__tests__/constraints-report.test.ts +1 -1
  77. package/src/cli/commands/__tests__/constraints-retire.test.ts +165 -4
  78. package/src/cli/commands/__tests__/failure.test.ts +0 -1
  79. package/src/cli/commands/__tests__/init.test.ts +40 -0
  80. package/src/cli/commands/__tests__/status-extra.test.ts +0 -2
  81. package/src/cli/commands/__tests__/status.test.ts +0 -2
  82. package/src/cli/commands/__tests__/sync-docs.test.ts +90 -0
  83. package/src/cli/commands/constraints-report.ts +2 -2
  84. package/src/cli/commands/constraints-retire.ts +114 -21
  85. package/src/cli/commands/doc-freshness-check.ts +1 -1
  86. package/src/cli/commands/init.ts +14 -14
  87. package/src/cli/commands/sync-docs/index.ts +20 -7
  88. package/src/cli/commands/sync-docs/project-reader.ts +10 -8
  89. package/src/cli/commands/validate.ts +3 -3
  90. package/src/cli/session-mining/text.ts +1 -1
  91. package/src/context/__tests__/progressive-loader.test.ts +6 -6
  92. package/src/core/CONTEXT.md +1 -1
  93. package/src/core/constraints/__tests__/injection-drift.test.ts +1 -1
  94. package/src/core/constraints/__tests__/interceptor.test.ts +3 -3
  95. package/src/core/constraints/definitions/iron-laws.ts +2 -2
  96. package/src/core/constraints/injection-drift.ts +1 -1
  97. package/src/core/effective-constraints.ts +2 -1
  98. package/src/core/project-config-loader.ts +9 -2
  99. package/src/gates/__tests__/acceptance.test.ts +0 -1
  100. package/src/gates/__tests__/command.test.ts +1 -1
  101. package/src/hooks/bootstrap.ts +1 -1
  102. package/src/knowledge/__tests__/doctor.test.ts +2 -2
  103. package/src/knowledge/__tests__/ingest.test.ts +2 -2
  104. package/src/knowledge/__tests__/migration.test.ts +0 -6
  105. package/src/knowledge/__tests__/store.test.ts +14 -1
  106. package/src/knowledge/store.ts +1 -1
  107. package/src/knowledge/types.ts +2 -0
  108. package/src/llm/__tests__/adapter.test.ts +1 -1
  109. package/src/monitoring/__tests__/knowledge-doctor.test.ts +0 -1
  110. package/src/monitoring/__tests__/performance-collector.test.ts +1 -2
  111. package/src/safety/output-guardrail.ts +2 -2
  112. package/src/spec/__tests__/annotation-checker.test.ts +0 -1
  113. package/src/spec/annotation-checker.ts +1 -1
  114. package/src/types/project-config.ts +21 -0
  115. package/src/utils/__tests__/file-walk.test.ts +53 -0
  116. package/src/utils/file-walk.ts +23 -3
  117. 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;
@@ -106,6 +106,43 @@ custom_constraints:
106
106
  });
107
107
  });
108
108
 
109
+ describe('自定义约束 promptInjection', () => {
110
+ it('应该透传 promptInjection 并进入注入段渲染', async () => {
111
+ fs.writeFileSync(
112
+ path.join(harnessDir, 'config.yml'),
113
+ `
114
+ custom_constraints:
115
+ my_prompt_rule:
116
+ rule: MY PROMPT RULE
117
+ message: Prompt rule message
118
+ level: guideline
119
+ trigger: commit
120
+ promptInjection: 我的自定义注入文本
121
+ my_silent_rule:
122
+ rule: MY SILENT RULE
123
+ message: No injection text
124
+ level: guideline
125
+ trigger: commit
126
+ `
127
+ );
128
+
129
+ const loader = new ProjectConfigLoader(tempDir);
130
+ loader.load();
131
+ const merged = loader.mergeConstraints();
132
+
133
+ // 字段透传
134
+ expect(merged.guidelines['my_prompt_rule']?.promptInjection).toBe('我的自定义注入文本');
135
+ // 未定义 promptInjection 的自定义约束保持不注入(与内置无注入文本条目一致)
136
+ expect(merged.guidelines['my_silent_rule']?.promptInjection).toBeUndefined();
137
+
138
+ // 端到端:进入 CLAUDE.md 注入段渲染
139
+ const { renderConstraintsSection } = await import('../core/constraints/injection-renderer');
140
+ const section = renderConstraintsSection(Object.values(merged.guidelines), '0.0.0-test');
141
+ expect(section).toContain('- **my_prompt_rule**: 我的自定义注入文本');
142
+ expect(section).not.toContain('my_silent_rule');
143
+ });
144
+ });
145
+
109
146
  describe('约束扩展', () => {
110
147
  it('应该扩展例外列表', () => {
111
148
  fs.writeFileSync(
@@ -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/` 约束引擎
@@ -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: {
@@ -111,6 +111,8 @@ describe('init command', () => {
111
111
  it('应该输出代码片段模式', async () => {
112
112
  await init({ preset: 'standard', printSnippets: true });
113
113
  expect(consoleSpy).toHaveBeenCalled();
114
+ // 打印版与落盘版 plan 匹配模式须一致(#35:模板字面量 \. 运行期退化为 .,防两副本 drift)
115
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("grep -E 'plans/.*\\.md$|\\.plan\\.md$'"));
114
116
  });
115
117
  });
116
118
 
@@ -132,6 +134,8 @@ describe('init command', () => {
132
134
 
133
135
  await init({ preset: 'standard', projectPath: PROJECT });
134
136
  expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('pre-commit 已存在'));
137
+ // 手动添加提示的片段同样 scoped 化(#36)
138
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('npx @dommaker/harness check --staged'));
135
139
  });
136
140
 
137
141
  it('应该创建 pre-commit hook', async () => {
@@ -143,6 +147,23 @@ describe('init command', () => {
143
147
  await init({ preset: 'standard', projectPath: PROJECT });
144
148
  expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('已创建 .git/hooks/pre-commit'));
145
149
  });
150
+
151
+ it('落盘 pre-commit 使用 scoped 包名(#36)', async () => {
152
+ mockFs.mkdir.mockResolvedValue(undefined);
153
+ mockFs.writeFile.mockResolvedValue(undefined);
154
+ existingFiles.add(`${PROJECT}/.git`);
155
+ existingFiles.add(`${PROJECT}/.git/hooks`);
156
+
157
+ await init({ preset: 'standard', projectPath: PROJECT });
158
+ const writeCalls = mockFs.writeFile.mock.calls;
159
+ const hookCall = writeCalls.find((c: any[]) => String(c[0]).includes('pre-commit'));
160
+ expect(hookCall).toBeDefined();
161
+ expect(hookCall![1]).toContain('npx @dommaker/harness check --staged');
162
+ expect(hookCall![1]).toContain('npx @dommaker/harness posteval-plan');
163
+ expect(hookCall![1]).not.toMatch(/npx harness /);
164
+ // plan 匹配模式须为转义点(#35:模板字面量 \. 运行期退化为 .,与打印版防 drift)
165
+ expect(hookCall![1]).toContain("grep -E 'plans/.*\\.md$|\\.plan\\.md$'");
166
+ });
146
167
  });
147
168
 
148
169
  describe('GitHub Actions', () => {
@@ -155,6 +176,20 @@ describe('init command', () => {
155
176
  await init({ preset: 'standard', projectPath: PROJECT });
156
177
  expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('已存在的 CI 配置'));
157
178
  });
179
+
180
+ it('落盘 harness-check.yml 使用 scoped 包名(#36)', async () => {
181
+ mockFs.mkdir.mockResolvedValue(undefined);
182
+ mockFs.writeFile.mockResolvedValue(undefined);
183
+
184
+ await init({ preset: 'standard', projectPath: PROJECT });
185
+ const writeCalls = mockFs.writeFile.mock.calls;
186
+ const workflowCall = writeCalls.find((c: any[]) => String(c[0]).includes('harness-check.yml'));
187
+ expect(workflowCall).toBeDefined();
188
+ expect(workflowCall![1]).toContain('npx @dommaker/harness check');
189
+ expect(workflowCall![1]).toContain('npx @dommaker/harness validate');
190
+ expect(workflowCall![1]).toContain('npx @dommaker/harness passes-gate');
191
+ expect(workflowCall![1]).not.toMatch(/npx harness /);
192
+ });
158
193
  });
159
194
 
160
195
  describe('CAPABILITIES.md', () => {
@@ -258,6 +293,10 @@ describe('init command', () => {
258
293
  expect(workflowCall).toBeDefined();
259
294
  expect(workflowCall![1]).toContain('Harness Governance');
260
295
  expect(workflowCall![1]).toContain('harness check');
296
+ // scoped 包名(#36)
297
+ expect(workflowCall![1]).toContain('npx @dommaker/harness check');
298
+ expect(workflowCall![1]).toContain('npx @dommaker/harness passes-gate');
299
+ expect(workflowCall![1]).not.toMatch(/npx harness /);
261
300
  });
262
301
 
263
302
  it('应该跳过治理 CI workflow 当已存在', async () => {
@@ -277,6 +316,7 @@ describe('init command', () => {
277
316
  const writeCalls = mockFs.writeFile.mock.calls;
278
317
  const workflowCall = writeCalls.find((c: any[]) => String(c[0]).includes('harness-governance.yml'));
279
318
  expect(workflowCall![1]).toContain('sync-docs');
319
+ expect(workflowCall![1]).toContain('npx @dommaker/harness sync-docs --check');
280
320
  });
281
321
 
282
322
  it('minimal 治理不应在 CI 中包含 docs check', async () => {
@@ -4,7 +4,6 @@
4
4
 
5
5
  import { status } from '../status';
6
6
  import * as fs from 'fs';
7
- import { TraceCollector } from '../../../monitoring/traces';
8
7
  import { TraceAnalyzer } from '../../../monitoring/trace-analyzer';
9
8
 
10
9
  // Mock fs
@@ -41,7 +40,6 @@ jest.mock('chalk', () => ({
41
40
  }));
42
41
 
43
42
  const mockFs = fs as jest.Mocked<typeof fs>;
44
- const MockTraceCollector = TraceCollector as jest.MockedClass<typeof TraceCollector>;
45
43
  const MockTraceAnalyzer = TraceAnalyzer as jest.MockedClass<typeof TraceAnalyzer>;
46
44
 
47
45
  describe('status command - 补充覆盖', () => {
@@ -4,7 +4,6 @@
4
4
 
5
5
  import { status } from '../status';
6
6
  import * as fs from 'fs';
7
- import { TraceCollector } from '../../../monitoring/traces';
8
7
  import { TraceAnalyzer } from '../../../monitoring/trace-analyzer';
9
8
 
10
9
  // Mock fs
@@ -41,7 +40,6 @@ jest.mock('chalk', () => ({
41
40
  }));
42
41
 
43
42
  const mockFs = fs as jest.Mocked<typeof fs>;
44
- const MockTraceCollector = TraceCollector as jest.MockedClass<typeof TraceCollector>;
45
43
  const MockTraceAnalyzer = TraceAnalyzer as jest.MockedClass<typeof TraceAnalyzer>;
46
44
 
47
45
  describe('status command', () => {