leerness 1.36.166 → 1.36.167

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.167 — 2026-08-26
4
+
5
+ - T-0145: 장기 운영 프로젝트의 레거시 완료 증거를 소급 수정하지 않고 명시적 tracker 경계 이전의 현재 실패만 exact 행+사유 SHA-256 지문으로 격리하는 claims baseline을 추가했습니다. verify-claim --all과 gate --claims는 원판정 실패·baseline 부채·현재 차단 및 진단 상세를 분리해 보고하고, --raw/개별 검증은 원판정을 유지합니다. 행 변경·신규 실패·경계 밖 항목·손상 baseline은 fail-closed하며, 생성 명령은 기존 baseline을 덮어쓰지 않아 변경 행의 재승인을 차단합니다. CLI·MCP·클린룸 프로브로 검증합니다.
6
+
3
7
  ## 1.36.166 — 2026-08-26
4
8
 
5
9
  - T-0144: unknown/foreign/malformed CLI 플래그를 migration과 쓰기 전에 exit 1로 거부하고 commands 카탈로그의 verify-code·contract verify 누락을 보완했습니다. 명령별 strict route와 값 누락·부울 equals·language 선택값 회귀 probe를 npm test에 추가했습니다.
package/README.md CHANGED
@@ -85,6 +85,16 @@ The generated workflow is production-grade: it **pins the leerness version** (re
85
85
 
86
86
  Then make that check **required** in GitHub branch protection. Now a PR that skips verification (or whose claims fail) **cannot merge** — the gate runs independently of the agent, returns a non-zero exit code, and blocks. That is the difference between a guideline and a guardrail. For exact per-claim enforcement, run `leerness gate --claims` — it adds a 6th check that runs `verify-claim` on **every** completed task and fails the gate if any "done" task's evidence doesn't match reality (the default 5-check gate already blocks false-done via heuristics; `--claims` makes it precise).
87
87
 
88
+ For a mature repository that predates strict claim verification, do not rewrite historical evidence merely to make the gate green. Create an explicit, reviewed debt boundary instead:
89
+
90
+ ```bash
91
+ leerness verify-claim baseline create --before T-0123 --yes
92
+ leerness verify-claim --all # applies exact-match legacy debt policy
93
+ leerness verify-claim --all --raw # ignores the baseline and audits raw verdicts
94
+ ```
95
+
96
+ The baseline stores no replacement evidence. It accepts only failures before the named tracker row whose **complete row and failure reasons still match their SHA-256 fingerprint**. A changed legacy row, a newly failing task, an entry at/after the boundary, or a corrupt baseline fails closed. `verify-claim <T-ID>` always remains raw for focused audits. Creation is one-shot: the CLI refuses to recalculate or overwrite an existing baseline, so a changed historical row cannot be relaundered by rerunning the command. The tracked `.leerness/claims-baseline.json` is repository policy, not a signature against a malicious repository writer: protect any removal or replacement with ordinary branch review and CODEOWNERS when the threat model requires that separation.
97
+
88
98
  For secrets, pair the gate with a **dedicated scanner** in the same workflow. `scan secrets` gives your agent the same signal locally; a dedicated scanner is the hard-guarantee layer:
89
99
 
90
100
  ```yaml
@@ -105,7 +115,7 @@ For secrets, pair the gate with a **dedicated scanner** in the same workflow. `s
105
115
 
106
116
  - **Memory** — `task` / `plan` / `decision` / `lesson` / `rule`: canonical JSON + markdown projections, archive/restore.
107
117
  - **Handoff** — `handoff` (session start context) · `session close` (closing report). Survives agent swaps.
108
- - **Verification** — `verify-claim` (evidence vs reality, stub/fake-test/inflated-count detection, `--run-tests --test-cmd` for any language; `--all` checks **every** completed claim at once for CI) · `contract verify` (spec ↔ impl) · `gate` (one-call CI gate).
118
+ - **Verification** — `verify-claim` (evidence vs reality, stub/fake-test/inflated-count detection, `--run-tests --test-cmd` for any language; `--all` checks every completed claim for CI; `baseline create --before <T-ID> --yes` isolates fingerprint-bound legacy debt; `--raw` audits the original verdicts) · `contract verify` (spec ↔ impl) · `gate` (one-call CI gate).
109
119
  - **Audit** — `audit` · `lazy detect` · `drift check` keep the workspace honest over time.
110
120
  - **Security** — `scan secrets` (committed-secret detection) · `encoding check` (BOM/CP949) — also runs at `session close`.
111
121
  - **Visualize** — `graph --html` writes a self-contained interactive ontology graph (`leerness.html`) of the whole harness (memory surfaces + skills + feature-graph) — click a node to read its content. Optional tracked auto-refresh: `LEERNESS_AUTO_GRAPH=1 leerness handoff . --writeback`.
@@ -128,7 +138,7 @@ MIT
128
138
  <!-- leerness:project-readme:start -->
129
139
  ## Leerness Project Harness
130
140
 
131
- 이 프로젝트는 Leerness v1.36.166 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
141
+ 이 프로젝트는 Leerness v1.36.167 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
132
142
 
133
143
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
134
144
 
@@ -182,7 +192,7 @@ leerness memory restore decision <date|title>
182
192
 
183
193
  ### MCP server (외부 AI 통합)
184
194
 
185
- Leerness v1.36.166는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
195
+ Leerness v1.36.167는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
186
196
 
187
197
  ```jsonc
188
198
  // 카테고리별
@@ -203,7 +213,7 @@ Leerness v1.36.166는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
203
213
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
204
214
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
205
215
 
206
- 현재 누적: **v1.9.x → 1.36.166 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
216
+ 현재 누적: **v1.9.x → 1.36.167 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
207
217
 
208
218
  ### 성능 가이드
209
219
 
@@ -241,5 +251,5 @@ leerness release pack --close --auto-main-push
241
251
  - `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
242
252
  - `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
243
253
 
244
- Last synced by Leerness v1.36.166: 2026-08-26
254
+ Last synced by Leerness v1.36.167: 2026-08-26
245
255
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -36,6 +36,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
36
36
  const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
37
37
  const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .leerness/*.json 상태 무결성 (audit/health/check 공유)
38
38
  const _cursorHook = require('../lib/cursor-session-hook');
39
+ const _claimsBaseline = require('../lib/claims-baseline');
39
40
  const {
40
41
  CANONICAL_WORKSPACE_DIR,
41
42
  LEGACY_WORKSPACE_DIR,
@@ -47,7 +48,7 @@ const {
47
48
  migrateLegacyWorkspace,
48
49
  } = require('../lib/workspace-dir');
49
50
 
50
- const VERSION = '1.36.166';
51
+ const VERSION = '1.36.167';
51
52
 
52
53
  // 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
53
54
  // 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
@@ -1318,7 +1319,7 @@ leerness memory restore <surface> <target> # archive → active 복귀 (DELETE
1318
1319
  '.leerness/test-evidence-policy.md': fm('test-evidence-policy', ['검증 결과 기록 시'], ['검증 형식 변경'], `# Test Evidence Policy\n\n매 검증은 \`.leerness/review-evidence.md\`에 누적 기록합니다.\n\n## Format\n\`\`\`\n## YYYY-MM-DD HH:MM\nTask: T-XXXX\nCommand: <명령>\nExit: <코드>\nNote: <주요 결과 요약>\nArtifacts: <스크린샷/로그 경로>\n\`\`\`\n`),
1319
1320
  '.leerness/review-evidence.md': fm('review-evidence', ['진행 보고','릴리즈 검토'], ['검증 결과 기록'], `# Review Evidence\n\nVerification command/result history. Append-only.\n`),
1320
1321
  '.leerness/AX_PLAN_GUIDE.md': fm('ax-plan-guide', ['계획 수립/변경','신규 프로젝트'], ['계획 가이드 변경'], `# AX Plan Guide\n\n1. 사용자 요청이 기존 plan.md 범위 내인지 확인합니다.\n2. 새 범위라면 plan.md(milestone)와 progress-tracker.md(T-id) 양쪽에 추가합니다.\n3. 사용자가 범위를 드랍하면 삭제 대신 dropped 표기를 추가합니다.\n4. 신규 프로젝트는 코딩 전에 plan.md/project-brief.md를 채웁니다.\n`),
1321
- '.leerness/AX_MIGRATION_GUIDE.md': fm('ax-migration-guide', ['마이그레이션 전'], ['마이그레이션 정책 변경'], `# AX Migration Guide\n\n- Back up before changes (\`.leerness/archive/\`).\n- 기존 프로젝트 메모리 보존 (preserve-by-default).\n- .env.example/.gitignore는 라인 단위 머지.\n- 보호 파일을 삭제하지 않습니다.\n- 마이그레이션 보고서는 \`.leerness/migration-report.md\`.\n- 자동: \`leerness update --yes\`가 위 절차를 백업·머지·검증까지 한번에 수행합니다.\n`),
1322
+ '.leerness/AX_MIGRATION_GUIDE.md': fm('ax-migration-guide', ['마이그레이션 전'], ['마이그레이션 정책 변경'], `# AX Migration Guide\n\n- Back up before changes (\`.leerness/archive/\`).\n- 기존 프로젝트 메모리 보존 (preserve-by-default).\n- .env.example/.gitignore는 라인 단위 머지.\n- 보호 파일을 삭제하지 않습니다.\n- 마이그레이션 보고서는 \`.leerness/migration-report.md\`.\n- 자동: \`leerness update --yes\`가 위 절차를 백업·머지·검증까지 한번에 수행합니다.\n- 레거시 완료 증거는 소급 수정하지 않습니다. 명시적 경계 이전의 현재 실패만 \`leerness verify-claim baseline create --before <T-ID> --yes\`로 격리합니다.\n- claims baseline은 전체 tracker 행+실패 사유 지문이 같을 때만 적용됩니다. 변경·신규 실패·손상 baseline은 fail-closed하며 \`verify-claim --all --raw\`로 원판정을 감사합니다.\n- 생성된 claims baseline은 CLI로 덮어쓰지 않습니다. 새 정책이 필요하면 기존 tracked 파일의 제거/교체를 별도 코드리뷰로 승인합니다.\n`),
1322
1323
  '.leerness/AX_NEW_PROJECT_GUIDE.md': fm('ax-new-project-guide', ['신규 프로젝트 감지'], ['신규 설치 정책 변경'], `# AX New Project Guide\n\nBefore coding, ask or infer the project goal, users, scope, out-of-scope, stack, deployment target, and milestones. Then fill plan.md and project-brief.md.\n`),
1323
1324
  '.leerness/AX_SKILL_LIBRARY_GUIDE.md': fm('ax-skill-library-guide', ['스킬 학습/검증/업로드'], ['스킬 정책 변경'], `# AX Skill Library Guide\n\nValidated skills require metadata, sensitive data scan, AI verification, dry-run publish, and explicit execute approval.\n`),
1324
1325
  '.leerness/skill-index.md': fm('skill-index', ['작업별 스킬 선택'], ['스킬 추가/삭제'], `# Skill Index\n\n| ID | Korean Name | Capabilities | Last Updated | Verification |\n|---|---|---|---|---|\n${skillRows}\n`),
@@ -1342,7 +1343,7 @@ leerness memory restore <surface> <target> # archive → active 복귀 (DELETE
1342
1343
  _files['.leerness/test-evidence-policy.md'] = fm('test-evidence-policy', ['recording verification results'], ['verification format changes'], `# Test Evidence Policy\n\nEvery verification is appended to \`.leerness/review-evidence.md\`.\n\n## Format\n\`\`\`\n## YYYY-MM-DD HH:MM\nTask: T-XXXX\nCommand: <command>\nExit: <code>\nNote: <key result summary>\nArtifacts: <screenshot/log paths>\n\`\`\`\n`, 'en');
1343
1344
  _files['.leerness/review-evidence.md'] = fm('review-evidence', ['progress reports', 'release review'], ['verification results recorded'], `# Review Evidence\n\nVerification command/result history. Append-only.\n`, 'en');
1344
1345
  _files['.leerness/AX_PLAN_GUIDE.md'] = fm('ax-plan-guide', ['planning/plan changes', 'new project'], ['plan guide changes'], `# AX Plan Guide\n\n1. Check whether the user's request is within the current plan.md scope.\n2. If it is new scope, add it to both plan.md (milestone) and progress-tracker.md (T-id).\n3. When the user drops scope, mark it dropped instead of deleting.\n4. For new projects, fill plan.md/project-brief.md before coding.\n`, 'en');
1345
- _files['.leerness/AX_MIGRATION_GUIDE.md'] = fm('ax-migration-guide', ['before migration'], ['migration policy changes'], `# AX Migration Guide\n\n- Back up before changes (\`.leerness/archive/\`).\n- Preserve existing project memory (preserve-by-default).\n- Merge .env.example/.gitignore line by line.\n- Never delete protected files.\n- Migration report: \`.leerness/migration-report.md\`.\n- Automatic: \`leerness update --yes\` performs backup, merge, and verification in one step.\n`, 'en');
1346
+ _files['.leerness/AX_MIGRATION_GUIDE.md'] = fm('ax-migration-guide', ['before migration'], ['migration policy changes'], `# AX Migration Guide\n\n- Back up before changes (\`.leerness/archive/\`).\n- Preserve existing project memory (preserve-by-default).\n- Merge .env.example/.gitignore line by line.\n- Never delete protected files.\n- Migration report: \`.leerness/migration-report.md\`.\n- Automatic: \`leerness update --yes\` performs backup, merge, and verification in one step.\n- Never rewrite legacy completion evidence. Isolate only current failures before an explicit boundary with \`leerness verify-claim baseline create --before <T-ID> --yes\`.\n- A claims baseline applies only while the full tracker row and failure reasons match. Changed/new failures or a corrupt baseline fail closed; audit raw verdicts with \`verify-claim --all --raw\`.\n- The CLI never overwrites a created claims baseline. Approve removal or replacement of the tracked policy file as a separate code-reviewed change.\n`, 'en');
1346
1347
  _files['.leerness/AX_NEW_PROJECT_GUIDE.md'] = fm('ax-new-project-guide', ['new project detected'], ['fresh-install policy changes'], `# AX New Project Guide\n\nBefore coding, ask or infer the project goal, users, scope, out-of-scope, stack, deployment target, and milestones. Then fill plan.md and project-brief.md.\n`, 'en');
1347
1348
  _files['.leerness/AX_SKILL_LIBRARY_GUIDE.md'] = fm('ax-skill-library-guide', ['skill learning/verification/upload'], ['skill policy changes'], `# AX Skill Library Guide\n\nValidated skills require metadata, sensitive data scan, AI verification, dry-run publish, and explicit execute approval.\n`, 'en');
1348
1349
  _files['.leerness/skill-index.md'] = fm('skill-index', ['choosing skills per task'], ['skills added/removed'], `# Skill Index\n\n| ID | Korean Name | Capabilities | Last Updated | Verification |\n|---|---|---|---|---|\n${skillRows}\n`, 'en');
@@ -5552,20 +5553,40 @@ function _selfTestCases() {
5552
5553
  } },
5553
5554
  { name: '1.33.3 (verify-claim --all → gate+MCP): _verifyClaimsAll 코어(exit 없음) + gate --claims opt-in 6번째(기본 5 유지) + MCP leerness_verify_claim_all def↔case', run: () => {
5554
5555
  const src = read(__filename);
5555
- const coreDef = src.match(/function _verifyClaimsAll\(root\) \{[\s\S]*?\n\}/); // 함수 본문만 캡처(첫 줄머리 } 까지)
5556
- const core = !!coreDef && coreDef[0].includes('return { ok: failed.length === 0, total: doneRows.length, failed: failed.length, results };') && !/process\.exit\(/.test(coreDef[0]); // 코어는 절대 process.exit( 안 함(게이트 step 집계 보호)
5556
+ const coreDef = src.match(/function _verifyClaimsAll\(root, options = \{\}\) \{[\s\S]*?\n\}/); // 함수 본문만 캡처(첫 줄머리 } 까지)
5557
+ const core = !!coreDef && coreDef[0].includes('const raw = { ok: failed.length === 0, total: doneRows.length, failed: failed.length, results };')
5558
+ && coreDef[0].includes('_claimsBaseline.applyBaseline(rows, raw') && !/process\.exit\(/.test(coreDef[0]); // 코어는 절대 process.exit( 안 함(게이트 step 집계 보호)
5557
5559
  // 1.36.82: 헤더 문자열을 **정확 리터럴**로 잡으면 그 줄을 만질 때마다 자기 줄만 매칭해 조용히 통과한다
5558
5560
  // (이번 라운드에만 두 번 그렇게 됐다). 표현이 아니라 **불변식**을 본다:
5559
5561
  // 헤더가 withClaims 로 5/6 을 계산하고, --claims 일 때만 verify-claims 단계가 붙는다.
5560
5562
  const gateOptIn = src.includes("const withClaims = has('--claims');")
5561
5563
  && /# leerness gate \(\$\{[^}]*withClaims \? 6 : 5[^}]*\} checks\)/.test(src)
5562
- && src.includes("if (withClaims) step('verify-claims', () => { const r = _verifyClaimsAll(root);");
5563
- const reuse = src.includes('const res = _verifyClaimsAll(root);'); // CLI 도 코어 공유(분기 없음)
5564
+ && /if \(withClaims\) step\('verify-claims',[\s\S]{0,100}?const r = _verifyClaimsAll\(root\)/.test(src);
5565
+ const reuse = src.includes("const res = _verifyClaimsAll(root, { raw: has('--raw') });"); // CLI 도 코어 공유(분기 없음)
5564
5566
  const tools = require('../lib/mcp-tools');
5565
5567
  const def = tools.find(t => t.name === 'leerness_verify_claim_all');
5566
5568
  const mcpOk = !!def && def.requiredTier === 'read-only' && src.includes("case 'leerness_verify_claim_all':") && /case 'leerness_verify_claim_all':[\s\S]{0,160}'verify-claim', '--all'[\s\S]{0,80}'--json'/.test(src);
5567
5569
  return core && gateOptIn && reuse && mcpOk && tools.length >= 84;
5568
5570
  } },
5571
+ { name: '1.36.167 (T-0145): claims baseline은 경계 이전 exact 행+사유만 승인하고 새/변경 실패·손상은 fail-closed (행위)', run: () => {
5572
+ const b = require('../lib/claims-baseline');
5573
+ const row = (id, status, evidence, nextAction = 'next') => ({ id, status, request: `request ${id}`, evidence, nextAction, updated: '2026-08-26' });
5574
+ const rows = [row('T-0001', 'done', 'legacy prose'), row('T-0002', 'in-progress', 'boundary'), row('T-0003', 'done', 'new prose')];
5575
+ const raw = { ok: false, total: 2, failed: 2, results: [
5576
+ { id: 'T-0001', request: 'request T-0001', status: 'done', ok: false, reasons: ['unverifiable-claim'] },
5577
+ { id: 'T-0003', request: 'request T-0003', status: 'done', ok: false, reasons: ['unverifiable-claim'] },
5578
+ ] };
5579
+ const built = b.buildBaseline(rows, raw, { beforeTaskId: 'T-0002', version: VERSION, createdAt: '2026-08-26T00:00:00.000Z' });
5580
+ const valid = b.validateBaseline(built.doc).ok && built.doc.entries.length === 1;
5581
+ const applied = b.applyBaseline(rows, raw, { state: 'valid', file: 'fixture', doc: built.doc });
5582
+ const changedRows = [{ ...rows[0], nextAction: 'changed' }, rows[1], rows[2]];
5583
+ const changed = b.applyBaseline(changedRows, raw, { state: 'valid', file: 'fixture', doc: built.doc });
5584
+ const corrupt = b.validateBaseline({ ...built.doc, integrity: 'sha256:' + '0'.repeat(64) });
5585
+ return valid && applied.ok === false && applied.baselined === 1 && applied.failed === 1
5586
+ && applied.baseline.newFailures.includes('T-0003')
5587
+ && changed.baselined === 0 && changed.failed === 2 && changed.baseline.mismatched.includes('T-0001')
5588
+ && corrupt.ok === false && corrupt.problems.includes('integrity-mismatch');
5589
+ } },
5569
5590
  { name: '14th 버그헌트 P2 (UR-0178/0179/0180): completed→done 정규화 + rule archive _cellSafe + nextRuleId 아카이브 스캔 (1.11.3)', run: () => {
5570
5591
  if (_normTaskStatus('completed') !== 'done' || _normTaskStatus('verified') !== 'done' || _normTaskStatus('in-progress') !== 'in-progress') return false;
5571
5592
  const src = read(__filename);
@@ -10967,7 +10988,7 @@ function commandsCmd(root) {
10967
10988
  { cmd: 'scan secrets [path]', desc: '시크릿 탐지', descEn: 'secret detection' },
10968
10989
  { cmd: 'encoding check [path]', desc: '인코딩 검증', descEn: 'encoding verification' },
10969
10990
  { cmd: 'lazy detect [path] [--json]', desc: '게으른 작업 감지 (1.9.101)', descEn: 'detect lazy/incomplete work (1.9.101)' },
10970
- { cmd: 'verify-claim <T-ID|--all> [--run-tests] [--test-cmd "<명령>"] [--strict-claims] [--require-evidence]', cmdEn: 'verify-claim <T-ID|--all> [--run-tests] [--test-cmd "<command>"] [--strict-claims] [--require-evidence]', desc: '주장 검증 (1.9.18~26) — --all: 모든 done 주장 일괄 검증(CI·스케일, 1.33.2) · --require-evidence: done 주장에 파일+테스트 근거 강제 (1.9.287) · --test-cmd: 비-JS 테스트 명령 (1.17.2)', descEn: 'verify a claim (1.9.18~26) — --all: verify every done claim at once (CI/scale, 1.33.2) · --require-evidence: require file + test evidence on done claims (1.9.287) · --test-cmd: non-JS test command (1.17.2)' },
10991
+ { cmd: 'verify-claim <T-ID|--all> [--run-tests] [--strict-claims] [--raw] · baseline create --before <T-ID> --yes|show', cmdEn: 'verify-claim <T-ID|--all> [--run-tests] [--strict-claims] [--raw] · baseline create --before <T-ID> --yes|show', desc: '주장 검증 — --all은 명시적 legacy baseline 적용, --raw는 원판정 감사. baseline create는 경계 이전 exact 행+사유 실패만 격리하며 evidence를 수정하지 않음 (1.36.167)', descEn: 'claim verification — --all applies an explicit legacy baseline; --raw audits raw verdicts. baseline create isolates only exact row+reason failures before a boundary without editing evidence (1.36.167)' },
10971
10992
  { cmd: 'verify-code [path] [--build] [--bench] [--strict]', desc: '프로젝트 테스트·린트·타입검사 자동 감지 실행 + 검증 증거 기록', descEn: 'auto-detect and run project tests/lint/typecheck, then record verification evidence' },
10972
10993
  { cmd: 'contract verify <spec.md> <impl.js> [--allow-empty] [--json]', desc: '명세의 함수·필드 선언과 구현 export 정적 대조', descEn: 'statically compare functions and fields declared in a spec with implementation exports' },
10973
10994
  { cmd: `lens [${_lensDomainList()}] [--json]`, desc: '분야별 자기질문 품질 렌즈 + 분야간 인과관계 (1.18.3 · database/contract/recovery/observability 는 심화, axes 는 8축 경량 점검)', descEn: 'per-domain self-question quality lenses + cross-domain causality (1.18.3 · database/contract/recovery/observability are deep dives, axes is a light 8-axis check)' },
@@ -14628,11 +14649,15 @@ const _STRICT_COMMAND_FLAGS = {
14628
14649
  about: { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'about [--json]' },
14629
14650
  identity: { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'identity [--json]' },
14630
14651
  gate: { allowed: ['--claims', '--require-referee'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'gate [path] [--claims] [--require-referee <id>] [--json]' },
14652
+ 'verify-claim': { allowed: ['--all', '--run-tests', '--test-cmd', '--strict-claims', '--require-evidence', '--lenient', '--referee', '--raw'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'verify-claim <T-ID|--all> [--run-tests] [--strict-claims] [--raw] [--json]' },
14653
+ 'verify-claim baseline create': { allowed: ['--before', '--yes'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'verify-claim baseline create --before <T-ID> --yes [--json]' },
14654
+ 'verify-claim baseline show': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'verify-claim baseline show [--json]' },
14631
14655
  'verify-code': { allowed: ['--build', '--bench', '--strict'], globals: [..._COMMON_COMMAND_FLAGS], usage: 'verify-code [path] [--build] [--bench] [--strict]' },
14632
14656
  'contract verify': { allowed: ['--allow-empty'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'contract verify <spec.md> <impl.js> [--allow-empty] [--json]' },
14633
14657
  };
14634
14658
  function _validateCommandFlags(cmd, args) {
14635
- const route = cmd === 'contract' && args[1] === 'verify' ? 'contract verify' : cmd;
14659
+ let route = cmd === 'contract' && args[1] === 'verify' ? 'contract verify' : cmd;
14660
+ if (cmd === 'verify-claim' && args[1] === 'baseline') route = `verify-claim baseline ${args[2] || ''}`.trim();
14636
14661
  const cfg = _STRICT_COMMAND_FLAGS[route];
14637
14662
  if (!cfg) return true;
14638
14663
  return _rejectUnknownFlags(cfg.allowed, cfg.usage, { globals: cfg.globals });
@@ -18849,7 +18874,10 @@ function verifyClaimCmd(root, taskId, opts = {}) {
18849
18874
  // 1.34.1 (16th리뷰 정직화): 기본 게이트 5체크는 워크스페이스-상태 휴리스틱(handoff/test-run/evidence 부재 등)으로 거짓완료를 잡고, 콘텐츠-레벨 주장(파일 존재·테스트 카운트·스텁·optimism)은 검사하지 않음. verify-claim --all 은 그 콘텐츠 차원을 추가 — lazy detect 가 깨끗한(handoff·테스트기록 완비) 성숙 프로젝트에선 5체크가 통과해도 이 검사만 콘텐츠 거짓을 잡음(실증: gate 기본 exit 0 vs --claims exit 1).
18850
18875
  // 1.33.3: 일괄 검증 코어 — 렌더/exit 없이 결과만 반환. verifyClaimAllCmd(CLI 렌더+exit) 와 gate --claims(opt-in 체크) 가 공유.
18851
18876
  // gate 의 step() 은 process.exit 가 아니라 process.exitCode 로 실패를 감지하므로, 코어는 절대 process.exit 하지 않음(게이트 프로세스 조기종료 방지).
18852
- function _verifyClaimsAll(root) {
18877
+ // 1.36.167 (T-0145): 오래된 프로젝트의 검증기 도입 이전 완료 행을 소급 수정하지 않으면서도 새 실패를
18878
+ // 계속 차단한다. 기준선은 명시적 tracker 경계 이전의 **현재 실패만** 담고, 전체 행+사유 지문이 같을 때만
18879
+ // 정책상 승인한다. 행/사유 변경·새 실패·손상 파일은 fail-closed. --raw 는 원판정을 계속 감사한다.
18880
+ function _verifyClaimsAll(root, options = {}) {
18853
18881
  root = absRoot(root);
18854
18882
  const rows = readProgressRows(root);
18855
18883
  const doneRows = rows.filter(r => /done|완료|completed/i.test(String(r.status || '')));
@@ -18858,30 +18886,111 @@ function _verifyClaimsAll(root) {
18858
18886
  const runMemo = {};
18859
18887
  const results = doneRows.map(r => verifyClaimCmd(root, r.id, { collect: true, runMemo }));
18860
18888
  const failed = results.filter(x => x && !x.ok);
18861
- return { ok: failed.length === 0, total: doneRows.length, failed: failed.length, results };
18889
+ const raw = { ok: failed.length === 0, total: doneRows.length, failed: failed.length, results };
18890
+ return _claimsBaseline.applyBaseline(rows, raw, options.raw ? null : _claimsBaseline.loadBaseline(root), { raw: !!options.raw });
18862
18891
  }
18863
18892
  function verifyClaimAllCmd(root) {
18864
18893
  root = absRoot(root);
18865
18894
  const _j = has('--json');
18866
18895
  const _L = _uiLang(root); const t = (ko, en) => (_L === 'en' ? en : ko);
18867
- const res = _verifyClaimsAll(root);
18896
+ const res = _verifyClaimsAll(root, { raw: has('--raw') });
18868
18897
  const { results, total } = res;
18869
18898
  if (_j) {
18870
- log(JSON.stringify({ ok: res.ok, root: path.basename(root), total, failed: res.failed, results }, null, 2));
18871
- if (res.failed) process.exitCode = 1;
18899
+ log(JSON.stringify({ ok: res.ok, root: path.basename(root), total, failed: res.failed, rawFailed: res.rawFailed, baselined: res.baselined, baseline: res.baseline, errors: res.errors, results }, null, 2));
18900
+ if (!res.ok) process.exitCode = 1;
18872
18901
  return;
18873
18902
  }
18874
- log(`# verify-claim --all (${path.basename(root)})`);
18903
+ log(`# verify-claim --all${has('--raw') ? ' --raw' : ''} (${path.basename(root)})`);
18904
+ if (res.errors && res.errors.length) {
18905
+ fail(t(`claims baseline 사용 불가: ${res.errors.join(', ')} (${(res.baseline && res.baseline.problems || []).join(', ') || 'context invalid'})`, `claims baseline unusable: ${res.errors.join(', ')} (${(res.baseline && res.baseline.problems || []).join(', ') || 'context invalid'})`));
18906
+ return process.exit(1);
18907
+ }
18875
18908
  if (!total) { log(t(' 완료(done) 주장이 없어 검증할 항목이 없습니다.', ' No completed (done) claims to verify.')); return; }
18876
- log(t(` 완료 주장 ${total}건 검증 — 통과 ${total - res.failed} · 실패 ${res.failed}`, ` Verified ${total} completed claim(s) — pass ${total - res.failed} · fail ${res.failed}`));
18909
+ log(t(` 완료 주장 ${total}건 — 원판정 실패 ${res.rawFailed} · 레거시 부채 승인 ${res.baselined} · 현재 차단 ${res.failed}`, ` ${total} completed claim(s) — raw failures ${res.rawFailed} · accepted legacy debt ${res.baselined} · blocking now ${res.failed}`));
18877
18910
  log('');
18878
18911
  for (const r of results) {
18879
- if (r.ok) log(` ✓ ${r.id} ${String(r.request || '').slice(0, 60)}`);
18912
+ if (r && r.baselineAccepted) log(` ≈ ${r.id} ${String(r.request || '').slice(0, 48)} ${t('← 레거시 부채', '← legacy debt')}: ${(r.baselineReasons || []).join(', ')}`);
18913
+ else if (r.ok) log(` ✓ ${r.id} ${String(r.request || '').slice(0, 60)}`);
18880
18914
  else log(` ✗ ${r.id} ${String(r.request || '').slice(0, 50)} ${t('← 불일치', '← mismatch')}: ${r.reasons.join(', ')}`);
18881
18915
  }
18882
18916
  log('');
18883
18917
  if (res.failed) { log(t(` ⚠ ${res.failed}건의 완료 주장이 증거와 불일치 — 재검토 권장 (개별 상세: leerness verify-claim <T-ID>)`, ` ⚠ ${res.failed} completed claim(s) do not match evidence — review (per-task detail: leerness verify-claim <T-ID>)`)); return process.exit(1); }
18884
- log(t(` ✓ 모든 완료 주장이 증거와 일치`, ` ✓ all completed claims match evidence`));
18918
+ if (res.baselined) log(t(` ✓ 신규·변경된 완료 주장에 차단 실패 없음 (레거시 부채 ${res.baselined}건은 baseline에 격리; --raw로 원판정 감사)`, ` ✓ no blocking failure in new or changed claims (${res.baselined} legacy debt item(s) isolated by baseline; audit raw verdicts with --raw)`));
18919
+ else log(t(` ✓ 모든 완료 주장이 증거와 일치`, ` ✓ all completed claims match evidence`));
18920
+ }
18921
+
18922
+ function claimBaselineCmd(root, sub) {
18923
+ root = absRoot(root);
18924
+ const json = has('--json');
18925
+ const _L = _uiLang(root); const t = (ko, en) => (_L === 'en' ? en : ko);
18926
+ const loaded = _claimsBaseline.loadBaseline(root);
18927
+ if (sub === 'show') {
18928
+ if (loaded.state === 'invalid') {
18929
+ failJson(json, 'baseline_invalid', t(`claims-baseline.json 손상/형상 무효: ${(loaded.problems || []).join(', ')}`, `claims-baseline.json is corrupt or invalid: ${(loaded.problems || []).join(', ')}`));
18930
+ return;
18931
+ }
18932
+ if (json) {
18933
+ log(JSON.stringify({ ok: true, state: loaded.state, path: loaded.file, baseline: loaded.doc }, null, 2));
18934
+ return;
18935
+ }
18936
+ if (loaded.state === 'absent') { log(t('claims baseline 없음', 'no claims baseline')); return; }
18937
+ log(`# claims baseline (${path.basename(root)})`);
18938
+ log(t(` 경계: ${loaded.doc.beforeTaskId} 이전 · 부채 ${loaded.doc.entries.length}건 · 생성 ${loaded.doc.createdAt}`, ` boundary: before ${loaded.doc.beforeTaskId} · debt ${loaded.doc.entries.length} · created ${loaded.doc.createdAt}`));
18939
+ return;
18940
+ }
18941
+ if (sub !== 'create') {
18942
+ failJson(json, 'unknown_subcommand', t('사용법: leerness verify-claim baseline create --before <T-ID> --yes | baseline show', 'usage: leerness verify-claim baseline create --before <T-ID> --yes | baseline show'));
18943
+ return;
18944
+ }
18945
+ if (!has('--yes')) {
18946
+ failJson(json, 'confirmation_required', t('레거시 실패를 정책 부채로 승인하려면 --yes가 필요합니다 (evidence 원문은 수정하지 않음)', '--yes is required to acknowledge legacy failures as policy debt (historical evidence is not modified)'));
18947
+ return;
18948
+ }
18949
+ const beforeTaskId = arg('--before', null);
18950
+ if (!beforeTaskId || beforeTaskId === true) {
18951
+ failJson(json, 'missing_boundary', t('--before <T-ID>가 필요합니다. 이 경계와 이후의 실패는 절대 baseline에 포함되지 않습니다.', '--before <T-ID> is required. Failures at or after that boundary are never baselined.'));
18952
+ return;
18953
+ }
18954
+ if (loaded.state === 'invalid') {
18955
+ failJson(json, 'baseline_invalid', t(`기존 claims-baseline.json이 손상되어 덮어쓰지 않습니다: ${(loaded.problems || []).join(', ')}`, `existing claims-baseline.json is invalid and will not be overwritten: ${(loaded.problems || []).join(', ')}`));
18956
+ return;
18957
+ }
18958
+ if (loaded.state === 'valid') {
18959
+ failJson(json, 'baseline_exists', t('기존 claims-baseline.json은 불변 정책으로 취급되어 다시 계산하거나 덮어쓰지 않습니다. 새 정책이 필요하면 tracked 파일 변경을 별도 코드리뷰로 승인하세요.', 'existing claims-baseline.json is treated as immutable policy and will not be recalculated or overwritten. Approve any tracked policy-file change in a separate code review.'));
18960
+ return;
18961
+ }
18962
+ const rows = readProgressRows(root);
18963
+ const raw = _verifyClaimsAll(root, { raw: true });
18964
+ let built;
18965
+ try { built = _claimsBaseline.buildBaseline(rows, raw, { beforeTaskId, version: VERSION, createdAt: now() }); }
18966
+ catch (error) {
18967
+ failJson(json, error && error.code === 'E_CLAIMS_BASELINE_BOUNDARY' ? 'boundary_not_found' : 'baseline_create_failed', error.message);
18968
+ return;
18969
+ }
18970
+ if (!built.doc.entries.length) {
18971
+ failJson(json, 'nothing_to_baseline', t(`${beforeTaskId} 이전에 실패한 완료 주장이 없습니다. 빈 기준선은 만들지 않습니다.`, `there are no failed completed claims before ${beforeTaskId}; an empty baseline was not created.`));
18972
+ return;
18973
+ }
18974
+ let file;
18975
+ try { file = _claimsBaseline.saveBaseline(root, built.doc, _withLock); }
18976
+ catch (error) {
18977
+ failJson(json, error && error.code === 'E_CLAIMS_BASELINE_EXISTS' ? 'baseline_exists' : 'baseline_write_failed', error.message);
18978
+ return;
18979
+ }
18980
+ const out = {
18981
+ ok: true,
18982
+ path: file,
18983
+ beforeTaskId,
18984
+ evaluatedBefore: built.stats.evaluatedBefore,
18985
+ baselined: built.stats.baselined,
18986
+ blockingAtOrAfterBoundary: built.stats.failuresAtOrAfterBoundary,
18987
+ historicalEvidenceModified: false,
18988
+ integrity: built.doc.integrity,
18989
+ replaced: false,
18990
+ };
18991
+ if (json) { log(JSON.stringify(out, null, 2)); return; }
18992
+ ok(t(`claims baseline 생성: ${built.stats.baselined}건 격리 · ${beforeTaskId}와 이후는 제외`, `claims baseline created: ${built.stats.baselined} isolated · ${beforeTaskId} and later excluded`));
18993
+ log(t(` evidence 원문 수정 0 · 행/사유 변경 시 면제 무효 · 새 실패 차단 ${out.blockingAtOrAfterBoundary}건`, ` historical evidence edits 0 · any row/reason change invalidates its exemption · ${out.blockingAtOrAfterBoundary} new failure(s) still block`));
18885
18994
  }
18886
18995
 
18887
18996
  // 1.9.22: orchestrate — Ollama 로컬 LLM으로 best-of-N 멀티 에이전트 시뮬
@@ -21040,7 +21149,21 @@ function gate(root) {
21040
21149
  step('encoding check', () => encodingCheck(root));
21041
21150
  step('lazy detect', () => lazyDetect(root));
21042
21151
  // 1.33.3: opt-in 정밀 per-claim 검증. 코어(_verifyClaimsAll)는 exit 안 하고 결과만 반환 → step 의 exitCode 감지로 실패 집계. 비-json 모드는 불일치 task 를 fail() 로 표면화.
21043
- if (withClaims) step('verify-claims', () => { const r = _verifyClaimsAll(root); if (!r.ok) { process.exitCode = 1; if (!jsonMode) r.results.filter(x => x && !x.ok).forEach(x => fail(`${x.id} 불일치: ${x.reasons.join(', ')}`)); } });
21152
+ // 1.36.167: 기준선이 있으면 정확히 일치하는 과거 실패만 별도 부채로 승인한다. JSON은 원판정/승인/현재차단을
21153
+ // 모두 노출해 "실패가 사라졌다"고 오해하지 않게 하고, 손상 baseline은 별도 오류로 fail-closed한다.
21154
+ if (withClaims) step('verify-claims', () => {
21155
+ const r = _verifyClaimsAll(root);
21156
+ _stepDetail = { total: r.total, failed: r.failed, rawFailed: r.rawFailed, baselined: r.baselined, baselineState: r.baseline && r.baseline.state, baseline: r.baseline, ...(r.errors && r.errors.length ? { errors: r.errors } : {}) };
21157
+ if (!r.ok) {
21158
+ process.exitCode = 1;
21159
+ if (!jsonMode) {
21160
+ if (r.errors && r.errors.length) fail(`claims baseline 사용 불가: ${r.errors.join(', ')} (${(r.baseline && r.baseline.problems || []).join(', ')})`);
21161
+ r.results.filter(x => x && !x.ok).forEach(x => fail(`${x.id} 불일치: ${x.reasons.join(', ')}`));
21162
+ }
21163
+ } else if (!jsonMode && r.baselined) {
21164
+ log(` ⓘ 레거시 부채 ${r.baselined}건은 지문 일치 baseline으로 격리됨 (원판정 실패 ${r.rawFailed}건 · --raw 감사 가능)`);
21165
+ }
21166
+ });
21044
21167
  // 1.36.82 (공허한 가드 스윕 High #2, 실측 확인): `referee verify` 성공 시 툴이 사용자에게
21045
21168
  // "이제 gate --require-referee <id> 가 이 검증기를 신뢰합니다" 라고 **안내하는데 gate 는 이 플래그를 읽지도 않았다**.
21046
21169
  // 사용자는 안내받은 명령을 CI 에 넣고 검증기가 머지를 막는다고 믿지만 아무 일도 일어나지 않았고,
@@ -24964,7 +25087,7 @@ function _mcpToCliArgs(name, args, targetPath) {
24964
25087
  case 'leerness_drift_check': cliArgs = ['drift', 'check', targetPath, '--json']; break;
24965
25088
  case 'leerness_audit': cliArgs = ['audit', targetPath, '--json', ...(args.fix ? ['--fix'] : []), ...(args.strict ? ['--strict'] : [])]; break;
24966
25089
  case 'leerness_verify_claim': cliArgs = ['verify-claim', args.taskId, '--path', targetPath, ...(args.refereeId ? ['--referee', String(args.refereeId)] : []), ...(args.runTests ? ['--run-tests'] : []), ...(args.strictClaims ? ['--strict-claims'] : []), ...(args.lenient ? ['--lenient'] : [])]; break;
24967
- case 'leerness_verify_claim_all': cliArgs = ['verify-claim', '--all', '--path', targetPath, '--json', ...(args.runTests ? ['--run-tests'] : []), ...(args.strictClaims ? ['--strict-claims'] : []), ...(args.lenient ? ['--lenient'] : [])]; break; // 1.33.3: 모든 done 주장 일괄 검증(--json 강제 → process.exitCode 만, 하드 exit 없음)
25090
+ case 'leerness_verify_claim_all': cliArgs = ['verify-claim', '--all', '--path', targetPath, '--json', ...(args.runTests ? ['--run-tests'] : []), ...(args.strictClaims ? ['--strict-claims'] : []), ...(args.lenient ? ['--lenient'] : []), ...(args.raw ? ['--raw'] : [])]; break; // 1.33.3/1.36.167: 일괄검증 + 원판정 감사
24968
25091
  case 'leerness_contract_verify': cliArgs = ['contract', 'verify', args.spec, args.impl]; break;
24969
25092
  case 'leerness_agents_list': cliArgs = ['agents', 'list', '--json']; break;
24970
25093
  case 'leerness_reuse_map': cliArgs = ['reuse-map', targetPath, ...(args.allApps ? ['--all-apps'] : []), ...(args.strictElements ? ['--strict-elements'] : []), '--json']; break;
@@ -29726,8 +29849,10 @@ STATUS & DIAGNOSTICS
29726
29849
  whats-new [path] Recent version changes
29727
29850
 
29728
29851
  VERIFICATION (evidence-gated "done")
29729
- verify-claim <T-ID> [--run-tests] [--test-cmd "..."] [--json]
29730
- Check evidence vs reality (stub / fake-test / inflated-count detection)
29852
+ verify-claim <T-ID|--all> [--run-tests] [--raw] [--json]
29853
+ Check evidence vs reality; --raw ignores a legacy baseline
29854
+ verify-claim baseline create --before <T-ID> --yes | baseline show
29855
+ Isolate fingerprint-bound legacy debt without editing evidence
29731
29856
  contract verify <spec.md> <impl.js> [--json] [--allow-empty] Spec <-> implementation match (empty spec rejected unless --allow-empty)
29732
29857
  verify-code [path] [--build] [--bench] Run tests/lint/typecheck, record evidence
29733
29858
  gate [path] One-call CI gate: verify + audit + scan + encoding + lazy
@@ -30326,7 +30451,13 @@ async function main() {
30326
30451
  // 측정이 관측 대상을 바꾸면 안 된다.
30327
30452
  if (cmd === 'handoff') { const _hp = arg('--path', args[1] || process.cwd()); const _hr = handoffCmd(_hp); if (has('--writeback') && process.env.LEERNESS_HOOK !== '1') { try { _tech.refreshTechProfile(_hp); } catch {} _maybeAutoGraph(_hp); } return _hr; } // T-0136: 기본은 추적 파일 쓰기 0. 명시적 --writeback 만 기존 프로필/그래프 투영을 갱신한다.
30328
30453
  if (cmd === 'reuse-map') return reuseMapCmd(arg('--path', args[1] || process.cwd()));
30329
- if (cmd === 'verify-claim') { const _p = arg('--path', process.cwd()); if (args[1] === '--all' || has('--all')) return verifyClaimAllCmd(_p); return verifyClaimCmd(_p, args[1]); } // 1.33.2: --all → 모든 done 주장 일괄 검증
30454
+ if (cmd === 'verify-claim') {
30455
+ const _p = arg('--path', process.cwd());
30456
+ if (args[1] === 'baseline') return claimBaselineCmd(_p, args[2]);
30457
+ if (args[1] === '--all' || has('--all')) return verifyClaimAllCmd(_p);
30458
+ if (has('--raw')) return failJson(has('--json'), 'raw_requires_all', '--raw 는 verify-claim --all 에서만 사용합니다. 개별 task 판정은 항상 원판정입니다.');
30459
+ return verifyClaimCmd(_p, args[1]);
30460
+ } // 1.33.2/1.36.167: --all 일괄검증 + 명시적 legacy baseline
30330
30461
  if (cmd === 'orchestrate') return await orchestrateCmd(arg('--path', process.cwd()), args.slice(1).filter(x => !x.startsWith('-')));
30331
30462
  if (cmd === 'llm-bench' && args[1] === 'record') return llmBenchRecordCmd(arg('--path', process.cwd()));
30332
30463
  if (cmd === 'deps') return depsImpactCmd(arg('--path', process.cwd()), args[1]);
@@ -0,0 +1,272 @@
1
+ // lib/claims-baseline.js — explicit, fingerprint-bound legacy claim debt.
2
+ //
3
+ // A baseline never edits historical evidence and never makes a new failure pass.
4
+ // It can acknowledge only failures that existed before an explicit tracker-row
5
+ // boundary, and only while the complete row plus the verifier reasons remain
6
+ // byte-for-byte equivalent after canonical JSON normalization.
7
+ 'use strict';
8
+
9
+ const crypto = require('crypto');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { absRoot, exists, read, mkdirp, writeUtf8 } = require('./io');
13
+
14
+ const SCHEMA_VERSION = 1;
15
+ const KIND = 'leerness-claims-baseline';
16
+ const POLICY = 'exact-progress-row-and-reasons';
17
+ const FILE_NAME = 'claims-baseline.json';
18
+
19
+ function baselinePath(root) {
20
+ return path.join(absRoot(root), '.leerness', FILE_NAME);
21
+ }
22
+
23
+ function _canonical(value) {
24
+ if (Array.isArray(value)) return '[' + value.map(_canonical).join(',') + ']';
25
+ if (value && typeof value === 'object') {
26
+ return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + _canonical(value[k])).join(',') + '}';
27
+ }
28
+ return JSON.stringify(value);
29
+ }
30
+
31
+ function _sha256(value) {
32
+ return crypto.createHash('sha256').update(_canonical(value)).digest('hex');
33
+ }
34
+
35
+ function _normalizedRow(row) {
36
+ return {
37
+ id: String(row && row.id || ''),
38
+ status: String(row && row.status || ''),
39
+ request: String(row && row.request || ''),
40
+ evidence: String(row && row.evidence || ''),
41
+ nextAction: String(row && row.nextAction || ''),
42
+ updated: String(row && row.updated || ''),
43
+ };
44
+ }
45
+
46
+ function normalizeReasons(reasons) {
47
+ return Array.from(new Set((Array.isArray(reasons) ? reasons : [])
48
+ .map(x => String(x || '').trim()).filter(Boolean))).sort();
49
+ }
50
+
51
+ function claimFingerprint(row, reasons) {
52
+ return _sha256({ row: _normalizedRow(row), reasons: normalizeReasons(reasons) });
53
+ }
54
+
55
+ function _withoutIntegrity(doc) {
56
+ const out = { ...doc };
57
+ delete out.integrity;
58
+ return out;
59
+ }
60
+
61
+ function _integrity(doc) {
62
+ return 'sha256:' + _sha256(_withoutIntegrity(doc));
63
+ }
64
+
65
+ function validateBaseline(doc) {
66
+ const problems = [];
67
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { ok: false, problems: ['root-not-object'] };
68
+ if (doc.schemaVersion !== SCHEMA_VERSION) problems.push('unsupported-schema');
69
+ if (doc.kind !== KIND) problems.push('wrong-kind');
70
+ if (doc.policy !== POLICY) problems.push('wrong-policy');
71
+ if (typeof doc.createdAt !== 'string' || Number.isNaN(Date.parse(doc.createdAt))) problems.push('invalid-created-at');
72
+ if (typeof doc.createdByVersion !== 'string' || !doc.createdByVersion.trim()) problems.push('invalid-created-by-version');
73
+ if (typeof doc.beforeTaskId !== 'string' || !/^[A-Z]+-\d{3,}$/.test(doc.beforeTaskId)) problems.push('invalid-before-task');
74
+ if (!Number.isInteger(doc.evaluatedBefore) || doc.evaluatedBefore < 0) problems.push('invalid-evaluated-before');
75
+ if (!Number.isInteger(doc.rawFailedBefore) || doc.rawFailedBefore < 0) problems.push('invalid-raw-failed-before');
76
+ if (!Array.isArray(doc.entries)) problems.push('entries-not-array');
77
+ const ids = new Set();
78
+ for (const entry of Array.isArray(doc.entries) ? doc.entries : []) {
79
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { problems.push('invalid-entry'); continue; }
80
+ if (typeof entry.id !== 'string' || !/^[A-Z]+-\d{3,}$/.test(entry.id)) problems.push('invalid-entry-id');
81
+ else if (ids.has(entry.id)) problems.push('duplicate-entry-id');
82
+ else ids.add(entry.id);
83
+ if (!/^[a-f0-9]{64}$/.test(String(entry.fingerprint || ''))) problems.push('invalid-entry-fingerprint');
84
+ const reasons = normalizeReasons(entry.reasons);
85
+ const reasonsAreCanonical = Array.isArray(entry.reasons)
86
+ && entry.reasons.every(reason => typeof reason === 'string' && reason.trim())
87
+ && JSON.stringify(entry.reasons) === JSON.stringify(reasons);
88
+ if (!reasons.length || !reasonsAreCanonical) problems.push('invalid-entry-reasons');
89
+ }
90
+ if (doc.rawFailedBefore !== (Array.isArray(doc.entries) ? doc.entries.length : -1)) problems.push('failed-count-mismatch');
91
+ if (!/^sha256:[a-f0-9]{64}$/.test(String(doc.integrity || ''))) problems.push('invalid-integrity-format');
92
+ else if (doc.integrity !== _integrity(doc)) problems.push('integrity-mismatch');
93
+ return { ok: problems.length === 0, problems: Array.from(new Set(problems)) };
94
+ }
95
+
96
+ function loadBaseline(root) {
97
+ const file = baselinePath(root);
98
+ if (!exists(file)) return { state: 'absent', file, doc: null, problems: [] };
99
+ let doc;
100
+ try { doc = JSON.parse(read(file)); }
101
+ catch (error) { return { state: 'invalid', file, doc: null, problems: ['invalid-json'], error: error.message }; }
102
+ const checked = validateBaseline(doc);
103
+ if (!checked.ok) return { state: 'invalid', file, doc, problems: checked.problems };
104
+ return { state: 'valid', file, doc, problems: [] };
105
+ }
106
+
107
+ function buildBaseline(rows, rawResult, options = {}) {
108
+ const beforeTaskId = String(options.beforeTaskId || '');
109
+ const boundaryIndex = rows.findIndex(row => row.id === beforeTaskId);
110
+ if (boundaryIndex < 0) {
111
+ const error = new Error(`progress-tracker boundary not found: ${beforeTaskId}`);
112
+ error.code = 'E_CLAIMS_BASELINE_BOUNDARY';
113
+ throw error;
114
+ }
115
+ const eligibleRows = rows.slice(0, boundaryIndex).filter(row => /done|완료|completed/i.test(String(row.status || '')));
116
+ const eligible = new Map(eligibleRows.map(row => [row.id, row]));
117
+ const failures = (rawResult.results || []).filter(result => result && !result.ok && eligible.has(result.id));
118
+ const entries = failures.map(result => ({
119
+ id: result.id,
120
+ fingerprint: claimFingerprint(eligible.get(result.id), result.reasons),
121
+ reasons: normalizeReasons(result.reasons),
122
+ }));
123
+ const doc = {
124
+ schemaVersion: SCHEMA_VERSION,
125
+ kind: KIND,
126
+ createdAt: String(options.createdAt || new Date().toISOString()),
127
+ createdByVersion: String(options.version || 'unknown'),
128
+ beforeTaskId,
129
+ policy: POLICY,
130
+ evaluatedBefore: eligibleRows.length,
131
+ rawFailedBefore: entries.length,
132
+ entries,
133
+ };
134
+ doc.integrity = _integrity(doc);
135
+ return {
136
+ doc,
137
+ stats: {
138
+ evaluatedBefore: eligibleRows.length,
139
+ baselined: entries.length,
140
+ failuresAtOrAfterBoundary: (rawResult.results || []).filter(result => result && !result.ok && !eligible.has(result.id)).length,
141
+ },
142
+ };
143
+ }
144
+
145
+ function saveBaseline(root, doc, withLock) {
146
+ const checked = validateBaseline(doc);
147
+ if (!checked.ok) {
148
+ const error = new Error(`refusing to write invalid claims baseline: ${checked.problems.join(', ')}`);
149
+ error.code = 'E_CLAIMS_BASELINE_INVALID';
150
+ throw error;
151
+ }
152
+ const file = baselinePath(root);
153
+ const write = () => {
154
+ mkdirp(path.dirname(file));
155
+ if (exists(file)) {
156
+ const error = new Error(`claims baseline already exists and will not be overwritten: ${file}`);
157
+ error.code = 'E_CLAIMS_BASELINE_EXISTS';
158
+ throw error;
159
+ }
160
+ const temp = file + `.tmp-${process.pid}-${Date.now()}`;
161
+ writeUtf8(temp, JSON.stringify(doc, null, 2) + '\n');
162
+ try { fs.renameSync(temp, file); }
163
+ catch (error) {
164
+ try { fs.rmSync(temp, { force: true }); } catch {}
165
+ throw error;
166
+ }
167
+ };
168
+ if (typeof withLock === 'function') withLock(file, write);
169
+ else write();
170
+ return file;
171
+ }
172
+
173
+ function _rawResult(rawResult, state, extra = {}) {
174
+ return {
175
+ ...rawResult,
176
+ rawFailed: rawResult.failed,
177
+ baselined: 0,
178
+ baseline: { state, ...extra },
179
+ errors: [],
180
+ };
181
+ }
182
+
183
+ function applyBaseline(rows, rawResult, loaded, options = {}) {
184
+ if (options.raw) return _rawResult(rawResult, 'ignored');
185
+ if (!loaded || loaded.state === 'absent') return _rawResult(rawResult, 'absent', { file: loaded && loaded.file });
186
+ if (loaded.state !== 'valid') {
187
+ return {
188
+ ..._rawResult(rawResult, 'invalid', { file: loaded.file, problems: loaded.problems || [] }),
189
+ ok: false,
190
+ errors: ['baseline-invalid'],
191
+ };
192
+ }
193
+
194
+ const doc = loaded.doc;
195
+ const duplicateIds = rows.map(row => row.id).filter((id, index, all) => all.indexOf(id) !== index);
196
+ const boundaryIndex = rows.findIndex(row => row.id === doc.beforeTaskId);
197
+ const rowIndex = new Map(rows.map((row, index) => [row.id, index]));
198
+ const outOfScope = doc.entries.filter(entry => !rowIndex.has(entry.id) || rowIndex.get(entry.id) >= boundaryIndex).map(entry => entry.id);
199
+ if (boundaryIndex < 0 || duplicateIds.length || outOfScope.length) {
200
+ const problems = [
201
+ ...(boundaryIndex < 0 ? ['boundary-missing'] : []),
202
+ ...(duplicateIds.length ? ['duplicate-progress-id'] : []),
203
+ ...(outOfScope.length ? ['entry-outside-boundary'] : []),
204
+ ];
205
+ return {
206
+ ..._rawResult(rawResult, 'invalid-context', { file: loaded.file, problems, outOfScope: outOfScope.slice(0, 20) }),
207
+ ok: false,
208
+ errors: ['baseline-invalid-context'],
209
+ };
210
+ }
211
+
212
+ const rowsById = new Map(rows.map(row => [row.id, row]));
213
+ const entriesById = new Map(doc.entries.map(entry => [entry.id, entry]));
214
+ let accepted = 0;
215
+ const mismatched = [];
216
+ const newFailures = [];
217
+ const acceptedIds = new Set();
218
+ const results = (rawResult.results || []).map(result => {
219
+ if (!result || result.ok) return result;
220
+ const entry = entriesById.get(result.id);
221
+ if (!entry) { newFailures.push(result.id); return result; }
222
+ const fingerprint = claimFingerprint(rowsById.get(result.id), result.reasons);
223
+ if (fingerprint !== entry.fingerprint) {
224
+ mismatched.push(result.id);
225
+ return { ...result, baselineAccepted: false, baselineMismatch: true };
226
+ }
227
+ accepted++;
228
+ acceptedIds.add(result.id);
229
+ return {
230
+ ...result,
231
+ ok: true,
232
+ rawOk: false,
233
+ reasons: [],
234
+ baselineAccepted: true,
235
+ baselineReasons: normalizeReasons(result.reasons),
236
+ };
237
+ });
238
+ const failed = results.filter(result => result && !result.ok).length;
239
+ const inactiveEntries = doc.entries.filter(entry => !acceptedIds.has(entry.id) && !mismatched.includes(entry.id)).map(entry => entry.id);
240
+ return {
241
+ ok: failed === 0,
242
+ total: rawResult.total,
243
+ failed,
244
+ rawFailed: rawResult.failed,
245
+ baselined: accepted,
246
+ results,
247
+ baseline: {
248
+ state: 'applied',
249
+ file: loaded.file,
250
+ beforeTaskId: doc.beforeTaskId,
251
+ entries: doc.entries.length,
252
+ accepted,
253
+ mismatched: mismatched.slice(0, 20),
254
+ newFailures: newFailures.slice(0, 20),
255
+ inactiveEntries: inactiveEntries.slice(0, 20),
256
+ },
257
+ errors: [],
258
+ };
259
+ }
260
+
261
+ module.exports = {
262
+ SCHEMA_VERSION,
263
+ FILE_NAME,
264
+ baselinePath,
265
+ normalizeReasons,
266
+ claimFingerprint,
267
+ validateBaseline,
268
+ loadBaseline,
269
+ buildBaseline,
270
+ saveBaseline,
271
+ applyBaseline,
272
+ };
package/lib/mcp-tools.js CHANGED
@@ -8,7 +8,7 @@ module.exports = [
8
8
  { name: 'leerness_drift_check', requiredTier: 'read-only', description: '1.9.136 — AI 에이전트 leerness 미사용 drift 자동 감지 JSON ({ root, score, level, signals[], healthy }). 5+ 신호 + 4단계 레벨 (🟢 healthy / 🟡 warning / 🟠 caution / 🔴 critical). 보안 신호 통합 (1.9.78)', inputSchema: { type: 'object', properties: { path: { type: 'string' } } } },
9
9
  { name: 'leerness_audit', requiredTier: 'safe-write', description: '1.9.102 — 워크스페이스 일관성 감사 JSON (warnings/failures/fixed/healthy + findings[]. kind 11종: design_dup/design_system_default/reuse_map_empty/milestone_unlinked/handoff_not_generated/current_state_stale/readme_version_mismatch/npm_cve/gitignore_missing_secrets/env_keys_missing/strict_promoted)', inputSchema: { type: 'object', properties: { path: { type: 'string' }, fix: { type: 'boolean' }, strict: { type: 'boolean' } } } },
10
10
  { name: 'leerness_verify_claim', requiredTier: 'read-only', description: 'AI 거짓 완료 자동 검증 (evidence 파일 + 실 테스트 실행). 1.9.309(UR-0048): done/완료 주장은 evidence(수정파일+테스트) 기본 강제 — 증거 없는 done 은 FAIL(exit 1). lenient=true 로 완화, runTests/strictClaims 추가 가능. 응답 verdict.evidenceComplete 포함.', inputSchema: { type: 'object', properties: { taskId: { type: 'string' }, path: { type: 'string' }, runTests: { type: 'boolean' }, strictClaims: { type: 'boolean' }, lenient: { type: 'boolean' }, refereeId: { type: 'string' } }, required: ['taskId'] } },
11
- { name: 'leerness_verify_claim_all', requiredTier: 'read-only', description: '1.33.3 — 모든 done/완료 주장을 한 번에 검증(CI·스케일). progress-tracker 의 done 항목 전부를 verify-claim 정밀 검사(파일 존재·스텁·부풀린 카운트·증거 완전성)로 일괄 점검. 응답 { ok, total, failed, results:[{id,ok,reasons}] }. 세션 마감 전 "내 완료 주장 전부 증거와 맞는가?" 자가 점검용. runTests/strictClaims/lenient 추가 가능.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, runTests: { type: 'boolean' }, strictClaims: { type: 'boolean' }, lenient: { type: 'boolean' } } } },
11
+ { name: 'leerness_verify_claim_all', requiredTier: 'read-only', description: '1.33.3/1.36.167 — 모든 done/완료 주장을 한 번에 검증(CI·스케일). 명시적 claims baseline이 있으면 exact 행+실패사유 지문이 같은 경계 이전 레거시 부채만 격리하고 새/변경 실패는 계속 차단한다. 응답 { ok, total, failed, rawFailed, baselined, baseline, results }. raw=true면 baseline을 무시하고 원판정을 감사한다.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, runTests: { type: 'boolean' }, strictClaims: { type: 'boolean' }, lenient: { type: 'boolean' }, raw: { type: 'boolean' } } } },
12
12
  { name: 'leerness_contract_verify', requiredTier: 'read-only', description: '명세 ↔ 구현 함수/필드 일치 자동 검사', inputSchema: { type: 'object', properties: { spec: { type: 'string' }, impl: { type: 'string' } }, required: ['spec', 'impl'] } },
13
13
  { name: 'leerness_agents_list', requiredTier: 'read-only', description: '외부 AI CLI 가용성 표 (claude/codex/agy/copilot 상태 + 환경변수 활성화 여부)', inputSchema: { type: 'object', properties: {} } },
14
14
  { name: 'leerness_reuse_map', requiredTier: 'read-only', description: '워크스페이스 중복 함수/capability 자동 감지 (--all-apps + fuzzy 매칭)', inputSchema: { type: 'object', properties: { path: { type: 'string' }, allApps: { type: 'boolean' }, strictElements: { type: 'boolean' } } } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.166",
3
+ "version": "1.36.167",
4
4
  "description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
5
5
  "keywords": [
6
6
  "leerness",
@@ -50,7 +50,7 @@
50
50
  "scripts": {
51
51
  "test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
52
52
  "test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js",
53
- "test:commands": "node ./scripts/command-flags-probe.js && node ./scripts/e2e-command-surface.js",
53
+ "test:commands": "node ./scripts/command-flags-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js",
54
54
  "test:installed": "node ./scripts/installed-cleanroom-probe.js",
55
55
  "test:handoff": "node ./scripts/handoff-readonly-probe.js",
56
56
  "test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./scripts/smoke.js",
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // T-0145 multi-session regression: two first-time creators may both observe
5
+ // "absent", but the create-only check inside the file lock must let exactly
6
+ // one policy win and must never overwrite that winner.
7
+ const crypto = require('crypto');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const cp = require('child_process');
12
+
13
+ const CLI = path.resolve(__dirname, '..', 'bin', 'leerness.js');
14
+ const env = {
15
+ ...process.env,
16
+ LEERNESS_OFFLINE: '1',
17
+ LEERNESS_NO_PROMPT: '1',
18
+ LEERNESS_NO_AUTOCHCP: '1',
19
+ LEERNESS_NO_AUTO_ROADMAP: '1',
20
+ LEERNESS_NO_STALE_CHECK: '1',
21
+ LEERNESS_INTERNAL: '1',
22
+ };
23
+
24
+ function run(root, args, timeout = 90000) {
25
+ return cp.spawnSync(process.execPath, [CLI, ...args, '--path', root], {
26
+ cwd: root,
27
+ encoding: 'utf8',
28
+ timeout,
29
+ env,
30
+ });
31
+ }
32
+
33
+ function spawnRun(root, args) {
34
+ return new Promise(resolve => {
35
+ const child = cp.spawn(process.execPath, [CLI, ...args, '--path', root], {
36
+ cwd: root,
37
+ env,
38
+ stdio: ['ignore', 'pipe', 'pipe'],
39
+ });
40
+ let stdout = '';
41
+ let stderr = '';
42
+ child.stdout.setEncoding('utf8');
43
+ child.stderr.setEncoding('utf8');
44
+ child.stdout.on('data', chunk => { stdout += chunk; });
45
+ child.stderr.on('data', chunk => { stderr += chunk; });
46
+ child.on('error', error => resolve({ status: null, stdout, stderr, error }));
47
+ child.on('close', status => resolve({ status, stdout, stderr }));
48
+ });
49
+ }
50
+
51
+ function json(result) {
52
+ try { return JSON.parse(result.stdout || ''); } catch { return null; }
53
+ }
54
+
55
+ function digest(file) {
56
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
57
+ }
58
+
59
+ const failures = [];
60
+ function check(label, condition, result) {
61
+ if (condition) { console.log(`✓ ${label}`); return; }
62
+ failures.push(label);
63
+ const detail = result ? `\n ${JSON.stringify(result).slice(0, 1000)}` : '';
64
+ console.log(`✗ ${label}${detail}`);
65
+ }
66
+
67
+ async function main() {
68
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-claims-baseline-race-'));
69
+ try {
70
+ let result = cp.spawnSync(process.execPath, [CLI, 'init', root, '--yes', '--no-enforce', '--no-stale-check'], {
71
+ cwd: root, encoding: 'utf8', timeout: 90000, env,
72
+ });
73
+ check('concurrency fixture init succeeds', result.status === 0, result);
74
+
75
+ result = run(root, ['task', 'add', 'legacy one']);
76
+ const legacyOne = (result.stdout.match(/T-\d{4,}/) || [])[0];
77
+ result = run(root, ['task', 'update', legacyOne, '--status', 'done', '--evidence', 'ghost-one.js implemented and tested', '--next', 'legacy']);
78
+ check('first legacy failure fixture is ready', result.status === 0 && !!legacyOne, result);
79
+
80
+ result = run(root, ['task', 'add', 'first boundary']);
81
+ const boundaryOne = (result.stdout.match(/T-\d{4,}/) || [])[0];
82
+ result = run(root, ['task', 'add', 'legacy two']);
83
+ const legacyTwo = (result.stdout.match(/T-\d{4,}/) || [])[0];
84
+ result = run(root, ['task', 'update', legacyTwo, '--status', 'done', '--evidence', 'ghost-two.js implemented and tested', '--next', 'legacy']);
85
+ check('second legacy failure fixture is ready', result.status === 0 && !!legacyTwo, result);
86
+
87
+ result = run(root, ['task', 'add', 'second boundary']);
88
+ const boundaryTwo = (result.stdout.match(/T-\d{4,}/) || [])[0];
89
+ check('two distinct policy boundaries are ready', result.status === 0 && !!boundaryOne && !!boundaryTwo, result);
90
+
91
+ const contenders = await Promise.all([
92
+ spawnRun(root, ['verify-claim', 'baseline', 'create', '--before', boundaryOne, '--yes', '--json']),
93
+ spawnRun(root, ['verify-claim', 'baseline', 'create', '--before', boundaryTwo, '--yes', '--json']),
94
+ ]);
95
+ const winners = contenders.filter(item => item.status === 0 && json(item)?.ok === true);
96
+ const losers = contenders.filter(item => item.status === 1 && json(item)?.code === 'baseline_exists');
97
+ check('two concurrent creators produce exactly one winner and one baseline_exists loser',
98
+ winners.length === 1 && losers.length === 1,
99
+ contenders);
100
+
101
+ const baselineFile = path.join(root, '.leerness', 'claims-baseline.json');
102
+ const stored = fs.existsSync(baselineFile) ? JSON.parse(fs.readFileSync(baselineFile, 'utf8')) : null;
103
+ const winner = json(winners[0] || {});
104
+ check('stored policy is exactly the reported winner, never a mixed or overwritten document',
105
+ stored && winner && stored.beforeTaskId === winner.beforeTaskId
106
+ && stored.integrity === winner.integrity && stored.entries.length === winner.baselined,
107
+ { stored, winner });
108
+
109
+ const winnerHash = fs.existsSync(baselineFile) ? digest(baselineFile) : null;
110
+ result = run(root, ['verify-claim', 'baseline', 'create', '--before', boundaryTwo, '--yes', '--json']);
111
+ check('later retries remain create-only and preserve the winning bytes',
112
+ result.status === 1 && json(result)?.code === 'baseline_exists'
113
+ && winnerHash && digest(baselineFile) === winnerHash,
114
+ result);
115
+ } finally {
116
+ fs.rmSync(root, { recursive: true, force: true });
117
+ }
118
+
119
+ if (failures.length) {
120
+ console.log(`CLAIMS_BASELINE_CONCURRENCY_PROBE_FAILED: ${failures.join('; ')}`);
121
+ process.exitCode = 1;
122
+ } else {
123
+ console.log('claims baseline concurrency probe passed');
124
+ }
125
+ }
126
+
127
+ main().catch(error => {
128
+ console.error(error && error.stack || error);
129
+ process.exitCode = 1;
130
+ });
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // T-0145 regression probe: a reviewed legacy debt boundary unblocks only exact
5
+ // historical failures. Raw/per-task audits, changed rows, new failures, invalid
6
+ // input, and corrupt stores must all remain fail-closed.
7
+ const crypto = require('crypto');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const cp = require('child_process');
12
+ const claimsBaseline = require('../lib/claims-baseline');
13
+
14
+ const CLI = path.resolve(__dirname, '..', 'bin', 'leerness.js');
15
+ const env = {
16
+ ...process.env,
17
+ LEERNESS_OFFLINE: '1',
18
+ LEERNESS_NO_PROMPT: '1',
19
+ LEERNESS_NO_AUTOCHCP: '1',
20
+ LEERNESS_NO_AUTO_ROADMAP: '1',
21
+ LEERNESS_NO_STALE_CHECK: '1',
22
+ LEERNESS_INTERNAL: '1',
23
+ };
24
+
25
+ function run(root, args, timeout = 60000) {
26
+ return cp.spawnSync(process.execPath, [CLI, ...args, '--path', root], {
27
+ cwd: root,
28
+ encoding: 'utf8',
29
+ timeout,
30
+ env,
31
+ });
32
+ }
33
+
34
+ function json(result) {
35
+ try { return JSON.parse(result.stdout || ''); } catch { return null; }
36
+ }
37
+
38
+ function digest(file) {
39
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
40
+ }
41
+
42
+ function canonical(value) {
43
+ if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
44
+ if (value && typeof value === 'object') {
45
+ return '{' + Object.keys(value).sort().map(key => JSON.stringify(key) + ':' + canonical(value[key])).join(',') + '}';
46
+ }
47
+ return JSON.stringify(value);
48
+ }
49
+
50
+ function withIntegrity(doc) {
51
+ const unsigned = { ...doc };
52
+ delete unsigned.integrity;
53
+ return {
54
+ ...unsigned,
55
+ integrity: 'sha256:' + crypto.createHash('sha256').update(canonical(unsigned)).digest('hex'),
56
+ };
57
+ }
58
+
59
+ const failures = [];
60
+ function check(label, condition, result) {
61
+ if (condition) { console.log(`✓ ${label}`); return; }
62
+ failures.push(label);
63
+ const detail = result ? `\n exit=${result.status}\n stdout=${String(result.stdout || '').slice(0, 800)}\n stderr=${String(result.stderr || '').slice(0, 400)}` : '';
64
+ console.log(`✗ ${label}${detail}`);
65
+ }
66
+
67
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-claims-baseline-'));
68
+ try {
69
+ let result = cp.spawnSync(process.execPath, [CLI, 'init', root, '--yes', '--no-enforce', '--no-stale-check'], {
70
+ cwd: root, encoding: 'utf8', timeout: 90000, env,
71
+ });
72
+ check('fixture init succeeds', result.status === 0, result);
73
+
74
+ fs.writeFileSync(path.join(root, 'calc.js'), 'function add(a,b){return a+b}\nmodule.exports={add}\n', 'utf8');
75
+ fs.mkdirSync(path.join(root, 'tests'), { recursive: true });
76
+ fs.writeFileSync(path.join(root, 'tests', 'calc.test.js'), 'const {add}=require("../calc.js");\ntest("a",()=>{if(add(1,2)!==3)throw 0});\n', 'utf8');
77
+
78
+ result = run(root, ['task', 'add', 'legacy calc claim']);
79
+ const legacyId = (result.stdout.match(/T-\d{4,}/) || [])[0];
80
+ result = run(root, ['task', 'update', legacyId, '--status', 'done', '--evidence', 'calc.js + tests/calc.test.js 테스트 50개 통과', '--next', 'legacy next']);
81
+ check('legacy false claim fixture is created', result.status === 0 && !!legacyId, result);
82
+ result = run(root, ['task', 'add', 'new work boundary']);
83
+ const boundaryId = (result.stdout.match(/T-\d{4,}/) || [])[0];
84
+ check('explicit non-done boundary fixture is created', result.status === 0 && !!boundaryId, result);
85
+
86
+ // Keep the five heuristic gate checks clean so --claims is the only failing dimension.
87
+ fs.writeFileSync(path.join(root, '.leerness', 'session-handoff.md'), '# Handoff\nLast generated: 2026-08-26T00:00:00Z\n\n## Completed\n- calc.js add() 구현 + 테스트\n\n## Next Exact Step\n- migration\n', 'utf8');
88
+ fs.writeFileSync(path.join(root, '.leerness', 'review-evidence.md'), '# Evidence\n## Test run\n- npm test: 1/1 passing\n', 'utf8');
89
+
90
+ const tracker = path.join(root, '.leerness', 'progress-tracker.md');
91
+ const trackerText = fs.readFileSync(tracker, 'utf8');
92
+ const trackerHash = digest(tracker);
93
+ const baselineFile = path.join(root, '.leerness', 'claims-baseline.json');
94
+
95
+ const beforeGate = run(root, ['gate', '.', '--claims', '--json'], 120000);
96
+ const beforeGateJson = json(beforeGate);
97
+ const beforeClaimCheck = beforeGateJson?.checks?.find(row => row.name === 'verify-claims');
98
+ check('gate --claims reproduces the legacy-only failure before migration',
99
+ beforeGate.status === 1 && beforeClaimCheck?.ok === false && beforeClaimCheck?.rawFailed === 1,
100
+ beforeGate);
101
+
102
+ result = run(root, ['verify-claim', 'baseline', 'create', '--before', boundaryId, '--json']);
103
+ check('baseline creation requires explicit --yes and writes nothing on refusal',
104
+ result.status === 1 && json(result)?.code === 'confirmation_required' && !fs.existsSync(baselineFile),
105
+ result);
106
+
107
+ result = run(root, ['verify-claim', 'baseline', 'create', '--before', 'T-9999', '--yes', '--json']);
108
+ check('unknown boundary fails before writing a baseline',
109
+ result.status === 1 && json(result)?.code === 'boundary_not_found' && !fs.existsSync(baselineFile),
110
+ result);
111
+
112
+ result = run(root, ['verify-claim', 'baseline', 'create', '--before', boundaryId, '--yes', '--json']);
113
+ const created = json(result);
114
+ check('baseline creation isolates only the failed row before the explicit boundary',
115
+ result.status === 0 && created?.ok === true && created?.baselined === 1
116
+ && created?.beforeTaskId === boundaryId && created?.historicalEvidenceModified === false
117
+ && fs.existsSync(baselineFile),
118
+ result);
119
+ check('baseline creation does not rewrite tracker rows or historical evidence',
120
+ digest(tracker) === trackerHash && fs.readFileSync(tracker, 'utf8') === trackerText);
121
+ const stored = JSON.parse(fs.readFileSync(baselineFile, 'utf8'));
122
+ check('baseline stores fingerprints/reasons, not replacement evidence',
123
+ stored.entries?.length === 1 && stored.entries[0].id === legacyId
124
+ && /^[a-f0-9]{64}$/.test(stored.entries[0].fingerprint)
125
+ && !JSON.stringify(stored).includes('테스트 50개'));
126
+
127
+ const casRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-claims-baseline-cas-'));
128
+ try {
129
+ let winnerHash = null;
130
+ let raceCode = null;
131
+ try {
132
+ claimsBaseline.saveBaseline(casRoot, stored, (target, write) => {
133
+ fs.mkdirSync(path.dirname(target), { recursive: true });
134
+ fs.writeFileSync(target, 'concurrent-winner\n', 'utf8');
135
+ winnerHash = digest(target);
136
+ return write();
137
+ });
138
+ } catch (error) { raceCode = error && error.code; }
139
+ const target = claimsBaseline.baselinePath(casRoot);
140
+ check('create-only CAS rechecks destination absence inside the acquired lock',
141
+ raceCode === 'E_CLAIMS_BASELINE_EXISTS' && digest(target) === winnerHash);
142
+ } finally {
143
+ fs.rmSync(casRoot, { recursive: true, force: true });
144
+ }
145
+
146
+ result = run(root, ['verify-claim', '--all', '--json']);
147
+ const applied = json(result);
148
+ check('default aggregate verification applies exact legacy debt and exposes raw counts',
149
+ result.status === 0 && applied?.ok === true && applied?.rawFailed === 1
150
+ && applied?.baselined === 1 && applied?.failed === 0
151
+ && applied?.results?.find(row => row.id === legacyId)?.baselineAccepted === true,
152
+ result);
153
+
154
+ result = run(root, ['verify-claim', '--all', '--raw', '--json']);
155
+ const raw = json(result);
156
+ check('--raw preserves the original failing verdict',
157
+ result.status === 1 && raw?.ok === false && raw?.failed === 1
158
+ && raw?.baselined === 0 && raw?.baseline?.state === 'ignored',
159
+ result);
160
+
161
+ result = run(root, ['verify-claim', legacyId, '--json']);
162
+ check('per-task verification always remains raw',
163
+ result.status === 1 && json(result)?.ok === false && !json(result)?.baselineAccepted,
164
+ result);
165
+
166
+ const afterGate = run(root, ['gate', '.', '--claims', '--json'], 120000);
167
+ const afterGateJson = json(afterGate);
168
+ const afterClaimCheck = afterGateJson?.checks?.find(row => row.name === 'verify-claims');
169
+ check('gate --claims is unblocked only through visible legacy-debt accounting',
170
+ afterGate.status === 0 && afterGateJson?.ok === true && afterClaimCheck?.ok === true
171
+ && afterClaimCheck?.rawFailed === 1 && afterClaimCheck?.baselined === 1 && afterClaimCheck?.failed === 0,
172
+ afterGate);
173
+
174
+ result = run(root, ['verify-claim', 'baseline', 'show', '--json']);
175
+ check('baseline show returns the integrity-bound policy document',
176
+ result.status === 0 && json(result)?.state === 'valid'
177
+ && json(result)?.baseline?.beforeTaskId === boundaryId,
178
+ result);
179
+
180
+ const changedTracker = trackerText.replace('| legacy next |', '| changed metadata |');
181
+ fs.writeFileSync(tracker, changedTracker, 'utf8');
182
+ result = run(root, ['verify-claim', '--all', '--json']);
183
+ const changed = json(result);
184
+ check('any historical tracker-row change invalidates that exemption',
185
+ result.status === 1 && changed?.baselined === 0 && changed?.failed === 1
186
+ && changed?.baseline?.mismatched?.includes(legacyId),
187
+ result);
188
+ const baselineHashBeforeRetry = digest(baselineFile);
189
+ result = run(root, ['verify-claim', 'baseline', 'create', '--before', boundaryId, '--yes', '--json']);
190
+ check('baseline create cannot relaunder a changed historical row by overwriting policy',
191
+ result.status === 1 && json(result)?.code === 'baseline_exists'
192
+ && digest(baselineFile) === baselineHashBeforeRetry,
193
+ result);
194
+ result = run(root, ['verify-claim', '--all', '--json']);
195
+ check('a refused baseline recreation leaves the changed row blocked',
196
+ result.status === 1 && json(result)?.baseline?.mismatched?.includes(legacyId),
197
+ result);
198
+ result = run(root, ['gate', '.', '--claims', '--json'], 120000);
199
+ const changedGateCheck = json(result)?.checks?.find(row => row.name === 'verify-claims');
200
+ check('gate JSON preserves baseline mismatch diagnostics',
201
+ result.status === 1 && changedGateCheck?.baseline?.mismatched?.includes(legacyId),
202
+ result);
203
+ fs.writeFileSync(tracker, trackerText, 'utf8');
204
+
205
+ result = run(root, ['task', 'add', 'new false claim']);
206
+ const newId = (result.stdout.match(/T-\d{4,}/) || [])[0];
207
+ result = run(root, ['task', 'update', newId, '--status', 'done', '--evidence', 'ghost.js implemented and tested', '--next', 'none']);
208
+ check('post-boundary false claim fixture is created', result.status === 0 && !!newId, result);
209
+ result = run(root, ['verify-claim', '--all', '--json']);
210
+ const withNew = json(result);
211
+ check('new failures still block while the unchanged legacy row stays isolated',
212
+ result.status === 1 && withNew?.rawFailed === 2 && withNew?.baselined === 1
213
+ && withNew?.failed === 1 && withNew?.baseline?.newFailures?.includes(newId),
214
+ result);
215
+ result = run(root, ['gate', '.', '--claims', '--json'], 120000);
216
+ const newFailureGateCheck = json(result)?.checks?.find(row => row.name === 'verify-claims');
217
+ check('gate JSON preserves new-failure identifiers',
218
+ result.status === 1 && newFailureGateCheck?.baseline?.newFailures?.includes(newId),
219
+ result);
220
+
221
+ const corrupt = { ...stored, integrity: 'sha256:' + '0'.repeat(64) };
222
+ fs.writeFileSync(baselineFile, JSON.stringify(corrupt, null, 2) + '\n', 'utf8');
223
+ result = run(root, ['verify-claim', '--all', '--json']);
224
+ check('corrupt baseline fails closed with a distinct machine-readable error',
225
+ result.status === 1 && json(result)?.ok === false
226
+ && json(result)?.errors?.includes('baseline-invalid')
227
+ && json(result)?.baseline?.problems?.includes('integrity-mismatch'),
228
+ result);
229
+ result = run(root, ['gate', '.', '--claims', '--json'], 120000);
230
+ const corruptGateCheck = json(result)?.checks?.find(row => row.name === 'verify-claims');
231
+ check('gate JSON preserves corrupt-baseline problems',
232
+ result.status === 1 && corruptGateCheck?.errors?.includes('baseline-invalid')
233
+ && corruptGateCheck?.baseline?.problems?.includes('integrity-mismatch'),
234
+ result);
235
+ result = run(root, ['verify-claim', '--all', '--raw', '--json']);
236
+ check('raw audit remains available even when the baseline store is corrupt',
237
+ result.status === 1 && json(result)?.baseline?.state === 'ignored'
238
+ && json(result)?.errors?.length === 0 && json(result)?.rawFailed === 2,
239
+ result);
240
+ result = run(root, ['verify-claim', 'baseline', 'show', '--json']);
241
+ check('baseline show reports corruption instead of silently treating it as absent',
242
+ result.status === 1 && json(result)?.code === 'baseline_invalid',
243
+ result);
244
+
245
+ const nonCanonical = withIntegrity({
246
+ ...stored,
247
+ entries: stored.entries.map(entry => ({ ...entry, reasons: [...entry.reasons, entry.reasons[0]] })),
248
+ });
249
+ fs.writeFileSync(baselineFile, JSON.stringify(nonCanonical, null, 2) + '\n', 'utf8');
250
+ result = run(root, ['verify-claim', '--all', '--json']);
251
+ check('non-canonical reasons fail closed even with a recomputed document integrity hash',
252
+ result.status === 1 && json(result)?.baseline?.problems?.includes('invalid-entry-reasons'),
253
+ result);
254
+ } finally {
255
+ fs.rmSync(root, { recursive: true, force: true });
256
+ }
257
+
258
+ if (failures.length) {
259
+ console.log(`CLAIMS_BASELINE_PROBE_FAILED: ${failures.join('; ')}`);
260
+ process.exitCode = 1;
261
+ } else {
262
+ console.log('claims baseline probe passed');
263
+ }
@@ -270,6 +270,26 @@ try {
270
270
  check('verify-claim retains its supported --test-cmd contract',
271
271
  claimOwnsTestCommand.status === 1 && json(claimOwnsTestCommand)?.code !== 'unknown_flag',
272
272
  claimOwnsTestCommand);
273
+
274
+ const claimRaw = run(['verify-claim', '--all', '--raw', '--path', emptyProject, '--json']);
275
+ check('verify-claim --all retains its supported raw-audit contract',
276
+ claimRaw.status === 0 && json(claimRaw)?.baseline?.state === 'ignored',
277
+ claimRaw);
278
+
279
+ const claimForeignBoundary = run(['verify-claim', 'T-NOT-FOUND', '--before', 'T-0002', '--path', emptyProject, '--json']);
280
+ check('per-task verify-claim rejects the baseline-only --before flag',
281
+ claimForeignBoundary.status === 1 && json(claimForeignBoundary)?.code === 'unknown_flag',
282
+ claimForeignBoundary);
283
+
284
+ const baselineForeignRun = run(['verify-claim', 'baseline', 'show', '--run-tests', '--path', emptyProject, '--json']);
285
+ check('claims baseline show rejects verification-only mutation flags',
286
+ baselineForeignRun.status === 1 && json(baselineForeignRun)?.code === 'unknown_flag',
287
+ baselineForeignRun);
288
+
289
+ const gateForeignRaw = run(['gate', emptyProject, '--raw', '--json']);
290
+ check('gate rejects --raw because raw audit belongs to verify-claim --all',
291
+ gateForeignRaw.status === 1 && json(gateForeignRaw)?.code === 'unknown_flag',
292
+ gateForeignRaw);
273
293
  } finally {
274
294
  fs.rmSync(emptyProject, { recursive: true, force: true });
275
295
  }