@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,178 @@
1
+ /**
2
+ * analyze-sessions 命令测试(O6)
3
+ *
4
+ * Seam:analyzeSessions(options) 公开入口。
5
+ * 隔离面:
6
+ * - os.homedir → 测试临时目录(memory 规则库落 TEST_HOME)
7
+ * - readTranscriptSessions → fixture 会话(extractCorrectionMatches/tokenize 等纯函数保持真实)
8
+ * - CLAUDE_TRANSCRIPTS_DIR → 测试临时目录(决定 transcripts 目录是否存在)
9
+ */
10
+
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { analyzeSessions } from '../analyze-sessions';
14
+ import { readTranscriptSessions, type MinedSession } from '../../session-mining';
15
+
16
+ const TEST_HOME = '/tmp/harness-analyze-test-home';
17
+
18
+ jest.mock('os', () => ({
19
+ ...jest.requireActual('os'),
20
+ homedir: () => '/tmp/harness-analyze-test-home',
21
+ }));
22
+
23
+ jest.mock('../../session-mining', () => ({
24
+ ...jest.requireActual('../../session-mining'),
25
+ readTranscriptSessions: jest.fn(),
26
+ }));
27
+
28
+ jest.mock('chalk', () => {
29
+ const id = (s: string) => s;
30
+ const chalkFn = Object.assign(id, {
31
+ gray: id,
32
+ blue: id,
33
+ green: id,
34
+ yellow: id,
35
+ cyan: id,
36
+ bold: id,
37
+ red: id,
38
+ });
39
+ return {
40
+ __esModule: true,
41
+ default: chalkFn,
42
+ gray: id,
43
+ blue: id,
44
+ green: id,
45
+ yellow: id,
46
+ cyan: id,
47
+ bold: id,
48
+ red: id,
49
+ };
50
+ });
51
+
52
+ const mockReadTranscriptSessions = readTranscriptSessions as jest.MockedFunction<
53
+ typeof readTranscriptSessions
54
+ >;
55
+
56
+ const TRANSCRIPTS_DIR = path.join(TEST_HOME, 'transcripts');
57
+
58
+ function mkSession(partial: Partial<MinedSession> = {}): MinedSession {
59
+ return {
60
+ id: 'session-1',
61
+ date: '2026-08-15',
62
+ mtimeMs: Date.now(),
63
+ turns: [
64
+ { role: 'user', content: '我不是说要用中文吗' },
65
+ { role: 'assistant', content: '收到,改用中文' },
66
+ ],
67
+ toolCalls: [],
68
+ ...partial,
69
+ };
70
+ }
71
+
72
+ function lastJsonOutput(consoleSpy: jest.SpyInstance): Record<string, unknown> {
73
+ const jsonLine = consoleSpy.mock.calls.map(c => c[0]).join('\n');
74
+ return JSON.parse(jsonLine);
75
+ }
76
+
77
+ describe('analyze-sessions command', () => {
78
+ let consoleSpy: jest.SpyInstance;
79
+
80
+ beforeEach(() => {
81
+ jest.clearAllMocks();
82
+ fs.rmSync(TEST_HOME, { recursive: true, force: true });
83
+ fs.mkdirSync(path.join(TEST_HOME, '.claude', 'projects', '-root-projects', 'memory'), { recursive: true });
84
+ fs.mkdirSync(TRANSCRIPTS_DIR, { recursive: true });
85
+ process.env.CLAUDE_TRANSCRIPTS_DIR = TRANSCRIPTS_DIR;
86
+ consoleSpy = jest.spyOn(console, 'log').mockImplementation();
87
+ });
88
+
89
+ afterEach(() => {
90
+ consoleSpy.mockRestore();
91
+ delete process.env.CLAUDE_TRANSCRIPTS_DIR;
92
+ });
93
+
94
+ test('transcripts 目录不存在:提示 No transcripts directory found', async () => {
95
+ process.env.CLAUDE_TRANSCRIPTS_DIR = path.join(TEST_HOME, 'missing-dir');
96
+
97
+ await analyzeSessions({});
98
+
99
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No transcripts directory found'));
100
+ expect(mockReadTranscriptSessions).not.toHaveBeenCalled();
101
+ });
102
+
103
+ test('窗口内无会话:提示 No sessions found(默认最近 7 天)', async () => {
104
+ mockReadTranscriptSessions.mockReturnValue([
105
+ mkSession({ id: 'old', mtimeMs: Date.now() - 30 * 86_400_000 }),
106
+ ]);
107
+
108
+ await analyzeSessions({});
109
+
110
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No sessions found in the last 7 days'));
111
+ });
112
+
113
+ test('--days 1:只统计最近 1 天(mtimeMs 窗口过滤)', async () => {
114
+ mockReadTranscriptSessions.mockReturnValue([
115
+ mkSession({ id: 'today' }),
116
+ mkSession({ id: 'two-days-ago', mtimeMs: Date.now() - 2 * 86_400_000 }),
117
+ ]);
118
+
119
+ await analyzeSessions({ days: 1 });
120
+
121
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Analyzing 1 sessions (last 1 days)'));
122
+ });
123
+
124
+ test('缺省 --days:窗口为 7 天,两天前会话仍计入', async () => {
125
+ mockReadTranscriptSessions.mockReturnValue([
126
+ mkSession({ id: 'today' }),
127
+ mkSession({ id: 'two-days-ago', mtimeMs: Date.now() - 2 * 86_400_000 }),
128
+ ]);
129
+
130
+ await analyzeSessions({});
131
+
132
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Analyzing 2 sessions (last 7 days)'));
133
+ });
134
+
135
+ test('--json:纠正句跨 3 会话聚合为 correction 候选', async () => {
136
+ mockReadTranscriptSessions.mockReturnValue([
137
+ mkSession({ id: 's1' }),
138
+ mkSession({ id: 's2' }),
139
+ mkSession({ id: 's3' }),
140
+ ]);
141
+
142
+ await analyzeSessions({ json: true });
143
+
144
+ const output = lastJsonOutput(consoleSpy);
145
+ expect(output.sessions).toBe(3);
146
+ expect(output.corrections).toBe(3);
147
+ expect(output.candidates).toEqual(
148
+ expect.arrayContaining([
149
+ expect.objectContaining({
150
+ source: 'correction',
151
+ frequency: 3,
152
+ pattern: '我不是说',
153
+ }),
154
+ ]),
155
+ );
156
+ });
157
+
158
+ test('--json:跨会话重复概念产出 ngram 候选', async () => {
159
+ const ngramText = { role: 'user', content: '数据库迁移方案的详细讨论' };
160
+ mockReadTranscriptSessions.mockReturnValue([
161
+ mkSession({
162
+ id: 's1',
163
+ turns: [ngramText, { role: 'assistant', content: '好的' }],
164
+ }),
165
+ mkSession({
166
+ id: 's2',
167
+ turns: [ngramText, { role: 'assistant', content: '明白' }],
168
+ }),
169
+ ]);
170
+
171
+ await analyzeSessions({ json: true });
172
+
173
+ const output = lastJsonOutput(consoleSpy);
174
+ const ngramCandidates = (output.candidates as Array<{ source: string }>)
175
+ .filter(c => c.source === 'ngram');
176
+ expect(ngramCandidates.length).toBeGreaterThan(0);
177
+ });
178
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * release 命令测试(O6)
3
+ *
4
+ * Seam:release(options) 公开入口。
5
+ * 隔离面:
6
+ * - child_process.execSync → 按命令分发的 fixture(git/npm/tsc 全部 mock,绝不真执行)
7
+ * - fs.existsSync / readFileSync → fixture 包(package.json + dist 关键文件)
8
+ * - process.exit → 抛异常断言化(exit code 可断言,且阻断后续真实副作用)
9
+ *
10
+ * 测试止于 dry-run 与「发布前闸门」(分支/同步/干净树/tag 冲突),
11
+ * 不覆盖真实 npm version/publish 路径——禁止真发版。
12
+ */
13
+
14
+ import * as fs from 'fs';
15
+ import { execSync } from 'child_process';
16
+ import { release } from '../release';
17
+
18
+ jest.mock('child_process', () => ({
19
+ ...jest.requireActual('child_process'),
20
+ execSync: jest.fn(),
21
+ }));
22
+
23
+ jest.mock('fs', () => ({
24
+ ...jest.requireActual('fs'),
25
+ existsSync: jest.fn(),
26
+ readFileSync: jest.fn(),
27
+ }));
28
+
29
+ jest.mock('chalk', () => {
30
+ const id = (s: string) => s;
31
+ const chalkFn = Object.assign(id, {
32
+ gray: id,
33
+ blue: id,
34
+ green: id,
35
+ yellow: id,
36
+ cyan: id,
37
+ bold: id,
38
+ red: id,
39
+ });
40
+ return {
41
+ __esModule: true,
42
+ default: chalkFn,
43
+ gray: id,
44
+ blue: id,
45
+ green: id,
46
+ yellow: id,
47
+ cyan: id,
48
+ bold: id,
49
+ red: id,
50
+ };
51
+ });
52
+
53
+ const mockExecSync = execSync as jest.MockedFunction<typeof execSync>;
54
+ const mockFs = fs as jest.Mocked<typeof fs>;
55
+
56
+ const PKG_JSON = JSON.stringify({ name: '@dommaker/harness', version: '0.18.0' });
57
+
58
+ describe('release command', () => {
59
+ let consoleSpy: jest.SpyInstance;
60
+ let errorSpy: jest.SpyInstance;
61
+ let exitSpy: jest.SpyInstance;
62
+ let pkgExists: boolean;
63
+ let tagListResult: string;
64
+
65
+ beforeEach(() => {
66
+ jest.clearAllMocks();
67
+ pkgExists = true;
68
+ tagListResult = '';
69
+ consoleSpy = jest.spyOn(console, 'log').mockImplementation();
70
+ errorSpy = jest.spyOn(console, 'error').mockImplementation();
71
+ exitSpy = jest.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
72
+ throw new Error(`__exit_${code}__`);
73
+ });
74
+
75
+ mockFs.existsSync.mockImplementation((p) => {
76
+ if (String(p).endsWith('package.json')) return pkgExists;
77
+ return true; // dist 关键文件视为存在
78
+ });
79
+ mockFs.readFileSync.mockReturnValue(PKG_JSON);
80
+
81
+ mockExecSync.mockImplementation((cmd: string) => {
82
+ const c = String(cmd);
83
+ if (c.startsWith('git rev-parse --abbrev-ref')) return 'master';
84
+ if (c.startsWith('git rev-list --count')) return '0';
85
+ if (c.startsWith('git status --porcelain')) return '';
86
+ if (c.startsWith('git tag -l')) return tagListResult;
87
+ if (c.startsWith('npx tsc')) return 'build ok';
88
+ return '';
89
+ });
90
+ });
91
+
92
+ afterEach(() => {
93
+ consoleSpy.mockRestore();
94
+ errorSpy.mockRestore();
95
+ exitSpy.mockRestore();
96
+ });
97
+
98
+ test('非包目录:exit 1 并提示 Not a package', async () => {
99
+ pkgExists = false;
100
+
101
+ await expect(release({})).rejects.toThrow('__exit_1__');
102
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Not a package'));
103
+ });
104
+
105
+ test('非 master/main 分支:exit 1 并提示', async () => {
106
+ mockExecSync.mockImplementation((cmd: string) =>
107
+ String(cmd).startsWith('git rev-parse --abbrev-ref') ? 'feat/x' : '');
108
+
109
+ await expect(release({})).rejects.toThrow('__exit_1__');
110
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Must be on master or main branch'));
111
+ });
112
+
113
+ test('落后于远程:exit 1 并提示 behind', async () => {
114
+ mockExecSync.mockImplementation((cmd: string) => {
115
+ const c = String(cmd);
116
+ if (c.startsWith('git rev-parse --abbrev-ref')) return 'master';
117
+ if (c.startsWith('git rev-list --count')) return '3';
118
+ return '';
119
+ });
120
+
121
+ await expect(release({})).rejects.toThrow('__exit_1__');
122
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('behind origin'));
123
+ });
124
+
125
+ test('工作树不干净:exit 1 并提示 Uncommitted changes', async () => {
126
+ mockExecSync.mockImplementation((cmd: string) => {
127
+ const c = String(cmd);
128
+ if (c.startsWith('git rev-parse --abbrev-ref')) return 'master';
129
+ if (c.startsWith('git rev-list --count')) return '0';
130
+ if (c.startsWith('git status --porcelain')) return ' M package.json';
131
+ return '';
132
+ });
133
+
134
+ await expect(release({})).rejects.toThrow('__exit_1__');
135
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Uncommitted changes'));
136
+ });
137
+
138
+ test('dry-run patch:计算目标版本 0.18.1,exit 0', async () => {
139
+ await expect(release({ bumpType: 'patch', dryRun: 'true' })).rejects.toThrow('__exit_0__');
140
+ expect(consoleSpy).toHaveBeenCalledWith(
141
+ expect.stringContaining('Dry-run complete. Would publish: @dommaker/harness@0.18.1'),
142
+ );
143
+ });
144
+
145
+ test('dry-run minor:计算目标版本 0.19.0', async () => {
146
+ await expect(release({ bumpType: 'minor', dryRun: 'true' })).rejects.toThrow('__exit_0__');
147
+ expect(consoleSpy).toHaveBeenCalledWith(
148
+ expect.stringContaining('Dry-run complete. Would publish: @dommaker/harness@0.19.0'),
149
+ );
150
+ });
151
+
152
+ test('目标 tag 已存在:发布前闸门 exit 1(不触 npm version)', async () => {
153
+ tagListResult = 'v0.18.1';
154
+
155
+ await expect(release({ bumpType: 'patch' })).rejects.toThrow('__exit_1__');
156
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('already exists'));
157
+ // 闸门阻断:npm version / npm publish 从未执行
158
+ const commands = mockExecSync.mock.calls.map(c => String(c[0]));
159
+ expect(commands.some(c => c.startsWith('npm version'))).toBe(false);
160
+ expect(commands.some(c => c.startsWith('npm publish'))).toBe(false);
161
+ });
162
+ });
@@ -0,0 +1,205 @@
1
+ /**
2
+ * update-user-model 命令测试(O1:--days flag + --json/--dry-run 兼容)
3
+ *
4
+ * Seam:updateUserModel(options) 公开入口。
5
+ * 隔离面:
6
+ * - os.homedir → 测试临时目录(state/profile 落 TEST_HOME,不触碰真实 ~/.claude)
7
+ * - readTranscriptSessions → fixture 会话(extractCorrectionMatches 等纯函数保持真实)
8
+ */
9
+
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import { updateUserModel } from '../update-user-model';
13
+ import { readTranscriptSessions, type MinedSession } from '../../session-mining';
14
+
15
+ const TEST_HOME = '/tmp/harness-uum-test-home';
16
+
17
+ jest.mock('os', () => ({
18
+ ...jest.requireActual('os'),
19
+ homedir: () => '/tmp/harness-uum-test-home',
20
+ }));
21
+
22
+ jest.mock('../../session-mining', () => ({
23
+ ...jest.requireActual('../../session-mining'),
24
+ readTranscriptSessions: jest.fn(),
25
+ }));
26
+
27
+ jest.mock('chalk', () => {
28
+ const id = (s: string) => s;
29
+ const chalkFn = Object.assign(id, {
30
+ gray: id,
31
+ blue: id,
32
+ green: id,
33
+ yellow: id,
34
+ cyan: id,
35
+ bold: id,
36
+ red: id,
37
+ });
38
+ return {
39
+ __esModule: true,
40
+ default: chalkFn,
41
+ gray: id,
42
+ blue: id,
43
+ green: id,
44
+ yellow: id,
45
+ cyan: id,
46
+ bold: id,
47
+ red: id,
48
+ };
49
+ });
50
+
51
+ const mockReadTranscriptSessions = readTranscriptSessions as jest.MockedFunction<
52
+ typeof readTranscriptSessions
53
+ >;
54
+
55
+ const STATE_FILE = path.join(TEST_HOME, '.claude', 'user-model-state.json');
56
+
57
+ function todayStr(offsetDays = 0): string {
58
+ return new Date(Date.now() - offsetDays * 86_400_000).toISOString().slice(0, 10);
59
+ }
60
+
61
+ function mkSession(partial: Partial<MinedSession> = {}): MinedSession {
62
+ return {
63
+ id: 'session-1',
64
+ date: todayStr(),
65
+ mtimeMs: Date.now(),
66
+ turns: [
67
+ { role: 'user', content: '数据库迁移方案需要执行' },
68
+ { role: 'assistant', content: '好的' },
69
+ ],
70
+ toolCalls: ['Read'],
71
+ ...partial,
72
+ };
73
+ }
74
+
75
+ function lastJsonOutput(consoleSpy: jest.SpyInstance): Record<string, unknown> {
76
+ const jsonLine = consoleSpy.mock.calls.map(c => c[0]).join('\n');
77
+ return JSON.parse(jsonLine);
78
+ }
79
+
80
+ describe('update-user-model command', () => {
81
+ let consoleSpy: jest.SpyInstance;
82
+
83
+ beforeEach(() => {
84
+ jest.clearAllMocks();
85
+ fs.rmSync(TEST_HOME, { recursive: true, force: true });
86
+ fs.mkdirSync(path.join(TEST_HOME, '.claude', 'projects', '-root-projects', 'memory'), { recursive: true });
87
+ process.env.CLAUDE_TRANSCRIPTS_DIR = path.join(TEST_HOME, 'transcripts');
88
+ consoleSpy = jest.spyOn(console, 'log').mockImplementation();
89
+ });
90
+
91
+ afterEach(() => {
92
+ consoleSpy.mockRestore();
93
+ delete process.env.CLAUDE_TRANSCRIPTS_DIR;
94
+ });
95
+
96
+ test('无新会话:提示 No new sessions to process', async () => {
97
+ mockReadTranscriptSessions.mockReturnValue([]);
98
+
99
+ await updateUserModel({});
100
+
101
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No new sessions to process'));
102
+ expect(fs.existsSync(STATE_FILE)).toBe(false);
103
+ });
104
+
105
+ test('--json:输出 newSessions 与 new_pattern 变化', async () => {
106
+ mockReadTranscriptSessions.mockReturnValue([
107
+ mkSession({ id: 'session-a' }),
108
+ mkSession({ id: 'session-b' }),
109
+ ]);
110
+
111
+ await updateUserModel({ json: true, dryRun: true });
112
+
113
+ const output = lastJsonOutput(consoleSpy);
114
+ expect(output.newSessions).toBe(2);
115
+ expect(output.changes).toEqual(
116
+ expect.arrayContaining([
117
+ expect.objectContaining({
118
+ type: 'new_pattern',
119
+ key: '数据库迁移方案需要执行',
120
+ }),
121
+ ]),
122
+ );
123
+ });
124
+
125
+ test('--dry-run:只展示变化,不写 state 文件', async () => {
126
+ mockReadTranscriptSessions.mockReturnValue([mkSession({ id: 'session-a' })]);
127
+
128
+ await updateUserModel({ dryRun: true });
129
+
130
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Processed 1 new sessions'));
131
+ expect(fs.existsSync(STATE_FILE)).toBe(false);
132
+ });
133
+
134
+ test('默认(非 dry-run):落盘 state 并记录已处理会话', async () => {
135
+ mockReadTranscriptSessions.mockReturnValue([mkSession({ id: 'session-a' })]);
136
+
137
+ await updateUserModel({});
138
+
139
+ expect(fs.existsSync(STATE_FILE)).toBe(true);
140
+ const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
141
+ expect(state.sessionsProcessed).toContain('session-a');
142
+ });
143
+
144
+ test('--days 1:只处理最近 1 天的会话', async () => {
145
+ mockReadTranscriptSessions.mockReturnValue([
146
+ mkSession({ id: 'today' }),
147
+ mkSession({
148
+ id: 'three-days-ago',
149
+ date: todayStr(3),
150
+ turns: [
151
+ { role: 'user', content: '旧会话概念内容测试' },
152
+ { role: 'assistant', content: '' },
153
+ ],
154
+ }),
155
+ ]);
156
+
157
+ await updateUserModel({ json: true, dryRun: true, days: 1 });
158
+
159
+ const output = lastJsonOutput(consoleSpy);
160
+ expect(output.newSessions).toBe(1);
161
+ });
162
+
163
+ test('缺省 --days:处理全部未处理会话(向后兼容)', async () => {
164
+ mockReadTranscriptSessions.mockReturnValue([
165
+ mkSession({ id: 'today' }),
166
+ mkSession({
167
+ id: 'three-days-ago',
168
+ date: todayStr(3),
169
+ turns: [
170
+ { role: 'user', content: '旧会话概念内容测试' },
171
+ { role: 'assistant', content: '' },
172
+ ],
173
+ }),
174
+ ]);
175
+
176
+ await updateUserModel({ json: true, dryRun: true });
177
+
178
+ const output = lastJsonOutput(consoleSpy);
179
+ expect(output.newSessions).toBe(2);
180
+ });
181
+
182
+ test('sessionsProcessed 去重:已处理会话不重复计入(与 --days 正交)', async () => {
183
+ fs.writeFileSync(
184
+ STATE_FILE,
185
+ JSON.stringify({
186
+ lastUpdated: '',
187
+ sessionsProcessed: ['session-a'],
188
+ patterns: {},
189
+ lensWeights: {},
190
+ principleWeights: {},
191
+ evolutionLog: [],
192
+ }),
193
+ 'utf-8',
194
+ );
195
+ mockReadTranscriptSessions.mockReturnValue([
196
+ mkSession({ id: 'session-a' }),
197
+ mkSession({ id: 'session-b' }),
198
+ ]);
199
+
200
+ await updateUserModel({ json: true, dryRun: true, days: 7 });
201
+
202
+ const output = lastJsonOutput(consoleSpy);
203
+ expect(output.newSessions).toBe(1);
204
+ });
205
+ });
@@ -28,6 +28,7 @@ import {
28
28
  } from '../session-mining';
29
29
 
30
30
  export interface UpdateUserModelOptions {
31
+ days?: number; // 只处理最近 N 天(自然日,含今天)的会话;缺省不过滤(向后兼容)
31
32
  json?: boolean;
32
33
  dryRun?: boolean; // don't update state, just show what would change
33
34
  }
@@ -58,7 +59,7 @@ export async function updateUserModel(options: UpdateUserModelOptions): Promise<
58
59
  const state = loadState();
59
60
 
60
61
  // 2. Scan new data
61
- const newSessions = findNewSessions(transcriptDir, state.sessionsProcessed);
62
+ const newSessions = findNewSessions(transcriptDir, state.sessionsProcessed, options.days);
62
63
  if (newSessions.length === 0) {
63
64
  console.log(chalk.gray('No new sessions to process'));
64
65
  return;
@@ -137,10 +138,19 @@ interface SimpleSession {
137
138
  toolCalls: string[]; // tool names used
138
139
  }
139
140
 
140
- function findNewSessions(dir: string, processed: string[]): SimpleSession[] {
141
- const sessions = readTranscriptSessions(dir)
142
- .filter(s => !processed.includes(s.id))
143
- .sort((a, b) => a.date.localeCompare(b.date));
141
+ function findNewSessions(dir: string, processed: string[], days?: number): SimpleSession[] {
142
+ let sessions = readTranscriptSessions(dir)
143
+ .filter(s => !processed.includes(s.id));
144
+
145
+ // 「最近 N 天」:自然日窗口(含今天)。days<=0 视为不过滤,与缺省一致。
146
+ if (days !== undefined && days > 0) {
147
+ const cutoff = new Date(Date.now() - (days - 1) * 86_400_000)
148
+ .toISOString()
149
+ .slice(0, 10);
150
+ sessions = sessions.filter(s => s.date >= cutoff);
151
+ }
152
+
153
+ sessions.sort((a, b) => a.date.localeCompare(b.date));
144
154
 
145
155
  return sessions.map(s => ({
146
156
  id: s.id,
@@ -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;TIPS 已退役为空表) + 检查引擎(ConstraintChecker) + 拦截器(ConstraintInterceptor) + 缓存(CheckCache) + 注入渲染(injection-renderer)/漂移校验(injection-drift)/使用统计(usage-report)
7
+ - `constraints/` — 约束定义(IRON_LAWS/GUIDELINES/PROMPTS;TIPS 已退役为空表) + 检查引擎(ConstraintChecker) + 拦截器(ConstraintInterceptor) + 缓存(CheckCache:TTL 缓存 + 计数采样,H6/G5 起公开导出) + 注入渲染(injection-renderer) + Agent prompt 渲染(agent-prompt-renderer:trigger 参数化分组渲染,role 路由留 studio,H6/G6)/漂移校验(injection-drift)/使用统计(usage-report)
8
8
  - `effective-constraints.ts` — `getEffectiveConstraints(projectRoot)`:全仓唯一生效集来源(内置 → preset → config.yml 禁用(内置与 custom 同效)→ custom 追加(禁用/已退役的不追加)→ scenes 过滤);`lintEffectiveConfig` 配置诊断
9
9
  - `validators/` — checkpoints、passes-gate、CSO 验证器
10
10
  - `session/` — 会话管理