@dommaker/harness 1.4.0 → 1.5.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 (71) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cli/commands/check.d.ts.map +1 -1
  3. package/dist/cli/commands/check.js +28 -0
  4. package/dist/cli/commands/check.js.map +1 -1
  5. package/dist/cli/commands/definitions.d.ts.map +1 -1
  6. package/dist/cli/commands/definitions.js +0 -7
  7. package/dist/cli/commands/definitions.js.map +1 -1
  8. package/dist/cli/commands/knowledge.d.ts +0 -19
  9. package/dist/cli/commands/knowledge.d.ts.map +1 -1
  10. package/dist/cli/commands/knowledge.js +0 -105
  11. package/dist/cli/commands/knowledge.js.map +1 -1
  12. package/dist/cli/commands/update-user-model.d.ts +7 -2
  13. package/dist/cli/commands/update-user-model.d.ts.map +1 -1
  14. package/dist/cli/commands/update-user-model.js +25 -16
  15. package/dist/cli/commands/update-user-model.js.map +1 -1
  16. package/dist/core/constraints/checker.d.ts.map +1 -1
  17. package/dist/core/constraints/checker.js +5 -3
  18. package/dist/core/constraints/checker.js.map +1 -1
  19. package/dist/core/constraints/checkers/capability-sync.d.ts +17 -6
  20. package/dist/core/constraints/checkers/capability-sync.d.ts.map +1 -1
  21. package/dist/core/constraints/checkers/capability-sync.js +48 -19
  22. package/dist/core/constraints/checkers/capability-sync.js.map +1 -1
  23. package/dist/core/constraints/checkers/docs-freshness.d.ts.map +1 -1
  24. package/dist/core/constraints/checkers/docs-freshness.js +15 -6
  25. package/dist/core/constraints/checkers/docs-freshness.js.map +1 -1
  26. package/dist/core/constraints/checkers/index.d.ts +2 -2
  27. package/dist/core/constraints/checkers/index.d.ts.map +1 -1
  28. package/dist/core/constraints/checkers/index.js +2 -1
  29. package/dist/core/constraints/checkers/index.js.map +1 -1
  30. package/dist/core/constraints/checkers/types.d.ts +41 -2
  31. package/dist/core/constraints/checkers/types.d.ts.map +1 -1
  32. package/dist/core/constraints/checkers/types.js +33 -0
  33. package/dist/core/constraints/checkers/types.js.map +1 -1
  34. package/dist/gates/checker-gate.d.ts +2 -1
  35. package/dist/gates/checker-gate.d.ts.map +1 -1
  36. package/dist/gates/checker-gate.js +10 -7
  37. package/dist/gates/checker-gate.js.map +1 -1
  38. package/dist/types/constraint.d.ts +8 -0
  39. package/dist/types/constraint.d.ts.map +1 -1
  40. package/dist/types/constraint.js +6 -1
  41. package/dist/types/constraint.js.map +1 -1
  42. package/dist/types/trace.d.ts +9 -0
  43. package/dist/types/trace.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/CONTEXT.md +1 -0
  46. package/src/__tests__/passes-gate.test.ts +11 -18
  47. package/src/cli/commands/CONTEXT.md +2 -2
  48. package/src/cli/commands/__tests__/bin-exit-mapping.test.ts +6 -0
  49. package/src/cli/commands/__tests__/check.test.ts +74 -0
  50. package/src/cli/commands/__tests__/project-path-convention.test.ts +85 -23
  51. package/src/cli/commands/__tests__/registry.test.ts +6 -0
  52. package/src/cli/commands/__tests__/update-user-model.test.ts +35 -1
  53. package/src/cli/commands/check.ts +39 -1
  54. package/src/cli/commands/definitions.ts +0 -7
  55. package/src/cli/commands/knowledge.ts +0 -119
  56. package/src/cli/commands/update-user-model.ts +31 -16
  57. package/src/core/CONTEXT.md +2 -1
  58. package/src/core/constraints/checker.ts +8 -4
  59. package/src/core/constraints/checkers/__tests__/capability-sync.test.ts +90 -31
  60. package/src/core/constraints/checkers/__tests__/check-outcome.test.ts +176 -0
  61. package/src/core/constraints/checkers/__tests__/docs-freshness.test.ts +18 -9
  62. package/src/core/constraints/checkers/__tests__/three-state-consistency.test.ts +12 -4
  63. package/src/core/constraints/checkers/capability-sync.ts +56 -18
  64. package/src/core/constraints/checkers/docs-freshness.ts +18 -5
  65. package/src/core/constraints/checkers/index.ts +9 -2
  66. package/src/core/constraints/checkers/types.ts +58 -2
  67. package/src/gates/CONTEXT.md +1 -1
  68. package/src/gates/__tests__/checker-gate.test.ts +24 -0
  69. package/src/gates/checker-gate.ts +11 -15
  70. package/src/types/constraint.ts +16 -2
  71. package/src/types/trace.ts +10 -0
@@ -21,9 +21,35 @@ import { GOVERNANCE_HEADING } from '../../core/constraints/injection-writer';
21
21
  import { getTraceCollector } from '../../monitoring/traces';
22
22
  import { countJsonlLines } from '../../utils/jsonl';
23
23
  import { DEFAULT_TRACE_FILE } from '../../types/trace';
24
- import type { ConstraintTrigger } from '../../types/constraint';
24
+ import type { ConstraintResult, ConstraintTrigger } from '../../types/constraint';
25
25
  import { log, processIO, type CommandIO, type CommandResult } from '../command-contract';
26
26
 
27
+ /** 证据行着色(与调用处所属结论块一致) */
28
+ const EVIDENCE_PAINT = {
29
+ red: chalk.red,
30
+ yellow: chalk.yellow,
31
+ gray: chalk.gray,
32
+ } as const;
33
+
34
+ /**
35
+ * 打印 checker 的判定证据行(harness#119)
36
+ *
37
+ * 文案由 checker 自行措辞(每行自描述),CLI 只负责缩进与着色——
38
+ * 不在这里解释内容,否则证据形状与措辞会两头漂移。
39
+ * 传 id 时首行挂上约束 id(提示块里没有父级结论行可依附)。
40
+ */
41
+ function logEvidence(
42
+ io: CommandIO,
43
+ result: ConstraintResult,
44
+ color: keyof typeof EVIDENCE_PAINT,
45
+ id?: string
46
+ ): void {
47
+ const paint = EVIDENCE_PAINT[color];
48
+ (result.evidence ?? []).forEach((line, index) => {
49
+ log(io, paint(index === 0 && id ? ` - ${id}: ${line}` : ` ${line}`));
50
+ });
51
+ }
52
+
27
53
  export interface CheckOptions {
28
54
  /** 预设名称 */
29
55
  preset: string;
@@ -110,6 +136,7 @@ export async function check(
110
136
  if (r.constraint) {
111
137
  log(io, chalk.red(` - ${r.constraint.id}: ${r.constraint.message}`));
112
138
  log(io, chalk.red(` ${r.constraint.rule}`));
139
+ logEvidence(io, r, 'red');
113
140
  }
114
141
  });
115
142
  log(io);
@@ -126,6 +153,7 @@ export async function check(
126
153
  result.guidelines.filter(r => !r.satisfied).forEach(r => {
127
154
  if (r.constraint) {
128
155
  log(io, chalk.yellow(` - ${r.constraint.id}: ${r.constraint.message}`));
156
+ logEvidence(io, r, 'yellow');
129
157
  }
130
158
  });
131
159
  } else if (result.guidelines.length > 0) {
@@ -134,6 +162,16 @@ export async function check(
134
162
  log(io, chalk.green(`✅ 指导原则: ${passedGuidelines}/${evaluatedGuidelines.length} 通过`));
135
163
  }
136
164
 
165
+ // 提示:通过但带证据(harness#119)——与本次变更无因果的仓库级漂移在此露出,
166
+ // 不判违规、不改 exit code,只保证「看得见且能自己修」
167
+ const hints = [...result.ironLaws, ...result.guidelines].filter(
168
+ r => !r.skipped && r.satisfied && (r.evidence?.length ?? 0) > 0
169
+ );
170
+ if (hints.length > 0) {
171
+ log(io, chalk.gray(`💡 提示: ${hints.length} 条(不判违规,供参考)`));
172
+ hints.forEach(r => logEvidence(io, r, 'gray', r.id));
173
+ }
174
+
137
175
  // Skipped:约定未采用 / 证据未接线,未评估(不计通过/失败)
138
176
  if (skippedResults.length > 0) {
139
177
  log(io, chalk.gray(`⏭️ 跳过评估: ${skippedResults.length} 条(约定未采用或证据未接线,不计通过/失败)`));
@@ -235,11 +235,6 @@ export const COMMAND_DEFINITIONS: CommandDefinition[] = [
235
235
  { flags: '--sources <sources>', description: '导入源(逗号分隔: code,git,docs)' },
236
236
  { flags: '--limit <n>', description: '结果数量限制', defaultValue: '20' },
237
237
  { flags: '--reset', description: '重置导入状态', defaultValue: false },
238
- { flags: '--scope <scope>', description: '知识范围(用于 upsert 去重)' },
239
- { flags: '--title <title>', description: '知识标题(用于 upsert)' },
240
- { flags: '--content <content>', description: '知识内容 Markdown(用于 upsert)' },
241
- { flags: '--file <path>', description: '从文件读取内容(用于 upsert)' },
242
- { flags: '--source <source>', description: '知识来源 (cli/design)', defaultValue: 'cli' },
243
238
  { flags: '--fix', description: '自动修复(用于 audit)', defaultValue: false },
244
239
  { flags: '--dry-run', description: '只输出报告不修改(用于 audit)', defaultValue: false },
245
240
  { flags: '--threshold <n>', description: '短内容阈值(字符数,用于 audit)', defaultValue: '50' },
@@ -258,8 +253,6 @@ export const COMMAND_DEFINITIONS: CommandDefinition[] = [
258
253
  decay: { impl: { module: 'knowledge', export: 'knowledgeDecay' }, aliases: ['d'] },
259
254
  stats: { impl: { module: 'knowledge', export: 'knowledgeStats' }, aliases: ['st'] },
260
255
  'sync-rag': { impl: { module: 'knowledge', export: 'knowledgeSyncRag' } },
261
- 'sync-status': { impl: { module: 'knowledge', export: 'knowledgeSyncStatus' }, aliases: ['sync'] },
262
- upsert: { impl: { module: 'knowledge', export: 'knowledgeUpsert' }, aliases: ['up'] },
263
256
  audit: { impl: { module: 'knowledge', export: 'knowledgeAudit' }, aliases: ['a'] },
264
257
  snapshot: { impl: { module: 'knowledge', export: 'knowledgeSnapshot' } },
265
258
  migrate: { impl: { module: 'knowledge', export: 'knowledgeMigrate' } },
@@ -281,76 +281,6 @@ export async function knowledgeStats(options: KnowledgeOptions, io: CommandIO =
281
281
  return { kind: 'ok' };
282
282
  }
283
283
 
284
- export interface KnowledgeUpsertOptions {
285
- scope?: string;
286
- title?: string;
287
- content?: string;
288
- file?: string;
289
- type?: string;
290
- source?: string;
291
- }
292
-
293
- /**
294
- * 设计时知识沉淀:写入 KnowledgeStore + 同步 Prisma Document(Studio UI 可见)
295
- *
296
- * 调用 Studio API POST /api/knowledge/upsert(内部端点,无 auth)
297
- */
298
- export async function knowledgeUpsert(options: KnowledgeUpsertOptions, io: CommandIO = processIO): Promise<CommandResult> {
299
- const apiPort = process.env.API_PORT || '3001';
300
- const url = `http://localhost:${apiPort}/api/knowledge/upsert`;
301
-
302
- // Read content from file if --file specified
303
- let content = options.content || '';
304
- if (options.file && !content) {
305
- try {
306
- content = fs.readFileSync(options.file, 'utf-8');
307
- } catch (e: any) {
308
- logError(io, chalk.red(`Failed to read file: ${options.file}`));
309
- logError(io, chalk.red(String(e)));
310
- return { kind: 'fail', reason: `无法读取 --file ${options.file}: ${String(e)}` };
311
- }
312
- }
313
-
314
- if (!options.scope || !options.title || !content) {
315
- logError(io, chalk.red('--scope, --title, and --content (or --file) are required'));
316
- return { kind: 'usage-error', reason: '--scope/--title/--content(--file) 缺一不可' };
317
- }
318
-
319
- try {
320
- const res = await fetch(url, {
321
- method: 'POST',
322
- headers: { 'Content-Type': 'application/json' },
323
- body: JSON.stringify({
324
- scope: options.scope,
325
- title: options.title,
326
- content,
327
- type: options.type || 'architecture',
328
- source: options.source || 'cli',
329
- }),
330
- });
331
-
332
- if (!res.ok) {
333
- const err: any = await res.json().catch(() => ({ error: res.statusText }));
334
- logError(io, chalk.red(`API error ${res.status}: ${err.error || res.statusText}`));
335
- return { kind: 'fail', reason: `upsert API error ${res.status}: ${err.error || res.statusText}` };
336
- }
337
-
338
- const result: any = await res.json();
339
- log(io, chalk.green(`✅ Knowledge upserted`));
340
- log(io, ` KnowledgeStore: ${result.knowledgeStore?.action} → ${result.knowledgeStore?.entryId}`);
341
- log(io, ` Studio UI: ${result.prismaDocument?.action} → ${result.prismaDocument?.docId || 'skipped'}`);
342
- return { kind: 'ok' };
343
- } catch (e: any) {
344
- if (e?.code === 'ECONNREFUSED') {
345
- logError(io, chalk.red(`Cannot reach Studio API at ${url}. Is the API running?`));
346
- } else {
347
- logError(io, chalk.red(`Upsert failed: ${e.message}`));
348
- }
349
- return { kind: 'fail', reason: `knowledge upsert 失败: ${e?.code === 'ECONNREFUSED' ? `Studio API 不可达 ${url}` : e.message}` };
350
- }
351
- }
352
-
353
- /**
354
284
  /**
355
285
  * RAG 同步:扫描 .harness/knowledge-docs/ 输出需要 ingest 的文件列表
356
286
  */
@@ -383,55 +313,6 @@ export async function knowledgeSyncRag(options: KnowledgeOptions, io: CommandIO
383
313
  return { kind: 'ok' };
384
314
  }
385
315
 
386
- /**
387
- * 知识同步状态:检测所有 tracked scope 的新鲜度
388
- */
389
- export async function knowledgeSyncStatus(options: KnowledgeOptions, io: CommandIO = processIO): Promise<CommandResult> {
390
- const apiPort = process.env.API_PORT || '3001';
391
- const url = `http://localhost:${apiPort}/api/knowledge/sync-status`;
392
-
393
- try {
394
- const res = await fetch(url);
395
- if (!res.ok) {
396
- const err: any = await res.json().catch(() => ({ error: res.statusText }));
397
- logError(io, chalk.red(`API error ${res.status}: ${err.error || res.statusText}`));
398
- return { kind: 'fail', reason: `sync-status API error ${res.status}: ${err.error || res.statusText}` };
399
- }
400
-
401
- const data: any = await res.json();
402
-
403
- if (options.json) {
404
- log(io, JSON.stringify(data, null, 2));
405
- return { kind: 'ok' };
406
- }
407
-
408
- log(io, chalk.blue(`🔄 KnowledgeSync Status\n`));
409
- log(io, chalk.bold(`Tracked scopes: ${data.trackedScopes?.join(', ') || 'none'}`));
410
- log(io, chalk.bold(`Stale entries: ${data.stale?.length || 0}`));
411
-
412
- if (data.stale?.length > 0) {
413
- log(io, chalk.yellow(`\n⚠️ Stale knowledge:\n`));
414
- for (const s of data.stale) {
415
- log(io, chalk.yellow(` ${s.scope} (${s.title}): ${s.stalenessHours}h old, files changed: ${s.changedFiles.join(', ')}`));
416
- }
417
- } else {
418
- log(io, chalk.green('\n✅ All knowledge fresh'));
419
- }
420
-
421
- if (data.healed?.length > 0) {
422
- log(io, chalk.cyan(`\n🩹 Auto-healed: ${data.healed.join(', ')}`));
423
- }
424
- } catch (e: any) {
425
- if (e?.code === 'ECONNREFUSED') {
426
- logError(io, chalk.red(`Cannot reach Studio API at ${url}`));
427
- } else {
428
- logError(io, chalk.red(`Sync check failed: ${e.message}`));
429
- }
430
- return { kind: 'fail', reason: `knowledge sync status 检查失败: ${e?.code === 'ECONNREFUSED' ? `Studio API 不可达 ${url}` : e.message}` };
431
- }
432
- return { kind: 'ok' };
433
- }
434
-
435
316
  /**
436
317
  * 飞轮健康检查 — 零 token 检测知识飞轮数据流状态
437
318
  */
@@ -10,8 +10,8 @@
10
10
  * studio/.harness/knowledge/ (知识新鲜度)
11
11
  * ~/.claude/projects/-root-projects/memory/ (规则库)
12
12
  *
13
- * 模型状态: ~/.claude/user-model-state.json
14
- * 画像输出: ~/.claude/projects/-root-projects/memory/user_profile.md
13
+ * 模型状态: $HARNESS_UUM_STATE_FILE,默认 ~/.claude/user-model-state.json
14
+ * 画像输出: $HARNESS_UUM_PROFILE_FILE,默认 ~/.claude/projects/-root-projects/memory/user_profile.md
15
15
  *
16
16
  * 工单 19-C:transcript 解析/纠正模式/相似度收敛至 cli/session-mining/。
17
17
  */
@@ -50,15 +50,30 @@ interface ModelState {
50
50
  evolutionLog: Array<{ date: string; change: string }>;
51
51
  }
52
52
 
53
- const STATE_FILE = path.join(os.homedir(), '.claude', 'user-model-state.json');
54
- const PROFILE_FILE = path.join(os.homedir(), '.claude', 'projects', '-root-projects', 'memory', 'user_profile.md');
53
+ // 路径集中解析(harness#116):默认值保持原行为,env 可覆盖(多工作区/测试隔离)。
54
+ // 运行时解析而非模块加载期常量,保证 env 设置后生效。
55
+ export interface UserModelPaths {
56
+ stateFile: string;
57
+ profileFile: string;
58
+ }
59
+
60
+ export function resolveUserModelPaths(env: NodeJS.ProcessEnv = process.env): UserModelPaths {
61
+ const home = os.homedir();
62
+ return {
63
+ stateFile: env.HARNESS_UUM_STATE_FILE
64
+ || path.join(home, '.claude', 'user-model-state.json'),
65
+ profileFile: env.HARNESS_UUM_PROFILE_FILE
66
+ || path.join(home, '.claude', 'projects', '-root-projects', 'memory', 'user_profile.md'),
67
+ };
68
+ }
55
69
 
56
70
  export async function updateUserModel(options: UpdateUserModelOptions, io: CommandIO = processIO): Promise<CommandResult> {
57
71
  const transcriptDir = process.env.CLAUDE_TRANSCRIPTS_DIR
58
72
  || path.join(os.homedir(), '.claude', 'projects', '-root--claude');
59
73
 
60
74
  // 1. Load previous state
61
- const state = loadState();
75
+ const paths = resolveUserModelPaths();
76
+ const state = loadState(paths);
62
77
 
63
78
  // 2. Scan new data
64
79
  const newSessions = findNewSessions(transcriptDir, state.sessionsProcessed, options.days);
@@ -90,8 +105,8 @@ export async function updateUserModel(options: UpdateUserModelOptions, io: Comma
90
105
  state.sessionsProcessed = state.sessionsProcessed.slice(-200);
91
106
  }
92
107
 
93
- saveState(state);
94
- updateProfile(state);
108
+ saveState(state, paths);
109
+ updateProfile(state, paths);
95
110
  }
96
111
 
97
112
  // 6. Output
@@ -107,10 +122,10 @@ export async function updateUserModel(options: UpdateUserModelOptions, io: Comma
107
122
 
108
123
  // ── State I/O ──
109
124
 
110
- function loadState(): ModelState {
125
+ function loadState(paths: UserModelPaths): ModelState {
111
126
  try {
112
- if (fs.existsSync(STATE_FILE)) {
113
- return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
127
+ if (fs.existsSync(paths.stateFile)) {
128
+ return JSON.parse(fs.readFileSync(paths.stateFile, 'utf-8'));
114
129
  }
115
130
  } catch {}
116
131
  return {
@@ -123,11 +138,11 @@ function loadState(): ModelState {
123
138
  };
124
139
  }
125
140
 
126
- function saveState(state: ModelState): void {
141
+ function saveState(state: ModelState, paths: UserModelPaths): void {
127
142
  try {
128
- const dir = path.dirname(STATE_FILE);
143
+ const dir = path.dirname(paths.stateFile);
129
144
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
130
- fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), 'utf-8');
145
+ fs.writeFileSync(paths.stateFile, JSON.stringify(state, null, 2), 'utf-8');
131
146
  } catch {}
132
147
  }
133
148
 
@@ -373,9 +388,9 @@ function applySignals(state: ModelState, signals: SessionSignals[], mergedConcep
373
388
 
374
389
  // ── Profile update ──
375
390
 
376
- function updateProfile(state: ModelState): void {
391
+ function updateProfile(state: ModelState, paths: UserModelPaths): void {
377
392
  try {
378
- let content = fs.readFileSync(PROFILE_FILE, 'utf-8');
393
+ let content = fs.readFileSync(paths.profileFile, 'utf-8');
379
394
 
380
395
  // Replace Derived Rules section
381
396
  const derivedStart = '## Derived Rules';
@@ -410,7 +425,7 @@ function updateProfile(state: ModelState): void {
410
425
  }
411
426
  }
412
427
 
413
- fs.writeFileSync(PROFILE_FILE, content, 'utf-8');
428
+ fs.writeFileSync(paths.profileFile, content, 'utf-8');
414
429
  } catch (e) {
415
430
  // Profile file might not exist yet — skip
416
431
  }
@@ -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) + 落点写入器(injection-writer:「声明→对照→幂等替换」中 marker-range 替换的唯一 writer——replaceStandaloneRange/replaceEnclosedRange/cutMarkerBlock + 半标记守护;落点路由读写两侧同住此模块 resolveInjectionTarget(CLAUDE.md 有标记段优先、否则 AGENTS.md PRESERVE:governance,studio #307)/resolveGovernanceLanding(init 写侧,含旧版无标记 Governance Rules 块豁免),ADR-0011) + Agent prompt 渲染(agent-prompt-renderer:trigger 参数化分组渲染,role 路由留 studio,H6/G6)/漂移校验(injection-drift:纯检测只读,路由消费 injection-writer)/使用统计(usage-report:constraints report·retire 的只读数据层,读 trace 两个入口——`readProjectTracesReport()` 带 `skippedLines`、`readProjectTraces()` 兼容包装丢计数;报告模型 `ConstraintsUsageReport` 的降级位 = `traceFileExists` + `skippedLines`,harness#100)/清单对照(capabilities-reconcile:CAPABILITIES.md 与代码实况的覆盖/幽灵判定唯一实现,capability_sync·docs_freshness·sync-docs 共消费,ADR-0009);CheckEnv 生产侧唯一构造点 `buildCheckEnv(context, providers|'none')`(checkers/types.ts:providers = 证据接线,git 两项绑 GitEvidence 实例,'none' = 显式不接 → evidence flag checker 按契约 skip
7
+ - `constraints/` — 约束定义(IRON_LAWS/GUIDELINES/PROMPTS) + 检查引擎(ConstraintChecker,拦截统一由 checkBeforeExecution 承担,ADR-0004) + 缓存(CheckCache:TTL 缓存 + 计数采样,H6/G5 起公开导出) + 注入渲染(injection-renderer) + 落点写入器(injection-writer:「声明→对照→幂等替换」中 marker-range 替换的唯一 writer——replaceStandaloneRange/replaceEnclosedRange/cutMarkerBlock + 半标记守护;落点路由读写两侧同住此模块 resolveInjectionTarget(CLAUDE.md 有标记段优先、否则 AGENTS.md PRESERVE:governance,studio #307)/resolveGovernanceLanding(init 写侧,含旧版无标记 Governance Rules 块豁免),ADR-0011) + Agent prompt 渲染(agent-prompt-renderer:trigger 参数化分组渲染,role 路由留 studio,H6/G6)/漂移校验(injection-drift:纯检测只读,路由消费 injection-writer)/使用统计(usage-report:constraints report·retire 的只读数据层,读 trace 两个入口——`readProjectTracesReport()` 带 `skippedLines`、`readProjectTraces()` 兼容包装丢计数;报告模型 `ConstraintsUsageReport` 的降级位 = `traceFileExists` + `skippedLines`,harness#100)/清单对照(capabilities-reconcile:CAPABILITIES.md 与代码实况的覆盖/幽灵判定唯一实现,capability_sync·docs_freshness·sync-docs 共消费,ADR-0009);CheckEnv 生产侧唯一构造点 `buildCheckEnv(context, providers|'none')`(checkers/types.ts:providers = 证据接线,git 两项绑 GitEvidence 实例,'none' = 显式不接 → evidence flag checker 按契约 skip);判定接缝 `CheckOutcome = boolean | 'skip' | CheckDetail{pass, evidence}`(harness#119/ADR-0016)——编排层与 `gates/checker-gate` 一律经唯一归一点 `normalizeCheckOutcome()` 取三态 + 证据,不各自 `=== false`(对新形状会误判);证据行由 checker 自行措辞(自描述、不带缩进),组装与截断策略单点在 `formatEvidence()`/`MAX_EVIDENCE_ITEMS`(同住 checkers/types.ts);证据四出口 = `ConstraintResult.evidence` → CLI 结论块/提示块、`ExecutionTrace.evidence`、铁律 `ConstraintViolationError.message`(铁律违规直接 throw,异常文案是其证据唯一外溢面)
8
8
  - `git-evidence.ts` — 全仓唯一 git 取证点(#87):`createGitEvidence(projectPath, run?)` 返回 `GitEvidence{stagedDiff, changedFileNames(staged), headDirs}`,seam 上两个真 adapter(缺省 `realGitCommandRunner` 走真 git / 注入 runner 供计数与替身)。命令级 memo 落在此实例(含失败结果),实例生命周期 = 一次 check run,故 context-builder 与 ConstraintChecker 传同一实例即共用证据;判定语义与迁移前一致(diff 取证失败 → 空串,`git ls-tree` 失败 → null = 所有目录视为新);`parseHeadDirs`/`splitFileNames` 是配套纯函数
9
9
  - `constraints/context-builder.ts` — 只装配不取证:`buildConstraintContext(options.evidence?)` 与 `detectTrigger(options.evidence?)` 经 GitEvidence 取 git 事实(不传 = 自建真 adapter),本模块无 child_process import;其余证据标志(traces/文档标记)仍为本地探测
10
10
  - `effective-constraints.ts` — `getEffectiveConstraints(projectRoot, {preset?})`:全仓唯一生效集来源(内置 → preset → config.yml 禁用(内置与 custom 同效)→ custom 追加(禁用/已退役的不追加)→ scenes 过滤);`getMergedConstraintsConfig(projectRoot, {preset?})`:同链路的完整 MergedConstraintsConfig 形状(含 disabled/custom/unknownIds),内置工单 23 优先级规则(--preset 仅在无项目自定义配置时生效),check 命令经此入口;`lintEffectiveConfig` 配置诊断
@@ -31,3 +31,4 @@
31
31
  - 零 Token 成本:所有分析纯文件操作,无 LLM 调用
32
32
  - 约束配置支持 .harness/config.yml 自定义合并(preset 真实生效)
33
33
  - 存在性探测约束(capability_sync/docs_freshness/context_doc_sync)在约定文件缺失,或 context_files 约定已立但无目标(enabled-empty)时 skip,不计 pass/fail;context_doc_sync 与 docs_freshness 对三态判定同构(工单 84)
34
+ - **fail 必须可归因到被评估的对象**(harness#119/ADR-0016):`capability_sync` 的 Step 1(staged 增量未登记)判违规,Step 2(T-058 全量完备性)与本次变更无因果、只出提示(`satisfied=true` + evidence);「有表格但零条目 + 确有源文件」的文档退化门仍判违规(显式,不借 Step 2 兜)。2026-09-08 前 `capability_sync` 恒红(studio 22/22)就是这条错位造成的。注意降级并不换来执法:`sync-docs --check` 单独跑才 exit 1,CI/ship 都先跑写入(file 模式自补行)再 `--check`,harness 自有 CI 那步还是 `continue-on-error`——仓库级漏登目前无自动拦截点,缺口与修法见 ADR-0016「影响」
@@ -22,7 +22,7 @@ import { matchesTrigger } from '../../utils/exec';
22
22
  import { join, relative } from 'path';
23
23
  import { CheckCache } from './check-cache';
24
24
  import { findTsSourceFiles } from '../../utils/file-walk';
25
- import { getConstraintCheck, buildCheckEnv, type CheckOutcome } from './checkers';
25
+ import { getConstraintCheck, buildCheckEnv, normalizeCheckOutcome, type CheckOutcome } from './checkers';
26
26
  import { createGitEvidence, type GitEvidence } from './git-evidence';
27
27
 
28
28
  /**
@@ -112,9 +112,11 @@ export class ConstraintChecker {
112
112
  }
113
113
 
114
114
  // 检查前置条件('skip' = 约定未采用/证据未接线:satisfied 置 true 但不计 pass/fail)
115
- const outcome = await this.checkPrecondition(constraint, context, evidence);
115
+ const outcome = normalizeCheckOutcome(
116
+ await this.checkPrecondition(constraint, context, evidence)
117
+ );
116
118
 
117
- if (outcome === 'skip') {
119
+ if (outcome.skipped) {
118
120
  return {
119
121
  id: constraint.id,
120
122
  level: constraint.level,
@@ -126,7 +128,7 @@ export class ConstraintChecker {
126
128
  };
127
129
  }
128
130
 
129
- const satisfied = outcome;
131
+ const satisfied = outcome.satisfied;
130
132
 
131
133
  return {
132
134
  id: constraint.id,
@@ -134,6 +136,7 @@ export class ConstraintChecker {
134
136
  satisfied,
135
137
  constraint,
136
138
  message: satisfied ? undefined : constraint.message,
139
+ evidence: outcome.evidence.length > 0 ? outcome.evidence : undefined,
137
140
  requiredAction: satisfied ? undefined : constraint.enforcement,
138
141
  checkedAt: new Date(),
139
142
  };
@@ -172,6 +175,7 @@ export class ConstraintChecker {
172
175
  severity: this.getSeverity(constraint.level),
173
176
  projectPath: context.projectPath,
174
177
  sessionId: context.sessionId,
178
+ evidence: checkResult.evidence,
175
179
  });
176
180
  }
177
181
 
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { describe, it, expect, jest } from '@jest/globals';
11
11
  import { capabilitySync } from '../capability-sync';
12
- import { buildCheckEnv, type CheckEnv } from '../types';
12
+ import { buildCheckEnv, normalizeCheckOutcome, type CheckEnv, type CheckOutcome } from '../types';
13
13
  import { collectSourceFiles } from '../../capabilities-reconcile';
14
14
  import { createProjectFixture } from '../../../../test-setup/project-fixture';
15
15
  import type { ConstraintContext } from '../../../../types/constraint';
@@ -47,6 +47,12 @@ function makeEnv(
47
47
  });
48
48
  }
49
49
 
50
+ /** 判定归一:boolean 与 CheckDetail 两种返回形状统一断言(harness#119) */
51
+ const passed = (outcome: CheckOutcome): boolean => normalizeCheckOutcome(outcome).satisfied;
52
+ /** 证据行拼成单串,便于按路径断言 */
53
+ const evidenceText = (outcome: CheckOutcome): string =>
54
+ normalizeCheckOutcome(outcome).evidence.join('\n');
55
+
50
56
  describe('capability_sync — skip 与文档格式门槛', () => {
51
57
  it('无 CAPABILITIES.md → skip(ADR-0001 存在性探测,有无变更都一样)', async () => {
52
58
  const dir = createProjectFixture({ name: 'cap-sync-none' });
@@ -56,84 +62,104 @@ describe('capability_sync — skip 与文档格式门槛', () => {
56
62
 
57
63
  it('散文文档(无表格)+ 有变更 → 放行(历史语义)', async () => {
58
64
  const dir = setupDir('prose', '# Capabilities\n\n- Feature: test', ['feature.ts']);
59
- expect(await capabilitySync.evaluate(makeEnv(dir, ['feature.ts']))).toBe(true);
65
+ expect(passed(await capabilitySync.evaluate(makeEnv(dir, ['feature.ts'])))).toBe(true);
60
66
  });
61
67
 
62
68
  it('清单格式(计数行)→ 放行,计数由 sync-docs 维护', async () => {
63
69
  const dir = setupDir('listing', '# Capabilities\n\n## CLI Commands (25)\ncheck, validate\n');
64
- expect(await capabilitySync.evaluate(makeEnv(dir, ['src/foo.ts']))).toBe(true);
70
+ expect(passed(await capabilitySync.evaluate(makeEnv(dir, ['src/foo.ts'])))).toBe(true);
65
71
  });
66
72
 
67
- it('有表格但零条目 + 有变更 → 不得放行', async () => {
73
+ it('有表格但零条目 + 有变更 → 不得放行(step1 拦下,证据点名变更文件)', async () => {
68
74
  const dir = setupDir('empty-table', `# Capabilities\n\n${TABLE_HEAD}`);
69
- expect(await capabilitySync.evaluate(makeEnv(dir, ['src/foo.ts']))).toBe(false);
75
+ const outcome = await capabilitySync.evaluate(makeEnv(dir, ['src/foo.ts']));
76
+ expect(passed(outcome)).toBe(false);
77
+ expect(evidenceText(outcome)).toContain('src/foo.ts');
70
78
  });
71
79
 
72
- it('有表格但零条目 + 源码有文件 → step2 不得放行', async () => {
80
+ it('有表格但零条目 + 源码有文件 → 文档退化门拦下(不靠 step2 兜,harness#119)', async () => {
73
81
  const dir = setupDir('empty-table-scan', `# Capabilities\n\n${TABLE_HEAD}`, ['src/foo.ts']);
74
- expect(await capabilitySync.evaluate(makeEnv(dir, []))).toBe(false);
82
+ const outcome = await capabilitySync.evaluate(makeEnv(dir, []));
83
+ expect(passed(outcome)).toBe(false);
84
+ expect(evidenceText(outcome)).toContain('零条目');
85
+ });
86
+
87
+ it('有表格但零条目 + 源码无文件 → 放行(门限定「确有可登记对象」,不扩大 fail 面)', async () => {
88
+ const dir = setupDir('empty-table-nosrc', `# Capabilities\n\n${TABLE_HEAD}`);
89
+ expect(passed(await capabilitySync.evaluate(makeEnv(dir, [])))).toBe(true);
75
90
  });
76
91
  });
77
92
 
78
- describe('capability_sync — step1 增量覆盖', () => {
93
+ describe('capability_sync — step1 增量覆盖(唯一判违规处,harness#119)', () => {
79
94
  const run = (dir: string, staged: string[]) =>
80
95
  capabilitySync.evaluate(makeEnv(dir, staged, {}));
81
96
 
82
- it('变更文件未被登记 → 失败', async () => {
97
+ it('变更文件未被登记 → 失败,且证据点名该文件', async () => {
83
98
  const dir = setupDir('step1-uncovered', `# C\n\n${TABLE_HEAD}| 旧 | old/module.ts | 旧 |\n`);
84
- expect(await run(dir, ['new-module.ts'])).toBe(false);
99
+ const outcome = await run(dir, ['new-module.ts']);
100
+ expect(passed(outcome)).toBe(false);
101
+ expect(evidenceText(outcome)).toContain('new-module.ts');
85
102
  });
86
103
 
87
104
  it('变更文件按登记路径覆盖 → 通过', async () => {
88
105
  const dir = setupDir('step1-covered', `# C\n\n${TABLE_HEAD}| foo | src/foo.ts | foo |\n`);
89
- expect(await run(dir, ['src/foo.ts'])).toBe(true);
106
+ expect(passed(await run(dir, ['src/foo.ts']))).toBe(true);
90
107
  });
91
108
 
92
109
  it('每个变更文件都必须被覆盖(every 而非 some)', async () => {
93
110
  const dir = setupDir('step1-every', `# C\n\n${TABLE_HEAD}| foo | src/foo.ts | foo |\n`);
94
- expect(await run(dir, ['src/foo.ts', 'scripts/bar.ts'])).toBe(false);
111
+ const outcome = await run(dir, ['src/foo.ts', 'scripts/bar.ts']);
112
+ expect(passed(outcome)).toBe(false);
113
+ expect(evidenceText(outcome)).toContain('scripts/bar.ts');
95
114
  });
96
115
 
97
116
  it('后缀碰撞不覆盖:xfoo.ts 不被 foo.ts 条目覆盖', async () => {
98
117
  const dir = setupDir('step1-endswith', `# C\n\n${TABLE_HEAD}| foo | foo.ts | foo |\n`);
99
- expect(await run(dir, ['web/xfoo.ts'])).toBe(false);
118
+ expect(passed(await run(dir, ['web/xfoo.ts']))).toBe(false);
100
119
  });
101
120
 
102
121
  it('子串误配不覆盖:docs/src/foo.tsx 不被 src/foo.ts 条目覆盖', async () => {
103
122
  const dir = setupDir('step1-includes', `# C\n\n${TABLE_HEAD}| foo | src/foo.ts | foo |\n`);
104
- expect(await run(dir, ['docs/src/foo.tsx'])).toBe(false);
123
+ expect(passed(await run(dir, ['docs/src/foo.tsx']))).toBe(false);
105
124
  });
106
125
 
107
126
  it('basename 条目按路径边界后缀覆盖(src/foo.ts 被 foo.ts 覆盖)', async () => {
108
127
  const dir = setupDir('step1-suffix-ok', `# C\n\n${TABLE_HEAD}| foo | foo.ts | foo |\n`);
109
- expect(await run(dir, ['src/foo.ts'])).toBe(true);
128
+ expect(passed(await run(dir, ['src/foo.ts']))).toBe(true);
110
129
  });
111
130
 
112
131
  it('测试文件与非代码变更不参与 step1', async () => {
113
132
  const dir = setupDir('step1-noncode', `# C\n\n${TABLE_HEAD}| foo | src/foo.ts | foo |\n`);
114
133
  expect(
115
- await run(dir, ['src/__tests__/foo.test.ts', 'docs/readme.md', 'package.json'])
134
+ passed(await run(dir, ['src/__tests__/foo.test.ts', 'docs/readme.md', 'package.json']))
116
135
  ).toBe(true);
117
136
  });
118
137
  });
119
138
 
120
- describe('capability_sync — step2 全量覆盖(file 模式)', () => {
121
- it('全部源文件已登记 → 通过', async () => {
139
+ describe('capability_sync — step2 全量覆盖(file 模式:只提示,不判违规)', () => {
140
+ it('全部源文件已登记 → 通过且无提示', async () => {
122
141
  const dir = setupDir(
123
142
  'step2-ok',
124
143
  `# C\n\n${TABLE_HEAD}| foo | src/foo.ts | foo |\n`,
125
144
  ['src/foo.ts']
126
145
  );
127
- expect(await capabilitySync.evaluate(makeEnv(dir, []))).toBe(true);
146
+ const outcome = await capabilitySync.evaluate(makeEnv(dir, []));
147
+ expect(passed(outcome)).toBe(true);
148
+ expect(evidenceText(outcome)).toBe('');
128
149
  });
129
150
 
130
- it('有未登记源文件 → 失败', async () => {
151
+ it('有未登记源文件 → 仍通过,但提示点名该文件(harness#119 归位)', async () => {
131
152
  const dir = setupDir(
132
153
  'step2-missing',
133
154
  `# C\n\n${TABLE_HEAD}| foo | src/foo.ts | foo |\n`,
134
155
  ['src/foo.ts', 'src/unlisted.ts']
135
156
  );
136
- expect(await capabilitySync.evaluate(makeEnv(dir, []))).toBe(false);
157
+ const outcome = await capabilitySync.evaluate(makeEnv(dir, []));
158
+ expect(passed(outcome)).toBe(true);
159
+ const evidence = evidenceText(outcome);
160
+ expect(evidence).toContain('src/unlisted.ts');
161
+ expect(evidence).toContain('仓库级漂移');
162
+ expect(evidence).toContain('sync-docs');
137
163
  });
138
164
 
139
165
  it('basename 条目同样按边界后缀覆盖 step2(step1/step2 语义一致)', async () => {
@@ -142,16 +168,40 @@ describe('capability_sync — step2 全量覆盖(file 模式)', () => {
142
168
  `# C\n\n${TABLE_HEAD}| foo | foo.ts | foo |\n`,
143
169
  ['src/foo.ts']
144
170
  );
145
- expect(await capabilitySync.evaluate(makeEnv(dir, []))).toBe(true);
171
+ expect(passed(await capabilitySync.evaluate(makeEnv(dir, [])))).toBe(true);
146
172
  });
147
173
 
148
- it('不同路径同后缀文件不被误覆盖(lib/foo.ts 不覆盖 src/foo.ts', async () => {
174
+ it('不同路径同后缀文件不被误覆盖(lib/foo.ts 不覆盖 src/foo.ts)→ 记为漂移', async () => {
149
175
  const dir = setupDir(
150
176
  'step2-boundary',
151
177
  `# C\n\n${TABLE_HEAD}| foo | lib/foo.ts | foo |\n`,
152
178
  ['src/foo.ts']
153
179
  );
154
- expect(await capabilitySync.evaluate(makeEnv(dir, []))).toBe(false);
180
+ const outcome = await capabilitySync.evaluate(makeEnv(dir, []));
181
+ expect(passed(outcome)).toBe(true);
182
+ expect(evidenceText(outcome)).toContain('src/foo.ts');
183
+ });
184
+
185
+ it('step1 优先:变更未登记时判违规,证据是变更文件而非仓库漂移清单', async () => {
186
+ const dir = setupDir(
187
+ 'step1-beats-step2',
188
+ `# C\n\n${TABLE_HEAD}| foo | src/foo.ts | foo |\n`,
189
+ ['src/foo.ts', 'src/drifted.ts']
190
+ );
191
+ const outcome = await capabilitySync.evaluate(makeEnv(dir, ['src/new.ts']));
192
+ expect(passed(outcome)).toBe(false);
193
+ expect(evidenceText(outcome)).toContain('src/new.ts');
194
+ expect(evidenceText(outcome)).not.toContain('src/drifted.ts');
195
+ });
196
+
197
+ it('漂移项超过 10 个 → 证据截断并给出全量查看入口', async () => {
198
+ const files = ['src/foo.ts', ...Array.from({ length: 12 }, (_, i) => `src/drift${i}.ts`)];
199
+ const dir = setupDir('step2-truncate', `# C\n\n${TABLE_HEAD}| foo | src/foo.ts | foo |\n`, files);
200
+ const outcome = await capabilitySync.evaluate(makeEnv(dir, []));
201
+ expect(passed(outcome)).toBe(true);
202
+ const lines = normalizeCheckOutcome(outcome).evidence;
203
+ expect(lines).toHaveLength(12); // 说明行 + 10 条 + 截断行
204
+ expect(lines[lines.length - 1]).toContain('另 2 项');
155
205
  });
156
206
  });
157
207
 
@@ -163,7 +213,7 @@ describe('capability_sync — module 模式(未覆盖聚合为目录形状)'
163
213
  ['src/core/foo.ts', 'src/core/bar.ts'],
164
214
  'module'
165
215
  );
166
- expect(await capabilitySync.evaluate(makeEnv(dir, []))).toBe(true);
216
+ expect(passed(await capabilitySync.evaluate(makeEnv(dir, [])))).toBe(true);
167
217
  });
168
218
 
169
219
  it('文件条目精确匹配也算覆盖', async () => {
@@ -173,17 +223,22 @@ describe('capability_sync — module 模式(未覆盖聚合为目录形状)'
173
223
  ['src/core/foo.ts'],
174
224
  'module'
175
225
  );
176
- expect(await capabilitySync.evaluate(makeEnv(dir, []))).toBe(true);
226
+ expect(passed(await capabilitySync.evaluate(makeEnv(dir, [])))).toBe(true);
177
227
  });
178
228
 
179
- it('新目录未登记 → 失败', async () => {
229
+ it('新目录未登记 → 不判违规,提示按目录聚合', async () => {
180
230
  const dir = setupDir(
181
231
  'mod-uncovered',
182
232
  `# C\n\n${TABLE_HEAD}| 核心 | src/core/ | 核心 |\n`,
183
233
  ['src/core/foo.ts', 'src/newdir/bar.ts'],
184
234
  'module'
185
235
  );
186
- expect(await capabilitySync.evaluate(makeEnv(dir, []))).toBe(false);
236
+ const outcome = await capabilitySync.evaluate(makeEnv(dir, []));
237
+ expect(passed(outcome)).toBe(true);
238
+ const evidence = evidenceText(outcome);
239
+ expect(evidence).toContain('未登记模块目录');
240
+ expect(evidence).toContain('src/newdir/');
241
+ expect(evidence).not.toContain('src/newdir/bar.ts');
187
242
  });
188
243
 
189
244
  it('module 模式文件条目按边界后缀覆盖(step1/step2 同规则)', async () => {
@@ -193,12 +248,12 @@ describe('capability_sync — module 模式(未覆盖聚合为目录形状)'
193
248
  ['src/foo.ts'],
194
249
  'module'
195
250
  );
196
- expect(await capabilitySync.evaluate(makeEnv(dir, []))).toBe(true);
251
+ expect(passed(await capabilitySync.evaluate(makeEnv(dir, [])))).toBe(true);
197
252
  });
198
253
  });
199
254
 
200
255
  describe('capability_sync — fail-open 可观测', () => {
201
- it('证据读取异常时放行但输出 warn', async () => {
256
+ it('证据读取异常时放行但输出 warn,且异常原因进 evidence', async () => {
202
257
  const dir = setupDir('failopen', `# C\n\n${TABLE_HEAD}| foo | src/foo.ts | foo |\n`);
203
258
  const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
204
259
  const env: CheckEnv = {
@@ -207,7 +262,11 @@ describe('capability_sync — fail-open 可观测', () => {
207
262
  throw new Error('git boom');
208
263
  },
209
264
  };
210
- expect(await capabilitySync.evaluate(env)).toBe(true);
265
+ const outcome = await capabilitySync.evaluate(env);
266
+ // fail-open 语义不变(仍不拦),但「因异常而通过」必须留痕:warn 只进本地 stderr
267
+ expect(passed(outcome)).toBe(true);
268
+ expect(evidenceText(outcome)).toContain('git boom');
269
+ expect(evidenceText(outcome)).toContain('fail-open');
211
270
  expect(warnSpy).toHaveBeenCalled();
212
271
  warnSpy.mockRestore();
213
272
  });