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.
- package/CHANGELOG.md +4 -0
- package/README.ko.md +5 -0
- package/README.md +9 -4
- package/bin/leerness.js +50 -3
- package/docs/state-paths-api.md +92 -0
- package/docs/state-scopes.md +264 -0
- package/lib/pure-utils.js +20 -0
- package/lib/state-git.js +138 -0
- package/lib/state-inspect.js +39 -0
- package/lib/state-inventory.js +69 -0
- package/lib/state-paths.js +59 -0
- package/lib/workspace-dir.js +22 -9
- package/package.json +6 -5
- package/scripts/command-flags-probe.js +1 -1
- package/scripts/false-claim-probe.js +3 -0
- package/scripts/file-lease-probe.js +1 -1
- package/scripts/state-inspect-cli-probe.js +209 -0
- package/scripts/state-scopes-probe.js +590 -0
- package/scripts/workspace-selection-probe.js +230 -0
package/lib/state-git.js
ADDED
|
@@ -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 };
|
package/lib/workspace-dir.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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.
|
|
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 ===
|
|
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 ===
|
|
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),
|