leerness 1.36.148 → 1.36.161
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 +59 -0
- package/README.ko.md +4 -1
- package/README.md +9 -5
- package/bin/leerness.js +1663 -271
- package/lib/catalogs.js +1 -1
- package/lib/cursor-session-hook.js +76 -0
- package/lib/diagnostics.js +28 -14
- package/lib/git.js +5 -5
- package/lib/io.js +6 -5
- package/lib/mcp-tools.js +1 -1
- package/lib/npm-process.js +90 -0
- package/lib/pure-utils.js +2 -1
- package/lib/session-presence.js +16 -3
- package/lib/team.js +16 -5
- package/package.json +6 -4
- package/scripts/e2e-command-surface.js +304 -0
- package/scripts/e2e-concurrency.js +141 -0
- package/scripts/e2e-core.js +24 -0
- package/scripts/e2e.js +356 -65
- package/scripts/handoff-readonly-probe.js +403 -0
- package/scripts/lock-probe.js +108 -5
- package/scripts/platform-smoke.js +185 -0
- package/scripts/release-bump-probe.js +137 -0
- package/scripts/release-runtime-probe.js +210 -0
- package/scripts/smoke.js +5 -3
package/lib/catalogs.js
CHANGED
|
@@ -23,7 +23,7 @@ const POWERFUL_COMMANDS = [
|
|
|
23
23
|
];
|
|
24
24
|
const ADAPTERS = {
|
|
25
25
|
claude: { label: 'Anthropic Claude Code', keys: ['CLAUDE.md', '.claude/commands/handoff.md', '.claude/commands/session-close.md', '.claude/commands/audit.md', '.claude/commands/lazy-detect.md', '.claude/commands/update.md', '.claude/skills/leerness/SKILL.md'], mcp: true, mcpFiles: ['.mcp.json'], mcpEvidence: 'documented' },
|
|
26
|
-
cursor: { label: 'Cursor', keys: ['.cursor/rules/leerness.mdc'], mcp: true, mcpFiles: ['.cursor/mcp.json', '.mcp.json'], mcpEvidence: 'documented' },
|
|
26
|
+
cursor: { label: 'Cursor', keys: ['.cursor/rules/leerness.mdc'], hookFiles: ['.cursor/hooks.json', '.cursor/hooks/leerness-session.cjs'], mcp: true, mcpFiles: ['.cursor/mcp.json', '.mcp.json'], mcpEvidence: 'documented' },
|
|
27
27
|
copilot: { label: 'GitHub Copilot', keys: ['.github/copilot-instructions.md'], mcp: false },
|
|
28
28
|
// 1.36.128 (실측): `.mcp.json` 을 둔 디렉터리에서 `codex mcp list` 출력이 대조군과 **완전히 동일**했고
|
|
29
29
|
// `codex mcp get` 은 "No MCP server named ... found" 를 냈다 — codex 는 프로젝트 `.mcp.json` 을 읽지 않는다.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Cursor project hook wiring for per-conversation leerness session addresses.
|
|
4
|
+
// Pure helpers only: adapter IO and conflict handling stay in bin/leerness.js.
|
|
5
|
+
|
|
6
|
+
const CURSOR_HOOK_CONFIG = '.cursor/hooks.json';
|
|
7
|
+
const CURSOR_HOOK_SCRIPT = '.cursor/hooks/leerness-session.cjs';
|
|
8
|
+
const CURSOR_HOOK_COMMAND = 'node .cursor/hooks/leerness-session.cjs';
|
|
9
|
+
const CURSOR_HOOK_MARK = '// leerness:managed cursor-session-address';
|
|
10
|
+
|
|
11
|
+
function cursorSessionHookScript() {
|
|
12
|
+
return `'use strict';
|
|
13
|
+
${CURSOR_HOOK_MARK}
|
|
14
|
+
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
let raw = '';
|
|
17
|
+
process.stdin.setEncoding('utf8');
|
|
18
|
+
process.stdin.on('data', chunk => { raw += chunk; });
|
|
19
|
+
process.stdin.on('end', () => {
|
|
20
|
+
try {
|
|
21
|
+
const input = JSON.parse(raw || '{}');
|
|
22
|
+
const source = String(input.session_id || input.conversation_id || '').trim();
|
|
23
|
+
if (!source) return;
|
|
24
|
+
const sessionKey = /^[A-Za-z0-9_-]{8,64}$/.test(source)
|
|
25
|
+
? source
|
|
26
|
+
: 'cursor-' + crypto.createHash('sha256').update(source).digest('hex').slice(0, 32);
|
|
27
|
+
process.stdout.write(JSON.stringify({
|
|
28
|
+
env: { LEERNESS_SESSION_ID: sessionKey },
|
|
29
|
+
additional_context: '[leerness] This Cursor conversation now has a unique session address. Run leerness handoff . before editing; it loads shared context without changing tracked project files.'
|
|
30
|
+
}) + '\\n');
|
|
31
|
+
} catch {}
|
|
32
|
+
});
|
|
33
|
+
`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function validateCursorHooksConfig(config) {
|
|
37
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
38
|
+
throw new TypeError('Cursor hooks config root must be an object');
|
|
39
|
+
}
|
|
40
|
+
if (Object.prototype.hasOwnProperty.call(config, 'hooks')) {
|
|
41
|
+
if (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)) {
|
|
42
|
+
throw new TypeError('Cursor hooks config hooks must be an object');
|
|
43
|
+
}
|
|
44
|
+
if (Object.prototype.hasOwnProperty.call(config.hooks, 'sessionStart')
|
|
45
|
+
&& !Array.isArray(config.hooks.sessionStart)) {
|
|
46
|
+
throw new TypeError('Cursor hooks config sessionStart must be an array');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return config;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function mergeCursorHooksConfig(config) {
|
|
53
|
+
validateCursorHooksConfig(config);
|
|
54
|
+
const next = JSON.parse(JSON.stringify(config));
|
|
55
|
+
if (!Object.prototype.hasOwnProperty.call(next, 'version')) next.version = 1;
|
|
56
|
+
if (!next.hooks) next.hooks = {};
|
|
57
|
+
if (!next.hooks.sessionStart) next.hooks.sessionStart = [];
|
|
58
|
+
const already = next.hooks.sessionStart.some(entry => entry && entry.command === CURSOR_HOOK_COMMAND);
|
|
59
|
+
if (!already) next.hooks.sessionStart.push({ command: CURSOR_HOOK_COMMAND });
|
|
60
|
+
return { config: next, changed: !already };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isManagedCursorHookScript(content) {
|
|
64
|
+
return String(content || '').includes(CURSOR_HOOK_MARK);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
CURSOR_HOOK_CONFIG,
|
|
69
|
+
CURSOR_HOOK_SCRIPT,
|
|
70
|
+
CURSOR_HOOK_COMMAND,
|
|
71
|
+
CURSOR_HOOK_MARK,
|
|
72
|
+
cursorSessionHookScript,
|
|
73
|
+
validateCursorHooksConfig,
|
|
74
|
+
mergeCursorHooksConfig,
|
|
75
|
+
isManagedCursorHookScript,
|
|
76
|
+
};
|
package/lib/diagnostics.js
CHANGED
|
@@ -9,20 +9,34 @@ const path = require('path');
|
|
|
9
9
|
const { log, fail, absRoot, exists, read } = require('./io');
|
|
10
10
|
const { parseHarnessVersion, _parseChangelogBetween, compareVer } = require('./pure-utils');
|
|
11
11
|
|
|
12
|
-
function doctorCmd(opts = {}, deps = {}) {
|
|
13
|
-
const { VERSION, uiLang, _selfTestCases,
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
12
|
+
function doctorCmd(opts = {}, deps = {}) {
|
|
13
|
+
const { VERSION, uiLang, _selfTestCases, _selfTestReport, _controlSelftestEnv,
|
|
14
|
+
_detectShellCtx, _mcpToolCount, has, harnessPath } = deps;
|
|
15
|
+
const t = (ko, en) => (uiLang === 'en' ? en : ko); // 1.28.2 (UR-0010 Phase 10c)
|
|
16
|
+
const json = opts.json || has('--json');
|
|
17
|
+
// 1) 코어 무결성: selftest 케이스 인라인 실행
|
|
18
|
+
// 1.36.160: CLI selftest는 실제 에이전트 세션/Cursor/언어 설정을 격리하는데 doctor의 인라인 실행만
|
|
19
|
+
// 그 병목을 우회했다. 운영 경로는 격리된 자식 프로세스 보고서를 주입받아 장시간 검사 중 부모
|
|
20
|
+
// MCP/library 프로세스의 process.env를 건드리지 않는다. 작은 DI 단위 테스트만 동기 fallback을 쓴다.
|
|
21
|
+
let pass = 0; let total = 0; let failNames = [];
|
|
22
|
+
let isolated = null;
|
|
23
|
+
try { if (typeof _selfTestReport === 'function') isolated = _selfTestReport(); } catch { isolated = null; }
|
|
24
|
+
if (isolated && Number.isSafeInteger(isolated.pass) && Number.isSafeInteger(isolated.total)
|
|
25
|
+
&& isolated.pass >= 0 && isolated.total >= isolated.pass && Array.isArray(isolated.failed)) {
|
|
26
|
+
pass = isolated.pass; total = isolated.total; failNames = isolated.failed.map(String);
|
|
27
|
+
if (isolated.ok !== true && failNames.length === 0) failNames = ['selftest subprocess failed'];
|
|
28
|
+
} else {
|
|
29
|
+
const controlledEnv = typeof _controlSelftestEnv === 'function' ? _controlSelftestEnv() : null;
|
|
30
|
+
try {
|
|
31
|
+
for (const c of _selfTestCases()) {
|
|
32
|
+
let okc = false;
|
|
33
|
+
try { okc = !!c.run(); } catch { okc = false; }
|
|
34
|
+
if (okc) pass++; else failNames.push(c.name);
|
|
35
|
+
}
|
|
36
|
+
} catch {}
|
|
37
|
+
finally { try { if (controlledEnv && typeof controlledEnv.restore === 'function') controlledEnv.restore(); } catch {} }
|
|
38
|
+
total = pass + failNames.length;
|
|
39
|
+
}
|
|
26
40
|
// 2) 셸/PowerShell 컨텍스트 (UR-0052)
|
|
27
41
|
let shell = null, psVersion = null;
|
|
28
42
|
try { const ctx = _detectShellCtx(); shell = ctx.shell; psVersion = ctx.psVersion; } catch {}
|
package/lib/git.js
CHANGED
|
@@ -23,6 +23,10 @@ const cp = require('child_process');
|
|
|
23
23
|
// ⚠ `GIT_CONFIG_GLOBAL` / `GIT_CONFIG_SYSTEM` 은 **걷어내지 않는다** — 그것은 사용자의 설정 파일을 가리키는
|
|
24
24
|
// 지속 설정이고, git 이 사용자 커밋에서도 똑같이 쓴다. 그 경우의 `core.hooksPath` 는 정직하게 따르되
|
|
25
25
|
// `hooksPathShared: true` 로 **고지**한다(실측 확인).
|
|
26
|
+
// ⚠ `GIT_CONFIG_NOSYSTEM` 도 **걷어내지 않는다.** 저장소 대상을 바꾸는 값이 아니라 시스템 설정을 읽을지의
|
|
27
|
+
// 실행 의미다. 이것만 내부 git 에서 지우면 설치기는 system `core.hooksPath` 에 훅을 쓰고, 같은 셸의 실제
|
|
28
|
+
// `git commit` 은 기본 `.git/hooks` 를 보게 되어 강제를 우회한다. 현재 호출자가 시스템 설정을 숨기면
|
|
29
|
+
// 설치기 역시 같은 설정을 보아야 한다.
|
|
26
30
|
// ⚠ `GIT_CEILING_DIRECTORIES` 도 **걷어내지 않는다.** 한 번 넣었다가 뺐다 — 내가 틀렸다.
|
|
27
31
|
// "정상 저장소를 not-a-repo 로 오판" 이라 읽었지만, 그 환경변수는 사용자가 **일부러 그은 경계**이고
|
|
28
32
|
// git 은 그 경계 밖으로 올라가지 않는 것이 정답이다. 우리가 걷어내면 사용자가 막아 둔 **상위 저장소**를
|
|
@@ -30,11 +34,7 @@ const cp = require('child_process');
|
|
|
30
34
|
// `not-a-repo` 라고 정직하게 답한다.
|
|
31
35
|
const GIT_LOCATION_ENV = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR',
|
|
32
36
|
'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_NAMESPACE',
|
|
33
|
-
'GIT_CONFIG', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_PARAMETERS'
|
|
34
|
-
// 1.36.143 (재검수 P1): `GIT_CONFIG_NOSYSTEM=1` 은 값을 주지 않고 **시스템 설정을 감춘다** —
|
|
35
|
-
// 시스템 `core.hooksPath` 가 있는 환경에서 우리만 그것을 못 보고 `.git/hooks` 에 설치하면,
|
|
36
|
-
// 사용자의 실제 커밋은 시스템 훅을 써서 강제가 **그냥 우회**된다. 같은 클래스다.
|
|
37
|
-
'GIT_CONFIG_NOSYSTEM'];
|
|
37
|
+
'GIT_CONFIG', 'GIT_CONFIG_COUNT', 'GIT_CONFIG_PARAMETERS'];
|
|
38
38
|
const _DROP = new Set(GIT_LOCATION_ENV);
|
|
39
39
|
// `GIT_CONFIG_KEY_0`, `GIT_CONFIG_VALUE_0`, … 은 개수가 정해져 있지 않으므로 접두로 판별한다.
|
|
40
40
|
const _DROP_PREFIX = /^GIT_CONFIG_(KEY|VALUE)_\d+$/;
|
package/lib/io.js
CHANGED
|
@@ -125,15 +125,17 @@ function readBuf(p) { return fs.readFileSync(p); }
|
|
|
125
125
|
// `task add` · `decision add` · `rule add` · `plan add` · `lesson save` · `state start` · `ci init` 은 **실제로 추가**했다.
|
|
126
126
|
// 명령마다 dry-run 분기를 심으면 16개의 새 실수 자리가 생긴다. 그래서 **쓰기 시점 하나**에서 막는다:
|
|
127
127
|
// dry-run 인데 쓰려 하면 그 자리에서 멈추고 무엇을 쓰려 했는지 말한다(조용히 쓰지도, 조용히 넘어가지도 않는다).
|
|
128
|
-
// ⚠
|
|
129
|
-
//
|
|
128
|
+
// ⚠ 면제는 실행 중에만 의미가 있는 락/임시 파일로 한정한다. 캐시는 파생 상태여도 디스크에 남는
|
|
129
|
+
// 관측 가능한 변경이다 — dry-run 이 캐시를 남기면 "변경 없음" 이 거짓이 된다.
|
|
130
130
|
let _dryGuard = false;
|
|
131
131
|
// ⚠ 1.36.146 (재검수 P1, 재현): 첫 면제 규칙은 `[\\/]cache[\\/]` 였다 — 경로 **어디에든**
|
|
132
132
|
// `cache` 디렉토리가 있으면 면제돼, `C:/Users/me/cache/myproject/` 에 사는 프로젝트는
|
|
133
133
|
// dry-run 가드가 **통째로 꺼졌다**(실측: `decision add --dry-run` 이 그대로 기록됨).
|
|
134
|
-
// 면제는 우리가 아는
|
|
134
|
+
// 면제는 우리가 아는 실행 중 산출물(락/임시 파일)로만 한정한다.
|
|
135
135
|
const _DRY_EXEMPT_TAIL = /(\.lock$|\.tmp-\d+-\d+$)/;
|
|
136
|
-
|
|
136
|
+
// 1.36.154 (T-0117 재검수): cache 는 "사용자 데이터가 아니다"라는 이유로 면제했지만,
|
|
137
|
+
// `session close --dry-run` 이 session-presence의 .host-salt·세션 레코드를 실제 생성했다.
|
|
138
|
+
// dry-run 의 약속은 데이터 성격이 아니라 영속적 파일 변경 0건이므로 cache 면제를 두지 않는다.
|
|
137
139
|
// ⚠ 한 번 `os.tmpdir()` 전체를 면제했다가 뺐다 — 그러면 **내 테스트가 전부 임시 디렉토리에 있어서**
|
|
138
140
|
// 실사용에서는 막히는데 계측만 죽는다(실측: 같은 프로브가 "가드 무력" 이라 보고). 가장 나쁜 조합이다.
|
|
139
141
|
// 면제는 leerness **자신이 만드는 임시 산출물**의 이름 규칙으로만 한정한다.
|
|
@@ -141,7 +143,6 @@ const _DRY_EXEMPT_OURS = /leerness-probe-\d+-\d+\.sh$/;
|
|
|
141
143
|
function _dryExempt(q) {
|
|
142
144
|
const s = String(q);
|
|
143
145
|
if (_DRY_EXEMPT_TAIL.test(s)) return true;
|
|
144
|
-
if (_DRY_EXEMPT_DIR.test(s)) return true;
|
|
145
146
|
if (_DRY_EXEMPT_OURS.test(s)) return true;
|
|
146
147
|
return false;
|
|
147
148
|
}
|
package/lib/mcp-tools.js
CHANGED
|
@@ -77,7 +77,7 @@ module.exports = [
|
|
|
77
77
|
{ name: 'leerness_agent_mode', requiredTier: 'safe-write', description: '1.9.239 (사용자 명시 UR-0013) — 자율 모드 전용 통합 명령. start: handoff + drift --auto-fix + session-resume --auto-fix (진입). tick: pulse 한 줄 (매 라운드). stop: session close --auto-apply-delivered --auto-cleanup-branches (마감). 외부 AI 가 자율 라운드 진입/매 라운드/마감을 단일 호출로 수행. 인자: { path?, sub (required: "start"|"tick"|"stop"|"help") }', inputSchema: { type: 'object', properties: { path: { type: 'string' }, sub: { type: 'string', enum: ['start', 'tick', 'stop', 'help'] } }, required: ['sub'] } },
|
|
78
78
|
{ name: 'leerness_env_info', requiredTier: 'read-only', description: '1.9.241 (사용자 명시 UR-0014) — 환경 종합 정보 회수. OS / 언어 (LANG, 코드페이지) / 한국어 Windows / 하드웨어 / 터미널 (TTY, PowerShell 버전) / 도구 버전 (git, npm, python). 외부 AI 가 환경 호환성을 미리 인지 → 인코딩 오류 예방. 응답: { os, node, locale, hardware, terminal, tools }. 인자: { path?, encodingCheck? }. encodingCheck: true 시 셸 스크립트 (.ps1/.bat/.cmd/.sh) BOM 없는 비-ASCII 위험 감지', inputSchema: { type: 'object', properties: { path: { type: 'string' }, encodingCheck: { type: 'boolean' } } } },
|
|
79
79
|
{ name: 'leerness_api_skill', requiredTier: 'safe-write', description: '1.9.245 (사용자 명시 UR-0015) — API 문서·관련링크 자동 캐시. 공식 API 문서 URL을 fetch 하고 1단계 same-domain 관련 링크까지 정리 → .harness/api-skills/<id>.md 저장. 후속 같은 API 관련 작업 시 자동 참조. 응답: list (skills 배열) / show (전체 본문) / match (task 매칭 결과). 인자: { path?, sub ("list"|"show"|"match"|"add"|"drop"), url? (add), id? (show/drop), query? (match), direction? (add: 구현 방향 텍스트) }. 외부 AI가 "이 프로젝트 어떤 API 문서가 정리되어 있나?" / "내 작업과 매칭되는 API skill 있나?" 회수.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, sub: { type: 'string', enum: ['list', 'show', 'match', 'add', 'drop'] }, url: { type: 'string' }, id: { type: 'string' }, query: { type: 'string' }, direction: { type: 'string' } }, required: ['sub'] } },
|
|
80
|
-
{ name: 'leerness_selftest', requiredTier: 'read-only', description: '1.9.258/259 — 설치된 leerness 바이너리의 코어 함수(보안 _isSecretKey / 버전 compareVer / 인코딩 _classifyCJK 등) 무결성 자가 검증. 응답: { version, total, pass, fail, ok, results[] }. 외부 AI/CI 가 "이 leerness 설치가 정상인가?(npx 캐시 손상·부분 설치 감지)" 를
|
|
80
|
+
{ name: 'leerness_selftest', requiredTier: 'read-only', description: '1.9.258/259 — 설치된 leerness 바이너리의 코어 함수(보안 _isSecretKey / 버전 compareVer / 인코딩 _classifyCJK 등) 무결성 자가 검증. 응답: { version, total, pass, fail, ok, results[] }. 외부 AI/CI 가 "이 leerness 설치가 정상인가?(npx 캐시 손상·부분 설치 감지)" 를 확인하며, 행위 검사가 포함돼 환경에 따라 수십 초 걸릴 수 있음. 인자: 없음.', inputSchema: { type: 'object', properties: {} } },
|
|
81
81
|
{ name: 'leerness_shell_guard', requiredTier: 'read-only', description: '1.9.260/261 (사용자 명시 UR-0020) — 터미널 명령 셸 호환성 린터. 외부 AI 가 명령을 실행하기 전에 셸 호환성 문제를 사전 점검. 예: Windows PowerShell 5.1 은 && / || 미지원 → A; if ($?) { B } 제안. 6 규칙(ps5-chain/ps-devnull/ps-inline-env/ps-rm-rf/cmd-semicolon/ps-version-unknown) + 과거 실패 회수(.harness/shell-failures.json). 응답: { shell, psVersion, issues[{rule,severity,detail,suggestion}], pastSame, pastSimilar, ok }. 인자: { command (required), path? }. 현재 셸/PS 버전 자동 감지.', inputSchema: { type: 'object', properties: { command: { type: 'string' }, path: { type: 'string' } }, required: ['command'] } },
|
|
82
82
|
{ name: 'leerness_slash_commands', requiredTier: 'safe-write', description: '1.9.265/266 (사용자 명시 UR-0021) — CLI AI 에이전트별 슬래시 명령어 레지스트리. 외부 AI(메인)가 sub-agent(codex/agy/claude/grok/copilot)를 호출할 때 각 에이전트에 알맞는 슬래시 명령을 참조. 빌트인 + 사용자 .harness/agent-slash-commands.json override 병합. 응답: { agents: { <id>: { label, asOf, invoke(slash|subcommand), note, source, count, commands[{cmd,desc}] } } }. 인자: { path?, agent? (생략 시 전체), refresh? (1.9.267 — 설치된 CLI --help probe 자동 갱신), dryRun? }. agent 지정 시 단일.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, agent: { type: 'string' }, refresh: { type: 'boolean' }, dryRun: { type: 'boolean' } } } },
|
|
83
83
|
{ name: 'leerness_roles', requiredTier: 'safe-write', description: '1.9.270 (사용자 명시) — 모델별 역할 부여. 여러 AI 에이전트 활성 시 역할(commander/reviewer/coder/architect/designer/debugger/dispatcher)을 provider+model 에 매핑하고, agents dispatch --role 로 라우팅. sub=suggest 면 활성 에이전트 기반 최적 배치 + 근거 반환(방향성 판단). sub=set 시 role/provider/model 필요. 응답: list/suggest/verify 별 JSON. 인자: { path?, sub(list|set|unset|catalog|suggest|verify), role?, provider?, model?, apply? }.', inputSchema: { type: 'object', properties: { path: { type: 'string' }, sub: { type: 'string' }, role: { type: 'string' }, provider: { type: 'string' }, model: { type: 'string' }, apply: { type: 'boolean' } } } },
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const cp = require('child_process');
|
|
6
|
+
|
|
7
|
+
function _isFile(filePath) {
|
|
8
|
+
if (!filePath) return false;
|
|
9
|
+
try { return fs.statSync(filePath).isFile(); } catch { return false; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function _envValue(env, name) {
|
|
13
|
+
if (!env) return null;
|
|
14
|
+
if (env[name]) return env[name];
|
|
15
|
+
const key = Object.keys(env).find(candidate => candidate.toLowerCase() === name.toLowerCase());
|
|
16
|
+
return key ? env[key] : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function _npmCliCandidates(execPath, env) {
|
|
20
|
+
const nodeDir = path.dirname(execPath);
|
|
21
|
+
const envExecPath = _envValue(env, 'npm_execpath');
|
|
22
|
+
const envNpmCli = /[\\/]npm-cli\.(?:c?js)$/i.test(String(envExecPath || '')) ? envExecPath : null;
|
|
23
|
+
return [
|
|
24
|
+
envNpmCli,
|
|
25
|
+
path.join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
26
|
+
path.resolve(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
27
|
+
path.resolve(nodeDir, '..', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
28
|
+
].filter(Boolean);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolveNpmCliPath(options = {}) {
|
|
32
|
+
const execPath = options.execPath || process.execPath;
|
|
33
|
+
const env = options.env || process.env;
|
|
34
|
+
const candidates = _npmCliCandidates(execPath, env);
|
|
35
|
+
for (const candidate of candidates) {
|
|
36
|
+
if (_isFile(candidate)) return candidate;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if ((options.platform || process.platform) !== 'win32') return null;
|
|
40
|
+
const where = cp.spawnSync('where.exe', ['npm.cmd'], {
|
|
41
|
+
encoding: 'utf8',
|
|
42
|
+
env,
|
|
43
|
+
timeout: 5000,
|
|
44
|
+
windowsHide: true,
|
|
45
|
+
});
|
|
46
|
+
if (where.status !== 0) return null;
|
|
47
|
+
for (const wrapper of String(where.stdout || '').split(/\r?\n/).filter(Boolean)) {
|
|
48
|
+
const candidate = path.join(path.dirname(wrapper.trim()), 'node_modules', 'npm', 'bin', 'npm-cli.js');
|
|
49
|
+
if (_isFile(candidate)) return candidate;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function _missingNpmResult() {
|
|
55
|
+
const error = new Error('npm CLI JavaScript entrypoint not found');
|
|
56
|
+
error.code = 'ENOENT';
|
|
57
|
+
return {
|
|
58
|
+
pid: 0,
|
|
59
|
+
output: [null, '', error.message],
|
|
60
|
+
stdout: '',
|
|
61
|
+
stderr: error.message,
|
|
62
|
+
status: null,
|
|
63
|
+
signal: null,
|
|
64
|
+
error,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function spawnNpmSync(args, options = {}) {
|
|
69
|
+
if (!Array.isArray(args)) throw new TypeError('npm args must be an array');
|
|
70
|
+
const npmArgs = args.map(value => String(value));
|
|
71
|
+
const hasExplicitCli = Object.prototype.hasOwnProperty.call(options, 'npmCliPath');
|
|
72
|
+
const { npmCliPath: explicitCli, ...childOptions } = options;
|
|
73
|
+
const spawnOptions = { ...childOptions, shell: false };
|
|
74
|
+
const env = spawnOptions.env || process.env;
|
|
75
|
+
const resolvedCli = hasExplicitCli
|
|
76
|
+
? (_isFile(explicitCli) ? explicitCli : null)
|
|
77
|
+
: resolveNpmCliPath({ execPath: process.execPath, env, platform: process.platform });
|
|
78
|
+
|
|
79
|
+
// npm_execpath가 가리키는 npm-cli.js를 우선하면 npm/pnpm을 섞어 실행하지 않고,
|
|
80
|
+
// Windows에서도 .cmd 셸 래퍼 없이 argv를 그대로 보존할 수 있다.
|
|
81
|
+
if (resolvedCli) {
|
|
82
|
+
if (spawnOptions.windowsHide === undefined) spawnOptions.windowsHide = true;
|
|
83
|
+
return cp.spawnSync(process.execPath, [resolvedCli, ...npmArgs], spawnOptions);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (process.platform === 'win32' || hasExplicitCli) return _missingNpmResult();
|
|
87
|
+
return cp.spawnSync('npm', npmArgs, spawnOptions);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { resolveNpmCliPath, spawnNpmSync };
|
package/lib/pure-utils.js
CHANGED
|
@@ -1602,7 +1602,8 @@ function _renderWorkspaceReferenceGuide(dirName, version, generatedAt) {
|
|
|
1602
1602
|
lines.push(`├── platform-constraints.json ← API 제약 catalog (1.9.208)`);
|
|
1603
1603
|
lines.push(`├── auto-resume-plan.json ← 다음 라운드 plan (1.9.203)`);
|
|
1604
1604
|
lines.push(`├── next-action-queue.json ← 다음 next-action 큐 (1.9.201)`);
|
|
1605
|
-
lines.push(`├──
|
|
1605
|
+
lines.push(`├── cache/sessions/ ← ignored 세션별 handoff runtime record`);
|
|
1606
|
+
lines.push(`├── last-handoff.json ← legacy projection (handoff --writeback)`);
|
|
1606
1607
|
lines.push(`├── environment.json ← 환경 변동 추적 (1.9.145)`);
|
|
1607
1608
|
lines.push(`├── skills/ ← 설치된 skill 디렉토리`);
|
|
1608
1609
|
lines.push(`└── templates/ ← 워크스페이스 템플릿`);
|
package/lib/session-presence.js
CHANGED
|
@@ -19,7 +19,17 @@ const SESSIONS_CLOSED_TTL_MS = 7 * 24 * 3600 * 1000; // 임의의 수 — 아
|
|
|
19
19
|
|
|
20
20
|
const KEY_RE = /^[A-Za-z0-9_-]{8,64}$/;
|
|
21
21
|
const FILE_RE = /^[A-Za-z0-9_-]{8,64}\.json$/;
|
|
22
|
-
|
|
22
|
+
// 내부 anonymous 슬롯과 사용자 세션 주소는 같은 파일명을 공유하면 안 된다.
|
|
23
|
+
// 대소문자를 접은 뒤 예약어를 거부해 `UNADDRESSED` 도 익명 marker 와 충돌하지 않게 한다.
|
|
24
|
+
const RESERVED_KEYS = new Set(['unaddressed']);
|
|
25
|
+
// 자체 점검이 쓰는 격리 marker는 실제 세션 주소가 될 수 없어야 한다. 그렇지 않으면 enforce install이
|
|
26
|
+
// 같은 주소의 실제 handoff를 저장/복원 사이에 되돌려 버릴 수 있다.
|
|
27
|
+
const RESERVED_PREFIXES = ['leerness-internal-'];
|
|
28
|
+
function isValidKey(s) {
|
|
29
|
+
if (typeof s !== 'string' || !KEY_RE.test(s)) return false;
|
|
30
|
+
const folded = s.toLowerCase();
|
|
31
|
+
return !RESERVED_KEYS.has(folded) && !RESERVED_PREFIXES.some(prefix => folded.startsWith(prefix));
|
|
32
|
+
}
|
|
23
33
|
// env 값의 참/거짓 — CI 계열은 `"false"`/`"0"` 을 **명시적으로** 넘기는 환경이 흔하다.
|
|
24
34
|
function isTruthyEnv(v) {
|
|
25
35
|
const t = String(v == null ? '' : v).trim().toLowerCase();
|
|
@@ -42,7 +52,7 @@ const FORBIDDEN_SIGNALS = ['CODEX_COMPANION_SESSION_ID', 'CODEX_COMPANION_TRANSC
|
|
|
42
52
|
// 실측: codex 형태 env 로 `handoff` → 레코드 파일 증가 0, `selfRecordPresent=false`. 그런데 같은 실행이
|
|
43
53
|
// "다른 세션 기록 2건" 은 출력한다. 사용자가 든 예시("codex 가 claude 에게 작업범위 전달")가 구조적으로 불가능했다.
|
|
44
54
|
// → 에이전트 무관한 **명시 주소**를 받는다. 세션마다 다른 값을 주면 그 세션이 곧 주소가 된다.
|
|
45
|
-
// 명시값을 앞에 둔다(사용자가 일부러 준 것이 추론보다 우선).
|
|
55
|
+
// 명시값을 앞에 둔다(사용자가 일부러 준 것이 추론보다 우선). 주소 없는 child 추론 억제는 그대로다.
|
|
46
56
|
// `CODEX_THREAD_ID` 는 **설정 없이** 이미 거기 있다 — codex-cli 0.147.0 실측: 소문자 UUIDv7 36자,
|
|
47
57
|
// 한 세션의 여러 자식 호출에서 바이트 동일, 세션이 바뀌면 값이 바뀐다(01a01570-54a6-… vs 01a01570-b228-…),
|
|
48
58
|
// 기존 KEY_RE 를 변환 없이 통과. 순서상 CLAUDE_* 뒤에 둔다 — claude 가 띄운 codex 는 CLAUDE 변수를 통째로
|
|
@@ -102,6 +112,9 @@ function suppressionReason(env, hasHarness) {
|
|
|
102
112
|
const e = env || {};
|
|
103
113
|
if (e.LEERNESS_INTERNAL === '1') return 'internal'; // leerness 가 자기를 스폰하는 20여 지점
|
|
104
114
|
if (e.LEERNESS_HOOK === '1') return 'hook';
|
|
115
|
+
// child는 부모의 LEERNESS_SESSION_ID도 그대로 상속할 수 있다. env 값만 보고 호출자가 새 주소를
|
|
116
|
+
// 지정했다고 단정하면 자식 활동이 부모 presence에 합쳐진다. 상태 MCP의 호출별 sessionKey는
|
|
117
|
+
// deriveSessionKey 경로에서 별도로 귀속되므로, presence 자체는 child를 계속 억제한다.
|
|
105
118
|
if (e.CLAUDE_CODE_CHILD_SESSION === '1') return 'child-agent';
|
|
106
119
|
// ⚠ (검수 P2, 실측 재현) `e.CI` 만 보면 `CI="false"`·`CI="0"` 도 억제했다 —
|
|
107
120
|
// 명시적으로 '아니다' 라고 말한 정상 로컬 환경이 조용히 등록되지 않는다. **참인 값만** 본다.
|
|
@@ -162,7 +175,7 @@ function hostRelation(recordHostId, selfHostId) {
|
|
|
162
175
|
|
|
163
176
|
module.exports = {
|
|
164
177
|
KEY_SOURCES, keySource, invalidExplicitKey,
|
|
165
|
-
SESSIONS_KEEP, SESSIONS_CLOSED_TTL_MS, KEY_RE, FILE_RE, FORBIDDEN_SIGNALS,
|
|
178
|
+
SESSIONS_KEEP, SESSIONS_CLOSED_TTL_MS, KEY_RE, FILE_RE, RESERVED_KEYS, RESERVED_PREFIXES, FORBIDDEN_SIGNALS,
|
|
166
179
|
isValidKey, isTruthyEnv, deriveSessionKey, detectOutermostAgent, suppressionReason,
|
|
167
180
|
selectPrunable, countOtherSessions, hostRelation,
|
|
168
181
|
};
|
package/lib/team.js
CHANGED
|
@@ -88,11 +88,22 @@ function teamCmd(root, sub, id, opts = {}, deps = {}) {
|
|
|
88
88
|
return;
|
|
89
89
|
}
|
|
90
90
|
if (sub === 'remove') {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
91
|
+
// 1.36.152 (T-0115): add는 teams.json 락 안에서 재로드하는데 remove만 시작 시점의 teams를
|
|
92
|
+
// 다시 써서, 이미 커밋된 병렬 add를 지웠다. 같은 키에서 재검사→삭제→저장까지 직렬화한다.
|
|
93
|
+
const _doRemove = () => {
|
|
94
|
+
const _rechk = (typeof _loadTeamsChecked === 'function') ? _loadTeamsChecked(root) : { teams: _loadTeams(root), invalid: false };
|
|
95
|
+
if (_rechk.invalid) { _fj(json, 'store_invalid', 'teams.json 형상 무효 — 덮어쓰기 거부(락 재확인)'); return null; }
|
|
96
|
+
const cur = _rechk.teams;
|
|
97
|
+
const next = cur.filter(x => x.id !== id);
|
|
98
|
+
if (next.length === cur.length) { _fj(json, 'not_found', `team 없음: ${id}`); return null; } // 1.36.78 (F4): --json 구조화 + exit 1
|
|
99
|
+
_saveTeams(root, next);
|
|
100
|
+
return next;
|
|
101
|
+
};
|
|
102
|
+
const removed = (typeof deps._withLock === 'function')
|
|
103
|
+
? deps._withLock(require('path').join(root, '.harness', 'teams.json'), _doRemove)
|
|
104
|
+
: _doRemove();
|
|
105
|
+
if (!removed) return;
|
|
106
|
+
if (json) { log(JSON.stringify({ ok: true, removed: id, count: removed.length }, null, 2)); return; }
|
|
96
107
|
ok(`team 제거: ${id}`);
|
|
97
108
|
return;
|
|
98
109
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.161",
|
|
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,9 +48,11 @@
|
|
|
48
48
|
"LICENSE"
|
|
49
49
|
],
|
|
50
50
|
"scripts": {
|
|
51
|
-
"test": "node ./scripts/lint.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/e2e.js",
|
|
52
|
-
"test:core": "node ./scripts/lint.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js",
|
|
53
|
-
"test:
|
|
51
|
+
"test": "node ./scripts/lint.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && npm run test:commands && node ./scripts/e2e.js",
|
|
52
|
+
"test:core": "node ./scripts/lint.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js",
|
|
53
|
+
"test:commands": "node ./scripts/e2e-command-surface.js",
|
|
54
|
+
"test:handoff": "node ./scripts/handoff-readonly-probe.js",
|
|
55
|
+
"test:fast": "node ./scripts/lint.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./scripts/smoke.js",
|
|
54
56
|
"test:smoke": "node ./scripts/smoke.js",
|
|
55
57
|
"lint": "node ./scripts/lint.js",
|
|
56
58
|
"prepack": "node ./bin/leerness.js readme sync . && node ./bin/leerness.js --version"
|