@kaydash9999/banker-plugins 0.24.1 → 0.25.1

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 (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +50 -0
  4. package/README.md +5 -4
  5. package/bin/lib/codex-hooks.mjs +5 -1
  6. package/codex/hooks.json +4 -4
  7. package/codex/manifest.json +1 -0
  8. package/codex/skills/teamwork/scripts/teamwork.mjs +18 -8
  9. package/codex/transform-matrix.md +1 -0
  10. package/hooks/telemetry-count-codex.mjs +3 -2
  11. package/package.json +1 -1
  12. package/skills/3d-intro-build/SKILL.md +1 -0
  13. package/skills/3d-intro-build/references/azure-adapter.mjs +54 -27
  14. package/skills/3d-intro-build/references/video-pool.mjs +11 -6
  15. package/skills/3d-intro-setup/references/azure-adapter.mjs +54 -27
  16. package/skills/3d-intro-setup/references/video-pool.mjs +11 -6
  17. package/skills/anti-drm/SKILL.md +2 -2
  18. package/skills/anti-drm/scripts/anti-drm.mjs +8 -5
  19. package/skills/anti-drm/scripts/instruction-toggle.mjs +6 -3
  20. package/skills/build-testbox/scripts/browser-cell.mjs +10 -2
  21. package/skills/build-testbox/scripts/containers.mjs +8 -3
  22. package/skills/build-testbox/scripts/proc.mjs +32 -8
  23. package/skills/build-testbox/scripts/testbox.mjs +10 -2
  24. package/skills/git-init/scripts/git-init.mjs +53 -5
  25. package/skills/git-init/scripts/marker-block.mjs +131 -19
  26. package/skills/init-dir/scripts/init-dir.mjs +29 -2
  27. package/skills/init-dir/scripts/marker-block.mjs +131 -19
  28. package/skills/init-dir/templates/scripts/lib/service.mjs +57 -6
  29. package/skills/lineage/SKILL.md +1 -1
  30. package/skills/lineage/lineage.py +11 -11
  31. package/skills/motion-sites/SKILL.md +87 -0
  32. package/skills/motion-sites/scripts/motion-sites.mjs +348 -0
  33. package/skills/omc-patch/scripts/omc-patch.mjs +76 -17
  34. package/skills/payload-mon/scripts/payload-mon.mjs +2 -1
  35. package/skills/payload-mon/scripts/payload-size.mjs +2 -2
  36. package/skills/progress-mon/scripts/pm-adr.mjs +6 -3
  37. package/skills/progress-mon/scripts/pm-config.mjs +133 -7
  38. package/skills/progress-mon/scripts/pm-data.mjs +35 -29
  39. package/skills/progress-mon/scripts/pm-memory.mjs +14 -7
  40. package/skills/progress-mon/scripts/pm-server.mjs +3 -3
  41. package/skills/progress-mon/scripts/pm-sources.mjs +2 -1
  42. package/skills/ralph-qa/SKILL.md +1 -0
  43. package/skills/ralph-qa/references/seat-run.mjs +21 -4
  44. package/skills/ralph-qa/references/verifier-probe.mjs +35 -13
  45. package/skills/remains/SKILL.md +31 -4
  46. package/skills/remains/scripts/boxes.mjs +44 -15
  47. package/skills/setup-bypass-permissions/scripts/bypass-permissions.mjs +2 -1
  48. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +12 -7
  49. package/skills/setup-omc-hud/assets/apply-hud.mjs +2 -1
  50. package/skills/setup-omc-hud/assets/codex-config.mjs +12 -7
  51. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +2 -1
  52. package/skills/smart-compact/scripts/codex-gate.mjs +25 -4
  53. package/skills/tone-compact/scripts/instruction-toggle.mjs +6 -3
  54. package/skills/tune-tmux/scripts/marker-block.mjs +131 -19
  55. package/skills/tune-tmux/scripts/tmux-config.mjs +35 -3
  56. package/skills/tune-tmux/scripts/tune-tmux.mjs +15 -9
  57. package/skills/ultra-chain/references/chain-ledger.mjs +8 -1
  58. package/skills/vertical-pptx/verify_a4p.mjs +11 -4
@@ -42,6 +42,7 @@
42
42
  * Exit status 1 means nothing was changed; 2 means an unexpected error stopped
43
43
  * the command partway, so check `status`.
44
44
  */
45
+ import { randomBytes } from "node:crypto";
45
46
  import { spawnSync } from "node:child_process";
46
47
  import {
47
48
  accessSync,
@@ -215,7 +216,7 @@ function linkedFolder(file) {
215
216
  // either name is replaced rather than written through, and a write that fails
216
217
  // leaves the old file as it was.
217
218
  function writeFresh(file, data, mode) {
218
- const tmp = `${file}.${process.pid}.tmp`;
219
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
219
220
  rmSync(tmp, { force: true });
220
221
  try {
221
222
  writeFileSync(tmp, data, { flag: "wx", mode });
@@ -33,7 +33,7 @@
33
33
  * fix sentPart and bump CACHE_VERSION, so totals cached under the old rules are
34
34
  * rebuilt.
35
35
  */
36
- import { createHash } from "node:crypto";
36
+ import { createHash, randomBytes } from "node:crypto";
37
37
  import {
38
38
  closeSync,
39
39
  fstatSync,
@@ -187,7 +187,7 @@ function saveCache(file, state) {
187
187
  const dir = dirname(file);
188
188
  mkdirSync(dir, { recursive: true, mode: 0o700 });
189
189
  if (!ownedDir(dir)) return; // a folder this account does not control: no cache
190
- const tmp = `${file}.${process.pid}.tmp`;
190
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
191
191
  rmSync(tmp, { force: true });
192
192
  writeFileSync(tmp, JSON.stringify(state), { flag: "wx", mode: 0o600 });
193
193
  renameSync(tmp, file);
@@ -6,7 +6,7 @@
6
6
  import crypto from 'node:crypto';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
- import { git, noBom, readRegular, writeAtomic } from './pm-config.mjs';
9
+ import { git, noBom, readRegular, writeAtomic, writeNew } from './pm-config.mjs';
10
10
 
11
11
  const ADR_FILE = 'adr.json';
12
12
  const ADR_CHECK_FILE = 'adr-check.json';
@@ -404,13 +404,16 @@ const stamp = (ms) => {
404
404
  };
405
405
 
406
406
  // 스키마 FAIL 이 남은 adr.json 을 같은 폴더의 adr.json.rejected-<시각>(있으면 -2, -3 ...)으로 옮긴다. 결정 기록을 잃지 않게
407
- // 지우지 않는다. 새 이름은 COPYFILE_EXCL 로 만들어 있는 파일을 덮지 않고, 사본을 만든 뒤에만 원본을 지운다. 돌려줌: 옮긴 경로.
407
+ // 지우지 않는다. 새 이름은 writeNew 로 만들어 있는 것(끊어진 링크 포함)을 덮거나 따라가지 않고, 사본을 만든 뒤에만 원본을
408
+ // 지운다. 돌려줌: 옮긴 경로.
408
409
  function reject(file, now) {
409
410
  const base = `${file}.rejected-${stamp(now)}`;
411
+ const bytes = fs.readFileSync(file);
412
+ const mode = fs.statSync(file).mode & 0o777; // as COPYFILE_EXCL kept it
410
413
  for (let i = 1; ; i++) {
411
414
  const dest = i === 1 ? base : `${base}-${i}`;
412
415
  try {
413
- fs.copyFileSync(file, dest, fs.constants.COPYFILE_EXCL);
416
+ writeNew(dest, bytes, { mode });
414
417
  } catch (e) {
415
418
  if (e.code === 'EEXIST') continue;
416
419
  throw e;
@@ -1,4 +1,5 @@
1
1
  // pm-config.mjs: 프로젝트 루트와 자료 폴더 결정, 런타임 판별, config.json 읽기와 기본값, 형식 검사, 원자적 쓰기.
2
+ import os from 'node:os';
2
3
  import fs from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import net from 'node:net';
@@ -52,13 +53,64 @@ export function readRegular(file, encoding = 'utf8', fsx = fs, max = READ_MAX) {
52
53
  return fsx.readFileSync(file, encoding);
53
54
  }
54
55
 
56
+ // 실제 경로. native 먼저(Windows 의 8.3 짧은 이름, macOS 와 Windows 의 디스크 대소문자로 푼다). 아직 없는 꼬리는 있는
57
+ // 조상까지 풀고 붙인다(macOS 의 /var 는 /private/var 로 가는 링크).
58
+ export function realNative(p) {
59
+ try {
60
+ return fs.realpathSync.native(p);
61
+ } catch {
62
+ try {
63
+ return fs.realpathSync(p);
64
+ } catch {
65
+ const up = path.dirname(p);
66
+ return up === p ? p : path.join(realNative(up), path.basename(p));
67
+ }
68
+ }
69
+ }
70
+
71
+ // 두 경로가 같은 폴더인지: 장치와 inode(bigint), inode 가 없는 파일 시스템은 realNative 철자(Windows, macOS 는 접어서).
72
+ // 문자열 비교는 대소문자, 8.3 이름, 링크인 부모 폴더처럼 철자만 다른 같은 폴더를 다른 폴더로 본다.
73
+ export function sameFolder(a, b) {
74
+ try {
75
+ const x = fs.statSync(a, { bigint: true });
76
+ const y = fs.statSync(b, { bigint: true });
77
+ if (x.ino !== 0n && y.ino !== 0n) return x.dev === y.dev && x.ino === y.ino;
78
+ } catch {
79
+ return false;
80
+ }
81
+ const fold = (p) => (process.platform === 'win32' || process.platform === 'darwin' ? p.toLowerCase() : p);
82
+ return fold(realNative(a)) === fold(realNative(b));
83
+ }
84
+
55
85
  // 프로젝트 안 자료 폴더의 경로 가운데 링크인 첫 폴더(.omc, progress-mon 등). 없으면 null.
56
- // 프로젝트 밖(--data)이면 자료 폴더 자체만 본다. 아직 없는 폴더에서 멈춘다.
86
+ // 프로젝트 밖(--data)이면 자료 폴더 자체만 본다. 아직 없는 폴더에서 멈춘다. 안팎은 자료 폴더에서 위로 올라가며
87
+ // 프로젝트 루트와 같은 폴더(sameFolder)를 만나는지로 본다: --data 를 다른 대소문자나 8.3 이름으로 적어도 안으로 본다.
57
88
  function linkedDir(root, dataDir) {
58
- const rel = path.relative(root, dataDir);
59
- const inside = rel && !rel.startsWith('..') && !path.isAbsolute(rel);
60
- const parts = inside ? rel.split(path.sep) : [];
61
- const dirs = inside ? parts.map((_, i) => path.join(root, ...parts.slice(0, i + 1))) : [dataDir];
89
+ const isLink = (d) => {
90
+ try {
91
+ return fs.lstatSync(d).isSymbolicLink();
92
+ } catch {
93
+ return false;
94
+ }
95
+ };
96
+ // 위로 올라가며 루트를 찾는다. 링크가 아닌 루트를 만나면 그 아래가 안이다(.omc -> . 같은 안의 링크도 목록에 남아
97
+ // 거부된다). 끝까지 못 만나면 루트를 가리키는 첫 링크(루트를 링크 철자로 적은 --data)를 루트로 본다.
98
+ const up = [];
99
+ let rootAt = -1;
100
+ let linkRootAt = -1;
101
+ for (let d = dataDir; ; d = path.dirname(d)) {
102
+ if (sameFolder(d, root)) {
103
+ if (!isLink(d)) {
104
+ rootAt = up.length;
105
+ break;
106
+ }
107
+ if (linkRootAt < 0) linkRootAt = up.length;
108
+ }
109
+ up.push(d);
110
+ if (path.dirname(d) === d) break;
111
+ }
112
+ const cut = rootAt >= 0 ? rootAt : linkRootAt;
113
+ const dirs = cut > 0 ? up.slice(0, cut).reverse() : [dataDir];
62
114
  for (const d of dirs) {
63
115
  let st;
64
116
  try {
@@ -85,8 +137,11 @@ export function privateIPv4(ip) {
85
137
  return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
86
138
  }
87
139
 
88
- // git 출력(앞뒤 공백 제거). git 이 없거나 실패하면 예외.
89
- export const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
140
+ // git 출력(앞뒤 공백 제거). git 이 없거나 실패하면 예외. 홈 폴더에서 git -C 로 부른다: Windows 는 일하는 폴더의
141
+ // 프로그램을 먼저 찾으므로, 프로젝트 안의 git.exe 가 실행되지 않게.
142
+ // 프로그램을 띄울 폴더: 홈 폴더(없으면 임시 폴더).
143
+ export const runDir = () => (fs.existsSync(os.homedir()) ? os.homedir() : os.tmpdir());
144
+ export const git = (cwd, args) => execFileSync('git', ['-C', path.resolve(cwd), ...args], { cwd: runDir(), encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
90
145
 
91
146
  // 프로젝트 루트: --project, 아니면 cwd 의 git 최상위, 실패면 cwd.
92
147
  export function projectRoot(cwd, project) {
@@ -123,6 +178,77 @@ function pickContext({ cwd = process.cwd(), project, runtime, data } = {}) {
123
178
  // 사용법 오류(종료 1).
124
179
  export class UsageError extends Error {}
125
180
 
181
+ // 고정 이름(잠금, 백업, 처음 만드는 설정)에 새 파일을 만든다(wx). 그 이름에 무엇이든 있으면 끊어진 링크까지 EEXIST.
182
+ // 까닭: Windows 의 wx 는 끊어진 링크를 따라가 그 너머에 파일을 만든다(macOS, Linux 는 EEXIST). 그래서 먼저 lstat 로
183
+ // 보고, 연 뒤 쓰기 전에 연 파일(fstat)과 이름(lstat)을 대조한다. 이름이 없거나 링크이거나 다른 파일이면 아무것도 쓰지
184
+ // 않고 EEXIST(링크를 따라 만든 빈 파일은 지움). 볼 수 없으면(ESTALE, EPERM 등) 보통의 wx 처럼 그대로 쓴다. 쓰기가
185
+ // 실패하면 이름이 아직 그 파일일 때 지운다(빈 잠금이나 빈 백업이 남지 않게). 처음 lstat 의 EPERM 등(Windows 에서 지우는
186
+ // 중인 파일)은 그대로 던진다. 부른 쪽은 wx 의 같은 코드처럼 기다린다.
187
+ export function writeNew(file, data, opts = {}, fsx = fs) {
188
+ if (fsx.lstatSync(file, { throwIfNoEntry: false }) !== undefined) throw eexist(file);
189
+ const fd = fsx.openSync(file, 'wx', opts.mode);
190
+ try {
191
+ const made = fstatOr(fd, fsx);
192
+ if (namesOpened(file, made, fsx) === false) throw eexist(file);
193
+ try {
194
+ fsx.writeFileSync(fd, data);
195
+ } catch (e) {
196
+ if (namesOpened(file, made, fsx) === true) fsx.rmSync(file, { force: true });
197
+ throw e;
198
+ }
199
+ } finally {
200
+ fsx.closeSync(fd);
201
+ }
202
+ }
203
+
204
+ const fstatOr = (fd, fsx) => {
205
+ try {
206
+ return fsx.fstatSync(fd, { bigint: true });
207
+ } catch {
208
+ return null;
209
+ }
210
+ };
211
+
212
+ // 이름이 연 파일(made)을 가리키는지: 참, 거짓(없음, 링크, 다른 파일), 볼 수 없으면 null. 링크면 그 링크로 만든 빈 파일을 지움.
213
+ function namesOpened(file, made, fsx) {
214
+ let st;
215
+ try {
216
+ st = fsx.lstatSync(file, { bigint: true, throwIfNoEntry: false });
217
+ } catch {
218
+ return null;
219
+ }
220
+ if (!st) return false;
221
+ if (st.isSymbolicLink()) {
222
+ if (made) dropFollowed(file, made, fsx);
223
+ return false;
224
+ }
225
+ if (!made || made.ino === 0n) return null;
226
+ return st.dev === made.dev && st.ino === made.ino;
227
+ }
228
+
229
+ // 링크를 따라 연 파일이 아직 그 빈 파일이면 지운다.
230
+ function dropFollowed(file, made, fsx) {
231
+ try {
232
+ const target = fsx.realpathSync(file);
233
+ const at = fsx.statSync(target, { bigint: true });
234
+ if (at.dev === made.dev && at.ino === made.ino && at.size === 0n) fsx.rmSync(target, { force: true });
235
+ } catch {
236
+ // 링크가 또 바뀜: 되돌릴 것을 찾지 못함
237
+ }
238
+ }
239
+
240
+ const eexist = (file) => Object.assign(new Error(`EEXIST: file already exists, open '${file}'`), { code: 'EEXIST', syscall: 'open', path: file });
241
+
242
+ // 그 자리에 일반 파일이 아닌 것(링크, 폴더 등)이 있는지. 없거나 볼 수 없으면 거짓.
243
+ export function notPlainFile(file, fsx = fs) {
244
+ try {
245
+ const st = fsx.lstatSync(file, { throwIfNoEntry: false });
246
+ return st !== undefined && !st.isFile();
247
+ } catch {
248
+ return false;
249
+ }
250
+ }
251
+
126
252
  const RENAME_RETRIES = 5;
127
253
  const RENAME_STEP_MS = 50;
128
254
  const RENAME_RETRY_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
@@ -4,7 +4,7 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import crypto from 'node:crypto';
7
- import { dataLabel, defaults, loadConfig, eventsPath, writeAtomic, git, noBom, readRegular, UsageError } from './pm-config.mjs';
7
+ import { dataLabel, defaults, loadConfig, eventsPath, writeAtomic, writeNew, notPlainFile, git, noBom, readRegular, realNative, UsageError } from './pm-config.mjs';
8
8
  import { stageList } from './pm-sources.mjs';
9
9
  import { planMemory, applyMemory } from './pm-memory.mjs';
10
10
 
@@ -58,7 +58,7 @@ function readLock(file, fsx) {
58
58
  function breakStale(file, seen, fsx) {
59
59
  const brk = `${file}.break`;
60
60
  try {
61
- fsx.writeFileSync(brk, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }) + '\n', { flag: 'wx' });
61
+ writeNew(brk, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }) + '\n', {}, fsx);
62
62
  } catch (e) {
63
63
  if (!BUSY_CODES.has(e.code)) return false;
64
64
  try {
@@ -97,6 +97,27 @@ function breakStale(file, seen, fsx) {
97
97
  return removed;
98
98
  }
99
99
 
100
+ // 잠금을 한 번 만들어 본다. 돌려줌: 만들었으면 null, 아니면 기다릴 까닭 코드(EEXIST, Windows 의 EPERM 등).
101
+ // 링크나 폴더가 잠금 자리에 있으면 풀릴 잠금이 아니므로 기다리지 않고 거부한다(끊어진 링크면 읽기가 없음으로 보여 헛돈다).
102
+ function tryLockOnce(file, body, fsx, label) {
103
+ try {
104
+ writeNew(file, body, {}, fsx);
105
+ return null;
106
+ } catch (e) {
107
+ if (!BUSY_CODES.has(e.code)) throw new UsageError(`잠금 파일을 만들지 못함(${e.code || e.message})`);
108
+ if (notPlainFile(file, fsx)) throw new UsageError(`잠금 자리에 일반 파일이 아닌 것(링크나 폴더)이 있어 거부: ${label}items.json.lock`);
109
+ return e.code;
110
+ }
111
+ }
112
+
113
+ // 기다림이 끝났을 때의 오류. 잠금 파일이 끝내 없었으면 다른 쓰기가 아니라 만들지 못한 것(권한 등).
114
+ function lockTimeout(code, seenLock, last, label) {
115
+ if (!seenLock && code !== 'EEXIST') return new UsageError(`잠금 파일을 만들지 못함(${code})`);
116
+ return new UsageError(
117
+ `다른 쓰기가 끝나지 않음(잠금 ${label}items.json.lock, pid ${last.pid ?? '알 수 없음'}, createdAt ${last.createdAt ?? '알 수 없음'}). 그 프로세스가 끝났는데도 남아 있으면 잠금 파일을 지운 뒤 다시 실행`,
118
+ );
119
+ }
120
+
100
121
  // items.json.lock 을 wx 로 만든다. 있으면 50 ms 간격으로 최대 waitMs 기다린다.
101
122
  // Windows 에서 지우는 중인 파일에 wx 하면 EPERM(또는 EACCES, EBUSY)이라 EEXIST 처럼 기다린다.
102
123
  // 낡은 잠금은 breakStale 로만 정리한다.
@@ -109,27 +130,15 @@ export async function acquireLock(dataDir, { fsx = fs, waitMs = LOCK_WAIT_MS, la
109
130
  let last = {};
110
131
  let seenLock = false; // 기다리는 동안 잠금 파일이 한 번이라도 보였는지
111
132
  for (;;) {
112
- let code;
113
- try {
114
- fsx.writeFileSync(file, body, { flag: 'wx' });
115
- return { file, mine };
116
- } catch (e) {
117
- code = e.code;
118
- if (!BUSY_CODES.has(code)) throw new UsageError(`잠금 파일을 만들지 못함(${code || e.message})`);
119
- }
133
+ const code = tryLockOnce(file, body, fsx, label);
134
+ if (code === null) return { file, mine };
120
135
  const info = readLock(file, fsx);
121
136
  // 그 사이 풀림: 바로 다시. 지우는 중(EPERM 등)이면 아래에서 기다린다
122
137
  if (info === null && code === 'EEXIST' && Date.now() < until) continue;
123
138
  if (info) seenLock = true;
124
139
  if (info && info.pid !== undefined) last = info;
125
140
  if (staleLock(info, Date.now()) && breakStale(file, info, fsx)) continue;
126
- if (Date.now() >= until) {
127
- // 잠금 파일이 끝내 없었으면 다른 쓰기가 아니라 만들지 못한 것(권한 등)
128
- if (!seenLock && code !== 'EEXIST') throw new UsageError(`잠금 파일을 만들지 못함(${code})`);
129
- throw new UsageError(
130
- `다른 쓰기가 끝나지 않음(잠금 ${label}items.json.lock, pid ${last.pid ?? '알 수 없음'}, createdAt ${last.createdAt ?? '알 수 없음'}). 그 프로세스가 끝났는데도 남아 있으면 잠금 파일을 지운 뒤 다시 실행`,
131
- );
132
- }
141
+ if (Date.now() >= until) throw lockTimeout(code, seenLock, last, label);
133
142
  await sleep(STEP_MS);
134
143
  }
135
144
  }
@@ -287,13 +296,6 @@ function storyChange(o, now, configIds) {
287
296
  const NEXT = ['다음 할 일', ' 1. references/authoring.md 를 읽고 req-spec.json, reqs.json, flow.json, arch.json, adr.json 작성', ' 2. check 로 검사. FAIL 0 까지 최대 3회', ' 3. start 로 서버 시작'];
288
297
 
289
298
  const slash = (p) => p.split(path.sep).join('/');
290
- const realOr = (p) => {
291
- try {
292
- return fs.realpathSync(p);
293
- } catch {
294
- return p;
295
- }
296
- };
297
299
  const inside = (rel) => rel && !rel.startsWith('..') && !path.isAbsolute(rel);
298
300
 
299
301
  // 자료 폴더를 git 제외 목록(git rev-parse --git-path info/exclude)에 한 줄 추가. 추적 파일은 바꾸지 않는다. 실패는 알림만.
@@ -306,8 +308,9 @@ function gitExclude(ctx) {
306
308
  } catch {
307
309
  return 'git 제외 목록: git 저장소 아님, 건너뜀';
308
310
  }
309
- // 링크 경로(macOS /var 와 /private/var, Windows junction)로 부른 --project 도 git 최상위(실제 경로)와 맞게 푼다
310
- const rel = path.relative(realOr(top), path.join(realOr(path.dirname(ctx.dataDir)), path.basename(ctx.dataDir)));
311
+ // 링크 경로(macOS /var 와 /private/var, Windows junction), 다른 대소문자, 8.3 이름으로 부른 --project 나 --data 도
312
+ // git 최상위와 같은 철자로 푼다(realNative: 디스크의 철자)
313
+ const rel = path.relative(realNative(top), realNative(ctx.dataDir));
311
314
  if (!inside(rel)) return 'git 제외 목록: 자료 폴더가 저장소 밖이라 건너뜀';
312
315
  const line = `/${slash(rel)}/`;
313
316
  const shown = inside(path.relative(ctx.project, excl)) ? slash(path.relative(ctx.project, excl)) : 'info/exclude';
@@ -334,10 +337,13 @@ function gitExclude(ctx) {
334
337
  return `git 제외 목록 추가: ${line} (${shown})`;
335
338
  }
336
339
 
337
- // 없을 때만 만든다. 돌려줌: 출력 줄.
340
+ // 없을 때만 만든다. 돌려줌: 출력 줄. 가리키는 것이 없는 링크는 거부한다(만들면 링크 너머에 파일이 생긴다).
338
341
  function makeOnce(file, label, text) {
339
- if (fs.existsSync(file)) return `그대로 둠: ${label}`;
340
- fs.writeFileSync(file, text, { flag: 'wx' });
342
+ if (fs.lstatSync(file, { throwIfNoEntry: false }) !== undefined) {
343
+ if (fs.existsSync(file)) return `그대로 둠: ${label}`;
344
+ throw new UsageError(`${label} 자리에 가리키는 것이 없는 링크가 있어 거부`);
345
+ }
346
+ writeNew(file, text);
341
347
  return `새로 만듦: ${label}`;
342
348
  }
343
349
 
@@ -2,10 +2,11 @@
2
2
  // 예외: 표식이 붙은 우리 줄(앞머리로 가린 규칙 줄과 색인 줄)이 지금 판과 다르면 그 줄만 제자리에서 바꾼다(첫 원본은 .progress-mon.bak).
3
3
  // 대상 파일은 에이전트가 넘긴다(--memory, --memory-dir). 스크립트는 HOME 아래 쓰기 경로를 스스로 만들지 않는다.
4
4
  // 덧붙인 뒤 규칙 로드 한도를 재고 경고 줄을 낸다(종료 코드는 그대로). status, check 도 같은 측정을 다시 한다.
5
+ import crypto from 'node:crypto';
5
6
  import fs from 'node:fs';
6
7
  import os from 'node:os';
7
8
  import path from 'node:path';
8
- import { git, readRegular, UsageError } from './pm-config.mjs';
9
+ import { git, readRegular, realNative, UsageError, writeNew } from './pm-config.mjs';
9
10
 
10
11
  export const MARKER = '<!-- progress-mon:rule -->';
11
12
  export const OMX_OPEN = '<!-- USER:OMX:POLICY:START -->';
@@ -159,8 +160,10 @@ const gitTop = (dir) => {
159
160
  // Codex 프로젝트 지시 합계: 저장소 루트에서 프로젝트 루트까지 각 폴더에서 Codex 가 읽는 파일 크기의 합.
160
161
  export function measureCodex(project, env = process.env) {
161
162
  const limit = codexLimit(env);
162
- const proj = path.resolve(project);
163
- const top = gitTop(proj);
163
+ // 둘 다 디스크의 철자로 푼다: 링크나 다른 대소문자로 준 --project 가 저장소 밖으로 보여 지시 파일을 빼먹지 않게
164
+ const proj = realNative(path.resolve(project));
165
+ const top0 = gitTop(proj);
166
+ const top = top0 && realNative(top0);
164
167
  const rel = top ? path.relative(top, proj) : '';
165
168
  const dirs = [top && !rel.startsWith('..') && !path.isAbsolute(rel) ? top : proj];
166
169
  if (dirs[0] !== proj) for (const part of rel.split(path.sep).filter(Boolean)) dirs.push(path.join(dirs[dirs.length - 1], part));
@@ -349,7 +352,8 @@ function rewrite(file, body) {
349
352
  const real = fs.realpathSync(file);
350
353
  const st = fs.statSync(real);
351
354
  if (!st.isFile() || st.nlink > 1) throw changedError(file);
352
- const tmp = `${real}.${process.pid}.${Date.now().toString(36)}.tmp`;
355
+ // 임시 이름은 난수: Windows 의 wx 는 끊어진 링크를 따라가므로, 미리 맞힐 수 있는 이름이면 링크를 심어 둘 수 있다.
356
+ const tmp = `${real}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
353
357
  try {
354
358
  fs.writeFileSync(tmp, body, { flag: 'wx', mode: st.mode & 0o777 });
355
359
  fs.chmodSync(tmp, st.mode & 0o7777);
@@ -368,13 +372,16 @@ function rewrite(file, body) {
368
372
  }
369
373
  }
370
374
 
371
- // 첫 원본 백업. 이미 있으면 덮지 않는다. 만들지 못하면 바꾸지 않고 거부.
375
+ // 첫 원본 백업. 이미 있으면 덮지 않는다. 만들지 못하거나 그 자리에 일반 파일이 아닌 것(링크 등)이 있으면 바꾸지 않고 거부.
372
376
  function backup(file, buf) {
373
377
  const bak = `${file}.progress-mon.bak`;
374
378
  try {
375
- fs.writeFileSync(bak, buf, { flag: 'wx' });
379
+ writeNew(bak, buf);
376
380
  } catch (e) {
377
- if (e.code !== 'EEXIST') throw new UsageError(`${path.basename(bak)} 백업을 만들지 못해 규칙 줄을 바꾸지 않음(${e.code || e.message})`);
381
+ // EEXIST 는 그 자리에 일반 파일(먼저 남긴 첫 원본)이 있을 때만 통과. 링크, 폴더, 그 사이 사라진 이름은 거부
382
+ const st = e.code === 'EEXIST' ? fs.lstatSync(bak, { throwIfNoEntry: false }) : null;
383
+ const why = e.code !== 'EEXIST' ? e.code || e.message : !st ? '만든 뒤 이름이 사라짐' : !st.isFile() ? '일반 파일이 아닌 것(링크나 폴더)이 있음' : null;
384
+ if (why) throw new UsageError(`${path.basename(bak)} 백업을 만들지 못해 규칙 줄을 바꾸지 않음(${why})`);
378
385
  }
379
386
  return bak;
380
387
  }
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import http from 'node:http';
5
5
  import path from 'node:path';
6
6
  import { spawn } from 'node:child_process';
7
- import { privateIPv4, dataLabel, loadConfig, writeAtomic, readRegular, UsageError } from './pm-config.mjs';
7
+ import { privateIPv4, dataLabel, loadConfig, writeAtomic, writeNew, readRegular, UsageError } from './pm-config.mjs';
8
8
  import { workflowRoots } from './pm-sources.mjs';
9
9
  import { memoryWarnings } from './pm-memory.mjs';
10
10
 
@@ -521,7 +521,7 @@ export async function saveHost(ctx, ip, io, fsx = fs) {
521
521
 
522
522
  // ---------- start 잠금 ----------
523
523
  // 동시에 부른 start 가 서버를 둘 띄우지 않게 start 전체를 server.json.lock 으로 감싼다(wx: 둘 가운데 하나만 만듦,
524
- // 그 자리의 링크는 따라가지 않고 EEXIST). 못 잡은 쪽은 기다렸다가 잡고, 그 안에서 떠 있는 서버를 다시 보아 재사용한다.
524
+ // 그 자리의 링크는 따라가지 않고 EEXIST. Windows 의 wx 는 끊어진 링크를 따라가므로 writeNew 가 먼저 lstat 로 본다). 못 잡은 쪽은 기다렸다가 잡고, 그 안에서 떠 있는 서버를 다시 보아 재사용한다.
525
525
  // 낡은 잠금(쥔 프로세스가 죽음, 또는 START_LOCK_STALE_MS 넘게 남음, 링크나 일반 파일 아님)은 넘겨받기 표지
526
526
  // server.json.lock.taking 을 wx 로 잡은 쪽만, 그 안에서 다시 판정한 뒤 지우고 새로 만든다.
527
527
  const START_LOCK = 'server.json.lock';
@@ -535,7 +535,7 @@ const LOCK_BUSY = new Set(['EEXIST', 'EPERM', 'EACCES', 'EBUSY']);
535
535
  // 돌려줌: 만들었으면 null, 아니면 그 까닭 코드. 그 밖 오류는 UsageError.
536
536
  function makeLockFile(file) {
537
537
  try {
538
- fs.writeFileSync(file, `${process.pid} ${iso(Date.now())}\n`, { flag: 'wx', mode: 0o600 });
538
+ writeNew(file, `${process.pid} ${iso(Date.now())}\n`, { mode: 0o600 });
539
539
  return null;
540
540
  } catch (e) {
541
541
  if (LOCK_BUSY.has(e.code)) return e.code;
@@ -186,7 +186,8 @@ export function stageStates(records) {
186
186
  // ---------- git ----------
187
187
 
188
188
  const gitCache = new Map();
189
- const git = (cwd, args) => execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
189
+ // 홈 폴더에서 부른다(Windows 는 일하는 폴더의 git.exe 를 먼저 찾는다)
190
+ const git = (cwd, args) => execFileSync('git', ['-C', path.resolve(cwd), ...args], { cwd: fs.existsSync(os.homedir()) ? os.homedir() : os.tmpdir(), encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
190
191
 
191
192
  const readText = (p) => {
192
193
  try {
@@ -408,6 +408,7 @@ node '<R>/gate-evidence.mjs' cleanup --dir '<g>'
408
408
 
409
409
  ### 3. 판정 수신
410
410
  각 좌석에서 4값 중 하나를 받는다. 외부 좌석은 **단독 줄 `VERDICT: <값>`** 이 필수이며, 부재·중복·절단·파싱실패·거부는 `ERROR` 다.
411
+ 코드펜스(```, ~~~) 안의 `VERDICT:` 줄은 인용한 글이라 세지 않는다. 판정 줄을 코드펜스로 감싼 답은 판정 줄이 없는 답이라 `ERROR` 다. send 출력의 `answered`, `failed` 는 답한 좌석과 실패한 좌석의 수다. `ok: true` 는 send 가 돌았다는 뜻일 뿐이고, 실패한 좌석의 `facts` 에는 `verdict` 가 없다.
411
412
 
412
413
  **나눠 보낸 페이로드.** 한 좌석에 나눠 보낸 부분들은 그 좌석의 한 판정이다. 모든 부분이 `APPROVE` 일 때만 `APPROVE`, 하나라도 `ERROR` 면 `ERROR`, 그 밖은 가장 나쁜 값(`REJECT` 가 `ITERATE` 보다 나쁘다)이다. 부분끼리는 서로의 내용을 모르므로 좌석 줄에 `분할 N` 을 적는다.
413
414
 
@@ -601,11 +601,24 @@ const readText = (file) => {
601
601
  }
602
602
  };
603
603
 
604
+ // The standalone verdict lines of an answer. A line inside a code fence (``` or ~~~) is quoted text, not the seat's verdict.
605
+ export function verdictLines(out) {
606
+ let fence = null;
607
+ const found = [];
608
+ for (const line of out.split(/\r?\n/)) {
609
+ const mark = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
610
+ if (mark && (!fence || (mark[1][0] === fence[0] && mark[1].length >= fence.length))) { fence = fence ? null : mark[1]; continue; }
611
+ const m = !fence && VERDICT_LINE.exec(line);
612
+ if (m) found.push(m[1]);
613
+ }
614
+ return found;
615
+ }
616
+
604
617
  // What a seat's answer and errors say.
605
618
  function factsOf(cli, plan) {
606
619
  const out = readText(plan.answer);
607
620
  const err = readText(plan.stderr);
608
- const facts = { verdicts: out.split(/\r?\n/).map((l) => VERDICT_LINE.exec(l)).filter(Boolean).map((m) => m[1]), empty: !out.trim() };
621
+ const facts = { verdicts: verdictLines(out), empty: !out.trim() };
609
622
  if (cli === "codex") facts.ranModel = (/^model: (\S+)/m.exec(err.split("\n").slice(0, 40).join("\n")) || [])[1] || null;
610
623
  if (cli === "gemini") Object.assign(facts, { policyError: err.includes("Policy file error"), apiError: err.includes("Full report") });
611
624
  if (cli === "opencode") facts.fallback = err.includes("Falling back to default agent");
@@ -665,10 +678,12 @@ async function runParts(ctx, run, seat, g, parts, base) {
665
678
  }
666
679
  const last = done[done.length - 1];
667
680
  const secs = Math.round(done.reduce((t, x) => t + (x.r.secs || 0), 0) * 10) / 10;
681
+ // a failed seat has no verdict to count, so its facts carry none (a reader that skips `status` would count it)
682
+ const shownFacts = (x) => { const f = factsShown(x.f); if (x.why) delete f.verdict; return f; };
668
683
  const result = { ...base, status: last.why ? "failed" : "answered", why: last.why, exit: last.r.exit, signal: last.r.signal, secs,
669
- answer: last.plan.answer, errors: last.plan.stderr, facts: factsShown(last.f) };
684
+ answer: last.plan.answer, errors: last.plan.stderr, facts: shownFacts(last) };
670
685
  if (!last.why && parts.length > 1) result.facts.verdict = gathered(done.map((x) => x.f.verdicts[0]));
671
- if (parts.length > 1) result.parts = done.map((x) => ({ answer: x.plan.answer, errors: x.plan.stderr, why: x.why, facts: factsShown(x.f) }));
686
+ if (parts.length > 1) result.parts = done.map((x) => ({ answer: x.plan.answer, errors: x.plan.stderr, why: x.why, facts: shownFacts(x) }));
672
687
  return result;
673
688
  }
674
689
 
@@ -838,7 +853,9 @@ async function send(o, ctx) {
838
853
  if (thrown) throw thrown.reason;
839
854
  const seats = settled.map((x) => x.value);
840
855
  if (!a.dryRun) appendFileSync(join(d, "run.md"), seats.filter((x) => x.cliVersion).map((x) => `cli-${x.seat} ${x.cliVersion}\n`).join(""), { mode: 0o600 });
841
- const result = { ok: true, ...(a.dryRun ? { dryRun: true } : {}), dir: d, out, bytes: payload.length, scan: 0, unsent: unsentCount(d), seats, declared: declared(run, payload.length, seats, d) };
856
+ // ok says the send ran; whether seats answered is in `answered`, `failed` and each seat's status
857
+ const answered = seats.filter((x) => x.status === "answered").length;
858
+ const result = { ok: true, ...(a.dryRun ? { dryRun: true } : {}), dir: d, out, bytes: payload.length, scan: 0, unsent: unsentCount(d), answered, failed: seats.filter((x) => x.status === "failed").length, seats, declared: declared(run, payload.length, seats, d) };
842
859
  if (!a.dryRun) writeResult(d, "send", resultJson("send", result));
843
860
  return [result, signals.code(), a.dryRun ? null : d];
844
861
  } finally {