leerness 1.36.174 → 1.36.176

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/bin/leerness.js CHANGED
@@ -27,7 +27,7 @@ const { _isSecretKey, _isPlaceholderSecret, _looksSecretLike, _mergeLines, _merg
27
27
  _withBuiltinSource, _esc, _roadmapTokenStyles, _parseSkillMd, _lintSkillMeta,
28
28
  _migrationGuideText, _parseContractSpec, _gitignoreMatch,
29
29
  _featureGraphTemplate, _parseFeatureGraph, _nextFeatureId, _featureBlock, _featureImpactBfs,
30
- _parseChangelogBetween, _cellSafe, _cellUnescape, _lineSafe, _parseLimit, _parseAddTitle, _parseImplExports, _taskPositionalPath, _completionClaimAllowed, _minorKey, _shouldPublishNpm,
30
+ _parseChangelogBetween, _cellSafe, _cellUnescape, _lineSafe, _milestoneSurface, _recentChangesAggregate, _parseLimit, _parseAddTitle, _parseImplExports, _taskPositionalPath, _completionClaimAllowed, _minorKey, _shouldPublishNpm,
31
31
  _matchTool, _parsePackageJsonDeps, _parseRequirementsTxt, _buildGlossary, _renderGlossaryMd, _briefUnfilled, _planGoalUnfilled, _draftAnchors, _replaceMdSection, _mdSectionBody, _blocksWithOffset, _decisionBlocksWithOffset } = require('../lib/pure-utils'); // 1.9.318~1.11.4 (UR-0025/.../0007 glossary): 순수 유틸 모듈 분리 · 1.36.36 anchors
32
32
  // 1.9.304 (UR-0025): 순수 분석/검증 함수 모듈 분리.
33
33
  const { _evidenceQuality, _parseEvidenceStats, _shellGuardAnalyze, _claimFileInGit, _epistemicHonestyCheck } = require('../lib/analyzers');
@@ -48,7 +48,7 @@ const {
48
48
  migrateLegacyWorkspace,
49
49
  } = require('../lib/workspace-dir');
50
50
 
51
- const VERSION = '1.36.174';
51
+ const VERSION = '1.36.176';
52
52
 
53
53
  // MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
54
54
  // 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
@@ -3173,59 +3173,169 @@ function _detectDeliveredRequests(root) {
3173
3173
 
3174
3174
  // 1.9.324 (UR-0025): _compareSemver 제거 — pure-utils compareVer 와 동일 기능(중복) → compareVer 단일화.
3175
3175
 
3176
+ // Git refs may contain `|`, while shortened refs can change when another ref is
3177
+ // ambiguous. Use the full tag ref plus NUL (forbidden in ref names), then accept
3178
+ // only the exact refs/tags/vX.Y.Z namespace and a real calendar/offset value.
3179
+ const _ROUND_TAG_FORMAT = '%(refname)%00%(creatordate:iso8601)';
3180
+ function _validRoundTagDate(value) {
3181
+ const m = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2})$/.exec(value);
3182
+ if (!m) return false;
3183
+ const year = Number(m[1]), month = Number(m[2]), day = Number(m[3]);
3184
+ const hour = Number(m[4]), minute = Number(m[5]), second = Number(m[6]);
3185
+ const offsetHour = Number(m[8]), offsetMinute = Number(m[9]);
3186
+ if (year < 1 || month < 1 || month > 12 || hour > 23 || minute > 59 || second > 59
3187
+ || offsetHour > 14 || offsetMinute > 59 || (offsetHour === 14 && offsetMinute !== 0)) return false;
3188
+ const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
3189
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
3190
+ return day >= 1 && day <= days[month - 1];
3191
+ }
3192
+ function _parseRoundTagOutput(stdout) {
3193
+ const tags = [];
3194
+ for (const record of String(stdout || '').split(/\r?\n/)) {
3195
+ if (!record) continue;
3196
+ const sep = record.indexOf('\0');
3197
+ if (sep <= 0) continue;
3198
+ const ref = record.slice(0, sep);
3199
+ const date = record.slice(sep + 1).trim();
3200
+ const match = /^refs\/tags\/(v\d+\.\d+\.\d+)$/.exec(ref);
3201
+ if (!match || !_validRoundTagDate(date)) continue;
3202
+ const name = match[1];
3203
+ tags.push({ name, version: name.slice(1), date });
3204
+ }
3205
+ return tags;
3206
+ }
3207
+ function _gitHistoryFailure(reason, detail) {
3208
+ const clean = _lineSafe(detail || 'Git history could not be measured');
3209
+ return { code: 'git_history_unavailable', reason, error: clean };
3210
+ }
3211
+ function _gitHistoryFailureFromResult(r) {
3212
+ let reason = 'git_failed';
3213
+ let detail = '';
3214
+ if (r && r.error) {
3215
+ reason = String(r.error.code || '') === 'ETIMEDOUT' ? 'timeout' : String(r.error.code || 'spawn_error').toLowerCase();
3216
+ detail = r.error.message || String(r.error);
3217
+ } else if (r && r.signal) {
3218
+ reason = 'signal';
3219
+ detail = `git terminated by signal ${r.signal}`;
3220
+ } else if (r && r.status != null) {
3221
+ reason = `exit_${r.status}`;
3222
+ detail = String(r.stderr || `git exited with status ${r.status}`).trim();
3223
+ }
3224
+ return _gitHistoryFailure(reason, detail);
3225
+ }
3226
+ function _readRoundTags(root) {
3227
+ let r;
3228
+ try {
3229
+ r = _gitSpawn(['tag', '--list', 'v*', '--sort=creatordate', `--format=${_ROUND_TAG_FORMAT}`], {
3230
+ cwd: root, encoding: 'utf8', timeout: 5000
3231
+ });
3232
+ } catch (err) {
3233
+ return { state: 'unavailable', tags: [], error: _gitHistoryFailure('spawn_exception', err && err.message) };
3234
+ }
3235
+ if (r && r.status === 0) {
3236
+ return { state: 'available', tags: r.stdout ? _parseRoundTagOutput(r.stdout) : [], error: null };
3237
+ }
3238
+ return { state: 'unavailable', tags: [], error: _gitHistoryFailureFromResult(r) };
3239
+ }
3240
+ function _throwGitHistoryUnavailable(error) {
3241
+ const detail = error && error.error ? error.error : 'Git history could not be measured';
3242
+ throw Object.assign(new Error(detail), { code: 'E_GIT_HISTORY_UNAVAILABLE', historyError: error || null });
3243
+ }
3244
+
3176
3245
  // 1.9.226: round-history — git tag v1.9.X 기반 자율 라운드 통계 + 다음 마일스톤
3177
- // git이 없거나 tag가 없으면 graceful fallback (current version + 0 history)
3178
- // 응답: { currentVersion, roundCount, baselineVersion, latestTags[], nextMilestone, roundsToNextMilestone, firstTagAt, latestTagAt, daysActive, avgRoundsPerDay }
3179
- function _computeRoundHistory(root) {
3246
+ // 태그 0건은 실제 0으로, Git 실행 실패는 unavailable/null 로 구분한다.
3247
+ // currentVersion 은 호환 별칭일 뿐이며 반드시 currentVersionScope 와 함께 해석한다.
3248
+ // 응답: { cliVersion, harnessVersion, currentVersion(deprecated alias), currentVersionScope, roundCount, ... }
3249
+ function _computeRoundHistory(root, opts = {}) {
3180
3250
  const result = {
3251
+ cliVersion: VERSION,
3252
+ harnessVersion: null,
3253
+ harnessVersionState: 'missing',
3254
+ // Backward compatibility for 1.9.226+ consumers. This never meant an app/project version.
3181
3255
  currentVersion: VERSION,
3256
+ currentVersionScope: 'leerness_cli',
3182
3257
  roundCount: 0,
3183
3258
  baselineVersion: null,
3259
+ latestTagVersion: null,
3184
3260
  latestTags: [],
3185
3261
  nextMilestone: null,
3186
3262
  roundsToNextMilestone: null,
3187
3263
  firstTagAt: null,
3188
3264
  latestTagAt: null,
3189
3265
  daysActive: 0,
3190
- avgRoundsPerDay: 0
3266
+ avgRoundsPerDay: 0,
3267
+ gitHistoryState: 'available',
3268
+ gitHistoryError: null
3191
3269
  };
3270
+ {
3271
+ const history = _readRoundTags(root);
3272
+ result.gitHistoryState = history.state;
3273
+ result.gitHistoryError = history.error;
3274
+ if (history.state !== 'available') {
3275
+ result.roundCount = null;
3276
+ result.latestTags = null;
3277
+ result.daysActive = null;
3278
+ result.avgRoundsPerDay = null;
3279
+ }
3280
+ const tags = history.tags;
3281
+ if (tags.length > 0) {
3282
+ result.roundCount = tags.length;
3283
+ result.baselineVersion = tags[0].version;
3284
+ result.firstTagAt = tags[0].date;
3285
+ result.latestTagAt = tags[tags.length - 1].date;
3286
+ result.latestTagVersion = tags[tags.length - 1].version;
3287
+ // `date` is a public field from 1.9.226. Git's iso8601 formatter uses a
3288
+ // space separator, so splitting it silently removed its time/offset in
3289
+ // 1.36.175. Preserve that established value and add a date-only view.
3290
+ result.latestTags = tags.slice(-10).reverse().map(t => ({
3291
+ version: t.version,
3292
+ date: t.date,
3293
+ dateOnly: t.date.split(' ')[0],
3294
+ }));
3295
+ const milestones = [50, 75, 100, 125, 150, 175, 200, 250, 300, 400, 500];
3296
+ const next = milestones.find(m => m > result.roundCount);
3297
+ if (next != null) {
3298
+ result.nextMilestone = next;
3299
+ result.roundsToNextMilestone = next - result.roundCount;
3300
+ }
3301
+ const first = new Date(result.firstTagAt).getTime();
3302
+ const last = new Date(result.latestTagAt).getTime();
3303
+ const spanDays = (last - first) / 86400000;
3304
+ if (tags.length >= 2 && spanDays > 0) {
3305
+ result.daysActive = Math.max(1, Math.floor(spanDays));
3306
+ result.avgRoundsPerDay = Math.round((result.roundCount / spanDays) * 100) / 100;
3307
+ }
3308
+ }
3309
+ }
3310
+
3311
+ // Git history and workspace provenance are independent evidence sources.
3312
+ // Aggregate callers may retain the measured tag count while exposing a
3313
+ // workspace-policy error; direct round-history/cadence calls still fail.
3314
+ let hv;
3192
3315
  try {
3193
- // git tag --list 'v1.9.*' --sort=creatordate (시간순)
3194
- const r = _gitSpawn(['tag', '--list', 'v*', '--sort=creatordate', '--format=%(refname:short)|%(creatordate:iso8601)'], {
3195
- cwd: root, encoding: 'utf8', timeout: 5000
3196
- });
3197
- if (r.status !== 0 || !r.stdout) return result;
3198
- const lines = r.stdout.split('\n').filter(l => l.trim() && /^v\d+\.\d+\.\d+\|/.test(l));
3199
- if (lines.length === 0) return result;
3200
- const tags = lines.map(l => {
3201
- const [name, date] = l.split('|');
3202
- return { name: name.trim(), version: name.replace(/^v/, '').trim(), date: date.trim() };
3203
- });
3204
- result.roundCount = tags.length;
3205
- result.baselineVersion = tags[0].version;
3206
- result.firstTagAt = tags[0].date;
3207
- result.latestTagAt = tags[tags.length - 1].date;
3208
- // 최근 10개
3209
- result.latestTags = tags.slice(-10).reverse().map(t => ({ version: t.version, date: t.date.split('T')[0] }));
3210
- // 다음 마일스톤 (50/100/150/200/300/...)
3211
- const milestones = [50, 75, 100, 125, 150, 175, 200, 250, 300, 400, 500];
3212
- const next = milestones.find(m => m > result.roundCount);
3213
- if (next != null) {
3214
- result.nextMilestone = next;
3215
- result.roundsToNextMilestone = next - result.roundCount;
3216
- }
3217
- // 일일 평균 — 1.36.78 (F8): 태그 2+ & 양의 구간일 때만(1개면 속도 관측 불가 → 0 유지)
3218
- const first = new Date(result.firstTagAt).getTime();
3219
- const last = new Date(result.latestTagAt).getTime();
3220
- const spanDays = (last - first) / 86400000;
3221
- if (tags.length >= 2 && spanDays > 0) {
3222
- result.daysActive = Math.max(1, Math.floor(spanDays));
3223
- result.avgRoundsPerDay = Math.round((result.roundCount / spanDays) * 100) / 100;
3224
- } else {
3225
- result.daysActive = 0;
3226
- result.avgRoundsPerDay = 0;
3316
+ hv = path.join(_workspaceDirAbs(root), 'HARNESS_VERSION');
3317
+ } catch (err) {
3318
+ if (opts.allowWorkspaceError && err && /^E_WORKSPACE_DIR_[A-Z_]+$/.test(String(err.code || ''))) {
3319
+ result.harnessVersionState = 'workspace-error';
3320
+ result.workspaceError = {
3321
+ code: String(err.code).slice(2).toLowerCase(),
3322
+ error: _lineSafe(err.message || String(err)),
3323
+ };
3324
+ return result;
3227
3325
  }
3228
- } catch {}
3326
+ throw err;
3327
+ }
3328
+ try {
3329
+ // Positional read-only calls select the legacy workspace without renaming
3330
+ // it, while still reporting the version actually installed there.
3331
+ if (exists(hv)) {
3332
+ const parsed = parseHarnessVersion(read(hv));
3333
+ // `leerness@X+plus@Y`의 설치 Leerness 버전은 X다. plus는 옛
3334
+ // 호환 레이어 버전이며 프로젝트 하네스 버전을 대신할 수 없다.
3335
+ result.harnessVersion = parsed.base || null;
3336
+ result.harnessVersionState = result.harnessVersion ? 'valid' : 'invalid';
3337
+ }
3338
+ } catch { result.harnessVersionState = 'unreadable'; }
3229
3339
  return result;
3230
3340
  }
3231
3341
 
@@ -3238,19 +3348,22 @@ function _computeMilestones(root) {
3238
3348
  reached: [],
3239
3349
  next: null,
3240
3350
  avgRoundsPerDay: 0,
3241
- baselineAt: null
3351
+ baselineAt: null,
3352
+ gitHistoryState: 'available',
3353
+ gitHistoryError: null
3242
3354
  };
3243
- try {
3244
- const r = _gitSpawn(['tag', '--list', 'v*', '--sort=creatordate', '--format=%(refname:short)|%(creatordate:iso8601)'], {
3245
- cwd: root, encoding: 'utf8', timeout: 5000
3246
- });
3247
- if (r.status !== 0 || !r.stdout) return result;
3248
- const lines = r.stdout.split('\n').filter(l => l.trim() && /^v\d+\.\d+\.\d+\|/.test(l));
3249
- if (lines.length === 0) return result;
3250
- const tags = lines.map(l => {
3251
- const [name, date] = l.split('|');
3252
- return { name: name.trim(), version: name.replace(/^v/, '').trim(), date: date.trim() };
3253
- });
3355
+ {
3356
+ const history = _readRoundTags(root);
3357
+ result.gitHistoryState = history.state;
3358
+ result.gitHistoryError = history.error;
3359
+ if (history.state !== 'available') {
3360
+ result.totalRounds = null;
3361
+ result.reached = null;
3362
+ result.avgRoundsPerDay = null;
3363
+ return result;
3364
+ }
3365
+ const tags = history.tags;
3366
+ if (tags.length === 0) return result;
3254
3367
  result.totalRounds = tags.length;
3255
3368
  result.baselineAt = tags[0].date;
3256
3369
  const milestones = [25, 50, 75, 100, 125, 150, 175, 200, 250, 300, 400, 500];
@@ -3288,7 +3401,7 @@ function _computeMilestones(root) {
3288
3401
  // 속도 관측 불가 — 다음 마일스톤은 표시하되 ETA 는 null(추정 없음)
3289
3402
  result.next = { milestone: nextM, roundsRemaining: nextM - tags.length, etaDays: null, etaDate: null };
3290
3403
  }
3291
- } catch {}
3404
+ }
3292
3405
  return result;
3293
3406
  }
3294
3407
 
@@ -3300,6 +3413,7 @@ function milestonesCmd(root) {
3300
3413
  const yl = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
3301
3414
  const dm = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
3302
3415
  const data = _computeMilestones(root);
3416
+ if (data.gitHistoryState !== 'available') _throwGitHistoryUnavailable(data.gitHistoryError);
3303
3417
  if (has('--json')) { log(JSON.stringify(data, null, 2)); return; }
3304
3418
  log(cy(`# leerness milestones (1.9.229) — 도달 마일스톤 + 다음 ETA`));
3305
3419
  log('');
@@ -3503,34 +3617,39 @@ function agentModeCmd(root, sub) {
3503
3617
  process.exitCode = 1;
3504
3618
  }
3505
3619
 
3506
- // 1.9.234: recentChanges — 최근 N 라운드의 핵심 변경 요약 (git tag commit subject)
3507
- // 응답: [{ version, date, subject }] (가장 최근부터)
3508
- function _computeRecentChanges(root, limit = 5) {
3620
+ // 1.9.234/1.36.175: recentChanges — 최근 N 라운드의 핵심 변경 요약.
3621
+ // shortened decoration과 `|` 구분자는 모호하다. full ref와 NUL 필드를
3622
+ // 사용하고, Git 실패는 빈 배열이 아니라 unavailable/items:null 이다.
3623
+ const _RECENT_CHANGE_FORMAT = '%(refname)%00%(creatordate:iso8601)%00%(*subject)%00%(subject)';
3624
+ function _parseRecentChangeOutput(stdout, limit = 5) {
3509
3625
  const items = [];
3626
+ for (const record of String(stdout || '').split(/\r?\n/)) {
3627
+ if (!record) continue;
3628
+ const fields = record.split('\0');
3629
+ if (fields.length !== 4) continue;
3630
+ const match = /^refs\/tags\/v(\d+\.\d+\.\d+)$/.exec(fields[0]);
3631
+ const date = String(fields[1] || '').trim();
3632
+ if (!match || !_validRoundTagDate(date)) continue;
3633
+ const subject = _lineSafe(fields[2] || fields[3] || '')
3634
+ .replace(/^\d+\.\d+\.\d+\s*[—-]\s*/, '').trim().slice(0, 100);
3635
+ items.push({ version: match[1], date: date.split(' ')[0], subject });
3636
+ if (items.length >= limit) break;
3637
+ }
3638
+ return items;
3639
+ }
3640
+ function _computeRecentChanges(root, limit = 5) {
3641
+ let r;
3510
3642
  try {
3511
- const r = _gitSpawn(['log', '--tags', '--simplify-by-decoration', '--pretty=format:%H|%D|%ad|%s', '--date=short', '-n', String(limit * 3)], {
3643
+ r = _gitSpawn(['for-each-ref', '--sort=-creatordate', `--format=${_RECENT_CHANGE_FORMAT}`, 'refs/tags/v*'], {
3512
3644
  cwd: root, encoding: 'utf8', timeout: 5000
3513
3645
  });
3514
- if (r.status !== 0 || !r.stdout) return items;
3515
- const lines = r.stdout.split('\n').filter(l => l.trim());
3516
- for (const line of lines) {
3517
- const idx1 = line.indexOf('|');
3518
- if (idx1 < 0) continue;
3519
- const idx2 = line.indexOf('|', idx1 + 1);
3520
- const idx3 = line.indexOf('|', idx2 + 1);
3521
- if (idx2 < 0 || idx3 < 0) continue;
3522
- const refs = line.slice(idx1 + 1, idx2);
3523
- const date = line.slice(idx2 + 1, idx3);
3524
- const subject = line.slice(idx3 + 1);
3525
- const m = refs.match(/tag: v(\d+\.\d+\.\d+)/);
3526
- if (!m) continue;
3527
- const version = m[1];
3528
- const cleanSubject = subject.replace(/^\d+\.\d+\.\d+\s*[—-]\s*/, '').trim();
3529
- items.push({ version, date, subject: cleanSubject.slice(0, 100) });
3530
- if (items.length >= limit) break;
3531
- }
3532
- } catch {}
3533
- return items;
3646
+ } catch (err) {
3647
+ return { state: 'unavailable', error: _gitHistoryFailure('spawn_exception', err && err.message), items: null };
3648
+ }
3649
+ if (!r || r.status !== 0) {
3650
+ return { state: 'unavailable', error: _gitHistoryFailureFromResult(r), items: null };
3651
+ }
3652
+ return { state: 'available', error: null, items: _parseRecentChangeOutput(r.stdout, limit) };
3534
3653
  }
3535
3654
 
3536
3655
  // 1.9.241: 환경 정보 종합 수집 + 셸 스크립트 인코딩 위험 감지 (사용자 명시 UR-0014)
@@ -4051,7 +4170,7 @@ function _selfTestCases() {
4051
4170
  { name: '_isSecretKey: 비시크릿 LEERNESS_* 통과', run: () => _isSecretKey('LEERNESS_ENABLE_AGY') === false && _isSecretKey('LANG') === false },
4052
4171
  { name: 'compareVer: 대소 비교', run: () => compareVer('1.9.258', '1.9.257') === 1 && compareVer('1.9.0', '1.10.0') === -1 && compareVer('2.0.0', '2.0.0') === 0 },
4053
4172
  { name: 'compareVer: 누락 파트/null 안전', run: () => compareVer('1.9', '1.9.0') === 0 && compareVer(null, '0.0.0') === 0 },
4054
- { name: 'parseHarnessVersion: canonical + legacy plus', run: () => parseHarnessVersion('1.9.0').base === '1.9.0' && parseHarnessVersion('leerness@1.8.0+plus@1.0.1').plus === '1.0.1' },
4173
+ { name: 'parseHarnessVersion: exact canonical/legacy formats only', run: () => parseHarnessVersion('1.9.0').base === '1.9.0' && parseHarnessVersion('leerness@1.8.0+plus@1.0.1').plus === '1.0.1' && parseHarnessVersion('leerness@1.8.0').base === '1.8.0' && parseHarnessVersion('garbage leerness@1.8.0 trailing').base === null && parseHarnessVersion('leerness@1.8.0.1').base === null && parseHarnessVersion('plus@1.0.1 junk').plus === null },
4055
4174
  { name: '_classifyCJK: 한국어 감지', run: () => { const b = Buffer.from('안녕', 'utf8'); return _classifyCJK(b, b.length).korean > 0; } },
4056
4175
  { name: '_classifyCJK: 중국어/일본어 구분', run: () => { const z = Buffer.from('中', 'utf8'), j = Buffer.from('ひ', 'utf8'); return _classifyCJK(z, z.length).chinese > 0 && _classifyCJK(j, j.length).japanese > 0; } },
4057
4176
  { name: '_riskLabel: korean→CP949 / chinese→CP936', run: () => /CP949/.test(_riskLabel({ korean: 3, japanese: 0, chinese: 0, other: 0 }).risk) && /CP936/.test(_riskLabel({ korean: 0, japanese: 0, chinese: 3, other: 0 }).risk) },
@@ -5003,12 +5122,12 @@ function _selfTestCases() {
5003
5122
  { name: 'UR-0073 Phase A: team 정의 레지스트리 (_renderTeamsMd + canonical load/save) 행위 (1.9.371)', run: () => { const m = require('../lib/pure-utils'); if (typeof teamCmd !== 'function' || typeof _renderTeamsMd !== 'function' || m._renderTeamsMd !== _renderTeamsMd) return false; const md = _renderTeamsMd([{ id: 't1', name: 'N', personas: ['security'], members: ['claude'], schedule: 'daily', status: 'active' }]); const mdOk = md.includes('## t1') && md.includes('security') && md.includes('daily') && md.includes('정의 전용'); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_team_')); let rtOk = false; try { _saveTeams(tmp, [{ id: 'x', name: 'X', personas: [], members: [], schedule: 'manual', status: 'active' }]); const loaded = _loadTeams(tmp); rtOk = loaded.length === 1 && loaded[0].id === 'x' && fs.existsSync(path.join(tmp, '.leerness', 'teams.json')) && fs.existsSync(path.join(tmp, '.leerness', 'teams.md')); } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } return mdOk && rtOk; } },
5004
5123
  { name: 'UR-0073 Phase B: _composeTeamPlan dry-run 실행 계획 (멤버별 dispatch, 실행 없음) 행위 (1.9.372)', run: () => { const m = require('../lib/pure-utils'); if (typeof _composeTeamPlan !== 'function' || m._composeTeamPlan !== _composeTeamPlan) return false; const team = { id: 'rev', name: 'R', purpose: 'PR 리뷰', personas: ['security', 'perf'], members: ['claude', 'codex'], schedule: 'manual' }; const p1 = _composeTeamPlan(team, '점검'); const ok1 = p1.steps.length === 2 && p1.task === '점검' && p1.steps[0].member === 'claude' && p1.steps[0].suggestedCommand.includes('agents dispatch') && p1.steps[0].suggestedCommand.includes('--to claude') && p1.steps[0].dispatchPrompt.includes('security'); const p2 = _composeTeamPlan(team, null); const ok2 = p2.task === 'PR 리뷰'; const p3 = _composeTeamPlan({ id: 'e', personas: [], members: [] }, 'x'); const ok3 = p3.steps.length === 0 && p3.memberCount === 0; return ok1 && ok2 && ok3; } },
5005
5124
  { name: 'UR-0073 Phase C: _teamHandoffReminders 스케줄 알림 (비-manual·active 만, 실행 트리거 아님) 행위 (1.9.373) + i18n en(1.31.3)', run: () => { const m = require('../lib/pure-utils'); if (typeof _teamHandoffReminders !== 'function' || m._teamHandoffReminders !== _teamHandoffReminders) return false; const r = _teamHandoffReminders([{ id: 'rev', schedule: 'every-session', status: 'active', members: ['a', 'b'] }, { id: 'man', schedule: 'manual', status: 'active' }, { id: 'paused', schedule: 'daily', status: 'paused' }]); const behaviorOk = r.length === 1 && r[0].includes('rev') && r[0].includes('every-session') && r[0].includes('team preview rev') && !r.join('|').includes('man') && !r.join('|').includes('paused'); const _H = /[가-힣]/; const en = _teamHandoffReminders([{ id: 'rev', schedule: 'every-session', status: 'active', members: ['a', 'b'], review: true }], 'en')[0] || ''; const ko = _teamHandoffReminders([{ id: 'rev', schedule: 'every-session', status: 'active', members: ['a', 'b'], review: true }])[0] || ''; const i18nOk = !_H.test(en) && /2 members/.test(en) && /review needed/.test(en) && /preview:/.test(en) && _H.test(ko) && /2명/.test(ko) && /검수필요/.test(ko); return behaviorOk && i18nOk; } },
5006
- { name: 'UR-0074: _cadenceAssessment 릴리스 빈도 평가 (임계값) 행위 (1.9.374)', run: () => { const m = require('../lib/pure-utils'); if (typeof _cadenceAssessment !== 'function' || m._cadenceAssessment !== _cadenceAssessment || typeof releaseCadenceCmd !== 'function') return false; return _cadenceAssessment(7, 1, 1).level === 'very-high' && _cadenceAssessment(3, 1, 1).level === 'high' && _cadenceAssessment(1, 1, 1).level === 'moderate' && _cadenceAssessment(0.2, 1, 1).level === 'healthy' && _cadenceAssessment(7, 1, 1).recommendation.length > 0; } },
5125
+ { name: 'UR-0074: _cadenceAssessment 릴리스 빈도 평가 (임계값) 행위 (1.9.374)', run: () => { const m = require('../lib/pure-utils'); if (typeof _cadenceAssessment !== 'function' || m._cadenceAssessment !== _cadenceAssessment || typeof releaseCadenceCmd !== 'function') return false; return _cadenceAssessment(7, 10, 2).level === 'very-high' && _cadenceAssessment(3, 10, 2).level === 'high' && _cadenceAssessment(1, 10, 2).level === 'moderate' && _cadenceAssessment(0.2, 10, 2).level === 'healthy' && _cadenceAssessment(7, 1, 0).level === 'insufficient-data' && _cadenceAssessment(7, 10, 2).recommendation.length > 0; } },
5007
5126
  { name: 'UR-0084 + 1.36.160: _withLock 35s 대기 + 토큰 소유권 + owner fail-closed 행위', run: () => _p0160LockOwnershipOk() },
5008
5127
  { name: 'UR-0073 Phase D: _teamDeployGate 이중 게이트 (dry-run 기본/env 게이트/실행) 행위 (1.9.376)', run: () => { const m = require('../lib/pure-utils'); if (typeof _teamDeployGate !== 'function' || m._teamDeployGate !== _teamDeployGate) return false; const team = { id: 'd', deployCommand: 'echo hi' }; const noCmd = _teamDeployGate({ id: 'x' }, { yes: true, envOn: true }).mode === 'no-command'; const dry = _teamDeployGate(team, { yes: false, envOn: true }).mode === 'dry-run'; const gated = _teamDeployGate(team, { yes: true, envOn: false }).mode === 'gated'; const exec = _teamDeployGate(team, { yes: true, envOn: true }).mode === 'execute'; return noCmd && dry && gated && exec; } },
5009
5128
  { name: 'UR-0025: _renderWorkspaceReferenceGuide 모듈 분리 + canonical migration 안내 (1.36.162)', run: () => { const m = require('../lib/pure-utils'); if (typeof _renderWorkspaceReferenceGuide !== 'function' || m._renderWorkspaceReferenceGuide !== _renderWorkspaceReferenceGuide) return false; const g = _renderWorkspaceReferenceGuide(CANONICAL_WORKSPACE_DIR, '9.9.9', '2026-01-01T00:00:00.000Z', LEGACY_WORKSPACE_DIR); const wrapperThin = read(__filename).includes('return _renderWorkspaceReferenceGuide(dirName, VERSION, new Date().toISOString(), LEGACY_WORKSPACE_DIR)'); const guideSafe = g.includes('canonical workspace') && g.includes('workspace-dir-conflict') && g.includes('.leerness/cache/agent-runs/') && g.includes(`${LEGACY_WORKSPACE_DIR}/`); return g.includes('.leerness/progress-tracker.md') && g.includes('9.9.9') && g.includes('자주 묻는 위치') && guideSafe && wrapperThin; } },
5010
5129
  { name: 'UR-0073: team MCP 도구 2종(read-only) 정의 + dispatch 와이어 (1.9.378)', run: () => { const tools = require('../lib/mcp-tools'); const src = read(__filename); const tl = tools.find(t => t.name === 'leerness_team_list'); const tp = tools.find(t => t.name === 'leerness_team_preview'); const defsOk = tl && tl.requiredTier === 'read-only' && tp && tp.requiredTier === 'read-only' && tp.inputSchema.required && tp.inputSchema.required.includes('id'); const wired = src.includes("case " + "'leerness_team_list':") && src.includes("case " + "'leerness_team_preview':") && /cliArgs = \['team', 'list'/.test(src) && /cliArgs = \['team', 'preview'/.test(src); return !!defsOk && wired; } },
5011
- { name: 'UR-0025 심화: pulse 렌더 코어 분리 — _memorySurface + _renderPulseLine 행위 (1.9.379)', run: () => { const m = require('../lib/pure-utils'); if (typeof _memorySurface !== 'function' || typeof _renderPulseLine !== 'function' || m._memorySurface !== _memorySurface || m._renderPulseLine !== _renderPulseLine) return false; const ms = _memorySurface({ tasks: 1, decisions: 2, rules: 3, milestones: 4, lessons: 5 }) === 'T1/D2/R3/P4/L5' && _memorySurface({}) === 'T0/D0/R0/P0/L0'; const base = _renderPulseLine({ version: '1.0.0', roundCount: 7, mcpTools: 85, memorySurface: 'T0/D1/R0/P2/L0' }); const ln = base.includes('v1.0.0') && base.includes('R7') && base.includes('MCP 85') && base.includes('T0/D1/R0/P2/L0') && !base.includes('🎯') && !base.includes('abnormal'); const full = _renderPulseLine({ version: '1.0.0', roundCount: 7, mcpTools: 85, memorySurface: 'x', nextMilestone: 400, etaDays: 6, abnormalShutdown: 'high' }); const ln2 = full.includes('🎯 R400 (6d)') && full.includes('abnormal:high'); const wired = read(__filename).includes('const line = _renderPulseLine(data)') && read(__filename).includes('data.memorySurface = _memorySurface('); return ms && ln && ln2 && wired; } },
5130
+ { name: 'UR-0025/T-0095: pulse 렌더 코어 + Git 이력 unavailable 정직 표기', run: () => { const m = require('../lib/pure-utils'); if (typeof _memorySurface !== 'function' || typeof _renderPulseLine !== 'function' || m._memorySurface !== _memorySurface || m._renderPulseLine !== _renderPulseLine) return false; const ms = _memorySurface({ tasks: 1, decisions: 2, rules: 3, milestones: 4, lessons: 5 }) === 'T1/D2/R3/P4/L5' && _memorySurface({}) === 'T0/D0/R0/P0/L0'; const base = _renderPulseLine({ version: '1.0.0', roundCount: 7, mcpTools: 85, memorySurface: 'T0/D1/R0/P2/L0' }); const ln = base.includes('v1.0.0') && base.includes('R7') && base.includes('MCP 85') && base.includes('T0/D1/R0/P2/L0') && !base.includes('🎯') && !base.includes('abnormal'); const full = _renderPulseLine({ version: '1.0.0', roundCount: 7, mcpTools: 85, memorySurface: 'x', nextMilestone: 400, etaDays: 6, abnormalShutdown: 'high' }); const ln2 = full.includes('🎯 R400 (6d)') && full.includes('abnormal:high'); const unavailable = _renderPulseLine({ version: '1.0.0', roundCount: null, mcpTools: 85, memorySurface: 'x' }); const unavailableOk = unavailable.includes('unavailable') && !unavailable.includes('Rnull'); const wired = read(__filename).includes('const line = _renderPulseLine(data)') && read(__filename).includes('data.memorySurface = _memorySurface('); return ms && ln && ln2 && unavailableOk && wired; } },
5012
5131
  { name: 'UR-0025: REQUIRED_WORKSPACE_FILES 단일출처 — verify/migrate audit·apply 3중 중복 제거 (1.9.380)', run: () => { const c = require('../lib/catalogs'); if (REQUIRED_WORKSPACE_FILES !== c.REQUIRED_WORKSPACE_FILES) return false; const listOk = Array.isArray(c.REQUIRED_WORKSPACE_FILES) && c.REQUIRED_WORKSPACE_FILES.length === 9 && c.REQUIRED_WORKSPACE_FILES.includes('AGENTS.md') && c.REQUIRED_WORKSPACE_FILES.includes('.leerness/plan.md'); const harnessUses = (read(__filename).match(/const required = REQUIRED_WORKSPACE_FILES;/g) || []).length >= 1; const migUses = (read(path.join(path.dirname(__filename), '..', 'lib', 'migrate.js')).match(/const required = REQUIRED_WORKSPACE_FILES;/g) || []).length >= 2; return listOk && harnessUses && migUses; } },
5013
5132
  { name: 'UR-0025: KEYWORD_STOPWORDS 단일출처 — handoff/lessons 키워드 stopwords 2중 중복 제거 (1.9.381)', run: () => { const c = require('../lib/catalogs'); if (KEYWORD_STOPWORDS !== c.KEYWORD_STOPWORDS) return false; const setOk = c.KEYWORD_STOPWORDS instanceof Set && c.KEYWORD_STOPWORDS.has('작업') && c.KEYWORD_STOPWORDS.has('task') && !c.KEYWORD_STOPWORDS.has('고유단어') && c.KEYWORD_STOPWORDS.size >= 25; const usesConst = (read(__filename).match(/const stopwords = KEYWORD_STOPWORDS;/g) || []).length >= 2 && !/const stopwords = new Set\(\[/.test(read(__filename)); return setOk && usesConst; } },
5014
5133
  { name: 'UR-0025 큰핸들러토대: lib/io.js 프리미티브(log/ok/warn/fail/today/now) 모듈 분리 + 동작 (1.9.382)', run: () => { const io = require('../lib/io'); const exportsOk = ['log', 'ok', 'warn', 'fail', 'today', 'now'].every(k => typeof io[k] === 'function') && io.log === log && io.fail === fail && io.now === now; const todayOk = /^\d{4}-\d{2}-\d{2}$/.test(io.today()) && /^\d{4}-\d{2}-\d{2}T/.test(io.now()); const src = read(__filename); const moved = src.includes("require('../lib/io')") && !/^function fail\(s\) \{ log/m.test(src) && !/^function now\(\) \{ return new Date/m.test(src); let exitOk = false; const saved = process.exitCode; const _w = process.stdout.write, _e = process.stderr.write; try { process.stdout.write = () => true; process.stderr.write = () => true; process.exitCode = 0; io.fail('probe'); exitOk = process.exitCode === 1; } finally { process.stdout.write = _w; process.stderr.write = _e; process.exitCode = saved; io.setQuiet(false); } return exportsOk && todayOk && moved && exitOk; } },
@@ -5041,10 +5160,10 @@ function _selfTestCases() {
5041
5160
  { name: 'T-0077 후속 graph auto-gen: handoff opt-in 배선 + quiet 무로그 (1.34.4)', run: () => { const m = require('../lib/graph'); const src = read(__filename); const wired = src.includes('_maybeAuto' + 'Graph(_hp)') && src.includes('LEERNESS_AUTO_' + 'GRAPH'); let quietOk = false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_autograph_')); const _w = process.stdout.write; let so = ''; try { process.stdout.write = s => { so += s; return true; }; fs.mkdirSync(path.join(tmp, '.leerness'), { recursive: true }); fs.writeFileSync(path.join(tmp, '.leerness', 'progress-tracker.md'), '| ID | Status | Request | Evidence | Next | Updated |\n|---|---|---|---|---|---|\n| T-0001 | done | x | - | - | 2026-06-26 |\n'); const out = path.join(tmp, 'leerness.html'); const r = m.graphHtmlCmd(tmp, { _roadmapData, _loadDecisions, _loadLessons, quiet: true }, out); quietOk = !!r && r.ok === true && fs.existsSync(out) && so === ''; } catch (e) { quietOk = false; } finally { process.stdout.write = _w; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } return wired && quietOk; } },
5042
5161
  { name: 'graph --html: 빈/미초기화 하네스 무크래시 + 유효 빈 HTML (1.35.0 방어가드)', run: () => { const m = require('../lib/graph'); let ok = false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_emptyg_')); const _w = process.stdout.write; let so = ''; try { process.stdout.write = s => { so += s; return true; }; fs.mkdirSync(path.join(tmp, '.leerness'), { recursive: true }); const data = m.buildGraphData(tmp, {}); const out = path.join(tmp, 'leerness.html'); const r = m.graphHtmlCmd(tmp, { quiet: true }, out); const html = fs.readFileSync(out, 'utf8'); const closers = html.split('</' + 'script>').length - 1; const dm = html.match(/var DATA = (\{[\s\S]*?\});/); let parsed = null; try { parsed = JSON.parse(dm[1]); } catch (e) {} ok = !!data && Array.isArray(data.nodes) && data.nodes.length === 0 && !!r && r.ok === true && r.nodes === 0 && fs.existsSync(out) && closers === 1 && !!parsed && parsed.nodes.length === 0 && so === ''; } catch (e) { ok = false; } finally { process.stdout.write = _w; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } return ok; } },
5043
5162
  { name: 'graph --html: 임베드 script JS 신택스 유효성(U+2028/정규식 리터럴 회귀 영구 차단, 1.35.2)', run: () => { const m = require('../lib/graph'); let ok = false; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), '__leerness_gjs_')); const _w = process.stdout.write; let so = ''; try { process.stdout.write = s => { so += s; return true; }; fs.mkdirSync(path.join(tmp, '.leerness'), { recursive: true }); fs.writeFileSync(path.join(tmp, '.leerness', 'progress-tracker.md'), '| ID | Status | Request | Evidence | Next | Updated |\n|---|---|---|---|---|---|\n| T-0001 | done | x </scr' + 'ipt> & <b> ' + String.fromCharCode(0x24) + '{y} | M-0002 | - | 2026-06-26 |\n'); const out = path.join(tmp, 'leerness.html'); m.graphHtmlCmd(tmp, { _roadmapData, _loadDecisions, _loadLessons, quiet: true }, out); const html = fs.readFileSync(out, 'utf8'); const o = '<scr' + 'ipt>', c = '</scr' + 'ipt>'; const js = html.slice(html.indexOf(o) + o.length, html.lastIndexOf(c)); let synOk = false; try { new Function(js); synOk = true; } catch (e) { synOk = false; } ok = js.length > 200 && synOk; } catch (e) { ok = false; } finally { process.stdout.write = _w; try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} } return ok; } },
5044
- { name: '7번째 버그헌트 P1-A (UR-0104): 테이블셀 안전화 _cellSafe/_cellUnescape (파이프/개행 injection 차단) (1.9.399)', run: () => { const m = require('../lib/pure-utils'); if (m._cellSafe !== _cellSafe || m._cellUnescape !== _cellUnescape) return false; const safe = _cellSafe('fix | bug\nrow2'); const noRaw = !/(?<!\\)\|/.test(safe) && !/[\r\n]/.test(safe); const pipeRt = _cellUnescape(_cellSafe('a | b | c')) === 'a | b | c'; const nlGone = _cellSafe('a\nb') === 'a b'; const src = read(__filename); const wired = src.includes('_cellSafe(r.request)') && src.includes('_cellSafe(r.rule)'); return noRaw && pipeRt && nlGone && wired; } },
5163
+ { name: '7번째 버그헌트 P1-A (UR-0104): 테이블셀 안전화 _cellSafe/_cellUnescape (파이프/개행 injection 차단) (1.9.399/1.36.175)', run: () => { const m = require('../lib/pure-utils'); if (m._cellSafe !== _cellSafe || m._cellUnescape !== _cellUnescape) return false; const safe = _cellSafe('fix | bug\nrow2\u2028row3\u2029row4'); const noRaw = !/(?<!\\)\|/.test(safe) && !/[\r\n\u2028\u2029]/.test(safe); const pipeRt = _cellUnescape(_cellSafe('a | b | c')) === 'a | b | c'; const nlGone = _cellSafe('a\nb\u2028c\u2029d') === 'a b c d'; const src = read(__filename); const wired = src.includes('_cellSafe(r.request)') && src.includes('_cellSafe(r.rule)'); return noRaw && pipeRt && nlGone && wired; } },
5045
5164
  { name: '7번째 버그헌트 P1-B (UR-0105): verify-claim/optimism-check/honesty-check --json 에러 구조화 (1.9.400)', run: () => { const src = read(__filename); const vc = /function verifyClaimCmd[\s\S]{0,1200}?failJson\(_j, 'not_found'/.test(src); const oc = /function optimismCheckCmd[\s\S]{0,700}?failJson\(_j, 'not_found'/.test(src); const hc = /function honestyCheckCmd[\s\S]{0,900}?failJson\(has\('--json'\), 'not_found'/.test(src); return vc && oc && hc; } }, // 1.30.5: {0,400}→{0,700} (F4 가 missing_args 라인을 en/ko 로 늘려 not_found 가 창 밖) · 1.33.2: vc {0,700}→{0,1200} (opts.collect 가드 라인이 not_found 를 더 밀어냄)
5046
5165
  { name: '7번째 버그헌트 P1-C (UR-0106): 시크릿 FN — gitignore 부정(!) + placeholder substring 정밀화 (1.9.401)', run: () => { const m = require('../lib/pure-utils'); const gm = m._gitignoreMatch; const negOk = gm('*.example\n!.env.example', '.env.example') === false && gm('*.log', 'a.log') === true && gm('a.log\n!a.log', 'a.log') === false && gm('.env', '.env') === true; const ph = m._isPlaceholderSecret; const phOk = ph('sk-EXAMPLEab12cd34ef56gh78ij90kl') === false && ph('sk-proj-realKEYexample9988776655') === false && ph('your-key-here') === true && ph('changeme') === true && ph('example') === true && ph('xxxxxxxxxxxxxxxxxxxxxxxxxxxx') === true; return negOk && phOk; } },
5047
- { name: '7번째 버그헌트 P1-A 잔여 (UR-0108): decisions/lessons MD projection 개행 주입 차단 _lineSafe (1.9.402)', run: () => { const m = require('../lib/pure-utils'); if (m._lineSafe !== _lineSafe) return false; const lsOk = _lineSafe('a\nb\r\nc') === 'a b c'; const md = m._renderDecisionsMd([{ date: '2026-06-07', title: 'real\n### 2099-01-01 — FAKE\n- Decision: forged', decision: 'd', reason: 'r' }]); const re = m._decisionsFromMd(md); const noInject = re.length === 1 && !/^### 2099-01-01 — FAKE/m.test(md); const lmd = m._renderLessonsMd([{ date: '2026-06-07', text: 'l1\n### FAKE\n- Lesson: x', tag: 't' }]); const lre = m._parseLessonEntries(lmd); const lNoInject = lre.length === 1; return lsOk && noInject && lNoInject; } },
5166
+ { name: '7번째 버그헌트 P1-A 잔여 (UR-0108/T-0095): MD/터미널 제어문자 주입 차단 _lineSafe (1.9.402/1.36.175)', run: () => { const m = require('../lib/pure-utils'); if (m._lineSafe !== _lineSafe) return false; const lsOk = _lineSafe('a\nb\r\nc\u2028d\u2029e') === 'a b c d e'; const controls = _lineSafe('a\u000b\u000c\u001b[2J\u007f\u0085b'); const controlsOk = !/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(controls); const md = m._renderDecisionsMd([{ date: '2026-06-07', title: 'real\n### 2099-01-01 — FAKE\n- Decision: forged', decision: 'd', reason: 'r' }]); const re = m._decisionsFromMd(md); const noInject = re.length === 1 && !/^### 2099-01-01 — FAKE/m.test(md); const lmd = m._renderLessonsMd([{ date: '2026-06-07', text: 'l1\n### FAKE\n- Lesson: x', tag: 't' }]); const lre = m._parseLessonEntries(lmd); const lNoInject = lre.length === 1; return lsOk && controlsOk && noInject && lNoInject; } },
5048
5167
  { name: '7번째 버그헌트 P2 (UR-0107): api-skill show/drop 에러 exit code 1 (1.9.403)', run: () => {
5049
5168
  // 낡은 소스-리터럴(인라인 `process.exitCode = 1`) 대신 **행위 검사**: 1.36.74 에서 에러 경로가 failJson 으로
5050
5169
  // 리팩터되며 리터럴이 가드 자기 줄에만 남아 공허참이 됐다. 실제로 호출해 exit code 를 확인한다.
@@ -7240,9 +7359,9 @@ function _selfTestCases() {
7240
7359
  fs.mkdirSync(path.join(tmp, '.leerness'), { recursive: true });
7241
7360
  fs.writeFileSync(c._previewsPath(tmp), '{"legacy":{"id":"P-0042"}}');
7242
7361
  const chk1 = c._loadPreviewsChecked(tmp);
7243
- fs.writeFileSync(c._previewsPath(tmp), '[{"id":"P-0001","title":"ok"},null]');
7362
+ fs.writeFileSync(c._previewsPath(tmp), '[{"id":"P-0001","title":"ok","status":"proposed"},null]');
7244
7363
  const chk2 = c._loadPreviewsChecked(tmp);
7245
- fs.writeFileSync(c._previewsPath(tmp), '[{"id":"P-0001","title":"ok"}]');
7364
+ fs.writeFileSync(c._previewsPath(tmp), '[{"id":"P-0001","title":"ok","status":"proposed"}]');
7246
7365
  const chk3 = c._loadPreviewsChecked(tmp);
7247
7366
  shape = chk1.invalid && chk2.invalid && !chk3.invalid && chk3.list.length === 1;
7248
7367
  } finally { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch {} }
@@ -9121,7 +9240,7 @@ function pulseCmd(root) {
9121
9240
  const dm = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
9122
9241
  const data = {
9123
9242
  version: VERSION,
9124
- roundCount: 0,
9243
+ roundCount: null,
9125
9244
  mcpTools: 0,
9126
9245
  memorySurface: 'T0/D0/R0/P0/L0',
9127
9246
  // 1.36.39 (codex 4차 #8): pulse 는 이 3종을 계산하지 않는다 — 상태값처럼 보이던 'unknown' placeholder 가
@@ -9132,10 +9251,26 @@ function pulseCmd(root) {
9132
9251
  notComputed: ['security', 'health', 'driftScore'],
9133
9252
  nextMilestone: null,
9134
9253
  etaDays: null,
9135
- abnormalShutdown: 'none'
9254
+ abnormalShutdown: 'none',
9255
+ roundHistoryError: null,
9256
+ gitHistoryState: null,
9257
+ gitHistoryError: null,
9258
+ workspaceError: null
9136
9259
  };
9137
9260
  // 라운드 카운트
9138
- try { data.roundCount = _computeRoundHistory(root).roundCount; } catch {}
9261
+ try {
9262
+ const rh = _computeRoundHistory(root, { allowWorkspaceError: true });
9263
+ data.roundCount = rh.roundCount;
9264
+ data.gitHistoryState = rh.gitHistoryState;
9265
+ data.gitHistoryError = rh.gitHistoryError;
9266
+ data.workspaceError = rh.workspaceError || null;
9267
+ data.roundHistoryError = rh.gitHistoryError || rh.workspaceError || null;
9268
+ } catch (err) {
9269
+ const failure = _gitHistoryFailure('compute_exception', err && err.message);
9270
+ data.gitHistoryState = 'unavailable';
9271
+ data.gitHistoryError = failure;
9272
+ data.roundHistoryError = failure;
9273
+ }
9139
9274
  // MCP 도구 수 (1.9.297: lib/mcp-tools.js 단일 출처)
9140
9275
  try { data.mcpTools = _mcpToolCount(); } catch {}
9141
9276
  // Memory Surface (T/D/R/P/L) — 1.9.232 BUG fix: memoryStatusCmd 와 동일한 패턴 사용
@@ -9172,6 +9307,7 @@ function pulseCmd(root) {
9172
9307
  log(cy(`# leerness pulse (1.9.231/1.9.232) — 한 줄 종합 요약`));
9173
9308
  log('');
9174
9309
  log(` ${line}`);
9310
+ if (data.roundHistoryError) warn(` round-history provenance unavailable: ${data.roundHistoryError.code}`);
9175
9311
  log('');
9176
9312
  log(dm(` → 상세: leerness handoff . | health . | round-history | milestones`));
9177
9313
  }
@@ -9591,7 +9727,7 @@ const _GROUP_USAGE_KO = {
9591
9727
  rule: 'rule add "<텍스트>" --trigger <트리거> | rule list | rule pause/resume/remove <ID> | rule verify',
9592
9728
  skill: 'skill list | skill add <id> | skill use <id> | skill search "<키>" | skill match "<텍스트>"',
9593
9729
  feature: 'feature add "<이름>" | feature list | feature show <ID> | feature link <A> <B> | feature impact <ID>',
9594
- memory: 'memory search "<키>" [--json] | memory status | memory archive | memory restore',
9730
+ memory: 'memory search "<키>" [--json] | memory status | memory archive list | memory restore <surface> <target>',
9595
9731
  readme: 'readme sync [path]',
9596
9732
  contract: 'contract verify <spec.md> <impl.js> [--json] [--allow-empty]',
9597
9733
  self: 'self check [path]',
@@ -9608,7 +9744,7 @@ const _GROUP_USAGE_EN = {
9608
9744
  rule: 'rule add "<text>" --trigger <trigger> | rule list | rule pause/resume/remove <ID> | rule verify',
9609
9745
  skill: 'skill list | skill add <id> | skill use <id> | skill search "<key>" | skill match "<text>"',
9610
9746
  feature: 'feature add "<name>" | feature list | feature show <ID> | feature link <A> <B> | feature impact <ID>',
9611
- memory: 'memory search "<key>" [--json] | memory status | memory archive | memory restore',
9747
+ memory: 'memory search "<key>" [--json] | memory status | memory archive list | memory restore <surface> <target>',
9612
9748
  readme: 'readme sync [path]',
9613
9749
  contract: 'contract verify <spec.md> <impl.js> [--json] [--allow-empty]',
9614
9750
  self: 'self check [path]',
@@ -10610,26 +10746,33 @@ function _p0104UsageOk() {
10610
10746
  fs.writeFileSync(path.join(d, 'package.json'), '{"name":"x","version":"0.1.0"}');
10611
10747
  cp2.spawnSync(process.execPath, [__filename, 'init', d, '--yes'], { cwd: d, encoding: 'utf8', timeout: 180000, env: env2 });
10612
10748
  }
10613
- const mcp = (name) => cp2.spawnSync(process.execPath, [__filename, 'mcp', 'serve'], {
10749
+ const mcp = (name, toolArgs = {}) => cp2.spawnSync(process.execPath, [__filename, 'mcp', 'serve'], {
10614
10750
  cwd: cwdP, encoding: 'utf8', timeout: 180000, env: env2, maxBuffer: 32 * 1024 * 1024,
10615
10751
  input: [
10616
10752
  { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'g', version: '1' } } },
10617
10753
  { jsonrpc: '2.0', method: 'notifications/initialized' },
10618
- { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name, arguments: { path: tgt } } },
10754
+ { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name, arguments: { ...toolArgs, path: tgt } } },
10619
10755
  ].map(r => JSON.stringify(r)).join('\n') + '\n'
10620
10756
  });
10757
+ // Observation-only MCP calls must not mutate their target merely to count
10758
+ // themselves. Writable calls still provide attributable usage evidence.
10621
10759
  mcp('leerness_about'); mcp('leerness_pulse');
10622
10760
  const U = (d) => ((cp2.spawnSync(process.execPath, [__filename, 'usage', '--path', d], { cwd: cwdP, encoding: 'utf8', timeout: 120000, env: env2 }).stdout) || '');
10761
+ const uReadOnly = U(tgt);
10762
+ const readOnlyClean = !/leerness_about|leerness_pulse/.test(uReadOnly);
10763
+ mcp('leerness_task_add', { text: 'usage attribution fixture' });
10764
+ mcp('leerness_decision_add', { title: 'usage attribution fixture' });
10623
10765
  const uT = U(tgt), uC = U(cwdP);
10624
- // ① 실제로 일한 프로젝트가 그 사실을 말한다
10625
- const shows = /leerness_about/.test(uT) && /leerness_pulse/.test(uT) && !/\(사용 기록 없음\)/.test(uT);
10766
+ // ① 실제로 쓴 프로젝트가 그 사실을 말하고, 관찰 호출은 기록하지 않는다
10767
+ const shows = /leerness_task_add/.test(uT) && /leerness_decision_add/.test(uT)
10768
+ && !/leerness_about|leerness_pulse/.test(uT) && !/\(사용 기록 없음\)/.test(uT);
10626
10769
  // ② 서버가 우연히 서 있던 프로젝트에 남의 활동이 기록되지 않는다
10627
10770
  const cwdClean = !/\|\s*mcp\s*\|/.test(uC);
10628
10771
  // ③ 대조군 — 평범한 CLI 경로는 그대로 명령 표에 남는다(표시 조건을 바꾸며 죽이지 않았는가)
10629
10772
  cp2.spawnSync(process.execPath, [__filename, 'audit', '--path', tgt], { cwd: cwdP, encoding: 'utf8', timeout: 180000, env: env2 });
10630
10773
  const uT2 = U(tgt);
10631
- const cliStillCounted = /\|\s*audit\s*\|/.test(uT2) && /leerness_about/.test(uT2);
10632
- return shows && cwdClean && cliStillCounted;
10774
+ const cliStillCounted = /\|\s*audit\s*\|/.test(uT2) && /leerness_task_add/.test(uT2);
10775
+ return readOnlyClean && shows && cwdClean && cliStillCounted;
10633
10776
  } catch { return false; } finally { try { fs.rmSync(arena, { recursive: true, force: true }); } catch { /* 정리 실패는 판정과 무관 */ } }
10634
10777
  }
10635
10778
  // 1.36.103: --json 계약 회귀 가드 — "실패해도 stdout 은 항상 파싱 가능한 JSON 하나".
@@ -11331,10 +11474,12 @@ function commandsCmd(root) {
11331
11474
  function releaseCadenceCmd(root) {
11332
11475
  root = absRoot(root);
11333
11476
  const rh = _computeRoundHistory(root);
11477
+ if (rh.gitHistoryState !== 'available') _throwGitHistoryUnavailable(rh.gitHistoryError);
11334
11478
  const a = _cadenceAssessment(rh.avgRoundsPerDay, rh.roundCount, rh.daysActive);
11335
- if (has('--json')) { log(JSON.stringify({ version: VERSION, currentVersion: rh.currentVersion, baselineVersion: rh.baselineVersion, ...a }, null, 2)); return; }
11479
+ if (has('--json')) { log(JSON.stringify({ version: VERSION, cliVersion: rh.cliVersion, harnessVersion: rh.harnessVersion, harnessVersionState: rh.harnessVersionState, currentVersion: rh.currentVersion, currentVersionScope: rh.currentVersionScope, baselineVersion: rh.baselineVersion, latestTagVersion: rh.latestTagVersion, gitHistoryState: rh.gitHistoryState, gitHistoryError: rh.gitHistoryError, ...a }, null, 2)); return; }
11336
11480
  log(`# leerness release cadence (1.9.374, UR-0074) — 릴리스 빈도 진단 (읽기 전용)`);
11337
- log(` 누적 릴리스: ${a.total} · 활동일: ${a.daysActive}일 · 빈도: ${a.releasesPerDay}/day`);
11481
+ const observedRate = a.dataSufficient ? `${a.releasesPerDay}/day` : '관측 불가 (서로 다른 시각의 태그 2개 이상 필요)';
11482
+ log(` 누적 릴리스: ${a.total} · 활동일: ${a.daysActive}일 · 빈도: ${observedRate}`);
11338
11483
  log(` 수준: ${a.level}`);
11339
11484
  log(` 권장: ${a.recommendation}`);
11340
11485
  log(`\n 관련: leerness release channel (stable/next 정책) · leerness install-safety (공급망 신뢰)`);
@@ -11347,10 +11492,15 @@ function roundHistoryCmd(root) {
11347
11492
  const yl = s => isTty ? `\x1b[33m${s}\x1b[0m` : s;
11348
11493
  const dm = s => isTty ? `\x1b[2m${s}\x1b[0m` : s;
11349
11494
  const data = _computeRoundHistory(root);
11495
+ if (data.gitHistoryState !== 'available') _throwGitHistoryUnavailable(data.gitHistoryError);
11350
11496
  if (has('--json')) { log(JSON.stringify(data, null, 2)); return; }
11351
11497
  log(cy(`# leerness round-history (1.9.226) — 자율 라운드 통계`));
11352
11498
  log('');
11353
- log(` 📦 현재 버전: ${gr(data.currentVersion)}`);
11499
+ log(` 📦 leerness CLI: ${gr(data.cliVersion)}`);
11500
+ if (data.harnessVersion) log(` 🧩 프로젝트 하네스: ${gr(data.harnessVersion)}`);
11501
+ else if (data.harnessVersionState === 'invalid' || data.harnessVersionState === 'unreadable') log(yl(` 🧩 프로젝트 하네스: 판독 불가 (${data.harnessVersionState})`));
11502
+ else if (data.harnessVersionState === 'missing') log(yl(` 🧩 프로젝트 하네스: 없음 (missing)`));
11503
+ if (data.latestTagVersion) log(` 🏷️ 저장소 최근 태그: v${data.latestTagVersion}`);
11354
11504
  log(` 🔄 누적 라운드: ${gr(String(data.roundCount))}`);
11355
11505
  if (data.baselineVersion) log(` 📍 baseline: v${data.baselineVersion} (${(data.firstTagAt || '').split('T')[0]})`);
11356
11506
  if (data.latestTagAt) log(` ⏰ 최근 tag: ${(data.latestTagAt || '').split('T')[0]}`);
@@ -11876,23 +12026,49 @@ function _isKnownTopLevelCommand(cmd) {
11876
12026
  return _knownTopLevelCommands.has(String(cmd || ''));
11877
12027
  }
11878
12028
 
12029
+ // Single authority for CLI routes that explicitly promise observation only.
12030
+ // The default is stateful: a newly added command cannot accidentally gain a
12031
+ // read-only exemption merely because a heuristic failed to recognize it.
12032
+ const _OBSERVATION_ONLY_COMMANDS = new Set([
12033
+ 'about', 'identity', 'status', 'commands', 'install-safety', 'capabilities',
12034
+ 'security-surface', 'dashboard', 'round-history', 'milestones', 'pulse',
12035
+ ]);
12036
+ const _OBSERVATION_ONLY_SUBCOMMANDS = new Map([
12037
+ ['release', new Set(['cadence'])],
12038
+ ['auto-update', new Set(['status'])],
12039
+ ['preview', new Set(['list', 'show'])],
12040
+ ]);
12041
+ function _cliMutationClass(args, cmd) {
12042
+ if (cmd === 'mcp') return 'observation-only'; // server startup must not mutate its incidental cwd
12043
+ if (_OBSERVATION_ONLY_COMMANDS.has(cmd)) return 'observation-only';
12044
+ // `preview` with no explicit child is an exact alias for `preview list`.
12045
+ // Classify the alias before auto-migration/telemetry so adding --json/--path
12046
+ // cannot turn a read into a write merely because no child token exists.
12047
+ if (cmd === 'preview' && (!args || !args[1] || String(args[1]).startsWith('-'))) return 'observation-only';
12048
+ const children = _OBSERVATION_ONLY_SUBCOMMANDS.get(cmd);
12049
+ return children && args && children.has(args[1]) ? 'observation-only' : 'stateful';
12050
+ }
12051
+ function _mustRemainReadOnly(args, cmd) { return _cliMutationClass(args, cmd) === 'observation-only'; }
12052
+
11879
12053
  function _autoMigrateLegacyWorkspace(args, cmd) {
11880
12054
  if (process.env.LEERNESS_NO_AUTO_WORKSPACE_MIGRATION === '1') return true;
11881
12055
  if (has('--dry-run') || cmd === 'migrate-workspace-dir' || cmd === 'workspace-dir') return true;
12056
+ if (_mustRemainReadOnly(args, cmd)) return true;
11882
12057
  // Invalid commands must be observationally pure. In particular, a typo in
11883
12058
  // a legacy project may not rename project memory before reporting the typo.
11884
12059
  if (!_isKnownTopLevelCommand(cmd)) return true;
11885
12060
  const candidates = [];
11886
12061
  const explicit = arg('--path', null);
11887
12062
  if (typeof explicit === 'string' && explicit.trim()) candidates.push(explicit);
11888
- candidates.push(process.cwd());
12063
+ else candidates.push(process.cwd());
11889
12064
  // Never interpret arbitrary command content as a project to mutate. Topics,
11890
12065
  // titles and output paths may all be path-shaped (for example
11891
12066
  // `brainstorm ./legacy-design`); scanning every positional token used to
11892
12067
  // migrate those unrelated directories before command dispatch. Positional
11893
12068
  // project targets can be migrated explicitly with `migrate-workspace-dir`,
11894
12069
  // while normal automatic migration is limited to cwd and the unambiguous
11895
- // global `--path` option.
12070
+ // global `--path` option. When it is present, the unrelated process cwd is
12071
+ // not another migration target.
11896
12072
  const seen = new Set();
11897
12073
  for (const candidate of candidates) {
11898
12074
  let root;
@@ -13893,8 +14069,10 @@ function _handoffVersionSkew(root) {
13893
14069
  const versionFile = path.join(absRoot(root), '.leerness', 'HARNESS_VERSION');
13894
14070
  if (!exists(versionFile)) return null;
13895
14071
  const parsed = parseHarnessVersion(read(versionFile));
13896
- const harnessVersion = parsed.plus || parsed.base;
13897
- if (!harnessVersion) return { detected: true, kind: 'invalid-harness-version', cliVersion: VERSION, harnessVersion: parsed.raw };
14072
+ // `leerness@X+plus@Y`에서 X가 설치된 Leerness 버전이고 Y는 옛 compatibility suffix다.
14073
+ // round-history/status와 같은 출처를 사용해야 skew 방향이 뒤집히지 않는다.
14074
+ const harnessVersion = parsed.base;
14075
+ if (!harnessVersion) return { detected: true, kind: 'invalid-harness-version', cliVersion: VERSION, harnessVersion: _lineSafe(parsed.raw) };
13898
14076
  const order = compareVer(VERSION, harnessVersion);
13899
14077
  if (order === 0) return null;
13900
14078
  return {
@@ -13909,11 +14087,11 @@ function _handoffVersionSkew(root) {
13909
14087
  function _emitHandoffVersionSkew(skew) {
13910
14088
  if (!skew) return;
13911
14089
  if (skew.kind === 'cli-older') {
13912
- warn(`버전 불일치: 실행 CLI v${skew.cliVersion} < 프로젝트 하네스 v${skew.harnessVersion} — 쓰기 명령 전 CLI를 업데이트하세요`);
14090
+ warn(`버전 불일치: 실행 CLI v${_lineSafe(skew.cliVersion)} < 프로젝트 하네스 v${_lineSafe(skew.harnessVersion)} — 쓰기 명령 전 CLI를 업데이트하세요`);
13913
14091
  } else if (skew.kind === 'harness-older') {
13914
- warn(`버전 불일치: 실행 CLI v${skew.cliVersion} > 프로젝트 하네스 v${skew.harnessVersion} — leerness update --yes 권장`);
14092
+ warn(`버전 불일치: 실행 CLI v${_lineSafe(skew.cliVersion)} > 프로젝트 하네스 v${_lineSafe(skew.harnessVersion)} — leerness update --yes 권장`);
13915
14093
  } else {
13916
- warn(`프로젝트 HARNESS_VERSION 을 해석할 수 없습니다: ${skew.harnessVersion}`);
14094
+ warn(`프로젝트 HARNESS_VERSION 을 해석할 수 없습니다: ${_lineSafe(skew.harnessVersion)}`);
13917
14095
  }
13918
14096
  }
13919
14097
  // 1.36.7 (메타-테스트 도그푸드): 세션에서 작업 중(task/decision add)인데 handoff 가 오래됨/미실행이면 넛지.
@@ -15785,21 +15963,46 @@ function route(name) {
15785
15963
 
15786
15964
  function status(root) {
15787
15965
  root = absRoot(root);
15788
- const _L = _uiLang(root); const t = (ko, en) => (_L === 'en' ? en : ko); // 1.23.1 (UR-0010 Phase 6)
15789
15966
  // 1.9.434 (11th 외부평가 Opus P2, UR-0136): 미존재 경로는 healthy 위조 금지 — failJson + exit 1.
15790
- if (!exists(root)) { failJson(has('--json'), 'path_not_found', t(`경로 없음: ${root}`, `path not found: ${root}`)); return; }
15791
- const verF = path.join(root,'.leerness/HARNESS_VERSION');
15792
- const ver = exists(verF) ? read(verF).trim() : 'not installed';
15793
- const lang = exists(path.join(root,'.leerness/LANGUAGE')) ? read(path.join(root,'.leerness/LANGUAGE')).trim() : 'ko';
15967
+ if (!exists(root)) {
15968
+ const missingLang = _uiLang(root);
15969
+ failJson(has('--json'), 'path_not_found', missingLang === 'en' ? `path not found: ${root}` : `경로 없음: ${root}`);
15970
+ return;
15971
+ }
15972
+ // Resolve the active workspace exactly once. A live legacy installation is
15973
+ // observable in place; status must neither migrate it nor report canonical
15974
+ // paths as missing. Conflict/override errors remain fail-closed in main().
15975
+ const workspaceDir = _workspaceDirName(root);
15976
+ const workspaceAbs = path.join(root, workspaceDir);
15977
+ const verF = path.join(workspaceAbs, 'HARNESS_VERSION');
15978
+ let ver = null;
15979
+ let versionState = 'missing';
15980
+ if (exists(verF)) {
15981
+ try {
15982
+ const parsed = parseHarnessVersion(read(verF));
15983
+ ver = parsed.base || null;
15984
+ versionState = ver ? 'valid' : 'invalid';
15985
+ } catch { versionState = 'unreadable'; }
15986
+ }
15987
+ const langF = path.join(workspaceAbs, 'LANGUAGE');
15988
+ const lang = exists(langF) ? read(langF).trim() : 'ko';
15989
+ const requestedLang = String(arg('--language', '') || process.env.LEERNESS_LANG || '').toLowerCase();
15990
+ const _L = requestedLang === 'en' || requestedLang === 'ko' ? requestedLang : (lang === 'en' ? 'en' : 'ko');
15991
+ const t = (ko, en) => (_L === 'en' ? en : ko); // 1.23.1 (UR-0010 Phase 6)
15794
15992
  // 1.9.287 (Codex 리뷰 수렴): --minimal 설치는 manifest.minimal=true → 의도적으로 생략된 파일을 missing 으로 경고하지 않음.
15795
15993
  let isMinimal = false;
15796
- try { const mf = path.join(root, '.leerness/manifest.json'); if (exists(mf)) isMinimal = !!JSON.parse(read(mf)).minimal; } catch {}
15797
- const files = Object.keys(coreFiles(root, lang, [], { minimal: isMinimal }));
15798
- const missing = files.filter(f => !exists(path.join(root,f)));
15994
+ try { const mf = path.join(workspaceAbs, 'manifest.json'); if (exists(mf)) isMinimal = !!JSON.parse(read(mf)).minimal; } catch {}
15995
+ const canonicalFiles = Object.keys(coreFiles(root, lang, [], { minimal: isMinimal }));
15996
+ const files = canonicalFiles.map(f => f === CANONICAL_WORKSPACE_DIR
15997
+ ? workspaceDir
15998
+ : (f.startsWith(CANONICAL_WORKSPACE_DIR + '/')
15999
+ ? workspaceDir + f.slice(CANONICAL_WORKSPACE_DIR.length)
16000
+ : f));
16001
+ const missing = files.filter(f => !exists(path.join(root, f)));
15799
16002
  // 1.9.384 (5번째 외부평가/UR-0085): --json 일관성 — AI 에이전트용 구조화 출력.
15800
16003
  // 1.9.418 (9th 외부평가 Codex P2): healthy 의 의미를 명시(설치 파일 존재 ≠ 프로젝트 안전). 프로젝트 안전은 gate/scan secrets 사용.
15801
- if (has('--json')) { log(JSON.stringify({ version: ver, language: lang, minimal: isMinimal, scope: 'install', total: files.length, present: files.length - missing.length, missing, healthy: missing.length === 0, healthyMeaning: t('설치 파일 존재 여부(프로젝트 안전 아님 — 보안/품질은 leerness gate / scan secrets 사용)', 'install-file presence only (not project safety — use leerness gate / scan secrets for security/quality)') }, null, 2)); return; }
15802
- log(`Leerness: ${ver}${isMinimal ? ' (minimal)' : ''}`);
16004
+ if (has('--json')) { log(JSON.stringify({ version: ver, versionState, workspaceDir, language: lang, minimal: isMinimal, scope: 'install', total: files.length, present: files.length - missing.length, missing, healthy: missing.length === 0, healthyMeaning: t('설치 파일 존재 여부(프로젝트 안전 아님 — 보안/품질은 leerness gate / scan secrets 사용)', 'install-file presence only (not project safety — use leerness gate / scan secrets for security/quality)') }, null, 2)); return; }
16005
+ log(`Leerness: ${ver || versionState}${isMinimal ? ' (minimal)' : ''}`);
15803
16006
  log(`Files: ${files.length - missing.length}/${files.length}`);
15804
16007
  if (missing.length) missing.forEach(x => warn('missing: ' + x));
15805
16008
  else ok(`required files present${isMinimal ? ' (minimal set)' : ''}`);
@@ -16620,30 +16823,33 @@ function handoff(root) {
16620
16823
  } catch {}
16621
16824
  // 1.9.227: roundHistory 통합 (handoff JSON 6번째 통합 필드) — 자율 진행도 외부 회수
16622
16825
  try {
16623
- const rh = _computeRoundHistory(root);
16826
+ const rh = _computeRoundHistory(root, { allowWorkspaceError: true });
16624
16827
  result.roundHistory = {
16625
16828
  roundCount: rh.roundCount,
16626
16829
  baselineVersion: rh.baselineVersion,
16627
16830
  nextMilestone: rh.nextMilestone,
16628
16831
  roundsToNextMilestone: rh.roundsToNextMilestone,
16629
16832
  daysActive: rh.daysActive,
16630
- avgRoundsPerDay: rh.avgRoundsPerDay
16833
+ avgRoundsPerDay: rh.avgRoundsPerDay,
16834
+ harnessVersionState: rh.harnessVersionState,
16835
+ workspaceError: rh.workspaceError || null,
16836
+ gitHistoryState: rh.gitHistoryState,
16837
+ gitHistoryError: rh.gitHistoryError
16631
16838
  };
16632
16839
  } catch {}
16633
16840
  // 1.9.230: milestones 통합 (handoff JSON 7번째 통합 필드) — 도달 마일스톤 + ETA
16634
16841
  try {
16635
16842
  const ms = _computeMilestones(root);
16636
- result.milestones = {
16637
- reachedCount: ms.reached.length,
16638
- reached: ms.reached.map(m => ({ milestone: m.milestone, version: m.version, reachedAt: m.reachedAt })),
16639
- next: ms.next,
16640
- avgRoundsPerDay: ms.avgRoundsPerDay
16641
- };
16642
- } catch {}
16843
+ result.milestones = _milestoneSurface(ms);
16844
+ } catch (err) {
16845
+ result.milestones = _milestoneSurface({ gitHistoryState: 'unavailable', gitHistoryError: _gitHistoryFailure('aggregate_exception', err && err.message) });
16846
+ }
16643
16847
  // 1.9.234: recentChanges 통합 (handoff JSON 8번째 통합 필드) — 최근 5 라운드 변경 요약
16644
16848
  try {
16645
- result.recentChanges = _computeRecentChanges(root, 5);
16646
- } catch {}
16849
+ Object.assign(result, _recentChangesAggregate(_computeRecentChanges(root, 5)));
16850
+ } catch (err) {
16851
+ Object.assign(result, _recentChangesAggregate({ state: 'unavailable', error: _gitHistoryFailure('aggregate_exception', err && err.message) }));
16852
+ }
16647
16853
  // 1.9.240: pyFiles 통합 (handoff JSON 9번째 통합 필드) — Python 다중 언어 가시화 (UR-0013 2단계)
16648
16854
  try {
16649
16855
  const pyFiles = _collectPyFiles(root, 200);
@@ -16751,6 +16957,10 @@ function handoff(root) {
16751
16957
  // 채널(메일박스)을 새로 만들기 전에, 이미 있는 신호가 도달하게 하는 것이 먼저다.
16752
16958
  try { result.sessions = _sessionSignal(root); } catch { /* 레지스트리를 못 읽어도 handoff 는 진행 */ }
16753
16959
  try { const _rem = _agentReminderSignal(root); if (_rem) result.agentReminders = _rem; } catch {}
16960
+ // A corrupt preview workflow is not equivalent to "0 pending". Machine
16961
+ // consumers must keep the approval gate unknown until previews.json is
16962
+ // repaired, rather than silently treating malformed/missing statuses as done.
16963
+ result.previewApproval = _previewApprovalSurface(root);
16754
16964
  log(JSON.stringify(result, null, 2));
16755
16965
  return;
16756
16966
  }
@@ -16780,6 +16990,11 @@ function handoff(root) {
16780
16990
  // 이 경로가 MCP handoff·REPL·agent-mode 가 실제로 쓰는 경로이고, 종전엔 여기서 헤드라인이 통째로 사라졌다(200줄→3줄).
16781
16991
  try { const _ss = _sessionSignal(root); flags.push(..._sessionSignalFlags(_ss)); } catch {}
16782
16992
  try { const _rm = _agentReminderSignal(root); if (_rm) flags.push(`🔔 리마인더 ${_rm.lines}줄`); } catch {}
16993
+ // 1.36.175 (T-0095): MCP handoff 가 바로 이 compact 경로를 사용한다. 승인 저장소가
16994
+ // 손상됐는데도 아무 표시가 없으면 에이전트가 `미승인 0`으로 오독하므로 known/count/code를
16995
+ // compact 텍스트에도 명시한다. 0건도 known=true로 써서 "침묵=확인됨" 추론을 없앤다.
16996
+ const _pa = _previewApprovalSurface(root, false);
16997
+ flags.push(`${_pa.known ? '🎨' : '⚠'} ${_previewApprovalText(_pa)}`);
16783
16998
  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 개행 주입 차단
16784
16999
  if (nx) log(_lineSafe(`다음: ${nx.id} [${nx.status}] ${nx.nextAction || nx.request || ''}`).slice(0, 200));
16785
17000
  log('핵심 규칙: 의존성0 · 한국어주석 · UTF-8noBOM · reuse-map등록 · anti-lazy-work · verify-claim자동검수');
@@ -17030,8 +17245,12 @@ function handoff(root) {
17030
17245
  // 17) 1.9.226: round-history 헤드라인 — 자율 라운드 카운터 + 다음 마일스톤 (5 라운드 이상 시만 표시)
17031
17246
  // 1.9.230: 다음 마일스톤 ETA (10R 이내 + ETA 7일 이내일 때 강조)
17032
17247
  try {
17033
- const rh = _computeRoundHistory(root);
17034
- if (rh.roundCount >= 5) {
17248
+ const rh = _computeRoundHistory(root, { allowWorkspaceError: true });
17249
+ if (rh.gitHistoryError) {
17250
+ parts.push(`⚠ Git history unavailable:${rh.gitHistoryError.reason}`);
17251
+ } else if (rh.workspaceError) {
17252
+ parts.push(`⚠ workspace provenance unavailable:${rh.workspaceError.code}`);
17253
+ } else if (rh.roundCount >= 5) {
17035
17254
  let label = `🔄 R${rh.roundCount}`;
17036
17255
  if (rh.nextMilestone != null && rh.roundsToNextMilestone <= 20) {
17037
17256
  label += ` → R${rh.nextMilestone} (${rh.roundsToNextMilestone}R ${t('남음', 'left')})`;
@@ -17088,9 +17307,15 @@ function handoff(root) {
17088
17307
  } catch {}
17089
17308
  // 1.36.51 (UR-0061): 미승인 미리보기 — 사용자 답을 기다리는 기능이 있으면 헤드라인 노출 (코드 작성 보류 계약)
17090
17309
  try {
17091
- const _pp = _clar.pendingPreviews(root);
17092
- if (_pp.length) parts.push(t(`🎨 미승인 미리보기 ${_pp.length}건 (approve 전 코드 금지)`, `🎨 ${_pp.length} preview(s) awaiting approval (no code before approve)`));
17093
- } catch {}
17310
+ const _pa = _previewApprovalSurface(root, false);
17311
+ if (!_pa.known) {
17312
+ parts.push(t(`⚠ 미리보기 승인 상태 불명 (${_pa.code}) — 복구 전 승인 완료로 간주 금지`, `⚠ preview approval unknown (${_pa.code}) — do not treat as approved until repaired`));
17313
+ } else if (_pa.pendingCount) {
17314
+ parts.push(t(`🎨 미승인 미리보기 ${_pa.pendingCount}건 (approve 전 코드 금지)`, `🎨 ${_pa.pendingCount} preview(s) awaiting approval (no code before approve)`));
17315
+ }
17316
+ } catch (e) {
17317
+ parts.push(t('⚠ 미리보기 승인 상태 확인 실패 — 복구 전 승인 완료로 간주 금지', '⚠ preview approval check failed — do not treat as approved until repaired'));
17318
+ }
17094
17319
  // 14) 1.9.209: pre-wake-audit 최근 보고서 (사용자 명시) — 깨어남 직후 자동 노출
17095
17320
  try {
17096
17321
  const pwState = _loadPreWakeReport(root);
@@ -18166,6 +18391,48 @@ function _parseSince(s) {
18166
18391
  return cutoff.toISOString().slice(0, 10); // YYYY-MM-DD
18167
18392
  }
18168
18393
 
18394
+ // 승인 상태는 단일/멀티 handoff와 MCP가 같은 필드 계약을 공유한다. `known=true`일 때도
18395
+ // code=null을 생략하지 않아 compact 텍스트를 파싱하는 에이전트가 침묵을 추론하지 않게 한다.
18396
+ function _previewApprovalSurface(root, includeItems = true) {
18397
+ try {
18398
+ const state = _clar.previewApprovalState(root);
18399
+ return {
18400
+ known: !!state.known,
18401
+ pendingCount: state.known ? state.count : null,
18402
+ code: state.known ? null : _lineSafe(String(state.code || 'approval_state_unavailable')),
18403
+ items: includeItems && state.known
18404
+ ? state.items.slice(0, 10).map(p => ({ id: p.id, title: p.title, status: p.status }))
18405
+ : [],
18406
+ };
18407
+ } catch (e) {
18408
+ return {
18409
+ known: false,
18410
+ pendingCount: null,
18411
+ code: _lineSafe(String((e && e.code) || 'approval_state_unavailable')),
18412
+ items: [],
18413
+ };
18414
+ }
18415
+ }
18416
+
18417
+ function _previewApprovalText(state) {
18418
+ return state.known
18419
+ ? `previewApproval known=true count=${state.pendingCount} code=null`
18420
+ : `previewApproval known=false count=null code=${state.code || 'approval_state_unavailable'}`;
18421
+ }
18422
+
18423
+ function _workspacePreviewApproval(projects) {
18424
+ const unknown = projects.filter(p => !p.previewApproval.known);
18425
+ const codes = Array.from(new Set(unknown.map(p => p.previewApproval.code || 'approval_state_unavailable')));
18426
+ return {
18427
+ known: unknown.length === 0,
18428
+ pendingCount: unknown.length === 0
18429
+ ? projects.reduce((sum, p) => sum + (p.previewApproval.pendingCount || 0), 0)
18430
+ : null,
18431
+ code: unknown.length === 0 ? null : (codes.length === 1 ? codes[0] : 'multiple_approval_state_errors'),
18432
+ unknownProjects: unknown.map(p => ({ project: p.project, path: p.path, code: p.previewApproval.code })),
18433
+ };
18434
+ }
18435
+
18169
18436
  // 1.9.17→1.9.18: 워크스페이스 통합 handoff — 4개 agent 동시 작업 시 메인 agent용 한 줄 요약
18170
18437
  // 1.9.18: --since <duration> 추가, 최근 수정된 T-row 강조 (🆕 마크 + 별도 섹션)
18171
18438
  function _handoffWorkspace(rootBase) {
@@ -18197,10 +18464,11 @@ function _handoffWorkspace(rootBase) {
18197
18464
  blocked: (buckets['blocked'] || []).length,
18198
18465
  activeRules,
18199
18466
  nextAction: (buckets['in-progress']?.[0]?.nextAction) || (buckets['planned']?.[0]?.nextAction) || (buckets['requested']?.[0]?.nextAction) || null,
18467
+ previewApproval: _previewApprovalSurface(p),
18200
18468
  recent: recent.map(r => ({ id: r.id, status: r.status, request: r.request, updated: r.updated }))
18201
18469
  };
18202
18470
  });
18203
- log(JSON.stringify({ workspace: path.basename(rootBase), since: sinceCutoff, projects, totals: {
18471
+ log(JSON.stringify({ workspace: path.basename(rootBase), since: sinceCutoff, previewApproval: _workspacePreviewApproval(projects), projects, totals: {
18204
18472
  tasks: projects.reduce((a, b) => a + b.total, 0),
18205
18473
  done: projects.reduce((a, b) => a + b.done, 0),
18206
18474
  inProgress: projects.reduce((a, b) => a + b.inProgress, 0),
@@ -18213,6 +18481,7 @@ function _handoffWorkspace(rootBase) {
18213
18481
  if (has('--compact')) {
18214
18482
  let totalDone = 0, totalTasks = 0, totalWIP = 0, totalRecent = 0;
18215
18483
  const projSummaries = [];
18484
+ const projectApprovals = [];
18216
18485
  for (const p of paths) {
18217
18486
  const rows = readProgressRows(p);
18218
18487
  const buckets = {};
@@ -18222,9 +18491,12 @@ function _handoffWorkspace(rootBase) {
18222
18491
  const recent = sinceCutoff ? rows.filter(isRecent).length : 0;
18223
18492
  totalDone += done; totalTasks += rows.length; totalWIP += wip; totalRecent += recent;
18224
18493
  const pct = rows.length ? Math.round(done / rows.length * 100) : 0;
18225
- projSummaries.push(`${path.basename(p)} ${done}/${rows.length}(${pct}%)`);
18494
+ const previewApproval = _previewApprovalSurface(p, false);
18495
+ projectApprovals.push({ project: path.basename(p), path: p, previewApproval });
18496
+ projSummaries.push(`${path.basename(p)} ${done}/${rows.length}(${pct}%) · ${_previewApprovalText(previewApproval)}`);
18226
18497
  }
18227
- log(`leerness compact (1.9.22): ${paths.length}프로젝트 · ${totalDone}/${totalTasks}(${totalTasks?Math.round(totalDone/totalTasks*100):0}%) done · WIP ${totalWIP}${sinceCutoff?` · 🆕${totalRecent}`:''}`);
18498
+ const workspaceApproval = _workspacePreviewApproval(projectApprovals);
18499
+ log(`leerness compact (1.9.22): ${paths.length}프로젝트 · ${totalDone}/${totalTasks}(${totalTasks?Math.round(totalDone/totalTasks*100):0}%) done · WIP ${totalWIP}${sinceCutoff?` · 🆕${totalRecent}`:''} · ${_previewApprovalText(workspaceApproval)}`);
18228
18500
  log(`projects: ${projSummaries.join(' | ')}`);
18229
18501
  log(`핵심 규칙: 의존성0 · 한국어주석 · UTF-8noBOM · reuse-map등록 · anti-lazy-work · verify-claim자동검수`);
18230
18502
  return;
@@ -18236,6 +18508,7 @@ function _handoffWorkspace(rootBase) {
18236
18508
  log('## 프로젝트별 진행 상태');
18237
18509
  let totalDone = 0, totalTasks = 0, totalWIP = 0, totalBlocked = 0, totalRecent = 0;
18238
18510
  const allRecent = [];
18511
+ const projectApprovals = [];
18239
18512
  for (const p of paths) {
18240
18513
  const rows = readProgressRows(p);
18241
18514
  const buckets = {};
@@ -18250,7 +18523,9 @@ function _handoffWorkspace(rootBase) {
18250
18523
  const nx = (buckets['in-progress']?.[0]) || (buckets['planned']?.[0]) || null;
18251
18524
  const pct = rows.length ? Math.round(done / rows.length * 100) : 0;
18252
18525
  const recentBadge = recent.length ? ` · 🆕 ${recent.length}` : '';
18253
- log(` ${path.basename(p)}: ${done}/${rows.length} (${pct}%) · WIP ${wip} · planned ${planned}${blocked ? ` · 🚫 blocked ${blocked}` : ''}${recentBadge}`);
18526
+ const previewApproval = _previewApprovalSurface(p, false);
18527
+ projectApprovals.push({ project: path.basename(p), path: p, previewApproval });
18528
+ log(` ${path.basename(p)}: ${done}/${rows.length} (${pct}%) · WIP ${wip} · planned ${planned}${blocked ? ` · 🚫 blocked ${blocked}` : ''}${recentBadge} · ${_previewApprovalText(previewApproval)}`);
18254
18529
  if (nx) log(` └ 다음: ${nx.id} [${nx.status}] ${nx.nextAction || nx.request}`);
18255
18530
  }
18256
18531
  // 1.9.18: --since 모드일 때 최근 추가/수정 섹션
@@ -18266,6 +18541,7 @@ function _handoffWorkspace(rootBase) {
18266
18541
  log(`## 📊 워크스페이스 총합`);
18267
18542
  log(` - 누적 task: ${totalTasks} (done ${totalDone}, ${totalTasks ? Math.round(totalDone / totalTasks * 100) : 0}%)`);
18268
18543
  log(` - 진행중 (WIP): ${totalWIP} · 차단: ${totalBlocked}${sinceCutoff ? ` · 🆕 최근 ${totalRecent}` : ''}`);
18544
+ log(` - 미리보기 승인: ${_previewApprovalText(_workspacePreviewApproval(projectApprovals))}`);
18269
18545
  if (totalBlocked > 0) log(` - ⚠ ${totalBlocked}건이 blocked — 우선 처리 검토`);
18270
18546
  log('');
18271
18547
  log(`## 💡 멀티에이전트 오케스트레이션 권장`);
@@ -21539,7 +21815,7 @@ function sessionClose(root, opts = {}) { // 1.36.132 (검수): handoff 는 --no-
21539
21815
  // (실측: handoff --no-record → 0건, session close --no-record → 1건). 같은 스토어의 같은 계약은 같은 문장으로.
21540
21816
  _captureDirectMcpPresenceMarker(); // hook/--no-record여도 nested skill/drift child에는 일회성 MCP marker를 물려주지 않는다
21541
21817
  if (!has('--no-record') && process.env.LEERNESS_HOOK !== '1') { try { _sessionPresenceRecord(root, 'close'); } catch {} } // 1.36.129 (P-0016 P1): close 는 '기록됨' 일 뿐 '프로세스가 죽었다' 가 아니다
21542
- 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)
21818
+ 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, previewApprovalState: _clar.previewApprovalState, _withLock }); } // 1.17.6 (UR-0049): 마감 정합 — done 낙관 재확인 + 시크릿 재확인 · 1.36.108: 락 주입(T-0097)
21543
21819
 
21544
21820
  function readmeCmd(root) { syncReadme(absRoot(root)); }
21545
21821
  function consistencyCheck(root) {
@@ -26170,7 +26446,10 @@ function mcpServeCmd(root) {
26170
26446
  try {
26171
26447
  cliArgs = _mcpToCliArgs(name, args, targetPath);
26172
26448
  if (cliArgs === null) return send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Unknown tool: ${name}` } });
26173
- try { _bumpMcpUsage(targetPath, name); } catch {} // 알려진 도구만 통계 기록(unknown tool 의 임의경로 쓰기 차단)
26449
+ const _mcpReadOnly = !!(_toolDef && _toolDef.requiredTier === 'read-only');
26450
+ // A read-only tool may not mutate the target merely to count itself.
26451
+ // Writable tools retain existing per-project MCP telemetry.
26452
+ if (!_mcpReadOnly) { try { _bumpMcpUsage(targetPath, name); } catch {} }
26174
26453
  // 1.9.288 (Codex gpt-5.5 리뷰 #1 수렴): MCP 도구도 policy enforce 적용 — read-only enforce 시 write 도구 차단.
26175
26454
  // 이전: _policyEnforce 는 agents multi --execute 한 곳뿐 → MCP state_start 등이 정책 우회하고 .leerness 기록.
26176
26455
  // cliArgs(실제 실행 명령) 로 required tier 판정 → enforce ON 이고 초과 시 JSON-RPC error 반환(실행 안 함).
@@ -26186,8 +26465,10 @@ function mcpServeCmd(root) {
26186
26465
  // 실제로 받은 **직접 handoff/close**에만 붙인다. state/agent-mode/일반 내부 스폰은
26187
26466
  // presence 억제 예외가 아니며, sessionKey 자체는 상태 귀속용 env로만 전달한다.
26188
26467
  const _lifecycle = name === 'leerness_handoff' || name === 'leerness_session_close';
26189
- const _mcpEnv = _sk ? { LEERNESS_SESSION_ID: _sk } : null;
26190
- if (_mcpEnv && _lifecycle) _mcpEnv.LEERNESS_MCP_ADDRESS_EXPLICIT = '1';
26468
+ const _mcpEnv = {};
26469
+ if (_sk) _mcpEnv.LEERNESS_SESSION_ID = _sk;
26470
+ if (_sk && _lifecycle) _mcpEnv.LEERNESS_MCP_ADDRESS_EXPLICIT = '1';
26471
+ if (_mcpReadOnly) _mcpEnv.LEERNESS_NO_AUTO_WORKSPACE_MIGRATION = '1';
26191
26472
  const r = callLeerness(cliArgs, _mcpEnv);
26192
26473
  // 1.9.61: cursor 기반 페이지네이션 — 긴 출력은 cursor offset로 다음 청크
26193
26474
  const fullText = r.stdout || r.stderr || '(no output)';
@@ -30620,7 +30901,7 @@ async function main() {
30620
30901
  // (scan secrets 처럼 '파일 경로' 가 정상 입력인 명령도 있어, 디렉토리 강제는 그 명령들엔 맞지 않는다.)
30621
30902
  const _strict = new Set(['pulse', 'round-history', 'milestones', 'session-resume',
30622
30903
  'tech', 'library', 'retro', 'insights', 'reuse-map', 'py-check', 'pre-wake-audit']);
30623
- const _strictPair = { memory: 'status', consistency: 'check', env: 'check', idempotency: 'audit', 'api-skill': 'list' };
30904
+ const _strictPair = { memory: 'status', consistency: 'check', env: 'check', idempotency: 'audit', 'api-skill': 'list', release: 'cadence' };
30624
30905
  const _isStrict = _strict.has(cmd) || (_strictPair[cmd] && args[1] === _strictPair[cmd]);
30625
30906
  if (_isStrict) {
30626
30907
  // 사용자가 '경로를 명시했을 때만' 검사한다 — 미지정(cwd)은 항상 존재하므로 검사할 것이 없다.
@@ -30634,7 +30915,9 @@ async function main() {
30634
30915
  // (좁히다가 false-BLOCK 을 false-PASS 로 바꾸면 1.36.101 이 잡던 바로 그 증상이 되돌아온다).
30635
30916
  const _SUBS = { library: new Set(['page']) };
30636
30917
  const _looksPath = (a) => /^([A-Za-z]:[\\/]|\/|\.\.?[\\/])/.test(a);
30637
- let _cands = [args[1], args[2]];
30918
+ // Paired commands own args[1] as their subcommand. Keep args[2] as a
30919
+ // positional path even when its literal text happens to equal that verb.
30920
+ let _cands = (_strictPair[cmd] && args[1] === _strictPair[cmd]) ? [args[2]] : [args[1], args[2]];
30638
30921
  if (_SUBS[cmd] && args[1] && !args[1].startsWith('-')) {
30639
30922
  if (_SUBS[cmd].has(args[1])) _cands = [null, args[2]]; // 하위명령 — 경로 아님
30640
30923
  // codex 검수 #4 는 "존재하는 디렉토리면 맨이름도 경로로 받아 달라" 였다 — 받지 않는다.
@@ -30649,13 +30932,13 @@ async function main() {
30649
30932
  return;
30650
30933
  }
30651
30934
  }
30652
- const _explicit = arg('--path', null) || _cands.find(a => a && !a.startsWith('-') && a !== _strictPair[cmd]);
30935
+ const _explicit = arg('--path', null) || _cands.find(a => a && !a.startsWith('-'));
30653
30936
  if (_explicit) {
30654
30937
  const _abs = absRoot(_explicit);
30655
30938
  if (!exists(_abs) || !fs.statSync(_abs).isDirectory()) {
30656
30939
  // 로케일을 붙인다 — en 프로젝트에서 이 가드만 한국어를 내면 그 자체가 i18n 누수다.
30657
30940
  const _en = _uiLang(_abs) === 'en' || _uiLang(process.cwd()) === 'en';
30658
- failJson(has('--json'), 'path_not_found', _en ? `path not found or not a directory: ${_abs}` : `경로 없음 또는 디렉토리 아님: ${_abs}`);
30941
+ failJson(has('--json'), 'path_not_found', _lineSafe(_en ? `path not found or not a directory: ${_abs}` : `경로 없음 또는 디렉토리 아님: ${_abs}`));
30659
30942
  return;
30660
30943
  }
30661
30944
  }
@@ -30685,9 +30968,9 @@ async function main() {
30685
30968
  const _abs = absRoot(_p);
30686
30969
  if (!exists(_abs)) {
30687
30970
  const _en = _uiLang(process.cwd()) === 'en';
30688
- failJson(has('--json'), 'path_not_found', _en
30971
+ failJson(has('--json'), 'path_not_found', _lineSafe(_en
30689
30972
  ? `path not found: ${_abs} — no directory was created`
30690
- : `경로 없음: ${_abs} — 디렉토리를 새로 만들지 않았습니다`);
30973
+ : `경로 없음: ${_abs} — 디렉토리를 새로 만들지 않았습니다`));
30691
30974
  return;
30692
30975
  }
30693
30976
  // 1.36.148 (재검수 P2): 존재성만 봐서 **일반 파일**이 프로젝트 root 로 통과했고,
@@ -30696,9 +30979,9 @@ async function main() {
30696
30979
  try {
30697
30980
  if (!fs.statSync(_abs).isDirectory()) {
30698
30981
  const _en2 = _uiLang(process.cwd()) === 'en';
30699
- failJson(has('--json'), 'path_not_a_directory', _en2
30982
+ failJson(has('--json'), 'path_not_a_directory', _lineSafe(_en2
30700
30983
  ? `--path is not a directory: ${_abs}`
30701
- : `--path 가 디렉토리가 아닙니다: ${_abs}`);
30984
+ : `--path 가 디렉토리가 아닙니다: ${_abs}`));
30702
30985
  return;
30703
30986
  }
30704
30987
  } catch { /* stat 실패는 위 존재 검사에서 이미 걸러졌다 */ }
@@ -30711,7 +30994,7 @@ async function main() {
30711
30994
  // 1.36.104: 'mcp' 를 제외한다. MCP 서버는 프로젝트 명령이 아니라 상주 프로세스라, 서버의 cwd 가 우연히
30712
30995
  // leerness 프로젝트면 거기에 `mcp: N` 이 쌓였다 — 사용자가 그 프로젝트에서 쓴 적 없는 '명령' 이 통계에 뜬다
30713
30996
  // (실측: 대상 프로젝트는 "사용 기록 없음", 무관한 cwd 는 "총 3회 호출"). 도구별 귀속은 _bumpMcpUsage 가 이미 한다.
30714
- if (process.env.LEERNESS_INTERNAL !== '1' && cmd !== 'usage' && cmd !== 'init' && cmd !== 'migrate' && cmd !== 'mcp' && cmd !== '--version' && cmd !== '--help') {
30997
+ if (process.env.LEERNESS_INTERNAL !== '1' && !_mustRemainReadOnly(args, cmd) && cmd !== 'usage' && cmd !== 'init' && cmd !== 'migrate' && cmd !== 'mcp' && cmd !== '--version' && cmd !== '--help') {
30715
30998
  try {
30716
30999
  // 1.9.352 (UR-0069 외부리뷰): usage 루트 — --path 우선, 없으면 args[1] 이 .leerness 보유 디렉토리일 때만 path(positional 보존), 아니면 cwd. (이전: args[1] 무조건 path 가정 → subcommand[decision add 등] root=cwd/add → .leerness 못 찾아 미집계)
30717
31000
  const _pathArg = arg('--path', null);
@@ -31360,7 +31643,11 @@ async function main() {
31360
31643
  const _pvToks = args.slice(1);
31361
31644
  // 1.36.80 (UR-0067): 라이브 미리보기 — serve(로컬 서버) / mode(self|project) 는 별도 모듈로 위임
31362
31645
  const _pvRoot = arg('--path', process.cwd());
31363
- if (_pvToks[0] === 'serve') return _pvsrv.previewServeCmd(_pvRoot, _pvToks[1], { has, arg, previews: _clar._loadPreviews(_pvRoot) });
31646
+ if (_pvToks[0] === 'serve') {
31647
+ const _pvChecked = _clar._loadPreviewsChecked(_pvRoot);
31648
+ if (_clar._failInvalidPreviewStore(_pvRoot, _pvChecked, has('--json'))) return;
31649
+ return _pvsrv.previewServeCmd(_pvRoot, _pvToks[1], { has, arg, previews: _pvChecked.list });
31650
+ }
31364
31651
  if (_pvToks[0] === 'mode') return _pvsrv.previewModeCmd(_pvRoot, _pvToks[1], { has, arg });
31365
31652
  return _clar.previewCmd(_pvRoot, _pvToks[0], _pvToks.slice(1), { has, arg, _withLock, _onResolved: (id) => _pvsrv.cleanupServeDir(_pvRoot, id) }); // 1.36.54 (#2): 변경은 락 직렬화 · 1.36.80: 승인/수정 시 임시 워크스페이스 정리
31366
31653
  }
@@ -31543,7 +31830,7 @@ async function main() {
31543
31830
  if (cmd === 'release' && args[1] === 'cleanup') return releaseCleanupCmd((args[2] && !args[2].startsWith('-')) ? args[2] : arg('--path', process.cwd()));
31544
31831
  // 1.9.275 (UR-0026): leerness release channel — 안정/실험 채널 정책 (npm dist-tag)
31545
31832
  if (cmd === 'release' && args[1] === 'channel') return releaseChannelCmd((args[2] && !args[2].startsWith('-')) ? args[2] : arg('--path', process.cwd()));
31546
- if (cmd === 'release' && args[1] === 'cadence') return releaseCadenceCmd((args[2] && !args[2].startsWith('-')) ? args[2] : arg('--path', process.cwd())); // 1.9.374 (UR-0074): 릴리스 빈도 진단
31833
+ if (cmd === 'release' && args[1] === 'cadence') return releaseCadenceCmd(_resolveRoot(args[2])); // 1.9.374/T-0095: --path > positional > cwd 단일 해석
31547
31834
  // 1.9.141: feature causality graph
31548
31835
  // 1.36.2 (clean-room, UR-0184): add/show/link/impact 도 trailing positional path 인식 — list(1.9.412)만 지원하던 것을 일관 확대.
31549
31836
  // 기존엔 add 가 모든 non-flag positional 을 NAME 으로 join → 경로가 이름에 흡수 + cwd 에 stray .leerness scaffold(조용한 오독).
@@ -31618,11 +31905,35 @@ async function main() {
31618
31905
  const root = absRoot(arg('--path', args[3] && !args[3].startsWith('-') ? args[3] : process.cwd()));
31619
31906
  return memoryArchiveListCmd(root, { json: has('--json') });
31620
31907
  }
31908
+ if (cmd === 'memory' && args[1] === 'archive') {
31909
+ const child = args[2] || null;
31910
+ const _memLang = _uiLang(arg('--path', process.cwd()));
31911
+ if (!child) {
31912
+ failJson(has('--json'), 'subcommand_required', _tx(_memLang,
31913
+ 'memory archive 하위명령 필요 — 사용법: leerness memory archive list',
31914
+ 'memory archive subcommand required — usage: leerness memory archive list'));
31915
+ } else {
31916
+ const safeChild = _lineSafe(child);
31917
+ failJson(has('--json'), 'unknown_subcommand', _tx(_memLang,
31918
+ `알 수 없는 memory archive 하위명령: ${safeChild} — 가능: list`,
31919
+ `Unknown memory archive subcommand: ${safeChild} — valid: list`));
31920
+ }
31921
+ return process.exit(process.exitCode || 1);
31922
+ }
31621
31923
  // 1.9.128: memory restore — archive 블록을 active 파일로 복귀 (DELETE→RESTORE cycle)
31622
31924
  if (cmd === 'memory' && args[1] === 'restore') {
31623
31925
  const root = absRoot(arg('--path', process.cwd()));
31624
31926
  return memoryRestoreCmd(root, args[2], args[3]);
31625
31927
  }
31928
+ if (cmd === 'memory' && args[1]) {
31929
+ const sub = args[1];
31930
+ const safeSub = _lineSafe(sub);
31931
+ const _memLang = _uiLang(arg('--path', process.cwd()));
31932
+ failJson(has('--json'), 'unknown_subcommand', _tx(_memLang,
31933
+ `알 수 없는 memory 하위명령: ${safeSub} — leerness memory search <query> | status | archive list | restore <surface> <target>`,
31934
+ `Unknown memory subcommand: ${safeSub} — leerness memory search <query> | status | archive list | restore <surface> <target>`));
31935
+ return process.exit(process.exitCode || 1);
31936
+ }
31626
31937
  // 1.9.112: lesson save — lessons.md에 새 lesson 추가
31627
31938
  // 1.9.117: lesson list — lessons.md 조회 + --tag 필터 + --json
31628
31939
  if (cmd === 'lesson') {
@@ -31702,6 +32013,19 @@ if (require.main === module) {
31702
32013
  `이 명령은 --dry-run 을 구현하지 않았습니다 — ${err.file} 을(를) 쓰려다 멈췄습니다. 변경된 것은 없습니다(--dry-run 을 빼고 실행하세요).`);
31703
32014
  process.exit(1);
31704
32015
  }
32016
+ if (err && /^E_WORKSPACE_DIR_[A-Z_]+$/.test(String(err.code || ''))) {
32017
+ // Resolver policy failures are part of the public read/status result.
32018
+ // Do not downgrade split-brain/invalid overrides to a generic error or
32019
+ // to harnessVersionState:"unreadable".
32020
+ failJson(process.argv.includes('--json'), String(err.code).slice(2).toLowerCase(),
32021
+ _lineSafe(err && err.message ? err.message : String(err)));
32022
+ process.exit(1);
32023
+ }
32024
+ if (err && err.code === 'E_GIT_HISTORY_UNAVAILABLE') {
32025
+ failJson(process.argv.includes('--json'), 'git_history_unavailable',
32026
+ _lineSafe(err && err.message ? err.message : 'Git history could not be measured'));
32027
+ process.exit(1);
32028
+ }
31705
32029
  fail(err && err.message ? err.message : String(err));
31706
32030
  // 1.36.88: 완료 게이트는 upsertProgress 에서 던진다 — taskUpdate/taskSync 외의 경로로 올라와도
31707
32031
  // 스택 트레이스 대신 탈출구를 보여준다(막힌 사용자가 빠져나갈 방법을 알아야 한다).
@@ -31720,6 +32044,7 @@ if (require.main === module) {
31720
32044
  // _isSecretKey (시크릿 차단, 보안), compareVer (버전 비교, stale-check 정확성), parseHarnessVersion (버전 파싱).
31721
32045
  module.exports = {
31722
32046
  VERSION,
32047
+ _cliMutationClass, _mustRemainReadOnly,
31723
32048
  _npmGlobalBin, _dirInPath, _leernessResolvable, _pathDiagnose, _registerPath,
31724
32049
  _winPathPsScript, _unixPathBlock, pathSetupCmd,
31725
32050
  _isSecretKey, compareVer, parseHarnessVersion,
@@ -31776,5 +32101,6 @@ module.exports = {
31776
32101
  _capLensDomains, _scanLensSignals,
31777
32102
  _handoffNudgeState, _getLastHandoffGap, _recordLastHandoff, _recordHandoffFreshness,
31778
32103
  _releaseFileSnapshot, _runReleaseTransaction,
31779
- _detectOptimism, _scanCodeForPatterns
32104
+ _detectOptimism, _scanCodeForPatterns,
32105
+ _parseRoundTagOutput, _parseRecentChangeOutput, _computeRoundHistory, _computeMilestones, _computeRecentChanges, _gitHistoryFailureFromResult
31780
32106
  };