leerness 1.36.131 → 1.36.141
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/README.md +4 -4
- package/bin/leerness.js +793 -120
- package/lib/drift.js +7 -3
- package/lib/git.js +52 -0
- package/lib/io.js +24 -2
- package/lib/session-close.js +7 -3
- package/lib/session-presence.js +30 -4
- package/package.json +1 -1
- package/scripts/e2e.js +1183 -1
package/bin/leerness.js
CHANGED
|
@@ -34,7 +34,7 @@ const { CAPABILITY_SURFACE, POWERFUL_COMMANDS, ADAPTERS, REUSE_CATEGORIES, REUSE
|
|
|
34
34
|
const { tokenizeForRank: _tokenizeForRank, expandQuery: _expandQuery, scoreHits: _scoreHits, suggestTerms: _suggestTerms } = require('../lib/search-core'); // 1.36.23: memory search 랭킹 코어(순수·0-deps)
|
|
35
35
|
const { findCorruptedStateJson: _findCorruptedStateJson } = require('../lib/state-integrity'); // 1.36.1 (클린룸 리뷰 FN): .harness/*.json 상태 무결성 (audit/health/check 공유)
|
|
36
36
|
|
|
37
|
-
const VERSION = '1.36.
|
|
37
|
+
const VERSION = '1.36.141';
|
|
38
38
|
|
|
39
39
|
// 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
|
|
40
40
|
// 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
|
|
@@ -170,8 +170,17 @@ function _sleepSyncMs(ms) {
|
|
|
170
170
|
// O_EXCL(wx) 원자적 생성으로 상호배제. 다른 프로세스가 보유 중이면 짧게 대기 후 재시도.
|
|
171
171
|
// stale(보유 프로세스 crash) 은 staleMs 초과 시 탈취. 타임아웃(maxWaitMs) 시 락 없이 진행(원자쓰기로 손상은 이미 방지, lost-update 만 rare 노출).
|
|
172
172
|
const _heldLocks = new Set(); // 프로세스 내 재진입 추적 (중첩 _withLock 데드락 방지)
|
|
173
|
+
// 1.36.132: 락 파일을 "지금은 못 만든다" 로 읽어야 하는 오류들. Windows 에서는 상대가 unlink 하는 순간에
|
|
174
|
+
// 열면 EPERM/EACCES 가, 파일 핸들이 아직 살아 있으면 EBUSY 가 난다 — 전부 **재시도 대상**이지 포기 사유가 아니다.
|
|
175
|
+
const _LOCK_RETRY_CODES = new Set(['EEXIST', 'EPERM', 'EACCES', 'EBUSY']);
|
|
176
|
+
const _lockFailOpen = []; // 보호 없이 진행한 기록(관측용) — 조용한 fail-open 을 막는다
|
|
177
|
+
// 1.36.132 (검수 P1, 재현): 부모가 stderr 파이프를 먼저 닫으면 우리 경고 한 줄이 **비동기 EPIPE** 로 올라와
|
|
178
|
+
// 완전한 JSON 을 이미 출력한 성공 명령이 exit 1 이 됐다. try/catch 는 동기 예외만 잡으므로 소용이 없다.
|
|
179
|
+
// 경고를 못 전달하는 것은 감수한다 — 그 때문에 성공을 실패로 보고하는 것은 감수하지 않는다.
|
|
180
|
+
for (const _st of [process.stderr, process.stdout]) { try { _st.on('error', (e) => { if (!e || e.code !== 'EPIPE') return; }); } catch {} }
|
|
173
181
|
function _withLock(targetPath, fn, opts = {}) {
|
|
174
182
|
const lockPath = targetPath + '.lock';
|
|
183
|
+
let bailCode = null, lastCode = null;
|
|
175
184
|
if (_heldLocks.has(lockPath)) return fn(); // 이미 이 프로세스가 보유 → 재진입(중첩 호출 안전)
|
|
176
185
|
const maxWaitMs = opts.maxWaitMs || 10000; // 1.9.375 (UR-0084): 5s→10s — 고부하 경합 시 fail-open(락 없이 진행) 전 인내 상향, lost-update 창 축소
|
|
177
186
|
const staleMs = opts.staleMs || 30000;
|
|
@@ -184,16 +193,48 @@ function _withLock(targetPath, fn, opts = {}) {
|
|
|
184
193
|
while (Date.now() - start <= maxWaitMs) {
|
|
185
194
|
try { const fd = fs.openSync(lockPath, 'wx'); fs.closeSync(fd); held = true; break; } // 원자적 배타 생성
|
|
186
195
|
catch (e) {
|
|
187
|
-
|
|
196
|
+
// 1.36.132 (P1, 실측): 종전엔 `EEXIST` 가 아니면 즉시 **락 없이 진행**했다. 그런데 Windows 에서
|
|
197
|
+
// 상대가 락을 지우는 그 순간에 열면 `EPERM` 이 난다 — 즉 **경합이 심할수록** 보호가 꺼졌다.
|
|
198
|
+
// 격리 실측(N=32, 획득 루프만): 현행 = 무보호 진행 1 · 유실 1행(3.1%) · EPERM 크래시 1,
|
|
199
|
+
// 재시도 술어만 바꾼 대조군 = 무보호 0 · 유실 0 · 크래시 0 (대기시간 동일 ~1.0s).
|
|
200
|
+
// 제품 경로(16 프로세스 동시 `task add`)는 파일 연산이 많아 6.3% 유실 + exit 1 이 2회 연속 재현됐다.
|
|
201
|
+
// 이 코드들은 전부 "지금은 못 잡는다" 이지 "잡을 수 없다" 가 아니다 → 기다린다.
|
|
202
|
+
lastCode = e.code || null; // 1.36.132 (검수 P2): 10초를 기다린 뒤 'timeout' 이라고만 하면 영구 권한 오류를 경합으로 오진한다
|
|
203
|
+
if (!_LOCK_RETRY_CODES.has(e.code)) { bailCode = e.code || 'unknown'; break; }
|
|
188
204
|
try { const st = fs.statSync(lockPath); if (Date.now() - st.mtimeMs > staleMs) { fs.unlinkSync(lockPath); continue; } } catch {}
|
|
189
205
|
_sleepSyncMs(15 + Math.floor((Date.now() - start) / 50)); // 점증 backoff
|
|
190
206
|
}
|
|
191
207
|
}
|
|
192
208
|
if (held) _heldLocks.add(lockPath);
|
|
209
|
+
else {
|
|
210
|
+
// 1.36.132: fail-open 은 **정책**이라 유지하되(명령이 멈추면 안 된다), 조용히 하지는 않는다.
|
|
211
|
+
// 종전엔 보호 없이 진행하면서 아무 말도 안 했다 — 유실이 나도 사용자에겐 성공으로만 보였다.
|
|
212
|
+
// stdout 은 `--json` 계약이 있으므로 건드리지 않는다. 경고는 stderr 로만 낸다.
|
|
213
|
+
const _why = bailCode || (lastCode && lastCode !== 'EEXIST' ? `${lastCode} 지속(${maxWaitMs}ms)` : 'timeout');
|
|
214
|
+
_lockFailOpen.push({ file: path.basename(targetPath), reason: _why, waitedMs: Date.now() - start });
|
|
215
|
+
if (process.env.LEERNESS_NO_LOCK_WARN !== '1') {
|
|
216
|
+
try {
|
|
217
|
+
process.stderr.write(`⚠ 락 획득 실패 — 보호 없이 진행합니다: ${path.basename(targetPath)} (${_why}). 동시 실행 중인 다른 세션이 있으면 이 쓰기가 유실될 수 있습니다.\n`);
|
|
218
|
+
} catch { /* stderr 가 닫혀 있어도 진행은 막지 않는다 */ }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
193
221
|
// 1.36.108 (T-0097, 자체 적대 검사에서 발견): fn 이 **프로미스를 돌려주면** 동기 finally 가 그 즉시 락을 푼다 —
|
|
194
222
|
// 비동기 본체(네트워크 fetch 등)는 락 없이 도는데 호출부는 보호받는다고 믿는다. 가장 나쁜 조합이다
|
|
195
223
|
// (`provider sync` 를 감싸면서 실제로 이 형태를 만들었다). thenable 이면 **정착 후에** 푼다.
|
|
196
|
-
|
|
224
|
+
// 1.36.132 (검수 P1): 해제 실패를 조용히 삼키면 **고아 락**이 남고 뒤따르는 쓰기들이 10초씩 기다렸다가
|
|
225
|
+
// 한꺼번에 무보호로 진행한다(stale 30초 탈취 전까지). 잠깐 다시 시도하고, 그래도 안 되면 말한다.
|
|
226
|
+
const release = () => {
|
|
227
|
+
if (!held) return;
|
|
228
|
+
_heldLocks.delete(lockPath);
|
|
229
|
+
for (let i = 0; i < 6; i++) {
|
|
230
|
+
try { fs.unlinkSync(lockPath); return; }
|
|
231
|
+
catch (e) { if (e.code === 'ENOENT') return; _sleepSyncMs(10 + i * 10); }
|
|
232
|
+
}
|
|
233
|
+
_lockFailOpen.push({ file: path.basename(targetPath), reason: 'release-failed', waitedMs: 0 });
|
|
234
|
+
if (process.env.LEERNESS_NO_LOCK_WARN !== '1') {
|
|
235
|
+
try { process.stderr.write(`⚠ 락 파일을 지우지 못했습니다: ${path.basename(lockPath)} — 다음 쓰기가 최대 30초 기다린 뒤 진행합니다.\n`); } catch {}
|
|
236
|
+
}
|
|
237
|
+
};
|
|
197
238
|
let out;
|
|
198
239
|
try { out = fn(); }
|
|
199
240
|
catch (e) { release(); throw e; }
|
|
@@ -273,7 +314,7 @@ const _VALUE_FLAGS = new Set(['--language','--skills','--path','--status','--pro
|
|
|
273
314
|
for (const _bf of _BRIEF_FIELDS) { if (_bf && _bf.flag) _VALUE_FLAGS.add('--' + _bf.flag); }
|
|
274
315
|
// 부울 플래그(값 없음) — 오타 경고의 대조군. 여기 있거나 _VALUE_FLAGS 에 있으면 '실재하는 플래그' 로 본다.
|
|
275
316
|
// 판정에만 쓰이고 파싱 동작에는 관여하지 않으므로, 넓게 잡아도 안전하다(과다 포함 = 경고 억제).
|
|
276
|
-
const _BOOL_FLAGS = new Set(['--ai','--all','--all-apps','--baseline','--all-presets','--allow-empty','--allow-unrelated','--apply','--audit','--auto','--auto-apply-delivered','--auto-cleanup-branches','--auto-fix','--auto-fix-bom','--auto-fix-encoding','--auto-main-push','--auto-recover','--auto-track','--banner','--bench','--build','--bundle-only','--claims','--close','--compact','--detect','--dry-run','--dry-run-npm','--embedding','--enforce','--fail-on-candidates','--fail-on-violation','--force','--gh-pages','--gh-release','--git-push','--global','--guide','--help','--html','--include-code','--interactive','--json','--last','--lenient','--major','--minimal','--minor','--no-animate','--no-auto-roadmap','--no-auto-update','--no-banner','--no-brainstorm-hits','--no-cache','--no-context-inject','--no-crawl','--no-drift-check','--no-enforce','--no-env','--no-env-detect','--no-feature-impact','--no-headline','--no-init-check','--no-interactive-select','--no-lazy-warn','--no-lessons','--no-longterm-recall','--no-mcp','--no-mem-delta','--no-multiagent-hint','--no-next-actions','--no-npm','--no-official-skills','--no-on-every-change','--no-pre-wake','--no-preserve-link','--no-readme-sync','--no-record','--no-repl','--no-review','--no-save','--no-security-check','--no-security-summary','--no-setup-agents','--no-skill-suggest','--no-stale-check','--no-suggest','--no-synonyms','--no-team-reminders','--no-wakeup-miss','--no-workflow-guide','--no-write','--npm-publish','--offline','--on-every-change','--pack','--parent-migrate','--publish-npm','--pulse','--quiet','--record','--refresh','--remove','--repair','--repl','--require-evidence','--run-tests','--skeleton','--strict','--strict-claims','--strict-elements','--strict-exit','--suggest','--version','--yes',
|
|
317
|
+
const _BOOL_FLAGS = new Set(['--ai','--all','--all-apps','--baseline','--all-presets','--allow-empty','--allow-unrelated','--apply','--audit','--auto','--auto-apply-delivered','--auto-cleanup-branches','--auto-fix','--auto-fix-bom','--auto-fix-encoding','--auto-main-push','--auto-recover','--auto-track','--banner','--bench','--build','--bundle-only','--claims','--close','--compact','--detect','--dry-run','--dry-run-npm','--embedding','--enforce','--fail-on-candidates','--fail-on-violation','--force','--gh-pages','--gh-release','--git-push','--global','--guide','--help','--html','--include-code','--interactive','--json','--last','--lenient','--major','--minimal','--minor','--no-animate','--no-auto-roadmap','--no-auto-update','--no-banner','--no-brainstorm-hits','--no-cache','--no-context-inject','--no-crawl','--no-drift-check','--no-enforce','--no-env','--no-env-detect','--no-feature-impact','--no-headline','--no-init-check','--no-interactive-select','--no-lazy-warn','--no-lessons','--no-longterm-recall','--no-mcp','--no-mem-delta','--no-multiagent-hint','--no-next-actions','--no-npm','--no-official-skills','--no-on-every-change','--no-pre-wake','--no-preserve-link','--no-readme-sync','--no-record','--no-repl','--no-review','--no-save','--no-security-check','--no-security-summary','--no-setup-agents','--no-skill-suggest','--no-stale-check','--no-suggest','--no-synonyms','--no-team-reminders','--no-wakeup-miss','--no-workflow-guide','--no-write','--npm-publish','--offline','--on-every-change','--pack','--parent-migrate','--publish-npm','--pulse','--quiet','--record','--refresh','--remove','--repair','--repl','--require-evidence','--run-tests','--skeleton','--skip-verify','--strict','--strict-claims','--strict-elements','--strict-exit','--suggest','--version','--yes',
|
|
277
318
|
// 소스에 리터럴로만 존재해 소비 형태를 기계 추출하지 못한 것들 — 실재하는 플래그이므로 오타로 오인하면 안 된다
|
|
278
319
|
'--commands','--decision','--errors','--expand-all','--fix','--list','--notes','--print','--raw','--select-all','--skip-git-repo-check','--tests','--verbose','--yolo',
|
|
279
320
|
// lib/*.js 의 부울 소비자 — bin/ 만 훑었다면 `agents route --confirm` 같은 실재 문법에 오탐 경고를 냈을 것이다
|
|
@@ -2628,7 +2669,7 @@ function _computeRoundHistory(root) {
|
|
|
2628
2669
|
};
|
|
2629
2670
|
try {
|
|
2630
2671
|
// git tag --list 'v1.9.*' --sort=creatordate (시간순)
|
|
2631
|
-
const r =
|
|
2672
|
+
const r = _gitSpawn(['tag', '--list', 'v*', '--sort=creatordate', '--format=%(refname:short)|%(creatordate:iso8601)'], {
|
|
2632
2673
|
cwd: root, encoding: 'utf8', timeout: 5000
|
|
2633
2674
|
});
|
|
2634
2675
|
if (r.status !== 0 || !r.stdout) return result;
|
|
@@ -2678,7 +2719,7 @@ function _computeMilestones(root) {
|
|
|
2678
2719
|
baselineAt: null
|
|
2679
2720
|
};
|
|
2680
2721
|
try {
|
|
2681
|
-
const r =
|
|
2722
|
+
const r = _gitSpawn(['tag', '--list', 'v*', '--sort=creatordate', '--format=%(refname:short)|%(creatordate:iso8601)'], {
|
|
2682
2723
|
cwd: root, encoding: 'utf8', timeout: 5000
|
|
2683
2724
|
});
|
|
2684
2725
|
if (r.status !== 0 || !r.stdout) return result;
|
|
@@ -2945,7 +2986,7 @@ function agentModeCmd(root, sub) {
|
|
|
2945
2986
|
function _computeRecentChanges(root, limit = 5) {
|
|
2946
2987
|
const items = [];
|
|
2947
2988
|
try {
|
|
2948
|
-
const r =
|
|
2989
|
+
const r = _gitSpawn(['log', '--tags', '--simplify-by-decoration', '--pretty=format:%H|%D|%ad|%s', '--date=short', '-n', String(limit * 3)], {
|
|
2949
2990
|
cwd: root, encoding: 'utf8', timeout: 5000
|
|
2950
2991
|
});
|
|
2951
2992
|
if (r.status !== 0 || !r.stdout) return items;
|
|
@@ -3931,7 +3972,7 @@ function _selfTestCases() {
|
|
|
3931
3972
|
let behavOk = true;
|
|
3932
3973
|
try {
|
|
3933
3974
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_enf_'));
|
|
3934
|
-
const g =
|
|
3975
|
+
const g = _gitSpawn(['-C', tmp, 'init'], { encoding: 'utf8', timeout: 10000 });
|
|
3935
3976
|
if (g.status === 0) {
|
|
3936
3977
|
fs.mkdirSync(path.join(tmp, '.harness'), { recursive: true });
|
|
3937
3978
|
const save = process.argv; const _w = process.stdout.write;
|
|
@@ -3945,8 +3986,11 @@ function _selfTestCases() {
|
|
|
3945
3986
|
} },
|
|
3946
3987
|
{ name: 'enforce 하드닝 (1.36.44, 적대적 자체헌트): .harness 無 체크아웃 FP 방지 + worktree 훅경로 + --no-verify 사후감지 audit + 한계 정직 (소스가드)', run: () => {
|
|
3947
3988
|
const s = read(__filename);
|
|
3948
|
-
|
|
3949
|
-
|
|
3989
|
+
// 1.36.134: 이 가드는 **옛 리터럴**을 붙잡고 있었다 — 하위 디렉토리 우회를 고치며 훅이 `$LRN_DIR` 를 쓰고
|
|
3990
|
+
// 훅 경로를 `--git-path`(core.hooksPath 반영)로 바꾸자 그대로 깨졌다. 지키려는 것은 문자열이 아니라 **성질**이다:
|
|
3991
|
+
// ① harness 가 없는 체크아웃에서 오차단하지 않는다 ② 훅 경로를 git 에게 묻는다.
|
|
3992
|
+
const fpFix = s.includes('if [ ! -d "$LRN_DIR" ]; then') && s.includes('오차단(FP) 방지');
|
|
3993
|
+
const wtOk = s.includes("'rev-parse', '--git-path', 'hooks/pre-commit'") || s.includes("'rev-parse', '--git-common-dir'");
|
|
3950
3994
|
const auditOk = s.includes("sub === 'audit'") && s.includes('우회 의심') && s.includes("'git_log_failed'");
|
|
3951
3995
|
const honestOk = s.includes('git commit --no-verify 는 git 설계상 훅을 스킵');
|
|
3952
3996
|
return fpFix && wtOk && auditOk && honestOk;
|
|
@@ -6958,7 +7002,9 @@ const _SELFTEST_CONTROLLED_ENV = ['LEERNESS_MCP_PROFILE', 'LEERNESS_WORKSPACE_DI
|
|
|
6958
7002
|
// 통제하지 않으면 tmp 픽스처에 진짜 세션이 등록되고, 측정 결과가 '누가 돌렸는지' 에 종속된다.
|
|
6959
7003
|
'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_HOST_SESSION_ID', 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDECODE',
|
|
6960
7004
|
'CODEX_MANAGED_BY_NPM', 'CODEX_MANAGED_BY_PNPM', 'CODEX_MANAGED_BY_BUN', 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE',
|
|
6961
|
-
'CURSOR_AGENT', 'LEERNESS_NO_SESSION_PRESENCE'
|
|
7005
|
+
'CURSOR_AGENT', 'LEERNESS_NO_SESSION_PRESENCE',
|
|
7006
|
+
// 1.36.132: 새 키 출처 2종도 통제한다 — 통제 목록이 키 출처를 따라가지 않으면 '누가 돌렸는지' 가 측정에 샌다.
|
|
7007
|
+
'LEERNESS_SESSION_ID', 'CODEX_THREAD_ID'];
|
|
6962
7008
|
function _controlSelftestEnv() {
|
|
6963
7009
|
const dropped = [], saved = {};
|
|
6964
7010
|
for (const k of _SELFTEST_CONTROLLED_ENV) {
|
|
@@ -7150,23 +7196,107 @@ function _enforceCfg(root) {
|
|
|
7150
7196
|
}
|
|
7151
7197
|
function _gitHookPath(root) {
|
|
7152
7198
|
// 1.36.44 (하드닝): worktree 에선 .git 이 파일 — 훅은 공용 디렉토리에 산다. git 에게 물어 해석(양쪽 다 정답).
|
|
7199
|
+
// 1.36.134 (검수 실측): `--git-common-dir` 는 **`core.hooksPath` 를 모른다** — 그 설정이 있는 저장소에서
|
|
7200
|
+
// 우리는 `.git/hooks/pre-commit` 에 쓰고 "설치됨" 이라 답했지만 git 은 그 파일을 **절대 실행하지 않는다**(거짓 성공).
|
|
7201
|
+
// 판별 대조군: 같은 두 저장소가 `core.hooksPath` 하나만 다를 때 하나는 차단되고 하나는 그냥 통과했다.
|
|
7202
|
+
// `--git-path hooks/pre-commit` 은 그 설정을 반영한 **git 자신의 답**이다. 이것을 쓴다.
|
|
7153
7203
|
try {
|
|
7154
|
-
const r =
|
|
7204
|
+
const r = _gitSpawn(['-C', root, 'rev-parse', '--git-path', 'hooks/pre-commit'], { encoding: 'utf8', timeout: 5000 });
|
|
7155
7205
|
if (r.status === 0 && r.stdout.trim()) {
|
|
7156
|
-
const
|
|
7206
|
+
const p = r.stdout.trim();
|
|
7207
|
+
return path.isAbsolute(p) ? p : path.join(root, p);
|
|
7208
|
+
}
|
|
7209
|
+
} catch {}
|
|
7210
|
+
try {
|
|
7211
|
+
const r2 = _gitSpawn(['-C', root, 'rev-parse', '--git-common-dir'], { encoding: 'utf8', timeout: 5000 });
|
|
7212
|
+
if (r2.status === 0 && r2.stdout.trim()) {
|
|
7213
|
+
const gd = r2.stdout.trim();
|
|
7157
7214
|
return path.join(path.isAbsolute(gd) ? gd : path.join(root, gd), 'hooks', 'pre-commit');
|
|
7158
7215
|
}
|
|
7159
7216
|
} catch {}
|
|
7160
7217
|
return path.join(root, '.git', 'hooks', 'pre-commit');
|
|
7161
7218
|
}
|
|
7162
7219
|
function enforceCmd(root, sub) {
|
|
7220
|
+
// 1.36.134 (실측): 이 함수의 사람 모드 출력이 스코프에 없는 흐림 헬퍼를 불러 **정상 저장소에서도 exit 1** 로 죽었다
|
|
7221
|
+
// (`enforce status` → `✗ dm is not defined`). e2e 가 `--json` 만 재서 사람 경로의 크래시가 안 보였다.
|
|
7222
|
+
// 이 파일의 다른 함수들과 같은 형태로 지역 정의한다.
|
|
7223
|
+
const dim = t => (process.stdout.isTTY && !has('--json') ? `[2m${t}[0m` : t); // 자립적으로 — 바깥 스코프 변수에 기대면 같은 클래스가 반복된다
|
|
7163
7224
|
root = absRoot(root);
|
|
7164
7225
|
const json = has('--json');
|
|
7165
7226
|
sub = sub || 'status';
|
|
7166
7227
|
const hookP = _gitHookPath(root);
|
|
7228
|
+
// 1.36.135 (재검수 P1, 재현): `core.hooksPath` 가 **공유/전역**이면 한 저장소에 설치한 훅이 그 설정을 쓰는
|
|
7229
|
+
// 모든 저장소에 걸린다. 실측: A 에만 설치했는데 B 가 `installed:true` 이고 B 의 커밋이 차단됐다 —
|
|
7230
|
+
// 사용자는 B 를 opt-in 한 적이 없다. 이건 git 의 설계라 **차단하지 않는다**. 다만 프로젝트 전용 설치인 척
|
|
7231
|
+
// 말하지 않는다 — 공유라는 사실을 표면에 싣는다("설치됨" 이 무엇을 뜻하는지 사용자가 알아야 한다).
|
|
7232
|
+
// 1.36.139 (재검수 P1): 첫 판은 **경로 포함관계**로 추정했다 — 공유 설정이 우연히 저장소 안을 가리키면 미탐,
|
|
7233
|
+
// 로컬 설정이 저장소 밖을 가리키면 오탐이었다. 추정 대신 **git 에게 범위를 묻는다**:
|
|
7234
|
+
// `core.hooksPath` 가 global/system 에서 왔으면 다른 저장소에도 걸린다. local 이면 이 저장소 것이다.
|
|
7235
|
+
const _hooksShared = (() => {
|
|
7236
|
+
try {
|
|
7237
|
+
// 두 조건의 **합집합**이다 — 어느 쪽이든 이 저장소 밖에 영향이 간다:
|
|
7238
|
+
// (1) 설정이 global/system 에서 왔다(다른 저장소도 같은 훅을 쓴다)
|
|
7239
|
+
// (2) 훅 경로가 이 저장소의 git 디렉토리 **밖**이다(다른 저장소가 같은 디렉토리를 가리킬 수 있다).
|
|
7240
|
+
// (1)만 쓰면 두 저장소가 각자 local 설정으로 같은 디렉토리를 가리키는 **실제 공유**를 놓친다(게이트가 잡았다).
|
|
7241
|
+
// (2)만 쓰면 검수가 지적한 오탐/미탐이 남는다. 둘 다 본다.
|
|
7242
|
+
const sc = _gitSpawn(['-C', root, 'config', '--show-scope', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: 5000 });
|
|
7243
|
+
const scope = sc.status === 0 ? (String(sc.stdout || '').trim().split(/\s+/)[0] || '') : '';
|
|
7244
|
+
if (scope === 'global' || scope === 'system') return true;
|
|
7245
|
+
if (sc.status !== 0) return false; // 설정 없음 → 기본 훅 디렉토리(공유 아님)
|
|
7246
|
+
const cd = _gitSpawn(['-C', root, 'rev-parse', '--git-common-dir'], { encoding: 'utf8', timeout: 5000 });
|
|
7247
|
+
if (cd.status !== 0 || !String(cd.stdout || '').trim()) return null; // 모르면 주장하지 않는다
|
|
7248
|
+
const gd = String(cd.stdout).trim();
|
|
7249
|
+
const abs = path.resolve(path.isAbsolute(gd) ? gd : path.join(root, gd));
|
|
7250
|
+
// 1.36.140 (재검수 P1/P2, 재현): 문자열 비교는 **모양만** 본다 —
|
|
7251
|
+
// · `.git` 안의 junction/symlink 가 바깥 공유 디렉토리를 가리켜도 "안" 이라 읽어 미탐(실측).
|
|
7252
|
+
// · Windows 는 경로 대소문자를 안 가리는데 비교는 가려서, `.GIT/hooks` 가 "밖" 이 돼 오탐(실측).
|
|
7253
|
+
// 실제 위치(realpath)로, 그리고 그 플랫폼이 쓰는 방식으로 비교한다.
|
|
7254
|
+
// ⚠ 없는 경로면 realpath 가 그대로 되돌려줘 **링크를 따라가지 못한다**(아직 없는 훅 디렉토리 등).
|
|
7255
|
+
// 존재하는 **가장 깊은 조상**까지 풀고 나머지를 다시 붙인다.
|
|
7256
|
+
const _real = (p0) => {
|
|
7257
|
+
let head = path.resolve(p0);
|
|
7258
|
+
const tail = [];
|
|
7259
|
+
for (let i = 0; i < 64; i++) {
|
|
7260
|
+
try { head = fs.realpathSync.native ? fs.realpathSync.native(head) : fs.realpathSync(head); break; } catch {}
|
|
7261
|
+
const parent = path.dirname(head);
|
|
7262
|
+
if (!parent || parent === head) break;
|
|
7263
|
+
tail.unshift(path.basename(head));
|
|
7264
|
+
head = parent;
|
|
7265
|
+
}
|
|
7266
|
+
const r = path.resolve(head, ...tail);
|
|
7267
|
+
return process.platform === 'win32' ? r.toLowerCase() : r;
|
|
7268
|
+
};
|
|
7269
|
+
// 훅 파일 자체는 아직 없을 수 있으므로 **디렉토리**로 비교한다.
|
|
7270
|
+
const hookDir = _real(path.dirname(hookP));
|
|
7271
|
+
const commonDir = _real(abs);
|
|
7272
|
+
return !(hookDir === commonDir || hookDir.startsWith(commonDir + path.sep));
|
|
7273
|
+
} catch { return null; }
|
|
7274
|
+
})();
|
|
7275
|
+
// 1.36.140 (재검수 P1, 재현): 연결된 worktree 들은 `core.hooksPath` 설정이 없어도 **같은 훅 파일**을 쓴다 —
|
|
7276
|
+
// 한 worktree 에 설치하면 형제 worktree 의 커밋까지 차단된다. `hooksPathShared` 는 설정에 관한 사실이므로
|
|
7277
|
+
// 그 뜻을 늘리지 않고, **별도 사실**로 센다(모르면 주장하지 않는다).
|
|
7278
|
+
const _worktreeCount = (() => {
|
|
7279
|
+
try {
|
|
7280
|
+
// ⚠ `core.hooksPath` 가 **worktree 범위**면 그 worktree 전용 훅이라 형제는 안 막힌다 —
|
|
7281
|
+
// 그때 "형제도 차단된다" 고 말하면 거짓 경고다(재검수 P2). 모르면 주장하지 않는다.
|
|
7282
|
+
const wsc = _gitSpawn(['-C', root, 'config', '--show-scope', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: 5000 });
|
|
7283
|
+
if (wsc.status === 0 && String(wsc.stdout || '').trim().split(/\s+/)[0] === 'worktree') return null;
|
|
7284
|
+
const wl = _gitSpawn(['-C', root, 'worktree', 'list', '--porcelain'], { encoding: 'utf8', timeout: 5000 });
|
|
7285
|
+
if (wl.status !== 0) return null;
|
|
7286
|
+
const n = String(wl.stdout || '').split('\n').filter((l) => l.startsWith('worktree ')).length;
|
|
7287
|
+
return n > 0 ? n : null;
|
|
7288
|
+
} catch { return null; }
|
|
7289
|
+
})();
|
|
7167
7290
|
const chainedP = hookP + '.pre-leerness';
|
|
7168
7291
|
if (sub === 'install') {
|
|
7169
|
-
|
|
7292
|
+
// 1.36.134: 종전엔 `.git` 디렉토리 존재만 봤다 — 손상된 저장소에서 훅 파일만 쓰고 성공이라 답했다(거짓 성공).
|
|
7293
|
+
// 기존 사유 코드(`not_a_git_repo`)는 그대로 유지하고, 새 상태만 **추가**한다(소비자 계약 보존).
|
|
7294
|
+
const _gsInstall = _gitState(root);
|
|
7295
|
+
if (!GIT_STATE_USABLE.has(_gsInstall)) {
|
|
7296
|
+
const _code = _gsInstall === 'not-a-repo' ? 'not_a_git_repo' : (_gsInstall === 'no-git' ? 'git_unavailable' : 'git_broken');
|
|
7297
|
+
failJson(json, _code, `pre-commit 강제를 걸 수 없습니다 (git 상태: ${_gsInstall}) — ${root}`);
|
|
7298
|
+
return;
|
|
7299
|
+
}
|
|
7170
7300
|
if (!exists(path.join(root, '.harness'))) { failJson(json, 'harness_missing', `leerness 미설치: ${root} — 먼저 leerness init`); return; }
|
|
7171
7301
|
// 1.36.49 (codex 6차 #8): 명시된 무효 window 는 24로 무언 보정하지 않고 거부 — 종전엔 0/NaN→24,
|
|
7172
7302
|
// 400자리 정수→Infinity 가 그대로 설치돼 훅에 WIN_MIN=Infinity(find -mmin 상시 실패=상시 차단)가 박혔다.
|
|
@@ -7175,7 +7305,69 @@ function enforceCmd(root, sub) {
|
|
|
7175
7305
|
if (!Number.isInteger(windowHours) || windowHours < 1 || windowHours > 8760) { failJson(json, 'invalid_window', `--window 는 1~8760 정수(시간)만: 받음 "${winRaw}"`); return; }
|
|
7176
7306
|
const strict = has('--strict');
|
|
7177
7307
|
mkdirp(path.dirname(_enforceCfgPath(root)));
|
|
7178
|
-
|
|
7308
|
+
// 1.36.134 (검수 P1): 여기서 설정을 쓰면 **실패한 설치가 installedAt 을 남긴다** — 다음 실행이 설치됐다고 믿는다.
|
|
7309
|
+
// 쓰기는 훅 발화 검증을 통과한 뒤로 옮겼다(아래).
|
|
7310
|
+
// 1.36.134 (검수 P1): 설치가 실패하면 **흔적을 남기지 않는다** — 종전엔 실패해도 우리 훅과 `.pre-leerness`
|
|
7311
|
+
// 백업이 그대로 남아 사용자 저장소가 중간 상태가 됐다. 설치 전 상태를 정확히 되돌린다.
|
|
7312
|
+
// 1.36.135 (재검수 P1): 첫 판은 "백업이 있으면 그걸 되돌린다" 였는데 **재설치**에서 틀린다 —
|
|
7313
|
+
// 그때 백업은 *최초 설치 이전*의 사용자 훅이라, 되돌리면 **멀쩡히 동작하던 우리 강제가 조용히 사라진다**.
|
|
7314
|
+
// 되돌릴 대상은 "설치 이전" 이 아니라 "**이번 명령 이전**" 이다. 그 상태를 미리 찍어 두고 그대로 복원한다.
|
|
7315
|
+
// 1.36.140 (재검수 P1, 재현): 바이트만 찍으면 **모양이 사라진다** — 기존 symlink 훅이 롤백에서
|
|
7316
|
+
// 일반 파일이 되고, 깨진 symlink 는 `existsSync` 가 false 라 아예 지워졌다. 권한도 늘 0755 로 넓어졌다.
|
|
7317
|
+
// 되돌린다는 것은 내용뿐 아니라 **종류와 권한**을 되돌리는 것이다.
|
|
7318
|
+
const _lexists = (p) => { try { fs.lstatSync(p); return true; } catch { return false; } };
|
|
7319
|
+
const _snapOne = (p) => {
|
|
7320
|
+
const st = fs.lstatSync(p); // 없으면 throw → 바깥에서 kind:'none'
|
|
7321
|
+
if (st.isSymbolicLink()) return { kind: 'link', target: fs.readlinkSync(p) };
|
|
7322
|
+
return { kind: 'file', buf: readBuf(p), mode: st.mode & 0o7777 };
|
|
7323
|
+
};
|
|
7324
|
+
const _snapHook = (() => {
|
|
7325
|
+
try {
|
|
7326
|
+
return {
|
|
7327
|
+
hook: _lexists(hookP) ? _snapOne(hookP) : { kind: 'none' },
|
|
7328
|
+
chained: _lexists(chainedP) ? _snapOne(chainedP) : { kind: 'none' },
|
|
7329
|
+
};
|
|
7330
|
+
} catch { return null; }
|
|
7331
|
+
})();
|
|
7332
|
+
// 1.36.139 (재검수 P1): 스냅샷을 못 찍었는데 설치를 계속하면 **롤백이 무동작**이 된다 —
|
|
7333
|
+
// 실패해도 되돌릴 수 없는 설치가 된다. 되돌릴 수 없으면 시작하지 않는다.
|
|
7334
|
+
if (!_snapHook) {
|
|
7335
|
+
failJson(json, 'snapshot_failed', `기존 훅 상태를 읽지 못해 설치를 시작하지 않았습니다 — 되돌릴 수 없는 변경은 하지 않습니다`);
|
|
7336
|
+
return;
|
|
7337
|
+
}
|
|
7338
|
+
// 1.36.140 (재검수 P1, 재현): 롤백 정의가 **실패 지점보다 뒤**에 있었고 변경 구간을 감싸는 catch 가 없어,
|
|
7339
|
+
// 읽기 전용 훅에서 쓰기가 EPERM 으로 튀면 예외가 최상위까지 올라가 **체인 백업이 덮인 채로 남았다**
|
|
7340
|
+
// (실측: 기존 `.pre-leerness` 내용이 사라지고 훅 내용으로 바뀜). 정의를 앞으로 옮기고 구간을 감싼다.
|
|
7341
|
+
const _restoreOne = (p, snap) => {
|
|
7342
|
+
// ⚠ 지우고 나서 쓰면 그 사이에 실패했을 때 **원본이 사라진다**. 흔한 경우(파일 → 파일)는
|
|
7343
|
+
// 제자리 덮어쓰기로 그 틈을 없애고, 종류가 바뀔 때만 지운다.
|
|
7344
|
+
if (snap.kind === 'file') {
|
|
7345
|
+
const cur = _lexists(p) ? (() => { try { return fs.lstatSync(p); } catch { return null; } })() : null;
|
|
7346
|
+
if (cur && !cur.isSymbolicLink()) {
|
|
7347
|
+
try {
|
|
7348
|
+
fs.writeFileSync(p, snap.buf);
|
|
7349
|
+
try { fs.chmodSync(p, snap.mode); } catch {} // 원래 권한으로 — 늘 0755 로 넓히지 않는다
|
|
7350
|
+
return;
|
|
7351
|
+
} catch { /* 제자리 쓰기가 막히면 아래 지우고-쓰기로 넘어간다 */ }
|
|
7352
|
+
}
|
|
7353
|
+
}
|
|
7354
|
+
if (_lexists(p)) { try { fs.unlinkSync(p); } catch {} }
|
|
7355
|
+
if (snap.kind === 'none') return;
|
|
7356
|
+
if (snap.kind === 'link') { fs.symlinkSync(snap.target, p); return; }
|
|
7357
|
+
fs.writeFileSync(p, snap.buf);
|
|
7358
|
+
try { fs.chmodSync(p, snap.mode); } catch {}
|
|
7359
|
+
};
|
|
7360
|
+
// 1.36.140 (재검수 P1): 되돌리기가 실패해도 조용히 삼키고 "되돌렸습니다" 라고 말했다 —
|
|
7361
|
+
// 되돌리지 못한 것을 되돌렸다고 하면 안 된다. 실패를 모아 실패 보고에 함께 싫는다.
|
|
7362
|
+
let _rbFail = [];
|
|
7363
|
+
const _rollbackHook = () => {
|
|
7364
|
+
_rbFail = [];
|
|
7365
|
+
try { _restoreOne(hookP, _snapHook.hook); } catch (e) { _rbFail.push('hook:' + String((e && (e.code || e.message)) || e).slice(0, 40)); }
|
|
7366
|
+
try { _restoreOne(chainedP, _snapHook.chained); } catch (e) { _rbFail.push('chain:' + String((e && (e.code || e.message)) || e).slice(0, 40)); }
|
|
7367
|
+
return _rbFail.length === 0;
|
|
7368
|
+
};
|
|
7369
|
+
const _rbNote = () => (_rbFail.length ? ` ⚠ 되돌리지 못한 것: ${_rbFail.join(' · ')}` : '');
|
|
7370
|
+
try {
|
|
7179
7371
|
mkdirp(path.dirname(hookP));
|
|
7180
7372
|
// 기존 훅 보존: 우리 것이 아니면 체인 파일로 이동 후 호출
|
|
7181
7373
|
if (exists(hookP) && !read(hookP).includes(_ENFORCE_MARK)) {
|
|
@@ -7184,22 +7376,62 @@ function enforceCmd(root, sub) {
|
|
|
7184
7376
|
}
|
|
7185
7377
|
// 훅은 자기완결 sh — 설치된 leerness 버전/네트워크/PATH 에 무의존(구버전 전역 CLI 가 enforce 를 몰라 오차단하던 실측 함정 회피).
|
|
7186
7378
|
// 핵심 체크 = last-handoff.json 의 mtime 이 window 안인지 (find -mmin, git bash 표준).
|
|
7379
|
+
// 1.36.134: git 이 훅을 부를 때의 작업 디렉토리 = toplevel. harness 위치를 그 기준 상대경로로 고정한다.
|
|
7380
|
+
let topLevel = root;
|
|
7381
|
+
{
|
|
7382
|
+
const tl = _gitSpawn(['-C', root, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 10000 });
|
|
7383
|
+
if (tl.status === 0 && String(tl.stdout || '').trim()) topLevel = path.resolve(String(tl.stdout).trim());
|
|
7384
|
+
}
|
|
7385
|
+
// 1.36.134 (재검수 P1, 재현): junction/symlink 로 저장소를 열면 `path.relative` 가 toplevel **밖**을 가리키는
|
|
7386
|
+
// `../…` 를 만들어 그것이 영구 훅에 박혔다(실측: `LRN_DIR='../__container/alias/.harness'`).
|
|
7387
|
+
// 양쪽을 realpath 로 펴서 비교하고, 그래도 밖이면 상대경로로 표현하지 않고 **절대경로**를 쓴다.
|
|
7388
|
+
let _hDir = path.join(root, '.harness'), _top = topLevel;
|
|
7389
|
+
try { _hDir = fs.realpathSync(_hDir); } catch {}
|
|
7390
|
+
try { _top = fs.realpathSync(_top); } catch {}
|
|
7391
|
+
let harnessRel = (path.relative(_top, _hDir) || '.harness').replace(/\\/g, '/');
|
|
7392
|
+
if (harnessRel.startsWith('..')) harnessRel = _hDir.replace(/\\/g, '/');
|
|
7393
|
+
// 1.36.134 (검수 P1, 재현): 이 값을 훅 셸 스크립트에 그대로 박았다 — 디렉토리 이름이 `pkg$(touch PWNED)` 면
|
|
7394
|
+
// **설치만으로 임의 코드가 실행**됐다(실측: PWNED 생성). 값을 신뢰하지 않는다.
|
|
7395
|
+
// ① 셸에서 특별한 뜻을 갖는 문자가 하나라도 있으면 설치를 거부한다(조용히 이스케이프하고 넘어가지 않는다 —
|
|
7396
|
+
// 우리가 못 다루는 입력을 다루는 척하지 않는다). ② 그리고 작은따옴표로 감싼다(따옴표 자체는 위 검사가 막는다).
|
|
7397
|
+
// ⚠ 첫 판은 화이트리스트로 **거부**했는데 그게 오차단이었다 — 실측: `내프로젝트`(한글)와 `my project`(공백)가
|
|
7398
|
+
// 둘 다 `unsafe_path` 로 막혔다. 한국어 환경에서 흔한 경로다. 거부가 아니라 **제대로 인용**하는 것이 답이다.
|
|
7399
|
+
// POSIX sh 의 작은따옴표 안에서는 `'` 를 제외한 모든 문자가 리터럴이다 — `'` 만 표준 관용구로 탈출시킨다.
|
|
7400
|
+
const shQuote = (v) => "'" + String(v).replace(/'/g, "'\''") + "'";
|
|
7401
|
+
if (/[\r\n]/.test(harnessRel)) {
|
|
7402
|
+
failJson(json, 'unsafe_path', `경로에 개행이 있어 훅 스크립트에 넣을 수 없습니다: ${JSON.stringify(harnessRel)}`);
|
|
7403
|
+
return;
|
|
7404
|
+
}
|
|
7187
7405
|
const hook = [
|
|
7188
7406
|
'#!/bin/sh',
|
|
7189
7407
|
_ENFORCE_MARK + ' — leerness 사용 강제 (제거: leerness enforce remove)',
|
|
7190
7408
|
'if [ "$LEERNESS_ENFORCE_BYPASS" = "1" ]; then exit 0; fi',
|
|
7409
|
+
// 1.36.134 (검수 P1, 재현): 훅은 **git toplevel** 에서 돈다. `.harness` 가 하위 디렉토리에 있으면
|
|
7410
|
+
// `-d ".harness"` 가 거짓이라 조용히 통과했다 — 설치는 `verified:'fired'` 인데 루트 커밋이 그냥 통과.
|
|
7411
|
+
// 설치 시점에 **toplevel 기준 상대경로**를 훅에 박아 넣어, 커밋이 어디서 나든 같은 곳을 본다.
|
|
7412
|
+
`LRN_DIR=${shQuote(harnessRel)}`,
|
|
7191
7413
|
'# 1.36.44: .harness 없는 체크아웃(워크트리/구브랜치)은 강제 대상 아님 — 오차단(FP) 방지',
|
|
7192
|
-
'if [ ! -d "
|
|
7414
|
+
'if [ ! -d "$LRN_DIR" ]; then',
|
|
7193
7415
|
' if [ -f "$(dirname "$0")/pre-commit.pre-leerness" ]; then exec sh "$(dirname "$0")/pre-commit.pre-leerness" "$@"; fi',
|
|
7194
7416
|
' exit 0',
|
|
7195
7417
|
'fi',
|
|
7196
|
-
`LH="
|
|
7418
|
+
`LH="$LRN_DIR/last-handoff.json"`,
|
|
7197
7419
|
`WIN_MIN=${windowHours * 60}`,
|
|
7198
7420
|
'if [ ! -f "$LH" ] || [ -z "$(find "$LH" -mmin -"$WIN_MIN" 2>/dev/null)" ]; then',
|
|
7199
7421
|
` echo "🔒 leerness 강제: 최근 ${windowHours}h 내 handoff 흔적 없음 — 커밋 전 실행: leerness handoff ." >&2`,
|
|
7200
7422
|
' echo " 긴급 우회(명시): LEERNESS_ENFORCE_BYPASS=1 git commit ..." >&2',
|
|
7201
7423
|
' exit 1',
|
|
7202
7424
|
'fi',
|
|
7425
|
+
// 1.36.134 (검수 P1): 설치 중 발화 검증이 **사용자의 기존 훅과 strict gate 를 실제로 두 번 실행**했다
|
|
7426
|
+
// (부작용 · 기존 훅이 enforce install 을 부르면 재귀). 검증 모드에서는 **자기 판정만 하고 즉시 끝낸다.**
|
|
7427
|
+
// 1.36.134 (검수 P1, 재현): 이것을 **환경변수**로 두자 실제 커밋에서 우회 통로가 됐다 —
|
|
7428
|
+
// 체인된 사용자 훅이 exit 1 인 저장소에서 `LEERNESS_ENFORCE_PROBE=1 git commit` 이 통과했다.
|
|
7429
|
+
// git 은 pre-commit 을 **인자 없이** 부른다 → 인자로 바꾸면 사용자가 환경으로 켤 수 없다.
|
|
7430
|
+
// 1.36.134 (재검수 P1 ×2, 재현): 검증용 조기 종료를 **출하되는 훅에 넣지 않는다.**
|
|
7431
|
+
// 환경변수로 두자 `LEERNESS_ENFORCE_PROBE=1 git commit` 이 체인 훅을 건너뛰었고,
|
|
7432
|
+
// 인자로 바꾸자 `git hook run pre-commit -- --leerness-probe` 로 또 뚫렸다.
|
|
7433
|
+
// 사용자가 켤 수 있는 스위치를 훅에 두면 그것이 곧 우회 통로다 — **스위치 자체를 없앤다.**
|
|
7434
|
+
// 검증은 아래에서 체인·strict 를 걷어낸 **임시 사본**으로 돌린다(출하물에 흔적 0).
|
|
7203
7435
|
...(strict ? [
|
|
7204
7436
|
'if command -v leerness >/dev/null 2>&1; then LEERNESS_INTERNAL=1 leerness gate . || exit 1;',
|
|
7205
7437
|
'elif command -v npx >/dev/null 2>&1; then LEERNESS_INTERNAL=1 npx -y leerness gate . || exit 1; fi',
|
|
@@ -7209,14 +7441,114 @@ function enforceCmd(root, sub) {
|
|
|
7209
7441
|
].join('\n');
|
|
7210
7442
|
writeUtf8(hookP, hook);
|
|
7211
7443
|
try { fs.chmodSync(hookP, 0o755); } catch {}
|
|
7212
|
-
|
|
7444
|
+
// 1.36.134 (검수 실측): 종전 성공 판정은 **"바이트가 떨어졌다"** 였다 — `core.hooksPath` 저장소에서
|
|
7445
|
+
// 파일은 잘 떨어지고 `ok:true` 가 나왔지만 git 은 그 훅을 **절대 부르지 않았다**(판별 대조군: 그 설정 하나만
|
|
7446
|
+
// 다른 두 저장소가 하나는 커밋 차단, 하나는 그냥 통과). 관측하지 못한 강제를 주장하지 않는다.
|
|
7447
|
+
// → 쓴 훅을 **git 이 부르는 방식대로 직접 두 번 돌려** 판별성을 확인한다(커밋 불필요, 프로세스 2개).
|
|
7448
|
+
// handoff 흔적이 창 안이면 통과(0), 창 밖이면 차단(≠0) 이어야 한다. 둘이 같으면 그 훅은 죽은 파일이다.
|
|
7449
|
+
// 검증 전용 사본: 체인 호출과 strict gate 줄만 걷어낸 같은 스크립트. 임시 디렉토리에 두고 끝나면 지운다.
|
|
7450
|
+
// 출하되는 훅에는 검증 분기가 없으므로 사용자가 켤 수 있는 우회 스위치도 없다.
|
|
7451
|
+
const probeHookP = path.join(os.tmpdir(), `leerness-probe-${process.pid}-${Date.now()}.sh`);
|
|
7452
|
+
try {
|
|
7453
|
+
writeUtf8(probeHookP, hook.split('\n').filter(l => !/pre-commit\.pre-leerness|leerness gate \./.test(l)).join('\n'));
|
|
7454
|
+
try { fs.chmodSync(probeHookP, 0o755); } catch {}
|
|
7455
|
+
} catch { /* 못 만들면 아래 검증이 'skipped' 로 답한다 */ }
|
|
7456
|
+
const _verifyHookFires = () => {
|
|
7457
|
+
const lhp = _lastHandoffPath(root);
|
|
7458
|
+
// ⚠ 이 파일은 **실데이터**다. 내용뿐 아니라 **mtime 도** 되돌려야 한다 —
|
|
7459
|
+
// 훅이 신선도를 판정하는 근거가 바로 그 mtime 이라, 안 되돌리면 설치 행위 자체가
|
|
7460
|
+
// "방금 handoff 했다" 는 상태를 만들어 **지금 설치하는 게이트를 스스로 약화**시킨다(실측: mtime 이 갱신됐다).
|
|
7461
|
+
let saved = null, savedTimes = null;
|
|
7462
|
+
try {
|
|
7463
|
+
if (exists(lhp)) { saved = read(lhp); const st = fs.statSync(lhp); savedTimes = [st.atime, st.mtime]; }
|
|
7464
|
+
} catch { return 'skipped'; }
|
|
7465
|
+
const runHook = () => {
|
|
7466
|
+
// PROBE=1 이면 훅이 자기 판정 직후 끝난다 — 사용자 기존 훅·strict gate 를 실행하지 않는다.
|
|
7467
|
+
// ⚠ cwd 는 **git 이 훅을 부르는 곳**(toplevel)이어야 한다 — `root` 로 재면 하위 디렉토리 설치에서
|
|
7468
|
+
// "발화한다" 고 잘못 답한다(실제로 그렇게 오답했고 루트 커밋이 통과했다).
|
|
7469
|
+
// 체인·strict 를 뺀 **임시 사본**을 돌린다 — 사용자 기존 훅과 gate 는 실행되지 않는다(부작용 0 · 재귀 0).
|
|
7470
|
+
const r = cp.spawnSync('sh', [probeHookP], { cwd: topLevel, encoding: 'utf8', timeout: 20000,
|
|
7471
|
+
env: Object.assign({}, process.env, { LEERNESS_ENFORCE_BYPASS: '' }) });
|
|
7472
|
+
if (r.error) return null; // sh 가 없다 — 판정하지 않는다
|
|
7473
|
+
return r.status;
|
|
7474
|
+
};
|
|
7475
|
+
try {
|
|
7476
|
+
// ⚠ 훅은 JSON 내용이 아니라 **파일 mtime** 을 본다(`find -mmin`). 내용만 바꾸면 두 번 다 통과해
|
|
7477
|
+
// 정상 저장소까지 'not-fired' 로 오판한다(실제로 그렇게 만들었다가 과잉 차단을 냈다).
|
|
7478
|
+
const stamp = (ageH) => {
|
|
7479
|
+
writeUtf8(lhp, JSON.stringify({ last: new Date(Date.now() - ageH * 3600000).toISOString() }, null, 2));
|
|
7480
|
+
const t = new Date(Date.now() - ageH * 3600000);
|
|
7481
|
+
try { fs.utimesSync(lhp, t, t); } catch {}
|
|
7482
|
+
};
|
|
7483
|
+
stamp(0);
|
|
7484
|
+
const inWindow = runHook();
|
|
7485
|
+
stamp(windowHours + 24);
|
|
7486
|
+
const outWindow = runHook();
|
|
7487
|
+
if (inWindow === null || outWindow === null) return 'skipped';
|
|
7488
|
+
return (inWindow === 0 && outWindow !== 0) ? 'fired' : 'not-fired';
|
|
7489
|
+
} catch { return 'skipped'; }
|
|
7490
|
+
finally {
|
|
7491
|
+
try {
|
|
7492
|
+
if (saved === null) { if (exists(lhp)) fs.unlinkSync(lhp); }
|
|
7493
|
+
else { writeUtf8(lhp, saved); if (savedTimes) fs.utimesSync(lhp, savedTimes[0], savedTimes[1]); }
|
|
7494
|
+
} catch {}
|
|
7495
|
+
try { if (exists(probeHookP)) fs.unlinkSync(probeHookP); } catch {} // 임시 사본은 남기지 않는다
|
|
7496
|
+
}
|
|
7497
|
+
};
|
|
7498
|
+
const _fired = _verifyHookFires();
|
|
7499
|
+
// 1.36.134 (검수 P1): `sh` 가 없어 **관측을 못 했는데** `ok:true` 로 답했다 — 이 라운드의 계약은
|
|
7500
|
+
// "성공 = 발화하고 판별한다" 이므로, 관측 불가는 성공이 아니다. 사유와 함께 실패로 답한다.
|
|
7501
|
+
// (opt-out: 관측 수단이 없는 환경에서 그래도 걸고 싶으면 `--skip-verify`.)
|
|
7502
|
+
if (_fired === 'skipped' && !has('--skip-verify')) {
|
|
7503
|
+
_rollbackHook();
|
|
7504
|
+
failJson(json, 'verify_unavailable', `훅 발화를 확인할 수 없습니다(sh 없음) — 관측하지 못한 강제를 성공이라 하지 않습니다. 그래도 설치하려면 --skip-verify` + _rbNote());
|
|
7505
|
+
return;
|
|
7506
|
+
}
|
|
7507
|
+
if (_fired === 'not-fired') {
|
|
7508
|
+
// ⚠ 실패한 설치가 흔적을 남기면 안 된다(검수 P1): 우리 훅·백업·설정 전부 이전 상태로 되돌린다.
|
|
7509
|
+
_rollbackHook();
|
|
7510
|
+
failJson(json, 'hook_inert', `훅을 썼지만 발화하지 않습니다 (${hookP}) — core.hooksPath 리다이렉트·실행권한·sh 부재를 확인하세요` + _rbNote());
|
|
7511
|
+
return;
|
|
7512
|
+
}
|
|
7513
|
+
// 1.36.136 (재검수 P1, 재현): 이 쓰기가 실패하면 **훅은 설치된 채로 남고** raw 예외가 튀어나왔다
|
|
7514
|
+
// (실측: `.harness/enforce.json` 을 디렉토리로 만들어 두면 EPERM 크래시 + 훅 마커 4건 잔존).
|
|
7515
|
+
// 설정 없는 강제는 어중간한 상태다 — 실패하면 이번 명령 이전으로 되돌리고 사유를 말한다.
|
|
7516
|
+
try {
|
|
7517
|
+
writeUtf8(_enforceCfgPath(root), JSON.stringify({ windowHours, strict, installedAt: now() }, null, 2) + '\n');
|
|
7518
|
+
} catch (e) {
|
|
7519
|
+
_rollbackHook();
|
|
7520
|
+
failJson(json, 'config_write_failed', `설정을 저장하지 못해 설치를 되돌렸습니다: ${String(e && e.code || e.message || e).slice(0, 80)}` + _rbNote());
|
|
7521
|
+
return;
|
|
7522
|
+
}
|
|
7523
|
+
if (json) { log(JSON.stringify({ ok: true, installed: true, windowHours, strict, hook: hookP, verified: _fired, hooksPathShared: _hooksShared, worktrees: _worktreeCount }, null, 2)); return; }
|
|
7213
7524
|
ok(`enforce 설치 — 커밋 전 leerness 사용(최근 ${windowHours}h 내 handoff${strict ? ' + gate 통과' : ''})을 git pre-commit 에서 강제`);
|
|
7525
|
+
log(` 훅 파일: ${hookP}`);
|
|
7526
|
+
// 1.36.140 (재검수 P1): `--json` 에만 있던 공유 사실이 사람 출력에는 없었다 — 사람이 더 모른다.
|
|
7527
|
+
if (_hooksShared === true) warn(` ⚠ 이 훅은 **공유 core.hooksPath** 에 있습니다 — 같은 설정을 쓰는 다른 저장소에도 걸립니다`);
|
|
7528
|
+
if (_worktreeCount > 1) warn(` ⚠ 이 저장소는 worktree 가 ${_worktreeCount}개입니다 — 훅은 공용이라 **형제 worktree 의 커밋도** 차단됩니다`);
|
|
7214
7529
|
log(` 대상: 어떤 에이전트/모드든(codex goal 모드 포함) — 커밋이 보편 관문이라 문서를 안 읽는 경로도 강제됨`);
|
|
7215
7530
|
log(` 긴급 우회: LEERNESS_ENFORCE_BYPASS=1 git commit … · 해제: leerness enforce remove`);
|
|
7216
7531
|
return;
|
|
7532
|
+
} catch (e) {
|
|
7533
|
+
// 되돌릴 수 있는 데까지 되돌리고, 무슨 일이 있었는지 말한다(조용한 중간 상태를 남기지 않는다).
|
|
7534
|
+
_rollbackHook();
|
|
7535
|
+
failJson(json, 'install_failed', `설치 중 실패해 되돌렸습니다: ${String((e && (e.code || e.message)) || e).slice(0, 120)}` + _rbNote());
|
|
7536
|
+
return;
|
|
7537
|
+
}
|
|
7217
7538
|
}
|
|
7218
7539
|
if (sub === 'check') {
|
|
7219
7540
|
if (!exists(path.join(root, '.harness'))) return; // leerness 미설치 저장소 — 강제 대상 아님(무음 통과)
|
|
7541
|
+
// 1.36.134 (검수 실측): `check` 는 네 번째 표면인데 **git 상태를 아예 안 봤다** — git 아님·HEAD 손상·gitdir 깨짐에서
|
|
7542
|
+
// 전부 `ok:true exit 0`. 훅이 개념적으로 부르는 표면이라 여기서 조용하면 강제 자체가 조용해진다.
|
|
7543
|
+
// 같은 술어를 태운다. 단 **차단하지 않는다** — 커밋 훅 경로에서 우리 판정 실패로 사용자 커밋을 막지 않는다(권고 원칙).
|
|
7544
|
+
{
|
|
7545
|
+
const _gsCheck = _gitState(root);
|
|
7546
|
+
if (!GIT_STATE_USABLE.has(_gsCheck)) {
|
|
7547
|
+
if (json) { log(JSON.stringify({ ok: true, enforced: false, gitState: _gsCheck, note: 'git_unusable' }, null, 2)); return; }
|
|
7548
|
+
warn(` ⚠ enforce check: git 상태가 ${_gsCheck} — 강제를 적용할 수 없습니다(차단하지 않고 통과)`);
|
|
7549
|
+
return;
|
|
7550
|
+
}
|
|
7551
|
+
}
|
|
7220
7552
|
const cfg = _enforceCfg(root);
|
|
7221
7553
|
const lhf = _lastHandoffPath(root);
|
|
7222
7554
|
let lastAt = null;
|
|
@@ -7248,13 +7580,19 @@ function enforceCmd(root, sub) {
|
|
|
7248
7580
|
const cfg2 = _enforceCfg(root);
|
|
7249
7581
|
let handoffs = [];
|
|
7250
7582
|
try { const j = JSON.parse(read(_lastHandoffPath(root))); handoffs = (j.history || []).map(t => new Date(t).getTime()).filter(n => !Number.isNaN(n)); } catch {}
|
|
7251
|
-
const gl =
|
|
7583
|
+
const gl = _gitSpawn(['-C', root, 'log', '-n', '30', '--pretty=format:%H|%ct|%s'], { encoding: 'utf8', timeout: 10000 });
|
|
7252
7584
|
// 1.36.49 (codex 6차 #9): 커밋 0개(unborn HEAD)는 정상 저장소 — 실패가 아니라 "검사 대상 없음" 성공.
|
|
7253
7585
|
if (gl.status !== 0 && /does not have any commits|unknown revision|bad default revision|ambiguous argument/i.test(gl.stderr || '')) {
|
|
7254
7586
|
if (json) { log(JSON.stringify({ checked: 0, windowHours: cfg2.windowHours, suspects: [], note: 'no_commits' }, null, 2)); return; }
|
|
7255
7587
|
ok(' 커밋 0개 — 검사 대상 없음'); return;
|
|
7256
7588
|
}
|
|
7257
|
-
if (gl.status !== 0) {
|
|
7589
|
+
if (gl.status !== 0) {
|
|
7590
|
+
// 1.36.134: `install` 과 **같은 술어**로 사유를 말한다 — 종전엔 install 이 ok:true 를 내는 저장소에서
|
|
7591
|
+
// audit 만 'git_log_failed' 라 두 표면이 서로를 반박했다.
|
|
7592
|
+
const _gsAudit = _gitState(root);
|
|
7593
|
+
failJson(json, 'git_log_failed', `git log 실패 (git 상태: ${_gsAudit}) — ${root}`);
|
|
7594
|
+
return;
|
|
7595
|
+
}
|
|
7258
7596
|
const winMs = cfg2.windowHours * 3600000;
|
|
7259
7597
|
const suspects = [];
|
|
7260
7598
|
for (const line of (gl.stdout || '').split('\n').filter(Boolean)) {
|
|
@@ -7290,12 +7628,22 @@ function enforceCmd(root, sub) {
|
|
|
7290
7628
|
// status
|
|
7291
7629
|
const installed = exists(hookP) && read(hookP).includes(_ENFORCE_MARK);
|
|
7292
7630
|
const cfg = _enforceCfg(root);
|
|
7293
|
-
|
|
7631
|
+
// 1.36.134 (실측): 종전엔 git 아님 / unborn / 정상 / HEAD 손상 / gitdir 깨짐 **5상태에서 payload 가 동일**했다 —
|
|
7632
|
+
// '설치 안 됨' 과 '설치할 데가 없음' 이 구분되지 않았다. 기존 필드는 그대로 두고 상태를 **추가**한다.
|
|
7633
|
+
const _gsStatus = _gitState(root);
|
|
7634
|
+
if (json) { log(JSON.stringify({ installed, ...cfg, gitState: _gsStatus, installable: GIT_STATE_USABLE.has(_gsStatus), hooksPathShared: _hooksShared, worktrees: _worktreeCount, hook: hookP }, null, 2)); return; }
|
|
7294
7635
|
log(`# leerness enforce — 사용 강제 (git pre-commit)`);
|
|
7295
7636
|
log(` 상태: ${installed ? `🟢 설치됨 (window ${cfg.windowHours}h${cfg.strict ? ' · strict=gate' : ''})` : '⚪ 미설치'}`);
|
|
7296
7637
|
log(` ${installed ? '해제: leerness enforce remove' : '설치: leerness enforce install [--window 24] [--strict]'}`);
|
|
7638
|
+
// 1.36.134: 설치할 수 없는 상태면 그 사실을 말한다 — 안 그러면 '미설치' 만 보고 설치를 시도하다 실패한다.
|
|
7639
|
+
if (!GIT_STATE_USABLE.has(_gsStatus)) log(` ⚠ 지금은 설치할 수 없습니다 (git 상태: ${_gsStatus})`);
|
|
7640
|
+
// 1.36.135: '설치됨' 이 프로젝트 전용이 아닐 수 있다 — 공유 hooksPath 는 그 설정을 쓰는 **모든** 저장소에 걸린다.
|
|
7641
|
+
if (_hooksShared === true) log(` ⚠ 이 훅은 **공유 core.hooksPath** 에 있습니다(${hookP}) — 같은 설정을 쓰는 다른 저장소에도 걸립니다`);
|
|
7642
|
+
if (_worktreeCount > 1) log(` ⚠ 이 저장소는 worktree 가 ${_worktreeCount}개입니다 — 훅은 공용이라 **형제 worktree 의 커밋도** 차단됩니다`);
|
|
7297
7643
|
// 1.36.44 (정직성): 한계 명시 — 과장 금지
|
|
7298
|
-
|
|
7644
|
+
// 1.36.134 (실측): 이 줄의 `dm` 은 이 스코프에 없어 **정상 저장소에서도 `enforce status` 가 exit 1 로 죽었다**
|
|
7645
|
+
// (`✗ dm is not defined`). e2e 가 `--json` 만 봐서 사람 경로의 크래시가 안 보였다 — 표면을 하나만 재면 이렇게 된다.
|
|
7646
|
+
log(dim(` ⓘ 한계: git commit --no-verify 는 git 설계상 훅을 스킵 — 사후 감지는 leerness enforce audit, 우회 불가 하드 레이어는 CI(leerness ci init + branch protection)`));
|
|
7299
7647
|
}
|
|
7300
7648
|
|
|
7301
7649
|
// 1.36.36 (도그푸딩 실측 후속): `leerness anchors [status|draft [--apply]]` — 정체성앵커(brief Purpose / plan Goal) 미작성 전환 지원.
|
|
@@ -9815,7 +10163,7 @@ function _detectAbnormalShutdown(root) {
|
|
|
9815
10163
|
|
|
9816
10164
|
// 5) release/X.X.X branch 있음 + main 미머지 (git 상태)
|
|
9817
10165
|
try {
|
|
9818
|
-
const r =
|
|
10166
|
+
const r = _gitSpawn(['branch', '--list', 'release/*'], { cwd: root, encoding: 'utf8', timeout: 3000 });
|
|
9819
10167
|
if (r.status === 0) {
|
|
9820
10168
|
const branches = (r.stdout || '').split('\n').map(s => s.trim().replace(/^\*?\s*/, '')).filter(b => b.startsWith('release/'));
|
|
9821
10169
|
if (branches.length >= 2) { // 2개 이상이면 누락 가능성
|
|
@@ -11630,10 +11978,18 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
11630
11978
|
try { fs.unlinkSync(fp); } catch { /* 지우지 못해도 아래 쓰기가 덮는다 */ }
|
|
11631
11979
|
}
|
|
11632
11980
|
}
|
|
11981
|
+
const _prevAgent = prev && prev.outermostAgent;
|
|
11982
|
+
// 1.36.132 (검수 P2): 라벨 순서를 안 맞추면 `A<->B` 와 `B<->A` 가 번갈아 기록되며 **매 handoff 마다** 새 경고가 났다.
|
|
11983
|
+
// 같은 두 에이전트는 같은 표식이어야 한다 — 정렬해서 순서를 없앤다.
|
|
11984
|
+
const _sharedAgents = (_prevAgent && _prevAgent !== 'unknown' && det.agent !== 'unknown' && _prevAgent !== det.agent)
|
|
11985
|
+
? [_prevAgent, det.agent].sort().join('<->') : ((prev && prev.sharedKeyAgents) || null);
|
|
11986
|
+
if (_sharedAgents && !(prev && prev.sharedKeyAgents === _sharedAgents) && process.env.LEERNESS_NO_LOCK_WARN !== '1') {
|
|
11987
|
+
try { process.stderr.write(`⚠ 세션 주소 공유 감지: ${key} 를 서로 다른 에이전트(${_sharedAgents})가 쓰고 있습니다 — 두 세션이 한 기록으로 합쳐져 서로를 못 봅니다. 세션마다 다른 LEERNESS_SESSION_ID 를 쓰세요.\n`); } catch {}
|
|
11988
|
+
}
|
|
11633
11989
|
const rec = {
|
|
11634
11990
|
schemaVersion: 1,
|
|
11635
11991
|
sessionKey: key,
|
|
11636
|
-
keySource: process.env.
|
|
11992
|
+
keySource: SP.keySource(process.env), // 1.36.132: 3종(LEERNESS_SESSION_ID 우선) — 손으로 적은 삼항은 새 출처를 놓친다
|
|
11637
11993
|
outermostAgent: det.agent,
|
|
11638
11994
|
agentEvidence: det.evidence, // 결론이 아니라 원증거 키 이름 — 규칙이 틀려도 원자료가 남는다
|
|
11639
11995
|
hostId: _sessionHostId(root),
|
|
@@ -11643,7 +11999,39 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
11643
11999
|
lastHandoffAt: phase === 'close' ? ((prev && prev.lastHandoffAt) || nowIso) : nowIso,
|
|
11644
12000
|
handoffCount: (Number((prev && prev.handoffCount) || 0) || 0) + (phase === 'close' ? 0 : 1),
|
|
11645
12001
|
closedAt: phase === 'close' ? nowIso : null,
|
|
12002
|
+
// 1.36.132 (검수 실측): 같은 주소를 두 세션이 쓰면 레코드가 조용히 병합됐다(others=0·헤드라인 침묵·
|
|
12003
|
+
// 라벨이 마지막 기록자로 덮임). 라벨이 **둘 다 unknown 이 아니면서** 바뀌었다면 그건 공유의 증거다.
|
|
12004
|
+
// unknown 을 제외하는 이유: 셸마다 env 가시성이 달라 라벨이 흔들릴 때 오탐이 난다(실측).
|
|
12005
|
+
sharedKeyAgents: _sharedAgents,
|
|
11646
12006
|
};
|
|
12007
|
+
// 1.36.133 (정찰 실측): `rec` 는 닫힌 리터럴이라 **우리가 모르는 키는 다음 handoff 에서 사라졌다** —
|
|
12008
|
+
// 주입한 `scope`/`futureField` 가 handoff 한 번에 소멸, exit 0, **stderr 0바이트**. 버전 혼재가 이 기계에 실재한다
|
|
12009
|
+
// (전역 설치본이 저장소보다 낮음) → 새 버전이 쓴 필드를 옛 버전이 조용히 지운다.
|
|
12010
|
+
// 지우는 쪽으로 실패하지 않는다: 모르는 키는 **그대로 실어 나르고**, 실어 날랐다는 사실을 말한다.
|
|
12011
|
+
// ⚠ 무제한 병합은 하지 않는다 — 개수/크기 상한을 넘으면 버리되 **버렸다고 말한다**(조용한 유실 금지).
|
|
12012
|
+
// 1.36.133 (검수 P1 ×3): 첫 판에 세 갈래로 틀렸다 —
|
|
12013
|
+
// · 메타 키(`carriedUnknownKeys`/`droppedUnknownKeys`)가 **다음 회차에 자기 슬롯을 잡아먹어** 진짜 10번째 키를 버렸다.
|
|
12014
|
+
// · `__proto__` 는 `rec[k]=` 로 own property 가 되지 않아 **이월했다고 적고 실제로는 잃었다**(거짓 보고).
|
|
12015
|
+
// · drop 경고를 stderr 로 냈더니 성공한 `handoff --json` 의 stderr-0 계약을 깼고, 억제하면 조용한 유실이 됐다.
|
|
12016
|
+
// → 메타 키를 알려진 키로 등록하고, 위험한 키 이름은 이월 대신 **드롭으로 정직하게** 세고,
|
|
12017
|
+
// 사실은 stderr 가 아니라 **레코드와 신호 표면**에 남긴다(계약을 안 깨고도 보인다).
|
|
12018
|
+
const _META = ['carriedUnknownKeys', 'droppedUnknownKeys'];
|
|
12019
|
+
const _UNSAFE = new Set(['__proto__', 'constructor', 'prototype']);
|
|
12020
|
+
const _known = new Set([...Object.keys(rec), ..._META]);
|
|
12021
|
+
const _carried = [], _dropped = [];
|
|
12022
|
+
if (prev && typeof prev === 'object') {
|
|
12023
|
+
for (const k of Object.keys(prev)) {
|
|
12024
|
+
if (_known.has(k)) continue;
|
|
12025
|
+
if (_UNSAFE.has(k)) { _dropped.push(k); continue; } // 이월할 수 없는 이름 — 세고 버린다
|
|
12026
|
+
if (_carried.length >= 10) { _dropped.push(k); continue; }
|
|
12027
|
+
let _sz = 0; try { _sz = JSON.stringify(prev[k]).length; } catch { _sz = Infinity; }
|
|
12028
|
+
if (!(_sz <= 4096)) { _dropped.push(k); continue; }
|
|
12029
|
+
Object.defineProperty(rec, k, { value: prev[k], enumerable: true, writable: true, configurable: true });
|
|
12030
|
+
_carried.push(k);
|
|
12031
|
+
}
|
|
12032
|
+
}
|
|
12033
|
+
if (_carried.length) rec.carriedUnknownKeys = _carried;
|
|
12034
|
+
if (_dropped.length) rec.droppedUnknownKeys = _dropped.slice(0, 10);
|
|
11647
12035
|
writeUtf8(fp, JSON.stringify(rec, null, 2) + '\n');
|
|
11648
12036
|
res = { written: true, reason: null };
|
|
11649
12037
|
});
|
|
@@ -11675,6 +12063,99 @@ function _sessionPresenceRecord(root, phase) {
|
|
|
11675
12063
|
return { written: false, reason: 'unavailable:' + String((e && e.code) || (e && e.message) || e).slice(0, 40) };
|
|
11676
12064
|
}
|
|
11677
12065
|
}
|
|
12066
|
+
// 1.36.132 (UR-0069): 세션 신호를 **한 곳에서** 만든다. 종전엔 헤드라인 한 군데에만 인라인으로 있었고
|
|
12067
|
+
// `--json`·`--compact` 에는 없었다 — 표면마다 따로 짜면 새 표면이 빠진다(1.36.108 이 고친 형태와 같다).
|
|
12068
|
+
// 1.36.132 (검수 P1, 재현): 첫 판은 `exists()` 만 보고 `_sessionHostId` 를 불렀는데, 그 함수는 염이
|
|
12069
|
+
// **형식에 안 맞으면 새로 만들어 덮어쓴다** — 실측: 훅(쓰기 0 계약)이 `INVALID_SALT` 를 새 32자 랜덤으로 교체했다.
|
|
12070
|
+
// 존재 확인이 아니라 **직접 읽고 직접 검증**한다. 쓰는 함수는 부르지 않는다.
|
|
12071
|
+
// 염이 없거나 깨졌으면 hostId 를 모른다 → `hostRelation` 이 3상태라 'unknown' 으로 정직하게 답한다.
|
|
12072
|
+
function _sessionHostIdIfPresent(root) {
|
|
12073
|
+
try {
|
|
12074
|
+
const sp = path.join(_sessionStoreDir(root), '.host-salt');
|
|
12075
|
+
if (!exists(sp)) return null;
|
|
12076
|
+
const v = String(read(sp) || '').trim();
|
|
12077
|
+
if (!/^[0-9a-f]{32}$/.test(v)) return null;
|
|
12078
|
+
return require('crypto').createHash('sha256').update(v + '|' + String(os.hostname())).digest('hex').slice(0, 12);
|
|
12079
|
+
} catch { return null; }
|
|
12080
|
+
}
|
|
12081
|
+
function _sessionSignal(root, opts = {}) {
|
|
12082
|
+
const SP = require('../lib/session-presence');
|
|
12083
|
+
const snap = _readSessionEntries(root);
|
|
12084
|
+
const selfKey = SP.deriveSessionKey(process.env);
|
|
12085
|
+
// 1.36.132 (검수 P1, 재현): 종전엔 readOnly 가 아닐 때 `_sessionHostId` 를 불러 **염을 만들었다** —
|
|
12086
|
+
// 그래서 `handoff --json --no-record` 가 파일 3개를 만들었다(기록하지 말라고 했는데 만들었다).
|
|
12087
|
+
// **신호를 재는 일은 어떤 경우에도 쓰기가 아니다.** 염 생성은 실제로 기록하는 `_sessionPresenceRecord` 만 한다.
|
|
12088
|
+
const others = SP.countOtherSessions(snap.entries.map(e => e.record), selfKey, _sessionHostIdIfPresent(root));
|
|
12089
|
+
// 내 레코드가 공유 주소로 표시돼 있으면 그것도 신호다(플래그 생성기가 짧은 표면에도 싣는다).
|
|
12090
|
+
// 1.36.132 (검수 P1, 재현): `selfRegistered: !!selfKey` 는 **주소가 있다**를 **등록됐다**로 보고했다 —
|
|
12091
|
+
// `--no-record`/훅에서는 레코드가 0개인데 true 였다. 키 유무가 아니라 **내 파일이 실제로 있는지**로 답한다.
|
|
12092
|
+
let mine = null;
|
|
12093
|
+
try { mine = selfKey ? (snap.entries.find(e => e.record && e.record.sessionKey === selfKey) || null) : null; } catch {}
|
|
12094
|
+
const sharedKeyAgents = (mine && mine.record && mine.record.sharedKeyAgents) || null;
|
|
12095
|
+
return {
|
|
12096
|
+
others, // 다른 세션 **기록** 수(활성 여부는 판정하지 않는다)
|
|
12097
|
+
sharedKeyAgents,
|
|
12098
|
+
// 1.36.132 (검수 P1): 명시 주소가 형식에 안 맞으면 종전엔 조용히 다음 출처로 넘어갔다(오타를 안 알림).
|
|
12099
|
+
invalidExplicitKey: SP.invalidExplicitKey(process.env),
|
|
12100
|
+
// 1.36.132 (검수 P1): 저장소를 못 읽은 것을 `others:0` 으로 축약하지 않는다 — 이 저장소의 규율이다.
|
|
12101
|
+
storeError: snap.storeError || null,
|
|
12102
|
+
selfRegistered: !!mine,
|
|
12103
|
+
selfAddressed: !!selfKey, // 주소는 있다(등록됐다와 다르다)
|
|
12104
|
+
selfSuppressedReason: SP.suppressionReason(process.env, exists(path.join(root, '.harness'))) || (selfKey ? null : 'not-identifiable'),
|
|
12105
|
+
unreadableRecords: snap.unreadable || 0,
|
|
12106
|
+
// 못 보는 것을 함께 적는다 — "0건" 이 '아무도 없다' 로 읽히면 그건 과대주장이다.
|
|
12107
|
+
// 1.36.132 (검수 P3): codex 는 이제 주소가 있다 — 사람 표면과 기계 표면이 같은 사실을 말해야 한다.
|
|
12108
|
+
// 1.36.133: **서브에이전트가 빠져 있었다.** 이 프로젝트의 session-workflow 는 파일 경로를 나눠 주는
|
|
12109
|
+
// 병렬 sub-agent 를 의무화하는데, 그들은 `child-agent` 로 억제돼 레지스트리에 아예 안 나타난다 —
|
|
12110
|
+
// 즉 겹침이 가장 잘 나는 주체가 구조적으로 안 보인다. 못 보는 것을 안 적으면 "0건" 이 거짓말이 된다.
|
|
12111
|
+
blindSpots: ['handoff 를 부르지 않는 세션', 'MCP 전용 클라이언트(LEERNESS_INTERNAL)',
|
|
12112
|
+
'병렬 sub-agent (CLAUDE_CODE_CHILD_SESSION — 억제되어 등록되지 않음)',
|
|
12113
|
+
'주소가 없는 에이전트(cursor/vscode/grok 등 · codex 는 CODEX_THREAD_ID 가 없는 실행에서만, TUI 미검증)',
|
|
12114
|
+
'다른 머신', '구버전 설치본'],
|
|
12115
|
+
};
|
|
12116
|
+
}
|
|
12117
|
+
// 1.36.132 (검수): 짧은 표면(--compact·훅)에 실을 세션 플래그를 **한 곳에서** 만든다.
|
|
12118
|
+
// 실측 비대칭: 👥(남이 보인다)는 4표면 전부에 갔는데 🙈(나는 안 보인다)는 평문 1곳뿐이었다.
|
|
12119
|
+
// 내가 안 보인다는 것은 남이 나를 피해 갈 수 없다는 뜻이라, 오히려 짧은 표면일수록 필요한 신호다.
|
|
12120
|
+
function _sessionSignalFlags(ss) {
|
|
12121
|
+
const out = [];
|
|
12122
|
+
if (ss.others > 0) out.push(`👥 다른 세션 ${ss.others}`);
|
|
12123
|
+
if (!ss.selfRegistered && ss.selfSuppressedReason !== 'internal' && ss.selfSuppressedReason !== 'child-agent') {
|
|
12124
|
+
out.push(`🙈 나는 안 보임(${ss.selfSuppressedReason})`);
|
|
12125
|
+
}
|
|
12126
|
+
if (ss.sharedKeyAgents) out.push(`⚠ 주소 공유(${ss.sharedKeyAgents})`);
|
|
12127
|
+
if (ss.storeError) out.push(`⚠ 세션저장소 읽기실패(${ss.storeError})`);
|
|
12128
|
+
if (ss.invalidExplicitKey) out.push(`⚠ LEERNESS_SESSION_ID 형식 오류("${ss.invalidExplicitKey}") — 무시됨`);
|
|
12129
|
+
return out;
|
|
12130
|
+
}
|
|
12131
|
+
// 1.36.132 (UR-0069): `.harness/agent-reminders.md` 는 leerness 안의 유일한 "에이전트에게 보내는 메시지" 파일인데
|
|
12132
|
+
// **읽는 배선이 없었다** — 실측: handoff 200줄에 0건, `hook session-start` 319바이트에 0건, 현재 관리 문서 지시 0건
|
|
12133
|
+
// (아카이브 README 에만 남아 있다). 파일 자신이 "사용자가 이 파일을 보고 메인 에이전트에 전달 가능" 이라고 적어
|
|
12134
|
+
// 배달을 사람에게 떠넘긴다. drift critical 때 만들어 놓고 아무도 보여주지 않으면 그것은 기능이 아니라 침묵이다.
|
|
12135
|
+
function _agentReminderSignal(root) {
|
|
12136
|
+
const p = path.join(root, '.harness', 'agent-reminders.md');
|
|
12137
|
+
if (!exists(p)) return null;
|
|
12138
|
+
// 1.36.132 (검수 P2): 통째로 읽으면 커진 파일에서 복제·분할 비용이 그대로 터진다(64MiB 에서 2.16초 실측).
|
|
12139
|
+
// 헤드라인에 쓰는 것은 앞 몇 줄뿐이니 앞부분만 읽는다. 줄 수는 '읽은 범위 기준' 이라고 표시한다.
|
|
12140
|
+
const CAP = 64 * 1024;
|
|
12141
|
+
let body = '', truncated = false;
|
|
12142
|
+
try {
|
|
12143
|
+
const st = fs.statSync(p);
|
|
12144
|
+
if (st.size > CAP) {
|
|
12145
|
+
const fd = fs.openSync(p, 'r'); const buf = Buffer.alloc(CAP);
|
|
12146
|
+
const got = fs.readSync(fd, buf, 0, CAP, 0); fs.closeSync(fd);
|
|
12147
|
+
body = buf.slice(0, got).toString('utf8'); truncated = true;
|
|
12148
|
+
} else { body = read(p); }
|
|
12149
|
+
} catch { return null; }
|
|
12150
|
+
if (!body.trim()) return null;
|
|
12151
|
+
const lines = body.replace(/\r\n/g, '\n').split('\n').filter(l => l.trim() && !/^<!--/.test(l.trim()));
|
|
12152
|
+
// 1.36.132 (검수 P1, 재현): 이 내용은 **사람이 편집하는 파일**인데 그대로 헤드라인·JSON 에 실렸다 —
|
|
12153
|
+
// 실제 ANSI 이스케이프·고립 CR·가짜 `=== 구획 ===`·비밀값 표식이 생바이트로 나갔다.
|
|
12154
|
+
// 제어문자를 지우고(터미널·문서 경계 위조 차단) 토큰을 마스킹한 뒤에만 싣는다. 기존 술어를 재사용한다.
|
|
12155
|
+
const _clean = (s) => require('../lib/pure-utils').redactSecrets(
|
|
12156
|
+
String(s).replace(new RegExp('[\u0000-\u001f\u007f]', 'g'), ' ').replace(/^[=\-]{3,}/, ''), 200).trim();
|
|
12157
|
+
return { path: rel(root, p), lines: lines.length, truncated, head: lines.slice(0, 6).map(l => _clean(l).slice(0, 200)).filter(Boolean) };
|
|
12158
|
+
}
|
|
11678
12159
|
// 1.36.129 (UR-0068 / P-0016 P1): `leerness sessions` — 이 프로젝트에 남은 세션 **기록**을 보여 준다.
|
|
11679
12160
|
// ⚠ 문구 원칙: 검증하지 않은 것을 주장하지 않는다. "활성/active" 라는 말을 쓰지 않고,
|
|
11680
12161
|
// close 기록이 없다는 사실만 말한다. 그리고 **못 보는 것을 항상 함께 적는다** —
|
|
@@ -11687,7 +12168,10 @@ function sessionsCmd(root) {
|
|
|
11687
12168
|
const cy = s => isTty ? `\x1b[36m${s}\x1b[0m` : s;
|
|
11688
12169
|
const dim = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
|
|
11689
12170
|
const selfKey = SP.deriveSessionKey(process.env);
|
|
11690
|
-
|
|
12171
|
+
// 1.36.132 (검수): **조회 명령이 남의 디렉토리에 파일을 만들었다** — 빈 디렉토리에 `sessions --json` 1회로
|
|
12172
|
+
// .harness/ · cache/ · sessions/ · .host-salt 4개 생성(exit 0). 읽기는 읽기여야 한다.
|
|
12173
|
+
// 염이 없으면 hostId 를 모른다 → hostRelation 이 이미 3상태라 'unknown' 으로 정직하게 답한다.
|
|
12174
|
+
const selfHost = _sessionHostIdIfPresent(root);
|
|
11691
12175
|
const _scan = _readSessionEntries(root);
|
|
11692
12176
|
const recs = _scan.entries.map(e => e.record).sort((a, b) => (Date.parse(b.lastHandoffAt || b.openedAt || '') || 0) - (Date.parse(a.lastHandoffAt || a.openedAt || '') || 0));
|
|
11693
12177
|
const suppressed = SP.suppressionReason(process.env, exists(path.join(root, '.harness')));
|
|
@@ -11723,6 +12207,10 @@ function sessionsCmd(root) {
|
|
|
11723
12207
|
t('leerness handoff 를 돌리지 않은 에이전트 세션', 'agent sessions that never ran `leerness handoff`'),
|
|
11724
12208
|
t('MCP 도구만 쓰는 클라이언트', 'clients that only use MCP tools'),
|
|
11725
12209
|
t(`${VERSION} 이전 버전으로 시작된 세션`, `sessions started on a version older than ${VERSION}`),
|
|
12210
|
+
// 1.36.132: 종전엔 '세션키가 없는 에이전트(codex/cursor 등)' 이라고 적었는데 codex 는 이제 주소가 있다.
|
|
12211
|
+
// 자기 도구의 능력을 과소·과대 주장하지 않는다 — 실측 범위를 그대로 적는다(codex exec 경로에서 확인).
|
|
12212
|
+
t('주소가 없는 에이전트 — cursor/vscode/grok 등. codex 는 CODEX_THREAD_ID 가 없는 실행에서만 (TUI 는 미검증)',
|
|
12213
|
+
'agents without an address - cursor/vscode/grok etc. codex only when CODEX_THREAD_ID is absent (TUI unverified)'),
|
|
11726
12214
|
t('다른 머신·컨테이너의 세션 (기록은 보이되 상태는 확인 불가)', 'sessions on another machine/container (record visible, state unverifiable)'),
|
|
11727
12215
|
t("'close 기록 없음' 은 그 세션이 지금 살아있다는 뜻이 아닙니다 — 프로세스 상태는 판정하지 않습니다",
|
|
11728
12216
|
"'no close recorded' does NOT mean that session is alive — process state is never judged"),
|
|
@@ -11746,6 +12234,12 @@ function sessionsCmd(root) {
|
|
|
11746
12234
|
` ⓘ A record for this session exists, but **this invocation** does not update it (reason: ${suppressed}).`)
|
|
11747
12235
|
: t(` ⓘ 이 세션은 레지스트리에 등록되지 않습니다 (사유: ${suppressed}) — 다른 세션에게 보이지 않습니다.`,
|
|
11748
12236
|
` ⓘ This session is not registered (reason: ${suppressed}) — other sessions cannot see it.`));
|
|
12237
|
+
// 1.36.132 (UR-0069): 사유만 말하고 **처방을 안 주면** 사용자는 여기서 막힌다.
|
|
12238
|
+
// `not-identifiable` 은 곧 "이 에이전트는 세션 식별자를 환경에 안 남긴다" 이고, 그건 주소를 주면 풀린다.
|
|
12239
|
+
if (suppressed === 'not-identifiable') {
|
|
12240
|
+
log(t(` → 이 세션을 주소로 만들려면: 에이전트를 띄울 때 LEERNESS_SESSION_ID 를 **세션마다 다른 값**으로 설정하세요 (8~64자, 영숫자/_/-).`,
|
|
12241
|
+
` → to give this session an address: set LEERNESS_SESSION_ID to a **unique value per session** when launching the agent (8-64 chars, alphanumeric/_/-).`));
|
|
12242
|
+
}
|
|
11749
12243
|
log('');
|
|
11750
12244
|
}
|
|
11751
12245
|
// '없음' 과 '확인 불가' 를 같은 문장으로 말하지 않는다(검수 P1).
|
|
@@ -13110,26 +13604,41 @@ function lessonListCmd(root, opts = {}) {
|
|
|
13110
13604
|
// 1.9.124: lesson drop — lessons.md 에서 특정 date 또는 text 매칭 블록 제거
|
|
13111
13605
|
function lessonDropCmd(root, target) {
|
|
13112
13606
|
root = absRoot(root);
|
|
13607
|
+
// 1.36.140 (재검수 P3): 락/쓰기 헬퍼가 부모를 만들기 때문에, 오타 난 `--path` 로 실패하면서도
|
|
13608
|
+
// 그 경로와 `.harness/` 를 만들었다(1.36.139 는 안 만들었다 — 대조군). 없으면 아무것도 만들지 않는다.
|
|
13609
|
+
if (!exists(root)) return failJson(has('--json'), 'path_not_found', `경로가 없습니다: ${root} — 디렉토리를 새로 만들지 않았습니다`);
|
|
13113
13610
|
if (!target) return fail('lesson drop <date|text-substring> 필요. 예: leerness lesson drop "2026-05-20" 또는 leerness lesson drop "JWT"');
|
|
13114
|
-
const kept = [];
|
|
13115
|
-
const removed = [];
|
|
13116
|
-
const all = _loadLessons(root);
|
|
13117
|
-
if (!all.length) return failJson(has('--json'), 'no_lessons', 'lessons 없음'); // codex P2: --json 에러 구조화
|
|
13118
|
-
for (const l of all) {
|
|
13119
|
-
if (l.date === target || (l.text || '').includes(target)) removed.push(l);
|
|
13120
|
-
else kept.push(l);
|
|
13121
|
-
}
|
|
13122
|
-
if (!removed.length) return failJson(has('--json'), 'lesson_not_found', `매칭 lesson 없음: "${target}"`);
|
|
13123
13611
|
// archive 보존 — lessons.archive.md 에 projection MD 블록 형태로 추가
|
|
13124
13612
|
const archivePath = path.join(root, '.harness/lessons.archive.md');
|
|
13125
|
-
|
|
13126
|
-
//
|
|
13127
|
-
//
|
|
13128
|
-
|
|
13129
|
-
|
|
13130
|
-
).
|
|
13131
|
-
|
|
13132
|
-
|
|
13613
|
+
// 1.36.140 (재검수 P1, 재현): 종전엔 락 **밖**에서 매칭한 뒤 락 안에서 `${date}|${text}` 복합 키로
|
|
13614
|
+
// 개수만큼 지웠다. `date` 안에 `|` 가 있으면 서로 다른 두 항목이 같은 키로 접혀
|
|
13615
|
+
// **무관한 항목이 지워지고 archive 에는 대상이 지워졌다고 적혔다**(실측: 순서를 뒤집으면 대상이 남았다).
|
|
13616
|
+
// 키를 고치는 대신 **셈을 없앤다** — 판정을 락 안에서 다시 하면 키가 필요 없다.
|
|
13617
|
+
// archive 도 "지우려던 것" 이 아니라 **실제로 지운 것**을 락 안에서 남긴다
|
|
13618
|
+
// (그래야 동시 drop 두 개가 각각 1건 성공이라 보고하고 archive 를 두 번 쌓는 일이 없다).
|
|
13619
|
+
let removed = [];
|
|
13620
|
+
let loadErr = null;
|
|
13621
|
+
_withLock(lessonsJsonPath(root), () => {
|
|
13622
|
+
const cur = _loadLessons(root);
|
|
13623
|
+
if (!cur.length) { loadErr = 'no_lessons'; return; }
|
|
13624
|
+
const keep = [], gone = [];
|
|
13625
|
+
for (const l of cur) {
|
|
13626
|
+
if (l.date === target || (l.text || '').includes(target)) gone.push(l);
|
|
13627
|
+
else keep.push(l);
|
|
13628
|
+
}
|
|
13629
|
+
if (!gone.length) { loadErr = 'lesson_not_found'; return; }
|
|
13630
|
+
// codex 버그헌트 P2: 사용자 텍스트(l.text/l.tag/target)를 archive MD 에 raw 삽입하면 개행으로
|
|
13631
|
+
// `## 제거 …` 델리미터를 위조해 가짜 archive 엔트리를 만들 수 있다(archive list/restore 오염). _lineSafe 로 개행 무력화.
|
|
13632
|
+
const archiveBlocks = gone.map(l =>
|
|
13633
|
+
`\n### ${_lineSafe(l.date)}\n- Lesson: ${_lineSafe(l.text)}\n${l.tag ? `- Tag: ${_lineSafe(l.tag)}\n` : ''}`
|
|
13634
|
+
).join('');
|
|
13635
|
+
const archiveHeader = exists(archivePath) ? '' : '# Lessons archive\n\n';
|
|
13636
|
+
append(archivePath, archiveHeader + `\n## 제거 ${today()} (target: "${_lineSafe(target)}")\n${archiveBlocks}\n`);
|
|
13637
|
+
_saveLessons(root, keep);
|
|
13638
|
+
removed = gone;
|
|
13639
|
+
});
|
|
13640
|
+
if (loadErr === 'no_lessons') return failJson(has('--json'), 'no_lessons', 'lessons 없음'); // codex P2: --json 에러 구조화
|
|
13641
|
+
if (loadErr === 'lesson_not_found' || !removed.length) return failJson(has('--json'), 'lesson_not_found', `매칭 lesson 없음: "${target}"`);
|
|
13133
13642
|
ok(`lesson dropped: ${removed.length}건 (보존: .harness/lessons.archive.md)`);
|
|
13134
13643
|
_autoRoadmap(absRoot(root), 'data-change');
|
|
13135
13644
|
}
|
|
@@ -13204,27 +13713,42 @@ function decisionListCmd(root, opts = {}) {
|
|
|
13204
13713
|
// 1.9.125: decision drop — decisions.md 에서 특정 결정 제거 (date 또는 title substring 매칭)
|
|
13205
13714
|
function decisionDropCmd(root, target) {
|
|
13206
13715
|
root = absRoot(root);
|
|
13716
|
+
// 1.36.140 (재검수 P3): 락/쓰기 헬퍼가 부모를 만들기 때문에, 오타 난 `--path` 로 실패하면서도
|
|
13717
|
+
// 그 경로와 `.harness/` 를 만들었다(1.36.139 는 안 만들었다 — 대조군). 없으면 아무것도 만들지 않는다.
|
|
13718
|
+
if (!exists(root)) return failJson(has('--json'), 'path_not_found', `경로가 없습니다: ${root} — 디렉토리를 새로 만들지 않았습니다`);
|
|
13207
13719
|
if (!target) return fail('decision drop <date|title-substring> 필요. 예: leerness decision drop "2026-05-20" 또는 leerness decision drop "PostgreSQL"');
|
|
13208
13720
|
// 1.9.339 (UR-0053): canonical JSON 기준 drop (date 또는 title substring 매칭) — JSON+MD projection 동시 갱신.
|
|
13209
|
-
const all = _loadDecisions(root);
|
|
13210
|
-
if (!all.length) return failJson(has('--json'), 'no_decisions', 'decisions 없음'); // codex P2: --json 에러 구조화
|
|
13211
|
-
const kept = [];
|
|
13212
|
-
const removed = [];
|
|
13213
|
-
for (const d of all) {
|
|
13214
|
-
if (d.date === target || (d.title || '').includes(target)) removed.push(d);
|
|
13215
|
-
else kept.push(d);
|
|
13216
|
-
}
|
|
13217
|
-
if (!removed.length) return failJson(has('--json'), 'decision_not_found', `매칭 decision 없음: "${target}"`);
|
|
13218
|
-
// archive 보존 (projection MD 블록 형태)
|
|
13219
13721
|
const archivePath = path.join(root, '.harness/decisions.archive.md');
|
|
13220
|
-
|
|
13221
|
-
//
|
|
13222
|
-
|
|
13223
|
-
|
|
13224
|
-
|
|
13225
|
-
|
|
13226
|
-
|
|
13227
|
-
|
|
13722
|
+
// 1.36.138 (동시성 감사 실측): `decision add` 는 락 안인데 **drop 은 락 밖**이었다 —
|
|
13723
|
+
// 드롭이 위에서 읽은 낡은 배열을 그대로 되써서 **락 안에서 커밋된 add 를 파괴**했다(8동시에서 1~3건).
|
|
13724
|
+
// 위험한 조합은 add+add 가 아니라 **add + drop** 이다.
|
|
13725
|
+
// 1.36.140 (재검수 P1, 재현): 그 다음 판인 `${date}|${title}` 개수 예산은 **구분자가 접혔다** —
|
|
13726
|
+
// `date` 안의 `|` 로 서로 다른 두 항목이 같은 키가 되어 무관한 항목이 지워지고
|
|
13727
|
+
// archive 에는 대상이 지워졌다고 적혔다(복원 불가). 키를 고치는 대신 **셈을 없앤다**:
|
|
13728
|
+
// 판정을 락 안에서 다시 하고, archive 는 **실제로 지운 것**을 같은 락 안에서 남긴다.
|
|
13729
|
+
let removed = [];
|
|
13730
|
+
let loadErr = null;
|
|
13731
|
+
_withLock(decisionsJsonPath(root), () => {
|
|
13732
|
+
const cur = _loadDecisions(root);
|
|
13733
|
+
if (!cur.length) { loadErr = 'no_decisions'; return; }
|
|
13734
|
+
const keep = [], gone = [];
|
|
13735
|
+
for (const d of cur) {
|
|
13736
|
+
if (d.date === target || (d.title || '').includes(target)) gone.push(d);
|
|
13737
|
+
else keep.push(d);
|
|
13738
|
+
}
|
|
13739
|
+
if (!gone.length) { loadErr = 'decision_not_found'; return; }
|
|
13740
|
+
// codex 버그헌트 P2: 사용자 필드 raw 삽입 → 개행으로 `## 제거` 델리미터 위조 가능. _lineSafe 로 무력화.
|
|
13741
|
+
const archiveBlocks = gone.map(d => {
|
|
13742
|
+
const head = d.date ? `${_lineSafe(d.date)} — ${_lineSafe(d.title)}` : _lineSafe(d.title);
|
|
13743
|
+
return `\n### ${head}\n- Decision: ${_lineSafe(d.decision || '')}\n- Reason: ${_lineSafe(d.reason || '')}\n- Alternatives: ${_lineSafe(d.alternatives || '')}\n- Impact: ${_lineSafe(d.impact || '')}\n`;
|
|
13744
|
+
}).join('');
|
|
13745
|
+
const archiveHeader = exists(archivePath) ? '' : '# Decisions archive\n\n';
|
|
13746
|
+
append(archivePath, archiveHeader + `\n## 제거 ${today()} (target: "${_lineSafe(target)}")\n${archiveBlocks}\n`);
|
|
13747
|
+
_saveDecisions(root, keep);
|
|
13748
|
+
removed = gone;
|
|
13749
|
+
});
|
|
13750
|
+
if (loadErr === 'no_decisions') return failJson(has('--json'), 'no_decisions', 'decisions 없음'); // codex P2: --json 에러 구조화
|
|
13751
|
+
if (loadErr === 'decision_not_found' || !removed.length) return failJson(has('--json'), 'decision_not_found', `매칭 decision 없음: "${target}"`);
|
|
13228
13752
|
ok(`decision dropped: ${removed.length}건 (보존: .harness/decisions.archive.md)`);
|
|
13229
13753
|
_autoRoadmap(absRoot(root), 'data-change');
|
|
13230
13754
|
}
|
|
@@ -14276,6 +14800,12 @@ function handoff(root) {
|
|
|
14276
14800
|
resumeGuide: ad.resumeGuide
|
|
14277
14801
|
};
|
|
14278
14802
|
} catch {}
|
|
14803
|
+
// 1.36.132 (UR-0069 / P-0016 P3 선결): 세션 신호가 **기계 경로에 아예 없었다**.
|
|
14804
|
+
// 실측: `handoff --json` 17키에 headline·sessions 필드 0 · `--compact` 는 200줄→3줄로 헤드라인을 통째로 죽인다.
|
|
14805
|
+
// MCP `leerness_handoff` 와 내부 호출자들이 바로 그 두 경로를 쓴다 → 다른 세션이 있어도 **구조적으로 못 본다**.
|
|
14806
|
+
// 채널(메일박스)을 새로 만들기 전에, 이미 있는 신호가 도달하게 하는 것이 먼저다.
|
|
14807
|
+
try { result.sessions = _sessionSignal(root); } catch { /* 레지스트리를 못 읽어도 handoff 는 진행 */ }
|
|
14808
|
+
try { const _rem = _agentReminderSignal(root); if (_rem) result.agentReminders = _rem; } catch {}
|
|
14279
14809
|
log(JSON.stringify(result, null, 2));
|
|
14280
14810
|
return;
|
|
14281
14811
|
}
|
|
@@ -14300,6 +14830,10 @@ function handoff(root) {
|
|
|
14300
14830
|
const flags = [];
|
|
14301
14831
|
if (openReq) flags.push(`📥 미답 ${openReq}`);
|
|
14302
14832
|
if (blocked) flags.push(`🚫 blocked ${blocked}`);
|
|
14833
|
+
// 1.36.132 (UR-0069): 다른 세션 기록과 대기 중인 에이전트 메시지는 **압축해도 지우면 안 되는 신호**다.
|
|
14834
|
+
// 이 경로가 MCP handoff·REPL·agent-mode 가 실제로 쓰는 경로이고, 종전엔 여기서 헤드라인이 통째로 사라졌다(200줄→3줄).
|
|
14835
|
+
try { const _ss = _sessionSignal(root); flags.push(..._sessionSignalFlags(_ss)); } catch {}
|
|
14836
|
+
try { const _rm = _agentReminderSignal(root); if (_rm) flags.push(`🔔 리마인더 ${_rm.lines}줄`); } catch {}
|
|
14303
14837
|
log(_lineSafe(`leerness compact: ${detectProjectName(root)} · ${done}/${rows.length}(${pct}%) done · WIP ${wip} · T${wip}/D${decN}/R${rulesActive}/P${mileN}/L${lessN}${flags.length ? ' · ' + flags.join(' · ') : ''}`)); // 1.36.18 (codex F4): projectName 개행 주입 차단
|
|
14304
14838
|
if (nx) log(_lineSafe(`다음: ${nx.id} [${nx.status}] ${nx.nextAction || nx.request || ''}`).slice(0, 200));
|
|
14305
14839
|
log('핵심 규칙: 의존성0 · 한국어주석 · UTF-8noBOM · reuse-map등록 · anti-lazy-work · verify-claim자동검수');
|
|
@@ -14347,6 +14881,9 @@ function handoff(root) {
|
|
|
14347
14881
|
: '';
|
|
14348
14882
|
sections.push(`\n=== ${label}${note} (${rel(root,p)}) ===\n${ls.join('\n')}`);
|
|
14349
14883
|
}
|
|
14884
|
+
// 1.36.132 (UR-0069): 리마인더를 **본문 맨 앞**에 둔다 — 이 파일은 drift critical 때 "다음 세션이 읽도록" 만들어졌는데
|
|
14885
|
+
// 1.9.38 이후 지금까지 어떤 표면도 내용을 보여주지 않았다(handoff 200줄 0건 · hook 319B 0건 · 관리문서 지시 0건).
|
|
14886
|
+
block('🔔 Agent Reminders (pending)', path.join(root, '.harness', 'agent-reminders.md'));
|
|
14350
14887
|
block('Session Handoff', handoffPath(root));
|
|
14351
14888
|
block('Current State', currentStatePath(root));
|
|
14352
14889
|
block('Plan', planPath(root));
|
|
@@ -14423,12 +14960,34 @@ function handoff(root) {
|
|
|
14423
14960
|
// 0이면 침묵한다 — 이 레지스트리는 handoff 를 안 부르는 에이전트·MCP 전용 클라이언트·
|
|
14424
14961
|
// 구버전 세션·다른 머신을 구조적으로 못 본다. "0건" 은 '아무도 없다' 로 읽히므로 쓰지 않는다.
|
|
14425
14962
|
// "활성" 이라는 말도 쓰지 않는다(프로세스 상태는 판정하지 않는다 — pid 라이브니스 실측 5.2% 오탐).
|
|
14963
|
+
// 1.36.132: 계산을 `_sessionSignal` 한 곳으로 옮겼다 — 표면(헤드라인·--json·--compact)마다 따로 짜면 새 표면이 빠진다.
|
|
14426
14964
|
try {
|
|
14427
|
-
const
|
|
14428
|
-
|
|
14429
|
-
|
|
14430
|
-
|
|
14965
|
+
const _ss = _sessionSignal(root);
|
|
14966
|
+
if (_ss.others > 0) parts.push(t(`👥 다른 세션 기록 ${_ss.others}건 (close 기록 없음) → leerness sessions`,
|
|
14967
|
+
`👥 ${_ss.others} other session record(s), no close recorded → leerness sessions`));
|
|
14968
|
+
// 1.36.132 (검수 P2): 공유 주소 경고가 **평문에만** 없었다(compact·훅·--json 에는 있었다).
|
|
14969
|
+
// stderr 경고는 처음 한 번뿐이라 평문에서 빠지면 그 뒤로 영영 안 보인다.
|
|
14970
|
+
if (_ss.sharedKeyAgents) parts.push(t(`⚠ 세션 주소 공유(${_ss.sharedKeyAgents}) — 두 세션이 한 기록으로 합쳐집니다`,
|
|
14971
|
+
`⚠ session address shared (${_ss.sharedKeyAgents}) - two sessions collapse into one record`));
|
|
14972
|
+
// 저장소를 못 읽었으면 "0건" 이라 말하지 않는다.
|
|
14973
|
+
if (_ss.storeError) parts.push(t(`⚠ 세션 저장소를 읽지 못함(${_ss.storeError}) — 기록이 없다는 뜻이 아닙니다`,
|
|
14974
|
+
`⚠ session store unreadable (${_ss.storeError}) - this does NOT mean there are none`));
|
|
14975
|
+
if (_ss.invalidExplicitKey) parts.push(t(`⚠ LEERNESS_SESSION_ID 가 형식에 안 맞아 무시됐습니다("${_ss.invalidExplicitKey}") — 8~64자 영숫자/_/-`,
|
|
14976
|
+
`⚠ LEERNESS_SESSION_ID ignored, invalid format ("${_ss.invalidExplicitKey}") - 8-64 chars, alphanumeric/_/-`));
|
|
14977
|
+
// 1.36.132 (검수): **비대칭이 조용했다** — 등록되지 않은 세션도 "남이 N건 있다" 는 듣지만
|
|
14978
|
+
// "나는 그들에게 보이지 않는다" 는 못 들었다. 사용자가 이 사실을 발견할 경로가 없었다.
|
|
14979
|
+
// 내가 안 보인다는 것은 남이 나를 피해 갈 수 없다는 뜻이라 겹침 사고의 직접 원인이다. 그러니 말한다.
|
|
14980
|
+
if (!_ss.selfRegistered && _ss.selfSuppressedReason !== 'internal' && _ss.selfSuppressedReason !== 'child-agent') {
|
|
14981
|
+
parts.push(t(`🙈 이 세션은 다른 세션에게 보이지 않습니다 (${_ss.selfSuppressedReason}) → LEERNESS_SESSION_ID 를 세션마다 고유값으로 설정`,
|
|
14982
|
+
`🙈 this session is invisible to others (${_ss.selfSuppressedReason}) → set a unique LEERNESS_SESSION_ID per session`));
|
|
14983
|
+
}
|
|
14431
14984
|
} catch { /* 레지스트리를 못 읽어도 handoff 는 그대로 진행한다 */ }
|
|
14985
|
+
// 1.36.132 (UR-0069): 대기 중인 에이전트 리마인더를 **처음으로** 노출한다(그전엔 만들어만 놓고 아무도 안 보여줬다).
|
|
14986
|
+
try {
|
|
14987
|
+
const _rm = _agentReminderSignal(root);
|
|
14988
|
+
if (_rm) parts.push(t(`🔔 에이전트 리마인더 대기 ${_rm.lines}줄 → ${_rm.path}`,
|
|
14989
|
+
`🔔 ${_rm.lines} line(s) of pending agent reminders → ${_rm.path}`));
|
|
14990
|
+
} catch {}
|
|
14432
14991
|
} catch {}
|
|
14433
14992
|
// 3) MCP 활동 누적
|
|
14434
14993
|
try {
|
|
@@ -14855,7 +15414,7 @@ function handoff(root) {
|
|
|
14855
15414
|
// 1.9.237: handoff 본문 release/* 누적 경고 — 50+ branches merged 시 자동 안내
|
|
14856
15415
|
// 1.9.220 비정상종료 release-branch-pending 신호의 본문 보강 (handoff 헤드라인은 ‘🔌 비정상종료’ 만 표시)
|
|
14857
15416
|
try {
|
|
14858
|
-
const branchR =
|
|
15417
|
+
const branchR = _gitSpawn(['branch', '--merged', 'main', '--list', 'release/*'], { cwd: root, encoding: 'utf8' });
|
|
14859
15418
|
if (branchR.status === 0) {
|
|
14860
15419
|
const mergedCnt = (branchR.stdout || '').split('\n').filter(l => l.trim() && /release\/\d+\.\d+\.\d+/.test(l)).length;
|
|
14861
15420
|
if (mergedCnt > 50) {
|
|
@@ -15789,6 +16348,11 @@ function hookSessionStartCmd(root) {
|
|
|
15789
16348
|
const flags = [];
|
|
15790
16349
|
if (openReq) flags.push(`📥 미답 요청 ${openReq}`);
|
|
15791
16350
|
if (blocked) flags.push(`🚫 blocked ${blocked}`);
|
|
16351
|
+
// 1.36.132 (UR-0069, 검수 fatal #2): 세션 신호가 4표면 중 1곳(평문 헤드라인)에만 닿았고, 못 가는 3곳에
|
|
16352
|
+
// **유일한 강제 경로인 이 훅**이 들어 있었다. 여기서 안 보이면 에이전트는 끝내 모른다.
|
|
16353
|
+
// ⚠ 이 경로는 **쓰기 0** 계약이라 readOnly 로 읽는다(염 파일을 새로 만들지 않는다).
|
|
16354
|
+
try { const _ss = _sessionSignal(root, { readOnly: true }); flags.push(..._sessionSignalFlags(_ss)); } catch {}
|
|
16355
|
+
try { const _rm = _agentReminderSignal(root); if (_rm) flags.push(`🔔 리마인더 ${_rm.lines}줄`); } catch {}
|
|
15792
16356
|
const lines = [_lineSafe(`[leerness] ${detectProjectName(root)} · ${done}/${rows.length}(${pct}%) done · WIP ${wip}${flags.length ? ' · ' + flags.join(' · ') : ''}`)];
|
|
15793
16357
|
if (nx) lines.push(_lineSafe(`다음: ${nx.id} [${nx.status}] ${nx.nextAction || nx.request || ''}`).slice(0, 200));
|
|
15794
16358
|
// 자기 라벨링(1.9.272 투명성 불변식): 요약임을 밝히고 전체 컨텍스트 경로와 끄는 법을 함께 준다.
|
|
@@ -16101,22 +16665,85 @@ function reuseMapCmd(root) {
|
|
|
16101
16665
|
// 1.9.302 (UR-0042, 외부리뷰 Opus G-1): git 변경 파일 집합 — verify-claim 시맨틱 교차검증용.
|
|
16102
16666
|
// working tree(staged/unstaged/untracked) + 직전 커밋(HEAD~1..HEAD) 변경을 합쳐, "주장한 파일이 실제로 변경됐는가" 판정.
|
|
16103
16667
|
// git repo 아니거나 git 미설치면 null(검증 불가 → 페널티 X). 경로는 root-relative forward-slash.
|
|
16104
|
-
|
|
16668
|
+
// 1.36.133 (P3-A 정찰이 실측): 이 함수는 이름과 달리 **워킹트리 신호가 아니었다** —
|
|
16669
|
+
// `status --porcelain` 과 `diff HEAD~1 HEAD` 를 **합집합**으로 돌려줘서, 방금 커밋만 한 파일이
|
|
16670
|
+
// "지금 변경 중" 으로 둔갑했다. 실측(임시 저장소): 더티는 dirty.txt 하나뿐인데 committed-only.txt 가 함께 나왔다.
|
|
16671
|
+
// 소비자(verify-claim)가 묻는 것은 "이 주장이 **실제 변경**으로 뒷받침되는가" 이므로 이건 오타가 아니라 잘못된 계약이다.
|
|
16672
|
+
// 그리고 비ASCII 경로가 통째로 깨졌다 — git 은 기본적으로 따옴표+8진 이스케이프로 내보내는데
|
|
16673
|
+
// 따옴표만 벗겨서 `한글더티.txt` 가 `/355/225/234/…` 가 됐다(한국어 사용자 환경에서 **전부 미탐**).
|
|
16674
|
+
// → `-z`(NUL 구분) 한 번으로 이스케이프 자체를 없앤다. trim/따옴표제거/역슬래시치환을 전부 버린다.
|
|
16675
|
+
// 반환도 3상태로 바꾼다. 종전엔 "git 없음"·"저장소 아님"·"출력 과대"가 모두 `null` 이라 소비자가 사유를 말할 수 없었다.
|
|
16676
|
+
// 1.36.133 (P3-A 정찰 실측 · '남의 프로젝트는 읽기 전용' 원칙 위반): 우리는 git 을 **48 지점**에서 직접 불렀고
|
|
16677
|
+
// `--no-optional-locks` 는 저장소 전체에 **0건**이었다. 맨 `git status` 는 인덱스 stat 캐시를 갱신하려고
|
|
16678
|
+
// `.git/index` 를 **다시 쓴다** — 실측(추적파일 200개 touch 후): 현행 mtime 변경 O, `--no-optional-locks` 변경 X.
|
|
16679
|
+
// 즉 "조회" 라고 광고한 프로브가 사용자 저장소를 고쳤다. 호출을 한 곳으로 모아 최상위 옵션을 붙인다.
|
|
16680
|
+
// ⚠ 이 플래그는 모든 서브명령에 안전한 최상위 옵션이다(쓰기 명령에서도 동작을 바꾸지 않는다).
|
|
16681
|
+
// 1.36.134 (실측 재현): `enforce` 의 세 표면이 **서로 다른 근거**로 git 을 판정해 모순을 냈다 —
|
|
16682
|
+
// `install` 은 `.git` **디렉토리 존재**만 봤고, `audit` 은 `git log` 실행 결과를 봤고, `status` 는 아무것도 안 봤다.
|
|
16683
|
+
// 그래서 HEAD 가 손상돼 **커밋 자체가 불가능한** 저장소에서 `install` 은 `{ok:true, installed:true}` 를 내고
|
|
16684
|
+
// 같은 디렉토리의 `audit` 은 `git_log_failed` 를 냈다(한 기능의 두 표면이 서로를 반박한다).
|
|
16685
|
+
// 그리고 `status --json` 은 git 아님/정상/손상 3상태에서 **payload 가 동일**해, '설치 안 됨' 과 '설치할 데가 없음' 이 구분 불가였다.
|
|
16686
|
+
// → 술어를 한 곳에 두고 세 표면이 같은 어휘로 답한다.
|
|
16687
|
+
// 'unborn'(커밋 0인 정상 저장소)은 **정상 취급**한다 — 훅을 걸어 두면 첫 커밋부터 동작한다.
|
|
16688
|
+
function _gitState(root) {
|
|
16689
|
+
// 1.36.134 (검수 실측): 첫 판은 `exists(root/.git)` 로 단락했다 — 정상 저장소의 **하위 디렉토리**에서
|
|
16690
|
+
// 한 payload 안에 `installed:true` 와 `gitState:'not-a-repo'` 가 동시에 나왔다(자기모순).
|
|
16691
|
+
// 훅 경로는 git 에게 묻는데 상태만 파일 존재로 판정하면 두 답이 갈린다. **git 에게 물어 한 답으로 만든다.**
|
|
16692
|
+
const r = _gitSpawn(['-C', root, 'rev-parse', '--git-dir'], { encoding: 'utf8', timeout: 10000 });
|
|
16693
|
+
if ((r.error && r.error.code === 'ENOENT') || r.status === null) return 'no-git';
|
|
16694
|
+
if (r.status !== 0) return exists(path.join(root, '.git')) ? 'broken' : 'not-a-repo';
|
|
16695
|
+
// 1.36.134 (검수 P1): bare 저장소에는 워킹트리가 없어 `git commit`/pre-commit 경로 자체가 없다 —
|
|
16696
|
+
// 훅을 걸고 "강제됨" 이라 답하면 거짓이다. 실측: `git init --bare` 에서 ok:true, verified:'fired' 였다.
|
|
16697
|
+
const bare = _gitSpawn(['-C', root, 'rev-parse', '--is-bare-repository'], { encoding: 'utf8', timeout: 10000 });
|
|
16698
|
+
if (bare.status === 0 && String(bare.stdout || '').trim() === 'true') return 'bare';
|
|
16699
|
+
const h = _gitSpawn(['-C', root, 'rev-parse', '--verify', 'HEAD'], { encoding: 'utf8', timeout: 10000 });
|
|
16700
|
+
if (h.status === 0) return 'ok';
|
|
16701
|
+
// 커밋이 아직 없는 정상 저장소와 HEAD 손상을 가른다(둘 다 `--verify HEAD` 는 실패한다).
|
|
16702
|
+
const sr = _gitSpawn(['-C', root, 'symbolic-ref', '-q', 'HEAD'], { encoding: 'utf8', timeout: 10000 });
|
|
16703
|
+
return sr.status === 0 ? 'unborn' : 'broken';
|
|
16704
|
+
}
|
|
16705
|
+
const GIT_STATE_USABLE = new Set(['ok', 'unborn']);
|
|
16706
|
+
// 1.36.140 (재검수 자체발견 P1): 초크포인트가 **셋**인데 세척은 여기 하나뿐이었다.
|
|
16707
|
+
// `lib/drift.js` · `lib/session-close.js` 가 같은 이름의 함수를 복제해 두고 환경을 안 걷어냈고,
|
|
16708
|
+
// 그 복제본이 `git branch -d` 를 부른다 — 실측으로 **남의 저장소 브랜치 50개가 지워졌다**.
|
|
16709
|
+
// 세척을 세 곳에 다시 심는 대신 실행 지점을 `lib/git.js` **하나**로 만들었다.
|
|
16710
|
+
const _gitSpawn = require('../lib/git').gitSpawn;
|
|
16711
|
+
function _gitWorkingTree(root) {
|
|
16712
|
+
let r;
|
|
16105
16713
|
try {
|
|
16106
|
-
|
|
16107
|
-
|
|
16108
|
-
|
|
16109
|
-
|
|
16110
|
-
|
|
16111
|
-
|
|
16112
|
-
|
|
16113
|
-
|
|
16114
|
-
|
|
16115
|
-
|
|
16116
|
-
|
|
16117
|
-
if (
|
|
16118
|
-
|
|
16119
|
-
|
|
16714
|
+
r = _gitSpawn(['-C', root, 'status', '--porcelain=v1', '-z', // 1.36.133: 최상위 읽기 플래그는 `_gitSpawn` 이 붙인다(중복 금지)
|
|
16715
|
+
'--untracked-files=all', '--no-renames', '--', '.'],
|
|
16716
|
+
{ encoding: 'buffer', timeout: 15000, maxBuffer: 256 * 1024 * 1024 });
|
|
16717
|
+
} catch (e) { return { ok: false, reason: 'git-error', detail: String(e && e.code || e).slice(0, 40) }; }
|
|
16718
|
+
if (r.error && r.error.code === 'ENOENT') return { ok: false, reason: 'no-git' };
|
|
16719
|
+
if (r.error && r.error.code === 'ENOBUFS') return { ok: false, reason: 'output-too-large' };
|
|
16720
|
+
if (r.error) return { ok: false, reason: 'git-error', detail: String(r.error.code || '').slice(0, 40) };
|
|
16721
|
+
if (r.status === null) return { ok: false, reason: 'no-git' }; // 죽거나 실행 자체가 안 됨
|
|
16722
|
+
if (r.status !== 0) return { ok: false, reason: 'not-a-git-repo' }; // 1.36.133 (검수 P1): 기존 공개 JSON 값을 유지한다 — 새 사유는 **추가**만 한다
|
|
16723
|
+
const files = new Set();
|
|
16724
|
+
for (const rec of String(r.stdout || '').split('\0')) {
|
|
16725
|
+
if (rec.length < 4) continue;
|
|
16726
|
+
const p = rec.slice(3); // 'XY ' 프리픽스만 제거 — 그 뒤는 git 이 준 원문 그대로다
|
|
16727
|
+
if (p) files.add(p.replace(/\\/g, '/'));
|
|
16728
|
+
}
|
|
16729
|
+
return { ok: true, files };
|
|
16730
|
+
}
|
|
16731
|
+
// 1.36.133 (검수 P1): 한 번에 두 가지를 바꾼 것이 소비자를 깼다 — **파싱 결함**(비ASCII·이스케이프)과
|
|
16732
|
+
// **집합 정의**(워킹트리 ∪ 직전커밋)를 같이 손대자 `verify-claim --strict-claims` 가 거짓 실패했다.
|
|
16733
|
+
// 실측: 주장한 파일을 이미 커밋했으면 워킹트리에는 없으므로 "변경 안 됨" 이 된다 — 커밋은 일을 안 했다는 뜻이 아니다.
|
|
16734
|
+
// → **고쳐야 했던 것은 파싱뿐이다.** 집합 정의(직전 커밋 포함)는 그대로 두고, 정확해진 파서 위에서 합집합을 만든다.
|
|
16735
|
+
// 워킹트리만 필요한 곳은 `_gitWorkingTree` 를 직접 쓴다(3상태로 사유까지 답한다).
|
|
16736
|
+
function _gitChangedFiles(root) {
|
|
16737
|
+
const wt = _gitWorkingTree(root);
|
|
16738
|
+
if (!wt.ok) return null;
|
|
16739
|
+
const out = new Set(wt.files);
|
|
16740
|
+
// 직전 커밋도 "이번 작업의 변경" 으로 본다(1.9.302 이래의 계약). `-z` 로 받아 경로를 원문 그대로 쓴다.
|
|
16741
|
+
const df = _gitSpawn(['-C', root, 'diff', '--name-only', '-z', 'HEAD~1', 'HEAD'],
|
|
16742
|
+
{ encoding: 'buffer', timeout: 15000, maxBuffer: 256 * 1024 * 1024 });
|
|
16743
|
+
if (!df.error && df.status === 0) {
|
|
16744
|
+
for (const p of String(df.stdout || '').split('\0')) if (p) out.add(p.replace(/\\/g, '/'));
|
|
16745
|
+
}
|
|
16746
|
+
return out;
|
|
16120
16747
|
}
|
|
16121
16748
|
// 주장 파일이 git 변경 집합에 있는지(상대경로 prefix 차이 허용).
|
|
16122
16749
|
// _claimFileInGit → lib/analyzers.js (1.9.304 UR-0025)
|
|
@@ -16348,7 +16975,12 @@ function verifyClaimCmd(root, taskId, opts = {}) {
|
|
|
16348
16975
|
}
|
|
16349
16976
|
// 1.9.302 (UR-0042, 외부리뷰 Opus G-1): git diff 시맨틱 교차검증 — 주장한 파일이 실제로 변경됐는가.
|
|
16350
16977
|
// "파일 존재"만으로는 "테스트만 통과하면 done" 허위완료를 못 막음(Opus). git working tree+직전커밋 변경과 대조.
|
|
16351
|
-
|
|
16978
|
+
// 1.36.133 (정찰 실측): 종전엔 `null` 하나로 "git 없음"·"저장소 아님"·"출력 과대"를 뭉뚱그려
|
|
16979
|
+
// 진짜 저장소인데 git 이 PATH 에 없을 뿐인 상황에서도 **'not-a-git-repo' 라고 단정**했다(사실이 아닌 주장).
|
|
16980
|
+
// 사유를 그대로 실어 보낸다 — 모르는 것과 아닌 것을 같은 말로 하지 않는다.
|
|
16981
|
+
const _gitProbe = _gitWorkingTree(root);
|
|
16982
|
+
const gitChanged = _gitProbe.ok ? _gitProbe.files : null;
|
|
16983
|
+
const gitUnavailableReason = _gitProbe.ok ? null : _gitProbe.reason;
|
|
16352
16984
|
const gitApplicable = !!gitChanged && gitChanged.size > 0 && files.length > 0;
|
|
16353
16985
|
const claimedInGit = gitApplicable ? files.filter(f => _claimFileInGit(f, gitChanged)) : [];
|
|
16354
16986
|
const claimedNotInGit = gitApplicable ? files.filter(f => !_claimFileInGit(f, gitChanged)) : [];
|
|
@@ -16547,7 +17179,8 @@ function verifyClaimCmd(root, taskId, opts = {}) {
|
|
|
16547
17179
|
evidence: { required: mustHaveEvidence, ...evq },
|
|
16548
17180
|
// 1.36.110: `advisory` 는 **게이팅하지 않는** 신호다(ok 에 영향 없음). 소비자가 경계를 오인하지 않도록 키를 분리한다.
|
|
16549
17181
|
claims: !claimsChecked ? null : { ok: strictOk, optimism: optimismSuspects.map(s => ({ kind: s.kind, label: s.label })), honesty: honestyFindings.map(f => ({ dim: f.dim, label: f.label })), advisory: honestyAdvisory.map(f => ({ dim: f.dim, label: f.label, severity: f.severity })) },
|
|
16550
|
-
|
|
17182
|
+
// 1.36.133: 사유를 뭉개지 않는다 — no-git / not-a-repo / output-too-large / git-error 를 구분해서 싣는다.
|
|
17183
|
+
git: gitChanged === null ? { applicable: false, reason: gitUnavailableReason || 'not-a-repo' } : (!gitApplicable ? { applicable: false, reason: 'no-working-changes-or-no-claimed-files' } : { applicable: true, claimedInGit: claimedInGit.length, claimedNotInGit, strongMismatch: gitStrongMismatch, changedNotClaimed }),
|
|
16551
17184
|
scopeCreep: !gitApplicable ? null : { count: changedNotClaimed.length, files: changedNotClaimed.slice(0, 10) } // 1.13.2 (Karpathy 외과적 변경): 요청/주장 밖 변경 파일(advisory)
|
|
16552
17185
|
};
|
|
16553
17186
|
if (runResult) {
|
|
@@ -18818,7 +19451,9 @@ function llmBenchRecordCmd(root) {
|
|
|
18818
19451
|
|
|
18819
19452
|
const _sessionClose = require('../lib/session-close');
|
|
18820
19453
|
// 1.9.425 (UR-0025/UR-0125 큰 핸들러 모듈화 10번째): sessionClose → lib/session-close.js (DI 위임)
|
|
18821
|
-
function sessionClose(root, opts = {}) {
|
|
19454
|
+
function sessionClose(root, opts = {}) { // 1.36.132 (검수): handoff 는 --no-record/LEERNESS_HOOK 게이트 뒤에서 기록하는데 여기는 **게이트 없이** 기록했다
|
|
19455
|
+
// (실측: handoff --no-record → 0건, session close --no-record → 1건). 같은 스토어의 같은 계약은 같은 문장으로.
|
|
19456
|
+
if (!has('--no-record') && process.env.LEERNESS_HOOK !== '1') { try { _sessionPresenceRecord(root, 'close'); } catch {} } // 1.36.129 (P-0016 P1): close 는 '기록됨' 일 뿐 '프로세스가 죽었다' 가 아니다
|
|
18822
19457
|
return _sessionClose.sessionClose(root, opts, { VERSION, STATUSES, MARK, has, arg, uiLang: _uiLang(root), harnessPath: __filename, readProgressRows, evidencePath, handoffPath, currentStatePath, taskLogPath, verifyRules, _autoRoadmap, _readUsageStats, readSessionCounter, writeSessionCounter, _retroAggregate, _retroOneLine, retroCmd, _loadDecisions, readRules, planPath, _loadLessons, _readFeatureGraph, _auditUserRequests, _detectDeliveredRequests, _computeRoundHistory, _computeMilestones, _computeRecentChanges, _collectPyFiles, _analyzePyFile, _collectRuntimeEnv, _scanShellScriptsEncoding, _listAPISkills, _matchAPISkills, _loadShellFailures, _shellEnvDrift, _runPreWakeAudit, _saveAndAppendPreWakeReport, _runIdempotencyAudit, _detectAbnormalShutdown, _updateUserRequest, _detectOptimism, _scanCodeForPatterns, _collectSecretFindings, _withLock }); } // 1.17.6 (UR-0049): 마감 정합 — done 낙관 재확인 + 시크릿 재확인 · 1.36.108: 락 주입(T-0097)
|
|
18823
19458
|
|
|
18824
19459
|
function readmeCmd(root) { syncReadme(absRoot(root)); }
|
|
@@ -20465,7 +21100,7 @@ function releaseNote(root, text) {
|
|
|
20465
21100
|
|
|
20466
21101
|
// 1.9.10: git remote 자동 감지 + gh-release + gh-pages 배포
|
|
20467
21102
|
function detectGitRemote(root) {
|
|
20468
|
-
const r =
|
|
21103
|
+
const r = _gitSpawn(['remote', 'get-url', 'origin'], { cwd: root, encoding: 'utf8', shell: true });
|
|
20469
21104
|
if (r.status !== 0) return null;
|
|
20470
21105
|
const url = (r.stdout || '').trim();
|
|
20471
21106
|
if (!url) return null;
|
|
@@ -20492,35 +21127,35 @@ function deployGhPages(root, sourceFile) {
|
|
|
20492
21127
|
const wt = path.join(root, '.harness/cache', `ghpages-${stamp}`);
|
|
20493
21128
|
mkdirp(path.dirname(wt));
|
|
20494
21129
|
// worktree (기존 gh-pages 있으면 fetch, 없으면 orphan)
|
|
20495
|
-
const fetchR =
|
|
21130
|
+
const fetchR = _gitSpawn(['fetch', 'origin', 'gh-pages'], { cwd: root, encoding: 'utf8', shell: true });
|
|
20496
21131
|
const hasBranch = fetchR.status === 0;
|
|
20497
21132
|
let wtArgs;
|
|
20498
21133
|
if (hasBranch) wtArgs = ['worktree', 'add', wt, 'origin/gh-pages'];
|
|
20499
21134
|
else wtArgs = ['worktree', 'add', '--orphan', '-b', 'gh-pages', wt];
|
|
20500
|
-
const wtR =
|
|
21135
|
+
const wtR = _gitSpawn(wtArgs, { cwd: root, encoding: 'utf8', shell: true }); // 1.36.133: 초크포인트 경유(전수 0 유지)
|
|
20501
21136
|
if (wtR.status !== 0) { fail('worktree 생성 실패: ' + (wtR.stderr || '').slice(0, 200)); process.exitCode = 1; return; }
|
|
20502
21137
|
try {
|
|
20503
21138
|
// orphan인 경우 초기화
|
|
20504
21139
|
if (!hasBranch) {
|
|
20505
|
-
|
|
21140
|
+
_gitSpawn(['rm', '-rf', '.'], { cwd: wt, encoding: 'utf8', shell: true });
|
|
20506
21141
|
}
|
|
20507
21142
|
// 소스 복사 (index.html로 이름 변경)
|
|
20508
21143
|
const destName = path.basename(src) === 'index.html' ? 'index.html' : 'index.html';
|
|
20509
21144
|
fs.copyFileSync(src, path.join(wt, destName));
|
|
20510
21145
|
// 원본 파일명도 보존
|
|
20511
21146
|
if (path.basename(src) !== 'index.html') fs.copyFileSync(src, path.join(wt, path.basename(src)));
|
|
20512
|
-
|
|
20513
|
-
const commit =
|
|
21147
|
+
_gitSpawn(['add', '-A'], { cwd: wt, encoding: 'utf8' });
|
|
21148
|
+
const commit = _gitSpawn(['commit', '-m', `deploy: ${path.basename(src)} ${stamp}`], { cwd: wt, encoding: 'utf8' });
|
|
20514
21149
|
if (commit.status !== 0 && !/nothing to commit/.test(commit.stdout || '')) {
|
|
20515
21150
|
fail('commit 실패: ' + (commit.stdout || commit.stderr || '').slice(0, 200));
|
|
20516
21151
|
process.exitCode = 1;
|
|
20517
21152
|
} else {
|
|
20518
|
-
const pushR =
|
|
21153
|
+
const pushR = _gitSpawn(['push', 'origin', 'gh-pages'], { cwd: wt, encoding: 'utf8' });
|
|
20519
21154
|
if (pushR.status !== 0) { fail('push 실패: ' + (pushR.stderr || '').slice(0, 200)); process.exitCode = 1; }
|
|
20520
21155
|
else ok(`gh-pages push 완료 → https://${remote.owner}.github.io/${remote.repo}/`);
|
|
20521
21156
|
}
|
|
20522
21157
|
} finally {
|
|
20523
|
-
|
|
21158
|
+
_gitSpawn(['worktree', 'remove', '--force', wt], { cwd: root, encoding: 'utf8', shell: true });
|
|
20524
21159
|
}
|
|
20525
21160
|
}
|
|
20526
21161
|
|
|
@@ -20532,7 +21167,7 @@ function releaseSyncMainCmd(root) {
|
|
|
20532
21167
|
const remoteName = arg('--remote', 'origin');
|
|
20533
21168
|
log('# leerness release sync-main (1.9.140)');
|
|
20534
21169
|
|
|
20535
|
-
const headR =
|
|
21170
|
+
const headR = _gitSpawn(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, encoding: 'utf8' });
|
|
20536
21171
|
if (headR.status !== 0) { fail('git 저장소가 아닙니다 — sync-main 스킵'); process.exitCode = 1; return; }
|
|
20537
21172
|
const fromBranch = branchArg || (headR.stdout || '').trim();
|
|
20538
21173
|
if (!fromBranch || fromBranch === 'main' || fromBranch === 'master') {
|
|
@@ -20544,27 +21179,27 @@ function releaseSyncMainCmd(root) {
|
|
|
20544
21179
|
if (dryRun) { log('(dry-run) merge & push 스킵'); return; }
|
|
20545
21180
|
|
|
20546
21181
|
// 1) main checkout (있으면 reuse, 없으면 origin/main에서 생성)
|
|
20547
|
-
const coR =
|
|
21182
|
+
const coR = _gitSpawn(['checkout', 'main'], { cwd: root, encoding: 'utf8' });
|
|
20548
21183
|
if (coR.status !== 0) {
|
|
20549
|
-
const co2 =
|
|
21184
|
+
const co2 = _gitSpawn(['checkout', '-b', 'main', `${remoteName}/main`], { cwd: root, encoding: 'utf8' });
|
|
20550
21185
|
if (co2.status !== 0) { fail('main 체크아웃 실패: ' + (co2.stderr || '').slice(0, 200)); process.exitCode = 1; return; }
|
|
20551
21186
|
}
|
|
20552
21187
|
// 2) origin/main pull (최신화)
|
|
20553
|
-
|
|
21188
|
+
_gitSpawn(['pull', '--ff-only', remoteName, 'main'], { cwd: root, encoding: 'utf8' });
|
|
20554
21189
|
// 3) release branch merge (fast-forward 우선, 안 되면 no-ff merge commit)
|
|
20555
21190
|
// 1.9.140+: --allow-unrelated 또는 LEERNESS_ALLOW_UNRELATED=1 (최초 main 동기화 시 release branch와 history 분리)
|
|
20556
21191
|
const allowUnrelated = has('--allow-unrelated') || process.env.LEERNESS_ALLOW_UNRELATED === '1';
|
|
20557
21192
|
let mergeArgs = ['merge', '--no-edit', fromBranch];
|
|
20558
|
-
let mergeR =
|
|
21193
|
+
let mergeR = _gitSpawn(mergeArgs, { cwd: root, encoding: 'utf8' }); // 1.36.133: 초크포인트 경유
|
|
20559
21194
|
if (mergeR.status !== 0 && /unrelated histories/.test(mergeR.stderr || mergeR.stdout || '')) {
|
|
20560
21195
|
if (allowUnrelated) {
|
|
20561
21196
|
warn('unrelated histories 감지 — --allow-unrelated 재시도');
|
|
20562
|
-
|
|
20563
|
-
mergeR =
|
|
21197
|
+
_gitSpawn(['merge', '--abort'], { cwd: root, encoding: 'utf8' });
|
|
21198
|
+
mergeR = _gitSpawn(['merge', '--no-edit', '--allow-unrelated-histories', fromBranch], { cwd: root, encoding: 'utf8' });
|
|
20564
21199
|
} else {
|
|
20565
21200
|
fail('main merge 실패 — unrelated histories: --allow-unrelated 옵션 추가하세요 (또는 LEERNESS_ALLOW_UNRELATED=1)');
|
|
20566
|
-
|
|
20567
|
-
|
|
21201
|
+
_gitSpawn(['merge', '--abort'], { cwd: root, encoding: 'utf8' });
|
|
21202
|
+
_gitSpawn(['checkout', fromBranch], { cwd: root, encoding: 'utf8' });
|
|
20568
21203
|
process.exitCode = 1;
|
|
20569
21204
|
return;
|
|
20570
21205
|
}
|
|
@@ -20572,18 +21207,18 @@ function releaseSyncMainCmd(root) {
|
|
|
20572
21207
|
if (mergeR.status !== 0) {
|
|
20573
21208
|
fail('main merge 실패 — 충돌 가능성: ' + (mergeR.stderr || mergeR.stdout || '').slice(0, 300));
|
|
20574
21209
|
// checkout back to original
|
|
20575
|
-
|
|
20576
|
-
|
|
21210
|
+
_gitSpawn(['merge', '--abort'], { cwd: root, encoding: 'utf8' });
|
|
21211
|
+
_gitSpawn(['checkout', fromBranch], { cwd: root, encoding: 'utf8' });
|
|
20577
21212
|
process.exitCode = 1;
|
|
20578
21213
|
return;
|
|
20579
21214
|
}
|
|
20580
21215
|
ok(`main merged: ${fromBranch}`);
|
|
20581
21216
|
// 4) main push
|
|
20582
|
-
const pushR =
|
|
21217
|
+
const pushR = _gitSpawn(['push', remoteName, 'main'], { cwd: root, encoding: 'utf8' });
|
|
20583
21218
|
if (pushR.status !== 0) { fail('main push 실패: ' + (pushR.stderr || '').slice(0, 200)); process.exitCode = 1; }
|
|
20584
21219
|
else ok(`main pushed → ${remoteName}/main`);
|
|
20585
21220
|
// 5) return to source branch (release 작업 흐름 보존)
|
|
20586
|
-
|
|
21221
|
+
_gitSpawn(['checkout', fromBranch], { cwd: root, encoding: 'utf8' });
|
|
20587
21222
|
|
|
20588
21223
|
// 1.9.178: 자동 npm publish (사용자 명시 — .env NPM_TOKEN 있으면 자동 배포).
|
|
20589
21224
|
// opt-out: --no-npm 또는 LEERNESS_NO_NPM_PUBLISH=1
|
|
@@ -20621,7 +21256,7 @@ function releaseCleanupCmd(root) {
|
|
|
20621
21256
|
keep = n;
|
|
20622
21257
|
}
|
|
20623
21258
|
// 1) git 저장소 확인
|
|
20624
|
-
const headR =
|
|
21259
|
+
const headR = _gitSpawn(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: root, encoding: 'utf8' });
|
|
20625
21260
|
if (headR.status !== 0) {
|
|
20626
21261
|
// 1.36.103: --json 이 사람용 텍스트를 내던 자리 — 기계 소비자는 여기서 JSON.parse 에 실패했다.
|
|
20627
21262
|
if (jsonMode) { log(JSON.stringify({ ok: false, error: 'git 저장소가 아닙니다', code: 'not_a_git_repo', root, deleted: [], candidates: [] }, null, 2)); process.exitCode = 1; return; }
|
|
@@ -20630,12 +21265,12 @@ function releaseCleanupCmd(root) {
|
|
|
20630
21265
|
}
|
|
20631
21266
|
const currentBranch = (headR.stdout || '').trim();
|
|
20632
21267
|
// 2) local release/* branches + merged 상태 회수
|
|
20633
|
-
const branchR =
|
|
21268
|
+
const branchR = _gitSpawn(['branch', '--merged', 'main', '--list', 'release/*'], { cwd: root, encoding: 'utf8' });
|
|
20634
21269
|
const mergedBranches = (branchR.stdout || '').split('\n')
|
|
20635
21270
|
.map(l => l.replace(/^\*?\s+/, '').trim())
|
|
20636
21271
|
.filter(l => l && /^release\/\d+\.\d+\.\d+$/.test(l));
|
|
20637
21272
|
// 3) 모든 release/* 도 회수 (unmerged 분석용)
|
|
20638
|
-
const allR =
|
|
21273
|
+
const allR = _gitSpawn(['branch', '--list', 'release/*'], { cwd: root, encoding: 'utf8' });
|
|
20639
21274
|
const allBranches = (allR.stdout || '').split('\n')
|
|
20640
21275
|
.map(l => l.replace(/^\*?\s+/, '').trim())
|
|
20641
21276
|
.filter(l => l && /^release\/\d+\.\d+\.\d+$/.test(l));
|
|
@@ -20654,7 +21289,7 @@ function releaseCleanupCmd(root) {
|
|
|
20654
21289
|
let deleted = 0, deleteFailed = 0;
|
|
20655
21290
|
if (apply) {
|
|
20656
21291
|
for (const b of toDelete) {
|
|
20657
|
-
const r =
|
|
21292
|
+
const r = _gitSpawn(['branch', '-d', b], { cwd: root, encoding: 'utf8' });
|
|
20658
21293
|
if (r.status === 0) deleted++; else deleteFailed++;
|
|
20659
21294
|
}
|
|
20660
21295
|
if (deleteFailed > 0) process.exitCode = 1; // 실패 시 nonzero exit
|
|
@@ -20970,10 +21605,10 @@ function releasePublish(root) {
|
|
|
20970
21605
|
log('(dry-run) git push / git push --tags 생략 (실행 안 함)');
|
|
20971
21606
|
} else {
|
|
20972
21607
|
log('git push:');
|
|
20973
|
-
const r1 =
|
|
21608
|
+
const r1 = _gitSpawn(['push'], { cwd: root, encoding: 'utf8', shell: true });
|
|
20974
21609
|
log((r1.stdout || r1.stderr || '').slice(-200) || '(no output)');
|
|
20975
21610
|
if (r1.status !== 0) { fail('git push 실패'); pubFail = true; }
|
|
20976
|
-
const r2 =
|
|
21611
|
+
const r2 = _gitSpawn(['push', '--tags'], { cwd: root, encoding: 'utf8', shell: true });
|
|
20977
21612
|
log((r2.stdout || r2.stderr || '').slice(-200) || '(no output)');
|
|
20978
21613
|
if (r2.status !== 0) { fail('git push --tags 실패'); pubFail = true; }
|
|
20979
21614
|
}
|
|
@@ -21803,6 +22438,15 @@ function _readUsageStats(root) {
|
|
|
21803
22438
|
}
|
|
21804
22439
|
function _bumpUsage(root, cmdName) {
|
|
21805
22440
|
// 가벼운 카운터 — 명령 실행마다 호출 (sync write로 작은 파일)
|
|
22441
|
+
// 1.36.137 (동시성 감사 실측): 이 함수는 **모든 명령이 공유하는 단 하나의 파일**을 락 없이 RMW 했다 —
|
|
22442
|
+
// 2 프로세스부터 카운터가 38~46% 사라졌다(8프로세스×15회=120 실행 → 74 계수). exit 0 · 경고 0 · 손상 0.
|
|
22443
|
+
// 카운터 하나 때문에 명령을 오래 붙잡을 수는 없으므로 **짧은 대기**만 준다 —
|
|
22444
|
+
// 못 잡으면 `_withLock` 이 fail-open 으로 진행한다(드물게 한 건 놓치는 것이, 40%를 잃는 것보다 낫다).
|
|
22445
|
+
try {
|
|
22446
|
+
return _withLock(_usageStatsPath(root), () => _bumpUsageLocked(root, cmdName), { maxWaitMs: 300 });
|
|
22447
|
+
} catch { /* 카운터 실패가 명령을 실패시키지 않는다 */ }
|
|
22448
|
+
}
|
|
22449
|
+
function _bumpUsageLocked(root, cmdName) {
|
|
21806
22450
|
try {
|
|
21807
22451
|
const stats = _readUsageStats(root);
|
|
21808
22452
|
if (!stats.commands) stats.commands = {};
|
|
@@ -23966,6 +24610,18 @@ function _loadRoles(root) {
|
|
|
23966
24610
|
if (!exists(f)) return {};
|
|
23967
24611
|
try { const j = JSON.parse(read(f)); return (j && j.roles && typeof j.roles === 'object') ? j.roles : {}; } catch { return {}; }
|
|
23968
24612
|
}
|
|
24613
|
+
// 1.36.137 (동시성 감사 실측): `roles set/unset/suggest --apply` 는 load→mutate→save 를 **락 없이** 했다 —
|
|
24614
|
+
// 4 프로세스만으로 28.8%, 16 프로세스에서 11~15/16 만 살아남았다(exit 0 · 경고 0 · 손상 0).
|
|
24615
|
+
// 저장만 잠그면 소용이 없다(읽기와 쓰기 사이가 창이다). 읽기·변경·쓰기를 **한 락 안**에 묶는다 —
|
|
24616
|
+
// 1.36.108 이 rules.md 에서 만든 형태 그대로다.
|
|
24617
|
+
function _updateRoles(root, mutate) {
|
|
24618
|
+
return _withLock(_rolesFile(root), () => {
|
|
24619
|
+
const roles = _loadRoles(root);
|
|
24620
|
+
// mutate 가 false 를 돌려주면 **쓰지 않는다** — 바꿀 것이 없는데 파일을 건드리지 않는다.
|
|
24621
|
+
if (mutate(roles) === false) return null;
|
|
24622
|
+
return _saveRoles(root, roles);
|
|
24623
|
+
});
|
|
24624
|
+
}
|
|
23969
24625
|
function _saveRoles(root, roles) {
|
|
23970
24626
|
const f = _rolesFile(root);
|
|
23971
24627
|
mkdirp(path.dirname(f));
|
|
@@ -23994,6 +24650,16 @@ function _suggestRoles(root) {
|
|
|
23994
24650
|
// leerness roles <list|set|unset|catalog|suggest|verify>
|
|
23995
24651
|
function rolesCmd(root, sub, ...args) {
|
|
23996
24652
|
root = absRoot(root || process.cwd());
|
|
24653
|
+
// 1.36.140 (재검수 P3, 재현): 오타 난 `--path` 로 `roles unset` 하면 **실패하면서 그 경로와 `.harness/` 를 만들었다**.
|
|
24654
|
+
// 락/쓰기 헬퍼가 부모 디렉토리를 만들기 때문이다. 없는 경로면 아무것도 만들지 않고 그 사실을 말한다.
|
|
24655
|
+
// ⚠ 첫 판은 이 검사를 **명령 진입부**에 뒀는데, 그러자 없는 경로에서도 잘 돌던
|
|
24656
|
+
// `roles catalog`(정적 목록) · `roles list`(읽기) 가 exit 1 이 됐다(대조군으로 확인: 1.36.139 는 둘 다 exit 0).
|
|
24657
|
+
// 막아야 할 것은 조회가 아니라 **쓰기**다. 쓰는 하위명령에서만 부른다.
|
|
24658
|
+
const _requireExistingPath = () => {
|
|
24659
|
+
if (exists(root)) return false;
|
|
24660
|
+
failJson(has('--json'), 'path_not_found', `경로가 없습니다: ${root} — 디렉토리를 새로 만들지 않았습니다`);
|
|
24661
|
+
return true;
|
|
24662
|
+
};
|
|
23997
24663
|
try { _loadEnvFile(root); _loadEnvFile(path.join(root, '..')); } catch {}
|
|
23998
24664
|
const json = has('--json');
|
|
23999
24665
|
sub = sub || 'list';
|
|
@@ -24021,9 +24687,8 @@ function rolesCmd(root, sub, ...args) {
|
|
|
24021
24687
|
let model = arg('--model', null);
|
|
24022
24688
|
if (!model) model = _pickModel(provider, (ROLE_CATALOG[role] || {}).modelKind || 'top');
|
|
24023
24689
|
const persona = arg('--persona', null) || (ROLE_CATALOG[role] ? ROLE_CATALOG[role].desc : '');
|
|
24024
|
-
|
|
24025
|
-
roles[role] = { provider, model: model || null, persona };
|
|
24026
|
-
const f = _saveRoles(root, roles);
|
|
24690
|
+
if (_requireExistingPath()) return;
|
|
24691
|
+
const f = _updateRoles(root, (roles) => { roles[role] = { provider, model: model || null, persona }; });
|
|
24027
24692
|
// 비활성 provider 경고 (verify 사전 노출)
|
|
24028
24693
|
let warnMsg = '';
|
|
24029
24694
|
try { const st = _checkAgent(EXTERNAL_AGENTS.find(a => a.id === provider) || { id: provider, bin: provider, versionArgs: ['--version'], envFlag: `LEERNESS_ENABLE_${provider.toUpperCase()}` }); if (st.status !== 'ready') warnMsg = `⚠ ${provider} 현재 비활성(${st.status}) — 활성화: LEERNESS_ENABLE_${provider.toUpperCase()}=1 + CLI 설치`; } catch {}
|
|
@@ -24037,10 +24702,16 @@ function rolesCmd(root, sub, ...args) {
|
|
|
24037
24702
|
|
|
24038
24703
|
if (sub === 'unset' || sub === 'remove' || sub === 'rm') {
|
|
24039
24704
|
const role = _normalizeRole(args.find(a => a && !a.startsWith('-')));
|
|
24040
|
-
|
|
24041
|
-
|
|
24042
|
-
|
|
24043
|
-
|
|
24705
|
+
// 1.36.139 (재검수 P1): 존재 판정이 **락 밖**이라 동시 제거 두 개가 모두 "제거했다" 고 답했다.
|
|
24706
|
+
// 판정과 변경은 같은 락 안에 있어야 한다 — mutate 가 `false` 를 돌려주면 `_updateRoles` 가 쓰지 않고,
|
|
24707
|
+
// 사유는 바깥의 `_unsetErr` 로 전달한다(락 안에서 출력하지 않는다).
|
|
24708
|
+
if (_requireExistingPath()) return;
|
|
24709
|
+
let _unsetErr = null;
|
|
24710
|
+
const f = _updateRoles(root, (roles) => {
|
|
24711
|
+
if (!roles[role]) { _unsetErr = `설정되지 않은 역할: ${role}`; return false; }
|
|
24712
|
+
delete roles[role];
|
|
24713
|
+
});
|
|
24714
|
+
if (_unsetErr) return fail(_unsetErr);
|
|
24044
24715
|
if (json) { log(JSON.stringify({ removed: role, file: f }, null, 2)); return; }
|
|
24045
24716
|
ok(`역할 제거: ${role}`);
|
|
24046
24717
|
return;
|
|
@@ -24049,10 +24720,12 @@ function rolesCmd(root, sub, ...args) {
|
|
|
24049
24720
|
if (sub === 'suggest') {
|
|
24050
24721
|
const { ready, suggestions } = _suggestRoles(root);
|
|
24051
24722
|
if (has('--apply')) {
|
|
24052
|
-
|
|
24723
|
+
if (_requireExistingPath()) return;
|
|
24053
24724
|
let applied = 0;
|
|
24054
|
-
|
|
24055
|
-
|
|
24725
|
+
const f = _updateRoles(root, (roles) => {
|
|
24726
|
+
applied = 0;
|
|
24727
|
+
for (const s of suggestions) { if (s.ready) { roles[s.role] = { provider: s.provider, model: s.model, persona: ROLE_CATALOG[s.role].desc }; applied++; } }
|
|
24728
|
+
});
|
|
24056
24729
|
if (json) { log(JSON.stringify({ applied, ready, file: f, suggestions }, null, 2)); return; }
|
|
24057
24730
|
ok(`제안 적용: ${applied}개 역할 → ${f}`);
|
|
24058
24731
|
for (const s of suggestions.filter(x => x.ready)) log(` ${s.role.padEnd(11)} → ${s.provider} / ${s.model}`);
|