leerness 1.36.298 → 1.36.299

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +4 -4
  3. package/bin/leerness.js +14 -4
  4. package/lib/copy-tree.js +5 -1
  5. package/lib/runtime-writes.js +24 -4
  6. package/package.json +6 -4
  7. package/scripts/bugfix-areas-probe.js +1 -1
  8. package/scripts/copy-tree-probe.js +17 -0
  9. package/scripts/done-status-probe.js +1 -1
  10. package/scripts/e2e-lived-in-harness.js +2 -2
  11. package/scripts/fix-evidence-language-probe.js +1 -1
  12. package/scripts/i18n-next-cluster-probe.js +1 -1
  13. package/scripts/memory-archive-check-probe.js +1 -1
  14. package/scripts/memory-archive-retention-probe.js +2 -2
  15. package/scripts/memory-arena-cleanup-probe.js +106 -0
  16. package/scripts/memory-checked-input-probe.js +1 -1
  17. package/scripts/memory-guard-order-probe.js +1 -1
  18. package/scripts/memory-input-preflight-probe.js +1 -1
  19. package/scripts/memory-legacy-retention-probe.js +2 -2
  20. package/scripts/memory-migration-test-support.js +40 -2
  21. package/scripts/memory-output-publication-probe.js +1 -1
  22. package/scripts/memory-policy-root-spelling-probe.js +16 -6
  23. package/scripts/memory-projection-retention-probe.js +1 -1
  24. package/scripts/memory-recovery-external-probe.js +68 -1
  25. package/scripts/memory-recovery-receipt-probe.js +1 -1
  26. package/scripts/memory-restore-canonical-probe.js +1 -1
  27. package/scripts/memory-utf8-admission-probe.js +1 -1
  28. package/scripts/path-setup-powershell-probe.js +15 -0
  29. package/scripts/release-bump-probe.js +1 -1
  30. package/scripts/review-request-labels-probe.js +1 -1
  31. package/scripts/review-request-signals-probe.js +1 -1
  32. package/scripts/skill-archive-probe.js +71 -0
  33. package/scripts/store-values-probe.js +9 -4
  34. package/scripts/task-sync-roundtrip-probe.js +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.299 - 2026-10-12
4
+
5
+ - Fixed: on Windows, `leerness skill publish` made a `.zip` instead of the `.tgz` when Git for Windows' GNU tar came first on PATH, as it does in Git Bash and in agent shells built on it (T-0352).
6
+ - Cause: the archive step gave `tar -czf` the absolute output path, and GNU tar reads the drive letter `C:` as a remote host ("Cannot connect to C:"). The step failed, and the Compress-Archive fallback made a `.zip`.
7
+ - The archive is now named relative to the `.leerness` folder, with forward slashes (`skills-publish-tarball/leerness-skills-<version>.tgz`). Windows' own tar (bsdtar) accepts that form too.
8
+ - Fixed: on Windows, `leerness path-setup --apply` could take a different folder for the npm bin folder and not add it (T-0353).
9
+ - Cause: it compared Path entries with PowerShell's `-contains`. That comparison uses the culture and ignores characters such as a soft hyphen (U+00AD), so an entry that differed from the bin folder only by such a character counted as present. Measured in Windows PowerShell 5.1 and PowerShell 7.
10
+ - Entries are now compared by ordinal value, letter case aside.
11
+ - Fixed: the recovery bundle that a memory write saves to the temp folder after a runtime-layout failure could be written inside the project (T-0349). The check that keeps it out compared the project and the temp folder as spelled.
12
+ - When the temp folder was named by an 8.3 short name (GitHub's Windows runners use `C:\Users\RUNNER~1\...`) or reached through a link, it could lie inside the project while its spelling did not.
13
+ - The check also took a folder inside the project whose name starts with two dots, such as `..temp`, for a folder outside it.
14
+ - The check now also compares their real paths, and only a first path part of exactly `..` counts as outside. A temp folder that does not resolve is refused, and so is a project path that fails to resolve for any reason other than being gone. A project path that no longer exists, because the project was renamed or removed during the operation, is compared as spelled only, as before, so its recovery is still saved.
15
+ - The bundle is now written below the temp folder's real path, the one that was checked, and the recovery folder it reports is named by that path. A temp link retargeted into the project after the check can no longer carry the bundle there.
16
+ - Tests (T-0347, T-0349, T-0352, T-0353, T-0354, T-0355):
17
+ - `scripts/skill-archive-probe.js` (`npm run test:skill-archive`, in `test`, `test:core` and `test:fast`) asks the archive step for the `.tgz` with each tar the machine has first on PATH, and checks that tar made it, with the expected entries. Here that is Windows' bsdtar and Git for Windows' GNU tar. With the old absolute path it passes 2 of 6.
18
+ - `scripts/memory-recovery-external-probe.js` (21 tests) adds nine. The old check fails eight of them:
19
+ - A temp folder inside the project, named through a link outside the project or by its 8.3 short name, must be refused before any recovery work. So must a folder inside the project whose name starts with two dots, named directly or through a link.
20
+ - A temp folder that does not resolve must be refused, also when the project path is gone. So must a project path that fails to resolve with an error other than "not found" (simulated). With the old check, an unresolvable temp folder failed the recovery later, with ENOENT.
21
+ - A temp link retargeted into the project while the check runs (simulated) must not carry the bundle into the project.
22
+ - A project renamed away during the operation must still get its recovery outside it. The old check passes this one.
23
+ - `scripts/path-setup-powershell-probe.js` (19 checks):
24
+ - An entry that differs from the bin folder by a soft hyphen must not count as present. The old script fails it.
25
+ - Every registry run now requires the parser check first. With a command injected into the script, no registry run happened. With that requirement removed, the injected command ran.
26
+ - `scripts/memory-policy-root-spelling-probe.js`: the MCP checks and the resolve check use the first non-canonical spelling the machine has. Before, they always used the lowercase drive letter, which a UNC path does not have. They print SKIP when there is none.
27
+ - The shared support of the memory probes (`scripts/memory-migration-test-support.js`) now removes its temp arena when every test passed, and keeps it when one failed. Links inside the arena are removed as links first, so their targets are left alone. An arena, or a folder in it, that was replaced with a link is neither walked nor removed. This holds also when the replacement happens while `finish()` runs: each folder must still resolve inside the arena just before it is listed and before a link in it is removed. A process that kept replacing folders during `finish()` could still race those checks; `finish()` runs after the tests.
28
+ - Before, every run left one arena (1,206 were in this machine's temp folder on 2026-10-11).
29
+ - `scripts/memory-arena-cleanup-probe.js` (`npm run test:memory-arena-cleanup`, in `test`, `test:core` and `test:fast`) checks five cases with links to folders outside the arena, three of them with a replacement before or during `finish()`. On the old support all five fail: the removal case because nothing is removed, the failing-run and replace-before cases only on the note that `finish()` now prints, and the two replacement-during cases because the old `finish()` never looks at the arena, so the replacement never happens.
30
+ - The 24 `fs.cpSync` copies in 21 test files now use `lib/copy-tree.js` (T-0347). On Windows with Node 22.23.3, `fs.cpSync` ends the process with an access violation when a path has non-ASCII letters, as the temp folder does for a Windows user name in Korean.
31
+ - Measured with the temp folder set to `...\AppData\Local\Temp\임시한글` on Node 22.23.3: on the old code all 21 files crashed (Segmentation fault, exit 139 in Git Bash). With the change, 20 passed.
32
+ - The 21st, `scripts/store-values-probe.js`, failed one language check, which read the Hangul in the temp path as Korean text. Its language checks now mask the test folder's path first, and it passes.
33
+ - On Windows, `lib/copy-tree.js` now resolves a relative directory link against the copy's own folder, so the copied link names its sibling in the copy. Before, Node resolved the junction target against the process's working folder. On POSIX the relative link is copied as written and names the same sibling. `fs.cpSync` instead points such a link back into the source tree.
34
+ - `scripts/copy-tree-probe.js` checks this where links can be made. A Windows machine without the symlink privilege skips it.
35
+
3
36
  ## 1.36.298 - 2026-10-11
4
37
 
5
38
  - Fixed: on Windows, `leerness path-setup --apply` never registered the npm bin folder in the user PATH (T-0351). This was so from 1.9.254.
package/README.md CHANGED
@@ -195,7 +195,7 @@ MIT
195
195
  <!-- leerness:project-readme:start -->
196
196
  ## Leerness Project Harness
197
197
 
198
- 이 프로젝트는 Leerness v1.36.298 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
198
+ 이 프로젝트는 Leerness v1.36.299 하네스를 사용합니다. AI 에이전트는 작업 전 `leerness handoff`로 컨텍스트를 적재하고, 작업 후 `leerness check`/`leerness audit`/`leerness session close`를 수행해야 합니다.
199
199
 
200
200
  ### 정체성 — AI 에이전트 운영 레이어 (UR-0030)
201
201
 
@@ -249,7 +249,7 @@ leerness memory restore decision <date|title>
249
249
 
250
250
  ### MCP server (외부 AI 통합)
251
251
 
252
- Leerness v1.36.298는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **102개 도구**를 노출:
252
+ Leerness v1.36.299는 stdio JSON-RPC MCP server를 내장합니다 — Claude Code · Cursor · Codex CLI 등 외부 AI에 **102개 도구**를 노출:
253
253
 
254
254
  ```jsonc
255
255
  // 카테고리별
@@ -270,7 +270,7 @@ Leerness v1.36.298는 stdio JSON-RPC MCP server를 내장합니다 — Claude Co
270
270
  `<<autonomous-loop-dynamic>>` 신호만 보내면 AI가:
271
271
  1) 다음 라운드 후보 선정 → 2) 코드 변경 → 3) 회귀 테스트 갱신 → 4) 전체 e2e 스위트 통과 → 5) npm publish + git tag → 6) main push → 7) session close → 8) 다음 라운드 예약.
272
272
 
273
- 현재 사용 중인 하네스 버전: **1.36.298**. 공개 릴리스 여부는 공개 저장소의 태그와 배포 기록에서 별도로 확인합니다.
273
+ 현재 사용 중인 하네스 버전: **1.36.299**. 공개 릴리스 여부는 공개 저장소의 태그와 배포 기록에서 별도로 확인합니다.
274
274
 
275
275
  ### 성능 가이드
276
276
 
@@ -308,7 +308,7 @@ leerness release pack --close --auto-main-push
308
308
  - `.leerness/session-handoff.md`: 다음 세션 인수인계 (자동 작성)
309
309
  - `.leerness/lessons.md` / `decisions.md` / `rules.md`: 영구 메모리 (5 surface)
310
310
 
311
- Last synced by Leerness v1.36.298: 2026-09-30
311
+ Last synced by Leerness v1.36.299: 2026-09-30
312
312
  <!-- leerness:project-readme:end -->
313
313
  GitHub 로그인 계정 확인용 서버 어댑터: [설정·연결·승인과의 구분](docs/github-login.md). 실제 앱 등록/브라우저 세션 연결과 대상별 승인은 후속입니다.
314
314
 
package/bin/leerness.js CHANGED
@@ -115,7 +115,7 @@ const {
115
115
  migrateLegacyWorkspace,
116
116
  } = require('../lib/workspace-dir');
117
117
 
118
- const VERSION = '1.36.298';
118
+ const VERSION = '1.36.299';
119
119
 
120
120
  // MCP lifecycle 주소 표식은 현재 CLI 호출 한 번에만 유효하다. CLI bootstrap에서 즉시 env에서
121
121
  // 떼어 두어 `--no-record`/hook처럼 presence 기록 함수에 도달하지 않는 경로도 후속 child에 유출하지 않는다.
@@ -3998,7 +3998,7 @@ function _pathDiagnose() {
3998
3998
  // do to the user Path (review round 1): the read expands %VAR% entries with this process's values, and the write
3999
3999
  // stores REG_SZ, so a REG_EXPAND_SZ Path (the Windows default, e.g. %USERPROFILE%\...\WindowsApps) would lose its
4000
4000
  // references. The script reads the raw value and its registry type and writes both back. An entry counts as present
4001
- // only as written (letter case aside): expanding %VAR% here would use this shell's variables, which new terminals may
4001
+ // only as written (ordinal, letter case aside): expanding %VAR% here would use this shell's variables, which new terminals may
4002
4002
  // not have, and could report EXISTS for a directory they cannot reach (review round 3; the owner chose this). The
4003
4003
  // cost is a harmless second entry when the Path already names the directory through a variable. Deleting a variable
4004
4004
  // that does not exist through SetEnvironmentVariable(..., 'User') writes nothing but sends the WM_SETTINGCHANGE that
@@ -4020,7 +4020,10 @@ function _winPathPsScript() {
4020
4020
  + "$kind = [Microsoft.Win32.RegistryValueKind]::ExpandString; "
4021
4021
  + "if ($names -contains 'Path') { $kind = $k.GetValueKind('Path') }; "
4022
4022
  + "if ($kind -ne [Microsoft.Win32.RegistryValueKind]::String -and $kind -ne [Microsoft.Win32.RegistryValueKind]::ExpandString) { throw ('the user Path has registry type ' + $kind) }; "
4023
- + "if ($raw.Split(';') -contains $b) { [Console]::Out.WriteLine('EXISTS') } "
4023
+ // T-0353: ordinal, case-insensitive. -contains compares with the culture, which ignores characters such as a
4024
+ // soft hyphen (U+00AD), so 'C:\np<U+00AD>m' counted as 'C:\npm' (measured in Windows PowerShell 5.1 and 7).
4025
+ + "$found = $false; foreach ($p in $raw.Split(';')) { if ([string]::Equals($p, $b, [StringComparison]::OrdinalIgnoreCase)) { $found = $true } }; "
4026
+ + "if ($found) { [Console]::Out.WriteLine('EXISTS') } "
4024
4027
  + "else { $nu = if ($raw -eq '') { $b } else { $raw.TrimEnd(';') + ';' + $b }; "
4025
4028
  + "$k.SetValue('Path', $nu, $kind); "
4026
4029
  + "[Environment]::SetEnvironmentVariable('LEERNESS_PATH_SETUP_NOTIFY', $null, 'User'); "
@@ -26493,7 +26496,12 @@ function _createArchive(cwd, sourceDir, outPath) {
26493
26496
  // 1) tar.gz (POSIX 환경에서 가장 안정)
26494
26497
  if (/\.(tgz|tar\.gz)$/i.test(outPath)) {
26495
26498
  tried.push('tar');
26496
- const r = _spawnPortableArgvSync('tar', ['-czf', outPath, sourceDir], {
26499
+ // T-0352: the archive is named relative to cwd, with forward slashes. Git for Windows' GNU tar, first on PATH in
26500
+ // Git Bash, reads the drive letter of an absolute Windows path as a remote host ("Cannot connect to C:") and
26501
+ // failed, so the .zip fallback ran instead. bsdtar accepts either form. A path on another drive stays absolute.
26502
+ const relativeOut = path.relative(cwd, outPath);
26503
+ const tarOut = relativeOut && !path.isAbsolute(relativeOut) ? relativeOut.split(path.sep).join('/') : outPath;
26504
+ const r = _spawnPortableArgvSync('tar', ['-czf', tarOut, sourceDir], {
26497
26505
  encoding: 'utf8', timeout: 30000, shell: false, cwd
26498
26506
  });
26499
26507
  if (r.status === 0 && exists(outPath)) return { ok: true, path: outPath, method: 'tar', tried };
@@ -34146,6 +34154,8 @@ module.exports = {
34146
34154
  _cliMutationClass, _mustRemainReadOnly, _runtimeProjectRoot,
34147
34155
  _npmGlobalBin, _dirInPath, _leernessResolvable, _pathDiagnose, _registerPath,
34148
34156
  _winPathPsScript, _unixPathBlock, pathSetupCmd,
34157
+ // T-0352: the skill publish archive chain (tar, then the zip fallbacks), for scripts/skill-archive-probe.js.
34158
+ _createArchive,
34149
34159
  _isSecretKey, compareVer, parseHarnessVersion,
34150
34160
  // 1.9.257: CJK 인코딩 분류 (UR-0014 계열) 순수 함수 — 단위 테스트
34151
34161
  _classifyCJK, _riskLabel,
package/lib/copy-tree.js CHANGED
@@ -12,10 +12,14 @@ const path = require('path');
12
12
  function copyTreeSync(src, dst) {
13
13
  const stat = fs.lstatSync(src);
14
14
  if (stat.isSymbolicLink()) {
15
- const target = fs.readlinkSync(src);
15
+ let target = fs.readlinkSync(src);
16
16
  let type;
17
17
  if (process.platform === 'win32') {
18
18
  try { type = fs.statSync(src).isDirectory() ? 'junction' : 'file'; } catch { type = 'file'; }
19
+ // T-0347: a junction's target must be absolute, and Node resolves a relative one against the process cwd.
20
+ // A relative directory link is resolved against the copy's own folder, so it still names the place it named
21
+ // relative to where it now is (fs.cpSync instead points it back into the source tree).
22
+ if (type === 'junction' && !path.isAbsolute(target)) target = path.resolve(path.dirname(dst), target);
19
23
  }
20
24
  fs.symlinkSync(target, dst, type);
21
25
  return;
@@ -159,13 +159,33 @@ function assertExternalRecoveryAllowed(root) {
159
159
  }
160
160
  const project = path.resolve(root);
161
161
  const base = path.resolve(os.tmpdir());
162
- const relative = path.relative(project, base);
163
- if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
162
+ // T-0349: compared as spelled and as real paths. A temp folder reached through a link, or named by an 8.3 short
163
+ // name (GitHub's Windows runners give C:\Users\RUNNER~1\...), can lie inside the project while its spelling does
164
+ // not, and the recovery bundle was then written into the project it must stay out of. A temp folder that cannot
165
+ // be resolved counts as unsafe. A project path that no longer exists (ENOENT or ENOTDIR: renamed or removed during
166
+ // the operation, one way this latch is reached) has nothing at it to lie inside, so only its spelling is compared;
167
+ // any other failure to resolve it counts as unsafe.
168
+ const unsafe = () => {
164
169
  const error = new Error(`External recovery temp must stay outside project root: ${base}`);
165
170
  error.code = 'E_RECOVERY_TEMP_UNSAFE';
166
171
  throw error;
167
- }
168
- return base;
172
+ };
173
+ // Outside means a first component of exactly '..' (a child folder may be named '..temp', review round 1).
174
+ const inside = (from, to) => {
175
+ const relative = path.relative(from, to);
176
+ return !(relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative));
177
+ };
178
+ const real = p => { try { return { path: fs.realpathSync.native(p) }; } catch (error) { return { error }; } };
179
+ const realBase = real(base), realProject = real(project);
180
+ if (!realBase.path) unsafe();
181
+ const missing = !realProject.path && ['ENOENT', 'ENOTDIR'].includes(realProject.error && realProject.error.code);
182
+ if (!realProject.path && !missing) unsafe();
183
+ if (inside(project, base)) unsafe();
184
+ if (realProject.path && inside(realProject.path, realBase.path)) unsafe();
185
+ // The bundle goes below the real path that was checked, not below the alias: a temp link retargeted into the project
186
+ // after this check would otherwise carry the bundle there (review round 2). A real folder replaced while the
187
+ // recovery runs is not covered, as for every path-based write here.
188
+ return realBase.path;
169
189
  }
170
190
 
171
191
  // Streaming recovery uses the same latch and external-only destination. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leerness",
3
- "version": "1.36.298",
3
+ "version": "1.36.299",
4
4
  "description": "The AI-coding operations layer that makes \"done\" require evidence — persistent memory, evidence-gated completion checks, and clean handoffs for any AI agent (Claude Code, Codex, Cursor). State lives as plain files in your repo. CLI + MCP, 0 runtime dependencies.",
5
5
  "keywords": [
6
6
  "leerness",
@@ -48,15 +48,15 @@
48
48
  "LICENSE"
49
49
  ],
50
50
  "scripts": {
51
- "test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && node ./scripts/e2e-lived-in-harness.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:github-login && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-publication && npm run test:ui-review-target && npm run test:ui-review-authorization && npm run test:ui-review-authority-host && npm run test:ui-review-authority-floor && npm run test:ui-review-proof-use && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:agent-chat-stream && npm run test:encoding-diagnostics && npm run test:managed-text && npm run test:plan-text && npm run test:session-close-text && npm run test:brief-text && npm run test:generation-text && npm run test:install-text && npm run test:authoring-language && npm run test:context-reading && npm run test:rule-text && npm run test:mode-text && npm run test:handoff-text && npm run test:audit-text && npm run test:audit-readme-text && npm run test:audit-env-text && npm run test:feature-graph-text && npm run test:env-sync-text && npm run test:teams-text && npm run test:reminder-text && npm run test:handoff-history-text && npm run test:env-snapshot-text && npm run test:context-current && npm run test:review-feedback && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && node ./scripts/review-control-probe.js && npm run test:ui-review-run && npm run test:rule-verification",
52
- "test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && node ./scripts/e2e-lived-in-harness.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:github-login && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-publication-smoke && npm run test:ui-review-target-smoke && npm run test:ui-review-authorization-smoke && npm run test:ui-review-authority-host-smoke && npm run test:ui-review-authority-floor-smoke && npm run test:ui-review-proof-use-smoke && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:review-feedback && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && node ./scripts/review-snapshot-transport-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && npm run test:ui-review-run && npm run test:rule-verification",
51
+ "test": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && node ./scripts/e2e-lived-in-harness.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && npm run test:skill-archive && npm run test:memory-arena-cleanup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:github-login && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-publication && npm run test:ui-review-target && npm run test:ui-review-authorization && npm run test:ui-review-authority-host && npm run test:ui-review-authority-floor && npm run test:ui-review-proof-use && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:agent-chat-stream && npm run test:encoding-diagnostics && npm run test:managed-text && npm run test:plan-text && npm run test:session-close-text && npm run test:brief-text && npm run test:generation-text && npm run test:install-text && npm run test:authoring-language && npm run test:context-reading && npm run test:rule-text && npm run test:mode-text && npm run test:handoff-text && npm run test:audit-text && npm run test:audit-readme-text && npm run test:audit-env-text && npm run test:feature-graph-text && npm run test:env-sync-text && npm run test:teams-text && npm run test:reminder-text && npm run test:handoff-history-text && npm run test:env-snapshot-text && npm run test:context-current && npm run test:review-feedback && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && npm run test:commands && npm run test:installed && node ./scripts/e2e.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && node ./scripts/review-control-probe.js && npm run test:ui-review-run && npm run test:rule-verification",
52
+ "test:core": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && node ./scripts/e2e-lived-in-harness.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && npm run test:skill-archive && npm run test:memory-arena-cleanup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/verify-code-cross-runtime-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:github-login && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-publication-smoke && npm run test:ui-review-target-smoke && npm run test:ui-review-authorization-smoke && npm run test:ui-review-authority-host-smoke && npm run test:ui-review-authority-floor-smoke && npm run test:ui-review-proof-use-smoke && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:review-feedback && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./bin/leerness.js --version && node ./bin/leerness.js selftest && node ./scripts/e2e-core.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/handoff-readonly-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && node ./scripts/review-snapshot-transport-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && npm run test:ui-review-run && npm run test:rule-verification",
53
53
  "test:commands": "npm run test:hooks && npm run test:e2e-observation && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/command-flags-probe.js && node ./scripts/dead-flags-probe.js && node ./scripts/free-text-args-probe.js && node ./scripts/title-args-probe.js && node ./scripts/false-claim-probe.js && node ./scripts/claim-sweep-git-probe.js && node ./scripts/claim-runtime-cache-probe.js && node ./scripts/runtime-cost-probe.js && node ./scripts/secret-scan-scope-probe.js && node ./scripts/handoff-preservation-probe.js && node ./scripts/next-action-suggestion-probe.js && node ./scripts/api-skill-match-probe.js && node ./scripts/crud-check-probe.js && node ./scripts/claims-baseline-probe.js && node ./scripts/claims-baseline-concurrency-probe.js && node ./scripts/e2e-command-surface.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-request-labels-probe.js && node ./scripts/review-request-signals-probe.js && node ./scripts/store-values-probe.js && node ./scripts/review-snapshot-transport-probe.js && node ./scripts/bugfix-areas-probe.js && node ./scripts/done-status-probe.js && node ./scripts/fix-evidence-language-probe.js && node ./scripts/decision-fence-offset-probe.js && node ./scripts/task-sync-roundtrip-probe.js && node ./scripts/tracker-write-language-probe.js && node ./scripts/write-path-language-probe.js && node ./scripts/write-primitive-language-probe.js && node ./scripts/dry-run-refusal-probe.js",
54
54
  "test:e2e-observation": "node ./scripts/e2e-temp-scope-probe.js && node ./scripts/e2e-child-diagnostics-probe.js",
55
55
  "test:next-actions": "node ./scripts/next-action-suggestion-probe.js && node ./scripts/api-skill-match-probe.js",
56
56
  "test:installed": "npm run test:installed-lifecycle && node ./scripts/installed-cleanroom-probe.js",
57
57
  "test:installed-lifecycle": "node ./scripts/installed-handoff-lifecycle-probe.js",
58
58
  "test:handoff": "node ./scripts/handoff-readonly-probe.js",
59
- "test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./scripts/false-claim-probe.js && node ./scripts/smoke.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && node ./scripts/review-snapshot-transport-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && npm run test:rule-verification",
59
+ "test:fast": "node ./scripts/lint.js && node ./scripts/workspace-dir-source-ratchet.js && npm run test:state-scopes && npm run test:runtime-compatibility && node ./scripts/workspace-dir-migration-probe.js && node ./scripts/workspace-dir-lock-order-probe.js && node ./scripts/lock-probe.js && node ./scripts/e2e-repo-guard.js && npm run test:lock-heartbeat && npm run test:temp-cleanup && npm run test:path-setup && npm run test:skill-archive && npm run test:memory-arena-cleanup && node ./scripts/platform-smoke.js && node ./scripts/parent-detect-selftest-probe.js && node ./scripts/release-runtime-probe.js && node ./scripts/which-shim-probe.js && node ./scripts/i18n-priority-surface-probe.js && npm run test:harness-text && npm run test:test-plan && node ./scripts/i18n-next-cluster-probe.js && node ./scripts/handoff-i18n-probe.js && node ./scripts/mutation-integrity-probe.js && node ./scripts/memory-guard-order-probe.js && node ./scripts/memory-input-preflight-probe.js && node ./scripts/memory-utf8-admission-probe.js && node ./scripts/memory-checked-input-probe.js && node ./scripts/memory-input-unit-probe.js && node ./scripts/memory-restore-canonical-probe.js && node ./scripts/memory-archive-retention-probe.js && node ./scripts/memory-archive-check-probe.js && node ./scripts/memory-projection-retention-probe.js && node ./scripts/memory-language-probe.js && node ./scripts/memory-legacy-retention-probe.js && node ./scripts/memory-recovery-receipt-probe.js && node ./scripts/memory-output-unit-probe.js && node ./scripts/memory-output-posix-probe.js && node ./scripts/memory-output-publication-probe.js && node ./scripts/memory-output-parent-race-probe.js && node ./scripts/mcp-presence-probe.js && node ./scripts/provider-capacity-probe.js && npm run test:provider-response && npm run test:provider-dispatch && npm run test:provider-receipt && npm run test:provider-observation && npm run test:ui-review-recovery && node ./scripts/file-lease-probe.js && node ./scripts/role-agent-schema-probe.js && node ./scripts/role-store-loader-probe.js && node ./scripts/role-fallback-probe.js && npm run test:agent-bench-output && npm run test:ledger-sync && npm run test:state-lock && npm run test:history-retention && node ./scripts/false-claim-probe.js && node ./scripts/smoke.js && node ./scripts/rule-applicability-probe.js && node ./scripts/review-checkpoint-probe.js && node ./scripts/review-checkpoint-transport-probe.js && node ./scripts/review-snapshot-probe.js && node ./scripts/review-snapshot-transport-probe.js && npm run test:memory-migration && npm run test:memory-items && npm run test:memory-budget && npm run test:memory-footprint && npm run test:memory-write-budget && npm run test:memory-write-preparation && npm run test:memory-write-admission && npm run test:memory-command-policy && npm run test:memory-publication-policy && npm run test:memory-recovery-transfer && npm run test:ui-boundary && npm run test:rule-verification",
60
60
  "test:false-claims": "node ./scripts/false-claim-probe.js",
61
61
  "test:claim-sweep": "node ./scripts/claim-sweep-git-probe.js",
62
62
  "test:claim-runtime": "node ./scripts/claim-runtime-cache-probe.js",
@@ -141,6 +141,8 @@
141
141
  "test:lock-heartbeat": "node ./scripts/lock-heartbeat-probe.js",
142
142
  "test:temp-cleanup": "node ./scripts/temp-cleanup-probe.js && node ./scripts/copy-tree-probe.js",
143
143
  "test:path-setup": "node ./scripts/path-setup-powershell-probe.js",
144
+ "test:skill-archive": "node ./scripts/skill-archive-probe.js",
145
+ "test:memory-arena-cleanup": "node ./scripts/memory-arena-cleanup-probe.js",
144
146
  "test:ui-review-publication": "node ./scripts/ui-review-publication-probe.js",
145
147
  "test:ui-review-publication-smoke": "node ./scripts/ui-review-publication-probe.js --smoke",
146
148
  "test:ui-review-target": "node ./scripts/ui-review-target-probe.js",
@@ -41,7 +41,7 @@ const seed = lang => {
41
41
  return (seeds[lang] = dir);
42
42
  };
43
43
  let copies = 0;
44
- const fresh = (lang = 'ko') => { const dir = path.join(arena, 'p' + (++copies)); fs.cpSync(seed(lang), dir, { recursive: true }); return dir; };
44
+ const fresh = (lang = 'ko') => { const dir = path.join(arena, 'p' + (++copies)); require('../lib/copy-tree').copyTreeSync(seed(lang), dir); return dir; };
45
45
  const runIn = (cwd, args, runEnv = env) => cp.spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8', timeout: 300000, windowsHide: true, env: runEnv });
46
46
  const show = r => `exit ${r.status}\n${String(r.stdout || '').slice(0, 1200)}${String(r.stderr || '').slice(0, 600)}`;
47
47
  const json = (r, label) => { const out = String(r.stdout || ''); try { return JSON.parse(out.slice(out.indexOf('{'))); } catch { throw new Error(label + ': not JSON — ' + show(r)); } };
@@ -50,6 +50,23 @@ try {
50
50
  assert.throws(() => copyTreeSync(from, to), error => error.code === 'EEXIST');
51
51
  assert.strictEqual(fs.readFileSync(path.join(to, 'bytes.bin'), 'utf8'), 'kept');
52
52
  });
53
+ // T-0347: a relative directory link must still name its sibling inside the copy. On Windows the copy is a junction,
54
+ // whose target must be absolute; Node resolved the relative one against the process cwd.
55
+ {
56
+ const from = path.join(arena, 'src-relative-link'), to = path.join(arena, 'dst-relative-link');
57
+ fs.mkdirSync(path.join(from, 'sibling'), { recursive: true });
58
+ fs.writeFileSync(path.join(from, 'sibling', 'mark.txt'), 'source');
59
+ let made = true;
60
+ try { fs.symlinkSync('sibling', path.join(from, 'link'), 'dir'); }
61
+ catch (error) { if (error.code !== 'EPERM') throw error; made = false; }
62
+ if (!made) console.log('SKIP a relative directory link (creating one needs the symlink privilege here)');
63
+ else test('a relative directory link names its sibling inside the copy', () => {
64
+ copyTreeSync(from, to);
65
+ fs.writeFileSync(path.join(to, 'sibling', 'mark.txt'), 'copy');
66
+ assert.strictEqual(fs.readFileSync(path.join(to, 'link', 'mark.txt'), 'utf8'), 'copy');
67
+ assert.strictEqual(fs.realpathSync.native(path.join(to, 'link')), fs.realpathSync.native(path.join(to, 'sibling')));
68
+ });
69
+ }
53
70
  test('a missing source is an error, not an empty copy', () => {
54
71
  assert.throws(() => copyTreeSync(path.join(arena, 'missing'), path.join(arena, 'dst-missing')), error => error.code === 'ENOENT');
55
72
  assert.ok(!fs.existsSync(path.join(arena, 'dst-missing')));
@@ -34,7 +34,7 @@ const seed = () => {
34
34
  return (seedDir = dir);
35
35
  };
36
36
  let copies = 0;
37
- const fresh = () => { const dir = path.join(arena, 'p' + (++copies)); fs.cpSync(seed(), dir, { recursive: true }); return dir; };
37
+ const fresh = () => { const dir = path.join(arena, 'p' + (++copies)); require('../lib/copy-tree').copyTreeSync(seed(), dir); return dir; };
38
38
  const runIn = (cwd, args) => cp.spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8', timeout: 300000, windowsHide: true, env });
39
39
  const show = r => `exit ${r.status}\n${String(r.stdout || '').slice(0, 1200)}${String(r.stderr || '').slice(0, 600)}`;
40
40
  const json = (r, label) => { const out = String(r.stdout || ''); try { return JSON.parse(out.slice(out.indexOf('{'))); } catch { throw new Error(label + ': not JSON — ' + show(r)); } };
@@ -225,7 +225,7 @@ if (require.main === module) {
225
225
  ]) {
226
226
  const copy = path.join(root, 'copy-' + (++n));
227
227
  covered.push(what);
228
- fs.cpSync(dir, copy, { recursive: true });
228
+ require('../lib/copy-tree').copyTreeSync(dir, copy);
229
229
  t(`fixture: ${what} changed alone is the one reported missing (#${n})`, () => {
230
230
  change(copy);
231
231
  assert.deepStrictEqual(livedInMissing(copy).map(m => m.split(' (')[0]), [what]);
@@ -235,7 +235,7 @@ if (require.main === module) {
235
235
  // Control for the whole-id match: the id in Markdown emphasis still names the task.
236
236
  t('fixture: a handoff naming the task as __id__ still counts', () => {
237
237
  const copy = path.join(root, 'copy-emphasis');
238
- fs.cpSync(dir, copy, { recursive: true });
238
+ require('../lib/copy-tree').copyTreeSync(dir, copy);
239
239
  edit(copy, 'session-handoff.md', built.ids.task1, '__' + built.ids.task1 + '__');
240
240
  assert.deepStrictEqual(livedInMissing(copy), []);
241
241
  });
@@ -31,7 +31,7 @@ const seed = lang => {
31
31
  return (seeds[lang] = dir);
32
32
  };
33
33
  let copies = 0;
34
- const fresh = lang => { const dir = path.join(arena, 'p' + (++copies)); fs.cpSync(seed(lang), dir, { recursive: true }); return dir; };
34
+ const fresh = lang => { const dir = path.join(arena, 'p' + (++copies)); require('../lib/copy-tree').copyTreeSync(seed(lang), dir); return dir; };
35
35
  const runIn = (cwd, args, extraEnv = {}) => cp.spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8', timeout: 300000, windowsHide: true, env: { ...env, ...extraEnv } });
36
36
  const show = r => `exit ${r.status}\n${String(r.stdout || '').slice(0, 1500)}${String(r.stderr || '').slice(0, 600)}`;
37
37
  const addDone = (dir, title, evidence) => {
@@ -140,7 +140,7 @@ function normalizedJson(label, result) {
140
140
  function cloneProjectState(target) {
141
141
  fs.mkdirSync(target, { recursive: true });
142
142
  fs.copyFileSync(path.join(project, 'package.json'), path.join(target, 'package.json'));
143
- fs.cpSync(path.join(project, '.leerness'), path.join(target, '.leerness'), { recursive: true });
143
+ require('../lib/copy-tree').copyTreeSync(path.join(project, '.leerness'), path.join(target, '.leerness'));
144
144
  }
145
145
 
146
146
  const surfaces = [
@@ -37,7 +37,7 @@ const code = r => { try { return JSON.parse(r.stdout).code; } catch { return '(n
37
37
  // A project holding `rows` (canonical JSON and its generated Markdown), dropped by `target`: the drop is the product's.
38
38
  function dropped(surface, rows, target) {
39
39
  const project = path.join(temp, `case-${++sequence}`);
40
- fs.cpSync(seed, project, { recursive: true });
40
+ require('../lib/copy-tree').copyTreeSync(seed, project);
41
41
  const hd = path.join(project, '.leerness');
42
42
  const f = { project, surface, json: path.join(hd, `${surface}.json`), md: path.join(hd, `${surface}.md`), archive: path.join(hd, `${surface}.archive.md`) };
43
43
  fs.writeFileSync(f.json, JSON.stringify(rows, null, 2) + '\n');
@@ -40,7 +40,7 @@ function run(project, args) {
40
40
  function success(r) { assert.strictEqual(r.status, 0, String(r.stdout || r.stderr).slice(-1000)); }
41
41
  function fixture(surface, entry) {
42
42
  const project = path.join(temp, `case-${++sequence}`);
43
- fs.cpSync(seed, project, { recursive: true });
43
+ require('../lib/copy-tree').copyTreeSync(seed, project);
44
44
  const hd = path.join(project, '.leerness');
45
45
  const json = path.join(hd, `${surface}.json`);
46
46
  const md = path.join(hd, `${surface}.md`);
@@ -176,7 +176,7 @@ try {
176
176
  });
177
177
  check('a lesson saved with surrounding spaces through the CLI drops and restores exactly', () => {
178
178
  const project = path.join(temp, `case-${++sequence}`);
179
- fs.cpSync(seed, project, { recursive: true });
179
+ require('../lib/copy-tree').copyTreeSync(seed, project);
180
180
  const json = path.join(project, '.leerness', 'lessons.json');
181
181
  success(run(project, ['lesson', 'save', ' existing ']));
182
182
  const saved = JSON.parse(fs.readFileSync(json, 'utf8'));
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+ // T-0354: scripts/memory-migration-test-support.js removes its arena at finish() when every test passed, and keeps
3
+ // it when one failed. A link inside the arena that points outside it is removed as a link: its target and the files
4
+ // in it must survive. An arena, or a folder in it, that was replaced with a link is not walked, whether that happened
5
+ // before finish() or while it ran. Each case runs the support in a child process, the way the memory probes use it.
6
+ const assert = require('assert');
7
+ const cp = require('child_process');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+
12
+ const support = path.join(__dirname, 'memory-migration-test-support.js');
13
+ let passed = 0, failed = 0;
14
+ function test(name, fn) {
15
+ try { fn(); passed++; console.log('PASS ' + name); }
16
+ catch (error) { failed++; console.error('FAIL ' + name + ': ' + (error && error.stack || error)); }
17
+ }
18
+ const linkType = process.platform === 'win32' ? 'junction' : 'dir';
19
+ const unlinkLink = link => { try { fs.unlinkSync(link); } catch { fs.rmdirSync(link); } };
20
+ const outside = fs.mkdtempSync(path.join(fs.realpathSync.native(os.tmpdir()), 'leerness-arena-cleanup-outside-'));
21
+ const sentinel = path.join(outside, 'sentinel.txt');
22
+ fs.writeFileSync(sentinel, 'kept');
23
+ // A second outside folder with a link of its own, for the replacement cases.
24
+ const elsewhere = fs.mkdtempSync(path.join(fs.realpathSync.native(os.tmpdir()), 'leerness-arena-cleanup-elsewhere-'));
25
+ fs.writeFileSync(path.join(elsewhere, 'keep.txt'), 'kept');
26
+ fs.symlinkSync(outside, path.join(elsewhere, 'keep-link'), linkType);
27
+ // The child links to `outside` from inside its arena, runs one test that passes or fails, calls finish(), and prints
28
+ // its arena path. `replace` renames a folder aside and puts a link to `elsewhere` in its place:
29
+ // 'arena-before' the arena, before finish();
30
+ // 'arena-after-check' the arena, right after finish() checked its identity;
31
+ // 'nested-during' the arena's `nested` folder, right after finish() saw it as a folder.
32
+ const child = (shouldFail, replace = '') => cp.spawnSync(process.execPath, ['-e', `
33
+ const S = require(${JSON.stringify(support)});
34
+ const fs = require('fs'), path = require('path');
35
+ const type = ${JSON.stringify(linkType)}, elsewhere = ${JSON.stringify(elsewhere)}, replace = ${JSON.stringify(replace)};
36
+ const swap = dir => { fs.renameSync(dir, dir + '-aside'); fs.symlinkSync(elsewhere, dir, type); };
37
+ fs.mkdirSync(path.join(S.arena, 'nested'));
38
+ fs.symlinkSync(${JSON.stringify(outside)}, path.join(S.arena, 'nested', 'link-out'), type);
39
+ if (replace === 'arena-before') swap(S.arena);
40
+ S.test('child test', () => { if (${shouldFail}) throw new Error('planned failure'); });
41
+ const lstat = fs.lstatSync;
42
+ let armed = replace === 'arena-after-check' || replace === 'nested-during';
43
+ fs.lstatSync = function (file, ...rest) {
44
+ const st = lstat.call(this, file, ...rest);
45
+ if (armed && replace === 'arena-after-check' && file === S.arena && rest[0] && rest[0].bigint) { armed = false; swap(file); }
46
+ if (armed && replace === 'nested-during' && file === path.join(S.arena, 'nested')) { armed = false; swap(file); }
47
+ return st;
48
+ };
49
+ S.finish('arena cleanup child');
50
+ fs.lstatSync = lstat;
51
+ console.log('ARENA ' + S.arena + (armed ? ' (the replacement never happened)' : ''));
52
+ `], { encoding: 'utf8', windowsHide: true, timeout: 60000 });
53
+ const arenaOf = r => {
54
+ const line = r.stdout.split(/\r?\n/).find(l => l.startsWith('ARENA '));
55
+ assert(line && !line.includes('never happened'), r.stdout + r.stderr);
56
+ return line.slice(6);
57
+ };
58
+ const outsideIntact = () => {
59
+ assert.strictEqual(fs.lstatSync(path.join(elsewhere, 'keep-link')).isSymbolicLink(), true, 'the link behind the replacement must be kept');
60
+ assert.strictEqual(fs.readFileSync(path.join(elsewhere, 'keep.txt'), 'utf8'), 'kept');
61
+ assert.strictEqual(fs.readFileSync(sentinel, 'utf8'), 'kept');
62
+ };
63
+ try {
64
+ test('a passing run removes its arena, and a link to a folder outside it leaves that folder alone', () => {
65
+ const r = child(false);
66
+ assert.strictEqual(r.status, 0, r.stdout + r.stderr);
67
+ assert.strictEqual(fs.existsSync(arenaOf(r)), false, 'the arena must be gone');
68
+ assert.strictEqual(fs.readFileSync(sentinel, 'utf8'), 'kept', 'the link target must be untouched');
69
+ assert.match(r.stdout, /arena cleanup child: 1\/1 passed; fixtures: .*\(removed\)/);
70
+ });
71
+ test('a failing run keeps its arena for inspection', () => {
72
+ const r = child(true);
73
+ assert.strictEqual(r.status, 1, r.stdout + r.stderr);
74
+ const arena = arenaOf(r), link = path.join(arena, 'nested', 'link-out');
75
+ assert.strictEqual(fs.existsSync(arena), true, 'the arena must be kept');
76
+ assert.strictEqual(fs.lstatSync(link).isSymbolicLink(), true, 'its contents must be kept');
77
+ assert.match(r.stdout, /arena cleanup child: 0\/1 passed; fixtures: .*\(kept\)/);
78
+ unlinkLink(link);
79
+ fs.rmSync(arena, { recursive: true, force: true });
80
+ assert.strictEqual(fs.readFileSync(sentinel, 'utf8'), 'kept');
81
+ });
82
+ for (const [replace, what] of [['arena-before', 'an arena replaced by a link before finish()'],
83
+ ['arena-after-check', 'an arena replaced by a link right after its identity check'],
84
+ ['nested-during', 'a folder in the arena replaced by a link while finish() walks the arena']]) {
85
+ test(`${what} is not walked, and nothing behind the link is removed`, () => {
86
+ // Each case starts with `elsewhere` whole, so one failure does not decide the next case.
87
+ try { fs.lstatSync(path.join(elsewhere, 'keep-link')); } catch { fs.symlinkSync(outside, path.join(elsewhere, 'keep-link'), linkType); }
88
+ fs.writeFileSync(path.join(elsewhere, 'keep.txt'), 'kept');
89
+ const r = child(false, replace);
90
+ assert.strictEqual(r.status, 0, r.stdout + r.stderr);
91
+ const arena = arenaOf(r);
92
+ outsideIntact();
93
+ assert.match(r.stdout, /arena cleanup child: 1\/1 passed; fixtures: .*\(not removed: E_ARENA_REPLACED\)/);
94
+ const swapped = replace === 'nested-during' ? path.join(arena, 'nested') : arena;
95
+ unlinkLink(swapped);
96
+ unlinkLink(path.join(replace === 'nested-during' ? path.join(arena, 'nested-aside') : path.join(arena + '-aside', 'nested'), 'link-out'));
97
+ fs.rmSync(replace === 'nested-during' ? arena : arena + '-aside', { recursive: true, force: true });
98
+ });
99
+ }
100
+ } finally {
101
+ try { unlinkLink(path.join(elsewhere, 'keep-link')); } catch {}
102
+ fs.rmSync(elsewhere, { recursive: true, force: true });
103
+ fs.rmSync(outside, { recursive: true, force: true });
104
+ }
105
+ console.log(`memory arena cleanup probe: ${passed}/${passed + failed} passed (${process.platform})`);
106
+ process.exitCode = failed ? 1 : 0;
@@ -63,7 +63,7 @@ function run(project,args,config) {
63
63
  }
64
64
  let serial=0;
65
65
  function fixture(surface,legacy=false) {
66
- const project=path.join(temp,'case-'+(++serial)); fs.cpSync(seed,project,{recursive:true});
66
+ const project=path.join(temp,'case-'+(++serial)); require('../lib/copy-tree').copyTreeSync(seed,project);
67
67
  const p=ext=>path.join(project,'.leerness',surface+'.'+ext);
68
68
  const item=value=>surface==='decisions'?{date:'2026-01-01',title:value,decision:value,reason:null,alternatives:null,impact:null,extension:{keep:'line1\nline2'}}:{date:'2026-01-01',text:value,tag:null,extension:{keep:'line1\nline2'}};
69
69
  const body=value=>surface==='decisions'?`### 2026-01-01 — ${value}\n- Decision: ${value}\n`:`### 2026-01-01\n- Lesson: ${value}\n`;
@@ -36,7 +36,7 @@ const snapshot = root => Object.fromEntries([
36
36
  let sequence = 0;
37
37
  const fixture = () => {
38
38
  const root = path.join(temp, `case-${++sequence}`);
39
- fs.cpSync(seed, root, { recursive: true });
39
+ require('../lib/copy-tree').copyTreeSync(seed, root);
40
40
  return root;
41
41
  };
42
42
  const check = (name, fn) => {
@@ -52,7 +52,7 @@ const syncProjection = (root, surface, entries) => fs.writeFileSync(projection(r
52
52
  let sequence = 0;
53
53
  function fixture() {
54
54
  const root = path.join(temp, `case-${String(++sequence).padStart(3, '0')}`);
55
- fs.cpSync(seed, root, { recursive: true });
55
+ require('../lib/copy-tree').copyTreeSync(seed, root);
56
56
  return root;
57
57
  }
58
58
  function run(root, args) {
@@ -44,7 +44,7 @@ function fileState(file) {
44
44
  }
45
45
  function fixture(surface) {
46
46
  const project = path.join(temp, `case-${++sequence}`);
47
- fs.cpSync(seed, project, { recursive: true });
47
+ require('../lib/copy-tree').copyTreeSync(seed, project);
48
48
  const p = ext => path.join(project, '.leerness', `${surface}.${ext}`);
49
49
  const entry = value => surface === 'decisions'
50
50
  ? { date: '2026-09-12', title: value, decision: value, reason: 'reason', alternatives: null, impact: null }
@@ -131,7 +131,7 @@ try {
131
131
  });
132
132
  check(`${surface} fresh init remains usable and retains its template`, () => {
133
133
  const project = path.join(temp, `case-${++sequence}`);
134
- fs.cpSync(seed, project, { recursive: true });
134
+ require('../lib/copy-tree').copyTreeSync(seed, project);
135
135
  const md = path.join(project, '.leerness', `${surface}.md`);
136
136
  const original = fs.existsSync(md) ? fs.readFileSync(md, 'utf8') : '';
137
137
  success(run(project, commands(surface).add));
@@ -54,8 +54,46 @@ function privateMetadata(result) {
54
54
  const legacy = surface => surface === 'decisions'
55
55
  ? `# Personal notes\n### 2026-09-12 — Example\n- Decision: ${PRIVATE}\n- Reason: test\n`
56
56
  : `# Personal notes\n### 2026-09-12\n- Lesson: ${PRIVATE}\n- Tag: fixture\n`;
57
+ // T-0354: a run with no failures removes its arena; nothing did before, and every run of a memory probe left one in
58
+ // the temp directory (1,206 were found). A failed run keeps it for inspection. Links inside the arena are removed
59
+ // first, as links, so the removal never walks into a link's target, wherever that points. The arena itself must
60
+ // still be the folder made above (same file identity, not a link): a test that renamed it away and put a link in its
61
+ // place would otherwise have the walk run inside the link's target (review round 1).
62
+ const arenaIdentity = fs.lstatSync(arena, { bigint: true });
63
+ function sameArena() {
64
+ const st = fs.lstatSync(arena, { bigint: true });
65
+ return !st.isSymbolicLink() && st.isDirectory() && st.dev === arenaIdentity.dev && st.ino === arenaIdentity.ino;
66
+ }
67
+ // Review round 2: a folder can also be replaced by a link after its lstat, and the arena right after sameArena().
68
+ // So each folder must still resolve inside the arena just before it is listed and before a link in it is removed.
69
+ // finish() runs after the tests, so nothing else changes the arena then; a process that kept swapping folders
70
+ // during finish() could still race these checks.
71
+ const arenaReal = fs.realpathSync.native(arena);
72
+ const replaced = () => Object.assign(new Error('the arena was replaced'), { code: 'E_ARENA_REPLACED' });
73
+ function resolvesInArena(dir) {
74
+ const relative = path.relative(arenaReal, fs.realpathSync.native(dir));
75
+ return !(relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative));
76
+ }
77
+ function removeLinks(dir) {
78
+ if (!resolvesInArena(dir)) throw replaced();
79
+ for (const name of fs.readdirSync(dir)) {
80
+ const file = path.join(dir, name), st = fs.lstatSync(file);
81
+ if (st.isSymbolicLink()) {
82
+ if (!resolvesInArena(dir)) throw replaced();
83
+ try { fs.unlinkSync(file); } catch { fs.rmdirSync(file); }
84
+ } else if (st.isDirectory()) removeLinks(file);
85
+ }
86
+ }
57
87
  function finish(label) {
58
- console.log(`${label}: ${count - failed}/${count} passed; fixtures: ${arena}`);
59
- if (failed) process.exitCode = 1;
88
+ let kept = '';
89
+ if (failed) { process.exitCode = 1; kept = ' (kept)'; }
90
+ else {
91
+ try {
92
+ if (!sameArena()) throw replaced();
93
+ removeLinks(arena); require('./temp-cleanup').removeTree(arena, { recursive: true, force: true }); kept = ' (removed)';
94
+ }
95
+ catch (error) { kept = ` (not removed: ${error.code || error.message})`; }
96
+ }
97
+ console.log(`${label}: ${count - failed}/${count} passed; fixtures: ${arena}${kept}`);
60
98
  }
61
99
  module.exports = { assert, fs, path, cp, arena, entry, PRIVATE, test, project, snapshot, command, privateMetadata, legacy, finish };
@@ -107,7 +107,7 @@ function entry(surface, value) {
107
107
  function render(surface, entries) { return surface === 'decisions' ? _renderDecisionsMd(entries) : _renderLessonsMd(entries); }
108
108
  function fixture(surface, options = {}) {
109
109
  const project = path.join(temp, `case-${++sequence}`);
110
- fs.cpSync(seed, project, { recursive: true });
110
+ require('../lib/copy-tree').copyTreeSync(seed, project);
111
111
  const hd = path.join(project, '.leerness');
112
112
  const json = path.join(hd, `${surface}.json`);
113
113
  const md = path.join(hd, `${surface}.md`);
@@ -52,11 +52,18 @@ function restoreMcp(spelled, s) {
52
52
  assert(!r.error && !r.signal, r.error && r.error.message); assert.equal(r.status,0,r.stderr);
53
53
  return JSON.parse(r.stdout.trim().split(/\r?\n/)[0]);
54
54
  }
55
+ // T-0355: a spelling this machine cannot give counts as absent: a test arena on a UNC path has no drive letter, and
56
+ // a link may be refused there. The MCP and resolve-check tests take the first non-canonical spelling that exists.
57
+ const trySpell = (spell, p) => { try { return spell(p); } catch { return null; } };
58
+ function alternate(p) {
59
+ for (const [name, spell] of spellings.slice(1)) { const spelled = trySpell(spell, p); if (spelled && spelled !== p) return [name, spelled]; }
60
+ return null;
61
+ }
55
62
 
56
63
  for (const s of ['decisions','lessons']) {
57
64
  let expected = null;
58
65
  for (const [name, spell] of spellings) {
59
- const p = make(s), spelled = spell(p);
66
+ const p = make(s), spelled = trySpell(spell, p);
60
67
  if (spelled === null) { console.log(`SKIP ${s}: policy restore through ${name} (this volume or path gives no such spelling)`); continue; }
61
68
  test(`${s}: policy restore through ${name} matches the restore through the real path`, () => {
62
69
  if (name !== 'as created') assert.notStrictEqual(spelled, p, 'the spelling must differ from the real path');
@@ -68,10 +75,11 @@ for (const s of ['decisions','lessons']) {
68
75
  if (expected === null) expected = after; else assert.deepEqual(after, expected);
69
76
  });
70
77
  }
71
- const [mcpName, mcpSpell] = spellings[1]; // a lowercase drive letter on Windows, a symlink elsewhere
78
+ const pm = make(s), alt = alternate(pm);
79
+ if (!alt) { console.log(`SKIP ${s}: MCP policy restore (this volume or path gives no non-canonical spelling)`); continue; }
80
+ const [mcpName, spelled] = alt; // a lowercase drive letter on Windows when there is one, a symlink elsewhere
72
81
  test(`${s}: MCP policy restore through ${mcpName}`, () => {
73
- const p = make(s), spelled = mcpSpell(p);
74
- assert(spelled && spelled !== p, 'the MCP case needs a non-canonical spelling');
82
+ const p = pm;
75
83
  const reply = restoreMcp(spelled, s);
76
84
  assert(reply.result, JSON.stringify(reply)); assert.equal(reply.result.isError, false, JSON.stringify(reply));
77
85
  assert.deepEqual(state(p, s).rows.map(entry => entry.title || entry.text), ['base','revive']);
@@ -80,9 +88,11 @@ for (const s of ['decisions','lessons']) {
80
88
  // The capture names files under the given spelling only after that spelling's workspace resolves to the resolved
81
89
  // one. Make it resolve elsewhere (as a swapped link would between the two resolutions) and the capture must refuse
82
90
  // before reading a file; without the check the files of the other folder would be measured as this project's.
83
- test('a spelled workspace that resolves to another folder is refused before any file is read', () => {
91
+ const resolveCheckProject = make('decisions'), resolveCheckAlternate = alternate(resolveCheckProject);
92
+ if (!resolveCheckAlternate) console.log('SKIP a spelled workspace that resolves to another folder (no non-canonical spelling here)');
93
+ else test('a spelled workspace that resolves to another folder is refused before any file is read', () => {
84
94
  const { inspectMemoryFootprint } = require('../lib/memory-footprint');
85
- const p = make('decisions'), other = make('decisions'), spelled = spellings[1][1](p);
95
+ const p = resolveCheckProject, other = make('decisions'), spelled = resolveCheckAlternate[1];
86
96
  const limits = JSON.stringify(options().observation);
87
97
  assert.equal(inspectMemoryFootprint(spelled,'decisions',limits).ok, true, 'control: the spelling itself is accepted');
88
98
  const workspace = path.join(spelled,'.leerness'), same = (a, b) => win ? a.toLowerCase() === b.toLowerCase() : a === b;
@@ -39,7 +39,7 @@ function run(project, args) {
39
39
  function success(r) { assert.strictEqual(r.status, 0, String(r.stdout || r.stderr).slice(-1000)); }
40
40
  function fixture(surface) {
41
41
  const project = path.join(temp, `case-${++sequence}`);
42
- fs.cpSync(seed, project, { recursive: true });
42
+ require('../lib/copy-tree').copyTreeSync(seed, project);
43
43
  const hd = path.join(project, '.leerness');
44
44
  const p = ext => path.join(hd, `${surface}.${ext}`);
45
45
  const entry = surface === 'decisions'
@@ -5,7 +5,9 @@ const os=require('os'),runtime=require('../lib/runtime-writes'),{attachMemoryRec
5
5
  // Node 18 writeFileSync dispatches through the later intercepted writeSync.
6
6
  // Capture primitive fixture writers before installing interception, so descriptor
7
7
  // changes represent an external writer rather than the guarded operation itself.
8
- const rawOpen=fs.openSync.bind(fs),rawWriteSync=fs.writeSync.bind(fs),rawClose=fs.closeSync.bind(fs);
8
+ const rawOpen=fs.openSync.bind(fs),rawWriteSync=fs.writeSync.bind(fs),rawClose=fs.closeSync.bind(fs),rawRename=fs.renameSync.bind(fs);
9
+ const rawSymlink=fs.symlinkSync.bind(fs),rawUnlink=fs.unlinkSync.bind(fs),rawRmdir=fs.rmdirSync.bind(fs);
10
+ const rawUnlinkLink=link=>{try{rawUnlink(link);}catch{rawRmdir(link);}};
9
11
  function rawWrite(file,text){const fd=rawOpen(file,'w');try{rawWriteSync(fd,Buffer.from(text));}finally{rawClose(fd);}}
10
12
  const descriptor={schema:'leerness.runtime-layout/v1',schemaVersion:1,scope:'project-local',generation:1,layout:'legacy',requiredWriterProtocol:1};
11
13
  // Begin in the supported implicit legacy layout. Creating an incompatible
@@ -36,6 +38,71 @@ test('a temp directory inside the project is rejected before producer work',()=>
36
38
  os.tmpdir=()=>p;try{assert.throws(()=>runtime.writeExternalRecoveryStream(p,()=>{called=true;},8),e=>e.code==='E_RECOVERY_TEMP_UNSAFE');}finally{os.tmpdir=original;}
37
39
  });assert.equal(called,false);
38
40
  });
41
+ // T-0349: the same folder spelled so that its text is outside the project: through a link placed outside it, and
42
+ // on Windows by its 8.3 short name. The guard compared spellings only and let both through.
43
+ function shortName(p){const r=cp.spawnSync(process.env.ComSpec||'cmd.exe',['/d','/s','/c',`"for %I in ("${p}") do @echo %~sI"`],{encoding:'utf8',windowsHide:true,windowsVerbatimArguments:true});const out=String(r.stdout||'').trim();return r.status===0&&out&&!out.includes('"')&&out.toLowerCase()!==p.toLowerCase()?out:null;}
44
+ const insideSpellings=[['a link outside the project',(p,inside)=>{const link=p+'-temp-link';fs.symlinkSync(inside,link,process.platform==='win32'?'junction':'dir');return link;}],
45
+ ...(process.platform==='win32'?[['its 8.3 short name',(p,inside)=>shortName(inside)]]:[])];
46
+ for(const [name,spell] of insideSpellings){
47
+ const p=project(),inside=path.join(p,'temp-folder-with-a-long-name');fs.mkdirSync(inside);const spelled=spell(p,inside);
48
+ if(!spelled){console.log('SKIP a temp folder inside the project named by '+name+' (no such spelling here)');continue;}
49
+ test('a temp folder inside the project named by '+name+' is rejected before producer work',()=>{
50
+ assert(path.relative(p,spelled).startsWith('..'),'the spelling must look outside the project: '+spelled);
51
+ const original=os.tmpdir;let called=false;latch(p,()=>{
52
+ os.tmpdir=()=>spelled;try{assert.throws(()=>runtime.writeExternalRecoveryStream(p,()=>{called=true;},8),e=>e.code==='E_RECOVERY_TEMP_UNSAFE');}finally{os.tmpdir=original;}
53
+ });assert.equal(called,false);assert.deepEqual(fs.readdirSync(inside),[]);
54
+ });
55
+ }
56
+ // T-0349 review: a project folder renamed away while the operation runs has no real path. Its recovery must still be
57
+ // written outside it, as before; only a temp folder that does not resolve is refused.
58
+ test('a project renamed away during the operation still gets its recovery outside it',()=>{
59
+ const p=project(),moved=p+'-moved';let dir;latch(p,()=>{
60
+ rawRename(p,moved);try{dir=runtime.writeExternalRecoveryStream(p,sink=>sink.write('before.bin',put=>put(Buffer.from('before'))),8);}finally{rawRename(moved,p);}
61
+ });
62
+ external.push(dir);assert(path.relative(p,dir).startsWith('..'));assert.equal(fs.readFileSync(path.join(dir,'before.bin'),'utf8'),'before');
63
+ });
64
+ // Review round 1: a child folder whose name starts with two dots is inside the project, named directly and through a
65
+ // link outside the project (only the real paths then show it).
66
+ for(const [name,spell] of [['directly',(p,inside)=>inside],['through a link outside the project',(p,inside)=>{const link=p+'-dots-link';fs.symlinkSync(inside,link,process.platform==='win32'?'junction':'dir');return link;}]]){
67
+ test('a temp folder inside the project whose name starts with two dots, named '+name+', is rejected before producer work',()=>{
68
+ const p=project(),inside=path.join(p,'..temp');fs.mkdirSync(inside);const spelled=spell(p,inside),original=os.tmpdir;let called=false;latch(p,()=>{
69
+ os.tmpdir=()=>spelled;try{assert.throws(()=>runtime.writeExternalRecoveryStream(p,()=>{called=true;},8),e=>e.code==='E_RECOVERY_TEMP_UNSAFE');}finally{os.tmpdir=original;}
70
+ });assert.equal(called,false);assert.deepEqual(fs.readdirSync(inside),[]);
71
+ });
72
+ }
73
+ test('a project path that fails to resolve for a reason other than absence is refused',()=>{
74
+ const p=project(),original=fs.realpathSync.native;let called=false;latch(p,()=>{
75
+ fs.realpathSync.native=function(q,...rest){if(path.resolve(String(q))===path.resolve(p))throw Object.assign(new Error('EIO: simulated'),{code:'EIO'});return original.call(this,q,...rest);};
76
+ try{assert.throws(()=>runtime.writeExternalRecoveryStream(p,()=>{called=true;},8),e=>e.code==='E_RECOVERY_TEMP_UNSAFE');}finally{fs.realpathSync.native=original;}
77
+ });assert.equal(called,false);
78
+ });
79
+ // Review round 2: the temp link is retargeted into the project after it was checked (here, while the guard resolves
80
+ // the project path). The bundle must still go below the folder that was checked.
81
+ test('a temp link retargeted into the project after the check still gets the recovery outside the project',()=>{
82
+ const p=project(),inside=path.join(p,'temp-after-retarget');fs.mkdirSync(inside);
83
+ const safe=fs.mkdtempSync(path.join(path.dirname(p),'safe-temp-')),link=p+'-temp-retarget-link',type=process.platform==='win32'?'junction':'dir';
84
+ rawSymlink(safe,link,type);
85
+ const originalTmp=os.tmpdir,originalReal=fs.realpathSync.native;let dir,retargeted=false;
86
+ latch(p,()=>{
87
+ os.tmpdir=()=>link;
88
+ fs.realpathSync.native=function(q,...rest){if(!retargeted&&path.resolve(String(q))===path.resolve(p)){retargeted=true;rawUnlinkLink(link);rawSymlink(inside,link,type);}return originalReal.call(this,q,...rest);};
89
+ try{dir=runtime.writeExternalRecoveryStream(p,sink=>sink.write('before.bin',put=>put(Buffer.from('before'))),8);}finally{os.tmpdir=originalTmp;fs.realpathSync.native=originalReal;}
90
+ });
91
+ external.push(dir);assert(retargeted,'the link must have been retargeted during the check');
92
+ assert.deepEqual(fs.readdirSync(inside),[]);assert.equal(fs.readFileSync(path.join(dir,'before.bin'),'utf8'),'before');
93
+ assert(path.relative(fs.realpathSync.native(p),fs.realpathSync.native(dir)).split(path.sep)[0]==='..','the recovery must be outside the project: '+dir);
94
+ });
95
+ test('a missing temp folder is refused when the project path is gone too',()=>{
96
+ const p=project(),moved=p+'-moved-too',original=os.tmpdir;let called=false;latch(p,()=>{
97
+ rawRename(p,moved);os.tmpdir=()=>path.join(path.dirname(p),'missing-temp-folder');
98
+ try{assert.throws(()=>runtime.writeExternalRecoveryStream(p,()=>{called=true;},8),e=>e.code==='E_RECOVERY_TEMP_UNSAFE');}finally{os.tmpdir=original;rawRename(moved,p);}
99
+ });assert.equal(called,false);
100
+ });
101
+ test('a temp folder that does not resolve is refused before producer work',()=>{
102
+ const p=project(),original=os.tmpdir;let called=false;latch(p,()=>{
103
+ os.tmpdir=()=>path.join(path.dirname(p),'missing-temp-folder');try{assert.throws(()=>runtime.writeExternalRecoveryStream(p,()=>{called=true;},8),e=>e.code==='E_RECOVERY_TEMP_UNSAFE');}finally{os.tmpdir=original;}
104
+ });assert.equal(called,false);
105
+ });
39
106
  test('invalid chunk admission occurs before creating an external namespace',()=>{
40
107
  const p=project();latch(p,()=>{for(const value of [0,-1,65537,1.5,'8'])assert.throws(()=>runtime.writeExternalRecoveryStream(p,()=>{},value),e=>e.code==='E_RECOVERY_BUNDLE_INVALID');});
41
108
  });
@@ -97,7 +97,7 @@ function entry(surface, value) {
97
97
  function render(surface, entries) { return surface === 'decisions' ? _renderDecisionsMd(entries) : _renderLessonsMd(entries); }
98
98
  function fixture(surface) {
99
99
  const project = path.join(temp, `case-${++sequence}`);
100
- fs.cpSync(seed, project, { recursive: true });
100
+ require('../lib/copy-tree').copyTreeSync(seed, project);
101
101
  const hd = path.join(project, '.leerness');
102
102
  const json = path.join(hd, `${surface}.json`);
103
103
  const md = path.join(hd, `${surface}.md`);
@@ -54,7 +54,7 @@ function check(name, fn) {
54
54
  }
55
55
  function fixture(surface) {
56
56
  const project = path.join(temp, `case-${++sequence}`);
57
- fs.cpSync(seed, project, { recursive: true });
57
+ require('../lib/copy-tree').copyTreeSync(seed, project);
58
58
  const file = ext => path.join(project, '.leerness', `${surface}.${ext}`);
59
59
  const identity = surface === 'decisions' ? 'title' : 'text';
60
60
  const item = (value, extra = {}) => surface === 'decisions'
@@ -54,7 +54,7 @@ function success(result) {
54
54
  }
55
55
  function fixture(surface, payload, bom = false) {
56
56
  const project = path.join(temp, `case-${++sequence}`);
57
- fs.cpSync(seed, project, { recursive: true });
57
+ require('../lib/copy-tree').copyTreeSync(seed, project);
58
58
  const dir = path.join(project, '.leerness');
59
59
  const file = ext => path.join(dir, `${surface}.${ext}`);
60
60
  const item = identity => surface === 'decisions'
@@ -127,8 +127,14 @@ function hookRegistration(options = {}) {
127
127
  };
128
128
  return seen;
129
129
  }
130
+ // T-0353: the parser check is a precondition for every run below. A script with a command in it (an appended
131
+ // `reg.exe add HKCU\Environment ...`, say) would pass the text rewrite, and the final Path hash would only see the
132
+ // damage afterwards; so when the script does not parse to zero commands, no registry run happens at all.
133
+ let commandCheck = null;
134
+ const scriptIsCommandFree = () => (commandCheck === null ? (commandCheck = commandsAndErrors(SCRIPT)) : commandCheck) === '0|0';
130
135
  // Runs _registerPath with the spawn hook. Returns its result, what the child printed, and the throwaway Path after.
131
136
  function register(bin, preset, options = {}) {
137
+ assert(scriptIsCommandFree(), 'the PATH script must parse to zero commands before any registry run: ' + commandCheck);
132
138
  presetTestPath(...preset);
133
139
  const seen = hookRegistration(options);
134
140
  let result;
@@ -193,6 +199,15 @@ try {
193
199
  assert.strictEqual(r.after, after);
194
200
  assert.deepStrictEqual({ ok: r.result.ok, method: r.result.method }, { ok: true, method: word === 'ADDED' ? 'powershell-user-path' : 'already' });
195
201
  });
202
+ // T-0353: the match is ordinal. PowerShell's -eq and -contains compare with the culture and ignore a soft hyphen,
203
+ // so an entry that only looks like the bin path counted as present.
204
+ test('an entry that differs from the bin path by an ignorable character is not taken as present', () => {
205
+ const softHyphen = String.fromCharCode(0xad);
206
+ const lookalike = BIN.slice(0, -1) + softHyphen + BIN.slice(-1);
207
+ const r = register(BIN, ['String', 'C:\\a;' + lookalike]);
208
+ assert.strictEqual(r.seen[0].stdout.toString(), 'ADDED\r\n', r.seen[0].stderr);
209
+ assert(r.after.startsWith('String=C:\\a;') && r.after.endsWith(';' + BIN), r.after);
210
+ });
196
211
  test('a bin path with PowerShell syntax is added as text', () => {
197
212
  const hostile = "C:\\safe $(Write-Output INJECTED) `tick 'q' t\\";
198
213
  const r = register(hostile, ['String', 'C:\\a']);
@@ -20,7 +20,7 @@ function versionFromSource(file) {
20
20
  }
21
21
 
22
22
  function copyFixture(root) {
23
- for (const dir of ['bin', 'lib']) fs.cpSync(path.join(sourceRoot, dir), path.join(root, dir), { recursive: true });
23
+ for (const dir of ['bin', 'lib']) require('../lib/copy-tree').copyTreeSync(path.join(sourceRoot, dir), path.join(root, dir));
24
24
  for (const file of ['package.json', 'README.md']) fs.copyFileSync(path.join(sourceRoot, file), path.join(root, file));
25
25
  fs.mkdirSync(path.join(root, '.leerness'), { recursive: true });
26
26
  const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
@@ -38,7 +38,7 @@ const project = () => {
38
38
  fs.mkdirSync(path.join(template, '.leerness', 'skills'));
39
39
  }
40
40
  const dir = path.join(arena, 'p' + (++seq));
41
- fs.cpSync(template, dir, { recursive: true });
41
+ require('../lib/copy-tree').copyTreeSync(template, dir);
42
42
  return dir;
43
43
  };
44
44
  const skill = (dir, id, displayNameKo, capabilities) => {
@@ -44,7 +44,7 @@ const project = () => {
44
44
  fs.mkdirSync(path.join(template, '.leerness', 'skills'));
45
45
  }
46
46
  const dir = path.join(arena, 'p' + (++seq));
47
- fs.cpSync(template, dir, { recursive: true });
47
+ require('../lib/copy-tree').copyTreeSync(template, dir);
48
48
  return dir;
49
49
  };
50
50
  const store = (dir, name) => path.join(dir, '.leerness', name);
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+ // T-0352: the archive step of `leerness skill publish --bundle-only` (_createArchive in bin/leerness.js).
3
+ // It ran `tar -czf <absolute path>`. Git for Windows' GNU tar, first on PATH in Git Bash and in agent shells built on
4
+ // it, reads the drive letter of an absolute Windows path as a remote host and failed, so the .zip fallback ran
5
+ // instead of the .tgz. The e2e check accepted either. This probe asks for the .tgz with each tar the machine has
6
+ // first on PATH and checks that tar made it, with the expected entries.
7
+ const assert = require('assert');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const zlib = require('zlib');
12
+ const { _createArchive } = require('../bin/leerness.js');
13
+
14
+ let passed = 0, failed = 0;
15
+ function test(name, fn) {
16
+ try { fn(); passed++; console.log('PASS ' + name); }
17
+ catch (error) { failed++; console.error('FAIL ' + name + ': ' + (error && error.stack || error)); }
18
+ }
19
+ // Entry names of a .tgz, read with zlib and the ustar header layout (name at 0, prefix at 345).
20
+ function tgzEntries(file) {
21
+ const tar = zlib.gunzipSync(fs.readFileSync(file)), names = [];
22
+ for (let at = 0; at + 512 <= tar.length;) {
23
+ const header = tar.subarray(at, at + 512);
24
+ if (header.every(byte => byte === 0)) break;
25
+ const text = (from, length) => header.subarray(from, from + length).toString('utf8').replace(/\0[\s\S]*$/, '');
26
+ const name = text(0, 100), prefix = text(345, 155), size = parseInt(text(124, 12).trim() || '0', 8);
27
+ const type = text(156, 1);
28
+ if (type !== 'x' && type !== 'g' && type !== 'L') names.push((prefix ? prefix + '/' : '') + name);
29
+ at += 512 + Math.ceil(size / 512) * 512;
30
+ }
31
+ return names.map(n => n.replace(/\/$/, '')).sort();
32
+ }
33
+ const arena = fs.mkdtempSync(path.join(fs.realpathSync.native(os.tmpdir()), 'leerness-skill-archive-'));
34
+ function project(name) {
35
+ const root = path.join(arena, name), source = path.join(root, '.leerness', 'skills-publish');
36
+ fs.mkdirSync(path.join(source, 'skill-a'), { recursive: true });
37
+ fs.writeFileSync(path.join(source, 'manifest.json'), '{"skills":1}\n');
38
+ fs.writeFileSync(path.join(source, 'skill-a', 'SKILL.md'), '# Skill A\n');
39
+ return path.join(root, '.leerness');
40
+ }
41
+ const tars = [['the tar found on PATH', null]];
42
+ if (process.platform === 'win32') {
43
+ const system32 = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32');
44
+ if (fs.existsSync(path.join(system32, 'tar.exe'))) tars.push(["System32's bsdtar first on PATH", system32]);
45
+ const gitUsrBin = path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'usr', 'bin');
46
+ if (fs.existsSync(path.join(gitUsrBin, 'tar.exe'))) tars.push(["Git for Windows' GNU tar first on PATH", gitUsrBin]);
47
+ else console.log("SKIP Git for Windows' GNU tar first on PATH (not installed)");
48
+ }
49
+ const expected = ['skills-publish', 'skills-publish/manifest.json', 'skills-publish/skill-a', 'skills-publish/skill-a/SKILL.md'];
50
+ const originalPath = process.env.PATH;
51
+ try {
52
+ for (const [name, dir] of tars) {
53
+ for (const [where, out] of [['below the working folder', cwd => path.join(cwd, 'skills-publish-tarball', 'bundle.tgz')],
54
+ ['beside the working folder', cwd => path.join(path.dirname(cwd), 'out', 'bundle.tgz')]]) {
55
+ test(`.tgz with ${name}, archive ${where}`, () => {
56
+ const cwd = project(`p-${passed + failed}`), file = out(cwd);
57
+ fs.mkdirSync(path.dirname(file), { recursive: true });
58
+ if (dir) process.env.PATH = dir + path.delimiter + originalPath;
59
+ let made;
60
+ try { made = _createArchive(cwd, 'skills-publish', file); } finally { process.env.PATH = originalPath; }
61
+ assert.deepStrictEqual({ ok: made.ok, method: made.method, path: made.path }, { ok: true, method: 'tar', path: file }, JSON.stringify(made));
62
+ assert.deepStrictEqual(tgzEntries(file), expected);
63
+ });
64
+ }
65
+ }
66
+ } finally {
67
+ process.env.PATH = originalPath;
68
+ fs.rmSync(arena, { recursive: true, force: true });
69
+ }
70
+ console.log(`skill archive probe: ${passed}/${passed + failed} passed (${process.platform})`);
71
+ process.exitCode = failed ? 1 : 0;
@@ -31,6 +31,11 @@ const filter = process.argv.includes('--match') ? process.argv[process.argv.inde
31
31
  const test = (name, fn) => { if (filter && !name.includes(filter)) return; try { fn(); passed++; console.log('PASS ' + name); } catch (e) { failed++; console.error('FAIL ' + name + ': ' + (e && e.stack || e)); } };
32
32
  const run = (dir, args, extra = {}) => cp.spawnSync(process.execPath, [CLI, ...args], { cwd: dir, encoding: 'utf8', timeout: 300000, windowsHide: true, env: { ...env, ...extra } });
33
33
  const both = r => String(r.stdout || '') + String(r.stderr || '');
34
+ // T-0347: the language checks read messages with the arena path masked. Below a temp folder with Hangul in its path
35
+ // (a Windows user name in Korean), a path in an English message is not Korean text, and in a Korean one it is not
36
+ // proof of Korean text.
37
+ const arenaSpellings = [...new Set([arena, fs.realpathSync.native(arena)])];
38
+ const prose = text => arenaSpellings.reduce((s, a) => s.split(a).join('<arena>'), String(text));
34
39
  const must = (r, label) => { assert.ifError(r.error); assert.strictEqual(r.status, 0, label + ' exit ' + r.status + '\n' + both(r)); return String(r.stdout || ''); };
35
40
  const json = (r, label) => { const out = must(r, label); try { return JSON.parse(out); } catch (e) { throw new Error(label + ': stdout is not one JSON object\n' + out.slice(0, 800)); } };
36
41
  // init takes most of the run time, so it runs once; each test works on its own copy, with no installed skills.
@@ -44,7 +49,7 @@ const project = () => {
44
49
  fs.mkdirSync(path.join(template, '.leerness', 'skills'));
45
50
  }
46
51
  const dir = path.join(arena, 'p' + (++seq));
47
- fs.cpSync(template, dir, { recursive: true });
52
+ require('../lib/copy-tree').copyTreeSync(template, dir);
48
53
  return dir;
49
54
  };
50
55
  const store = (dir, ...parts) => path.join(dir, '.leerness', ...parts);
@@ -128,7 +133,7 @@ test('array fields that are not arrays do not crash skill commands, and skill le
128
133
  const r = run(dir, ['skill', 'learn', 'str-caps', '--capability', 'zebra import', '--language', lang, '--path', dir]);
129
134
  assert.strictEqual(r.status, 1, 'learn into a non-list must fail\n' + both(r));
130
135
  assert.ok(/capabilities/.test(both(r)) && !JS_ERROR.test(both(r)), both(r));
131
- assert.ok(lang === 'en' ? !/[가-힣]/.test(both(r)) : /[가-힣]/.test(both(r)), lang + ' message: ' + both(r));
136
+ assert.ok(lang === 'en' ? !/[가-힣]/.test(prose(both(r))) : /[가-힣]/.test(prose(both(r))), lang + ' message: ' + both(r));
132
137
  }
133
138
  assert.strictEqual(fs.readFileSync(store(dir, 'skills', 'str-caps', 'skill.json'), 'utf8'), before);
134
139
  // skill use counts from zero when there is no count (it saved NaN as null); a count that is not a number and optimizations
@@ -295,9 +300,9 @@ test('malformed platform-constraints.json entries are reported as an invalid sto
295
300
  const dir = project();
296
301
  constraints(dir, shapes['aliases is a string']);
297
302
  const add = run(dir, ['constraints', 'add', 'acme', '--constraint', 'cost:x', '--language', 'en', '--path', dir]);
298
- assert.ok(add.status === 1 && /damaged/.test(both(add)) && !/[가-힣]/.test(both(add)), both(add));
303
+ assert.ok(add.status === 1 && /damaged/.test(both(add)) && !/[가-힣]/.test(prose(both(add))), both(add));
299
304
  const note = json(run(dir, ['constraints', 'list', '--json', '--language', 'en', '--path', dir]), 'list --json en').note;
300
- assert.ok(/damaged/.test(note) && !/[가-힣]/.test(note), note);
305
+ assert.ok(/damaged/.test(note) && !/[가-힣]/.test(prose(note)), note);
301
306
  });
302
307
 
303
308
  test('the graph page reads skill fields as text', () => {
@@ -37,7 +37,7 @@ const seed = () => {
37
37
  return (seedDir = dir);
38
38
  };
39
39
  let copies = 0;
40
- const fresh = () => { const dir = path.join(arena, 'p' + (++copies)); fs.cpSync(seed(), dir, { recursive: true }); return dir; };
40
+ const fresh = () => { const dir = path.join(arena, 'p' + (++copies)); require('../lib/copy-tree').copyTreeSync(seed(), dir); return dir; };
41
41
  const runIn = (cwd, args) => cp.spawnSync(process.execPath, [CLI, ...args], { cwd, encoding: 'utf8', timeout: 300000, windowsHide: true, env });
42
42
  const show = r => `exit ${r.status}\n${String(r.stdout || '').slice(0, 1500)}${String(r.stderr || '').slice(0, 600)}`;
43
43
  const jsonOf = (r, label) => { const out = String(r.stdout || ''); try { return JSON.parse(out.slice(out.indexOf('{'))); } catch { throw new Error(label + ': no JSON — ' + show(r)); } };