@dommaker/harness 0.18.0 → 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 (34) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/bin/harness.js +6 -1
  3. package/dist/cli/commands/update-user-model.d.ts +1 -0
  4. package/dist/cli/commands/update-user-model.d.ts.map +1 -1
  5. package/dist/cli/commands/update-user-model.js +12 -5
  6. package/dist/cli/commands/update-user-model.js.map +1 -1
  7. package/dist/core/constraints/agent-prompt-renderer.d.ts +32 -0
  8. package/dist/core/constraints/agent-prompt-renderer.d.ts.map +1 -0
  9. package/dist/core/constraints/agent-prompt-renderer.js +58 -0
  10. package/dist/core/constraints/agent-prompt-renderer.js.map +1 -0
  11. package/dist/core/constraints/check-cache.d.ts +48 -3
  12. package/dist/core/constraints/check-cache.d.ts.map +1 -1
  13. package/dist/core/constraints/check-cache.js +53 -5
  14. package/dist/core/constraints/check-cache.js.map +1 -1
  15. package/dist/core/constraints/index.d.ts +4 -0
  16. package/dist/core/constraints/index.d.ts.map +1 -1
  17. package/dist/core/constraints/index.js +7 -1
  18. package/dist/core/constraints/index.js.map +1 -1
  19. package/dist/index.d.ts +4 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +8 -1
  22. package/dist/index.js.map +1 -1
  23. package/package.json +4 -4
  24. package/src/cli/commands/__tests__/analyze-sessions.test.ts +178 -0
  25. package/src/cli/commands/__tests__/release.test.ts +162 -0
  26. package/src/cli/commands/__tests__/update-user-model.test.ts +205 -0
  27. package/src/cli/commands/update-user-model.ts +15 -5
  28. package/src/core/CONTEXT.md +1 -1
  29. package/src/core/constraints/__tests__/agent-prompt-renderer.test.ts +129 -0
  30. package/src/core/constraints/__tests__/check-cache.test.ts +227 -0
  31. package/src/core/constraints/agent-prompt-renderer.ts +71 -0
  32. package/src/core/constraints/check-cache.ts +75 -3
  33. package/src/core/constraints/index.ts +10 -0
  34. package/src/index.ts +8 -0
@@ -0,0 +1,129 @@
1
+ /**
2
+ * trigger 参数化约束分组渲染 API 测试(H6/G6,收编 studio prompt-injection.ts)
3
+ *
4
+ * 语义:
5
+ * - 入参 = 触发条件(单 trigger 或数组),无 role 概念(role→trigger 路由留 studio)
6
+ * - 数据源 = getEffectiveConstraints(内置 → preset → config.yml 禁用 → custom 追加 → scenes 过滤)
7
+ * - 输出按层级分组:铁律 → 指导原则 → 行为提示;无 promptInjection 的约束不渲染
8
+ */
9
+
10
+ import * as fs from 'fs';
11
+ import * as os from 'os';
12
+ import * as path from 'path';
13
+ import { renderConstraintsByTrigger } from '../agent-prompt-renderer';
14
+ import { renderConstraintsByTrigger as PublicRenderConstraintsByTrigger } from '../../../index';
15
+
16
+ describe('公开导出(根 barrel)', () => {
17
+ test('renderConstraintsByTrigger 经 src/index.ts 公开导出', () => {
18
+ expect(PublicRenderConstraintsByTrigger).toBe(renderConstraintsByTrigger);
19
+ });
20
+ });
21
+
22
+ /** 创建临时项目目录,可按相对路径写入 .harness/config.yml 等夹具文件 */
23
+ function makeProject(files: Record<string, string>): string {
24
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'h6-render-'));
25
+ for (const [rel, content] of Object.entries(files)) {
26
+ const target = path.join(dir, rel);
27
+ fs.mkdirSync(path.dirname(target), { recursive: true });
28
+ fs.writeFileSync(target, content, 'utf-8');
29
+ }
30
+ return dir;
31
+ }
32
+
33
+ describe('renderConstraintsByTrigger', () => {
34
+ test('内置约束按层级分组渲染:铁律 → 指导原则 → 行为提示', () => {
35
+ const projectRoot = makeProject({});
36
+ const out = renderConstraintsByTrigger('code_implementation', { projectRoot });
37
+
38
+ expect(out).toContain('## 行为约束(前置声明)');
39
+ expect(out).toContain('### 铁律(绝对禁止,无例外)');
40
+ expect(out).toContain('- **no_completion_without_verification**:');
41
+ expect(out).toContain('### 指导原则(优先建议)');
42
+ expect(out).toContain('- **no_hardcoded_credentials**:');
43
+ expect(out).toContain('### 行为提示');
44
+ expect(out).toContain('- **no_code_without_test**:');
45
+
46
+ // 分组顺序:铁律 < 指导原则 < 行为提示
47
+ const ironIdx = out.indexOf('### 铁律(绝对禁止,无例外)');
48
+ const guideIdx = out.indexOf('### 指导原则(优先建议)');
49
+ const promptIdx = out.indexOf('### 行为提示');
50
+ expect(ironIdx).toBeGreaterThanOrEqual(0);
51
+ expect(guideIdx).toBeGreaterThan(ironIdx);
52
+ expect(promptIdx).toBeGreaterThan(guideIdx);
53
+ });
54
+
55
+ test('无 promptInjection 的约束不渲染(即使 trigger 匹配)', () => {
56
+ const projectRoot = makeProject({});
57
+ // docs_freshness / capability_sync / context_doc_sync 无 promptInjection
58
+ const out = renderConstraintsByTrigger('module_modification', { projectRoot });
59
+
60
+ expect(out).not.toContain('- **docs_freshness**:');
61
+ expect(out).not.toContain('- **capability_sync**:');
62
+ expect(out).not.toContain('- **context_doc_sync**:');
63
+ });
64
+
65
+ test('支持 trigger 数组入参,仅匹配交集约束', () => {
66
+ const projectRoot = makeProject({});
67
+ const out = renderConstraintsByTrigger(['test_creation'], { projectRoot });
68
+
69
+ expect(out).toContain('- **no_test_simplification**:');
70
+ // no_completion_without_verification 的 trigger 为 code_implementation,不匹配
71
+ expect(out).not.toContain('- **no_completion_without_verification**:');
72
+ });
73
+
74
+ test('无匹配约束返回空字符串', () => {
75
+ const projectRoot = makeProject({});
76
+ expect(renderConstraintsByTrigger('nonexistent_trigger_xyz', { projectRoot })).toBe('');
77
+ expect(renderConstraintsByTrigger([], { projectRoot })).toBe('');
78
+ });
79
+
80
+ test('config.yml 禁用的约束不进入渲染(生效集数据源)', () => {
81
+ const projectRoot = makeProject({
82
+ '.harness/config.yml': [
83
+ 'constraints:',
84
+ ' no_hardcoded_credentials:',
85
+ ' enabled: false',
86
+ '',
87
+ ].join('\n'),
88
+ });
89
+ const out = renderConstraintsByTrigger('code_implementation', { projectRoot });
90
+
91
+ expect(out).not.toContain('- **no_hardcoded_credentials**:');
92
+ // 同 trigger 的其它约束仍渲染
93
+ expect(out).toContain('- **no_completion_without_verification**:');
94
+ });
95
+
96
+ test('custom 约束追加进生效集并参与分组渲染', () => {
97
+ const projectRoot = makeProject({
98
+ '.harness/config.yml': [
99
+ 'custom_constraints:',
100
+ ' my_custom_rule:',
101
+ ' level: prompt',
102
+ ' trigger: code_implementation',
103
+ ' rule: CUSTOM RULE',
104
+ ' message: 自定义约束消息',
105
+ ' promptInjection: 自定义行为提示文本',
106
+ '',
107
+ ].join('\n'),
108
+ });
109
+ const out = renderConstraintsByTrigger('code_implementation', { projectRoot });
110
+
111
+ expect(out).toContain('- **my_custom_rule**: 自定义行为提示文本');
112
+ const promptIdx = out.indexOf('### 行为提示');
113
+ const customIdx = out.indexOf('- **my_custom_rule**:');
114
+ expect(customIdx).toBeGreaterThan(promptIdx);
115
+ });
116
+
117
+ test('scenes 过滤:带 appliesTo 的场景专属 prompt 默认不渲染,声明场景后渲染', () => {
118
+ // no_skill_without_test:appliesTo ['agent-skill'],trigger module_creation
119
+ const plain = makeProject({});
120
+ expect(renderConstraintsByTrigger('module_creation', { projectRoot: plain }))
121
+ .not.toContain('- **no_skill_without_test**:');
122
+
123
+ const withScene = makeProject({
124
+ '.harness/config.yml': 'scenes:\n - agent-skill\n',
125
+ });
126
+ expect(renderConstraintsByTrigger('module_creation', { projectRoot: withScene }))
127
+ .toContain('- **no_skill_without_test**:');
128
+ });
129
+ });
@@ -0,0 +1,227 @@
1
+ /**
2
+ * CheckCache 测试(H6/G5:TTL 缓存既有行为 + 计数采样扩展)
3
+ *
4
+ * 计数采样语义(与 studio runtime/cache.ts 收编对齐):
5
+ * - 每个 (namespace, key) 独立计数
6
+ * - 第 1 次调用必执行(采样轮),此后每 N 次执行 1 次
7
+ * - 非采样轮:缓存命中(未过期)返回缓存值;未命中/已过期返回 defaultValueOnMiss,不执行 fn
8
+ */
9
+
10
+ import { CheckCache } from '../check-cache';
11
+ import { CheckCache as PublicCheckCache } from '../../../index';
12
+
13
+ describe('公开导出(根 barrel)', () => {
14
+ test('CheckCache 经 src/index.ts 公开导出', () => {
15
+ expect(PublicCheckCache).toBe(CheckCache);
16
+ });
17
+ });
18
+
19
+ describe('CheckCache', () => {
20
+ beforeEach(() => {
21
+ jest.useFakeTimers();
22
+ jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
23
+ });
24
+
25
+ afterEach(() => {
26
+ jest.useRealTimers();
27
+ });
28
+
29
+ describe('TTL 缓存(既有行为,不传 sampling 参数)', () => {
30
+ test('缓存命中不重复执行 fn,过期后重新执行', async () => {
31
+ const cache = new CheckCache({ ttlMs: 1000 });
32
+ const fn = jest.fn(async () => 'v1');
33
+
34
+ expect(await cache.get('ns', 'k', fn)).toBe('v1');
35
+ expect(await cache.get('ns', 'k', fn)).toBe('v1');
36
+ expect(fn).toHaveBeenCalledTimes(1);
37
+
38
+ jest.advanceTimersByTime(1001);
39
+ expect(await cache.get('ns', 'k', fn)).toBe('v1');
40
+ expect(fn).toHaveBeenCalledTimes(2);
41
+ });
42
+
43
+ test('getSync 命中/过期行为一致', () => {
44
+ const cache = new CheckCache({ ttlMs: 1000 });
45
+ const fn = jest.fn(() => 'sync');
46
+
47
+ expect(cache.getSync('ns', 'k', fn)).toBe('sync');
48
+ expect(cache.getSync('ns', 'k', fn)).toBe('sync');
49
+ expect(fn).toHaveBeenCalledTimes(1);
50
+
51
+ jest.advanceTimersByTime(1001);
52
+ expect(cache.getSync('ns', 'k', fn)).toBe('sync');
53
+ expect(fn).toHaveBeenCalledTimes(2);
54
+ });
55
+
56
+ test('缺省构造使用默认 TTL 5000ms', async () => {
57
+ const cache = new CheckCache();
58
+ const fn = jest.fn(async () => 'v');
59
+
60
+ await cache.get('ns', 'k', fn);
61
+ jest.advanceTimersByTime(4999);
62
+ await cache.get('ns', 'k', fn);
63
+ expect(fn).toHaveBeenCalledTimes(1);
64
+
65
+ jest.advanceTimersByTime(2); // 累计 5001ms,已过期
66
+ await cache.get('ns', 'k', fn);
67
+ expect(fn).toHaveBeenCalledTimes(2);
68
+ });
69
+
70
+ test('命名空间隔离:不同 namespace 相同 key 互不影响', async () => {
71
+ const cache = new CheckCache({ ttlMs: 1000 });
72
+ const f1 = jest.fn(async () => 'a');
73
+ const f2 = jest.fn(async () => 'b');
74
+
75
+ expect(await cache.get('ns1', 'k', f1)).toBe('a');
76
+ expect(await cache.get('ns2', 'k', f2)).toBe('b');
77
+ expect(f1).toHaveBeenCalledTimes(1);
78
+ expect(f2).toHaveBeenCalledTimes(1);
79
+ });
80
+
81
+ test('invalidate(namespace) 仅清除指定命名空间', async () => {
82
+ const cache = new CheckCache({ ttlMs: 1000 });
83
+ const f1 = jest.fn(async () => 'a');
84
+ const f2 = jest.fn(async () => 'b');
85
+
86
+ await cache.get('ns1', 'k', f1);
87
+ await cache.get('ns2', 'k', f2);
88
+ cache.invalidate('ns1');
89
+
90
+ expect(await cache.get('ns1', 'k', f1)).toBe('a');
91
+ expect(await cache.get('ns2', 'k', f2)).toBe('b');
92
+ expect(f1).toHaveBeenCalledTimes(2);
93
+ expect(f2).toHaveBeenCalledTimes(1);
94
+ });
95
+
96
+ test('invalidate() 清除全部缓存', async () => {
97
+ const cache = new CheckCache({ ttlMs: 1000 });
98
+ const fn = jest.fn(async () => 'v');
99
+
100
+ await cache.get('ns', 'k1', fn);
101
+ await cache.get('ns', 'k2', fn);
102
+ cache.invalidate();
103
+
104
+ await cache.get('ns', 'k1', fn);
105
+ await cache.get('ns', 'k2', fn);
106
+ expect(fn).toHaveBeenCalledTimes(4);
107
+ });
108
+ });
109
+
110
+ describe('计数采样(声明式配置)', () => {
111
+ test('第 1 次调用必执行,此后每 N 次执行 1 次,其余复用缓存结果', async () => {
112
+ const cache = new CheckCache({ ttlMs: 60_000 });
113
+ let runs = 0;
114
+ const fn = jest.fn(async () => `run-${++runs}`);
115
+ const sampling = { sampleRate: 3, defaultValueOnMiss: 'skip' };
116
+
117
+ // 第 1 次:采样轮 → 执行
118
+ expect(await cache.get('ns', 'k', fn, sampling)).toBe('run-1');
119
+ // 第 2、3 次:非采样轮 → 复用缓存
120
+ expect(await cache.get('ns', 'k', fn, sampling)).toBe('run-1');
121
+ expect(await cache.get('ns', 'k', fn, sampling)).toBe('run-1');
122
+ expect(fn).toHaveBeenCalledTimes(1);
123
+ // 第 4 次:采样轮 → 执行
124
+ expect(await cache.get('ns', 'k', fn, sampling)).toBe('run-2');
125
+ expect(fn).toHaveBeenCalledTimes(2);
126
+ });
127
+
128
+ test('非采样轮缓存已过期:返回 defaultValueOnMiss,不执行 fn', async () => {
129
+ const cache = new CheckCache({ ttlMs: 1000 });
130
+ const fn = jest.fn(async () => 'fresh');
131
+ const sampling = { sampleRate: 3, defaultValueOnMiss: 'skip' };
132
+
133
+ expect(await cache.get('ns', 'k', fn, sampling)).toBe('fresh'); // 采样轮,TTL=1s 缓存
134
+ jest.advanceTimersByTime(1001);
135
+
136
+ expect(await cache.get('ns', 'k', fn, sampling)).toBe('skip'); // 过期 + 非采样轮
137
+ expect(await cache.get('ns', 'k', fn, sampling)).toBe('skip');
138
+ expect(fn).toHaveBeenCalledTimes(1);
139
+
140
+ // 第 4 次:采样轮 → 重新执行
141
+ expect(await cache.get('ns', 'k', fn, sampling)).toBe('fresh');
142
+ expect(fn).toHaveBeenCalledTimes(2);
143
+ });
144
+
145
+ test('采样计数器按 key 独立', async () => {
146
+ const cache = new CheckCache({ ttlMs: 60_000 });
147
+ const f1 = jest.fn(async () => 'a');
148
+ const f2 = jest.fn(async () => 'b');
149
+ const sampling = { sampleRate: 3, defaultValueOnMiss: 'x' };
150
+
151
+ await cache.get('ns', 'k1', f1, sampling);
152
+ await cache.get('ns', 'k2', f2, sampling);
153
+ expect(f1).toHaveBeenCalledTimes(1);
154
+ expect(f2).toHaveBeenCalledTimes(1);
155
+
156
+ await cache.get('ns', 'k1', f1, sampling); // k1 非采样轮
157
+ expect(f1).toHaveBeenCalledTimes(1);
158
+ });
159
+
160
+ test('invalidate 重置计数器:失效后下一次调用恢复为采样轮', async () => {
161
+ const cache = new CheckCache({ ttlMs: 60_000 });
162
+ const fn = jest.fn(async () => 'v');
163
+ const sampling = { sampleRate: 3, defaultValueOnMiss: 'd' };
164
+
165
+ await cache.get('ns', 'k', fn, sampling); // count=1 采样
166
+ await cache.get('ns', 'k', fn, sampling); // count=2 跳过
167
+ cache.invalidate('ns');
168
+ await cache.get('ns', 'k', fn, sampling); // 重置后 count=1 采样
169
+ expect(fn).toHaveBeenCalledTimes(2);
170
+ });
171
+
172
+ test('invalidate() 全量同样重置计数器', async () => {
173
+ const cache = new CheckCache({ ttlMs: 60_000 });
174
+ const fn = jest.fn(async () => 'v');
175
+ const sampling = { sampleRate: 2, defaultValueOnMiss: 'd' };
176
+
177
+ await cache.get('ns', 'k', fn, sampling); // count=1 采样
178
+ cache.invalidate();
179
+ await cache.get('ns', 'k', fn, sampling); // 重置后 count=1 采样
180
+ expect(fn).toHaveBeenCalledTimes(2);
181
+ });
182
+
183
+ test('sampleRate <= 1 视为不采样:回退为普通 TTL 缓存语义', async () => {
184
+ const cache = new CheckCache({ ttlMs: 60_000 });
185
+ const fn = jest.fn(async () => 'v');
186
+
187
+ await cache.get('ns', 'k', fn, { sampleRate: 1, defaultValueOnMiss: 'd' });
188
+ await cache.get('ns', 'k', fn, { sampleRate: 0, defaultValueOnMiss: 'd' });
189
+ expect(fn).toHaveBeenCalledTimes(1); // TTL 命中,无采样短路
190
+ });
191
+
192
+ test('getSync 同样支持计数采样', () => {
193
+ const cache = new CheckCache({ ttlMs: 60_000 });
194
+ const fn = jest.fn(() => 'sync');
195
+ const sampling = { sampleRate: 3, defaultValueOnMiss: 'skip' };
196
+
197
+ expect(cache.getSync('ns', 'k', fn, sampling)).toBe('sync'); // 采样轮
198
+ expect(cache.getSync('ns', 'k', fn, sampling)).toBe('sync'); // 复用缓存
199
+ expect(cache.getSync('ns', 'k', fn, sampling)).toBe('sync'); // 复用缓存
200
+ expect(fn).toHaveBeenCalledTimes(1);
201
+ });
202
+
203
+ test('getSync 非采样轮缓存过期返回默认值', () => {
204
+ const cache = new CheckCache({ ttlMs: 1000 });
205
+ const fn = jest.fn(() => 'sync');
206
+ const sampling = { sampleRate: 3, defaultValueOnMiss: 'skip' };
207
+
208
+ expect(cache.getSync('ns', 'k', fn, sampling)).toBe('sync'); // 采样轮 @t0
209
+ jest.advanceTimersByTime(1001);
210
+ expect(cache.getSync('ns', 'k', fn, sampling)).toBe('skip'); // 过期 + 非采样轮
211
+ expect(fn).toHaveBeenCalledTimes(1);
212
+ });
213
+
214
+ test('采样轮执行结果以正常 TTL 写入缓存,非采样轮不刷新 TTL', async () => {
215
+ const cache = new CheckCache({ ttlMs: 1000 });
216
+ const fn = jest.fn(async () => 'v');
217
+ const sampling = { sampleRate: 3, defaultValueOnMiss: 'd' };
218
+
219
+ await cache.get('ns', 'k', fn, sampling); // 采样轮 @t0
220
+ jest.advanceTimersByTime(500);
221
+ await cache.get('ns', 'k', fn, sampling); // 非采样轮 @t0+500(不应刷新过期时间)
222
+ jest.advanceTimersByTime(600); // t0+1100:若 TTL 被刷新则仍命中
223
+ expect(await cache.get('ns', 'k', fn, sampling)).toBe('d'); // 已过期 → 默认值
224
+ expect(fn).toHaveBeenCalledTimes(1);
225
+ });
226
+ });
227
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Agent prompt 约束段渲染(H6/G6:trigger 参数化分组渲染 API)
3
+ *
4
+ * 收编 studio prompt-injection.ts 的渲染层:role→trigger 路由留在 studio,
5
+ * 本模块只做 trigger 参数化 + 生效集过滤 + 层级分组渲染。
6
+ *
7
+ * 数据源 = getEffectiveConstraints(内置 → preset → config.yml 禁用 →
8
+ * custom 追加 → scenes 过滤),与 init 注入、harness check 同一来源。
9
+ *
10
+ * 输出按层级分组:铁律 → 指导原则 → 行为提示;无 promptInjection 的约束
11
+ * (如 docs_freshness)不渲染。tip 层级已随 TIPS 退役,不参与分组。
12
+ */
13
+
14
+ import type { Constraint, ConstraintLevel, ConstraintTrigger } from '../../types/constraint';
15
+ import { normalizeTriggers } from '../../utils/exec';
16
+ import { getEffectiveConstraints } from '../effective-constraints';
17
+
18
+ /**
19
+ * renderConstraintsByTrigger 选项
20
+ */
21
+ export interface RenderConstraintsByTriggerOptions {
22
+ /** 项目根路径(决定 config.yml 生效集),缺省 process.cwd() */
23
+ projectRoot?: string;
24
+ }
25
+
26
+ /** 层级分组定义(渲染顺序即声明顺序) */
27
+ const LEVEL_GROUPS: ReadonlyArray<{ level: ConstraintLevel; heading: string }> = [
28
+ { level: 'iron_law', heading: '### 铁律(绝对禁止,无例外)' },
29
+ { level: 'guideline', heading: '### 指导原则(优先建议)' },
30
+ { level: 'prompt', heading: '### 行为提示' },
31
+ ];
32
+
33
+ /**
34
+ * 按触发条件渲染约束分组文本(注入 Agent system prompt 用)
35
+ *
36
+ * 过滤:生效集内 trigger 与入参存在交集的约束;无 promptInjection 的不渲染。
37
+ * 分组:按层级 iron_law → guideline → prompt 顺序渲染为标题 + 条目列表;
38
+ * 无适用约束时返回空字符串。
39
+ *
40
+ * @param triggers 触发条件(单个或多个;空数组返回空字符串)
41
+ * @param options.projectRoot 项目根路径(决定 config.yml 生效集)
42
+ */
43
+ export function renderConstraintsByTrigger(
44
+ triggers: ConstraintTrigger | ConstraintTrigger[],
45
+ options?: RenderConstraintsByTriggerOptions,
46
+ ): string {
47
+ const requested = Array.isArray(triggers) ? triggers : [triggers];
48
+ if (requested.length === 0) return '';
49
+
50
+ const constraints = getEffectiveConstraints(options?.projectRoot);
51
+ const applicable = constraints.filter(
52
+ c => c.promptInjection && matchesTrigger(c, requested)
53
+ );
54
+ if (applicable.length === 0) return '';
55
+
56
+ const lines: string[] = ['\n## 行为约束(前置声明)\n'];
57
+ for (const group of LEVEL_GROUPS) {
58
+ const items = applicable.filter(c => c.level === group.level);
59
+ if (items.length === 0) continue;
60
+ lines.push(`${group.heading}\n`);
61
+ for (const c of items) {
62
+ lines.push(`- **${c.id}**: ${c.promptInjection}`);
63
+ }
64
+ lines.push('');
65
+ }
66
+ return lines.join('\n');
67
+ }
68
+
69
+ function matchesTrigger(constraint: Constraint, requested: ConstraintTrigger[]): boolean {
70
+ return normalizeTriggers<ConstraintTrigger>(constraint.trigger).some(t => requested.includes(t));
71
+ }
@@ -4,10 +4,19 @@
4
4
  * 缓存 git diff 和 src/ 递归扫描结果,减少重复 I/O。
5
5
  * 同一请求内多次 checkConstraints 调用共享缓存。
6
6
  *
7
+ * 计数采样(H6/G5,收编 studio runtime/cache.ts):
8
+ * get/getSync 可传声明式采样配置,对每个 (namespace, key) 独立计数,
9
+ * 第 1 次调用必执行,此后每 N 次执行 1 次完整检查,其余 N-1 次复用缓存;
10
+ * 非采样轮缓存未命中(无缓存或已过期)时返回 defaultValueOnMiss,不执行 fn。
11
+ * invalidate 同时重置对应计数,保证失效后下一次调用恢复为完整检查。
12
+ *
7
13
  * 用法:
8
14
  * ```typescript
9
15
  * const cache = new CheckCache({ ttlMs: 5000 });
16
+ * // 普通 TTL 缓存
10
17
  * const diff = await cache.get('git_diff', projectPath, () => runCommand('git diff --cached', projectPath));
18
+ * // 计数采样:每 3 次调用执行 1 次完整检查
19
+ * const ok = await cache.get('goal_check', key, check, { sampleRate: 3, defaultValueOnMiss: true });
11
20
  * ```
12
21
  */
13
22
 
@@ -16,13 +25,41 @@ interface CacheEntry {
16
25
  expiresAt: number;
17
26
  }
18
27
 
28
+ /**
29
+ * CheckCache 构造配置
30
+ */
19
31
  export interface CheckCacheConfig {
20
32
  /** 缓存 TTL(毫秒),默认 5000 */
21
33
  ttlMs: number;
22
34
  }
23
35
 
36
+ /**
37
+ * 计数采样配置(声明式)
38
+ *
39
+ * 传此配置后,对应 (namespace, key) 启用计数采样:
40
+ * - 第 1 次调用必执行 fn(采样轮),结果以正常 TTL 写入缓存;
41
+ * - 其后每 N 次调用执行 1 次(计数独立于普通缓存读写);
42
+ * - 非采样轮:缓存命中(未过期)返回缓存值;未命中或已过期
43
+ * 返回 {@link defaultValueOnMiss},不执行 fn、不刷新 TTL。
44
+ */
45
+ export interface CheckSamplingConfig<T> {
46
+ /** 采样率:每 N 次调用执行 1 次完整检查;N <= 1 视为不采样(回退为普通 TTL 缓存语义) */
47
+ sampleRate: number;
48
+ /** 非采样轮且缓存未命中(无缓存或已过期)时的默认返回值 */
49
+ defaultValueOnMiss: T;
50
+ }
51
+
52
+ /**
53
+ * 约束检查缓存:TTL 缓存 + 可选计数采样
54
+ *
55
+ * 普通 get/getSync 为 TTL 缓存;传入 sampling 配置后对应 (namespace, key)
56
+ * 启用计数采样(每 N 次执行 1 次完整检查,其余轮次复用缓存或返回
57
+ * defaultValueOnMiss)。语义详见 {@link CheckSamplingConfig}。
58
+ */
24
59
  export class CheckCache {
25
60
  private store = new Map<string, CacheEntry>();
61
+ /** 采样计数器(仅启用采样的 key 有计数) */
62
+ private sampleCounters = new Map<string, number>();
26
63
  private ttlMs: number;
27
64
 
28
65
  constructor(config?: CheckCacheConfig) {
@@ -35,19 +72,28 @@ export class CheckCache {
35
72
  * @param namespace 命名空间(如 'git_diff', 'src_scan')
36
73
  * @param key 缓存键(如 projectPath)
37
74
  * @param fn miss 时的计算函数
75
+ * @param sampling 计数采样配置(可选;不传时每次 miss 都执行 fn)
38
76
  */
39
77
  async get<T>(
40
78
  namespace: string,
41
79
  key: string,
42
80
  fn: () => Promise<T>,
81
+ sampling?: CheckSamplingConfig<T>,
43
82
  ): Promise<T> {
44
83
  const cacheKey = `${namespace}:${key}`;
45
84
  const entry = this.store.get(cacheKey);
85
+ const rate = sampling ? Math.max(1, Math.floor(sampling.sampleRate)) : 1;
86
+ // 采样轮优先于缓存命中:每 N 次调用必执行一次完整检查(第 1 次必执行)
87
+ const sampleTurn = rate > 1 && this.isSampleTurn(cacheKey, rate);
46
88
 
47
- if (entry && Date.now() < entry.expiresAt) {
89
+ if (!sampleTurn && entry && Date.now() < entry.expiresAt) {
48
90
  return entry.value as T;
49
91
  }
50
92
 
93
+ if (sampling && rate > 1 && !sampleTurn) {
94
+ return sampling.defaultValueOnMiss;
95
+ }
96
+
51
97
  const value = await fn();
52
98
  this.store.set(cacheKey, {
53
99
  value,
@@ -58,19 +104,28 @@ export class CheckCache {
58
104
 
59
105
  /**
60
106
  * 同步版本(用于 readdirSync 等同步操作)
107
+ *
108
+ * @param sampling 计数采样配置(语义同 {@link get})
61
109
  */
62
110
  getSync<T>(
63
111
  namespace: string,
64
112
  key: string,
65
113
  fn: () => T,
114
+ sampling?: CheckSamplingConfig<T>,
66
115
  ): T {
67
116
  const cacheKey = `${namespace}:${key}`;
68
117
  const entry = this.store.get(cacheKey);
118
+ const rate = sampling ? Math.max(1, Math.floor(sampling.sampleRate)) : 1;
119
+ const sampleTurn = rate > 1 && this.isSampleTurn(cacheKey, rate);
69
120
 
70
- if (entry && Date.now() < entry.expiresAt) {
121
+ if (!sampleTurn && entry && Date.now() < entry.expiresAt) {
71
122
  return entry.value as T;
72
123
  }
73
124
 
125
+ if (sampling && rate > 1 && !sampleTurn) {
126
+ return sampling.defaultValueOnMiss;
127
+ }
128
+
74
129
  const value = fn();
75
130
  this.store.set(cacheKey, {
76
131
  value,
@@ -80,7 +135,8 @@ export class CheckCache {
80
135
  }
81
136
 
82
137
  /**
83
- * 使指定命名空间缓存失效
138
+ * 使指定命名空间缓存失效(同时重置该命名空间的采样计数器,
139
+ * 保证失效后的下一次调用恢复为采样轮/完整检查)
84
140
  */
85
141
  invalidate(namespace?: string): void {
86
142
  if (namespace) {
@@ -89,8 +145,24 @@ export class CheckCache {
89
145
  this.store.delete(key);
90
146
  }
91
147
  }
148
+ for (const key of this.sampleCounters.keys()) {
149
+ if (key.startsWith(`${namespace}:`)) {
150
+ this.sampleCounters.delete(key);
151
+ }
152
+ }
92
153
  } else {
93
154
  this.store.clear();
155
+ this.sampleCounters.clear();
94
156
  }
95
157
  }
158
+
159
+ /**
160
+ * 计数采样判定:计数 +1 后返回是否采样轮(第 1 次必采样,每 N 次 1 次)。
161
+ * 仅在采样启用(rate > 1)时调用。
162
+ */
163
+ private isSampleTurn(cacheKey: string, rate: number): boolean {
164
+ const count = (this.sampleCounters.get(cacheKey) ?? 0) + 1;
165
+ this.sampleCounters.set(cacheKey, count);
166
+ return count % rate === 1;
167
+ }
96
168
  }
@@ -29,6 +29,16 @@ export {
29
29
  renderConstraintsSection,
30
30
  } from './injection-renderer';
31
31
 
32
+ // 约束检查缓存(H6/G5:TTL 缓存 + 计数采样,公开导出)
33
+ export { CheckCache } from './check-cache';
34
+ export type { CheckCacheConfig, CheckSamplingConfig } from './check-cache';
35
+
36
+ // Agent prompt 约束段渲染(H6/G6:trigger 参数化分组渲染,role 路由留 studio)
37
+ export {
38
+ renderConstraintsByTrigger,
39
+ } from './agent-prompt-renderer';
40
+ export type { RenderConstraintsByTriggerOptions } from './agent-prompt-renderer';
41
+
32
42
  // 类型导出
33
43
  export type {
34
44
  ConstraintId,
package/src/index.ts CHANGED
@@ -107,6 +107,14 @@ export * from './agents';
107
107
  // ========================================
108
108
  export * from './presets';
109
109
 
110
+ // ========================================
111
+ // 约束缓存与渲染导出(H6/G5-G6,#31 收编)
112
+ // ========================================
113
+ export { CheckCache } from './core/constraints/check-cache';
114
+ export type { CheckCacheConfig, CheckSamplingConfig } from './core/constraints/check-cache';
115
+ export { renderConstraintsByTrigger } from './core/constraints/agent-prompt-renderer';
116
+ export type { RenderConstraintsByTriggerOptions } from './core/constraints/agent-prompt-renderer';
117
+
110
118
  // ========================================
111
119
  // 便捷 API
112
120
  // ========================================