@dommaker/harness 1.2.0 → 1.2.2

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 (46) hide show
  1. package/CHANGELOG.md +12 -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 +137 -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__/init.test.ts +43 -0
  36. package/src/cli/commands/__tests__/sync-docs-agents.test.ts +28 -0
  37. package/src/cli/commands/check.ts +1 -1
  38. package/src/cli/commands/constraints-report.ts +1 -1
  39. package/src/cli/commands/constraints-retire.ts +38 -27
  40. package/src/cli/commands/init.ts +150 -3
  41. package/src/core/CONTEXT.md +1 -1
  42. package/src/core/constraints/__tests__/injection-drift.test.ts +90 -0
  43. package/src/core/constraints/checkers/governance-presence.ts +74 -0
  44. package/src/core/constraints/checkers/index.ts +2 -0
  45. package/src/core/constraints/definitions/guidelines.ts +22 -0
  46. package/src/core/constraints/injection-drift.ts +60 -21
@@ -569,7 +569,102 @@ export async function setupClaudeMdOutputStyle(projectPath: string): Promise<voi
569
569
  }
570
570
 
571
571
  /**
572
- * CLAUDE.md 中写入/更新 Governance Rules 约束段
572
+ * 治理契约 PRESERVE 段标记(studio #302,ADR 2026-08-21 落点模型:
573
+ * 治理契约正本住 AGENTS.md 手写 PRESERVE 段,sync-docs 重新生成时原样保留)
574
+ */
575
+ const GOVERNANCE_PRESERVE_BEGIN = '<!-- PRESERVE:governance -->';
576
+ const GOVERNANCE_PRESERVE_END = '<!-- /PRESERVE:governance -->';
577
+
578
+ /**
579
+ * 在 AGENTS.md 的 PRESERVE:governance 段写入/更新 Governance Rules 约束段(新落点模型)
580
+ *
581
+ * - AGENTS.md 不存在:创建最小骨架(标题 + 说明 + PRESERVE:governance 段),
582
+ * 完整导读由 `harness sync-docs --agents` 生成,PRESERVE 段在重新生成时原样保留
583
+ * - 已有 PRESERVE:governance 段:段内机器管理的只有 HARNESS_CONSTRAINTS 标记区间——
584
+ * 有标记则只替换标记区间,段内其余手写内容(治理契约引言/流程/纪律等)原样保留;
585
+ * 无标记(纯手写段)则在段尾追加注入段,不动手写内容
586
+ * - 无该段:在文件末尾追加
587
+ * - 段标记残缺(只有单边标记):不写入,告警交由人工修复(防二次损坏)
588
+ */
589
+ export async function setupAgentsMdConstraints(projectPath: string): Promise<void> {
590
+ const agentsMdPath = path.join(projectPath, 'AGENTS.md');
591
+ const version = getPackageVersion();
592
+
593
+ const constraints = getEffectiveConstraints(projectPath);
594
+ const bodyOnly = renderConstraintsSection(constraints, version);
595
+ const block = `${GOVERNANCE_PRESERVE_BEGIN}\n## Governance Rules\n${bodyOnly}${GOVERNANCE_PRESERVE_END}\n`;
596
+
597
+ let existingContent: string | null = null;
598
+ try {
599
+ existingContent = await fs.readFile(agentsMdPath, 'utf-8');
600
+ } catch {
601
+ // AGENTS.md 不存在
602
+ }
603
+
604
+ if (existingContent === null) {
605
+ const skeleton = [
606
+ '# AGENTS.md',
607
+ '',
608
+ '> 机器生成部分由 `harness sync-docs --agents` 维护;`PRESERVE:governance` 段是治理契约正本(手写/治理变更流程管控),重新生成时原样保留。',
609
+ '',
610
+ block,
611
+ ].join('\n');
612
+ await fs.writeFile(agentsMdPath, skeleton, 'utf-8');
613
+ console.log(chalk.green(`✅ 已创建 AGENTS.md 并写入治理契约 PRESERVE:governance 段 (v${version})`));
614
+ return;
615
+ }
616
+
617
+ const startIdx = existingContent.indexOf(GOVERNANCE_PRESERVE_BEGIN);
618
+ const endIdx = existingContent.indexOf(GOVERNANCE_PRESERVE_END);
619
+
620
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
621
+ // 段内机器管理的只有 HARNESS_CONSTRAINTS 标记区间;其余手写内容原样保留
622
+ const before = existingContent.slice(0, startIdx);
623
+ const blockContent = existingContent.slice(startIdx + GOVERNANCE_PRESERVE_BEGIN.length, endIdx);
624
+ const after = existingContent.slice(endIdx + GOVERNANCE_PRESERVE_END.length).replace(/^\n+/, '');
625
+
626
+ const csIdx = blockContent.indexOf(CONSTRAINTS_START_MARKER);
627
+ const ceIdx = blockContent.indexOf(CONSTRAINTS_END_MARKER);
628
+ let newBlockContent: string;
629
+ if (csIdx !== -1 && ceIdx !== -1 && ceIdx > csIdx) {
630
+ // 只替换标记区间。bodyOnly 自带结尾换行,故剥掉尾部恰好一个前导换行
631
+ // (END 标记行的行尾换行),其余手写内容逐字保留,保证幂等。
632
+ newBlockContent =
633
+ blockContent.slice(0, csIdx) +
634
+ bodyOnly +
635
+ blockContent.slice(ceIdx + CONSTRAINTS_END_MARKER.length).replace(/^\n/, '');
636
+ } else {
637
+ // 纯手写段(无约束标记):段尾追加注入段,手写内容不动
638
+ newBlockContent = blockContent.trimEnd() + '\n\n## Governance Rules\n' + bodyOnly;
639
+ }
640
+
641
+ const newContent =
642
+ before +
643
+ GOVERNANCE_PRESERVE_BEGIN +
644
+ newBlockContent +
645
+ GOVERNANCE_PRESERVE_END +
646
+ '\n' +
647
+ (after ? '\n' + after : '');
648
+ if (newContent !== existingContent) {
649
+ await fs.writeFile(agentsMdPath, newContent, 'utf-8');
650
+ console.log(chalk.green(`✅ 已更新 AGENTS.md 治理契约 PRESERVE:governance 段 (v${version})`));
651
+ }
652
+ return;
653
+ }
654
+
655
+ if (startIdx !== -1 || endIdx !== -1) {
656
+ console.log(chalk.yellow('⚠️ AGENTS.md 中 PRESERVE:governance 标记残缺(只有单边),跳过治理契约写入,请人工修复'));
657
+ return;
658
+ }
659
+
660
+ // 无该段:文件末尾追加
661
+ const newContent = existingContent.trimEnd() + '\n\n' + block;
662
+ await fs.writeFile(agentsMdPath, newContent, 'utf-8');
663
+ console.log(chalk.green(`✅ 已追加治理契约 PRESERVE:governance 段到 AGENTS.md (v${version})`));
664
+ }
665
+
666
+ /**
667
+ * 在 CLAUDE.md 中写入/更新 Governance Rules 约束段(旧落点模型,向后兼容保留)
573
668
  *
574
669
  * - 约束集来自 getEffectiveConstraints(ADR-0001):preset 裁剪、config.yml
575
670
  * 禁用、custom 追加、scenes 过滤全部反映在注入文本里
@@ -632,6 +727,30 @@ export async function setupClaudeMdConstraints(projectPath: string): Promise<voi
632
727
  }
633
728
  }
634
729
 
730
+ /**
731
+ * 治理约束段写入落点路由(studio #302,ADR 2026-08-21 落点模型)
732
+ *
733
+ * - 旧模型仓(CLAUDE.md 已有 HARNESS_CONSTRAINTS 标记或 `## Governance Rules` 块):
734
+ * 继续写 CLAUDE.md——init 幂等重跑不破坏既有仓,不制造双份约束正本
735
+ * - 其余(新仓初始化):写 AGENTS.md PRESERVE:governance 段(入库公共面正本)
736
+ */
737
+ export async function setupGovernanceConstraints(projectPath: string): Promise<void> {
738
+ let claudeContent: string | null = null;
739
+ try {
740
+ claudeContent = await fs.readFile(path.join(projectPath, 'CLAUDE.md'), 'utf-8');
741
+ } catch {
742
+ // CLAUDE.md 不存在 → 新仓
743
+ }
744
+
745
+ if (
746
+ claudeContent !== null &&
747
+ (claudeContent.includes(CONSTRAINTS_START_MARKER) || /^##\s+Governance Rules/m.test(claudeContent))
748
+ ) {
749
+ return setupClaudeMdConstraints(projectPath);
750
+ }
751
+ return setupAgentsMdConstraints(projectPath);
752
+ }
753
+
635
754
  /**
636
755
  * 设置治理相关文件
637
756
  */
@@ -648,8 +767,9 @@ async function setupGovernance(projectPath: string, level: string): Promise<void
648
767
  // 2. 在 CLAUDE.md 中写入 Output Style 段(仅在不存在时创建)
649
768
  await setupClaudeMdOutputStyle(projectPath);
650
769
 
651
- // 3. CLAUDE.md 中写入/更新 Governance Rules 约束段
652
- await setupClaudeMdConstraints(projectPath);
770
+ // 3. 写入/更新 Governance Rules 约束段(新仓 → AGENTS.md PRESERVE:governance;
771
+ // 旧模型仓 → CLAUDE.md,落点路由见 setupGovernanceConstraints)
772
+ await setupGovernanceConstraints(projectPath);
653
773
 
654
774
  // 4. 生成 CONTEXT.md 文件
655
775
  const contextConfig = governance.context_files as Record<string, unknown> | undefined;
@@ -768,6 +888,26 @@ async function createContextMd(projectPath: string, dir: string): Promise<void>
768
888
  console.log(chalk.green(`✅ 已创建 ${dir}/CONTEXT.md`));
769
889
  }
770
890
 
891
+ /**
892
+ * 检测已有 workflow 是否已覆盖 harness 治理命令
893
+ * (harness check / passes-gate / sync-docs --check,含 npx、scoped 包名等调用形式)
894
+ */
895
+ const GOVERNANCE_COMMAND_PATTERN = /\bharness\s+(?:check\b|passes-gate\b|sync-docs\b[^\n]*--check)/;
896
+
897
+ async function findGovernanceCoverage(workflowsDir: string): Promise<string | undefined> {
898
+ for (const file of await findCiWorkflows(workflowsDir)) {
899
+ try {
900
+ const content = await fs.readFile(path.join(workflowsDir, file), 'utf-8');
901
+ if (GOVERNANCE_COMMAND_PATTERN.test(content)) {
902
+ return file;
903
+ }
904
+ } catch {
905
+ // 读取失败,忽略该文件
906
+ }
907
+ }
908
+ return undefined;
909
+ }
910
+
771
911
  /**
772
912
  * 设置治理 CI workflow
773
913
  */
@@ -784,6 +924,13 @@ async function setupGovernanceWorkflow(projectPath: string, level: string): Prom
784
924
  // 不存在,继续创建
785
925
  }
786
926
 
927
+ // 能力检测:已有 workflow 已跑 harness 治理命令时跳过,避免重复 CI 面
928
+ const coveredBy = await findGovernanceCoverage(workflowsDir);
929
+ if (coveredBy) {
930
+ console.log(chalk.gray(`治理检查已由 ${coveredBy} 覆盖,跳过创建 harness-governance.yml`));
931
+ return;
932
+ }
933
+
787
934
  await fs.mkdir(workflowsDir, { recursive: true });
788
935
 
789
936
  const docsCheckStep = level !== 'minimal'
@@ -4,7 +4,7 @@
4
4
  约束引擎核心:check/prompt 二元约束系统(ADR-0001)、生效集合并(effective-constraints)、检查点验证器(CSO/passes-gate)、会话管理、Spec 验证器、项目配置加载。
5
5
 
6
6
  ## 核心导出
7
- - `constraints/` — 约束定义(IRON_LAWS/GUIDELINES/PROMPTS) + 检查引擎(ConstraintChecker,拦截统一由 checkBeforeExecution 承担,ADR-0004) + 缓存(CheckCache:TTL 缓存 + 计数采样,H6/G5 起公开导出) + 注入渲染(injection-renderer) + Agent prompt 渲染(agent-prompt-renderer:trigger 参数化分组渲染,role 路由留 studio,H6/G6)/漂移校验(injection-drift)/使用统计(usage-report);CheckEnv 生产侧唯一构造点 `buildCheckEnv(context, providers|'none')`(checkers/types.ts:providers = 证据接线,'none' = 显式不接 → evidence flag checker 按契约 skip)
7
+ - `constraints/` — 约束定义(IRON_LAWS/GUIDELINES/PROMPTS) + 检查引擎(ConstraintChecker,拦截统一由 checkBeforeExecution 承担,ADR-0004) + 缓存(CheckCache:TTL 缓存 + 计数采样,H6/G5 起公开导出) + 注入渲染(injection-renderer) + Agent prompt 渲染(agent-prompt-renderer:trigger 参数化分组渲染,role 路由留 studio,H6/G6)/漂移校验(injection-drift:含注入段落点路由 resolveInjectionTarget,CLAUDE.md 有标记优先、否则 AGENTS.md PRESERVE:governance,studio #307)/使用统计(usage-report);CheckEnv 生产侧唯一构造点 `buildCheckEnv(context, providers|'none')`(checkers/types.ts:providers = 证据接线,'none' = 显式不接 → evidence flag checker 按契约 skip)
8
8
  - `effective-constraints.ts` — `getEffectiveConstraints(projectRoot, {preset?})`:全仓唯一生效集来源(内置 → preset → config.yml 禁用(内置与 custom 同效)→ custom 追加(禁用/已退役的不追加)→ scenes 过滤);`getMergedConstraintsConfig(projectRoot, {preset?})`:同链路的完整 MergedConstraintsConfig 形状(含 disabled/custom/unknownIds),内置工单 23 优先级规则(--preset 仅在无项目自定义配置时生效),check 命令经此入口;`lintEffectiveConfig` 配置诊断
9
9
  - `effective-set.ts` — `filterEnabledEntries(knownIds, entries, {onUnknownId})`:约束侧(collect)与门禁侧(throw)共用的 config 条目筛选器
10
10
  - `validators/` — checkpoints、passes-gate、CSO 验证器
@@ -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. 版本漂移