@dommaker/harness 1.2.0 → 1.2.1

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 (45) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/cli/commands/check.js +1 -1
  3. package/dist/cli/commands/check.js.map +1 -1
  4. package/dist/cli/commands/constraints-report.js +1 -1
  5. package/dist/cli/commands/constraints-report.js.map +1 -1
  6. package/dist/cli/commands/constraints-retire.d.ts +6 -3
  7. package/dist/cli/commands/constraints-retire.d.ts.map +1 -1
  8. package/dist/cli/commands/constraints-retire.js +34 -25
  9. package/dist/cli/commands/constraints-retire.js.map +1 -1
  10. package/dist/cli/commands/init.d.ts +21 -1
  11. package/dist/cli/commands/init.d.ts.map +1 -1
  12. package/dist/cli/commands/init.js +112 -3
  13. package/dist/cli/commands/init.js.map +1 -1
  14. package/dist/core/constraints/checkers/governance-presence.d.ts +20 -0
  15. package/dist/core/constraints/checkers/governance-presence.d.ts.map +1 -0
  16. package/dist/core/constraints/checkers/governance-presence.js +76 -0
  17. package/dist/core/constraints/checkers/governance-presence.js.map +1 -0
  18. package/dist/core/constraints/checkers/index.d.ts.map +1 -1
  19. package/dist/core/constraints/checkers/index.js +2 -0
  20. package/dist/core/constraints/checkers/index.js.map +1 -1
  21. package/dist/core/constraints/definitions/guidelines.d.ts.map +1 -1
  22. package/dist/core/constraints/definitions/guidelines.js +21 -0
  23. package/dist/core/constraints/definitions/guidelines.js.map +1 -1
  24. package/dist/core/constraints/injection-drift.d.ts +29 -4
  25. package/dist/core/constraints/injection-drift.d.ts.map +1 -1
  26. package/dist/core/constraints/injection-drift.js +52 -20
  27. package/dist/core/constraints/injection-drift.js.map +1 -1
  28. package/package.json +1 -1
  29. package/src/__tests__/governance-presence.test.ts +129 -0
  30. package/src/__tests__/iron-laws.test.ts +7 -7
  31. package/src/cli/commands/CONTEXT.md +4 -1
  32. package/src/cli/commands/__tests__/constraints-report.test.ts +2 -2
  33. package/src/cli/commands/__tests__/constraints-retire.test.ts +45 -4
  34. package/src/cli/commands/__tests__/init-injection.test.ts +202 -0
  35. package/src/cli/commands/__tests__/sync-docs-agents.test.ts +28 -0
  36. package/src/cli/commands/check.ts +1 -1
  37. package/src/cli/commands/constraints-report.ts +1 -1
  38. package/src/cli/commands/constraints-retire.ts +38 -27
  39. package/src/cli/commands/init.ts +123 -3
  40. package/src/core/CONTEXT.md +1 -1
  41. package/src/core/constraints/__tests__/injection-drift.test.ts +90 -0
  42. package/src/core/constraints/checkers/governance-presence.ts +74 -0
  43. package/src/core/constraints/checkers/index.ts +2 -0
  44. package/src/core/constraints/definitions/guidelines.ts +22 -0
  45. package/src/core/constraints/injection-drift.ts +60 -21
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * - 三类漂移各自检出:版本 / 内容(条目级 missing/extra)/ 重复章节
5
5
  * - 无漂移静默;无标记段 = 未注入不算漂移;CLAUDE.md 不存在不算漂移
6
+ * - 落点路由(studio #307):AGENTS.md PRESERVE:governance 内注入段同样校验;
7
+ * 两文件均有标记段时 CLAUDE.md 优先(旧模型仓豁免)
6
8
  * - config.yml 变更后未重跑 init → 内容漂移(extra)
7
9
  *
8
10
  * 使用真实临时目录(getEffectiveConstraints 读真实 fs)。
@@ -33,6 +35,17 @@ function writeSyncedClaudeMd(root: string, extra = ''): string {
33
35
  return content;
34
36
  }
35
37
 
38
+ /** 新模型仓注入段:AGENTS.md `PRESERVE:governance` 段内含标记段(版本 TEST_VERSION) */
39
+ function writeSyncedAgentsMd(root: string, extra = ''): string {
40
+ const section =
41
+ '<!-- PRESERVE:governance -->\n## Governance Rules\n' +
42
+ renderConstraintsSection(getEffectiveConstraints(root), TEST_VERSION) +
43
+ '<!-- /PRESERVE:governance -->\n';
44
+ const content = `# AGENTS.md\n\n${section}${extra}`;
45
+ fs.writeFileSync(path.join(root, 'AGENTS.md'), content, 'utf-8');
46
+ return content;
47
+ }
48
+
36
49
  describe('detectInjectionDrift', () => {
37
50
  it('无漂移:注入段与期望渲染一致 → hasDrift=false', () => {
38
51
  const root = makeTmpProject();
@@ -161,4 +174,81 @@ describe('detectInjectionDrift', () => {
161
174
 
162
175
  expect(drift.versionDrift).toEqual({ expected: TEST_VERSION, actual: '(缺失)' });
163
176
  });
177
+
178
+ it('新模型仓:AGENTS.md PRESERVE:governance 注入段一致 → 无漂移,injectionFile=AGENTS.md', () => {
179
+ const root = makeTmpProject();
180
+ writeSyncedAgentsMd(root);
181
+
182
+ const drift = detectInjectionDrift(root, TEST_VERSION);
183
+
184
+ expect(drift.notInjected).toBe(false);
185
+ expect(drift.injectionFile).toBe('AGENTS.md');
186
+ expect(drift.hasDrift).toBe(false);
187
+ expect(drift.duplicateHeading).toBe(false);
188
+ });
189
+
190
+ it('新模型仓:版本漂移与内容漂移照常检出', () => {
191
+ const root = makeTmpProject();
192
+ const synced = writeSyncedAgentsMd(root);
193
+ const originalLine = synced.split('\n').find(l => l.startsWith('- **'))!;
194
+ const editedLine = originalLine.replace(/: .+$/, ': 手工篡改的注入文本');
195
+ fs.writeFileSync(
196
+ path.join(root, 'AGENTS.md'),
197
+ synced
198
+ .replace(`<!-- version: ${TEST_VERSION} -->`, '<!-- version: 0.0.1-old -->')
199
+ .replace(originalLine, editedLine),
200
+ 'utf-8'
201
+ );
202
+
203
+ const drift = detectInjectionDrift(root, TEST_VERSION);
204
+
205
+ expect(drift.injectionFile).toBe('AGENTS.md');
206
+ expect(drift.hasDrift).toBe(true);
207
+ expect(drift.versionDrift).toEqual({ expected: TEST_VERSION, actual: '0.0.1-old' });
208
+ expect(drift.contentDrift!.missing).toEqual([originalLine]);
209
+ expect(drift.contentDrift!.extra).toEqual([editedLine]);
210
+ });
211
+
212
+ it('新模型仓:PRESERVE 段之外还有 ## Governance Rules 标题 → duplicateHeading', () => {
213
+ const root = makeTmpProject();
214
+ writeSyncedAgentsMd(root, '\n## Governance Rules\n\n旧版遗留的同名章节\n');
215
+
216
+ const drift = detectInjectionDrift(root, TEST_VERSION);
217
+
218
+ expect(drift.injectionFile).toBe('AGENTS.md');
219
+ expect(drift.duplicateHeading).toBe(true);
220
+ expect(drift.hasDrift).toBe(true);
221
+ });
222
+
223
+ it('两文件均有标记段:旧模型仓豁免,CLAUDE.md 优先(AGENTS.md 漂移不影响判定)', () => {
224
+ const root = makeTmpProject();
225
+ writeSyncedClaudeMd(root);
226
+ const agents = writeSyncedAgentsMd(root);
227
+ // AGENTS.md 注入段手改出漂移,CLAUDE.md 保持一致
228
+ fs.writeFileSync(
229
+ path.join(root, 'AGENTS.md'),
230
+ agents.replace(`<!-- version: ${TEST_VERSION} -->`, '<!-- version: 0.0.1-old -->'),
231
+ 'utf-8'
232
+ );
233
+
234
+ const drift = detectInjectionDrift(root, TEST_VERSION);
235
+
236
+ expect(drift.injectionFile).toBe('CLAUDE.md');
237
+ expect(drift.hasDrift).toBe(false);
238
+ });
239
+
240
+ it('两处均无标记段但 AGENTS.md 有重复 Governance Rules 标题 → notInjected + duplicateHeading', () => {
241
+ const root = makeTmpProject();
242
+ fs.writeFileSync(
243
+ path.join(root, 'AGENTS.md'),
244
+ '# AGENTS.md\n\n## Governance Rules\n\n甲\n\n## Governance Rules\n\n乙\n',
245
+ 'utf-8'
246
+ );
247
+
248
+ const drift = detectInjectionDrift(root, TEST_VERSION);
249
+
250
+ expect(drift.notInjected).toBe(true);
251
+ expect(drift.hasDrift).toBe(false);
252
+ expect(drift.duplicateHeading).toBe(true);
253
+ });
164
254
  });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * governance_presence:治理契约在场守护(studio #302,ADR 2026-08-21 落点模型)
3
+ *
4
+ * 治理契约正本 = AGENTS.md 手写 `PRESERVE:governance` 段(旧模型仓 = CLAUDE.md
5
+ * Governance Rules / HARNESS_CONSTRAINTS 注入段)。PRESERVE 只保「存在」不保「在场」:
6
+ * 段被删除/掏空后 sync-docs 重新生成会静默丢失,此处补「在场」校验。
7
+ *
8
+ * ADR-0001 存在性探测:项目未采用 harness 治理(无 .harness/config.yml)→ skip。
9
+ * 向后兼容:旧模型仓(CLAUDE.md 有治理块、AGENTS.md 无 PRESERVE:governance)→ pass,
10
+ * 不强制迁移;两处都没有才报违规。
11
+ */
12
+
13
+ import { readFileSync } from 'fs';
14
+ import { join } from 'path';
15
+ import { loadRawProjectConfig } from '../../project-config-loader';
16
+ import type { ConstraintCheck } from './types';
17
+
18
+ /** 治理契约 PRESERVE 段名(ADR 落点模型约定) */
19
+ export const GOVERNANCE_PRESERVE_NAME = 'governance';
20
+
21
+ const GOVERNANCE_BEGIN_RE = /^<!-- PRESERVE:governance -->\s*$/m;
22
+
23
+ /** AGENTS.md 是否存在非空 PRESERVE:governance 块(标记须独占一行,与 preserve-block.ts 同语义) */
24
+ export function hasGovernancePreserveBlock(agentsMdPath: string): boolean {
25
+ let content: string;
26
+ try {
27
+ content = readFileSync(agentsMdPath, 'utf-8');
28
+ } catch {
29
+ return false;
30
+ }
31
+ const beginMatch = GOVERNANCE_BEGIN_RE.exec(content);
32
+ if (!beginMatch) return false;
33
+ const rest = content.slice(beginMatch.index + beginMatch[0].length);
34
+ const endIdx = rest.indexOf(`<!-- /PRESERVE:${GOVERNANCE_PRESERVE_NAME} -->`);
35
+ if (endIdx === -1) return false;
36
+ // 块体(首尾标记之间)去空白后非空才算「在场」
37
+ return rest.slice(0, endIdx).trim().length > 0;
38
+ }
39
+
40
+ /** CLAUDE.md 是否存在治理块(与 agents-syncer getGovernanceInfo 同判定,旧模型仓豁免) */
41
+ export function hasClaudeGovernance(claudeMdPath: string): boolean {
42
+ let content: string;
43
+ try {
44
+ content = readFileSync(claudeMdPath, 'utf-8');
45
+ } catch {
46
+ return false;
47
+ }
48
+ return /^##\s+Governance Rules/m.test(content) || content.includes('HARNESS_CONSTRAINTS_START');
49
+ }
50
+
51
+ export const governancePresence: ConstraintCheck = {
52
+ id: 'governance_presence',
53
+ async evaluate(env) {
54
+ const projectPath = env.projectPath;
55
+
56
+ // 存在性探测:无 harness 配置 = 未采用治理约定 → skip(不计 pass/fail)
57
+ let adopted = false;
58
+ try {
59
+ adopted = loadRawProjectConfig(projectPath) !== undefined;
60
+ } catch {
61
+ adopted = false;
62
+ }
63
+ if (!adopted) return 'skip';
64
+
65
+ if (hasGovernancePreserveBlock(join(projectPath, 'AGENTS.md'))) return true;
66
+ if (hasClaudeGovernance(join(projectPath, 'CLAUDE.md'))) return true;
67
+
68
+ console.error(
69
+ '[governance_presence] 治理契约缺失:AGENTS.md 无非空 PRESERVE:governance 段,' +
70
+ '且 CLAUDE.md 无 Governance Rules 块——约束正本静默丢失,请恢复其一'
71
+ );
72
+ return false;
73
+ },
74
+ };
@@ -22,6 +22,7 @@ import { capabilitySync } from './capability-sync';
22
22
  import { contextDocSync } from './context-doc-sync';
23
23
  import { docsFreshness } from './docs-freshness';
24
24
  import { noHardcodedCredentials } from './no-hardcoded-credentials';
25
+ import { governancePresence } from './governance-presence';
25
26
 
26
27
  const CHECKS: ConstraintCheck[] = [
27
28
  // Iron Laws
@@ -35,6 +36,7 @@ const CHECKS: ConstraintCheck[] = [
35
36
  noBypassCheckpoint,
36
37
  capabilitySync,
37
38
  contextDocSync,
39
+ governancePresence,
38
40
  ];
39
41
 
40
42
  const registry = new Map<string, ConstraintCheck>(CHECKS.map(c => [c.id, c]));
@@ -97,4 +97,26 @@ export const GUIDELINES: Record<string, Constraint> = {
97
97
  - 测试目录(__tests__、test)
98
98
  - 生成代码目录(dist、build、generated)`,
99
99
  },
100
+
101
+ /**
102
+ * 治理契约在场守护(studio #302,ADR 2026-08-21 落点模型)
103
+ * 原因:PRESERVE 只保「存在」不保「在场」——AGENTS.md 治理段被删除/掏空后
104
+ * sync-docs 重新生成会静默丢失约束正本
105
+ */
106
+ governance_presence: {
107
+ id: 'governance_presence',
108
+ kind: 'check',
109
+ rule: 'GOVERNANCE CONTRACT MUST BE PRESENT (AGENTS.MD PRESERVE:GOVERNANCE BLOCK OR CLAUDE.MD GOVERNANCE RULES)',
110
+ message: '治理契约缺失:AGENTS.md 缺少非空 PRESERVE:governance 段,且 CLAUDE.md 无 Governance Rules 块',
111
+ level: 'guideline',
112
+ trigger: ['file_modification', 'module_creation', 'module_modification', 'doc_update', 'config_change', 'commit'],
113
+ enforcement: 'governance-presence-check',
114
+ description: `治理契约(约束清单正本)必须在场,两处居其一:
115
+ - 新模型:AGENTS.md 手写 \`<!-- PRESERVE:governance -->\` 段(块体非空)
116
+ - 旧模型:CLAUDE.md 的 \`## Governance Rules\` 块 / HARNESS_CONSTRAINTS 注入段
117
+
118
+ PRESERVE 机制只保证「存在的段重新生成时保留」,不保证「段在场」——段被删除或掏空后,
119
+ sync-docs 重新生成会静默丢弃治理契约。本检查在 harness check 时校验在场性,防静默丢失。
120
+ 未采用 harness 治理(无 .harness/config.yml)的项目跳过评估(skip)。`,
121
+ },
100
122
  };
@@ -1,5 +1,5 @@
1
1
  /**
2
- * CLAUDE.md 约束注入段漂移校验(ADR-0001 决策 7)
2
+ * 治理约束注入段漂移校验(ADR-0001 决策 7)
3
3
  *
4
4
  * 检测三类漂移:
5
5
  * 1. 版本漂移:标记段内 `<!-- version: x -->` ≠ 当前 harness 包版本
@@ -9,8 +9,12 @@
9
9
  * 3. 重复章节:标记段之外还存在另一个 `## Governance Rules` 标题
10
10
  * (init 旧版本在无标记时追加第二个同名章节的历史问题)
11
11
  *
12
+ * 落点路由(studio #307,ADR 2026-08-21 落点模型):注入段可能在
13
+ * CLAUDE.md(旧模型仓)或 AGENTS.md `PRESERVE:governance` 段内(新模型仓),
14
+ * 与 init 的 setupGovernanceConstraints 路由一致——CLAUDE.md 有标记优先。
15
+ *
12
16
  * 纯检测、只读:不改任何文件。check 仅警告不阻断,详细差异进 report。
13
- * 文件无标记段 = 未注入,不算漂移(report 一句话提示,check 不警告)。
17
+ * 两处均无标记段 = 未注入,不算漂移(report 一句话提示,check 不警告)。
14
18
  */
15
19
 
16
20
  import * as fs from 'fs';
@@ -22,12 +26,18 @@ import {
22
26
  renderConstraintsSection,
23
27
  } from './injection-renderer';
24
28
 
29
+ /** 注入段落点文件名(检测顺序即路由优先级:旧模型仓 CLAUDE.md 豁免优先) */
30
+ const INJECTION_FILES = ['CLAUDE.md', 'AGENTS.md'] as const;
31
+ export type InjectionFile = (typeof INJECTION_FILES)[number];
32
+
25
33
  /** 注入漂移检测结果 */
26
34
  export interface InjectionDrift {
27
35
  /** 是否存在任一漂移(版本/内容/重复章节) */
28
36
  hasDrift: boolean;
29
- /** CLAUDE.md 不存在或无约束标记段(未注入,不算漂移) */
37
+ /** CLAUDE.md / AGENTS.md 均无约束标记段(未注入,不算漂移) */
30
38
  notInjected: boolean;
39
+ /** 注入段落点文件(未注入时 undefined) */
40
+ injectionFile?: InjectionFile;
31
41
  /** 版本漂移:注入段版本 ≠ 当前 harness 版本 */
32
42
  versionDrift?: { expected: string; actual: string };
33
43
  /**
@@ -73,8 +83,41 @@ function significantLines(section: string): string[] {
73
83
  .filter(l => ENTRY_LINE_RE.test(l) || GROUP_HEADING_RE.test(l));
74
84
  }
75
85
 
86
+ function readIfExists(filePath: string): string | null {
87
+ try {
88
+ const content = fs.readFileSync(filePath, 'utf-8');
89
+ return typeof content === 'string' ? content : null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * 解析治理约束注入段落点(studio #307,ADR 2026-08-21 落点模型)
97
+ *
98
+ * 与 init 的 setupGovernanceConstraints 路由一致:CLAUDE.md 含标记段优先
99
+ * (旧模型仓豁免),否则看 AGENTS.md(新模型仓注入段在 PRESERVE:governance 内);
100
+ * 两处均无完整标记段 → null(未注入)。
101
+ *
102
+ * detectInjectionDrift 与 constraints retire 的注入段同步共用本路由。
103
+ */
104
+ export function resolveInjectionTarget(
105
+ projectRoot: string
106
+ ): { file: InjectionFile; content: string; startIdx: number; endIdx: number } | null {
107
+ for (const file of INJECTION_FILES) {
108
+ const content = readIfExists(path.join(projectRoot, file));
109
+ if (content === null) continue;
110
+ const startIdx = content.indexOf(CONSTRAINTS_START_MARKER);
111
+ const endIdx = content.indexOf(CONSTRAINTS_END_MARKER);
112
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
113
+ return { file, content, startIdx, endIdx };
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
76
119
  /**
77
- * 检测 CLAUDE.md 约束注入段漂移
120
+ * 检测约束注入段漂移
78
121
  *
79
122
  * @param projectRoot 项目根路径
80
123
  * @param currentVersion 当前 harness 版本(缺省读 package.json;测试可显式传入)
@@ -90,31 +133,27 @@ export function detectInjectionDrift(
90
133
  fixHint: INJECTION_DRIFT_FIX_HINT,
91
134
  };
92
135
 
93
- const claudeMdPath = path.join(projectRoot, 'CLAUDE.md');
94
- let content: string;
95
- try {
96
- content = fs.readFileSync(claudeMdPath, 'utf-8');
97
- } catch {
98
- result.notInjected = true;
99
- return result;
100
- }
101
- if (typeof content !== 'string') {
136
+ const target = resolveInjectionTarget(projectRoot);
137
+ if (!target) {
138
+ // 两处均无标记段 = 未注入,不算漂移(check 不警告)。
139
+ // 重复章节仍如实记录供 report 提示:旧落点 CLAUDE.md 优先,其次 AGENTS.md。
140
+ const legacy =
141
+ readIfExists(path.join(projectRoot, 'CLAUDE.md')) ??
142
+ readIfExists(path.join(projectRoot, 'AGENTS.md'));
143
+ if (legacy !== null) {
144
+ result.duplicateHeading = (legacy.match(GOVERNANCE_HEADING_RE) ?? []).length > 1;
145
+ }
102
146
  result.notInjected = true;
103
147
  return result;
104
148
  }
105
149
 
150
+ result.injectionFile = target.file;
151
+ const { content, startIdx, endIdx } = target;
152
+
106
153
  // 重复章节:全文统计 `## Governance Rules` 标题数(含标记段所属的合法标题)
107
154
  const headingCount = (content.match(GOVERNANCE_HEADING_RE) ?? []).length;
108
155
  result.duplicateHeading = headingCount > 1;
109
156
 
110
- const startIdx = content.indexOf(CONSTRAINTS_START_MARKER);
111
- const endIdx = content.indexOf(CONSTRAINTS_END_MARKER);
112
- if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
113
- // 无标记段 = 未注入,不算漂移(check 不警告;duplicateHeading 仍如实记录供 report 提示)
114
- result.notInjected = true;
115
- return result;
116
- }
117
-
118
157
  const actualSection = content.slice(startIdx, endIdx + CONSTRAINTS_END_MARKER.length);
119
158
 
120
159
  // 1. 版本漂移