leerness 1.36.187 → 1.36.188

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.188 — 2026-09-08
4
+
5
+ - Add read-only state stores diagnostics for decision, lesson and role status, bounded reads and explicit fallback provenance; preserve metadata-only state inspect and existing mutation behavior.
6
+
3
7
  ## 1.36.187 — 2026-09-05
4
8
 
5
9
  - feat(state): add read-only runtime compatibility diagnosis and observed-layout write guards; keep legacy storage without migration or activation
package/README.ko.md CHANGED
@@ -90,6 +90,8 @@ leerness verify-claim T-0001 --require-evidence
90
90
  ```
91
91
 
92
92
  - `optimism-check` · `verify-claim` — evidence의 도메인 주장(API·DB·결제·이메일·큐·캐시·알림·스토리지 등 10종)을 실제 소스 호출과 대조. **JavaScript뿐 아니라 Python·Ruby·Go·C#·Java·PHP·Rust 구현도 인식**합니다(1.13).
93
+
94
+ `verify-claim`의 통과는 적용된 증거 검사 통과이며 요구사항의 의미적 정확성 보장이 아닙니다. 단건 `--json`의 `evidenceLevel: "executed"`에는 실패한 실행도 포함되고 `semanticVerified`는 항상 `false`입니다. `--run-tests`를 지정해도 명령이 없거나 차단되면 실행을 건너뜁니다. `ok`·`reasons`·`run.skipped`·`run.allPassed`를 함께 확인하세요. [검증 결과 해석 및 실행 예제](docs/verification-evidence.md)를 참고하세요.
93
95
  - `lazy detect` — 증거 없는 done, 빈 handoff, 테스트 미실행, 미추적 TODO를 탐지.
94
96
  - 정직한 완료는 통과하고 가짜 완료만 exit 1로 차단 — `gate` 또는 CI에 그대로 연결됩니다.
95
97
 
package/README.md CHANGED
@@ -39,9 +39,9 @@ npx leerness task update T-0002 --status done --evidence "payment.js implemented
39
39
  npx leerness verify-claim T-0002 # exit 1 — payment.js does not exist. Claim rejected.
40
40
  ```
41
41
 
42
- Now actually write `payment.js`, then run the **same** `verify-claim T-0002` it exits 0. That is the whole idea: **"done" must match reality.**
42
+ Now write the implementation and record evidence, then run the **same** `verify-claim T-0002` again. A successful check means the applicable evidence checks passed; creating a file alone does not prove that the payment API works.
43
43
 
44
- > Tip: if your evidence claims a specific test count (e.g. "5 tests passed"), leerness counts the test functions actually present and rejects a claim that exceeds them so claim only what's true. That default is a static count of test declarations, not proof they pass; add `--run-tests --test-cmd "<your test cmd>"` to verify they actually pass by running them.
44
+ > Tip: test counts are static declaration-pattern heuristics, not execution or coverage proof. Add `--run-tests --test-cmd "<your trusted test command>" --json` to check the selected command's exit status and supported reporter output. `evidenceLevel: "executed"` includes failed runs; inspect `ok`, `reasons`, and `run` together. `semanticVerified` remains `false`. See [verification evidence and runnable examples](docs/verification-evidence.md) for skipped runs, unparsed output, and reporting limits.
45
45
 
46
46
  > Want a smaller footprint? `leerness init . --minimal` installs only the core memory + verification files instead of the full set.
47
47
 
@@ -166,7 +166,7 @@ MIT
166
166
  <!-- leerness:project-readme:start -->
167
167
  ## Leerness Project Harness
168
168
 
169
- 이 프로젝트는 Leerness v1.36.187 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
169
+ 이 프로젝트는 Leerness v1.36.188 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
170
170
 
171
171
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
172
172
 
@@ -220,7 +220,7 @@ leerness memory restore decision <date|title>
220
220
 
221
221
  ### MCP server (외부 AI 통합)
222
222
 
223
- Leerness v1.36.187는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **98개 도구**를 노출:
223
+ Leerness v1.36.188는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **98개 도구**를 노출:
224
224
 
225
225
  ```jsonc
226
226
  // 카테고리별
@@ -241,7 +241,7 @@ Leerness v1.36.187는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
241
241
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
242
242
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
243
243
 
244
- 현재 누적: **v1.9.x → 1.36.187 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
244
+ 현재 누적: **v1.9.x → 1.36.188 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
245
245
 
246
246
  ### 성능 가이드
247
247
 
@@ -279,5 +279,5 @@ leerness release pack --close --auto-main-push
279
279
  - `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
280
280
  - `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
281
281
 
282
- Last synced by Leerness v1.36.187: 2026-09-06
282
+ Last synced by Leerness v1.36.188: 2026-09-08
283
283
  <!-- leerness:project-readme:end -->
package/bin/leerness.js CHANGED
@@ -50,7 +50,7 @@ const {
50
50
  migrateLegacyWorkspace,
51
51
  } = require('../lib/workspace-dir');
52
52
 
53
- const VERSION = '1.36.187';
53
+ const VERSION = '1.36.188';
54
54
 
55
55
  // MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
56
56
  // 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
@@ -893,7 +893,7 @@ function _errorUiLang(root, command) {
893
893
  const parsed = nonFlagArgs();
894
894
  const resolvedCommand = command || parsed[0];
895
895
  if (resolvedCommand === 'mode' && has('--json')) return 'ko';
896
- if (resolvedCommand === 'state' && ['inspect', 'compatibility'].includes(parsed[1])) return _inspectionUiLang();
896
+ if (resolvedCommand === 'state' && ['inspect', 'compatibility', 'stores'].includes(parsed[1])) return _inspectionUiLang();
897
897
  return _uiLang(resolvedCommand === 'mode' ? _modeRoot(parsed, root) : root);
898
898
  }
899
899
  function _inspectionUiLang() {
@@ -11563,6 +11563,7 @@ function commandsCmd(root) {
11563
11563
  { cmd: 'state show|start|record|verify|handoff', desc: '.leerness/ JSON 상태 substrate (에이전트 간 인수인계 표준) — 1.9.278', descEn: '.leerness/ JSON state substrate (cross-agent handoff standard) — 1.9.278' },
11564
11564
  { cmd: 'state inspect [path] [--json]', desc: '5-scope 현재/제안 경로와 기존 상태 목록 — 읽기 전용, 이동·활성화 없음', descEn: 'five-scope current/proposed paths and legacy inventory — read-only, no migration or activation' },
11565
11565
  { cmd: 'state compatibility [path] [--json]', desc: '관측한 runtime layout의 쓰기 호환성 — 읽기 전용, 이동·활성화 없음', descEn: 'observed runtime-layout write compatibility — read-only, no migration or activation' },
11566
+ { cmd: 'state stores [path] [--json]', desc: '결정·교훈·역할 저장소 상태와 fallback — 읽기 전용, 본문 비노출', descEn: 'decision, lesson and role store status and fallback — read-only, no payload output' },
11566
11567
  { cmd: 'adapter <tool>|list [--dry-run]', desc: '도구별 지침/.mcp.json 선택 생성 (claude/cursor/codex/goose/...) — 1.9.280', descEn: 'generate per-tool instructions/.mcp.json selectively (claude/cursor/codex/goose/...) — 1.9.280' },
11567
11568
  { cmd: 'ci init [path] [--force]', desc: 'PR 마다 leerness gate 실행하는 GitHub Actions 워크플로 생성 (.github/workflows/leerness-gate.yml) — 1.9.444', descEn: 'create a GitHub Actions workflow that runs leerness gate on every PR (.github/workflows/leerness-gate.yml) — 1.9.444' },
11568
11569
  { cmd: 'export|prompt --target <agent>', desc: 'adapter 별칭 — 도구별 지침/계약 파일 생성 (claude/cursor/codex/agents-md/...) — 1.9.448', descEn: 'adapter alias — generate per-tool instruction/contract files (claude/cursor/codex/agents-md/...) — 1.9.448' },
@@ -12257,7 +12258,7 @@ const _OBSERVATION_ONLY_SUBCOMMANDS = new Map([
12257
12258
  ['lease', new Set(['list', 'check'])],
12258
12259
  ['roles', new Set(['validate'])],
12259
12260
  ['role', new Set(['validate'])],
12260
- ['state', new Set(['inspect', 'compatibility'])],
12261
+ ['state', new Set(['inspect', 'compatibility', 'stores'])],
12261
12262
  ]);
12262
12263
  function _cliMutationClass(args, cmd) {
12263
12264
  if (cmd === 'mcp') return 'observation-only'; // server startup must not mutate its incidental cwd
@@ -15709,6 +15710,7 @@ const _STRICT_COMMAND_FLAGS = {
15709
15710
  'roles validate': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'roles validate [--path .] [--json]' },
15710
15711
  'state inspect': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'state inspect [path] [--path <path>] [--json]' },
15711
15712
  'state compatibility': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'state compatibility [path] [--path <path>] [--json]' },
15713
+ 'state stores': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'state stores [path] [--path <path>] [--json]' },
15712
15714
  'roles set': { allowed: ['--provider', '--to', '--model', '--model-family', '--policy', '--fallback-policy', '--candidate', '--candidate-family', '--remove-candidate', '--clear-candidates', '--persona', '--force'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'roles set <role> --provider <id> [--model <id>] [--policy strict|balanced|continuity]' },
15713
15715
  'roles unset': { allowed: [], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'roles unset <role> [--path .] [--json]' },
15714
15716
  'roles suggest': { allowed: ['--apply'], globals: [..._COMMON_COMMAND_FLAGS, '--json'], usage: 'roles suggest [--apply] [--path .] [--json]' },
@@ -15790,7 +15792,7 @@ function _validateRolesCommandShape(cmd, args) {
15790
15792
  }
15791
15793
 
15792
15794
  function _validateCommandFlags(cmd, args) {
15793
- if (cmd === 'state' && ['inspect', 'compatibility'].includes(args[1])) {
15795
+ if (cmd === 'state' && ['inspect', 'compatibility', 'stores'].includes(args[1])) {
15794
15796
  if (!_rejectDuplicateValueFlags(['--path'])) return false;
15795
15797
  if (args.length > 3) {
15796
15798
  failJson(has('--json'), 'too_many_arguments', `Usage: state ${args[1]} [path] [--path <path>] [--json]`);
@@ -15809,7 +15811,7 @@ function _validateCommandFlags(cmd, args) {
15809
15811
  if (cmd === 'intent' && args[1] === 'expand') route = 'intent expand';
15810
15812
  if (cmd === 'lease') route = `lease ${args[1] == null ? 'list' : args[1]}`;
15811
15813
  if (roleSub) route = `roles ${roleSub === 'remove' || roleSub === 'rm' ? 'unset' : roleSub}`;
15812
- if (cmd === 'state' && ['inspect', 'compatibility'].includes(args[1])) route = `state ${args[1]}`;
15814
+ if (cmd === 'state' && ['inspect', 'compatibility', 'stores'].includes(args[1])) route = `state ${args[1]}`;
15813
15815
  const cfg = _STRICT_COMMAND_FLAGS[route];
15814
15816
  if (!cfg) return true;
15815
15817
  return _rejectUnknownFlags(cfg.allowed, cfg.usage, { globals: cfg.globals });
@@ -29077,6 +29079,18 @@ function stateCompatibilityCmd(root) {
29077
29079
  return report;
29078
29080
  }
29079
29081
 
29082
+ function stateStoresCmd(root) {
29083
+ try {
29084
+ const { inspectStores, formatStoreDiagnostics } = require('../lib/store-diagnostics');
29085
+ const report = inspectStores(root);
29086
+ log(has('--json') ? JSON.stringify(report, null, 2) : formatStoreDiagnostics(report));
29087
+ if (!report.ok) process.exitCode = 1;
29088
+ return report;
29089
+ } catch {
29090
+ return failJson(has('--json'), 'store_diagnostics_failed', 'Store diagnostics could not be completed; no files were changed.');
29091
+ }
29092
+ }
29093
+
29080
29094
  // leerness state <show|start|record|verify|handoff>
29081
29095
  // 1.9.292 (UR-0031): get_project_context — 외부 에이전트 온보딩용 단일 집약 컨텍스트.
29082
29096
  // 1콜로 현재 작업/미답 요청/최근 결정/활성 룰/next-actions/memory/프로젝트 의도를 구조화 회수.
@@ -31986,6 +32000,7 @@ STATUS & DIAGNOSTICS
31986
32000
  status [path] Install status (files present)
31987
32001
  state inspect [path] [--json] Read-only five-scope paths/inventory; no migration or runtime activation
31988
32002
  state compatibility [path] [--json] Read-only observed-layout write admission; no activation
32003
+ state stores [path] [--json] Read-only decision/lesson/role diagnostics; no payload output
31989
32004
  health [path] | doctor [--json] health: project state · doctor: CLI install/environment (no path arg)
31990
32005
  verify [path] Required-file verification
31991
32006
  which [--json] Resolve current binary/version (npm cache conflicts)
@@ -32069,6 +32084,7 @@ function help() {
32069
32084
  leerness lease acquire|release|list|check [file|lease-id] [--session KEY] [--ttl SEC] [--json] # exact-file opt-in 협업 lease
32070
32085
  leerness state inspect [path] [--json] # 5-scope 경로/목록 읽기 전용 진단 — 이동·활성화 없음
32071
32086
  leerness state compatibility [path] [--json] # 관측 layout 쓰기 호환성 읽기 전용 진단 — 활성화 없음
32087
+ leerness state stores [path] [--json] # 결정/교훈/역할 상태·fallback 진단 — 읽기 전용, 본문 비노출
32072
32088
  leerness retro [path] [--days 7] [--all-apps] [--include p1,p2] [--json] # 회고 (1.9.13~1.9.16)
32073
32089
  leerness insights [path] [--all-apps] [--include p1,p2] [--json] # 누적 통계 (1.9.13~1.9.16)
32074
32090
  leerness brainstorm "<주제>" [--all-apps] [--include p1,p2] [--json] # 브레인스토밍 (1.9.13~1.9.16)
@@ -32115,6 +32131,10 @@ async function main(runtimeEntered = false) {
32115
32131
  const explicit = arg('--path', null);
32116
32132
  return stateCompatibilityCmd(explicit !== null ? explicit : args[2] === undefined ? process.cwd() : args[2]);
32117
32133
  }
32134
+ if (cmd === 'state' && args[1] === 'stores') {
32135
+ const explicit = arg('--path', null);
32136
+ return stateStoresCmd(explicit !== null ? explicit : args[2] === undefined ? process.cwd() : args[2]);
32137
+ }
32118
32138
  const runtimeRoot = _runtimeProjectRoot(args, cmd);
32119
32139
  if (!runtimeEntered && runtimeRoot && !_mustRemainReadOnly(args, cmd)) {
32120
32140
  return withRuntimeWrites(runtimeRoot, () => main(true));
@@ -33296,7 +33316,7 @@ async function main(runtimeEntered = false) {
33296
33316
 
33297
33317
  const skillCatalog = (() => {
33298
33318
  const args = nonFlagArgs();
33299
- if (require.main === module && args[0] === 'state' && ['inspect', 'compatibility'].includes(args[1])) {
33319
+ if (require.main === module && args[0] === 'state' && ['inspect', 'compatibility', 'stores'].includes(args[1])) {
33300
33320
  return _withBuiltinSource(BUILTIN_CATALOG);
33301
33321
  }
33302
33322
  return _loadSkillCatalog();
@@ -4,6 +4,11 @@ Available in v1.36.186. T-0174 / P-0020 is an additive inspection-only foundatio
4
4
  It does not move data or activate a new backend. The complete staged design is in
5
5
  [State scopes and migration](state-scopes.md).
6
6
 
7
+ This API inventories path metadata, not payload validity. A successful inspection
8
+ does not imply that the stores contain valid JSON or domain records. See the
9
+ [store observation boundaries and staged contract](store-observation-contract.md)
10
+ for existing validator differences, and the [separate store diagnostic candidate](store-diagnostics-api.md).
11
+
7
12
  ## CLI
8
13
 
9
14
  ```sh
@@ -0,0 +1,67 @@
1
+ # Store diagnostics API v1
2
+
3
+ Implementation candidate for T-0192 / P-0022. Publication is tracked separately.
4
+
5
+ ```sh
6
+ leerness state stores ./project --json
7
+ leerness state stores --path ./project
8
+ ```
9
+
10
+ This separate command reads only decisions, lessons and roles in the selected
11
+ workspace. `state inspect` remains metadata-only. Explicit `--path` takes precedence
12
+ over the optional positional directory. Duplicate paths, extra positional arguments
13
+ and unsupported write flags fail before ordinary startup bookkeeping.
14
+
15
+ `inspectStores(root, options)` is exported by `lib/store-diagnostics.js`; options
16
+ are passed to the existing `resolveStatePaths` resolver. It resolves the selected
17
+ workspace, not all worktrees or every native runtime authority. A legacy workspace
18
+ is diagnosed in place without migration. No provider or model call is made.
19
+
20
+ ## Result
21
+
22
+ The single JSON document has schema `leerness.store-diagnostics/v1`, `ok`,
23
+ `readOnly:true`, `writesPerformed:false`, `workspace`, `warnings`, and three `stores`.
24
+ Each row identifies the fixed filename and contains:
25
+
26
+ - `canonical.status`: `valid`, `missing`, `invalid_json`, `invalid_shape`,
27
+ `unreadable`, `unsupported`, or `unknown`.
28
+ - `reasonCode` and actual `bytesRead` inside `canonical`; no original text or parse
29
+ error excerpt is included. `syntaxValid`, `shapeValid`, `itemsValid` are tri-state.
30
+ - `effectiveSource`: source selected by this diagnostic (`canonical`,
31
+ `legacy_markdown`, `none`), not an authorization to write or switch runtime authority.
32
+ - `fallback`: whether the diagnostic attempted/used bounded Markdown parsing, its
33
+ status and (when attempted) read metadata. It does not execute the legacy loader.
34
+ - `itemCount`: parsed array/Markdown count or validated role count, otherwise null.
35
+
36
+ Missing optional data is allowed. An invalid canonical file keeps `ok:false` even
37
+ when Markdown is available. CLI exit is 0 only when `ok:true`; failed discovery
38
+ returns a generic `store_diagnostics_failed` envelope and exit 1. For each memory
39
+ store, fallback is attempted only on missing, syntax-error or non-array JSON.
40
+ Unreadable, unsupported and changing sources do not trigger fallback. This is
41
+ deliberately stricter than the existing resilient memory loader, which is unchanged.
42
+
43
+ Memory JSON validates array shape only: `itemsValid:null` even for an empty array.
44
+ This is not full item validation or a semantic-health certificate. Roles reuse the
45
+ pure legacy role schema and extended role-definition validator. Markdown uses the
46
+ existing pure parsers; readable text with no matching entries can yield count zero.
47
+
48
+ ## Read and scope limits
49
+
50
+ Each file is limited to 1 MiB; the reader consumes at most limit+1 bytes to detect
51
+ growth/overflow. Size checks alone are not relied on. It uses strict UTF-8 and
52
+ rejects observed linked/non-regular/multiply-linked files and linked workspace
53
+ parents. File/parent identity and file metadata are compared around the read;
54
+ observed changes fail rather than certify an unstable snapshot. These checks are
55
+ not a portable filesystem transaction, writer lease or guarantee against every
56
+ hostile concurrent filesystem action.
57
+
58
+ Only selected known files are covered, with no recursive scan, automatic repair,
59
+ archive append, usage write, presence update or migration. The diagnostic performs
60
+ no content writes; ordinary filesystem access-time effects are not a content/mtime
61
+ immutability guarantee. Existing mutation commands and their late save guards are
62
+ unchanged. See [the broader observation contract](store-observation-contract.md).
63
+
64
+ Run `npm run test:store-diagnostics` for the focused functional checks. This suite
65
+ also runs through `test:state-scopes`, hence through the existing fast/full gates.
66
+ Its ordinary fixtures test schema/fallback/size/encoding/privacy and CLI zero-write
67
+ behavior; it is not an adversarial filesystem or OS-permission coverage claim.
@@ -0,0 +1,94 @@
1
+ # Store observation: current boundaries and staged changes
2
+
3
+ T-0187-B / T-0191. Source baseline: product code at `70b2a124`; the later
4
+ `7694fb8` commit changes verification documentation only. This is a bounded
5
+ inventory and implementation contract, not a claim that the proposed API exists.
6
+
7
+ Update: the T-0192 implementation candidate adds a separate `state stores` command.
8
+ See [its exact API and remaining limits](store-diagnostics-api.md). The baseline
9
+ table below describes pre-existing behavior; mutation admission and migration B
10
+ remain unimplemented follow-ups.
11
+
12
+ ## Current behavior
13
+
14
+ | Surface | What it observes | Missing / invalid / unreadable behavior | Boundary |
15
+ | --- | --- | --- | --- |
16
+ | `state inspect` / `inspectLegacyInventory` | Known paths, file kinds, sizes and times | `absent`, `unreadable`, or blocked parent metadata | Does not read payloads; `ok:true` does not validate store contents |
17
+ | `findCorruptedStateJson` used by check/health/audit | Nonempty, readable, top-level `.leerness/*.json` syntax | Missing or unreadable directory returns no findings; unreadable/blank files are skipped | No recursive scan or schema validation; no findings is not proof every store was checked |
18
+ | `readRoleStore` / `roles validate` | Role file boundaries, size checks before and after a whole-file read, strict UTF-8, JSON and role schema | Missing is explicit; malformed JSON, invalid shape and unreadable state are rejected separately | The read itself has no byte cap; this role schema does not apply to other stores |
19
+ | `_loadDecisions`, `_loadLessons` | JSON array, otherwise Markdown fallback | Missing, malformed JSON, non-array JSON and caught JSON read errors can all fall back | Returned array does not identify the chosen source or canonical error; items are not fully schema-validated |
20
+ | `_assertStoreParsable` | Existing JSON syntax at a save boundary | Parse/read failure becomes `E_STORE_CORRUPT`; valid JSON with wrong shape passes | A late save guard is not a multi-file transaction |
21
+ | `state compatibility` | Fixed runtime layout descriptor and admission conditions | Missing compatible legacy layout differs from unsupported/unreadable conditions via `reasonCode` | Compatibility-only; no payload migration, runtime activation or memory validation |
22
+
23
+ Sources: [inventory](../lib/state-inventory.js), [inspection](../lib/state-inspect.js),
24
+ [integrity](../lib/state-integrity.js), [roles](../lib/role-store.js),
25
+ [runtime layout](../lib/runtime-layout.js), and the `_loadDecisions`, `_loadLessons`,
26
+ `_assertStoreParsable`, `lessonDropCmd`, `decisionDropCmd`, `memoryRestoreCmd`
27
+ functions in [the CLI](../bin/leerness.js). Function names are navigation anchors,
28
+ not a promise that these internal functions are public exports.
29
+
30
+ ### Legacy memory needs separate read and write contracts
31
+
32
+ - A readable JSON array wins over Markdown, even if individual items are unsuitable.
33
+ - A non-array JSON document is parseable but is not a valid memory array; the loader
34
+ falls back while the syntax-only save guard permits it.
35
+ - Blank JSON falls back on read and fails the save guard, yet the integrity scan skips it.
36
+ - JSON read exceptions are caught by the loader; Markdown read exceptions are not.
37
+ `existsSync` false is not a positively observed `ENOENT` reason.
38
+ - Add/save can exit on deduplication before reaching the save guard. A successful
39
+ no-op therefore does not certify the canonical file.
40
+ - Drop appends to the archive before the final save guard. Restore appends to active
41
+ Markdown before the final save guard. Refusing the canonical save does not undo
42
+ those earlier writes. JSON then Markdown saves are sequential, not transactional.
43
+
44
+ The ordering and fallback statements above are source observations, not destructive
45
+ tests against user data. Do not activate migration B assuming zero mutation on failure.
46
+
47
+ ## Proposed observation contract — not implemented
48
+
49
+ Keep `state inspect` metadata-only. Add a separately requested, read-only store
50
+ diagnostic surface in a later implementation; do not silently add payload reads to
51
+ the existing command. Start with decisions, lessons and roles, not arbitrary files.
52
+
53
+ Each domain adapter should describe these independent facts:
54
+
55
+ | Axis | Proposed values / rule |
56
+ | --- | --- |
57
+ | Canonical observation | `missing`, `valid`, `invalid_json`, `invalid_shape`, `unreadable`, `unsupported`, `unknown`; preserve the native domain reason code |
58
+ | Effective source | `canonical`, `legacy_markdown`, `none`; no silent switch of authority |
59
+ | Fallback | Explicit attempted/used state and reason; fallback availability does not make canonical data valid |
60
+ | Validation | Separate syntax, schema and item validation; unperformed checks are unknown, not true |
61
+ | Coverage | Enumerated stores, skipped stores and reason, bytes/limits; no “all healthy” from an empty findings list |
62
+ | Write effects | Zero for the diagnostic itself; not a promise about separate mutation commands |
63
+
64
+ These labels are design vocabulary, not existing wire fields. Public output should
65
+ contain paths/allowlisted codes and bounded metadata, not memory text, parse-error
66
+ excerpts, credentials, prompts or provider output. Missing optional data is not a
67
+ failure; inability to determine its state must not become success. Preserve existing
68
+ read fallback until an explicit compatibility change is implemented and tested.
69
+
70
+ Reuse domain validators and scope resolution. Avoid a universal schema or recursive
71
+ payload scan. A role-validator error must remain an error; an array-only legacy
72
+ memory read must not be advertised as complete item validation. Before parsing,
73
+ require bounded regular-file reads, appropriate link/identity checks and strict
74
+ decoding. Reuse pure domain schema validation on bounded input; do not assume the
75
+ current `readRoleStore` whole-file read enforces a physical read cap during growth.
76
+ A diagnostic snapshot is not a writer lease or a durable authority.
77
+
78
+ ## Implementation order and acceptance gates
79
+
80
+ 1. **Observability:** separate read-only diagnostics, preserving existing metadata-only
81
+ behavior. Check valid/absent/blank/syntax/shape/read-error/oversize inputs and
82
+ effective-source provenance; assert original bytes, file set and mtimes unchanged.
83
+ 2. **Memory mutation admission:** validate the canonical source inside the operation's
84
+ lock and before archive, projection, log or canonical writes. Retest add, dedup,
85
+ drop and restore. Handle concurrent replacement and failed later writes explicitly;
86
+ an early check alone does not create a transaction.
87
+ 3. **Migration B:** reuse the validated source-selection contract. Refuse uncertain
88
+ canonical authority, retain originals and evidence, and prove rollback and legacy
89
+ writer admission. Only then advance runtime activation.
90
+
91
+ Characterization checks of today's skip behavior document a gap; they are not an
92
+ acceptance requirement to preserve that gap forever. New diagnostics require new
93
+ tests for explicit unknown/error reporting. Existing role/runtime guards must not
94
+ be weakened, and a source inventory is not a complete writer audit.
@@ -0,0 +1,67 @@
1
+ # Verification evidence: what a passing check means
2
+
3
+ This guide describes the existing single-task `verify-claim --json` contract. It does not introduce a new verifier or change exit codes. 한국어 요약은 아래에 있습니다.
4
+
5
+ ## Read the result, not just the label
6
+
7
+ | Field | Meaning | Not a guarantee of |
8
+ | --- | --- | --- |
9
+ | `ok`, `reasons` | Overall result of the applicable claim checks | Correct requirements, complete coverage, production readiness |
10
+ | `semanticVerified: false` | Semantic correctness is not established by this verifier | A hidden semantic review having passed |
11
+ | `evidenceLevel: "static"` | No non-skipped test-run result was collected | Tests passing; absence of every other kind of execution |
12
+ | `evidenceLevel: "executed"` | A non-skipped test-run result was collected, including failed runs | A successful run |
13
+ | `run.skipped` | The selected test execution was skipped; inspect `reason` | A test failure or success |
14
+ | `run.allPassed` | Exit status was zero and, if parsed, the reporter's passed/total counts agree | Meaningful tests, sufficient coverage, or any particular number when `parsed` is null |
15
+ | `verdict.testCountMatch: null` | No applicable count comparison was made | A passing count comparison |
16
+
17
+ Without `--run-tests`, `run` is absent. With it, command selection is `--test-cmd`, then `.leerness/leerness-config.json`'s `testCommand`, then an available non-placeholder `npm test`, otherwise skip. A blocked command also skips. A skipped run stays `static` and does not itself add `tests-failed`; other checks can still fail.
18
+
19
+ An executed failure stays `executed` and contributes `tests-failed`. An exit-zero command with output the parser cannot recognize can have `parsed: null` and `allPassed: true`. Report that as successful command execution with unknown parsed test count, not “all requirements tested.” Only run commands from a workspace you trust: `--run-tests` executes project code.
20
+
21
+ These two boundary fields are present in the single-task JSON result, not promised for each `--all` aggregate entry. Static source checks count recognized declaration patterns; they do not run tests or establish coverage. Git checks compare working changes, not a reviewer's approval of a particular commit.
22
+
23
+ ## A small executable example
24
+
25
+ Use a disposable directory outside your real project, initialize it with `leerness init . --minimal --yes`, and create these two files. The example assumes Node.js and an installed `leerness` CLI.
26
+
27
+ `sum.cjs`:
28
+
29
+ ```js
30
+ module.exports = (a, b) => a + b;
31
+ ```
32
+
33
+ `sum.test.cjs`:
34
+
35
+ ```js
36
+ const assert = require('node:assert/strict');
37
+ const sum = require('./sum.cjs');
38
+ assert.equal(sum(2, 3), 5);
39
+ console.log('1/1 passed');
40
+ ```
41
+
42
+ Register a task and substitute its actual printed ID for `T-0001` below. Record only what you actually ran; the first check deliberately makes no passing-test claim.
43
+
44
+ ```sh
45
+ leerness task add "Implement integer addition"
46
+ leerness task update T-0001 --status done --evidence "Files: sum.cjs, sum.test.cjs"
47
+ leerness verify-claim T-0001 --json
48
+ leerness verify-claim T-0001 --run-tests --test-cmd "node sum.test.cjs" --json
49
+ ```
50
+
51
+ The first result is `static`, with no `run`. The second is `executed`; for the supplied assertion, expect `run.exitCode: 0`, `run.allPassed: true`, and parsed count 1/1. Both retain `semanticVerified: false`. These are expected observations in the isolated example, not universal pass promises for an existing project's other evidence checks.
52
+
53
+ Now change the expected value from `5` to `6` in the disposable test and rerun the second command. The assertion fails: expect a nonzero CLI exit, `ok: false`, `executed`, and `tests-failed`. Restore `5` and rerun before recording success. Do not use a command that merely prints a pass count as proof of test quality.
54
+
55
+ ## Keep an auditable handoff
56
+
57
+ Alongside the claim, record the project/worktree and commit tested, exact command, exit status, parsed count or “unavailable,” log location, and known exclusions. Keep failed and skipped attempts distinct from subsequent success. Review should identify the exact commit reviewed. These are reporting practices, not fields automatically guaranteed by every existing command.
58
+
59
+ For a sibling repository, run its tests there. Do not pretend its paths are relative to the current repository. A local evidence document can reference the external commit and logs, but verifying that document's existence does not rerun or validate the sibling implementation.
60
+
61
+ ## 한국어 요약
62
+
63
+ - `ok`는 적용된 검사 결과이고 `executed`는 실행 여부의 분류입니다. 실패도 `executed`입니다.
64
+ - 미실행은 `run.skipped`와 사유를 확인합니다. `null`은 미측정/비적용이지 통과가 아닙니다.
65
+ - 종료 코드가 0이어도 `parsed: null`이면 테스트 개수는 미확인입니다. 요구사항 충족이나 테스트 품질을 증명하지 않습니다.
66
+ - 위 예제는 실제 assertion 성공/실패를 비교합니다. 정적 검사와 실행 결과를 섞지 말고 명령·종료 코드·검증한 commit·미검증 범위를 함께 기록합니다.
67
+ - `semanticVerified`는 계속 `false`이며, 이 가이드는 현재 계약을 설명할 뿐 새 의미론 검증이나 migration 활성화를 구현하지 않습니다.
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { TextDecoder } = require('util');
6
+ const MAX_BYTES = 1024 * 1024;
7
+ const NAMES = new Set(['decisions.json', 'lessons.json', 'agent-roles.json', 'decisions.md', 'lessons.md']);
8
+ const identity = (a, b) => a.dev === b.dev && a.ino === b.ino && a.mode === b.mode;
9
+ const snapshot = (a, b) => identity(a, b) && a.size === b.size
10
+ && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs && a.nlink === b.nlink;
11
+ const regular = stat => stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1n;
12
+
13
+ // 고정된 최상위 파일만 읽는다. 반환 text는 내부용이며 공개 보고서에 복사하지 않는다.
14
+ const readStoreFile = (workspace, name) => {
15
+ let fd = null;
16
+ let bytesRead = 0;
17
+ const fail = (status, reasonCode) => ({ status, reasonCode, bytesRead });
18
+ if (!NAMES.has(name)) return fail('unsupported', 'store_not_supported');
19
+ const file = path.join(workspace, name);
20
+ try {
21
+ let parent;
22
+ try { parent = fs.lstatSync(workspace, { bigint: true }); }
23
+ catch (error) { return fail(error.code === 'ENOENT' ? 'missing' : 'unreadable', 'workspace_unavailable'); }
24
+ if (parent.isSymbolicLink() || !parent.isDirectory()) return fail('unsupported', 'workspace_type');
25
+ const parentStable = () => {
26
+ const now = fs.lstatSync(workspace, { bigint: true });
27
+ return now.isDirectory() && !now.isSymbolicLink() && identity(parent, now);
28
+ };
29
+ let before;
30
+ try { before = fs.lstatSync(file, { bigint: true }); }
31
+ catch (error) {
32
+ if (!parentStable()) return fail('unknown', 'workspace_changed');
33
+ return fail(error.code === 'ENOENT' ? 'missing' : 'unreadable', error.code === 'ENOENT' ? 'file_missing' : 'stat_failed');
34
+ }
35
+ if (!regular(before)) return fail('unsupported', 'file_type_or_links');
36
+ if (before.size > BigInt(MAX_BYTES)) return fail('unsupported', 'size_limit');
37
+ const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0);
38
+ fd = fs.openSync(file, flags);
39
+ const opened = fs.fstatSync(fd, { bigint: true });
40
+ const current = fs.lstatSync(file, { bigint: true });
41
+ if (!parentStable() || !regular(opened) || !regular(current)
42
+ || !snapshot(before, opened) || !snapshot(opened, current)) return fail('unknown', 'changed_before_read');
43
+ // 크기 검사와 별개로 실제 I/O를 limit+1 바이트에서 중단한다.
44
+ const buffer = Buffer.alloc(MAX_BYTES + 1);
45
+ while (bytesRead < buffer.length) {
46
+ const n = fs.readSync(fd, buffer, bytesRead, buffer.length - bytesRead, bytesRead);
47
+ if (n === 0) break;
48
+ bytesRead += n;
49
+ }
50
+ if (bytesRead > MAX_BYTES) return fail('unsupported', 'size_limit');
51
+ const after = fs.fstatSync(fd, { bigint: true });
52
+ const afterPath = fs.lstatSync(file, { bigint: true });
53
+ if (!parentStable() || !regular(afterPath) || !snapshot(opened, after)
54
+ || !snapshot(after, afterPath) || BigInt(bytesRead) !== after.size) return fail('unknown', 'changed_during_read');
55
+ let text;
56
+ try { text = new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, bytesRead)); }
57
+ catch { return fail('unsupported', 'invalid_utf8'); }
58
+ return { status: 'valid', reasonCode: 'read_ok', bytesRead, text };
59
+ } catch {
60
+ return fail('unreadable', 'read_failed');
61
+ } finally {
62
+ if (fd !== null) fs.closeSync(fd);
63
+ }
64
+ };
65
+
66
+ module.exports = { readStoreFile, MAX_BYTES };
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ const { resolveStatePaths } = require('./state-paths');
4
+ const { readStoreFile, MAX_BYTES } = require('./store-diagnostic-read');
5
+ const { validateLegacyRoleStore } = require('./role-agent-schema');
6
+ const { validateRoleDefinitionShape } = require('./role-fallback');
7
+ const { _decisionsFromMd, _parseLessonEntries } = require('./pure-utils');
8
+
9
+ const observeCanonical = (workspace, name, file) => {
10
+ const read = readStoreFile(workspace, file);
11
+ const result = { status: read.status, reasonCode: read.reasonCode, bytesRead: read.bytesRead,
12
+ syntaxValid: null, shapeValid: null, itemsValid: null };
13
+ if (read.status !== 'valid') return { canonical: result, count: null };
14
+ let document;
15
+ try { document = JSON.parse(read.text); result.syntaxValid = true; }
16
+ catch {
17
+ return { canonical: { ...result, status: 'invalid_json', reasonCode: 'json_syntax', syntaxValid: false }, count: null };
18
+ }
19
+ if (name !== 'roles') {
20
+ const valid = Array.isArray(document);
21
+ return { canonical: { ...result, shapeValid: valid,
22
+ status: valid ? 'valid' : 'invalid_shape', reasonCode: valid ? 'array_valid_items_unchecked' : 'array_required' },
23
+ count: valid ? document.length : null };
24
+ }
25
+ const shape = validateLegacyRoleStore(document);
26
+ const extended = shape.ok && Object.entries(document.roles).every(([role, value]) =>
27
+ validateRoleDefinitionShape(role, value, { allowLegacyProviderIds: true }).ok);
28
+ return { canonical: { ...result, shapeValid: shape.ok, itemsValid: shape.ok ? extended : null,
29
+ status: shape.ok && extended ? 'valid' : 'invalid_shape', reasonCode: shape.ok && extended ? 'role_schema_valid' : 'role_schema_invalid' },
30
+ count: shape.ok && extended ? Object.keys(document.roles).length : null };
31
+ };
32
+
33
+ const observeStore = (workspace, name, file) => {
34
+ const { canonical, count } = observeCanonical(workspace, name, file);
35
+ const row = { name, file, canonical, effectiveSource: canonical.status === 'valid' ? 'canonical' : 'none',
36
+ itemCount: count, fallback: { attempted: false, used: false, status: 'not_attempted' } };
37
+ if (name === 'roles' || !['missing', 'invalid_json', 'invalid_shape'].includes(canonical.status)) return row;
38
+ const fallback = readStoreFile(workspace, `${name}.md`);
39
+ row.fallback = { attempted: true, used: false, status: fallback.status,
40
+ reasonCode: fallback.reasonCode, bytesRead: fallback.bytesRead };
41
+ if (fallback.status !== 'valid') return row;
42
+ try {
43
+ const entries = (name === 'decisions' ? _decisionsFromMd : _parseLessonEntries)(fallback.text);
44
+ row.itemCount = entries.length;
45
+ row.effectiveSource = 'legacy_markdown';
46
+ row.fallback.used = true;
47
+ } catch {
48
+ row.fallback.status = 'unknown';
49
+ row.fallback.reasonCode = 'markdown_parse_failed';
50
+ }
51
+ return row;
52
+ };
53
+
54
+ const inspectStores = (root, options = {}) => {
55
+ const resolved = resolveStatePaths(root, options);
56
+ const workspace = resolved.workspace.selectedPath;
57
+ const stores = [['decisions', 'decisions.json'], ['lessons', 'lessons.json'], ['roles', 'agent-roles.json']]
58
+ .map(([name, file]) => observeStore(workspace, name, file));
59
+ const ok = stores.every(row => ['valid', 'missing'].includes(row.canonical.status)
60
+ && ['not_attempted', 'valid', 'missing'].includes(row.fallback.status));
61
+ return { schema: 'leerness.store-diagnostics/v1', schemaVersion: 1, ok,
62
+ projectRoot: resolved.projectRoot, workspace, readOnly: true, writesPerformed: false,
63
+ runtimeActivated: false, migrationAvailable: false, maxBytesPerFile: MAX_BYTES,
64
+ coverage: { stores: stores.length, recursive: false, memoryItemsValidated: false },
65
+ warnings: resolved.warnings, stores };
66
+ };
67
+
68
+ const formatStoreDiagnostics = report => [
69
+ 'Leerness store diagnostics (read-only; no migration)',
70
+ ...report.stores.map(row => `${row.name}: ${row.canonical.status} (${row.canonical.reasonCode}); source=${row.effectiveSource}; fallback=${row.fallback.status}`),
71
+ 'Memory arrays are not item/schema validation. A diagnostic is not writer admission.',
72
+ ].join('\n');
73
+
74
+ module.exports = { inspectStores, formatStoreDiagnostics };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.187",
3
+ "version": "1.36.188",
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",
@@ -70,7 +70,8 @@
70
70
  "test:role-agent-schema": "node ./scripts/role-agent-schema-probe.js",
71
71
  "test:role-store-loader": "node ./scripts/role-store-loader-probe.js",
72
72
  "test:role-fallback": "node ./scripts/role-fallback-probe.js",
73
- "test:state-scopes": "node ./scripts/workspace-selection-probe.js && node ./scripts/state-scopes-probe.js && node ./scripts/state-inspect-cli-probe.js",
73
+ "test:store-diagnostics": "node ./scripts/store-diagnostic-probe.js",
74
+ "test:state-scopes": "node ./scripts/workspace-selection-probe.js && node ./scripts/state-scopes-probe.js && node ./scripts/state-inspect-cli-probe.js && npm run test:store-diagnostics",
74
75
  "test:runtime-compatibility": "node ./scripts/runtime-layout-probe.js && node ./scripts/runtime-admission-probe.js && node ./scripts/runtime-write-probe.js && node ./scripts/runtime-repl-probe.js && node ./scripts/runtime-git-write-probe.js && node ./scripts/runtime-replacement-probe.js && node ./scripts/encoding-selftest-probe.js && node ./scripts/selftest-cleanup-probe.js"
75
76
  },
76
77
  "publishConfig": {
@@ -72,7 +72,7 @@ const catalog = run(['commands', '--json']);
72
72
  const catalogJson = json(catalog);
73
73
  const catalogRows = catalogJson?.categories ? Object.values(catalogJson.categories).flat() : [];
74
74
  check('commands --json returns the machine-readable catalog',
75
- catalog.status === 0 && catalogJson?.totalCommands === 102 && catalogJson?.categories && !catalog.stderr,
75
+ catalog.status === 0 && catalogJson?.totalCommands === 103 && catalogJson?.categories && !catalog.stderr,
76
76
  catalog);
77
77
  check('commands totalCommands equals the category sum',
78
78
  catalogJson?.totalCommands === catalogRows.length,
@@ -80,7 +80,8 @@ check('commands totalCommands equals the category sum',
80
80
  check('commands catalog includes verify-code, contract verify, and exact-file lease',
81
81
  catalogRows.some(row => /^verify-code\b/.test(row.cmd))
82
82
  && catalogRows.some(row => /^contract verify\b/.test(row.cmd))
83
- && catalogRows.some(row => /^lease acquire\|release\|list\|check\b/.test(row.cmd)),
83
+ && catalogRows.some(row => /^lease acquire\|release\|list\|check\b/.test(row.cmd))
84
+ && catalogRows.some(row => /^state stores\b/.test(row.cmd)),
84
85
  catalog);
85
86
 
86
87
  const englishCatalog = run(['commands', '--language', 'en', '--json']);
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Functional contract checks using only disposable, locally owned fixtures.
5
+ const assert = require('assert');
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+ const crypto = require('crypto');
10
+ const cp = require('child_process');
11
+ const vm = require('vm');
12
+ const { inspectStores } = require('../lib/store-diagnostics');
13
+ const { _shouldDrop } = require('../lib/git');
14
+ const CLI = path.resolve(__dirname, '../bin/leerness.js');
15
+ const tempRoot = fs.realpathSync.native(os.tmpdir());
16
+ const fixture = fs.mkdtempSync(path.join(tempRoot, 'leerness-store-diagnostic-'));
17
+ const fixtureIdentity = fs.realpathSync.native(fixture);
18
+ const LIMIT = 1024 * 1024;
19
+ const PRIVATE = 'synthetic-private-fixture-content';
20
+ let passed = 0;
21
+
22
+ function check(label, action) {
23
+ action();
24
+ passed++;
25
+ console.log(`PASS ${label}`);
26
+ }
27
+
28
+ function project(name, files = {}) {
29
+ const root = path.join(fixture, name);
30
+ fs.mkdirSync(path.join(root, '.leerness'), { recursive: true });
31
+ for (const [file, content] of Object.entries(files)) {
32
+ fs.writeFileSync(path.join(root, '.leerness', file), content);
33
+ }
34
+ return root;
35
+ }
36
+
37
+ function snapshot(root) {
38
+ const rows = {};
39
+ function visit(file, relative) {
40
+ const stat = fs.lstatSync(file);
41
+ const row = { mtimeMs: stat.mtimeMs, kind: stat.isDirectory() ? 'dir' : 'file' };
42
+ if (stat.isFile()) row.sha256 = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
43
+ rows[relative] = row;
44
+ if (stat.isDirectory()) for (const name of fs.readdirSync(file).sort()) visit(path.join(file, name), `${relative}/${name}`);
45
+ }
46
+ visit(root, '.');
47
+ return rows;
48
+ }
49
+
50
+ function inspect(root) {
51
+ const before = snapshot(fixture);
52
+ const result = inspectStores(root);
53
+ assert.strictEqual(result.schema, 'leerness.store-diagnostics/v1');
54
+ assert.strictEqual(result.readOnly, true);
55
+ assert.strictEqual(result.writesPerformed, false);
56
+ assert.strictEqual(typeof result.ok, 'boolean');
57
+ assert.deepStrictEqual(result.stores.map(row => row.name).sort(), ['decisions', 'lessons', 'roles']);
58
+ assert.deepStrictEqual(snapshot(fixture), before, 'inspection must preserve file set, hashes and mtimes');
59
+ assert(!JSON.stringify(result).includes(PRIVATE), 'raw fixture content must not appear in diagnostics');
60
+ return result;
61
+ }
62
+
63
+ function row(report, name) { return report.stores.find(store => store.name === name); }
64
+ const legacy = {
65
+ 'decisions.md': `# Decisions\n\n### 2026-09-08 — Fixture\n- Decision: ${PRIVATE}\n- Reason: test\n`,
66
+ 'lessons.md': `# Lessons\n\n### 2026-09-08\n- Lesson: ${PRIVATE}\n- Tag: fixture\n`,
67
+ };
68
+
69
+ try {
70
+ function fakeRead(mode) {
71
+ const workspace = path.join(fixture, 'in-memory-workspace');
72
+ const regular = { dev: 1n, ino: 2n, mode: 1n, size: 2n, nlink: 1n,
73
+ mtimeNs: 1n, ctimeNs: 1n, isFile: () => true, isSymbolicLink: () => false };
74
+ const parent = { dev: 1n, ino: 1n, mode: 2n, isDirectory: () => true, isSymbolicLink: () => false };
75
+ const metrics = { requested: 0, consumed: 0, closes: 0 };
76
+ const fakeFs = {
77
+ constants: fs.constants,
78
+ lstatSync: file => file === workspace ? parent : regular,
79
+ openSync: () => 9,
80
+ fstatSync: () => regular,
81
+ readSync(fd, buffer, offset, length) {
82
+ assert.strictEqual(fd, 9);
83
+ metrics.requested += length;
84
+ assert(metrics.requested <= LIMIT + 1, 'actual I/O requests must remain bounded');
85
+ if (mode === 'denied') throw Object.assign(new Error('synthetic read failure'), { code: 'EACCES' });
86
+ buffer.fill(0x20, offset, offset + length);
87
+ metrics.consumed += length;
88
+ return length;
89
+ },
90
+ closeSync(fd) { assert.strictEqual(fd, 9); metrics.closes++; },
91
+ };
92
+ const module = { exports: {} };
93
+ vm.runInNewContext(fs.readFileSync(path.resolve(__dirname, '../lib/store-diagnostic-read.js'), 'utf8'), {
94
+ module, Buffer, require: name => name === 'fs' ? fakeFs : require(name),
95
+ }, { filename: 'store-diagnostic-read.js', timeout: 2000 });
96
+ assert.strictEqual(module.exports.MAX_BYTES, LIMIT);
97
+ return { result: module.exports.readStoreFile(workspace, 'decisions.json'), metrics };
98
+ }
99
+ check('in-memory continuous reader stops at the actual byte limit despite small metadata', () => {
100
+ const { result, metrics } = fakeRead('continuous');
101
+ assert.strictEqual(result.status, 'unsupported');
102
+ assert.strictEqual(result.reasonCode, 'size_limit');
103
+ assert.strictEqual(result.bytesRead, LIMIT + 1);
104
+ assert.strictEqual(metrics.requested, LIMIT + 1);
105
+ assert.strictEqual(metrics.consumed, LIMIT + 1);
106
+ assert.strictEqual(metrics.closes, 1);
107
+ });
108
+ check('in-memory read failure is unreadable and closes its descriptor once', () => {
109
+ const { result, metrics } = fakeRead('denied');
110
+ assert.strictEqual(result.status, 'unreadable');
111
+ assert.strictEqual(result.reasonCode, 'read_failed');
112
+ assert.strictEqual(result.bytesRead, 0);
113
+ assert.strictEqual(metrics.consumed, 0);
114
+ assert.strictEqual(metrics.closes, 1);
115
+ });
116
+ check('absent optional stores are missing, not invented empty canonical arrays', () => {
117
+ const report = inspect(project('absent'));
118
+ assert.strictEqual(report.ok, true);
119
+ for (const store of report.stores) {
120
+ assert.strictEqual(store.canonical.status, 'missing');
121
+ assert.strictEqual(store.effectiveSource, 'none');
122
+ assert.strictEqual(store.itemCount, null);
123
+ }
124
+ });
125
+ const valid = project('valid target 한글', {
126
+ 'decisions.json': JSON.stringify([{ title: PRIVATE }]),
127
+ 'lessons.json': '[]',
128
+ 'agent-roles.json': JSON.stringify({ schemaVersion: 1, roles: { implementer: { provider: 'codex', model: 'fixture-model' } } }),
129
+ ...legacy,
130
+ });
131
+ check('valid canonical stores take precedence without reading fallback', () => {
132
+ const report = inspect(valid);
133
+ assert.strictEqual(report.ok, true);
134
+ for (const store of report.stores) {
135
+ assert.strictEqual(store.canonical.status, 'valid');
136
+ assert.strictEqual(store.canonical.syntaxValid, true);
137
+ assert.strictEqual(store.canonical.shapeValid, true);
138
+ assert.strictEqual(store.effectiveSource, 'canonical');
139
+ assert.strictEqual(store.fallback.attempted, false);
140
+ assert.strictEqual(store.fallback.used, false);
141
+ assert(store.canonical.bytesRead > 0);
142
+ }
143
+ assert.strictEqual(row(report, 'decisions').itemCount, 1);
144
+ assert.strictEqual(row(report, 'lessons').itemCount, 0);
145
+ assert.strictEqual(row(report, 'roles').itemCount, 1);
146
+ assert.strictEqual(row(report, 'decisions').canonical.itemsValid, null);
147
+ assert.strictEqual(row(report, 'lessons').canonical.itemsValid, null);
148
+ assert.strictEqual(row(report, 'roles').canonical.itemsValid, true);
149
+ });
150
+ check('memory array shape acceptance does not claim item validation', () => {
151
+ const report = inspect(project('array shape only', { 'decisions.json': '[null,1,"fixture"]' }));
152
+ assert.strictEqual(report.ok, true);
153
+ assert.strictEqual(row(report, 'decisions').canonical.itemsValid, null);
154
+ assert.strictEqual(row(report, 'decisions').itemCount, 3);
155
+ });
156
+ for (const [label, content, status, fallbackAllowed] of [
157
+ ['invalid JSON', '{', 'invalid_json', true],
158
+ ['object shape', '{}', 'invalid_shape', true],
159
+ ['blank', ' \n', 'invalid_json', true],
160
+ ['oversize', ' '.repeat(LIMIT + 100), 'unsupported', false],
161
+ ['invalid UTF8', Buffer.from([0xc3, 0x28]), 'unsupported', false],
162
+ ]) {
163
+ check(`${label}: explicit canonical failure remains visible with legacy files present`, () => {
164
+ const report = inspect(project(label, { 'decisions.json': content, 'lessons.json': content, ...legacy }));
165
+ assert.strictEqual(report.ok, false);
166
+ for (const name of ['decisions', 'lessons']) {
167
+ const store = row(report, name);
168
+ assert.strictEqual(store.canonical.status, status);
169
+ assert.strictEqual(typeof store.canonical.reasonCode, 'string');
170
+ assert(store.canonical.bytesRead <= LIMIT + 1);
171
+ assert.strictEqual(store.fallback.attempted, fallbackAllowed);
172
+ assert.strictEqual(store.fallback.used, fallbackAllowed);
173
+ assert.strictEqual(store.effectiveSource, fallbackAllowed ? 'legacy_markdown' : 'none');
174
+ assert.strictEqual(store.itemCount, fallbackAllowed ? 1 : null);
175
+ }
176
+ });
177
+ }
178
+ check('missing canonical files can report legacy counts without backfill', () => {
179
+ const report = inspect(project('legacy only', legacy));
180
+ assert.strictEqual(report.ok, true);
181
+ for (const name of ['decisions', 'lessons']) {
182
+ const store = row(report, name);
183
+ assert.strictEqual(store.canonical.status, 'missing');
184
+ assert.strictEqual(store.fallback.status, 'valid');
185
+ assert.strictEqual(store.fallback.used, true);
186
+ assert.strictEqual(store.itemCount, 1);
187
+ }
188
+ });
189
+ check('invalid role definitions are not accepted as valid assignments', () => {
190
+ const report = inspect(project('invalid roles', { 'agent-roles.json': JSON.stringify({ schemaVersion: 1, roles: { reviewer: { provider: 42 } } }) }));
191
+ assert.strictEqual(report.ok, false);
192
+ const store = row(report, 'roles');
193
+ assert.strictEqual(store.canonical.status, 'invalid_shape');
194
+ assert.strictEqual(store.canonical.syntaxValid, true);
195
+ assert.strictEqual(store.fallback.attempted, false);
196
+ assert.strictEqual(store.effectiveSource, 'none');
197
+ assert.strictEqual(store.itemCount, null);
198
+ });
199
+ check('invalid role schema versions remain explicit failures', () => {
200
+ const report = inspect(project('role schema', { 'agent-roles.json': '{"schemaVersion":999,"roles":{}}' }));
201
+ assert.strictEqual(report.ok, false);
202
+ assert.strictEqual(row(report, 'roles').canonical.status, 'invalid_shape');
203
+ });
204
+
205
+ const cwd = project('unrelated cwd', { 'current-state.md': 'Keep user state.\n', 'manifest.json': '{"language":"ko"}' });
206
+ const env = { ...process.env };
207
+ for (const key of Object.keys(env)) if (/^(LEERNESS_|GIT_CONFIG_|NODE_OPTIONS)/i.test(key) || _shouldDrop(key)) delete env[key];
208
+ env.LEERNESS_LANG = 'en';
209
+ function cli(target, extra = []) {
210
+ return cp.spawnSync(process.execPath, [CLI, 'state', 'stores', target, ...extra, '--json'], { cwd, env, encoding: 'utf8', timeout: 15000, maxBuffer: 1024 * 1024 });
211
+ }
212
+ check('real CLI from unrelated cwd preserves target and cwd file sets, hashes and mtimes', () => {
213
+ const before = snapshot(fixture);
214
+ const result = cli(valid);
215
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
216
+ assert.strictEqual(result.stderr, '');
217
+ const report = JSON.parse(result.stdout);
218
+ assert.strictEqual(report.schema, 'leerness.store-diagnostics/v1');
219
+ assert.strictEqual(report.ok, true);
220
+ assert.strictEqual(report.readOnly, true);
221
+ assert.strictEqual(report.writesPerformed, false);
222
+ assert.strictEqual(row(report, 'decisions').itemCount, 1);
223
+ assert(!result.stdout.includes(PRIVATE));
224
+ assert.deepStrictEqual(snapshot(fixture), before);
225
+ });
226
+ check('real CLI failed diagnostic keeps canonical failure despite successful fallback and writes nothing', () => {
227
+ const target = project('cli invalid', { 'decisions.json': '{', ...legacy });
228
+ const before = snapshot(fixture);
229
+ const result = cli(target);
230
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
231
+ assert.strictEqual(result.stderr, '');
232
+ const report = JSON.parse(result.stdout);
233
+ assert.strictEqual(report.ok, false);
234
+ assert.strictEqual(row(report, 'decisions').canonical.status, 'invalid_json');
235
+ assert.strictEqual(row(report, 'decisions').fallback.used, true);
236
+ assert(!result.stdout.includes(PRIVATE));
237
+ assert.deepStrictEqual(snapshot(fixture), before);
238
+ });
239
+ for (const [label, args] of [
240
+ ['mutation flag', ['--apply']],
241
+ ['extra positional path', [cwd]],
242
+ ['duplicate explicit path', ['--path', valid, '--path', cwd]],
243
+ ]) {
244
+ check(`real CLI rejects ${label} without writes`, () => {
245
+ const before = snapshot(fixture);
246
+ const result = cli(valid, args);
247
+ assert.strictEqual(result.status, 1, result.stderr || result.stdout);
248
+ assert.strictEqual(result.stderr, '');
249
+ assert.strictEqual(JSON.parse(result.stdout).ok, false);
250
+ assert.deepStrictEqual(snapshot(fixture), before);
251
+ });
252
+ }
253
+ check('explicit --path overrides an unrelated positional root without writes', () => {
254
+ const before = snapshot(fixture);
255
+ const result = cli(cwd, ['--path', valid]);
256
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
257
+ assert.strictEqual(result.stderr, '');
258
+ const report = JSON.parse(result.stdout);
259
+ assert.strictEqual(report.ok, true);
260
+ assert.strictEqual(report.projectRoot, fs.realpathSync.native(valid));
261
+ assert.strictEqual(row(report, 'decisions').itemCount, 1);
262
+ assert.deepStrictEqual(snapshot(fixture), before);
263
+ });
264
+ console.log(`store-diagnostic probe: ${passed}/${passed} PASS`);
265
+ } finally {
266
+ // Resolve and check the exact allocated directory before recursive removal.
267
+ const resolved = fs.realpathSync.native(fixture);
268
+ assert.strictEqual(resolved, fixtureIdentity);
269
+ assert.strictEqual(path.dirname(resolved), tempRoot);
270
+ assert(path.basename(resolved).startsWith('leerness-store-diagnostic-'));
271
+ fs.rmSync(resolved, { recursive: true, force: true });
272
+ }