leerness 1.36.168 → 1.36.170
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +4 -4
- package/bin/leerness.js +275 -65
- package/lib/migrate.js +27 -5
- package/package.json +3 -2
- package/scripts/e2e-concurrency.js +73 -2
- package/scripts/e2e.js +18 -12
- package/scripts/next-action-suggestion-probe.js +192 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.170 — 2026-08-27
|
|
4
|
+
|
|
5
|
+
- next-action의 정규화 계약을 모든 읽기 표면으로 확장했습니다. release-time auto-resume plan 생성과 외부 에이전트용 `context`가 legacy `--filter`, 자동 완료 명령, 시간 기반 중복, 존재하지 않는 stress 실행을 다시 노출하지 않습니다.
|
|
6
|
+
- 1.36.169 이전에 저장된 `auto-resume-plan.json`도 `resume`/`handoff`에서 지연 정규화하며, pre-wake audit가 폐기된 `pending` 필드 대신 canonical queue를 세어 대기 작업을 정확히 보고합니다.
|
|
7
|
+
- 회귀 probe가 `next-action list/take`뿐 아니라 release plan, 기존 plan 재개, context JSON, pre-wake pending count까지 실제 CLI로 검증합니다. 외부 Codex 교차 리뷰 결과 P0/P1/P2는 없었습니다.
|
|
8
|
+
|
|
9
|
+
## 1.36.169 — 2026-08-27
|
|
10
|
+
|
|
11
|
+
- 멀티세션 lost-update 방지: plan/anchors canonical lock, decisions·lessons migration transaction, rule baseline verification serialization, 11-pair forced-interleaving regressions
|
|
12
|
+
- 외부 Codex 교차 검수에서 재현한 자동 next-action 오류를 수정했습니다. decision/plan 제안은 실제 지원 명령만 안내하고, 일반 적용 프로젝트에 없는 `scripts/e2e.js` 대신 `leerness verify-code .`를 사용하며, 아직 생성되지 않은 stress 파일 실행을 제거합니다.
|
|
13
|
+
- next-action에 시간·버전과 무관한 안정 키를 도입해 stress 버전 및 progress 경과시간 중복을 하나로 갱신합니다. 업그레이드 직후 list/take도 legacy 큐를 즉시 정규화하고, take의 선택·정규화·제거를 한 잠금 트랜잭션으로 묶어 동시 handoff가 제목을 바꿔도 task와 큐 상태가 갈라지지 않게 했습니다.
|
|
14
|
+
|
|
3
15
|
## 1.36.168 — 2026-08-26
|
|
4
16
|
|
|
5
17
|
- Windows which가 동일 npm 설치의 extensionless/.cmd/.ps1 shim을 실제 package bin target과 npm 실행문까지 검증한 뒤 한 설치로 그룹화하고, pathCandidates 원문은 유지한 채 pathInstallations를 추가해 PATH 충돌 오탐을 제거. 독립 검토에서 잡힌 주석·더미 인자 우회까지 막아 변조·미증명·다른 디렉터리·비표준 .bat·POSIX 후보를 보수적으로 분리하며 15개 회귀 프로브와 제품 배선 가드 추가.
|
package/README.md
CHANGED
|
@@ -138,7 +138,7 @@ MIT
|
|
|
138
138
|
<!-- leerness:project-readme:start -->
|
|
139
139
|
## Leerness Project Harness
|
|
140
140
|
|
|
141
|
-
이 프로젝트는 Leerness v1.36.
|
|
141
|
+
이 프로젝트는 Leerness v1.36.170 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
|
|
142
142
|
|
|
143
143
|
### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
|
|
144
144
|
|
|
@@ -192,7 +192,7 @@ leerness memory restore decision <date|title>
|
|
|
192
192
|
|
|
193
193
|
### MCP server (외부 AI 통합)
|
|
194
194
|
|
|
195
|
-
Leerness v1.36.
|
|
195
|
+
Leerness v1.36.170는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **89개 도구**를 노출:
|
|
196
196
|
|
|
197
197
|
```jsonc
|
|
198
198
|
// 카테고리별
|
|
@@ -213,7 +213,7 @@ Leerness v1.36.168는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
|
|
|
213
213
|
`<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
|
|
214
214
|
1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
|
|
215
215
|
|
|
216
|
-
현재 누적: **v1.9.x → 1.36.
|
|
216
|
+
현재 누적: **v1.9.x → 1.36.170 릴리스 태그 이력** (수백 라운드) · _reports/는 비공개 보존.
|
|
217
217
|
|
|
218
218
|
### 성능 가이드
|
|
219
219
|
|
|
@@ -251,5 +251,5 @@ leerness release pack --close --auto-main-push
|
|
|
251
251
|
- `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
|
|
252
252
|
- `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
|
|
253
253
|
|
|
254
|
-
Last synced by Leerness v1.36.
|
|
254
|
+
Last synced by Leerness v1.36.170: 2026-08-27
|
|
255
255
|
<!-- leerness:project-readme:end -->
|
package/bin/leerness.js
CHANGED
|
@@ -48,7 +48,7 @@ const {
|
|
|
48
48
|
migrateLegacyWorkspace,
|
|
49
49
|
} = require('../lib/workspace-dir');
|
|
50
50
|
|
|
51
|
-
const VERSION = '1.36.
|
|
51
|
+
const VERSION = '1.36.170';
|
|
52
52
|
|
|
53
53
|
// 1.9.290 (UR-0037, Codex gpt-5.5 #4 수렴): CLI 전용 부작용은 require 시 실행하지 않는다.
|
|
54
54
|
// 이전: warning listener 제거 / NODE_OPTIONS 변경 / chcp IIFE 가 top-level 즉시 실행 → require('harness') 시 호스트 프로세스 오염.
|
|
@@ -4384,9 +4384,26 @@ function _selfTestCases() {
|
|
|
4384
4384
|
// 1.36.47: 어색 조사 병기 소거 + 장문은 단어 경계 절단(…) — 실적용에서 발견된 품질 결함 회귀 방지
|
|
4385
4385
|
const dLong = p._draftAnchors({ pkgDescription: '가나다 라마바사 아자차카 타파하 '.repeat(20) });
|
|
4386
4386
|
const phraseOk = !dLong.goal.join('').includes('를(을)') && dLong.goal.some(l => l.includes('…')) && !/[가-힣]{2}…[가-힣]/.test(dLong.goal.join(''));
|
|
4387
|
-
// 재초안: draft 마커 잔존 섹션은 gap 취급
|
|
4388
|
-
const
|
|
4389
|
-
|
|
4387
|
+
// 재초안: draft 마커 잔존 섹션은 gap 취급 — 실제 CLI 핸들러가 두 파일을 교체하는지 검증한다.
|
|
4388
|
+
const redraw = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_anchor_redraft_'));
|
|
4389
|
+
let redraftOk = false;
|
|
4390
|
+
const savedArgv = process.argv, savedWrite = process.stdout.write;
|
|
4391
|
+
try {
|
|
4392
|
+
mkdirp(path.join(redraw, '.leerness'));
|
|
4393
|
+
writeUtf8(path.join(redraw, 'package.json'), JSON.stringify({ name: 'probe', version: '1.0.0', description: '새 정체성 신호' }));
|
|
4394
|
+
writeUtf8(path.join(redraw, '.leerness', 'project-brief.md'), '# Brief\n\n## Purpose\n<!-- draft: leerness anchors old -->\n- OLD_PURPOSE_SENTINEL\n');
|
|
4395
|
+
writeUtf8(path.join(redraw, '.leerness', 'plan.md'), '# Plan\n\n## Goal\n<!-- draft: leerness anchors old -->\n- OLD_GOAL_SENTINEL\n');
|
|
4396
|
+
process.argv = ['node', 'leerness', 'anchors', 'draft', '--apply'];
|
|
4397
|
+
process.stdout.write = () => true;
|
|
4398
|
+
anchorsCmd(redraw, 'draft');
|
|
4399
|
+
const rb = read(path.join(redraw, '.leerness', 'project-brief.md'));
|
|
4400
|
+
const rp = read(path.join(redraw, '.leerness', 'plan.md'));
|
|
4401
|
+
redraftOk = !rb.includes('OLD_PURPOSE_SENTINEL') && !rp.includes('OLD_GOAL_SENTINEL')
|
|
4402
|
+
&& rb.includes('새 정체성 신호') && rp.includes('새 정체성 신호');
|
|
4403
|
+
} catch {} finally {
|
|
4404
|
+
process.argv = savedArgv; process.stdout.write = savedWrite;
|
|
4405
|
+
try { fs.rmSync(redraw, { recursive: true, force: true }); } catch {}
|
|
4406
|
+
}
|
|
4390
4407
|
if (!(phraseOk && redraftOk)) return false;
|
|
4391
4408
|
// 무신호 시 발명 금지
|
|
4392
4409
|
const empty = p._draftAnchors({});
|
|
@@ -4401,6 +4418,60 @@ function _selfTestCases() {
|
|
|
4401
4418
|
const wired = typeof anchorsCmd === 'function' && s.includes("cmd === 'anchors'") && s.includes('→ leerness anchors draft');
|
|
4402
4419
|
return draftOk && noInvent && secOk && noSec && wired;
|
|
4403
4420
|
} },
|
|
4421
|
+
{ name: 'next-action 자동 제안 실행 가능성 (1.36.169/Codex P2): 일반 프로젝트 명령 + stable-key 마이그레이션/중복 갱신', run: () => {
|
|
4422
|
+
const arena = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_next_action_'));
|
|
4423
|
+
const root = path.join(arena, 'project');
|
|
4424
|
+
try {
|
|
4425
|
+
const hd = path.join(root, '.leerness');
|
|
4426
|
+
const stressDir = path.join(arena, '_apps', 'leerness-stress', 'bin');
|
|
4427
|
+
mkdirp(hd); mkdirp(stressDir);
|
|
4428
|
+
writeUtf8(path.join(hd, 'plan.md'), '# Plan\n\n### M-0001. install release\nStatus: planned\n');
|
|
4429
|
+
writeUtf8(path.join(hd, 'decisions.md'), '# Decisions\n\n### 2026-08-27 — install policy\n- Reason: probe\n');
|
|
4430
|
+
writeUtf8(path.join(hd, 'review-evidence.md'), 'verified\n'.repeat(20));
|
|
4431
|
+
const stressFile = path.join(stressDir, 'stress-v211.js');
|
|
4432
|
+
writeUtf8(stressFile, '// existing baseline\n');
|
|
4433
|
+
const old = new Date(Date.now() - 48 * 3600000); fs.utimesSync(stressFile, old, old);
|
|
4434
|
+
|
|
4435
|
+
const actions = _suggestNextActions(root, { id: 'T-0001', request: 'install 기능 보강' }, 'install');
|
|
4436
|
+
const plan = actions.find(a => a.icon === '🎯');
|
|
4437
|
+
const decision = actions.find(a => a.icon === '💭');
|
|
4438
|
+
const stress = actions.find(a => a.icon === '🧬');
|
|
4439
|
+
const generatedOk = plan?.command === 'leerness plan list --path .'
|
|
4440
|
+
&& decision?.command === 'leerness decision list --query "install"'
|
|
4441
|
+
&& stress?.title === 'stress-v211 오래됨 — 새 stress test 작성 권장'
|
|
4442
|
+
&& !Object.prototype.hasOwnProperty.call(stress, 'command')
|
|
4443
|
+
&& plan?.actionKey === 'plan:M-0001' && decision?.actionKey === 'decision:install'
|
|
4444
|
+
&& stress?.actionKey === 'stress-refresh';
|
|
4445
|
+
|
|
4446
|
+
const legacy = [
|
|
4447
|
+
{ icon: '🧬', title: 'stress-v210 마지막 갱신 46h 전 — 새 stress test 작성 권장', command: 'node _apps/leerness-stress/bin/stress-v211.js' },
|
|
4448
|
+
{ icon: '🧬', title: 'stress-v211 마지막 갱신 48h 전 — 새 stress test 작성 권장', command: 'node _apps/leerness-stress/bin/stress-v212.js' },
|
|
4449
|
+
{ icon: '🎯', title: plan.title, command: 'leerness plan list --filter "install"' },
|
|
4450
|
+
{ icon: '💭', title: decision.title, command: 'leerness decision list --filter "install"' },
|
|
4451
|
+
{ icon: '🧪', title: 'review-evidence.md 비어있음 — e2e 또는 verify-code 실행', command: 'node ./scripts/e2e.js' },
|
|
4452
|
+
{ icon: '🔄', title: 'progress-tracker 24h 정체 — task T-0001 status 갱신', command: 'leerness task update T-0001 --status completed' },
|
|
4453
|
+
{ icon: '🔄', title: 'progress-tracker 25h 정체 — task T-0001 status 갱신', command: 'leerness task update T-0001 --status completed' }
|
|
4454
|
+
];
|
|
4455
|
+
writeUtf8(_nextActionQueuePath(root), JSON.stringify({ queue: legacy, at: new Date().toISOString() }, null, 2));
|
|
4456
|
+
_enqueueNextActions(root, []); // 새 제안이 없는 handoff도 legacy 큐를 고쳐야 한다.
|
|
4457
|
+
_enqueueNextActions(root, [{ actionKey: 'stress-refresh', icon: '🧬', title: 'stress-v212 오래됨 — 새 stress test 작성 권장' }]);
|
|
4458
|
+
const migrated = JSON.parse(read(_nextActionQueuePath(root))).queue;
|
|
4459
|
+
const migratedStress = migrated.filter(a => a.icon === '🧬');
|
|
4460
|
+
const migratedPlan = migrated.find(a => a.icon === '🎯');
|
|
4461
|
+
const migratedDecision = migrated.find(a => a.icon === '💭');
|
|
4462
|
+
const migratedEvidence = migrated.find(a => a.icon === '🧪');
|
|
4463
|
+
const migratedProgress = migrated.find(a => a.icon === '🔄');
|
|
4464
|
+
return generatedOk && migrated.length === 5 && migratedStress.length === 1
|
|
4465
|
+
&& migratedStress[0].title === 'stress-v212 오래됨 — 새 stress test 작성 권장'
|
|
4466
|
+
&& !Object.prototype.hasOwnProperty.call(migratedStress[0], 'command')
|
|
4467
|
+
&& migratedPlan.command === plan.command && migratedDecision.command === decision.command
|
|
4468
|
+
&& migratedEvidence.command === 'leerness verify-code .'
|
|
4469
|
+
&& migratedProgress.title === 'progress-tracker 정체 — task T-0001 상태 검토'
|
|
4470
|
+
&& migratedProgress.command === 'leerness task list --path .'
|
|
4471
|
+
&& migrated.every(a => typeof a.actionKey === 'string' && a.actionKey.length > 0);
|
|
4472
|
+
} catch { return false; }
|
|
4473
|
+
finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch {} }
|
|
4474
|
+
} },
|
|
4404
4475
|
{ name: 'managed 파일 force 병합 (1.36.37, 1.9.441 드리프트 실측): writeIfSafe 가 --force 여도 managed 는 라인-diff 병합 — CLAUDE 커스텀 보존 (행위검사)', run: () => {
|
|
4405
4476
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_wif_'));
|
|
4406
4477
|
let ok1 = false, ok2 = false, ok3 = false;
|
|
@@ -8393,8 +8464,10 @@ function anchorsCmd(root, sub) {
|
|
|
8393
8464
|
const _draftM = '<!-- draft: leerness anchors';
|
|
8394
8465
|
// 1.36.49 (codex 6차 #4): 헤딩 자체가 없는 파일도 gap — 종전 _sectionUnfilled 는 "섹션 없음→미플래그"(감지용 보수 기본)라
|
|
8395
8466
|
// ## Goal 이 아예 없는 plan.md 가 "작성됨"으로 보고되고 draft --apply 도 무동작이었다. apply 시 헤딩을 신설한다.
|
|
8396
|
-
const
|
|
8397
|
-
const
|
|
8467
|
+
const _briefHasGap = txt => !!txt && !txt.includes(naM) && (_briefUnfilled(txt) || _mdSectionBody(txt, 'Purpose') == null || (_mdSectionBody(txt, 'Purpose') || '').includes(_draftM));
|
|
8468
|
+
const _planHasGap = txt => !!txt && !txt.includes(naM) && (_planGoalUnfilled(txt) || _mdSectionBody(txt, 'Goal') == null || (_mdSectionBody(txt, 'Goal') || '').includes(_draftM));
|
|
8469
|
+
const briefGap = _briefHasGap(briefTxt);
|
|
8470
|
+
const planGap = _planHasGap(planTxt);
|
|
8398
8471
|
if (!sub || sub === 'status') {
|
|
8399
8472
|
if (json) { log(JSON.stringify({ briefUnfilled: briefGap, planGoalUnfilled: planGap }, null, 2)); return; }
|
|
8400
8473
|
log(`# leerness anchors — 정체성앵커 상태`);
|
|
@@ -8416,16 +8489,31 @@ function anchorsCmd(root, sub) {
|
|
|
8416
8489
|
const apply = has('--apply');
|
|
8417
8490
|
const applied = [];
|
|
8418
8491
|
if (briefGap && draft.purpose.length) {
|
|
8419
|
-
if (apply) {
|
|
8492
|
+
if (apply) {
|
|
8493
|
+
// 1.36.169 (T-0108): 초안 계산 뒤 다른 세션이 채운 내용을 낡은 snapshot으로 덮지 않는다.
|
|
8494
|
+
_withLock(bf, () => {
|
|
8495
|
+
const current = exists(bf) ? read(bf) : '';
|
|
8496
|
+
if (!_briefHasGap(current)) return;
|
|
8497
|
+
writeUtf8(bf, _replaceMdSection(current, 'Purpose', draft.purpose, { appendIfMissing: true }));
|
|
8498
|
+
applied.push('project-brief.md#Purpose');
|
|
8499
|
+
});
|
|
8500
|
+
}
|
|
8420
8501
|
}
|
|
8421
8502
|
if (planGap && draft.goal.length) {
|
|
8422
|
-
if (apply) {
|
|
8503
|
+
if (apply) {
|
|
8504
|
+
_withPlanLock(root, () => {
|
|
8505
|
+
const current = exists(pf) ? read(pf) : '';
|
|
8506
|
+
if (!_planHasGap(current)) return;
|
|
8507
|
+
writeUtf8(pf, _replaceMdSection(current, 'Goal', draft.goal, { appendIfMissing: true }));
|
|
8508
|
+
applied.push('plan.md#Goal');
|
|
8509
|
+
});
|
|
8510
|
+
}
|
|
8423
8511
|
}
|
|
8424
8512
|
if (json) { log(JSON.stringify({ briefUnfilled: briefGap, planGoalUnfilled: planGap, draft: { purpose: draft.purpose, goal: draft.goal }, applied, dryRun: !apply }, null, 2)); return; }
|
|
8425
8513
|
log(`# leerness anchors draft ${apply ? '(적용)' : '(dry-run — 적용: --apply)'}`);
|
|
8426
|
-
if (briefGap) { log(`\n## project-brief Purpose ${apply ? '← 적용됨' : '초안'}`); draft.purpose.forEach(l => log(' ' + l)); }
|
|
8514
|
+
if (briefGap) { log(`\n## project-brief Purpose ${apply ? (applied.includes('project-brief.md#Purpose') ? '← 적용됨' : '← 다른 세션 변경 감지로 건드리지 않음') : '초안'}`); draft.purpose.forEach(l => log(' ' + l)); }
|
|
8427
8515
|
else log(`\n ✓ project-brief Purpose 는 이미 작성됨 — 건드리지 않음`);
|
|
8428
|
-
if (planGap) { log(`\n## plan Goal ${apply ? '← 적용됨' : '초안'}`); draft.goal.forEach(l => log(' ' + l)); }
|
|
8516
|
+
if (planGap) { log(`\n## plan Goal ${apply ? (applied.includes('plan.md#Goal') ? '← 적용됨' : '← 다른 세션 변경 감지로 건드리지 않음') : '초안'}`); draft.goal.forEach(l => log(' ' + l)); }
|
|
8429
8517
|
else log(` ✓ plan Goal 은 이미 작성됨 — 건드리지 않음`);
|
|
8430
8518
|
if (apply && applied.length) log(`\n✓ 적용: ${applied.join(', ')} (초안 표식 주석 포함 — 검토 후 주석 제거로 확정)`);
|
|
8431
8519
|
return;
|
|
@@ -11312,12 +11400,14 @@ function _runPreWakeAudit(root) {
|
|
|
11312
11400
|
|
|
11313
11401
|
// 6) next-action queue 잔여 (1.9.201)
|
|
11314
11402
|
try {
|
|
11315
|
-
|
|
11316
|
-
|
|
11403
|
+
// loader 계약은 { queue, at }이다. 과거 `pending`을 읽어 이 검사가 항상 무언
|
|
11404
|
+
// no-op이었고, raw 큐 수를 쓰면 legacy 중복까지 과대 계수된다.
|
|
11405
|
+
const q = _normalizedNextActionState(root);
|
|
11406
|
+
if (q.queue.length > 0) {
|
|
11317
11407
|
audit.findings.info.push({
|
|
11318
11408
|
kind: 'next-action-pending',
|
|
11319
|
-
count: q.
|
|
11320
|
-
detail: `${q.
|
|
11409
|
+
count: q.queue.length,
|
|
11410
|
+
detail: `${q.queue.length}건 next-action 대기 — leerness next-action take 로 즉시 처리`
|
|
11321
11411
|
});
|
|
11322
11412
|
}
|
|
11323
11413
|
} catch {}
|
|
@@ -11820,7 +11910,11 @@ function _loadAutoResumePlan(root) {
|
|
|
11820
11910
|
if (!j.savedAt) return null;
|
|
11821
11911
|
const ageMs = Date.now() - new Date(j.savedAt).getTime();
|
|
11822
11912
|
const expiredMs = j.expectedFireAt ? Date.now() - new Date(j.expectedFireAt).getTime() : 0;
|
|
11823
|
-
|
|
11913
|
+
// 1.36.170: 1.36.169 이전에 저장한 plan도 resume/handoff에서 곧바로 현재 명령 계약으로
|
|
11914
|
+
// 읽는다. 큐 list/take만 정규화하면 release 직후 저장된 plan이 legacy --filter,
|
|
11915
|
+
// 자동 done 명령, 존재하지 않는 stress 파일 실행을 다시 노출할 수 있다.
|
|
11916
|
+
const nextActions = _compactNextActionQueue(Array.isArray(j.nextActions) ? j.nextActions : []);
|
|
11917
|
+
return { ...j, nextActions, ageMs, ageMin: Math.floor(ageMs / 60000), elapsedFromExpected: Math.floor(expiredMs / 60000) };
|
|
11824
11918
|
} catch { return null; }
|
|
11825
11919
|
}
|
|
11826
11920
|
function _writeAutoResumePlan(root, plan) {
|
|
@@ -11843,7 +11937,7 @@ function _buildAutoResumePlan(root, opts) {
|
|
|
11843
11937
|
// 현재 VERSION
|
|
11844
11938
|
const currentVersion = VERSION;
|
|
11845
11939
|
// next-action queue snapshot
|
|
11846
|
-
const queueState =
|
|
11940
|
+
const queueState = _normalizedNextActionState(root);
|
|
11847
11941
|
const queueTop = queueState.queue.length > 0 ? queueState.queue[queueState.queue.length - 1] : null;
|
|
11848
11942
|
// memory surface counts
|
|
11849
11943
|
const memorySurface = {
|
|
@@ -13686,24 +13780,102 @@ function _writeNextActionQueue(root, queue) {
|
|
|
13686
13780
|
return false;
|
|
13687
13781
|
}
|
|
13688
13782
|
}
|
|
13689
|
-
// handoff 에서 제안된 next-action 들을 큐에 자동 저장 (중복 방지:
|
|
13783
|
+
// handoff 에서 제안된 next-action 들을 큐에 자동 저장 (중복 방지: 안정된 actionKey 는 갱신)
|
|
13690
13784
|
// 1.36.108 (T-0097): 읽기~쓰기를 락으로 직렬화. 이 큐는 handoff 가 자동으로도 채우기 때문에
|
|
13691
13785
|
// 사용자가 `next-action add` 하는 순간 handoff 가 겹칠 수 있다 — 실사용에서 가장 겹치기 쉬운 조합이다.
|
|
13786
|
+
function _normalizeSuggestedAction(action) {
|
|
13787
|
+
const a = { ...(action || {}) };
|
|
13788
|
+
// 1.36.169 (Codex 교차검수 P2): 예전 생성기는 존재하지 않는 --filter를 안내했다.
|
|
13789
|
+
// 이미 큐에 들어간 항목도 다음 enqueue 때 실행 가능한 현재 CLI 문법으로 마이그레이션한다.
|
|
13790
|
+
if (a.icon === '💭' && /^leerness decision list --filter\s+/.test(String(a.command || ''))) {
|
|
13791
|
+
a.command = String(a.command).replace('decision list --filter', 'decision list --query');
|
|
13792
|
+
}
|
|
13793
|
+
if (a.icon === '🎯' && /^leerness plan list --filter\s+/.test(String(a.command || ''))) {
|
|
13794
|
+
a.command = 'leerness plan list --path .';
|
|
13795
|
+
}
|
|
13796
|
+
const plan = String(a.title || '').match(/plan\.md milestone (M-\d{4,})/);
|
|
13797
|
+
if (a.icon === '🎯' && plan) a.actionKey = `plan:${plan[1]}`;
|
|
13798
|
+
const decision = String(a.command || '').match(/decision list --(?:query|filter)\s+"([^"]+)"/)
|
|
13799
|
+
|| String(a.title || '').match(/decisions\.md "([^"]+)"/);
|
|
13800
|
+
if (a.icon === '💭' && decision) a.actionKey = `decision:${decision[1]}`;
|
|
13801
|
+
const lesson = String(a.title || '').match(/lessons\.md 에 "([^"]+)" 관련/);
|
|
13802
|
+
if (a.icon === '🛡' && lesson) a.actionKey = `lessons:${lesson[1]}`;
|
|
13803
|
+
// 패키지를 적용한 일반 프로젝트에는 leerness 자체의 scripts/e2e.js가 없다. 이전 제안도
|
|
13804
|
+
// 업그레이드 직후 list/take에서 바로 실행 가능한 공개 CLI로 바꾼다.
|
|
13805
|
+
if (a.icon === '🧪' && (/review-evidence\.md/.test(String(a.title || ''))
|
|
13806
|
+
|| String(a.command || '') === 'node ./scripts/e2e.js')) {
|
|
13807
|
+
a.command = 'leerness verify-code .';
|
|
13808
|
+
a.actionKey = 'verification:evidence';
|
|
13809
|
+
}
|
|
13810
|
+
// 경과 시간이 제목에 들어가면 24h/25h가 서로 다른 큐 항목이 된다. 상태를 단정해 done으로
|
|
13811
|
+
// 바꾸는 명령 대신 현재 task 목록을 확인하는 안전한 명령을 안내한다.
|
|
13812
|
+
const stalled = String(a.title || '').match(/^progress-tracker(?: \d+h)? 정체 — task (T-\d{4,}) (?:status 갱신|상태 검토)$/);
|
|
13813
|
+
if (a.icon === '🔄' && stalled) {
|
|
13814
|
+
a.title = `progress-tracker 정체 — task ${stalled[1]} 상태 검토`;
|
|
13815
|
+
a.command = 'leerness task list --path .';
|
|
13816
|
+
a.actionKey = `task-status:${stalled[1]}`;
|
|
13817
|
+
}
|
|
13818
|
+
if (a.icon === '📝' && /버전 변동 task — CHANGELOG\.md \/ README\.md 갱신 확인/.test(String(a.title || ''))) {
|
|
13819
|
+
a.actionKey = 'release-docs';
|
|
13820
|
+
}
|
|
13821
|
+
// 시간값이 제목에 들어가 매 handoff마다 같은 제안이 새 항목으로 쌓였고, command는 아직 만들지
|
|
13822
|
+
// 않은 stress-v(N+1)를 실행해 항상 ENOENT였다. 작업 제안은 유지하되 안정된 제목으로 합치고
|
|
13823
|
+
// 실행 명령은 싣지 않는다(next-action take가 이 제목으로 추적 task를 만든다).
|
|
13824
|
+
const stress = String(a.title || '').match(/^stress-v(\d+) (?:마지막 갱신 \d+h 전 — |오래됨 — )새 stress test 작성 권장$/);
|
|
13825
|
+
if (a.icon === '🧬' && stress) {
|
|
13826
|
+
a.title = `stress-v${stress[1]} 오래됨 — 새 stress test 작성 권장`;
|
|
13827
|
+
delete a.command;
|
|
13828
|
+
a.actionKey = 'stress-refresh';
|
|
13829
|
+
}
|
|
13830
|
+
return a;
|
|
13831
|
+
}
|
|
13832
|
+
function _nextActionIdentity(action) {
|
|
13833
|
+
const key = String(action && action.actionKey || '').trim();
|
|
13834
|
+
if (key) return `key:${key}`;
|
|
13835
|
+
return `title:${String(action && action.icon || '')}\u0000${String(action && action.title || '')}`;
|
|
13836
|
+
}
|
|
13837
|
+
function _compactNextActionQueue(queue) {
|
|
13838
|
+
const compacted = [];
|
|
13839
|
+
const seen = new Set();
|
|
13840
|
+
// 최신 항목을 보존한다. stress 버전/경과 시간처럼 표시값이 달라도 actionKey가 같으면 하나다.
|
|
13841
|
+
for (let i = (Array.isArray(queue) ? queue.length : 0) - 1; i >= 0; i--) {
|
|
13842
|
+
const action = _normalizeSuggestedAction(queue[i]);
|
|
13843
|
+
const identity = _nextActionIdentity(action);
|
|
13844
|
+
if (seen.has(identity)) continue;
|
|
13845
|
+
seen.add(identity);
|
|
13846
|
+
compacted.unshift(action);
|
|
13847
|
+
}
|
|
13848
|
+
return compacted;
|
|
13849
|
+
}
|
|
13850
|
+
function _normalizedNextActionState(root) {
|
|
13851
|
+
const state = _loadNextActionQueue(root);
|
|
13852
|
+
return { ...state, queue: _compactNextActionQueue(state.queue) };
|
|
13853
|
+
}
|
|
13692
13854
|
function _enqueueNextActions(root, actions) {
|
|
13693
|
-
if (!Array.isArray(actions)
|
|
13855
|
+
if (!Array.isArray(actions)) return 0;
|
|
13694
13856
|
return _withLock(_nextActionQueuePath(root), () => {
|
|
13695
13857
|
const state = _loadNextActionQueue(root);
|
|
13696
|
-
const
|
|
13858
|
+
const before = JSON.stringify(state.queue);
|
|
13859
|
+
state.queue = _compactNextActionQueue(state.queue);
|
|
13860
|
+
const existing = new Map(state.queue.map((a, i) => [_nextActionIdentity(a), i]));
|
|
13697
13861
|
let added = 0;
|
|
13698
|
-
for (const
|
|
13699
|
-
|
|
13700
|
-
|
|
13701
|
-
|
|
13702
|
-
|
|
13862
|
+
for (const raw of actions) {
|
|
13863
|
+
const a = _normalizeSuggestedAction(raw);
|
|
13864
|
+
const identity = _nextActionIdentity(a);
|
|
13865
|
+
if (existing.has(identity)) {
|
|
13866
|
+
const i = existing.get(identity);
|
|
13867
|
+
const previous = state.queue[i];
|
|
13868
|
+
// 새 관측값(최신 stress 버전/결정 hit 수)은 같은 위치에서 갱신하되 최초 추가 시각은 보존한다.
|
|
13869
|
+
state.queue[i] = { ...previous, ...a, addedAt: previous.addedAt || a.addedAt || new Date().toISOString() };
|
|
13870
|
+
} else {
|
|
13871
|
+
state.queue.push({ ...a, addedAt: new Date().toISOString() });
|
|
13872
|
+
existing.set(identity, state.queue.length - 1);
|
|
13873
|
+
added++;
|
|
13874
|
+
}
|
|
13703
13875
|
}
|
|
13704
13876
|
// 최근 20개만 유지
|
|
13705
13877
|
if (state.queue.length > 20) state.queue = state.queue.slice(-20);
|
|
13706
|
-
if (added > 0) _writeNextActionQueue(root, state.queue);
|
|
13878
|
+
if (added > 0 || before !== JSON.stringify(state.queue)) _writeNextActionQueue(root, state.queue);
|
|
13707
13879
|
return added;
|
|
13708
13880
|
});
|
|
13709
13881
|
}
|
|
@@ -13711,8 +13883,9 @@ function _enqueueNextActions(root, actions) {
|
|
|
13711
13883
|
// 1.9.201: next-action CLI 명령 — handoff 가 저장한 queue 에서 take/list/clear
|
|
13712
13884
|
async function nextActionCmd(root, sub, ...rest) {
|
|
13713
13885
|
root = absRoot(root);
|
|
13714
|
-
const state = _loadNextActionQueue(root);
|
|
13715
13886
|
if (!sub || sub === 'list') {
|
|
13887
|
+
// 조회 자체는 쓰지 않지만, 업그레이드 직후에도 legacy 명령/중복을 노출하지 않는다.
|
|
13888
|
+
const state = _normalizedNextActionState(root);
|
|
13716
13889
|
if (has('--json')) { log(JSON.stringify(state, null, 2)); return; }
|
|
13717
13890
|
log(`# leerness next-action list (1.9.201)`);
|
|
13718
13891
|
if (state.queue.length === 0) {
|
|
@@ -13732,28 +13905,41 @@ async function nextActionCmd(root, sub, ...rest) {
|
|
|
13732
13905
|
return;
|
|
13733
13906
|
}
|
|
13734
13907
|
if (sub === 'take') {
|
|
13735
|
-
|
|
13736
|
-
|
|
13737
|
-
|
|
13738
|
-
|
|
13739
|
-
|
|
13740
|
-
|
|
13741
|
-
|
|
13908
|
+
let taken;
|
|
13909
|
+
try {
|
|
13910
|
+
// 1.36.169 (Codex 재검수 P2): 선택을 락 밖에서 하고 제목으로 다시 찾으면, 그 사이 legacy
|
|
13911
|
+
// 정규화가 제목을 바꿨을 때 task는 만들면서 큐에는 항목을 남겼다. 정규화·선택·제거를 한
|
|
13912
|
+
// canonical queue transaction으로 묶고 index도 그 스냅샷에서 해석한다.
|
|
13913
|
+
taken = _withLock(_nextActionQueuePath(root), () => {
|
|
13914
|
+
const fresh = _loadNextActionQueue(root);
|
|
13915
|
+
const queue = _compactNextActionQueue(fresh.queue);
|
|
13916
|
+
// 1.36.114: 로더의 손상→빈 배열 fallback과 실제 빈 큐를 판정 지점에서 구분한다.
|
|
13917
|
+
if (queue.length === 0) {
|
|
13918
|
+
_assertStoreParsable(_nextActionQueuePath(root), 'next-action-queue');
|
|
13919
|
+
return { error: 'empty' };
|
|
13920
|
+
}
|
|
13921
|
+
const n = rest[0] !== undefined ? Number(rest[0]) : queue.length - 1;
|
|
13922
|
+
if (isNaN(n) || n < 0 || n >= queue.length) return { error: 'index', n, max: queue.length - 1 };
|
|
13923
|
+
const action = queue[n];
|
|
13924
|
+
queue.splice(n, 1);
|
|
13925
|
+
if (!_writeNextActionQueue(root, queue)) throw new Error('next-action 큐 저장 실패 — task를 만들지 않습니다');
|
|
13926
|
+
return { action, n };
|
|
13927
|
+
});
|
|
13928
|
+
} catch (e) {
|
|
13929
|
+
if (e && e.code === 'E_STORE_CORRUPT') throw e;
|
|
13930
|
+
fail(`next-action take 실패: ${_lineSafe(e && e.message ? e.message : e)}`);
|
|
13931
|
+
process.exitCode = 1;
|
|
13932
|
+
return;
|
|
13933
|
+
}
|
|
13934
|
+
if (taken.error === 'empty') { fail('큐 비어있음 — handoff 먼저 실행'); process.exitCode = 1; return; }
|
|
13935
|
+
if (taken.error === 'index') { fail(`잘못된 index: ${taken.n} (0~${taken.max})`); process.exitCode = 1; return; }
|
|
13936
|
+
const { action, n } = taken;
|
|
13742
13937
|
log(`# leerness next-action take [${n}] (1.9.201)`);
|
|
13743
13938
|
log(` ${action.icon || '•'} ${_lineSafe(action.title)}`);
|
|
13744
13939
|
if (action.command) log(` \`${_lineSafe(action.command)}\``);
|
|
13745
13940
|
// task add 자동 호출
|
|
13746
13941
|
try {
|
|
13747
13942
|
const taskTitle = action.title.replace(/^[^\w가-힣]+/, '').slice(0, 100);
|
|
13748
|
-
// 큐에서 제거 → write
|
|
13749
|
-
// 1.36.108 (T-0097): 락 안에서 **다시 읽고** 제거한다. 함수 머리에서 읽은 state 로 지우면
|
|
13750
|
-
// 그 사이에 handoff 가 큐에 넣은 항목이 통째로 사라진다(handoff 는 자동으로 이 큐를 채운다).
|
|
13751
|
-
_withLock(_nextActionQueuePath(root), () => {
|
|
13752
|
-
const fresh = _loadNextActionQueue(root);
|
|
13753
|
-
const idx = fresh.queue.findIndex(a => a && a.title === action.title);
|
|
13754
|
-
if (idx >= 0) fresh.queue.splice(idx, 1);
|
|
13755
|
-
_writeNextActionQueue(root, fresh.queue);
|
|
13756
|
-
});
|
|
13757
13943
|
// leerness task add 호출
|
|
13758
13944
|
const taskResult = cp.spawnSync(process.execPath, [__filename, 'task', 'add', taskTitle, '--path', root], { encoding: 'utf8', timeout: 8000, env: { ...process.env, LEERNESS_INTERNAL: '1' } });
|
|
13759
13945
|
if (taskResult.status === 0) {
|
|
@@ -13773,8 +13959,12 @@ async function nextActionCmd(root, sub, ...rest) {
|
|
|
13773
13959
|
return;
|
|
13774
13960
|
}
|
|
13775
13961
|
if (sub === 'clear') {
|
|
13776
|
-
|
|
13777
|
-
|
|
13962
|
+
const removed = _withLock(_nextActionQueuePath(root), () => {
|
|
13963
|
+
const fresh = _loadNextActionQueue(root);
|
|
13964
|
+
if (!_writeNextActionQueue(root, [])) throw new Error('next-action 큐 저장 실패');
|
|
13965
|
+
return fresh.queue.length;
|
|
13966
|
+
});
|
|
13967
|
+
ok(`next-action 큐 초기화 (${removed}건 제거)`);
|
|
13778
13968
|
return;
|
|
13779
13969
|
}
|
|
13780
13970
|
if (sub === 'add') {
|
|
@@ -13811,7 +14001,7 @@ function _suggestNextActions(root, latestRow, keyword) {
|
|
|
13811
14001
|
const blocks = lt.split(/\n### /);
|
|
13812
14002
|
const hits = blocks.filter(b => fuzzyRe.test(b) && /(실패|fail|롤백|버그|incomplete)/i.test(b));
|
|
13813
14003
|
if (hits.length > 0) {
|
|
13814
|
-
actions.push({ icon: '🛡', title: `과거 실패 회피 — lessons.md 에 "${keyword}" 관련 ${hits.length}건`, command: `leerness lessons --auto --path .` });
|
|
14004
|
+
actions.push({ actionKey: `lessons:${keyword}`, icon: '🛡', title: `과거 실패 회피 — lessons.md 에 "${keyword}" 관련 ${hits.length}건`, command: `leerness lessons --auto --path .` });
|
|
13815
14005
|
}
|
|
13816
14006
|
}
|
|
13817
14007
|
} catch {}
|
|
@@ -13823,7 +14013,7 @@ function _suggestNextActions(root, latestRow, keyword) {
|
|
|
13823
14013
|
const milestones = (pt.match(/^### (M-\d{4,})\..*$/gm) || []).filter(l => fuzzyRe.test(l));
|
|
13824
14014
|
if (milestones.length > 0) {
|
|
13825
14015
|
const m = milestones[0].match(/M-\d{4,}/);
|
|
13826
|
-
if (m) actions.push({ icon: '🎯', title: `plan.md milestone ${m[0]} 검증 — "${keyword}" 관련`, command:
|
|
14016
|
+
if (m) actions.push({ actionKey: `plan:${m[0]}`, icon: '🎯', title: `plan.md milestone ${m[0]} 검증 — "${keyword}" 관련`, command: 'leerness plan list --path .' });
|
|
13827
14017
|
}
|
|
13828
14018
|
}
|
|
13829
14019
|
} catch {}
|
|
@@ -13835,7 +14025,7 @@ function _suggestNextActions(root, latestRow, keyword) {
|
|
|
13835
14025
|
const blocks = _extractDecisionBlocks(dt);
|
|
13836
14026
|
const hits = blocks.filter(b => fuzzyRe.test(b));
|
|
13837
14027
|
if (hits.length > 0) {
|
|
13838
|
-
actions.push({ icon: '💭', title: `decisions.md "${keyword}" 관련 결정 ${hits.length}건 영향 확인`, command: `leerness decision list --
|
|
14028
|
+
actions.push({ actionKey: `decision:${keyword}`, icon: '💭', title: `decisions.md "${keyword}" 관련 결정 ${hits.length}건 영향 확인`, command: `leerness decision list --query "${keyword}"` });
|
|
13839
14029
|
}
|
|
13840
14030
|
}
|
|
13841
14031
|
} catch {}
|
|
@@ -13843,7 +14033,7 @@ function _suggestNextActions(root, latestRow, keyword) {
|
|
|
13843
14033
|
// 4) review-evidence.md 부재 → e2e
|
|
13844
14034
|
try {
|
|
13845
14035
|
if (!exists(evidencePath(root)) || fs.statSync(evidencePath(root)).size < 100) {
|
|
13846
|
-
actions.push({ icon: '🧪', title: `review-evidence.md 비어있음 —
|
|
14036
|
+
actions.push({ actionKey: 'verification:evidence', icon: '🧪', title: `review-evidence.md 비어있음 — verify-code 실행`, command: `leerness verify-code .` });
|
|
13847
14037
|
}
|
|
13848
14038
|
} catch {}
|
|
13849
14039
|
|
|
@@ -13853,7 +14043,7 @@ function _suggestNextActions(root, latestRow, keyword) {
|
|
|
13853
14043
|
if (exists(pp)) {
|
|
13854
14044
|
const ageHours = Math.floor((Date.now() - fs.statSync(pp).mtimeMs) / 3600000);
|
|
13855
14045
|
if (ageHours >= 24 && latestRow.id) {
|
|
13856
|
-
actions.push({ icon: '🔄', title: `progress-tracker
|
|
14046
|
+
actions.push({ actionKey: `task-status:${latestRow.id}`, icon: '🔄', title: `progress-tracker 정체 — task ${latestRow.id} 상태 검토`, command: `leerness task list --path .` });
|
|
13857
14047
|
}
|
|
13858
14048
|
}
|
|
13859
14049
|
} catch {}
|
|
@@ -13861,7 +14051,7 @@ function _suggestNextActions(root, latestRow, keyword) {
|
|
|
13861
14051
|
// 6) CHANGELOG / README 버전 일치 확인 (1.9.X bump 후 자주 누락되는 단계)
|
|
13862
14052
|
try {
|
|
13863
14053
|
if (/(\d\.\d\.\d+)/.test(taskText) || /(bump|version|release)/i.test(taskText)) {
|
|
13864
|
-
actions.push({ icon: '📝', title: `버전 변동 task — CHANGELOG.md / README.md 갱신 확인`, command: `leerness whats-new .` });
|
|
14054
|
+
actions.push({ actionKey: 'release-docs', icon: '📝', title: `버전 변동 task — CHANGELOG.md / README.md 갱신 확인`, command: `leerness whats-new .` });
|
|
13865
14055
|
}
|
|
13866
14056
|
} catch {}
|
|
13867
14057
|
|
|
@@ -13877,16 +14067,16 @@ function _suggestNextActions(root, latestRow, keyword) {
|
|
|
13877
14067
|
const latestFile = path.join(stressDir, `stress-v${latest}.js`);
|
|
13878
14068
|
const ageHours = Math.floor((Date.now() - fs.statSync(latestFile).mtimeMs) / 3600000);
|
|
13879
14069
|
if (ageHours >= 24 && /(bump|version|보강|추가|기능)/.test(taskText)) {
|
|
13880
|
-
actions.push({ icon: '🧬', title: `stress-v${latest}
|
|
14070
|
+
actions.push({ actionKey: 'stress-refresh', icon: '🧬', title: `stress-v${latest} 오래됨 — 새 stress test 작성 권장` });
|
|
13881
14071
|
}
|
|
13882
14072
|
}
|
|
13883
14073
|
}
|
|
13884
14074
|
} catch {}
|
|
13885
14075
|
|
|
13886
|
-
// 중복 제거 (
|
|
14076
|
+
// 중복 제거 (시간/버전과 무관한 actionKey 기준) + top 3
|
|
13887
14077
|
const seen = new Set();
|
|
13888
14078
|
return actions.filter(a => {
|
|
13889
|
-
const key = a
|
|
14079
|
+
const key = _nextActionIdentity(a);
|
|
13890
14080
|
if (seen.has(key)) return false;
|
|
13891
14081
|
seen.add(key);
|
|
13892
14082
|
return true;
|
|
@@ -14207,10 +14397,20 @@ function upsertProgress(root, row) {
|
|
|
14207
14397
|
}
|
|
14208
14398
|
|
|
14209
14399
|
function planShow(root) { const p = planPath(root); const has_ = exists(p); const content = has_ ? read(p) : ''; if (has('--json')) { const milestones = (content.match(/^### (M-\d{4,})\b.*$/gm) || []).map(l => l.replace(/^###\s*/, '').trim()); log(JSON.stringify({ exists: has_, milestones, raw: content }, null, 2)); return; } log(has_ ? content : 'plan.md not found'); } // 1.9.428 (UR-0128): plan show --json 구조화
|
|
14210
|
-
// plan.md
|
|
14211
|
-
// plan
|
|
14212
|
-
function
|
|
14213
|
-
function
|
|
14400
|
+
// plan.md 단독 RMW의 canonical lock. plan/progress를 함께 바꾸는 경로는 항상
|
|
14401
|
+
// progress → plan 순서로 잡아 역순 교착과 서로 다른 락 아래의 lost-update를 함께 막는다.
|
|
14402
|
+
function _withPlanLock(root, fn) { return _withLock(planPath(root), fn); }
|
|
14403
|
+
function _withPlanTransaction(root, fn) {
|
|
14404
|
+
return _withLock(progressPath(root), () => _withPlanLock(root, fn));
|
|
14405
|
+
}
|
|
14406
|
+
function planInit(root) {
|
|
14407
|
+
const goal = arg('--goal', '');
|
|
14408
|
+
if (!exists(planPath(root))) return install(root);
|
|
14409
|
+
// 1.36.169 (T-0108): plan add/drop/remove/restore와 같은 plan.md 락을 쓴다.
|
|
14410
|
+
// 종전 append는 성공했어도 동시에 진행된 plan remove의 낡은 전체파일 rename에 지워졌다.
|
|
14411
|
+
_withPlanLock(root, () => append(planPath(root), `\n## User Goal\n- ${goal || '사용자 목적을 작성하세요.'}\n`));
|
|
14412
|
+
ok('plan goal appended');
|
|
14413
|
+
}
|
|
14214
14414
|
// 1.9.119: plan list — plan.md 의 모든 milestone (M-XXXX) 조회 (CLI + --json + MCP)
|
|
14215
14415
|
function planListCmd(root, opts = {}) {
|
|
14216
14416
|
root = absRoot(root);
|
|
@@ -14895,10 +15095,11 @@ function memoryRestoreCmd(root, surface, target) {
|
|
|
14895
15095
|
: surface === 'lessons' ? lessonsPath(root)
|
|
14896
15096
|
: planPath(root);
|
|
14897
15097
|
// 1.36.152 (T-0115): restore는 active canonical store와 archive를 함께 RMW한다.
|
|
14898
|
-
//
|
|
15098
|
+
// 1.36.169 (T-0108): plan 전용 경로는 progress의 대리 락이 아니라 plan.md canonical 락을 쓴다.
|
|
15099
|
+
// plan+progress 동시 변경은 _withPlanTransaction이 progress → plan 순서로 같은 락에 합류한다.
|
|
14899
15100
|
const memoryLockPath = surface === 'decisions' ? decisionsJsonPath(root)
|
|
14900
15101
|
: surface === 'lessons' ? lessonsJsonPath(root)
|
|
14901
|
-
:
|
|
15102
|
+
: planPath(root);
|
|
14902
15103
|
const restoredCount = _withLock(memoryLockPath, () => {
|
|
14903
15104
|
if (!exists(archivePath)) return fail(`${surface}.archive.md 없음 — 복원할 항목 없음`);
|
|
14904
15105
|
// 1.36.76 (9차 헌트 #3 P1) → (검수 #2): 잘린/깨진 UTF-8 아카이브가 손상 데이터를 active 로 유입시키던 것.
|
|
@@ -15378,7 +15579,7 @@ function verify(root) {
|
|
|
15378
15579
|
// harness 는 deps(VERSION·canonical 메모리 함수·카탈로그·compareVer·harness 경로)를 1회 구성해 위임(thin wrapper). 호출부/동작 무변경.
|
|
15379
15580
|
const _migrate = require('../lib/migrate');
|
|
15380
15581
|
function _migrateDeps() {
|
|
15381
|
-
return { VERSION, compareVer, REQUIRED_WORKSPACE_FILES, decisionsPath, decisionsJsonPath, lessonsPath, lessonsJsonPath, _loadDecisions, _saveDecisions, _loadLessons, _saveLessons, harnessPath: __filename };
|
|
15582
|
+
return { VERSION, compareVer, REQUIRED_WORKSPACE_FILES, decisionsPath, decisionsJsonPath, lessonsPath, lessonsJsonPath, _loadDecisions, _saveDecisions, _loadLessons, _saveLessons, _withLock, harnessPath: __filename };
|
|
15382
15583
|
}
|
|
15383
15584
|
function migrateAuditCmd(root, opts = {}) { return _migrate.migrateAuditCmd(root, opts, _migrateDeps()); }
|
|
15384
15585
|
function migrateApplyCmd(root, opts = {}) { return _migrate.migrateApplyCmd(root, opts, _migrateDeps()); }
|
|
@@ -17170,6 +17371,8 @@ function handoff(root) {
|
|
|
17170
17371
|
if (!has('--no-next-actions') && !has('--quiet') && process.env.LEERNESS_NO_NEXT_ACTIONS !== '1') {
|
|
17171
17372
|
try {
|
|
17172
17373
|
const actions = _suggestNextActions(root, latestRow, keyword);
|
|
17374
|
+
// 새 제안이 없어도 legacy queue의 잘못된 명령/시간가변 중복은 정규화한다.
|
|
17375
|
+
const added = _enqueueNextActions(root, actions);
|
|
17173
17376
|
if (actions.length > 0) {
|
|
17174
17377
|
const isTty = process.stdout && process.stdout.isTTY;
|
|
17175
17378
|
const grn = s => isTty ? `\x1b[32m${s}\x1b[0m` : s;
|
|
@@ -17183,10 +17386,7 @@ function handoff(root) {
|
|
|
17183
17386
|
}
|
|
17184
17387
|
}
|
|
17185
17388
|
// 1.9.201: queue 자동 저장 — `leerness next-action take` 로 즉시 task add 가능 (토글과 무관: 상태다)
|
|
17186
|
-
|
|
17187
|
-
const added = _enqueueNextActions(root, actions);
|
|
17188
|
-
if (added > 0 && _showAdvice) log(dim(` → 즉시 task add: leerness next-action take (큐 +${added}건 저장됨, 1.9.201)`));
|
|
17189
|
-
} catch {}
|
|
17389
|
+
if (added > 0 && _showAdvice) log(dim(` → 즉시 task add: leerness next-action take (큐 +${added}건 저장됨, 1.9.201)`));
|
|
17190
17390
|
if (_showAdvice) { log(dim(` → 직접 입력: leerness next-action add "<text>"`)); log(''); }
|
|
17191
17391
|
}
|
|
17192
17392
|
} catch {}
|
|
@@ -22581,6 +22781,15 @@ function captureProjectState(root) {
|
|
|
22581
22781
|
|
|
22582
22782
|
function verifyRules(root) {
|
|
22583
22783
|
root = absRoot(root);
|
|
22784
|
+
// 활성 룰이 없으면 읽기 전용으로 끝낸다. cache 디렉터리를 잠금 부작용만으로 만들지 않는다.
|
|
22785
|
+
if (!readRules(root).some(r => r.status === 'active')) return [];
|
|
22786
|
+
// 1.36.169 (T-0108): baseline read → project capture → rule merge → baseline write를 한
|
|
22787
|
+
// cache transaction으로 직렬화한다. 종전에는 오래된 verify가 늦게 끝나 최신 baseline을
|
|
22788
|
+
// 되돌려 다음 세션이 이미 수행한 version/changelog 변경을 다시 pass로 오인할 수 있었다.
|
|
22789
|
+
return _withLock(rulesCachePath(root), () => _verifyRulesLocked(root));
|
|
22790
|
+
}
|
|
22791
|
+
|
|
22792
|
+
function _verifyRulesLocked(root) {
|
|
22584
22793
|
const rules = readRules(root);
|
|
22585
22794
|
const active = rules.filter(r => r.status === 'active');
|
|
22586
22795
|
if (!active.length) return [];
|
|
@@ -27103,7 +27312,8 @@ function contextCmd(root, opts = {}) {
|
|
|
27103
27312
|
const _decs = _loadDecisions(root);
|
|
27104
27313
|
const decisionCount = _decs.length;
|
|
27105
27314
|
const recentDecisions = _decs.slice(-3).reverse().map(d => ({ date: d.date || null, title: (d.title || '').slice(0, 100) }));
|
|
27106
|
-
|
|
27315
|
+
// 외부 에이전트 context도 next-action list/take와 같은 정규화 계약을 공유한다.
|
|
27316
|
+
const queueState = _normalizedNextActionState(root);
|
|
27107
27317
|
const nextActions = (queueState.queue || []).slice(-3).reverse().map(a => ({ title: a.title, command: a.command || null }));
|
|
27108
27318
|
const memory = {
|
|
27109
27319
|
tasksInProgress: rows.filter(r => r.status === 'in-progress').length,
|
package/lib/migrate.js
CHANGED
|
@@ -38,19 +38,41 @@ function migrateAuditCmd(root, opts = {}, deps = {}) {
|
|
|
38
38
|
// UR-0075 Phase C (1.9.357): migrate apply — audit가 찾은 '안전 항목'(canonical 백필)만 비파괴 적용.
|
|
39
39
|
// 기본 dry-run(변경 없음) · --yes 로 실제 적용. version-drift/missing-file 은 apply 범위 외(수동 안내).
|
|
40
40
|
function migrateApplyCmd(root, opts = {}, deps = {}) {
|
|
41
|
-
const { VERSION, compareVer, REQUIRED_WORKSPACE_FILES, decisionsPath, decisionsJsonPath, lessonsPath, lessonsJsonPath, _loadDecisions, _saveDecisions, _loadLessons, _saveLessons } = deps;
|
|
41
|
+
const { VERSION, compareVer, REQUIRED_WORKSPACE_FILES, decisionsPath, decisionsJsonPath, lessonsPath, lessonsJsonPath, _loadDecisions, _saveDecisions, _loadLessons, _saveLessons, _withLock } = deps;
|
|
42
42
|
root = absRoot(root);
|
|
43
43
|
const apply = !!opts.yes;
|
|
44
|
+
if (apply && typeof _withLock !== 'function') throw new Error('migrate apply requires the canonical store lock dependency');
|
|
45
|
+
const withLock = typeof _withLock === 'function' ? _withLock : (_target, fn) => fn();
|
|
44
46
|
const applied = []; // 안전하게 적용 가능(또는 dry-run 예정): canonical 백필
|
|
45
47
|
const skipped = []; // apply 범위 외: 수동 조치 필요
|
|
46
48
|
// canonical-pending: MD 항목 존재 + JSON 부재 → load(MD 백필)→save(canonical JSON + MD 정규화). idempotent(UR-0053).
|
|
47
49
|
if (exists(decisionsPath(root)) && !exists(decisionsJsonPath(root)) && _loadDecisions(root).length > 0) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
let changed = !apply;
|
|
51
|
+
if (apply) {
|
|
52
|
+
// 1.36.169 (T-0108): exists/load/save 전체가 decision add/drop/restore와 같은 락 안이어야 한다.
|
|
53
|
+
// 바깥에서 읽은 legacy snapshot을 락 획득 뒤 쓰면 그 사이 추가된 결정을 지울 수 있다.
|
|
54
|
+
changed = withLock(decisionsJsonPath(root), () => {
|
|
55
|
+
if (!exists(decisionsPath(root)) || exists(decisionsJsonPath(root))) return false;
|
|
56
|
+
const current = _loadDecisions(root);
|
|
57
|
+
if (!current.length) return false;
|
|
58
|
+
_saveDecisions(root, current);
|
|
59
|
+
return true;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (changed) applied.push({ kind: 'canonical-backfill', detail: 'decisions.md → decisions.json' });
|
|
50
63
|
}
|
|
51
64
|
if (exists(lessonsPath(root)) && !exists(lessonsJsonPath(root)) && _loadLessons(root).length > 0) {
|
|
52
|
-
|
|
53
|
-
|
|
65
|
+
let changed = !apply;
|
|
66
|
+
if (apply) {
|
|
67
|
+
changed = withLock(lessonsJsonPath(root), () => {
|
|
68
|
+
if (!exists(lessonsPath(root)) || exists(lessonsJsonPath(root))) return false;
|
|
69
|
+
const current = _loadLessons(root);
|
|
70
|
+
if (!current.length) return false;
|
|
71
|
+
_saveLessons(root, current);
|
|
72
|
+
return true;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (changed) applied.push({ kind: 'canonical-backfill', detail: 'lessons.md → lessons.json' });
|
|
54
76
|
}
|
|
55
77
|
// version-drift / 누락 파일 — apply 가 안전하게 in-place 처리 불가(npm 재설치/재초기화 필요) → 수동 안내.
|
|
56
78
|
const hvPath = path.join(root, '.leerness', 'HARNESS_VERSION');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leerness",
|
|
3
|
-
"version": "1.36.
|
|
3
|
+
"version": "1.36.170",
|
|
4
4
|
"description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"leerness",
|
|
@@ -50,7 +50,8 @@
|
|
|
50
50
|
"scripts": {
|
|
51
51
|
"test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js",
|
|
52
52
|
"test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/handoff-readonly-probe.js",
|
|
53
|
-
"test:commands": "node ./scripts/command-flags-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js",
|
|
53
|
+
"test:commands": "node ./scripts/command-flags-probe.js && node ./scripts/next-action-suggestion-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js",
|
|
54
|
+
"test:next-actions": "node ./scripts/next-action-suggestion-probe.js",
|
|
54
55
|
"test:installed": "node ./scripts/installed-cleanroom-probe.js",
|
|
55
56
|
"test:handoff": "node ./scripts/handoff-readonly-probe.js",
|
|
56
57
|
"test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/lock-probe.js && node ./scripts/platform-smoke.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/smoke.js",
|
|
@@ -44,8 +44,11 @@ function spawn(d, args, stallTarget) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
function waitForStall(d, target) {
|
|
47
|
-
const
|
|
48
|
-
const
|
|
47
|
+
const normalized = target.replace(/\\/g, '/');
|
|
48
|
+
const targetDir = path.dirname(normalized) === '.' ? '' : path.dirname(normalized);
|
|
49
|
+
const targetName = path.basename(normalized);
|
|
50
|
+
const re = new RegExp('^' + targetName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\.tmp-');
|
|
51
|
+
const hd = path.join(d, '.leerness', targetDir);
|
|
49
52
|
for (let i = 0; i < 800; i++) {
|
|
50
53
|
try { if (fs.readdirSync(hd).some(name => re.test(name))) return true; } catch {}
|
|
51
54
|
nap(10);
|
|
@@ -60,6 +63,7 @@ async function race(name, config) {
|
|
|
60
63
|
if (config.seed) config.seed(d);
|
|
61
64
|
const first = spawn(d, config.first, config.stallTarget);
|
|
62
65
|
const stalled = waitForStall(d, config.stallTarget);
|
|
66
|
+
if (config.beforeSecond) config.beforeSecond(d);
|
|
63
67
|
const second = spawn(d, config.second);
|
|
64
68
|
const exits = await Promise.all([wait(first), wait(second)]);
|
|
65
69
|
const inspected = config.inspect(d);
|
|
@@ -132,6 +136,73 @@ async function race(name, config) {
|
|
|
132
136
|
return { archived: plan.includes('archived'), survivor: plan.includes('survivor'), preserved: plan.includes('archived') && plan.includes('survivor') };
|
|
133
137
|
}
|
|
134
138
|
}));
|
|
139
|
+
results.push(await race('plan-init-vs-remove', {
|
|
140
|
+
stallTarget: 'plan.md',
|
|
141
|
+
seed: d => run(d, ['plan', 'add', 'victim']),
|
|
142
|
+
first: ['plan', 'remove', 'victim'],
|
|
143
|
+
second: ['plan', 'init', '--goal', 'survivor-goal'],
|
|
144
|
+
inspect: d => {
|
|
145
|
+
const plan = fs.readFileSync(path.join(d, '.leerness', 'plan.md'), 'utf8');
|
|
146
|
+
return {
|
|
147
|
+
victimRemoved: !plan.includes('victim'),
|
|
148
|
+
survivorPreserved: plan.includes('survivor-goal'),
|
|
149
|
+
preserved: !plan.includes('victim') && plan.includes('survivor-goal')
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}));
|
|
153
|
+
results.push(await race('anchors-plan-vs-plan-add', {
|
|
154
|
+
stallTarget: 'plan.md',
|
|
155
|
+
seed: d => fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'probe', version: '1.0.0', description: 'coordination probe' })),
|
|
156
|
+
first: ['anchors', 'draft', '--apply'],
|
|
157
|
+
second: ['plan', 'add', 'survivor-milestone'],
|
|
158
|
+
inspect: d => {
|
|
159
|
+
const plan = fs.readFileSync(path.join(d, '.leerness', 'plan.md'), 'utf8');
|
|
160
|
+
return { survivorPreserved: plan.includes('survivor-milestone'), preserved: plan.includes('survivor-milestone') };
|
|
161
|
+
}
|
|
162
|
+
}));
|
|
163
|
+
results.push(await race('migrate-decisions-vs-add', {
|
|
164
|
+
stallTarget: 'decisions.json',
|
|
165
|
+
seed: d => {
|
|
166
|
+
run(d, ['decision', 'add', 'legacy-decision']);
|
|
167
|
+
fs.unlinkSync(path.join(d, '.leerness', 'decisions.json'));
|
|
168
|
+
},
|
|
169
|
+
first: ['migrate', 'apply', '--yes'],
|
|
170
|
+
second: ['decision', 'add', 'survivor-decision'],
|
|
171
|
+
inspect: d => {
|
|
172
|
+
const titles = JSON.parse(fs.readFileSync(path.join(d, '.leerness', 'decisions.json'), 'utf8')).map(item => item.title);
|
|
173
|
+
return { titles, preserved: titles.includes('legacy-decision') && titles.includes('survivor-decision') };
|
|
174
|
+
}
|
|
175
|
+
}));
|
|
176
|
+
results.push(await race('migrate-lessons-vs-save', {
|
|
177
|
+
stallTarget: 'lessons.json',
|
|
178
|
+
seed: d => {
|
|
179
|
+
run(d, ['lesson', 'save', 'legacy-lesson']);
|
|
180
|
+
fs.unlinkSync(path.join(d, '.leerness', 'lessons.json'));
|
|
181
|
+
},
|
|
182
|
+
first: ['migrate', 'apply', '--yes'],
|
|
183
|
+
second: ['lesson', 'save', 'survivor-lesson'],
|
|
184
|
+
inspect: d => {
|
|
185
|
+
const texts = JSON.parse(fs.readFileSync(path.join(d, '.leerness', 'lessons.json'), 'utf8')).map(item => item.text);
|
|
186
|
+
return { texts, preserved: texts.includes('legacy-lesson') && texts.includes('survivor-lesson') };
|
|
187
|
+
}
|
|
188
|
+
}));
|
|
189
|
+
results.push(await race('rule-cache-verify-vs-verify', {
|
|
190
|
+
stallTarget: 'cache/rule-state.json',
|
|
191
|
+
seed: d => {
|
|
192
|
+
const pkg = version => fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'probe', version }));
|
|
193
|
+
pkg('1.0.0');
|
|
194
|
+
run(d, ['rule', 'add', 'version bump required', '--trigger', 'every-update']);
|
|
195
|
+
run(d, ['rule', 'verify']);
|
|
196
|
+
pkg('1.0.1');
|
|
197
|
+
},
|
|
198
|
+
beforeSecond: d => fs.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'probe', version: '1.0.2' })),
|
|
199
|
+
first: ['rule', 'verify'],
|
|
200
|
+
second: ['rule', 'verify'],
|
|
201
|
+
inspect: d => {
|
|
202
|
+
const cached = JSON.parse(fs.readFileSync(path.join(d, '.leerness', 'cache', 'rule-state.json'), 'utf8'));
|
|
203
|
+
return { cachedVersion: cached.packageVersion, preserved: cached.packageVersion === '1.0.2' };
|
|
204
|
+
}
|
|
205
|
+
}));
|
|
135
206
|
const ok = results.every(result => result.ok);
|
|
136
207
|
console.log(JSON.stringify({ ok, results }));
|
|
137
208
|
if (!ok) process.exitCode = 1;
|
package/scripts/e2e.js
CHANGED
|
@@ -10688,7 +10688,10 @@ total++;
|
|
|
10688
10688
|
if (syncUrl) CASES.push(['provider sync', ['provider', 'sync', syncUrl]]);
|
|
10689
10689
|
dbg.syncCovered = !!syncUrl;
|
|
10690
10690
|
const offenders = [], failedCmds = [];
|
|
10691
|
-
|
|
10691
|
+
// 파일 → 모든 쓰기에서 공통으로 쥔 락 키의 교집합. 보조 락이 더해져도 canonical 락이
|
|
10692
|
+
// 공통이면 상호배제는 유지된다(예: progress → plan 순서의 2파일 transaction).
|
|
10693
|
+
const commonKeysByFile = {};
|
|
10694
|
+
const keySetsByFile = {};
|
|
10692
10695
|
let lockedSeen = 0, rows = 0;
|
|
10693
10696
|
for (const [label, argv, seed] of CASES) {
|
|
10694
10697
|
const d = path.join(sb, label.replace(/[^a-z0-9]+/gi, '_'));
|
|
@@ -10713,8 +10716,10 @@ total++;
|
|
|
10713
10716
|
byFile[l.file] = (l.file in byFile ? byFile[l.file] : true) && l.locked;
|
|
10714
10717
|
// 어느 락 키로 지켰는지 모은다 — 파일 자신의 락이 아니면 다른 명령과 서로 배제되지 않는다.
|
|
10715
10718
|
if (l.locked && !DERIVED.test(l.file) && !EXEMPT[l.file]) {
|
|
10716
|
-
|
|
10717
|
-
|
|
10719
|
+
const held = new Set(l.heldOn || []);
|
|
10720
|
+
(keySetsByFile[l.file] = keySetsByFile[l.file] || new Set()).add([...held].sort().join(' + ') || '(none)');
|
|
10721
|
+
if (!commonKeysByFile[l.file]) commonKeysByFile[l.file] = held;
|
|
10722
|
+
else commonKeysByFile[l.file] = new Set([...commonKeysByFile[l.file]].filter(k => held.has(k)));
|
|
10718
10723
|
}
|
|
10719
10724
|
}
|
|
10720
10725
|
for (const [f, lockedOk] of Object.entries(byFile)) {
|
|
@@ -10728,11 +10733,12 @@ total++;
|
|
|
10728
10733
|
// codex 검수 P2(재현됨): "락을 하나라도 쥐었나" 는 너무 약하다. 상호배제는 **같은 키**를 쥘 때만 성립한다.
|
|
10729
10734
|
// session close 는 session-handoff.md.lock 을 쥔 채 current-state.md 를 썼고 audit --fix 는
|
|
10730
10735
|
// current-state.md.lock 을 쥐었다 — 둘 다 '락 보유' 인 채로 audit 의 갱신이 덮였다.
|
|
10731
|
-
// 올바른 불변식은 "자기 이름의 락" 이 아니라 **"그 파일의 모든 쓰기가
|
|
10732
|
-
// (decisions.md 를 decisions.json.lock 아래에서 쓰는 것은
|
|
10736
|
+
// 올바른 불변식은 "자기 이름의 락" 이 아니라 **"그 파일의 모든 쓰기가 적어도 한 canonical 키를 공유한다"** 이다.
|
|
10737
|
+
// (decisions.md 를 decisions.json.lock 아래에서 쓰는 것은 정상이고, progress→plan transaction처럼
|
|
10738
|
+
// 보조 락이 더해진 쓰기도 공통 plan.md.lock/progress-tracker.md.lock가 남으면 정상이다.)
|
|
10733
10739
|
const wrongKey = [];
|
|
10734
|
-
for (const [f,
|
|
10735
|
-
if (
|
|
10740
|
+
for (const [f, common] of Object.entries(commonKeysByFile)) {
|
|
10741
|
+
if (common.size === 0) wrongKey.push(f + ' ← 공통 락 없음 (' + [...keySetsByFile[f]].sort().join(' / ') + ')');
|
|
10736
10742
|
}
|
|
10737
10743
|
dbg.wrongKey = wrongKey;
|
|
10738
10744
|
dbg.offenders = offenders;
|
|
@@ -10746,7 +10752,7 @@ total++;
|
|
|
10746
10752
|
ok = dbg.probeAlive && dbg.coverage && dbg.syncCovered && offenders.length === 0 && wrongKey.length === 0;
|
|
10747
10753
|
try { srv.kill(); } catch {}
|
|
10748
10754
|
} catch (e) { dbg.err = String(e && e.message).slice(0, 200); } finally { try { fs.rmSync(sb, { recursive: true, force: true }); } catch {} }
|
|
10749
|
-
console.log(ok ? `✓ L(1.36.108/T-0097) 사용자 상태 쓰기 ${dbg.rows}건 전부 락 안 +
|
|
10755
|
+
console.log(ok ? `✓ L(1.36.108/T-0097, 1.36.169/T-0108) 사용자 상태 쓰기 ${dbg.rows}건 전부 락 안 + 파일별 canonical 락 교집합 존재 (락 관측 ${dbg.lockedSeen}건 = 계측 살아있음 · 명령 실패 0)`
|
|
10750
10756
|
: '✗ 1.36.108 락 불변식 위반 ' + JSON.stringify(dbg));
|
|
10751
10757
|
if (!ok) failed++;
|
|
10752
10758
|
}
|
|
@@ -15527,8 +15533,8 @@ total++;
|
|
|
15527
15533
|
}
|
|
15528
15534
|
|
|
15529
15535
|
total++;
|
|
15530
|
-
// AO(1.36.152/T-0115) — write 시점의 락 관측만으로는 "읽은 사본도 락 안인가"를 증명할 수 없다.
|
|
15531
|
-
// 첫 프로세스의 최종 rename을 강제로 멈추고 두 번째 쓰기를 붙여, 서로 다른 RMW
|
|
15536
|
+
// AO(1.36.152/T-0115, 1.36.169/T-0108 확장) — write 시점의 락 관측만으로는 "읽은 사본도 락 안인가"를 증명할 수 없다.
|
|
15537
|
+
// 첫 프로세스의 최종 rename을 강제로 멈추고 두 번째 쓰기를 붙여, 서로 다른 RMW 11쌍이 모두 생존하는지 확인한다.
|
|
15532
15538
|
{
|
|
15533
15539
|
let ok = false; const dbg = {};
|
|
15534
15540
|
try {
|
|
@@ -15543,9 +15549,9 @@ total++;
|
|
|
15543
15549
|
dbg.exit = race.status; dbg.report = report;
|
|
15544
15550
|
if (!report) dbg.stderr = String(race.stderr || '').slice(0, 500);
|
|
15545
15551
|
ok = race.status === 0 && !!report && report.ok === true
|
|
15546
|
-
&& Array.isArray(report.results) && report.results.length ===
|
|
15552
|
+
&& Array.isArray(report.results) && report.results.length === 11 && report.results.every(r => r.ok === true);
|
|
15547
15553
|
} catch (e) { dbg.err = String(e && e.message || e).slice(0, 200); }
|
|
15548
|
-
console.log(ok ? '✓ AO(1.36.152/T-0115) 지연 경쟁
|
|
15554
|
+
console.log(ok ? '✓ AO(1.36.152/T-0115, 1.36.169/T-0108) 지연 경쟁 11쌍 모두 exit 0 + 양쪽 기록 보존'
|
|
15549
15555
|
: '✗ 1.36.152 동시 RMW 회귀 ' + JSON.stringify(dbg));
|
|
15550
15556
|
if (!ok) failed++;
|
|
15551
15557
|
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const cp = require('child_process');
|
|
8
|
+
|
|
9
|
+
const cliPath = path.join(__dirname, '..', 'bin', 'leerness.js');
|
|
10
|
+
const source = fs.readFileSync(cliPath, 'utf8');
|
|
11
|
+
const start = source.indexOf('function _suggestNextActions(');
|
|
12
|
+
const end = source.indexOf('function _autoInstallQueryFromHandoff(', start);
|
|
13
|
+
if (start < 0 || end <= start) {
|
|
14
|
+
console.error('next-action-suggestion-probe: generator boundaries not found');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const generator = source.slice(start, end);
|
|
19
|
+
const resumeBuildStart = source.indexOf('function _buildAutoResumePlan(');
|
|
20
|
+
const resumeBuildEnd = source.indexOf('function resumeCmd(', resumeBuildStart);
|
|
21
|
+
const resumeBuilder = resumeBuildStart >= 0 && resumeBuildEnd > resumeBuildStart
|
|
22
|
+
? source.slice(resumeBuildStart, resumeBuildEnd)
|
|
23
|
+
: '';
|
|
24
|
+
const offenders = [];
|
|
25
|
+
const run = (args, cwd) => cp.spawnSync(process.execPath, [cliPath, ...args], {
|
|
26
|
+
cwd,
|
|
27
|
+
encoding: 'utf8',
|
|
28
|
+
timeout: 15000,
|
|
29
|
+
env: { ...process.env, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' }
|
|
30
|
+
});
|
|
31
|
+
const writeQueue = (root, queue) => {
|
|
32
|
+
const dir = path.join(root, '.leerness');
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
34
|
+
fs.writeFileSync(path.join(dir, 'next-action-queue.json'), JSON.stringify({ queue, at: new Date().toISOString() }, null, 2), 'utf8');
|
|
35
|
+
};
|
|
36
|
+
const commandArgs = (command) => (String(command).match(/"[^"]*"|\S+/g) || []).map(x => x.startsWith('"') ? x.slice(1, -1) : x);
|
|
37
|
+
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
38
|
+
const hasLegacySuggestion = actions => (Array.isArray(actions) ? actions : []).some(a =>
|
|
39
|
+
/\s--filter(?:\s|$)/.test(String(a.command || ''))
|
|
40
|
+
|| String(a.command || '').includes('./scripts/e2e.js')
|
|
41
|
+
|| /task update .* --status completed/.test(String(a.command || ''))
|
|
42
|
+
|| (/^progress-tracker \d+h 정체/.test(String(a.title || '')))
|
|
43
|
+
|| (/^node _apps\/leerness-stress/.test(String(a.command || '')))
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
async function reproduceTakeMigrationRace(root) {
|
|
47
|
+
const queuePath = path.join(root, '.leerness', 'next-action-queue.json');
|
|
48
|
+
const lockPath = queuePath + '.lock';
|
|
49
|
+
const oldAction = {
|
|
50
|
+
icon: '🔄',
|
|
51
|
+
title: 'progress-tracker 24h 정체 — task T-0001 status 갱신',
|
|
52
|
+
command: 'leerness task update T-0001 --status completed'
|
|
53
|
+
};
|
|
54
|
+
const concurrentAction = { ...oldAction, title: 'progress-tracker 25h 정체 — task T-0001 status 갱신' };
|
|
55
|
+
writeQueue(root, [oldAction]);
|
|
56
|
+
fs.mkdirSync(lockPath);
|
|
57
|
+
|
|
58
|
+
const child = cp.spawn(process.execPath, [cliPath, 'next-action', 'take', '0', '--path', root], {
|
|
59
|
+
cwd: root,
|
|
60
|
+
windowsHide: true,
|
|
61
|
+
env: { ...process.env, LEERNESS_OFFLINE: '1', LEERNESS_NO_PROMPT: '1' },
|
|
62
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
63
|
+
});
|
|
64
|
+
let stdout = '';
|
|
65
|
+
let stderr = '';
|
|
66
|
+
let sawSelection;
|
|
67
|
+
const selected = new Promise(resolve => { sawSelection = resolve; });
|
|
68
|
+
child.stdout.on('data', chunk => { stdout += chunk; sawSelection(); });
|
|
69
|
+
child.stderr.on('data', chunk => { stderr += chunk; });
|
|
70
|
+
await Promise.race([selected, delay(1000)]);
|
|
71
|
+
|
|
72
|
+
// Current buggy take has already selected the 24h title outside the lock. A concurrent
|
|
73
|
+
// normalizer changes that title while holding the queue lock, so its later title lookup misses.
|
|
74
|
+
writeQueue(root, [concurrentAction]);
|
|
75
|
+
fs.rmdirSync(lockPath);
|
|
76
|
+
const result = await new Promise(resolve => {
|
|
77
|
+
const timer = setTimeout(() => { try { child.kill(); } catch {} resolve({ code: null, stdout, stderr, timeout: true }); }, 15000);
|
|
78
|
+
child.on('exit', code => { clearTimeout(timer); resolve({ code, stdout, stderr, timeout: false }); });
|
|
79
|
+
});
|
|
80
|
+
const persisted = JSON.parse(fs.readFileSync(queuePath, 'utf8')).queue;
|
|
81
|
+
return { ...result, remaining: persisted.length };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function main() {
|
|
85
|
+
if (generator.includes('leerness decision list --filter')) offenders.push('decision-list-filter');
|
|
86
|
+
if (generator.includes('leerness plan list --filter')) offenders.push('plan-list-filter');
|
|
87
|
+
if (/stress-v\$\{latest\+1\}/.test(generator)) offenders.push('missing-stress-execution');
|
|
88
|
+
if (generator.includes('node ./scripts/e2e.js')) offenders.push('consumer-missing-e2e');
|
|
89
|
+
if (!resumeBuilder.includes('_normalizedNextActionState(root)')) offenders.push('auto-resume-build-raw-queue');
|
|
90
|
+
|
|
91
|
+
const arena = fs.mkdtempSync(path.join(os.tmpdir(), 'leerness-next-action-probe-'));
|
|
92
|
+
try {
|
|
93
|
+
const listRoot = path.join(arena, 'list-project');
|
|
94
|
+
const legacy = [
|
|
95
|
+
{ icon: '🎯', title: 'plan.md milestone M-0001 검증 — "install" 관련', command: 'leerness plan list --filter "install"' },
|
|
96
|
+
{ icon: '💭', title: 'decisions.md "install" 관련 결정 1건 영향 확인', command: 'leerness decision list --filter "install"' },
|
|
97
|
+
{ icon: '🧪', title: 'review-evidence.md 비어있음 — e2e 또는 verify-code 실행', command: 'node ./scripts/e2e.js' },
|
|
98
|
+
{ icon: '🧬', title: 'stress-v210 마지막 갱신 47h 전 — 새 stress test 작성 권장', command: 'node _apps/leerness-stress/bin/stress-v211.js' },
|
|
99
|
+
{ icon: '🧬', title: 'stress-v211 마지막 갱신 48h 전 — 새 stress test 작성 권장', command: 'node _apps/leerness-stress/bin/stress-v212.js' },
|
|
100
|
+
{ icon: '🔄', title: 'progress-tracker 24h 정체 — task T-0001 status 갱신', command: 'leerness task update T-0001 --status completed' },
|
|
101
|
+
{ icon: '🔄', title: 'progress-tracker 25h 정체 — task T-0001 status 갱신', command: 'leerness task update T-0001 --status completed' }
|
|
102
|
+
];
|
|
103
|
+
writeQueue(listRoot, legacy);
|
|
104
|
+
fs.writeFileSync(path.join(listRoot, 'package.json'), JSON.stringify({
|
|
105
|
+
name: 'next-action-probe-project',
|
|
106
|
+
private: true,
|
|
107
|
+
scripts: { test: 'node -e "process.exit(0)"' }
|
|
108
|
+
}, null, 2), 'utf8');
|
|
109
|
+
|
|
110
|
+
const listed = run(['next-action', 'list', '--path', listRoot, '--json'], listRoot);
|
|
111
|
+
let queue = [];
|
|
112
|
+
try { queue = JSON.parse(listed.stdout).queue; } catch { offenders.push('list-json-contract'); }
|
|
113
|
+
if (listed.status !== 0) offenders.push('list-exit');
|
|
114
|
+
if (queue.some(a => /\s--filter(?:\s|$)/.test(String(a.command || '')))) offenders.push('lazy-filter-migration');
|
|
115
|
+
if (queue.some(a => String(a.command || '').includes('./scripts/e2e.js'))) offenders.push('lazy-e2e-migration');
|
|
116
|
+
if (queue.filter(a => a.icon === '🧬').length !== 1) offenders.push('cross-version-stress-dedup');
|
|
117
|
+
if (queue.filter(a => a.icon === '🔄').length !== 1) offenders.push('hour-changing-progress-dedup');
|
|
118
|
+
if (queue.some(a => a.icon === '🔄' && /\d+h/.test(String(a.title)))) offenders.push('dynamic-progress-title');
|
|
119
|
+
if (queue.filter(a => ['🎯', '💭', '🧪', '🧬', '🔄'].includes(a.icon)).some(a => !a.actionKey)) offenders.push('stable-action-key');
|
|
120
|
+
|
|
121
|
+
// Every read surface must expose the same normalized contract as next-action list/take.
|
|
122
|
+
// A published release writes auto-resume-plan from this snapshot, while context is consumed
|
|
123
|
+
// by external agents; leaking legacy commands through either surface reintroduces the bug.
|
|
124
|
+
const planRoot = path.join(arena, 'resume-project');
|
|
125
|
+
fs.mkdirSync(path.join(planRoot, '.leerness'), { recursive: true });
|
|
126
|
+
fs.writeFileSync(path.join(planRoot, '.leerness', 'auto-resume-plan.json'), JSON.stringify({
|
|
127
|
+
savedAt: new Date().toISOString(),
|
|
128
|
+
expectedFireAt: new Date(Date.now() + 60000).toISOString(),
|
|
129
|
+
nextRoundVersion: 'next after 1.36.169',
|
|
130
|
+
nextActions: legacy
|
|
131
|
+
}, null, 2), 'utf8');
|
|
132
|
+
const resumed = run(['resume', '--path', planRoot, '--json'], planRoot);
|
|
133
|
+
let resumedActions = [];
|
|
134
|
+
try { resumedActions = JSON.parse(resumed.stdout).nextActions; } catch { offenders.push('resume-json-contract'); }
|
|
135
|
+
if (resumed.status !== 0) offenders.push('resume-exit');
|
|
136
|
+
if (hasLegacySuggestion(resumedActions)) offenders.push('legacy-auto-resume-plan');
|
|
137
|
+
if ((resumedActions || []).filter(a => a.icon === '🧬').length !== 1
|
|
138
|
+
|| (resumedActions || []).filter(a => a.icon === '🔄').length !== 1) offenders.push('auto-resume-plan-dedup');
|
|
139
|
+
|
|
140
|
+
const contextual = run(['context', '--path', listRoot, '--json'], listRoot);
|
|
141
|
+
let contextActions = [];
|
|
142
|
+
try { contextActions = JSON.parse(contextual.stdout).nextActions; } catch { offenders.push('context-json-contract'); }
|
|
143
|
+
if (contextual.status !== 0) offenders.push('context-exit');
|
|
144
|
+
if (hasLegacySuggestion(contextActions)) offenders.push('context-raw-queue');
|
|
145
|
+
|
|
146
|
+
const preWake = run(['pre-wake-audit', '--path', listRoot, '--json'], listRoot);
|
|
147
|
+
let pendingFinding = null;
|
|
148
|
+
try {
|
|
149
|
+
const report = JSON.parse(preWake.stdout);
|
|
150
|
+
pendingFinding = (report.findings.info || []).find(f => f.kind === 'next-action-pending');
|
|
151
|
+
} catch { offenders.push('pre-wake-json-contract'); }
|
|
152
|
+
if (preWake.status !== 0) offenders.push('pre-wake-exit');
|
|
153
|
+
if (!pendingFinding) offenders.push('pre-wake-missing-next-action');
|
|
154
|
+
else if (pendingFinding.count !== queue.length) offenders.push('pre-wake-raw-next-action-count');
|
|
155
|
+
|
|
156
|
+
// Execute corrected generated commands against an ordinary project. This guards command
|
|
157
|
+
// existence and flags, rather than merely asserting that selected source strings changed.
|
|
158
|
+
for (const action of queue.filter(a => ['🎯', '💭', '🧪'].includes(a.icon))) {
|
|
159
|
+
const args = commandArgs(action.command);
|
|
160
|
+
if (args.shift() !== 'leerness') { offenders.push(`non-leerness-command:${action.icon}`); continue; }
|
|
161
|
+
const result = run(args, listRoot);
|
|
162
|
+
if (result.status !== 0) offenders.push(`generated-command-exit:${action.icon}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const raceRoot = path.join(arena, 'race-project');
|
|
166
|
+
const race = await reproduceTakeMigrationRace(raceRoot);
|
|
167
|
+
if (race.timeout || race.code !== 0) offenders.push('take-race-process');
|
|
168
|
+
if (race.remaining !== 0) offenders.push('take-race-left-queued');
|
|
169
|
+
} finally {
|
|
170
|
+
try { fs.rmSync(arena, { recursive: true, force: true }); } catch {}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const required = [
|
|
174
|
+
'leerness decision list --query',
|
|
175
|
+
'leerness plan list --path .',
|
|
176
|
+
'leerness verify-code .',
|
|
177
|
+
'stress-v${latest} 오래됨 — 새 stress test 작성 권장'
|
|
178
|
+
];
|
|
179
|
+
for (const needle of required) {
|
|
180
|
+
if (!generator.includes(needle)) offenders.push(`missing:${needle}`);
|
|
181
|
+
}
|
|
182
|
+
if (offenders.length) {
|
|
183
|
+
console.error(`invalid-next-action-suggestion: ${[...new Set(offenders)].join(',')}`);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
console.log('next-action suggestion probe passed: executable commands, lazy migration, stable dedup, atomic take');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
main().catch(err => {
|
|
190
|
+
console.error(`next-action-suggestion-probe: ${err && err.stack ? err.stack : err}`);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
});
|