leerness 1.36.185 → 1.36.186

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.
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { gitSpawn } = require('./git');
6
+
7
+ class StatePathError extends Error {
8
+ constructor(code, message) {
9
+ super(message);
10
+ this.name = 'StatePathError';
11
+ this.code = code;
12
+ }
13
+ }
14
+
15
+ function canonicalDirectory(value) {
16
+ if (typeof value !== 'string' || !value.trim() || /[\x00-\x1f\x7f]/.test(value)) {
17
+ throw new StatePathError('path_invalid', 'An existing directory path without control characters is required.');
18
+ }
19
+ try {
20
+ const resolved = fs.realpathSync.native(path.resolve(value));
21
+ if (!fs.statSync(resolved).isDirectory()) {
22
+ throw new StatePathError('path_not_directory', 'The inspection target must be a directory.');
23
+ }
24
+ return resolved;
25
+ } catch (error) {
26
+ if (error instanceof StatePathError) throw error;
27
+ const missing = error.code === 'ENOENT' || error.code === 'ENOTDIR';
28
+ throw new StatePathError(missing ? 'path_not_found' : 'path_unreadable',
29
+ missing ? 'The inspection directory does not exist.' : 'The inspection directory cannot be resolved.');
30
+ }
31
+ }
32
+
33
+ function unavailable() {
34
+ return new StatePathError('git_repository_unavailable',
35
+ 'Git repository discovery failed; no alternate state backend was selected.');
36
+ }
37
+
38
+ // Distinguish a genuinely non-Git directory from damaged repository metadata.
39
+ // Respect Git's discovery ceilings and filesystem boundary; never inspect above them.
40
+ function hasRepositoryMarker(root, env) {
41
+ const ceilingValue = Object.keys(env).find(key => key.toUpperCase() === 'GIT_CEILING_DIRECTORIES');
42
+ const ceilings = new Set(String(env[ceilingValue] || '').split(path.delimiter)
43
+ .filter(value => value && path.isAbsolute(value)).flatMap(value => {
44
+ try { return [fs.realpathSync.native(value)]; }
45
+ catch (error) {
46
+ // Git ignores nonexistent ceiling entries; they are not project paths.
47
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return [];
48
+ throw error;
49
+ }
50
+ }));
51
+ const acrossKey = Object.keys(env).find(key => key.toUpperCase() === 'GIT_DISCOVERY_ACROSS_FILESYSTEM');
52
+ const across = /^(1|true|yes|on)$/i.test(String(env[acrossKey] || ''));
53
+ const device = fs.statSync(root).dev;
54
+ let current = root;
55
+ for (let depth = 0; depth < 128; depth++) {
56
+ try { fs.lstatSync(path.join(current, '.git')); return true; }
57
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
58
+ const parent = path.dirname(current);
59
+ if (parent === current || ceilings.has(parent)) return false;
60
+ if (!across && fs.statSync(parent).dev !== device) return false;
61
+ current = parent;
62
+ }
63
+ throw unavailable();
64
+ }
65
+
66
+ function processFailure(result) {
67
+ // The Windows portable gateway reports missing/unsupported launchers via
68
+ // its own bounded child exit, not spawnSync.error. Keep the same taxonomy.
69
+ if (result.status === 127 && /^leerness: command not found on PATH: git\r?\n?$/.test(String(result.stderr || ''))) {
70
+ throw new StatePathError('git_missing', 'Git is unavailable; install Git before inspecting repository topology.');
71
+ }
72
+ if (result.status === 126 && /^leerness: unsupported Windows command shim: /.test(String(result.stderr || ''))) {
73
+ throw new StatePathError('git_unsupported', 'The configured Git launcher is unsupported.');
74
+ }
75
+ if (!result.error) return;
76
+ const codes = {
77
+ ENOENT: ['git_missing', 'Git is unavailable; install Git before inspecting repository topology.'],
78
+ ETIMEDOUT: ['git_timeout', 'Git topology discovery timed out.'],
79
+ ENOBUFS: ['git_output_limit', 'Git topology output exceeded the inspection limit.'],
80
+ EACCES: ['git_unreadable', 'Git could not be executed with the current permissions.'],
81
+ EPERM: ['git_unreadable', 'Git could not be executed with the current permissions.'],
82
+ };
83
+ const [code, message] = codes[result.error.code] || ['git_failed', 'Git topology discovery could not run.'];
84
+ throw new StatePathError(code, message);
85
+ }
86
+
87
+ function parseTopology(root, result, env) {
88
+ processFailure(result);
89
+ const output = String(result.stdout || '').replace(/\r?\n$/, '');
90
+ const lines = output.split(/\r?\n/);
91
+ if (lines.includes('--path-format=absolute')) {
92
+ throw new StatePathError('git_unsupported', 'Git must support rev-parse --path-format=absolute.');
93
+ }
94
+ if (lines[0] === 'true') {
95
+ throw new StatePathError('git_bare_unsupported', 'A bare repository has no worktree-private project runtime.');
96
+ }
97
+ if (lines[1] === 'true') {
98
+ throw new StatePathError('git_metadata_path', 'Inspect a worktree project, not a Git metadata directory.');
99
+ }
100
+ if (result.status !== 0) {
101
+ const stderr = String(result.stderr || '');
102
+ if (/permission denied|access is denied/i.test(stderr)) {
103
+ throw new StatePathError('git_unreadable', 'Git could not read repository metadata.');
104
+ }
105
+ if (!output && /fatal: not a git repository \(or any (?:of the )?parent (?:directories|up to mount point)/i.test(stderr)
106
+ && !hasRepositoryMarker(root, env)) return null;
107
+ throw unavailable();
108
+ }
109
+ if (lines.length !== 5 || lines[0] !== 'false' || lines[1] !== 'false'
110
+ || lines.slice(2).some(value => !path.isAbsolute(value))) {
111
+ throw new StatePathError('git_output_invalid', 'Git did not return an unambiguous absolute topology.');
112
+ }
113
+ const [worktreeRoot, gitDir, gitCommonDir] = lines.slice(2).map(canonicalDirectory);
114
+ const relative = path.relative(worktreeRoot, root);
115
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw unavailable();
116
+ return { worktreeRoot, gitDir, gitCommonDir, linkedWorktree: gitDir !== gitCommonDir };
117
+ }
118
+
119
+ function resolveGitTopology(root, options = {}) {
120
+ const env = { ...(options.env || process.env), LC_ALL: 'C', LANGUAGE: 'C', GIT_TERMINAL_PROMPT: '0' };
121
+ const args = ['-C', root, 'rev-parse', '--path-format=absolute', '--is-bare-repository',
122
+ '--is-inside-git-dir', '--show-toplevel', '--git-dir', '--git-common-dir'];
123
+ try {
124
+ const result = gitSpawn(args, { cwd: root, env, encoding: 'utf8', timeout: 5000, maxBuffer: 64 * 1024 });
125
+ return parseTopology(root, result, env);
126
+ } catch (error) {
127
+ if (error instanceof StatePathError && !error.code.startsWith('path_')) throw error;
128
+ if (['EACCES', 'EPERM', 'path_unreadable'].includes(error.code)) {
129
+ throw new StatePathError('git_unreadable', 'Git repository directories could not be read.');
130
+ }
131
+ if (error.code === 'path_invalid' || error.code === 'path_not_directory') {
132
+ throw new StatePathError('git_output_invalid', 'Git did not return usable directory paths.');
133
+ }
134
+ throw unavailable();
135
+ }
136
+ }
137
+
138
+ module.exports = { StatePathError, canonicalDirectory, resolveGitTopology };
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { resolveStatePaths } = require('./state-paths');
5
+ const { inspectLegacyInventory } = require('./state-inventory');
6
+
7
+ function inspectState(root, options = {}) {
8
+ const resolved = resolveStatePaths(root, options);
9
+ // Legacy project memory and canonical runtime/substrate can coexist. Selection
10
+ // determines the project reader, not whether residual state exists elsewhere.
11
+ const locations = [...new Set([resolved.workspace.selectedPath, ...resolved.workspace.detectedPaths])];
12
+ const inventory = locations.flatMap(workspacePath => inspectLegacyInventory(workspacePath).map(row => ({
13
+ ...row, workspacePath, selectedWorkspace: workspacePath === resolved.workspace.selectedPath,
14
+ })));
15
+ return { ...resolved, schema: 'leerness.state-inspection/v1', ok: true,
16
+ inventory };
17
+ }
18
+
19
+ function formatStateInspection(report, language = 'en') {
20
+ const ko = language === 'ko';
21
+ const lines = [ko ? 'Leerness 상태 범위 진단 (읽기 전용)' : 'Leerness state scopes (read-only)',
22
+ `${ko ? '프로젝트' : 'Project'}: ${report.projectRoot}`,
23
+ `${ko ? '현재 저장소' : 'Current workspace'}: ${report.workspace.selectedPath} (${report.workspace.status})`,
24
+ ko ? '기존 배치 유지 · runtime 활성화 안 됨 · 마이그레이션 미구현'
25
+ : 'Legacy layout retained; runtime NOT activated; migration NOT implemented.', '',
26
+ ko ? '제안 경로 (생성/이동하지 않음):' : 'Proposed paths (nothing created or moved):'];
27
+ for (const value of Object.values(report.scopes)) {
28
+ const paths = value.proposedPaths || { path: value.proposedPath };
29
+ for (const [name, target] of Object.entries(paths)) {
30
+ lines.push(` ${value.scope}${name === 'path' ? '' : ` / ${name}`}: ${target || (ko ? '사용 불가 (Git 없음)' : 'unavailable (non-Git)')}`);
31
+ }
32
+ }
33
+ lines.push('', ko ? '기존 상태 목록 (내용/하위 폴더는 읽지 않음):' : 'Legacy inventory (no contents or recursive traversal):');
34
+ for (const row of report.inventory) lines.push(` ${path.basename(row.workspacePath)}/${row.relativePath}: ${row.metadata.status} -> ${row.proposedScope}`);
35
+ if (report.warnings.length) lines.push(`\n${ko ? '주의' : 'Warnings'}: ${report.warnings.join(', ')}`);
36
+ return lines.join('\n');
37
+ }
38
+
39
+ module.exports = { inspectState, formatStateInspection };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // This is an inventory of known legacy surfaces, NOT a migration manifest.
7
+ // Mixed files contain human intent as well as execution state; preserve both.
8
+ const SURFACES = Object.freeze([
9
+ ['cache/sessions', 'runtime', 'Worktree'],
10
+ ['cache/handoffs', 'runtime', 'Worktree'],
11
+ ['cache/agent-sessions', 'runtime-private-conversation', 'Worktree'],
12
+ ['cache/agent-runs', 'runtime', 'Worktree'],
13
+ ['cache/file-leases.json', 'physical-file-coordination', 'Worktree'],
14
+ ['state.json', 'runtime', 'Worktree'],
15
+ ['runs', 'mixed-run-evidence', 'Worktree + Immutable-Record'],
16
+ ['handoff', 'runtime', 'Worktree'],
17
+ ['execution-ledger.jsonl', 'mixed-run-evidence', 'Worktree + Immutable-Record'],
18
+ ['decisions.json', 'persistent-memory', 'Immutable-Record'],
19
+ ['decisions.md', 'mixed-memory-projection', 'Immutable-Record + Generated-View'],
20
+ ['lessons.json', 'persistent-memory', 'Immutable-Record'],
21
+ ['lessons.md', 'mixed-memory-projection', 'Immutable-Record + Generated-View'],
22
+ ['progress-tracker.md', 'mixed-task-authority', 'Project + Generated-View'],
23
+ ['current-state.md', 'mixed-project-context', 'Project + Generated-View'],
24
+ ['session-handoff.md', 'mixed-project-context', 'Project + Worktree + Generated-View'],
25
+ ['active-wakeups.json', 'mixed-intent-runtime', 'Project + Worktree'],
26
+ ['auto-resume-plan.json', 'mixed-intent-runtime', 'Project + Worktree'],
27
+ ['next-action-queue.json', 'mixed-intent-runtime', 'Project + Worktree'],
28
+ ['pre-wake-report.json', 'runtime', 'Worktree'],
29
+ ['last-handoff.json', 'runtime', 'Worktree'],
30
+ ['routing-log.json', 'runtime', 'Worktree'],
31
+ ]);
32
+
33
+ function metadata(file) {
34
+ try {
35
+ const stat = fs.lstatSync(file);
36
+ const kind = stat.isSymbolicLink() ? 'link' : stat.isDirectory() ? 'directory'
37
+ : stat.isFile() ? 'file' : 'special';
38
+ return { status: kind, sizeBytes: kind === 'file' ? stat.size : null, mtimeMs: stat.mtimeMs };
39
+ } catch (error) {
40
+ return { status: error.code === 'ENOENT' ? 'absent' : 'unreadable', errorCode: error.code || 'unknown' };
41
+ }
42
+ }
43
+
44
+ function inspectLegacyInventory(workspace) {
45
+ const cache = new Map();
46
+ const inspect = file => {
47
+ if (!cache.has(file)) cache.set(file, metadata(file));
48
+ return cache.get(file);
49
+ };
50
+ return SURFACES.map(([relativePath, classification, proposedScope]) => {
51
+ const parts = relativePath.split('/');
52
+ let parent = workspace;
53
+ let blocked = null;
54
+ for (const part of parts) {
55
+ const state = inspect(parent);
56
+ if (state.status !== 'directory') {
57
+ blocked = { status: state.status === 'absent' ? 'absent' : 'blocked',
58
+ parentStatus: state.status, blockedAt: parent };
59
+ break;
60
+ }
61
+ parent = path.join(parent, part);
62
+ }
63
+ const currentPath = path.join(workspace, ...parts);
64
+ return { relativePath, currentPath, classification, proposedScope,
65
+ migrationAvailable: false, metadata: blocked || inspect(currentPath) };
66
+ });
67
+ }
68
+
69
+ module.exports = { inspectLegacyInventory };
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const crypto = require('crypto');
5
+ const { StatePathError, canonicalDirectory, resolveGitTopology } = require('./state-git');
6
+ const { inspectWorkspace, selectWorkspaceDirName, CANONICAL_WORKSPACE_DIR } = require('./workspace-dir');
7
+
8
+ function scopeMap(projectPath, currentPath, git, projectKey) {
9
+ const privatePath = git
10
+ ? path.join(git.gitDir, 'leerness', 'projects', projectKey, 'runtime')
11
+ : path.join(projectPath, 'cache', 'state-runtime');
12
+ const commonPath = git ? path.join(git.gitCommonDir, 'leerness', 'control', 'projects', projectKey) : null;
13
+ return {
14
+ project: { scope: 'Project', currentPath, proposedPath: projectPath },
15
+ worktree: { scope: 'Worktree', proposedPath: privatePath, backend: git ? 'git-private' : 'project-local-fallback' },
16
+ commonControl: { scope: 'Common-Control', proposedPath: commonPath, available: !!git },
17
+ immutableRecord: { scope: 'Immutable-Record', proposedPaths: {
18
+ decisions: path.join(projectPath, 'memory', 'decisions'),
19
+ lessons: path.join(projectPath, 'memory', 'lessons'),
20
+ runs: path.join(projectPath, 'records', 'runs'),
21
+ } },
22
+ generatedView: { scope: 'Generated-View', proposedPaths: {
23
+ project: path.join(projectPath, 'generated'), worktree: path.join(privatePath, 'views'),
24
+ } },
25
+ };
26
+ }
27
+
28
+ function resolveStatePaths(root = process.cwd(), options = {}) {
29
+ const projectRoot = canonicalDirectory(root);
30
+ const git = resolveGitTopology(projectRoot, options);
31
+ const snapshot = inspectWorkspace(projectRoot);
32
+ if (snapshot.legacy.inspectionError || snapshot.canonical.inspectionError) {
33
+ throw new StatePathError('workspace_unreadable', 'Workspace metadata is unreadable; it was not treated as an empty store.');
34
+ }
35
+ const envValue = options.envValue === undefined
36
+ ? (options.env || process.env).LEERNESS_WORKSPACE_DIR : options.envValue;
37
+ const selectedName = selectWorkspaceDirName(snapshot, envValue);
38
+ const selected = selectedName === CANONICAL_WORKSPACE_DIR ? snapshot.canonical : snapshot.legacy;
39
+ const relative = git ? path.relative(git.worktreeRoot, projectRoot).split(path.sep).join('/') || '.' : '.';
40
+ const projectKey = `project-${crypto.createHash('sha256').update(relative).digest('hex')}`;
41
+ const status = !selected.exists ? 'absent' : selected.foreign ? 'foreign'
42
+ : selected.live ? 'live' : selected.substrateOnly ? 'substrate' : 'unrecognized';
43
+ const warnings = [];
44
+ if (!git) warnings.push('non_git_no_common_control');
45
+ if (status === 'foreign' || status === 'unrecognized') warnings.push('workspace_not_recognized');
46
+ if (selectedName !== CANONICAL_WORKSPACE_DIR) warnings.push('legacy_workspace_not_migrated');
47
+ if (snapshot.canonical.foreign && selectedName !== CANONICAL_WORKSPACE_DIR) warnings.push('canonical_workspace_foreign');
48
+ return {
49
+ schemaVersion: 1, schema: 'leerness.state-paths/v1',
50
+ activeLayout: 'legacy', runtimeActivated: false, migrationAvailable: false,
51
+ projectRoot, projectKey, projectRelativePath: relative, git,
52
+ workspace: { selectedName, selectedPath: selected.abs, status,
53
+ canonicalPath: snapshot.canonical.abs, legacyPresent: snapshot.legacy.exists,
54
+ detectedPaths: [snapshot.canonical, snapshot.legacy].filter(value => value.exists).map(value => value.abs) },
55
+ scopes: scopeMap(snapshot.canonical.abs, selected.abs, git, projectKey), warnings,
56
+ };
57
+ }
58
+
59
+ module.exports = { resolveStatePaths };
@@ -68,18 +68,20 @@ function _root(root) {
68
68
  return path.resolve(root || process.cwd());
69
69
  }
70
70
 
71
- function _entryNames(dir) {
72
- try { return fs.readdirSync(dir); } catch { return []; }
73
- }
74
-
75
71
  function _inspectDir(root, name) {
76
72
  const abs = path.join(root, name);
77
73
  let stat = null;
78
- try { stat = fs.lstatSync(abs); } catch {}
74
+ let inspectionError = null;
75
+ try { stat = fs.lstatSync(abs); }
76
+ catch (error) { if (error.code !== 'ENOENT') inspectionError = error.code || 'unknown'; }
79
77
  const exists = !!stat;
80
78
  const isSymbolicLink = !!stat && stat.isSymbolicLink();
81
79
  const isDirectory = !!stat && stat.isDirectory() && !stat.isSymbolicLink();
82
- const entries = isDirectory ? _entryNames(abs) : [];
80
+ let entries = [];
81
+ if (isDirectory) {
82
+ try { entries = fs.readdirSync(abs); }
83
+ catch (error) { inspectionError = error.code || 'unknown'; }
84
+ }
83
85
  const markers = entries.filter((entry) => STRONG_MARKERS.has(entry));
84
86
  const live = markers.length > 0;
85
87
  const substrateEntries = name === CANONICAL_WORKSPACE_DIR
@@ -105,6 +107,9 @@ function _inspectDir(root, name) {
105
107
  substrateEntries,
106
108
  nonSubstrateEntries,
107
109
  foreign,
110
+ // Existing selection semantics are unchanged. Strict diagnostic consumers
111
+ // can distinguish unreadable metadata from an actually absent/empty store.
112
+ ...(inspectionError ? { inspectionError } : {}),
108
113
  };
109
114
  }
110
115
 
@@ -132,9 +137,10 @@ function validateWorkspaceDirName(value) {
132
137
  return v;
133
138
  }
134
139
 
135
- function resolveWorkspaceDirName(root, opts = {}) {
136
- const state = inspectWorkspace(root);
137
- const envValue = opts.envValue === undefined ? process.env.LEERNESS_WORKSPACE_DIR : opts.envValue;
140
+ // Selection is pure so one read-only operation can reuse its inspection
141
+ // snapshot without repeating directory reads. Callers that need a fresh
142
+ // filesystem view should keep using resolveWorkspaceDirName below.
143
+ function selectWorkspaceDirName(state, envValue) {
138
144
  const forced = validateWorkspaceDirName(envValue);
139
145
  for (const candidate of [state.legacy, state.canonical]) {
140
146
  if (candidate.isSymbolicLink) {
@@ -175,6 +181,12 @@ function resolveWorkspaceDirName(root, opts = {}) {
175
181
  return CANONICAL_WORKSPACE_DIR;
176
182
  }
177
183
 
184
+ function resolveWorkspaceDirName(root, opts = {}) {
185
+ const state = inspectWorkspace(root);
186
+ const envValue = opts.envValue === undefined ? process.env.LEERNESS_WORKSPACE_DIR : opts.envValue;
187
+ return selectWorkspaceDirName(state, envValue);
188
+ }
189
+
178
190
  function workspacePath(root, ...parts) {
179
191
  const resolvedRoot = _root(root);
180
192
  return path.join(resolvedRoot, resolveWorkspaceDirName(resolvedRoot), ...parts);
@@ -832,6 +844,7 @@ module.exports = {
832
844
  PROTECTED_GITIGNORE_LINES,
833
845
  WorkspaceDirectoryError,
834
846
  inspectWorkspace,
847
+ selectWorkspaceDirName,
835
848
  validateWorkspaceDirName,
836
849
  resolveWorkspaceDirName,
837
850
  workspacePath,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.185",
3
+ "version": "1.36.186",
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",
@@ -48,13 +48,13 @@
48
48
  "LICENSE"
49
49
  ],
50
50
  "scripts": {
51
- "test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/provider-capacity-probe.js && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
52
- "test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/provider-capacity-probe.js && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js",
51
+ "test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/provider-capacity-probe.js && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
52
+ "test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/provider-capacity-probe.js && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js",
53
53
  "test:commands": "node ./scripts/command-flags-probe.js && node ./scripts/dead-flags-probe.js && node ./scripts/false-claim-probe.js && node ./scripts/next-action-suggestion-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js",
54
54
  "test:next-actions": "node ./scripts/next-action-suggestion-probe.js",
55
55
  "test:installed": "node ./scripts/installed-cleanroom-probe.js",
56
56
  "test:handoff": "node ./scripts/handoff-readonly-probe.js",
57
- "test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/provider-capacity-probe.js && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && node ./scripts/false-claim-probe.js && node ./scripts/smoke.js",
57
+ "test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/provider-capacity-probe.js && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && node ./scripts/false-claim-probe.js && node ./scripts/smoke.js",
58
58
  "test:false-claims": "node ./scripts/false-claim-probe.js",
59
59
  "test:workspace-lock-order": "node ./scripts/workspace-dir-lock-order-probe.js",
60
60
  "test:mcp-presence": "node ./scripts/mcp-presence-probe.js",
@@ -67,7 +67,8 @@
67
67
  "test:file-lease": "node ./scripts/file-lease-probe.js",
68
68
  "test:role-agent-schema": "node ./scripts/role-agent-schema-probe.js",
69
69
  "test:role-store-loader": "node ./scripts/role-store-loader-probe.js",
70
- "test:role-fallback": "node ./scripts/role-fallback-probe.js"
70
+ "test:role-fallback": "node ./scripts/role-fallback-probe.js",
71
+ "test:state-scopes": "node ./scripts/workspace-selection-probe.js && node ./scripts/state-scopes-probe.js && node ./scripts/state-inspect-cli-probe.js"
71
72
  },
72
73
  "publishConfig": {
73
74
  "access": "public"
@@ -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 === 100 && catalogJson?.categories && !catalog.stderr,
75
+ catalog.status === 0 && catalogJson?.totalCommands === 101 && catalogJson?.categories && !catalog.stderr,
76
76
  catalog);
77
77
  check('commands totalCommands equals the category sum',
78
78
  catalogJson?.totalCommands === catalogRows.length,
@@ -403,6 +403,7 @@ try {
403
403
  ['preview', ['preview']],
404
404
  ['preview', ['preview', 'list']],
405
405
  ['preview', ['preview', 'show', 'P-0001']],
406
+ ['state', ['state', 'inspect']],
406
407
  ].every(([cmd, argv]) => _cliMutationClass(argv, cmd) === 'observation-only'));
407
408
  let readOnlyStatusesOk = true;
408
409
  for (const args of [
@@ -421,6 +422,7 @@ try {
421
422
  ['security-surface', '--json'],
422
423
  ['preview', '--json'],
423
424
  ['preview', 'list', '--json'],
425
+ ['state', 'inspect', '--json'],
424
426
  ]) {
425
427
  const readOnlyResult = run(root, args);
426
428
  if (readOnlyResult.status !== 0) readOnlyStatusesOk = false;
@@ -441,6 +443,7 @@ try {
441
443
  { args: ['identity', '--json'], status: 0 },
442
444
  { args: ['status', '--json'], status: 0 },
443
445
  { args: ['auto-update', 'status', '--json'], status: 0 },
446
+ { args: ['state', 'inspect', '--json'], status: 0 },
444
447
  // These surfaces currently require the canonical workspace to render;
445
448
  // on legacy input they may fail read-only, but must never migrate it.
446
449
  { args: ['dashboard', '--json'], status: 1 },
@@ -1054,7 +1054,7 @@ async function main() {
1054
1054
  && PU._requiredTier('npx --yes leerness lease acquire reports/release publish/status.js') === 'safe-write');
1055
1055
 
1056
1056
  check('command catalog and both help locales advertise exact-file lease',
1057
- catalog.status === 0 && catalogJson?.totalCommands === 100
1057
+ catalog.status === 0 && catalogJson?.totalCommands === rows.length
1058
1058
  && rows.some(row => /^lease acquire\|release\|list\|check\b/.test(row.cmd))
1059
1059
  && /lease acquire\|release\|list\|check/.test(helpEn.stdout)
1060
1060
  && /leerness lease acquire\|release\|list\|check/.test(helpKo.stdout),