@kaydash9999/banker-plugins 0.23.0 → 0.23.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +27 -0
- package/README.md +1 -1
- package/codex/skills/teamwork/SKILL.md +8 -2
- package/codex/skills/teamwork/scripts/teamwork.mjs +65 -15
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +1 -1
- package/skills/3d-intro-build/references/step-nav.js +8 -1
- package/skills/progress-mon/README.md +2 -0
- package/skills/progress-mon/SKILL.md +1 -1
- package/skills/progress-mon/references/items.md +2 -2
- package/skills/progress-mon/scripts/pm-check.mjs +28 -5
- package/skills/progress-mon/scripts/pm-config.mjs +4 -2
- package/skills/progress-mon/scripts/pm-data.mjs +2 -2
- package/skills/progress-mon/scripts/pm-memory.mjs +52 -27
- package/skills/progress-mon/scripts/pm-server.mjs +66 -20
- package/skills/ralph-qa/SKILL.md +3 -2
- package/skills/ralph-qa/references/seat-run.mjs +41 -4
- package/skills/remains/SKILL.md +1 -0
- package/skills/ultraqa/SKILL.md +1 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "banker",
|
|
3
|
-
"version": "0.23.
|
|
3
|
+
"version": "0.23.2",
|
|
4
4
|
"description": "Claude Code plugin: QA/audit/docs/architecture skills + commands, a /banker:setup installer for OS dependencies (playwright, etc.), and wiki compaction.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "smallOpenSource",
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.23.2] - 2026-10-31
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- `teamwork`(Codex 전용): Codex `/rename` 으로 정한 세션 이름을 알아본다. 피어 목록과 규칙에 `이름:` 으로 보이고, `send <이름>` 으로 보낸다(전송은 그대로 UUID). 공백과 `-` 를 같게 보고 대소문자를 가리지 않으며, 대상 찾는 순서는 `all`, 역할, 이름, 짧은ID 다. 역할이 없으면 요청 머리말의 보낸 세션에 이름을 쓴다. 이름을 바꾸면 다른 세션은 다음 입력 때 바뀐 목록을 받는다. 이름은 `$CODEX_HOME/session_index.jsonl` 의 그 세션 마지막 줄에서 읽고, 일반 파일이 아니면 읽지 않는다.
|
|
7
|
+
|
|
8
|
+
### Notes
|
|
9
|
+
- 0.23.1 은 npm 에 게시하지 않았다. npm 에서는 0.23.0 다음이 0.23.2 이고, 0.23.1 의 변경도 이 판에 들어 있다.
|
|
10
|
+
- 실제 Codex CLI 0.165.0 두 세션(tmux)에서 `/rename` 이름으로 요청과 결과를 양방향으로 주고받았고, 열린 세션에서 이름을 바꾼 뒤 새 이름으로도 보냈다.
|
|
11
|
+
- teamwork 엔진이 0.3.1 로 바뀌어 훅 명령의 `--rev` 가 달라진다. 스킬로 `on` 을 다시 실행한 뒤 Codex 가 teamwork 훅 신뢰를 한 번 다시 묻는다.
|
|
12
|
+
|
|
13
|
+
## [0.23.1] - 2026-10-31
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- `ralph-qa` send 가 폴더를 잡은 직후, 좌석 작업 전에 `.sent.md` 표지를 send 당 한 번 쓴다. 두 send 가 겹치거나 첫 send 가 결과를 쓰기 전에 멈춰도, 다음 send 는 같은 페이로드를 다시 보내지 않는다(외부 좌석 과금과 사본 겹침 방지). 표지를 못 쓰면 새 멈춤 `sent-mark` 로 끝나고 아무것도 보내지 않는다. `sent` 안내는 좌석의 답이 답 폴더(`ralph-qa-out.*`)에 있다고 적는다.
|
|
17
|
+
- `progress-mon`
|
|
18
|
+
- start 잠금: 읽기 전용 폴더 같은 진짜 권한 오류는 15초를 기다리지 않고 1초대에 까닭과 함께 끝난다. 낡은 잠금을 넘겨받는 표지 자리도 같다.
|
|
19
|
+
- `server.log` 가 링크, 특수 파일, 하드링크면 쓰지도 비우지도 않고, 기록이 멈춘 것을 `status` 경고로 한 번 알린다. 그 밖 파일 시스템 오류도 코드와 함께 한 번 알린다.
|
|
20
|
+
- 메모리 대상(`CLAUDE.md`, `AGENTS.md`, `MEMORY.md` 등)이 하드링크면 거부한다. 검사 뒤 쓰기 전에 하드링크나 특수 파일로 바뀌면 쓰지 않고, FIFO 에서 멈추지 않는다. 거부 문구에 종류별 대안을 적는다.
|
|
21
|
+
- 커밋 해시는 4~40자 16진으로 통일한다. `check` 가 `commitRegex` 를 실제 커밋 제목으로 돌려 보고(스토리가 없으면 화면처럼 돌리지 않음), `config.json` 이 폴더나 특수 파일이면 까닭을 밝힌다.
|
|
22
|
+
- README 보안 요약을 실제 동작에 맞춘다.
|
|
23
|
+
- `3d-intro-build` 결과 페이지: 소개 안 버튼이나 링크에 포커스가 있으면 좌우 키를 그 요소에 맡긴다(장면 점과 nav 는 그대로 장면 이동). 안쪽 요소의 blur 가 누르는 중 상태를 풀어 스냅이 잘못 일어나던 것을 고친다. 결과 페이지의 e2e 시험은 기준 프레임이 빈 화면이면 실패한다.
|
|
24
|
+
- `remains`: `for` 반복으로 시험을 여러 번 돌릴 때 반복마다 `|| exit /b 1`(POSIX 는 `|| exit 1`)을 붙이라고 안내한다. 앞 반복의 실패가 마지막 반복의 종료 코드에 가려지지 않는다.
|
|
25
|
+
- `ultraqa`: 게이트 명령(`package.json` 시험 명령 등)을 찾은 그대로 돌리고, 시간 제한 같은 플래그를 더하거나 바꾸지 않는다. 바꿔야 하면 사용자에게 묻는다.
|
|
26
|
+
|
|
27
|
+
### Notes
|
|
28
|
+
- 훅 코드는 바뀌지 않아 Codex 플러그인 사용자에게 `/hooks` 신뢰를 다시 묻지 않는다.
|
|
29
|
+
|
|
3
30
|
## [0.23.0] - 2026-10-30
|
|
4
31
|
|
|
5
32
|
### Added
|
package/README.md
CHANGED
|
@@ -100,7 +100,7 @@ Claude Code는 `/banker:<이름>`, Codex는 `$banker-<이름>`(플러그인 설
|
|
|
100
100
|
| `/banker:front-qa` | 스펙(note) 기반 프론트엔드 구현 + parity QA |
|
|
101
101
|
| `/banker:setup` | 구성요소·의존성 설치 오케스트레이터 (multi-select) |
|
|
102
102
|
| `/graceful-pause` | 작업 중에도 바로 실행되는 정지 요청. 지금 단계만 끝내고 멈춘 뒤 보고하고 지시를 기다림 (Claude Code 2.1.289 이상, mod. Codex 는 작업 중에 `$banker-graceful-pause` 를 보내면 실행 중인 도구 호출이 끝난 뒤 멈춤) |
|
|
103
|
-
| `$banker-teamwork` (Codex 전용 스킬) | 같은 기기의 Codex CLI 세션끼리 피어 협업을 켜고 끔(`on` 기본, `off`, `status`). 켜면 열린 세션과 새 세션이 모두 팀으로 동작하고, 역할은 각 세션에 말로 주며, 요청과 결과는 Codex 내장 `codex queue` 로 오감. 플러그인 길은 `$banker:teamwork` |
|
|
103
|
+
| `$banker-teamwork` (Codex 전용 스킬) | 같은 기기의 Codex CLI 세션끼리 피어 협업을 켜고 끔(`on` 기본, `off`, `status`). 켜면 열린 세션과 새 세션이 모두 팀으로 동작하고, 역할은 각 세션에 말로 주며, 요청과 결과는 Codex 내장 `codex queue` 로 오감. 보낼 대상은 역할, `/rename` 으로 정한 세션 이름, 짧은ID 로 지정. 플러그인 길은 `$banker:teamwork` |
|
|
104
104
|
| `/progress` | 진행 상황 패널을 켜고 끔(`show` 기본 토글, `on`, `off`). 맨 위는 이 세션이 쓰거나 읽은 마지막 계획 파일(`.omc/plans/*.md`, `docs/superpowers/plans/*.md`, `~/.claude/plans/*.md`, ralph `prd.json`, ultragoal `goals.json`) 전체. Notion 토글 목록처럼 제목이 그 아래 내용을, 목록 항목이 하위 항목을, 표와 코드 블록이 행과 줄을 품고, 처음에는 큰 제목만 접힌 채 보임. 체크박스는 완료와 대기, 제목은 그 아래 체크 수(`2/5`). 그 아래 단계는 할 일 목록(Claude 의 작업 목록이나 `mcp__banker__todo`), 없으면 이 세션의 요청이고, 하위 목록은 그 단계가 진행 중일 때 한 도구 호출 한 단계까지. 작업 중에도 바로 실행됨(2.1.289 이상에서 확인) (mod. Codex 는 `$banker-progress` 스킬이 지금 세션의 계획 트리, `update_plan` 단계, 최근 도구 호출을 텍스트로 보이고, tmux 안이면 `--pane` 으로 오른쪽 칸에 갱신) |
|
|
105
105
|
|
|
106
106
|
Claude Code 2.1.296 은 Claude 5 모델(Opus 5.5 등)에 작업 목록 도구(TaskCreate, TodoWrite)를 주지 않습니다. 그래서 `/progress` 를 켜면 모델에게 할 일 도구 `mcp__banker__todo` 를 줍니다(다음 요청부터). 모델이 여러 단계 작업을 시작할 때 할 일 목록을 보내면 그 목록이 단계가 됩니다. 한 단계로 끝나는 일에는 쓰지 않으므로 그때는 요청이 단계입니다. 내장 작업 목록 도구가 있는 세션(옛 모델, `CLAUDE_CODE_ENABLE_TODO_TOOLS=1`)에는 이 도구를 더하지 않습니다. 도구는 `/progress off` 뒤에도 세션이 끝날 때까지 남고, 그동안 부르면 패널이 꺼져 있다고 답합니다.
|
|
@@ -79,9 +79,12 @@ argument-hint: "on | off | status"
|
|
|
79
79
|
|---|---|
|
|
80
80
|
| `<래퍼> role <역할>` | 사용자가 이 세션의 역할을 정해 줄 때 |
|
|
81
81
|
| `<래퍼> peers` | 보내기 전 피어 확인 |
|
|
82
|
-
| `<래퍼> send
|
|
82
|
+
| `<래퍼> send <역할\|이름\|짧은ID\|all> '<요청>'` | 다른 세션에 일을 맡길 때 |
|
|
83
83
|
| `<래퍼> reply <요청 번호> '<결과>'` | 받은 요청의 결과를 보낼 때. 요청 하나에 한 번만 |
|
|
84
84
|
|
|
85
|
+
- 이름은 Codex `/rename` 으로 정한 세션 이름. 피어 목록의 `이름:` 값
|
|
86
|
+
- 공백과 `-` 를 같게 보고 대소문자를 가리지 않음 (`'리뷰 담당'` 과 `리뷰-담당`)
|
|
87
|
+
- 대상 찾는 순서: `all`, 역할, 이름, 짧은ID. 같은 역할이나 이름이 여럿이면 모두에게 보냄
|
|
85
88
|
- 본문은 홑따옴표로 감쌈. 따옴표, `$`, 백틱, 여러 줄이 있으면 작업 폴더에 파일로 쓰고 `--file <경로>` 를 씀
|
|
86
89
|
- `--file` 은 이 세션의 작업 폴더(훅이 기록한 세션 폴더)와 임시 폴더 안의 파일만 읽음. 명령을 부른 폴더는 기준이 아님
|
|
87
90
|
- 링크를 따라가지 않고, 연 파일이 확인한 파일과 같은지(dev, ino) 봄
|
|
@@ -100,6 +103,8 @@ argument-hint: "on | off | status"
|
|
|
100
103
|
- 피어 목록의 폴더는 훅이 기록한 세션 폴더만 씀. 명령(`role`, `send`, `reply`, `on`)을 부른 폴더는 기록하지 않음
|
|
101
104
|
- 목록에 보일 때는 제어 문자와 줄 구분 문자를 `?` 로 바꾸고 200자로 자른 뒤 JSON 문자열로 감쌈
|
|
102
105
|
- 역할 이름도 기록할 때와 보일 때 모두 공백, 제어 문자, 괄호, 따옴표를 `-` 로 바꿈
|
|
106
|
+
- 세션 이름(`/rename`)은 폴더처럼 바꾸고 64자로 잘라 JSON 문자열로 감쌈. 머리말의 보낸 세션에 쓸 때는 역할 이름처럼 바꿈
|
|
107
|
+
- `session_index.jsonl` 이 일반 파일이 아니면 읽지 않고 이름 없이 동작함. FIFO 에서 멈추지 않음. 1MiB 를 넘으면 끝부분만 읽음
|
|
103
108
|
- 승인 규칙(`$CODEX_HOME/rules/teamwork.rules`)은 래퍼의 하위 명령 6개만 허용함. 첫 줄 표지(`# teamwork <판>`)가 없는 같은 이름의 사용자 파일은 덮어쓰지도 지우지도 않음
|
|
104
109
|
- 래퍼와 훅은 샌드박스 밖에서 돎. 그래서
|
|
105
110
|
- `node`, `codex`, `ps`, `sh` 는 `on` 때 찾은 절대 경로만 씀. PATH 로 다시 찾지 않음. 기록이 없거나 파일이 사라지면 훅은 빈 응답과 한 줄 안내, 명령은 6
|
|
@@ -131,7 +136,8 @@ argument-hint: "on | off | status"
|
|
|
131
136
|
- Linux, macOS. Node 18 이상, Codex 0.149.0 이상 (`codex queue`). Windows 는 `on` 이 까닭을 밝히고 6. Codex 0.160.1 Windows 실측에서 승인 규칙 판정(`codex execpolicy check`)이 래퍼를 바로 부르는 꼴에만 맞고 PowerShell 로 부르는 꼴(`& '<래퍼>' send ...`)에는 맞지 않아, 쓸 때마다 승인을 물어야 하기 때문
|
|
132
137
|
- 같은 `CODEX_HOME` 을 쓰는 세션끼리만 통신
|
|
133
138
|
- Codex 0.165.0 은 훅과 명령을 상주 서버(app-server)에서 돌림. 그래서 세션의 pid 를 알 수 없고, 닫은 세션의 정리는 마지막 활동 뒤 24시간 기준
|
|
134
|
-
- 세션 이름(`/rename`)
|
|
139
|
+
- 세션 이름(`/rename`)은 Codex 가 `$CODEX_HOME/session_index.jsonl` 에 덧붙이는 줄 중 그 세션의 마지막 줄에서 읽음. 이름을 바꾸면 다른 세션은 다음 입력 때 바뀐 목록을 받음
|
|
140
|
+
- 전송은 이름이 아니라 UUID(`CODEX_THREAD_ID`)로 함. 이름으로 보내는 `codex queue` 는 0.165.0 에서 "No active session found" 로 실패하기 때문. `send <이름>` 은 스크립트가 UUID 로 바꿔 보냄
|
|
135
141
|
- 받는 세션이 작업 중이면 그 턴이 끝난 뒤 처리. Esc 로 중단하면 큐에 남음
|
|
136
142
|
- `off` 전에 보낸 요청 중 처리되지 않은 것은 받는 쪽이 처리하지 않음. 보낸 쪽은 알 수 없음
|
|
137
143
|
- 훅을 신뢰하기 전에는 `codex exec` 가 응답 없이 멈출 수 있음. TUI 에서 한 번 신뢰한 뒤 사용
|
|
@@ -13,7 +13,7 @@ import crypto from 'node:crypto';
|
|
|
13
13
|
import { spawnSync } from 'node:child_process';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
|
|
16
|
-
const VERSION = '0.3.
|
|
16
|
+
const VERSION = '0.3.1';
|
|
17
17
|
const MIN_CODEX = [0, 149, 0];
|
|
18
18
|
const CODEX_HOME = path.resolve(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'));
|
|
19
19
|
const TW = path.join(CODEX_HOME, 'teamwork');
|
|
@@ -28,6 +28,8 @@ const TURN = path.join(TW, 'turn');
|
|
|
28
28
|
const LOG = path.join(TW, 'log.jsonl');
|
|
29
29
|
const ORPHAN = path.join(TW, 'orphan-noticed');
|
|
30
30
|
const HOOKS = path.join(CODEX_HOME, 'hooks.json');
|
|
31
|
+
const INDEX = path.join(CODEX_HOME, 'session_index.jsonl');
|
|
32
|
+
const INDEX_TAIL = 1024 * 1024;
|
|
31
33
|
const RULES = path.join(CODEX_HOME, 'rules', 'teamwork.rules');
|
|
32
34
|
const RULES_MARK = '# teamwork ';
|
|
33
35
|
const ALLOWED = ['peers', 'role', 'send', 'reply', 'status', 'version'];
|
|
@@ -71,7 +73,7 @@ function writeText(file, text) { ensureDir(path.dirname(file)); fs.writeFileSync
|
|
|
71
73
|
|
|
72
74
|
function shortId(id) { return String(id).replace(/-/g, '').slice(-6); }
|
|
73
75
|
|
|
74
|
-
function isUuid(s) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s
|
|
76
|
+
function isUuid(s) { return typeof s === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); }
|
|
75
77
|
|
|
76
78
|
function state() { return readJson(STATE, { enabled: false }); }
|
|
77
79
|
|
|
@@ -283,6 +285,35 @@ function dropStale(file, p) {
|
|
|
283
285
|
return again;
|
|
284
286
|
}
|
|
285
287
|
|
|
288
|
+
// ---------- 세션 이름 (/rename) ----------
|
|
289
|
+
|
|
290
|
+
// Codex 는 /rename 이름을 $CODEX_HOME/session_index.jsonl 에 한 줄씩 덧붙인다. 같은 id 의 마지막 줄이 지금 이름이다.
|
|
291
|
+
// 특수 파일에서 멈추지 않게 O_NONBLOCK 으로 열어 일반 파일만 읽고, 커지면 끝 1MiB 만 본다
|
|
292
|
+
function readIndexTail() {
|
|
293
|
+
let fd;
|
|
294
|
+
try { fd = fs.openSync(INDEX, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0)); } catch { return ''; }
|
|
295
|
+
try {
|
|
296
|
+
const st = fs.fstatSync(fd);
|
|
297
|
+
if (!st.isFile()) return '';
|
|
298
|
+
const len = Math.min(st.size, INDEX_TAIL);
|
|
299
|
+
const buf = Buffer.alloc(len);
|
|
300
|
+
const text = buf.toString('utf8', 0, fs.readSync(fd, buf, 0, len, st.size - len));
|
|
301
|
+
return len < st.size ? text.slice(text.indexOf('\n') + 1) : text;
|
|
302
|
+
} catch { return ''; } finally { fs.closeSync(fd); }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function parseThreadNames(text) {
|
|
306
|
+
const names = new Map();
|
|
307
|
+
for (const line of String(text).split('\n')) {
|
|
308
|
+
let e;
|
|
309
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
310
|
+
if (e && isUuid(e.id) && typeof e.thread_name === 'string') names.set(e.id.toLowerCase(), e.thread_name.trim());
|
|
311
|
+
}
|
|
312
|
+
return names;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function threadNames() { return parseThreadNames(readIndexTail()); }
|
|
316
|
+
|
|
286
317
|
function roster() {
|
|
287
318
|
let names = [];
|
|
288
319
|
try { names = fs.readdirSync(PEERS).filter((n) => n.endsWith('.json')); } catch { return []; }
|
|
@@ -295,28 +326,45 @@ function roster() {
|
|
|
295
326
|
const keep = isStale(p, now) ? dropStale(file, p) : p;
|
|
296
327
|
if (keep) list.push(keep);
|
|
297
328
|
}
|
|
298
|
-
|
|
329
|
+
const titles = list.length ? threadNames() : new Map();
|
|
330
|
+
return list.map((p) => ({ ...p, name: titles.get(p.id.toLowerCase()) || '' }))
|
|
331
|
+
.sort((a, b) => String(a.startedAt).localeCompare(String(b.startedAt)));
|
|
299
332
|
}
|
|
300
333
|
|
|
334
|
+
const NAME_SEP = /[\s\u0000-\u001f\u007f[\]()#:'"`$\\]+/g;
|
|
335
|
+
|
|
301
336
|
// 역할 이름: 머리말과 목록의 줄을 흉내 내지 못하게 공백, 제어 문자, 괄호와 따옴표를 바꾼다. 기록할 때와 보일 때 모두 쓴다
|
|
302
|
-
function roleName(s) { return String(s || '').normalize('NFKC').trim().replace(
|
|
337
|
+
function roleName(s) { return String(s || '').normalize('NFKC').trim().replace(NAME_SEP, '-').slice(0, 32); }
|
|
338
|
+
|
|
339
|
+
// 세션 이름 비교: 공백과 '-' 를 같게 보고 대소문자를 가리지 않는다 ('리뷰 담당' 과 리뷰-담당). 앞뒤에 남은 '-'(따옴표 등)는 뺀다
|
|
340
|
+
function nameKey(s) { return String(s || '').normalize('NFKC').trim().toLowerCase().replace(NAME_SEP, '-').replace(/^-+|-+$/g, ''); }
|
|
303
341
|
|
|
304
|
-
|
|
342
|
+
// 머리말의 보낸 세션: 역할, 없으면 세션 이름
|
|
343
|
+
function label(p) { const r = roleName(p.role) || roleName(p.name); return r ? `${r}(${shortId(p.id)})` : shortId(p.id); }
|
|
305
344
|
|
|
306
|
-
// 폴더는 그 세션의 훅 입력(cwd)에서 온다. 줄을 나눠 지시처럼 보이지 못하게 제어 문자를 바꾸고
|
|
307
|
-
function showCwd(s) { return sanitize(JSON.stringify(String(s).replace(/[\u0000-\u001f\u007f\u2028\u2029]/g, '?').slice(0,
|
|
345
|
+
// 폴더는 그 세션의 훅 입력(cwd)에서 온다. 줄을 나눠 지시처럼 보이지 못하게 제어 문자를 바꾸고 잘라서 JSON 문자열로 감싼다. 세션 이름도 같게
|
|
346
|
+
function showCwd(s, max = 200) { return sanitize(JSON.stringify(String(s).replace(/[\u0000-\u001f\u007f\u2028\u2029]/g, '?').slice(0, max))); }
|
|
347
|
+
|
|
348
|
+
function peerLine(p, selfId) {
|
|
349
|
+
const name = p.name ? ` 이름:${showCwd(p.name, 64)}` : '';
|
|
350
|
+
return `- ${shortId(p.id)}${name} 역할:${roleName(p.role) || '미정'} 폴더:${p.cwd ? showCwd(p.cwd) : '?'}${p.id === selfId ? ' (이 세션)' : ''}`;
|
|
351
|
+
}
|
|
308
352
|
|
|
309
353
|
function rosterText(list, selfId) {
|
|
310
354
|
if (!list.length) return '등록된 세션 없음';
|
|
311
|
-
return list.map((p) =>
|
|
355
|
+
return list.map((p) => peerLine(p, selfId)).join('\n');
|
|
312
356
|
}
|
|
313
357
|
|
|
358
|
+
// 대상 찾는 순서: all, 역할, 세션 이름(/rename), 짧은ID 나 UUID. 같은 역할이나 이름이 여럿이면 모두에게 보낸다
|
|
314
359
|
function resolveTargets(to, selfId) {
|
|
315
360
|
const list = roster().filter((p) => p.id !== selfId);
|
|
316
361
|
const key = String(to).trim().toLowerCase();
|
|
317
362
|
if (key === 'all') return list;
|
|
318
363
|
const byRole = list.filter((p) => p.role && p.role.toLowerCase() === key);
|
|
319
364
|
if (byRole.length) return byRole;
|
|
365
|
+
const nk = nameKey(to);
|
|
366
|
+
const byName = list.filter((p) => nk && p.name && nameKey(p.name) === nk);
|
|
367
|
+
if (byName.length) return byName;
|
|
320
368
|
return list.filter((p) => shortId(p.id) === key.replace(/-/g, '') || p.id.toLowerCase() === key);
|
|
321
369
|
}
|
|
322
370
|
|
|
@@ -324,15 +372,16 @@ function resolveTargets(to, selfId) {
|
|
|
324
372
|
|
|
325
373
|
function protocolText(selfId, list) {
|
|
326
374
|
const w = shq(WRAPPER);
|
|
327
|
-
const me = selfId ? readJson(peerFile(selfId), {}) : {};
|
|
375
|
+
const me = list.find((p) => p.id === selfId) || (selfId ? readJson(peerFile(selfId), {}) : {});
|
|
376
|
+
const name = me.name ? ` 이름:${showCwd(me.name, 64)}` : '';
|
|
328
377
|
return [
|
|
329
378
|
'[teamwork 켜짐] 이 기기의 Codex 세션들이 피어로 협업한다.',
|
|
330
|
-
`이 세션: ${selfId ? shortId(selfId) : '?'} 역할:${roleName(me.role) || '미정'}`,
|
|
379
|
+
`이 세션: ${selfId ? shortId(selfId) : '?'}${name} 역할:${roleName(me.role) || '미정'}`,
|
|
331
380
|
'피어 목록:',
|
|
332
381
|
rosterText(list, selfId),
|
|
333
382
|
'규칙:',
|
|
334
383
|
`- 사용자가 이 세션의 역할을 정해 주면 \`${w} role <역할>\` 을 실행한다.`,
|
|
335
|
-
`- 다른 세션에 일을 맡길 때 \`${w} send
|
|
384
|
+
`- 다른 세션에 일을 맡길 때 \`${w} send <역할|이름|짧은ID|all> '<요청>'\` 을 실행한다. 이름은 Codex \`/rename\` 으로 정한 세션 이름이고 목록의 이름: 값이다. 이름에 공백이 있으면 홑따옴표로 감싸거나 공백을 - 로 바꾼다. 내용에 따옴표, $, 백틱, 여러 줄이 있으면 작업 폴더에 파일로 쓰고 \`${w} send <대상> --file <경로>\` 를 쓴다. 보내기 전에 \`${w} peers\` 로 목록을 확인한다.`,
|
|
336
385
|
'- 요청을 보낸 뒤 결과를 기다리지 말고 할 수 있는 일을 마치고 턴을 끝낸다. 결과는 이 세션의 턴이 끝난 뒤 다음 입력으로 도착한다.',
|
|
337
386
|
'- "[teamwork 요청" 으로 시작하는 입력은 피어의 요청이다. 처리하고 그 안의 reply 명령으로 결과를 한 번 보낸다. reply 명령이 없으면 결과를 사용자에게만 알린다.',
|
|
338
387
|
'- "[teamwork 결과" 로 시작하는 입력은 내가 맡긴 일의 결과다. 회신하지 않는다. 사용자에게 보고하거나 원래 작업에 반영하고 끝낸다.',
|
|
@@ -355,7 +404,7 @@ const orphanText = () => `[teamwork 멈춤] banker 가 이 기기에서 지워
|
|
|
355
404
|
|
|
356
405
|
function digest(selfId, list) {
|
|
357
406
|
const me = list.find((p) => p.id === selfId) || readJson(peerFile(selfId), {});
|
|
358
|
-
const peers = list.map((p) => `${p.id}:${p.role}`).join(',');
|
|
407
|
+
const peers = list.map((p) => `${p.id}:${p.role}:${p.name || ''}`).join(',');
|
|
359
408
|
return crypto.createHash('sha1').update(`${me.role}|${peers}`).digest('hex');
|
|
360
409
|
}
|
|
361
410
|
|
|
@@ -856,7 +905,8 @@ function sender() {
|
|
|
856
905
|
const id = selfId();
|
|
857
906
|
if (id) register(id, { pid: findCodexPid(process.ppid) });
|
|
858
907
|
const me = id ? readJson(peerFile(id), {}) : {};
|
|
859
|
-
|
|
908
|
+
const name = id ? threadNames().get(id.toLowerCase()) : '';
|
|
909
|
+
return { id, from: id ? label({ id, role: me.role, name }) : '사용자' };
|
|
860
910
|
}
|
|
861
911
|
|
|
862
912
|
function checkBody(text, usageLine) {
|
|
@@ -868,7 +918,7 @@ function noTarget(msg, extra) { err(msg); if (extra) err(extra); process.exit(EX
|
|
|
868
918
|
|
|
869
919
|
// 요청: 받는 쪽은 reply 로 결과를 한 번 보낸다
|
|
870
920
|
function cmdSend(to, args) {
|
|
871
|
-
const usageLine = '사용법: teamwork send
|
|
921
|
+
const usageLine = '사용법: teamwork send <역할|이름|짧은ID|all> \'<요청>\' 또는 --file <경로>';
|
|
872
922
|
if (!to) usageExit(usageLine);
|
|
873
923
|
const text = readBody(args);
|
|
874
924
|
checkBody(text, usageLine);
|
|
@@ -913,7 +963,7 @@ function cmdReply(rid, args) {
|
|
|
913
963
|
}
|
|
914
964
|
|
|
915
965
|
function usage() {
|
|
916
|
-
out('사용법: teamwork <on|off|status|peers|role <역할>|send
|
|
966
|
+
out('사용법: teamwork <on|off|status|peers|role <역할>|send <역할|이름|짧은ID|all> \'<요청>\'|reply <요청 번호> \'<결과>\'|uninstall|version>');
|
|
917
967
|
}
|
|
918
968
|
|
|
919
969
|
const COMMANDS = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaydash9999/banker-plugins",
|
|
3
|
-
"version": "0.23.
|
|
3
|
+
"version": "0.23.2",
|
|
4
4
|
"description": "banker — Claude Code + Codex CLI plugin (QA, audit, docs, architecture, wiki skills). Install via Claude Code marketplace, or `npm i -g @kaydash9999/banker-plugins` then `banker setup`.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"banker": "bin/banker.js"
|
|
@@ -124,7 +124,7 @@ if (creds._tracked) throw new Error(`프로젝트 키 파일을 git 이 추적
|
|
|
124
124
|
| `CAMERA_FEEL` | CLIP_MODE 를 따름. dive 면 "장면마다 바깥에서 들어감"(장면별 MOVE, b 는 나가기 없음, c 는 있음), chain 과 holdFlight 면 잔잔한 전진 활공(fly-through, walkthrough, locked-iso 중 고름) | 카메라 움직임 |
|
|
125
125
|
| `MOBILE` | no | yes 면 약 2배 비용(세로 체인 추가) |
|
|
126
126
|
| `STILLS_SOURCE` | gpt-image-2 | 코히전 강화가 필요하면 FLUX 옵트인 |
|
|
127
|
-
| `STEP_NAV` | yes | 장면 단위 이동(`step-nav.js`). 멈춤 자리는 장면마다 시작점(dive 첫 프레임, 나가기는 다음 장면으로 잇는 구간), 마지막 장면 끝, 본문 시작. 휠 1칸과 방향키(위아래, 좌우) 1번은 다음 멈춤 자리까지 절반, 2번이면 한 장면. PageUp/PageDown, Space, 스와이프는 한 장면, Home 은 첫 장면 시작, End 는 마지막 장면 끝으로 애니메이션 없이 바로 가고, 장면 점(클릭, 또는 점에 포커스를 두고 Enter 나 Space)도 그 장면 시작점으로 바로 간다(포스터 디코드 뒤, 문구도 바로). 포스터 디코드를 기다리는 시간이 150ms(`loadingDelayMs`)를 넘으면 화면 가운데에 작은 불러오는 중 표시(`loadingLabel`, 화면 읽기 프로그램에 알림, 포커스와 입력은 막지 않음, 움직임 줄이기에서는 돌지 않음)를 보이고 이동하면 바로 숨긴다. 애니메이션 이동의 대기(최대 300ms)에도 같은 규칙이다. 멈춤 자리나 절반 지점이 아닌 곳에 멈추면 잠시 뒤 가까운 장면 시작점으로 붙는다. 멈춘 자리에서는 그 장면 문구를 보인다. 스크롤 동작을 가로채므로 원하지 않으면 매니페스트 `"stepNav": false` 로 끈다 |
|
|
127
|
+
| `STEP_NAV` | yes | 장면 단위 이동(`step-nav.js`). 멈춤 자리는 장면마다 시작점(dive 첫 프레임, 나가기는 다음 장면으로 잇는 구간), 마지막 장면 끝, 본문 시작. 휠 1칸과 방향키(위아래, 좌우) 1번은 다음 멈춤 자리까지 절반, 2번이면 한 장면. 장면 안 버튼이나 링크에 포커스가 있으면 좌우 키는 그 요소에 맡기고 장면을 옮기지 않는다(장면 점과 상단 메뉴는 예외로 좌우 키로 옮긴다). PageUp/PageDown, Space, 스와이프는 한 장면, Home 은 첫 장면 시작, End 는 마지막 장면 끝으로 애니메이션 없이 바로 가고, 장면 점(클릭, 또는 점에 포커스를 두고 Enter 나 Space)도 그 장면 시작점으로 바로 간다(포스터 디코드 뒤, 문구도 바로). 포스터 디코드를 기다리는 시간이 150ms(`loadingDelayMs`)를 넘으면 화면 가운데에 작은 불러오는 중 표시(`loadingLabel`, 화면 읽기 프로그램에 알림, 포커스와 입력은 막지 않음, 움직임 줄이기에서는 돌지 않음)를 보이고 이동하면 바로 숨긴다. 애니메이션 이동의 대기(최대 300ms)에도 같은 규칙이다. 멈춤 자리나 절반 지점이 아닌 곳에 멈추면 잠시 뒤 가까운 장면 시작점으로 붙는다. 멈춘 자리에서는 그 장면 문구를 보인다. 스크롤 동작을 가로채므로 원하지 않으면 매니페스트 `"stepNav": false` 로 끈다 |
|
|
128
128
|
| `PANEL` | 없음 | `glass` 면 흐린 유리 문구 패널(`panel-glass.css`) |
|
|
129
129
|
|
|
130
130
|
---
|
|
@@ -254,6 +254,9 @@
|
|
|
254
254
|
const pressable = function (t) {
|
|
255
255
|
return Boolean(t) && (/^(BUTTON|A|SUMMARY)$/.test(t.tagName || '') || /^(button|link|checkbox|switch|menuitem|tab|option)$/.test(role(t)));
|
|
256
256
|
};
|
|
257
|
+
// The intro's own controls (the route dots and the nav items) keep moving the scene on Left/Right after a click
|
|
258
|
+
// focuses them; another focused button or link (in scene content) leaves Left/Right to itself.
|
|
259
|
+
const ownControl = function (t) { return Boolean(t && t.closest && t.closest('.sw-nav, .sw-route')); };
|
|
257
260
|
const KEYS = { ArrowDown: { steps: 1 }, ArrowRight: { steps: 1 }, ArrowUp: { steps: -1 }, ArrowLeft: { steps: -1 },
|
|
258
261
|
PageDown: { scenes: 1 }, PageUp: { scenes: -1 }, Home: { to: 0, instant: true }, End: { to: 'last', instant: true } };
|
|
259
262
|
|
|
@@ -261,6 +264,7 @@
|
|
|
261
264
|
function keyInput(e) {
|
|
262
265
|
if (e.altKey || e.ctrlKey || e.metaKey || editable(e.target)) return null;
|
|
263
266
|
if (e.key === ' ') return pressable(e.target) ? null : { scenes: e.shiftKey ? -1 : 1 };
|
|
267
|
+
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && pressable(e.target) && !ownControl(e.target)) return null;
|
|
264
268
|
return KEYS[e.key] || null;
|
|
265
269
|
}
|
|
266
270
|
|
|
@@ -640,7 +644,10 @@
|
|
|
640
644
|
window.addEventListener('pointerdown', function () { ctx.pressed = true; clearTimeout(rest); }, true);
|
|
641
645
|
// a press can end without a pointerup: a context menu, a release outside the window, a lost capture, the tab hidden
|
|
642
646
|
const release = function () { if (!ctx.pressed) return; ctx.pressed = false; later(); };
|
|
643
|
-
['pointerup', 'pointercancel', 'lostpointercapture', 'contextmenu'
|
|
647
|
+
['pointerup', 'pointercancel', 'lostpointercapture', 'contextmenu'].forEach(function (k) { window.addEventListener(k, release, true); });
|
|
648
|
+
// Capture catches the window losing focus; a focused element inside the page losing focus (its own blur, which
|
|
649
|
+
// capture also sees) must not end a press, or a click that moves focus would clear `pressed` mid-press.
|
|
650
|
+
window.addEventListener('blur', function (e) { if (e.target === window) release(); }, true);
|
|
644
651
|
document.addEventListener('visibilitychange', function () { if (document.hidden) release(); });
|
|
645
652
|
window.addEventListener('scroll', function () {
|
|
646
653
|
if (ctx.nav.state().moving) return;
|
|
@@ -67,6 +67,8 @@
|
|
|
67
67
|
- 자료 폴더 파일(`server.json` 포함)은 서빙하지 않는다. 오류 응답은 일반 문구만, 스택은 `server.log` 에만
|
|
68
68
|
- 응답 머리: `cache-control: no-store`, `x-content-type-options: nosniff`, `referrer-policy: no-referrer`, `content-security-policy: default-src 'none'; style-src 'unsafe-inline'; img-src data:`
|
|
69
69
|
- `stop` 은 상태 파일 PID 와 신원 응답 PID 가 같을 때만
|
|
70
|
+
- 자료 폴더나 그 위 `.omc`, `.omx` 가 링크면 모든 명령을 거부한다(저장소가 심은 링크로 밖에 쓰거나 지우지 않게)
|
|
71
|
+
- `server.log` 가 링크, 특수 파일, 하드링크면 쓰지도 비우지도 않고, 기록이 멈춘 것을 status 경고로 한 번 알린다. 자료 파일(`config.json` 등)은 링크를 따라 읽되 일반 파일만 읽는다. 메모리 대상은 프로젝트(또는 `--memory-dir`) 밖을 가리키는 링크와 하드링크를 거부한다
|
|
70
72
|
|
|
71
73
|
## Codex override 가려짐 해소
|
|
72
74
|
|
|
@@ -88,7 +88,7 @@ progress-mon skill body marker v1
|
|
|
88
88
|
| `stop` | 없음 | 상태 파일 PID 가 이 서버일 때만 끔 |
|
|
89
89
|
| `status` | 없음 | 실행 여부, URL, PID, 자료 폴더, 탭별 자료 유무(깨진 JSON 은 깨짐), 시작 시각, 요청 수, 마지막 오류 시각. 서버를 띄운 뒤 `config.json` 의 `port`, `portStrict`, `hosts` 가 바뀌었으면 경고(그 셋만 `stop` 뒤 `start` 해야 적용, 나머지는 다음 응답에 반영) |
|
|
90
90
|
| `export` | `<file> [--now <ISO>]` | 4탭을 한 HTML 파일로. 외부 자원 없음, `file://` 에서 탭 전환. 사용자가 지시할 때만 만든다 |
|
|
91
|
-
| `item` | `--key <k> --status todo\|doing\|done --text <t> [--note <t>] [--issue <t>] [--at <ISO>] [--commit <hash>]` | 기타 항목. 같은 key 는 대체하고 시작 시각 유지(시작 시각은 `doing` 으로 기록할 때만). `--at` 은 지금 대신 그 시각으로 기록(시간대 필수). `--commit` 은
|
|
91
|
+
| `item` | `--key <k> --status todo\|doing\|done --text <t> [--note <t>] [--issue <t>] [--at <ISO>] [--commit <hash>]` | 기타 항목. 같은 key 는 대체하고 시작 시각 유지(시작 시각은 `doing` 으로 기록할 때만). `--at` 은 지금 대신 그 시각으로 기록(시간대 필수). `--commit` 은 4~40자 16진 해시(commit Hash 칸에 앞 7자리). 빼면 있던 해시 유지 |
|
|
92
92
|
| `note` | `--key <k> [--note <t>] [--issue <t>]` | 단계(`stage:<id>`)나 스토리 행의 비고, 문제점. 빈 값은 그 칸을 지움 |
|
|
93
93
|
| `stage` | `--id <id> --status started\|done\|skipped\|failed\|stopped [--label <t>]` | 단계 상태 기록. 설정에 없는 id 는 새 단계 행 |
|
|
94
94
|
| `story` | `--id <id> (--status todo\|doing\|done \| --commit <hash>) [--name <t>]` | 스토리 상태나 완료 커밋 기록. 설정에 없는 id 는 새 스토리 행 |
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
| 명령 | 동작 |
|
|
32
32
|
|---|---|
|
|
33
|
-
| `item --key <k> --status <s> --text <t> [--note <t>] [--issue <t>] [--at <ISO>] [--commit <hash>]` | 같은 key 는 대체하고 시작 시각 `s` 는 유지. 새 key 는 끝에 추가. `s` 는 `doing` 으로 기록할 때만 정함(처음부터 `done`, `todo` 면 없음). `--at` 이 있으면 지금 대신 그 시각을 `t` 로(`doing` 이면 `s` 도). `--commit` 은
|
|
33
|
+
| `item --key <k> --status <s> --text <t> [--note <t>] [--issue <t>] [--at <ISO>] [--commit <hash>]` | 같은 key 는 대체하고 시작 시각 `s` 는 유지. 새 key 는 끝에 추가. `s` 는 `doing` 으로 기록할 때만 정함(처음부터 `done`, `todo` 면 없음). `--at` 이 있으면 지금 대신 그 시각을 `t` 로(`doing` 이면 `s` 도). `--commit` 은 4~40자 16진 해시를 `hash` 에. `--commit` 이 없으면 있던 `hash` 를 유지 |
|
|
34
34
|
| `note --key <k> [--note <t>] [--issue <t>]` | 그 key 의 칸만 대체. 빈 값(`--issue ""`)은 그 칸을 지움 |
|
|
35
35
|
| `stage --id <id> --status <s> [--label <t>]` | `stageEvents` 에 한 줄 추가. 설정에 없는 id 는 새 단계 행(이름은 `--label` 또는 id) |
|
|
36
36
|
| `story --id <id> --status <s> [--name <t>]` | `storyStates` 의 그 id 를 대체 |
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
- `s` 는 항목이 `doing` 이 된 시각. 이미 있으면 바꾸지 않는다. `s` 가 없는 항목(처음부터 `done` 이나 `todo`)은 소요시간이 "-"
|
|
47
47
|
- 옛 판이 쓴 자료에는 `s` 와 `t` 가 같은 `done` 항목이 있다(처음부터 `done` 으로 기록한 항목). 이 줄은 소요시간이 "0:00" 으로 보인다. "-" 로 보이게 하려면 그 항목의 `s` 를 지운다
|
|
48
48
|
- `item --at <ISO>`: 날짜, 시각, 시간대(`Z` 또는 `+09:00`)가 모두 있어야 한다(예 `2026-10-20T12:34:56+09:00`, 초는 선택). 그 밖은 종료 1. 지난 커밋은 `git log --format=%cI` 의 커밋 시각을 그대로 넘긴다. 저장은 위 UTC Z 형식
|
|
49
|
-
- `item --commit <hash>`:
|
|
49
|
+
- `item --commit <hash>`: 4~40자 16진 해시. 그 밖은 종료 1. 손으로 쓴 `hash` 가 이 꼴이 아니면 `check` 가 FAIL. 지난 커밋은 `git log --format=%h` 의 해시를 함께 넘긴다(`--at <%cI> --commit <%h>`)
|
|
50
50
|
- Z 없는 19자 옛 형식(`YYYY-MM-DDTHH:MM:SS`)은 서버를 실행한 환경의 현지 시각으로 읽는다(옛 진척도 서버와 같음). 옛 자료에는 UTC 값을 Z 없이 쓴 줄도 섞여 있다. 그 줄도 현지로 읽어 옛 화면과 같은 시각을 낸다(시차만큼 이르게 보임)
|
|
51
51
|
- Z 나 시차(`+09:00`)가 붙은 시각은 그 값 그대로 읽는다
|
|
52
52
|
- 손으로 적을 때는 Z 를 붙여 UTC 로 적거나 시차를 붙인다. Z 없는 19자는 서버 시간대가 바뀌면 다른 시각이 된다
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { loadConfig, noBom, readRegular, UsageError } from './pm-config.mjs';
|
|
10
|
-
import { readItems, storyList, workflowRoots } from './pm-sources.mjs';
|
|
10
|
+
import { gitLog, regexBatch, readItems, storyList, workflowRoots } from './pm-sources.mjs';
|
|
11
11
|
import { ITEM_STATUS, STAGE_STATUS } from './pm-data.mjs';
|
|
12
12
|
import { archLayout, archFlaws, flowLayout, nameFits, FLOW } from './pm-render.mjs';
|
|
13
13
|
import { color, finite, textW, wrap, TABS } from './pm-html.mjs';
|
|
@@ -63,8 +63,32 @@ function checkConfig(ctx, r) {
|
|
|
63
63
|
return config;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// commitRegex 를 실제 커밋 제목으로 돌려 본다(컴파일만 보던 것). 역추적이 폭발하면 화면은 매 요청 500 인데 check 는
|
|
67
|
+
// 통과하던 것을 FAIL 로 잡는다. 화면처럼 스토리(ids)가 없으면 이 정규식을 쓰지 않으므로 돌리지 않는다.
|
|
68
|
+
// sources.git 이 꺼졌거나 git 저장소가 아니면 돌려보지 못해 WARN.
|
|
69
|
+
function checkCommitRegex(ctx, config, ids, r) {
|
|
70
|
+
if (!config.commitRegex || !ids.length) return;
|
|
71
|
+
if (!config.sources.git) return r.warn('config.json', 'commitRegex', 'sources.git 이 꺼져 있어 실제 커밋 제목으로 돌려보지 못함(화면도 이 정규식을 쓰지 않음)');
|
|
72
|
+
const lines = gitLog(ctx.project, config.sources.gitMax);
|
|
73
|
+
if (lines === null) return r.warn('config.json', 'commitRegex', 'git 저장소가 아니거나 커밋이 없어 실제 제목으로 돌려보지 못함');
|
|
74
|
+
try {
|
|
75
|
+
regexBatch(config.commitRegex, 'commitRegex', lines.map((l) => l.split('|').slice(2).join('|')));
|
|
76
|
+
} catch (e) {
|
|
77
|
+
r.fail('config.json', 'commitRegex', e.message);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
66
81
|
// ---------- items.json ----------
|
|
67
82
|
|
|
83
|
+
// storyCommits 의 값은 해시 문자열이나 {hash}. 둘 다 16진 4~40자(item --commit, story --commit 과 같은 규칙).
|
|
84
|
+
function checkStoryCommits(commits, r) {
|
|
85
|
+
for (const [id, c] of Object.entries(obj(commits) || {})) {
|
|
86
|
+
const hash = typeof c === 'string' ? c : obj(c)?.hash;
|
|
87
|
+
if (typeof hash !== 'string') r.fail('items.json', `storyCommits.${id}`, '해시 문자열 또는 {hash} 가 아님');
|
|
88
|
+
else if (!/^[0-9a-f]{4,40}$/i.test(hash)) r.fail('items.json', `storyCommits.${id}`, `4~40자 16진 해시가 아님: ${hash}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
68
92
|
function checkItems(dir, r) {
|
|
69
93
|
const got = readJson(path.join(dir, 'items.json'));
|
|
70
94
|
if (got.missing) return;
|
|
@@ -84,11 +108,9 @@ function checkItems(dir, r) {
|
|
|
84
108
|
if (!ITEM_STATUS.includes(x.status)) r.fail(F, `${p}.status`, `todo, doing, done 가운데 하나가 아님: ${x.status}`);
|
|
85
109
|
if (typeof x.text !== 'string') r.fail(F, `${p}.text`, '문자열이 아님');
|
|
86
110
|
for (const f of ['key', 't', 's', 'note', 'issue', 'hash']) if (x[f] !== undefined && typeof x[f] !== 'string') r.fail(F, `${p}.${f}`, '문자열이 아님');
|
|
87
|
-
if (typeof x.hash === 'string' && !/^[0-9a-f]{
|
|
111
|
+
if (typeof x.hash === 'string' && !/^[0-9a-f]{4,40}$/i.test(x.hash)) r.fail(F, `${p}.hash`, `4~40자 16진 해시가 아님: ${x.hash}`);
|
|
88
112
|
});
|
|
89
|
-
|
|
90
|
-
if (typeof c !== 'string' && !(obj(c) && typeof c.hash === 'string')) r.fail(F, `storyCommits.${id}`, '해시 문자열 또는 {hash} 가 아님');
|
|
91
|
-
}
|
|
113
|
+
checkStoryCommits(v.storyCommits, r);
|
|
92
114
|
for (const [id, s] of Object.entries(obj(v.storyStates) || {})) {
|
|
93
115
|
if (!obj(s) || !ITEM_STATUS.includes(s.status)) r.fail(F, `storyStates.${id}`, '{status: todo|doing|done} 가 아님');
|
|
94
116
|
}
|
|
@@ -488,6 +510,7 @@ export function runCheck(ctx, env = process.env) {
|
|
|
488
510
|
checkItems(ctx.dataDir, r);
|
|
489
511
|
const items = readItems(ctx.dataDir);
|
|
490
512
|
const ids = storyList(config, items).map((s) => s.id);
|
|
513
|
+
checkCommitRegex(ctx, config, ids, r);
|
|
491
514
|
const req = checkReq(ctx.dataDir, r, ids);
|
|
492
515
|
const flow = checkFlow(ctx.dataDir, r);
|
|
493
516
|
const arch = checkArch(ctx.dataDir, r, config.text.plannedPrefix);
|
|
@@ -47,7 +47,8 @@ export const READ_MAX = 32 * 1024 * 1024;
|
|
|
47
47
|
export function readRegular(file, encoding = 'utf8', fsx = fs, max = READ_MAX) {
|
|
48
48
|
const st = fsx.statSync(file);
|
|
49
49
|
const code = !st.isFile() ? 'EINVAL' : st.size > max ? 'EFBIG' : null;
|
|
50
|
-
|
|
50
|
+
// statSync 는 링크를 따라가므로, EINVAL 은 대상이 폴더나 특수 파일일 때(그것을 가리키는 링크 포함)다. EFBIG 는 32 MB 초과.
|
|
51
|
+
if (code) throw Object.assign(new Error(`${path.basename(file)}: ${code === 'EINVAL' ? '일반 파일 아님(폴더나 특수 파일, 또는 그것을 가리키는 링크)' : '크기 상한 초과(32 MB)'}`), { code });
|
|
51
52
|
return fsx.readFileSync(file, encoding);
|
|
52
53
|
}
|
|
53
54
|
|
|
@@ -285,7 +286,8 @@ export function loadConfig(ctx) {
|
|
|
285
286
|
text = readRegular(file);
|
|
286
287
|
} catch (e) {
|
|
287
288
|
if (e.code === 'ENOENT') return { config: defaults(ctx.project, ctx.runtime), errors: [], found: false };
|
|
288
|
-
|
|
289
|
+
// e.message 는 readRegular 가 지은 구체 문구(일반 파일 아님 / 크기 상한 초과)이거나 그 밖 fs 오류다.
|
|
290
|
+
return { config: defaults(ctx.project, ctx.runtime), errors: [{ path: '$', msg: `읽기 실패: ${e.message || e.code || 'error'}` }], found: true };
|
|
289
291
|
}
|
|
290
292
|
let raw;
|
|
291
293
|
try {
|
|
@@ -199,7 +199,7 @@ const AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}
|
|
|
199
199
|
// item: 같은 key 는 대체하고 시작 시각 s 는 유지. 새 key 는 끝에 추가.
|
|
200
200
|
// s 는 doing 으로 기록할 때만 정한다(이미 있으면 유지). 처음부터 done 이나 todo 면 s 없음(소요시간 "-").
|
|
201
201
|
// --at 이 있으면 지금 대신 그 시각을 t 로 쓴다. 지난 커밋을 커밋 시각으로 기록할 때.
|
|
202
|
-
// --commit 은
|
|
202
|
+
// --commit 은 4~40자 16진 해시(story --commit 과 같은 규칙). hash 칸에 두고 진척도 표 commit Hash 칸에 앞 7자리로 보인다. --commit 이 없으면 있던 hash 를 유지.
|
|
203
203
|
export function itemChange(o, now) {
|
|
204
204
|
const key = need(o, 'key', 'item');
|
|
205
205
|
const status = need(o, 'status', 'item');
|
|
@@ -210,7 +210,7 @@ export function itemChange(o, now) {
|
|
|
210
210
|
at = typeof o.at === 'string' && AT_RE.test(o.at) ? Date.parse(o.at) : NaN;
|
|
211
211
|
if (!Number.isFinite(at)) throw new UsageError(`--at 은 시간대가 붙은 ISO 8601 시각(예 2026-10-29T12:34:56+09:00): ${o.at}`);
|
|
212
212
|
}
|
|
213
|
-
if (o.commit !== undefined && !(typeof o.commit === 'string' && /^[0-9a-f]{
|
|
213
|
+
if (o.commit !== undefined && !(typeof o.commit === 'string' && /^[0-9a-f]{4,40}$/i.test(o.commit))) throw new UsageError(`--commit 은 4~40자 16진 해시(받은 값 ${o.commit})`);
|
|
214
214
|
const t = isoSec(at);
|
|
215
215
|
return (v) => {
|
|
216
216
|
const i = v.items.findIndex((x) => obj(x) && x.key === key);
|
|
@@ -167,6 +167,10 @@ export function measureCodex(project, env = process.env) {
|
|
|
167
167
|
// ---------- 경고 줄 ----------
|
|
168
168
|
|
|
169
169
|
const MEM_ALT = '대안: --memory claude-md 로 다시 등록(CLAUDE.md 는 한도 없음)';
|
|
170
|
+
// init 의 메모리 대상이 거부될 때 안내하는 종류별 대안.
|
|
171
|
+
const CLAUDE_ALT = '대안: --memory agents-md, 또는 --memory auto-memory --memory-dir <dir>';
|
|
172
|
+
const AGENTS_ALT = '대안: --memory claude-md, 또는 --memory auto-memory --memory-dir <dir>';
|
|
173
|
+
const AUTO_ALT = '대안: 다른 --memory-dir, 또는 --memory claude-md 나 agents-md';
|
|
170
174
|
const CODEX_ALT = '대안: 프로젝트 지시 파일 정리(banker cleansing-memory)';
|
|
171
175
|
export const SHADOW_WARN = '경고: AGENTS.override.md 에 블록만 있어 AGENTS.md 가 가려짐';
|
|
172
176
|
|
|
@@ -201,46 +205,67 @@ const within = (a, b) => {
|
|
|
201
205
|
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
202
206
|
};
|
|
203
207
|
|
|
208
|
+
// 링크의 대상 stat. 대상이 없거나 실제 경로가 base 밖이면 UsageError.
|
|
209
|
+
function linkTarget(file, name, base, tail) {
|
|
210
|
+
let st;
|
|
211
|
+
let real;
|
|
212
|
+
try {
|
|
213
|
+
st = fs.statSync(file);
|
|
214
|
+
real = fs.realpathSync(file);
|
|
215
|
+
} catch {
|
|
216
|
+
throw new UsageError(`${name} 은 대상이 없는 링크라 거부${tail}`);
|
|
217
|
+
}
|
|
218
|
+
if (!within(fs.realpathSync(base), real)) throw new UsageError(`${name} 은 ${path.basename(base)} 밖을 가리키는 링크라 거부${tail}`);
|
|
219
|
+
return st;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 파일을 UTF-8 로 읽는다(BOM 은 뗌). UTF-8 이 아니면 UsageError.
|
|
223
|
+
function readUtf8(file, name, tail) {
|
|
224
|
+
const buf = fs.readFileSync(file);
|
|
225
|
+
try {
|
|
226
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
|
227
|
+
} catch {
|
|
228
|
+
throw new UsageError(`${name} 이 UTF-8 이 아니라 거부${tail}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
204
232
|
// 덧붙일 수 있는 파일인지. 일반 파일이나, 실제 경로가 base(프로젝트 루트나 --memory-dir) 안인 일반 파일을 가리키는 링크만.
|
|
205
233
|
// 저장소가 심은 링크로 밖의 파일에 덧붙이지 않게 한다. CLAUDE.md -> AGENTS.md 같은 안쪽 링크는 받는다. 없으면 새로 만든다.
|
|
234
|
+
// 하드링크(nlink > 1)는 거부한다. 다른 이름이 어디 있는지 알 수 없어, 밖의 파일과 같은 내용일 수 있다.
|
|
206
235
|
// 돌려줌: { exists, text, eol }. 거부는 UsageError.
|
|
207
|
-
function inspect(file, name, base) {
|
|
236
|
+
function inspect(file, name, base, alt = '') {
|
|
237
|
+
const tail = alt ? `. ${alt}` : '';
|
|
208
238
|
let lst;
|
|
209
239
|
try {
|
|
210
240
|
lst = fs.lstatSync(file);
|
|
211
241
|
} catch (e) {
|
|
212
242
|
if (e.code === 'ENOENT') return { exists: false, text: '', eol: '\n' };
|
|
213
|
-
throw new UsageError(`${name} 을 읽지 못함(${e.code || e.message})`);
|
|
214
|
-
}
|
|
215
|
-
let st = lst;
|
|
216
|
-
if (lst.isSymbolicLink()) {
|
|
217
|
-
let real;
|
|
218
|
-
try {
|
|
219
|
-
st = fs.statSync(file);
|
|
220
|
-
real = fs.realpathSync(file);
|
|
221
|
-
} catch {
|
|
222
|
-
throw new UsageError(`${name} 은 대상이 없는 링크라 거부`);
|
|
223
|
-
}
|
|
224
|
-
if (!within(fs.realpathSync(base), real)) throw new UsageError(`${name} 은 ${path.basename(base)} 밖을 가리키는 링크라 거부`);
|
|
225
|
-
}
|
|
226
|
-
if (!st.isFile()) throw new UsageError(`${name} 은 일반 파일이 아니라 거부`);
|
|
227
|
-
const buf = fs.readFileSync(file);
|
|
228
|
-
let text;
|
|
229
|
-
try {
|
|
230
|
-
text = new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
|
231
|
-
} catch {
|
|
232
|
-
throw new UsageError(`${name} 이 UTF-8 이 아니라 거부`);
|
|
243
|
+
throw new UsageError(`${name} 을 읽지 못함(${e.code || e.message})${tail}`);
|
|
233
244
|
}
|
|
245
|
+
const st = lst.isSymbolicLink() ? linkTarget(file, name, base, tail) : lst;
|
|
246
|
+
if (!st.isFile()) throw new UsageError(`${name} 은 일반 파일이 아니라 거부${tail}`);
|
|
247
|
+
if (st.nlink > 1) throw new UsageError(`${name} 은 하드링크(이름 ${st.nlink}개)라 거부${tail}`);
|
|
248
|
+
const text = readUtf8(file, name, tail);
|
|
234
249
|
// TextDecoder 는 BOM 을 떼므로 줄 끝 판정만 쓴다. 쓰기는 원본 바이트 뒤에 덧붙인다.
|
|
235
250
|
return { exists: true, text, eol: text.includes('\r\n') ? '\r\n' : '\n' };
|
|
236
251
|
}
|
|
237
252
|
|
|
238
253
|
// 원본 바이트를 그대로 두고 끝에 줄을 덧붙인다. 마지막 줄에 줄바꿈이 없으면 먼저 넣는다.
|
|
254
|
+
// 연 fd 를 fstat 으로 다시 본다: 검사(inspect)와 쓰기 사이에 하드링크나 특수 파일로 바뀌었으면 쓰지 않는다.
|
|
239
255
|
function append(file, info, lines) {
|
|
240
256
|
const glue = info.text && !info.text.endsWith('\n') ? info.eol : '';
|
|
241
257
|
const body = glue + lines.join(info.eol) + info.eol;
|
|
242
|
-
|
|
243
|
-
|
|
258
|
+
// O_NONBLOCK: 검사 뒤 FIFO 로 바뀐 자리에서 open 이 읽는 쪽을 기다리며 멈추지 않게 한다(Windows 에는 없어 0)
|
|
259
|
+
const { O_WRONLY, O_APPEND, O_CREAT, O_EXCL } = fs.constants;
|
|
260
|
+
const flags = O_WRONLY | (fs.constants.O_NONBLOCK || 0) | O_CREAT | (info.exists ? O_APPEND : O_EXCL);
|
|
261
|
+
const fd = fs.openSync(file, flags, 0o666);
|
|
262
|
+
try {
|
|
263
|
+
const st = fs.fstatSync(fd);
|
|
264
|
+
if (!st.isFile() || st.nlink > 1) throw new UsageError(`${path.basename(file)} 이 검사 뒤 하드링크나 특수 파일로 바뀌어 쓰지 않음`);
|
|
265
|
+
fs.writeFileSync(fd, body); // 다 쓸 때까지 반복
|
|
266
|
+
} finally {
|
|
267
|
+
fs.closeSync(fd);
|
|
268
|
+
}
|
|
244
269
|
}
|
|
245
270
|
|
|
246
271
|
// 등록 계획. 쓰기 전에 대상을 모두 검사한다(거부면 아무것도 바꾸지 않음).
|
|
@@ -260,8 +285,8 @@ export function planMemory({ kind, memoryDir, project, runtime }) {
|
|
|
260
285
|
runtime,
|
|
261
286
|
dir,
|
|
262
287
|
steps: [
|
|
263
|
-
{ file: rule, name: '자동 메모리 progress-mon.md', info: inspect(rule, 'progress-mon.md', dir), what: 'rule-file' },
|
|
264
|
-
{ file: index, name: '자동 메모리 MEMORY.md', info: inspect(index, 'MEMORY.md', dir), what: 'index' },
|
|
288
|
+
{ file: rule, name: '자동 메모리 progress-mon.md', info: inspect(rule, 'progress-mon.md', dir, AUTO_ALT), what: 'rule-file' },
|
|
289
|
+
{ file: index, name: '자동 메모리 MEMORY.md', info: inspect(index, 'MEMORY.md', dir, AUTO_ALT), what: 'index' },
|
|
265
290
|
],
|
|
266
291
|
memory: { kind, path: index },
|
|
267
292
|
};
|
|
@@ -270,11 +295,11 @@ export function planMemory({ kind, memoryDir, project, runtime }) {
|
|
|
270
295
|
const root = path.resolve(project);
|
|
271
296
|
if (kind === 'claude-md') {
|
|
272
297
|
const file = path.join(root, 'CLAUDE.md');
|
|
273
|
-
return { kind, runtime, steps: [{ file, name: 'CLAUDE.md', info: inspect(file, 'CLAUDE.md', root), what: 'line' }], memory: { kind, path: file } };
|
|
298
|
+
return { kind, runtime, steps: [{ file, name: 'CLAUDE.md', info: inspect(file, 'CLAUDE.md', root, CLAUDE_ALT), what: 'line' }], memory: { kind, path: file } };
|
|
274
299
|
}
|
|
275
300
|
const t = codexTarget(root);
|
|
276
301
|
const name = path.basename(t.file);
|
|
277
|
-
return { kind, runtime, steps: [{ file: t.file, name, info: inspect(t.file, name, root), what: 'block' }], memory: { kind, path: t.file } };
|
|
302
|
+
return { kind, runtime, steps: [{ file: t.file, name, info: inspect(t.file, name, root, AGENTS_ALT), what: 'block' }], memory: { kind, path: t.file } };
|
|
278
303
|
}
|
|
279
304
|
|
|
280
305
|
// 계획대로 쓴다. 돌려줌: 출력 줄 목록(규칙 줄, 경고 줄).
|
|
@@ -72,24 +72,32 @@ function dropState(dataDir) {
|
|
|
72
72
|
const NOFOLLOW = fs.constants.O_NOFOLLOW || 0; // Windows 에는 없음. 거기서는 lstat 만으로 링크를 거른다
|
|
73
73
|
const NONBLOCK = fs.constants.O_NONBLOCK || 0;
|
|
74
74
|
|
|
75
|
-
// server.log 가 링크나 일반 파일이 아닌 것(FIFO 등)
|
|
75
|
+
// server.log 가 링크나 일반 파일이 아닌 것(FIFO 등)이거나 하드링크(nlink > 1)면 참. 없으면 거짓.
|
|
76
|
+
// 하드링크를 거르는 까닭: rotateLog 가 옮기지 못할 때 ftruncate 로 비우는데, 하드링크면 그 대상(남의 파일)이 비워진다.
|
|
76
77
|
function oddLog(file) {
|
|
77
78
|
try {
|
|
78
|
-
|
|
79
|
+
const st = fs.lstatSync(file);
|
|
80
|
+
return !st.isFile() || st.nlink > 1;
|
|
79
81
|
} catch (e) {
|
|
80
82
|
return e.code !== 'ENOENT';
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
// 링크, 특수 파일, 하드링크라 server.log 를 거부한 오류. fs 가 낸 오류(EINVAL 등)와 섞이지 않게 표시를 따로 둔다.
|
|
87
|
+
const oddLogError = (msg) => Object.assign(new Error(msg), { oddLog: true });
|
|
88
|
+
// lstat 과 open 사이에 링크(ELOOP, BSD 의 EMLINK)나 소켓(ENXIO)으로 바뀐 자리에서 open 이 내는 코드. 같은 거부로 본다.
|
|
89
|
+
const ODD_OPEN = new Set(['ELOOP', 'EMLINK', 'ENXIO']);
|
|
90
|
+
|
|
84
91
|
// server.log 의 fd. 기본은 덧붙이기, append 가 거짓이면 처음부터 다시 쓰기(Windows 의 덧붙이기 fd 는 자를 수 없음).
|
|
85
92
|
// 링크와 일반 파일 아닌 것은 열지 않는다(POSIX 는 O_NOFOLLOW, 연 뒤 fstat 다시 확인).
|
|
86
93
|
function openLog(file, append = true) {
|
|
87
|
-
if (oddLog(file)) throw
|
|
94
|
+
if (oddLog(file)) throw oddLogError('server.log 가 일반 파일 아님');
|
|
88
95
|
const { O_WRONLY, O_RDWR, O_APPEND, O_CREAT } = fs.constants;
|
|
89
96
|
const fd = fs.openSync(file, (append ? O_WRONLY | O_APPEND : O_RDWR) | O_CREAT | NOFOLLOW | NONBLOCK, 0o600);
|
|
90
|
-
|
|
97
|
+
const st = fs.fstatSync(fd);
|
|
98
|
+
if (!st.isFile() || st.nlink > 1) {
|
|
91
99
|
fs.closeSync(fd);
|
|
92
|
-
throw
|
|
100
|
+
throw oddLogError('server.log 가 일반 파일 아님 또는 하드링크');
|
|
93
101
|
}
|
|
94
102
|
return fd;
|
|
95
103
|
}
|
|
@@ -114,6 +122,12 @@ function rotateLog(file, fd, warn) {
|
|
|
114
122
|
}
|
|
115
123
|
}
|
|
116
124
|
|
|
125
|
+
// server.log 를 못 쓴 까닭의 경고 문구. 링크, 특수 파일, 하드링크 거부와 fs 오류를 나눈다.
|
|
126
|
+
function logFailure(e) {
|
|
127
|
+
if (e && (e.oddLog || ODD_OPEN.has(e.code))) return 'server.log 가 링크, 특수 파일이거나 하드링크라 기록하지 않음. 그 파일을 치우면 다시 기록';
|
|
128
|
+
return `server.log 에 기록하지 못함(${(e && (e.code || e.message)) || e}). 서버는 계속`;
|
|
129
|
+
}
|
|
130
|
+
|
|
117
131
|
// server.log 한 줄 덧붙이기. 덧붙이면 max(1 MB)를 넘을 때 먼저 server.log.1 로 옮긴다(대상을 자르지 않음).
|
|
118
132
|
// 링크이거나 일반 파일이 아니면 쓰지 않는다. 옮기지 못한 일은 onWarn(서버의 status 경고)과 로그의 경고 줄로 한 번 알린다. 실패해도 서버는 계속.
|
|
119
133
|
export function makeLogger(dataDir, max = LOG_MAX, onWarn = () => {}) {
|
|
@@ -139,8 +153,9 @@ export function makeLogger(dataDir, max = LOG_MAX, onWarn = () => {}) {
|
|
|
139
153
|
if (pending) fs.writeSync(fd, pending);
|
|
140
154
|
pending = '';
|
|
141
155
|
fs.writeSync(fd, line);
|
|
142
|
-
} catch {
|
|
143
|
-
// 로그를 못 써도 응답은
|
|
156
|
+
} catch (e) {
|
|
157
|
+
// 로그를 못 써도 응답은 계속. 기록이 멈춘 것을 까닭과 함께 한 번 알린다(status 경고)
|
|
158
|
+
warn(logFailure(e));
|
|
144
159
|
} finally {
|
|
145
160
|
if (fd !== null) fs.closeSync(fd);
|
|
146
161
|
}
|
|
@@ -508,21 +523,26 @@ function staleLock(file, now) {
|
|
|
508
523
|
return pid !== null && !alive(pid);
|
|
509
524
|
}
|
|
510
525
|
|
|
526
|
+
// 낡은 잠금을 넘겨받는다. 돌려줌: 넘겨받았으면 null, 아니면 기다릴 까닭 { code, slot }(slot 은 그 코드를 낸 파일).
|
|
527
|
+
// 표지를 EEXIST 밖의 까닭(EPERM, EACCES, EBUSY)으로 못 만들면 slot 은 표지다. 그래야 표지 자리의 진짜 권한 오류가
|
|
528
|
+
// 잠금 자리처럼 1초대에 끝나고, 15초 뒤 "다른 start 가 끝나지 않음" 으로 잘못 안내하지 않는다.
|
|
511
529
|
function takeOverLock(file, now) {
|
|
512
530
|
const taking = `${file}.taking`;
|
|
513
|
-
|
|
531
|
+
const code = makeLockFile(taking);
|
|
532
|
+
if (code !== null) {
|
|
514
533
|
// 넘겨받던 start 가 죽어 남은 표지는 START_LOCK_STALE_MS 뒤 치운다(이번은 기다리고 다음에 넘겨받음)
|
|
515
534
|
try {
|
|
516
535
|
if (now - fs.lstatSync(taking).mtimeMs > START_LOCK_STALE_MS) fs.rmSync(taking, { force: true });
|
|
517
536
|
} catch {
|
|
518
|
-
// 그 사이
|
|
537
|
+
// 그 사이 지워짐, 또는 표지가 없음(EPERM 등)
|
|
519
538
|
}
|
|
520
|
-
return
|
|
539
|
+
return { code, slot: taking };
|
|
521
540
|
}
|
|
522
541
|
try {
|
|
523
|
-
if (!staleLock(file, now)) return
|
|
542
|
+
if (!staleLock(file, now)) return { code: 'EEXIST', slot: file };
|
|
524
543
|
fs.rmSync(file, { force: true });
|
|
525
|
-
|
|
544
|
+
const again = makeLockFile(file);
|
|
545
|
+
return again === null ? null : { code: again, slot: file };
|
|
526
546
|
} finally {
|
|
527
547
|
fs.rmSync(taking, { force: true });
|
|
528
548
|
}
|
|
@@ -530,18 +550,44 @@ function takeOverLock(file, now) {
|
|
|
530
550
|
|
|
531
551
|
// start 잠금을 잡을 때까지 waitMs 기다린다. 돌려줌: 잠금 경로. 시간 초과는 UsageError: 잠금 파일이 한 번이라도 보였으면
|
|
532
552
|
// 다른 start, 끝내 없었으면(EPERM 등이 계속, 권한 등) 만들지 못함.
|
|
533
|
-
|
|
553
|
+
// 잠금 파일이 끝내 안 보이는 동안 EPERM/EACCES/EBUSY 가 pendingMs(1초) 넘게 이어지면 바로 실패한다: Windows 의
|
|
554
|
+
// delete pending 은 ms 안에 풀려 그 사이 성공하지만, 읽기 전용 폴더 같은 진짜 권한 오류는 15초를 기다리지 않고 끝낸다.
|
|
555
|
+
// 잠금을 만들거나 낡은 것을 넘겨받으면 { lock }, 아니면 그 까닭 { code, slot }.
|
|
556
|
+
function tryStartLock(file, now) {
|
|
557
|
+
const code = makeLockFile(file);
|
|
558
|
+
if (code === null) return { lock: file };
|
|
559
|
+
if (!staleLock(file, now)) return { code, slot: file };
|
|
560
|
+
const why = takeOverLock(file, now);
|
|
561
|
+
return why === null ? { lock: file } : why;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// 전체 대기가 끝났을 때 던질 오류: 잠금 파일을 한 번도 못 봤으면 만들지 못함, 봤으면 다른 start 가 끝나지 않음.
|
|
565
|
+
function startWaitError(file, seen, code) {
|
|
566
|
+
if (!seen) return new UsageError(`잠금 ${START_LOCK} 을 만들지 못함(${code})`);
|
|
567
|
+
return new UsageError(`다른 start 가 끝나지 않음(잠금 ${START_LOCK}, pid ${lockPid(file) ?? '알 수 없음'}). 30초 뒤 다시 실행. 그 start 가 끝났는데도 남아 있으면 잠금 파일을 지운 뒤 다시 실행`);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// 잠금을 못 잡은 반복의 상태 갱신. 돌려줌: [seen, errSince]. 그 코드를 낸 파일(slot: 잠금 또는 넘겨받기 표지)이 없는
|
|
571
|
+
// EPERM/EACCES/EBUSY 가 pendingMs 넘게 이어지면(진짜 권한 오류) 바로 실패시킨다. errSince 는 그런 코드가 처음 난 때.
|
|
572
|
+
// seen 은 잠금 파일을 한 번이라도 본 것(표지를 못 만든 반복도 잠금 파일은 있음).
|
|
573
|
+
function startWaitStep(file, { code, slot }, seen, errSince, now, pendingMs) {
|
|
574
|
+
const here = code === 'EEXIST' || fs.existsSync(slot);
|
|
575
|
+
const es = here ? null : (errSince ?? now);
|
|
576
|
+
if (es !== null && now - es >= pendingMs) throw new UsageError(`잠금 ${path.basename(slot)} 을 만들지 못함(${code})`);
|
|
577
|
+
return [seen || here || (slot !== file && fs.existsSync(file)), es];
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export async function takeStartLock(dataDir, waitMs = START_LOCK_WAIT_MS, pendingMs = 1000) {
|
|
534
581
|
const file = path.join(dataDir, START_LOCK);
|
|
535
582
|
const until = Date.now() + waitMs;
|
|
536
583
|
let seen = false;
|
|
584
|
+
let errSince = null;
|
|
537
585
|
for (;;) {
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
throw new UsageError(`다른 start 가 끝나지 않음(잠금 ${START_LOCK}, pid ${lockPid(file) ?? '알 수 없음'}). 30초 뒤 다시 실행. 그 start 가 끝났는데도 남아 있으면 잠금 파일을 지운 뒤 다시 실행`);
|
|
544
|
-
}
|
|
586
|
+
const now = Date.now();
|
|
587
|
+
const r = tryStartLock(file, now);
|
|
588
|
+
if (r.lock) return r.lock;
|
|
589
|
+
[seen, errSince] = startWaitStep(file, r, seen, errSince, now, pendingMs);
|
|
590
|
+
if (now >= until) throw startWaitError(file, seen, r.code);
|
|
545
591
|
await sleep(100);
|
|
546
592
|
}
|
|
547
593
|
}
|
package/skills/ralph-qa/SKILL.md
CHANGED
|
@@ -117,7 +117,7 @@ node "<이 스킬 디렉터리 절대경로>/references/verifier-probe.mjs" --ru
|
|
|
117
117
|
|
|
118
118
|
폴더, 페이로드, 비밀 검사, 좌석 명령, 시간 상한, 정리는 실행기 `references/seat-run.mjs` 가 한다. **좌석 명령과 git 사슬을 손으로 짜지 않는다.** 실행기가 멈추면 출력 JSON 의 `stop` 을 아래 멈춤 표대로 처리한다.
|
|
119
119
|
`<R>` 은 `<이 스킬 디렉터리 절대경로>/references` 다(Codex 의 npm 설치본은 `~/.codex/skills/banker-ralph-qa/references`, 플러그인 설치본은 `~/.codex/plugins/cache/<마켓플레이스>/banker/<판>/skills/ralph-qa/references`. `CODEX_HOME` 이 있으면 `~/.codex` 대신 그 폴더). 호출 사이에 셸 변수가 남지 않으므로 호출마다 실제 경로를 적는다.
|
|
120
|
-
명령마다 stdout 에 JSON 하나를 내고, 같은 JSON 을 `<d>/result-<명령>.md` 에도 쓴다(멈춘 send 는 `result-send-stop.md`). 종료 코드는 0 끝남, 1 비밀처럼 보이는 줄, 2 인자나 실행기 오류, 3 멈춤, 4 512 KiB 초과, 5 send 가 아직 돎이다.
|
|
120
|
+
명령마다 stdout 에 JSON 하나를 내고, 같은 JSON 을 `<d>/result-<명령>.md` 에도 쓴다(멈춘 send 는 `result-send-stop.md`. 멈춘 send 도 `.sent.md` 가 있으면 좌석이 페이로드를 이미 받았을 수 있어 다시 보내지 않는다). 종료 코드는 0 끝남, 1 비밀처럼 보이는 줄, 2 인자나 실행기 오류, 3 멈춤, 4 512 KiB 초과, 5 send 가 아직 돎이다.
|
|
121
121
|
페이로드 폴더는 반복마다 새로 만든다. `--gates` 의 게이트 기록 폴더 `<g>` 는 이것과 따로, 실행 내내 하나다(게이트 증거 절).
|
|
122
122
|
|
|
123
123
|
1. `init` 으로 폴더 둘을 만든다. 출력의 `dir` 이 페이로드 폴더 `<d>`, `out` 이 답 폴더다.
|
|
@@ -200,7 +200,8 @@ node '<R>/seat-run.mjs' cleanup --dir '<d>'
|
|
|
200
200
|
| `scan` | 비밀처럼 보이는 줄이 있다(종료 1) | 4단계대로 가린다 | 끝내 못 가리면 `cli-call-failed` |
|
|
201
201
|
| `too-large` | 512 KiB 를 넘는다(종료 4) | 4단계대로 묻는다 | 못 물으면 `cli-call-failed`, 거절이면 `model-declined` |
|
|
202
202
|
| `split` | 나눌 단위가 부분 수보다 적거나, 부분의 표지나 검사가 맞지 않다. prompt.md 의 짧은 부분이 prepare 때와 다를 때도(짧은 부분 안을 고쳐 가림. 길이가 같아도 `run.md` 의 sha256 과 견줌) | 부분 수를 줄인다. 짧은 부분 안을 가렸으면 short.md 를 고쳐 prepare 를 다시 한다 | 없음 |
|
|
203
|
-
| `sent` | 이미 보낸 폴더다 | `init` 으로 새 폴더를 만든다 | 없음 |
|
|
203
|
+
| `sent` | 이미 보낸 폴더다(`result-send.md` 나 `.sent.md` 가 있음). 멈춘 send 뒤(`result-send-stop.md` 가 있음)라면 좌석이 이미 페이로드를 받았을 수 있다 | 다시 보내지 않는다. 좌석의 답이 있으면 답 폴더(`ralph-qa-out.*`)에 있다. 다시 보낼지는 사용자에게 묻는다. 새 반복이면 `init` 으로 새 폴더를 만든다 | 없음 |
|
|
204
|
+
| `sent-mark` | 보냈음 표지 `.sent.md` 를 쓰지 못했다(디스크 가득 참 등) | 폴더의 쓰기 상태를 본다. 좌석은 아무것도 보내지 않았다 | 없음 |
|
|
204
205
|
| `not-prepared` | `prepare` 가 끝난 폴더가 아니다 | `prepare` 를 한다 | 없음 |
|
|
205
206
|
| `not-a-run-folder` | `init` 이 만든 폴더가 아니다. 이름과 표지가 맞아도 알려진 기준 폴더(실제 경로로 견줌) 바로 아래가 아니거나 그 기준 폴더를 다른 계정도 쓸 수 있으면 이것이다 | `init` 이 찍은 경로를 쓴다 | 없음 |
|
|
206
207
|
| `sandboxed` | Codex sandbox 가 네트워크를 막는다 | 승인을 받아 sandbox 밖에서 다시 한다 | 승인할 수 없으면 `cli-call-failed` |
|
|
@@ -32,7 +32,7 @@ import { CLI_DEFAULT, probe as localProbe, safeModel } from "./verifier-probe.mj
|
|
|
32
32
|
|
|
33
33
|
// The stops a command may end with, as `stop` in its JSON; the skill's table of what to do holds the same set.
|
|
34
34
|
export const STOP_CODES = ["index-missing", "no-diff", "list", "binary-diff", "range-source", "range-link", "range-outside", "file", "markers",
|
|
35
|
-
"spec", "not-a-repo", "base-invalid", "git-failed", "git-timeout", "scan", "too-large", "sent", "sandboxed", "base-not-private",
|
|
35
|
+
"spec", "not-a-repo", "base-invalid", "git-failed", "git-timeout", "scan", "too-large", "sent", "sent-mark", "sandboxed", "base-not-private",
|
|
36
36
|
"base-not-writable", "inside-repo", "busy", "not-prepared", "not-a-run-folder", "split", "cleanup-left"];
|
|
37
37
|
const EXIT_OF = { scan: 1, "too-large": 4, busy: 5 };
|
|
38
38
|
|
|
@@ -44,6 +44,10 @@ const ANY_RUN = /^ralph-qa(-out)?\.[A-Za-z0-9]+$/;
|
|
|
44
44
|
// The lock of a send that runs: one name, made with "wx", holding the pid and the time.
|
|
45
45
|
const LOCK_NAME = ".running.md";
|
|
46
46
|
const TAKING_NAME = ".taking.md";
|
|
47
|
+
// The "sent" mark, written once under the lock before any seat work (send's markOrStop). It stands apart from
|
|
48
|
+
// result-send.md (written after every seat ends): a send that holds the lock in the A2 window finds this mark and
|
|
49
|
+
// does not send the payload again, even if that first send died before it wrote result-send.md.
|
|
50
|
+
const SENT_NAME = ".sent.md";
|
|
47
51
|
const STALE_TAKING_MS = 60 * 1000;
|
|
48
52
|
|
|
49
53
|
class Usage extends Error {}
|
|
@@ -144,11 +148,39 @@ function listUntracked(o, ctx) {
|
|
|
144
148
|
return [{ ok: true, ...untracked({ repo: o.repo, env: ctx.env, folder: o.folder || null }) }, 0, null];
|
|
145
149
|
}
|
|
146
150
|
|
|
147
|
-
|
|
151
|
+
// A folder is sent once either mark is there: result-send.md (a send ran to the end) or .sent.md (a send took the
|
|
152
|
+
// folder to send it, even if it then stopped or died before result-send.md).
|
|
153
|
+
const sent = (d) => existsSync(join(d, "result-send.md")) || existsSync(join(d, SENT_NAME));
|
|
148
154
|
const refuseSent = (d) => {
|
|
149
|
-
if (sent(d)) throw new Stop("sent", `이미 보낸 폴더다.
|
|
155
|
+
if (sent(d)) throw new Stop("sent", `이미 보낸 폴더다. 좌석의 답이 있으면 답 폴더(ralph-qa-out.*)에 있다. 다시 보내지 말고, 다시 보낼지는 사용자에게 묻는다: ${d}`);
|
|
150
156
|
};
|
|
151
157
|
|
|
158
|
+
// Writes .sent.md once (the memo answers again with the first result). EEXIST means another send (the A2 window)
|
|
159
|
+
// already marked it; any other write error is `err`, and a mark left empty by a failed write is taken away, so the
|
|
160
|
+
// next send is not refused for a folder nothing went out of.
|
|
161
|
+
export function makeMarkSent(d, now) {
|
|
162
|
+
let state = null;
|
|
163
|
+
return () => {
|
|
164
|
+
if (state) return state;
|
|
165
|
+
const file = join(d, SENT_NAME);
|
|
166
|
+
try {
|
|
167
|
+
writeFileSync(file, `${process.pid} ${new Date(now()).toISOString()}\n`, { flag: "wx", mode: 0o600 });
|
|
168
|
+
state = { ok: true };
|
|
169
|
+
} catch (e) {
|
|
170
|
+
if (e.code !== "EEXIST") rmSync(file, { force: true });
|
|
171
|
+
state = e.code === "EEXIST" ? { already: true } : { err: e };
|
|
172
|
+
}
|
|
173
|
+
return state;
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Marks the folder sent under the lock, before any seat work, or stops the send (the lock goes back in send's finally).
|
|
178
|
+
function markOrStop(d, now) {
|
|
179
|
+
const mark = makeMarkSent(d, now)();
|
|
180
|
+
if (mark.already) throw new RunStop("sent", `다른 send 가 이미 이 폴더를 보냈다: ${d}`);
|
|
181
|
+
if (mark.err) throw new RunStop("sent-mark", `보냈음 표지(.sent.md)를 쓰지 못했다(${mark.err.code || mark.err.message}). 좌석은 아무것도 보내지 않았다: ${d}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
152
184
|
// prepare and check end with 1 when a line looks like a secret and 4 past 512 KiB; the JSON carries both.
|
|
153
185
|
function verdict(result) {
|
|
154
186
|
if (result.scan.count) {
|
|
@@ -797,6 +829,9 @@ async function send(o, ctx) {
|
|
|
797
829
|
const signals = watchSignals();
|
|
798
830
|
const run = { ...a, d, o: out, id, short, shortSha, signalled: () => signals.code() !== 0 };
|
|
799
831
|
try {
|
|
832
|
+
// The folder is marked sent under the lock, before any seat writes into it: a send that lost the A2 window stops
|
|
833
|
+
// here, before it could overwrite the winner's prompt copies or clean the winner's seat folders.
|
|
834
|
+
if (!a.dryRun) markOrStop(d, ctx.now);
|
|
800
835
|
// every seat settles before the lock goes back and the stop handlers go away
|
|
801
836
|
const settled = await Promise.allSettled(a.seats.map((x) => sendSeat(ctx, run, x, payload)));
|
|
802
837
|
const thrown = settled.find((x) => x.status === "rejected");
|
|
@@ -833,7 +868,9 @@ const folderOf = (o, ctx) => {
|
|
|
833
868
|
}
|
|
834
869
|
};
|
|
835
870
|
|
|
836
|
-
// A send that stopped goes to result-send-stop.md
|
|
871
|
+
// A send that stopped goes to result-send-stop.md, not result-send.md (that one says every seat ended). A stopped
|
|
872
|
+
// send may still have carried the payload to some seats: .sent.md, written under the lock before any seat work, is what
|
|
873
|
+
// keeps the next send from sending it again.
|
|
837
874
|
const stopName = (command) => (command === "send" ? "send-stop" : command);
|
|
838
875
|
|
|
839
876
|
// A stop of run-folder.mjs carries no name: this runner's goes before it.
|
package/skills/remains/SKILL.md
CHANGED
|
@@ -55,6 +55,7 @@ argument-hint: "[기준 커밋] [--no-boxes]"
|
|
|
55
55
|
- 보내는 것은 추적하는 파일의 커밋된 내용 그대로다(줄 끝 변환 없음). `--ref worktree` 는 커밋하지 않은 추적 파일의 변경까지 보낸다. 추적하지 않는 새 파일은 보내지 않는다(출력의 `untracked`). 그 파일이 시험에 필요하면 미검증으로 적는다.
|
|
56
56
|
- Windows 박스에서는 명령이 `.cmd` 파일 안에서 돈다. 명령이 OS 마다 달라야 하면 `--box <이름>` 으로 나눠 돌린다.
|
|
57
57
|
- 그 `.cmd` 파일은 UTF-8 코드 페이지로 돌아 한글 인자도 그대로 간다. `for` 의 변수는 배치 파일 규칙대로 `%%i` 로 쓰고, `for` 나 `if` 안에서 npm 같은 배치 명령을 부르면 `call npm ...` 으로 쓴다.
|
|
58
|
+
- `for` 반복으로 시험을 여러 번 돌릴 때 종료 코드는 마지막 반복 것이라 앞 반복의 실패가 가려진다(POSIX `run.sh` 도 같다). 각 반복에 `|| exit /b 1`(POSIX `|| exit 1`)을 붙여 한 번이라도 실패하면 비정상 종료로 끝낸다.
|
|
58
59
|
- 오래 걸리면 `--timeout <분>`(기본 20)을 늘린다.
|
|
59
60
|
4. 출력 JSON 의 박스별 `summary`(tests, pass, fail, skipped, failures)와 `log` 파일을 근거로 쓴다. 종료 코드는 다음과 같다.
|
|
60
61
|
- 0: 모든 박스가 통과
|
package/skills/ultraqa/SKILL.md
CHANGED
|
@@ -46,6 +46,7 @@ argument-hint: "--tests | --build | --lint | --typecheck | --custom \"<명령이
|
|
|
46
46
|
|
|
47
47
|
- 구조화된 목표가 없으면 인자를 행동 목표로 읽고, 저장소에서 실행 가능한 e2e 방법을 찾는다.
|
|
48
48
|
- 목표 명령은 저장소에서 찾는다(`package.json` 의 `scripts`, CI 시험 단계, `Makefile`, pytest 설정). 찾은 근거를 보고에 적는다.
|
|
49
|
+
- 찾은 그대로 돌린다. 게이트 명령에 플래그를 더하거나 바꾸지 않는다(예: `--test-timeout`, `--test-force-exit` 는 멈춤을 통과로 가려 게이트를 거짓으로 만든다). 바꿔야 할 까닭이 있으면 멈추고 사용자에게 묻는다. 저장소의 시험 명령 자체를 고치는 것은 QA 가 아니라 구현 작업이다.
|
|
49
50
|
|
|
50
51
|
## 시나리오 행렬
|
|
51
52
|
|