@kaydash9999/banker-plugins 0.22.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.
Files changed (106) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +68 -0
  4. package/PRIVACY.md +1 -0
  5. package/README.md +39 -13
  6. package/bin/banker.js +118 -22
  7. package/bin/lib/codex-hooks.mjs +116 -18
  8. package/bin/lib/codex-names.mjs +4 -0
  9. package/bin/lib/codex-routes.mjs +93 -1
  10. package/bin/lib/command-skill.cjs +37 -6
  11. package/codex/command-skills/front-qa/SKILL.md +1 -1
  12. package/codex/command-skills/setup/SKILL.md +4 -4
  13. package/codex/hooks.json +5 -5
  14. package/codex/manifest.json +18 -15
  15. package/codex/skills/progress/scripts/progress.mjs +44 -14
  16. package/codex/skills/teamwork/SKILL.md +143 -0
  17. package/codex/skills/teamwork/scripts/teamwork.mjs +1007 -0
  18. package/codex/transform-matrix.md +5 -3
  19. package/hooks/obsidize-hook.mjs +75 -32
  20. package/hooks/telemetry-count-codex.mjs +238 -57
  21. package/hooks/update-fetch.mjs +2 -1
  22. package/hooks/update-notify.mjs +82 -20
  23. package/package.json +3 -1
  24. package/skills/3d-intro-build/SKILL.md +101 -20
  25. package/skills/3d-intro-build/references/NOTICE.md +9 -2
  26. package/skills/3d-intro-build/references/assemble.mjs +113 -22
  27. package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
  28. package/skills/3d-intro-build/references/clip-post.mjs +443 -0
  29. package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
  30. package/skills/3d-intro-build/references/intro-fixes.css +11 -0
  31. package/skills/3d-intro-build/references/prompts.md +51 -4
  32. package/skills/3d-intro-build/references/scrub-engine.js +1 -0
  33. package/skills/3d-intro-build/references/step-nav.css +29 -0
  34. package/skills/3d-intro-build/references/step-nav.js +284 -106
  35. package/skills/3d-intro-build/references/verify-intro.mjs +373 -32
  36. package/skills/3d-intro-build/references/verify.md +6 -0
  37. package/skills/3d-intro-build/references/video-pool.mjs +142 -12
  38. package/skills/3d-intro-setup/SKILL.md +20 -7
  39. package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
  40. package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
  41. package/skills/all-in-one/SKILL.md +125 -86
  42. package/skills/omc-patch/SKILL.md +1 -1
  43. package/skills/payload-mon/SKILL.md +1 -1
  44. package/skills/play-qa/SKILL.md +6 -4
  45. package/skills/progress-mon/README.md +164 -0
  46. package/skills/progress-mon/SKILL.md +165 -0
  47. package/skills/progress-mon/assets/fixture/README.md +1 -0
  48. package/skills/progress-mon/assets/fixture/arch.json +33 -0
  49. package/skills/progress-mon/assets/fixture/commits.json +7 -0
  50. package/skills/progress-mon/assets/fixture/config.json +30 -0
  51. package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
  52. package/skills/progress-mon/assets/fixture/flow.json +44 -0
  53. package/skills/progress-mon/assets/fixture/items.json +14 -0
  54. package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
  55. package/skills/progress-mon/assets/fixture/reqs.json +11 -0
  56. package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
  57. package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
  58. package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
  59. package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
  60. package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
  61. package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
  62. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
  63. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
  64. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
  65. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
  66. package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
  67. package/skills/progress-mon/references/arch.md +280 -0
  68. package/skills/progress-mon/references/authoring.md +199 -0
  69. package/skills/progress-mon/references/config.md +115 -0
  70. package/skills/progress-mon/references/flow.md +75 -0
  71. package/skills/progress-mon/references/inventory.md +58 -0
  72. package/skills/progress-mon/references/items.md +74 -0
  73. package/skills/progress-mon/references/req-spec.md +74 -0
  74. package/skills/progress-mon/references/reqs.md +30 -0
  75. package/skills/progress-mon/scripts/index.js +31 -0
  76. package/skills/progress-mon/scripts/pm-check.mjs +549 -0
  77. package/skills/progress-mon/scripts/pm-config.mjs +343 -0
  78. package/skills/progress-mon/scripts/pm-data.mjs +394 -0
  79. package/skills/progress-mon/scripts/pm-html.mjs +77 -0
  80. package/skills/progress-mon/scripts/pm-memory.mjs +333 -0
  81. package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
  82. package/skills/progress-mon/scripts/pm-server.mjs +781 -0
  83. package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
  84. package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
  85. package/skills/ralph-qa/SKILL.md +107 -17
  86. package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
  87. package/skills/ralph-qa/references/payload-scan.mjs +19 -2
  88. package/skills/ralph-qa/references/run-folder.mjs +299 -0
  89. package/skills/ralph-qa/references/seat-payload.mjs +60 -13
  90. package/skills/ralph-qa/references/seat-run.mjs +200 -159
  91. package/skills/remains/SKILL.md +2 -0
  92. package/skills/remains/scripts/boxes.mjs +6 -2
  93. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
  94. package/skills/setup-mcp/SKILL.md +1 -1
  95. package/skills/setup-omc-hud/SKILL.md +2 -1
  96. package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
  97. package/skills/ultra-chain/SKILL.md +32 -14
  98. package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
  99. package/skills/ultra-init/SKILL.md +30 -22
  100. package/skills/ultraqa/NOTICE.md +34 -0
  101. package/skills/ultraqa/SKILL.md +197 -0
  102. package/skills/ultraqa/references/gate-evidence.mjs +457 -0
  103. package/skills/ultraqa/references/run-folder.mjs +299 -0
  104. package/skills/ultraqa/references/tmux-drive.md +52 -0
  105. package/skills/ultraqa/references/uqa-run.mjs +339 -0
  106. package/skills/update-banker/SKILL.md +1 -1
@@ -0,0 +1,394 @@
1
+ // pm-data.mjs: init, item, note, stage, story(계획 3절, 4.1절, 4.7절, 6.2절).
2
+ // items.json 은 잠금(items.json.lock) 안에서 다시 읽고 바꾼 칸만 고친 뒤 임시 파일에 써서 rename 한다.
3
+ // 새로 쓰는 시각은 Z 붙은 UTC ISO 8601(YYYY-MM-DDTHH:MM:SSZ). 시각은 명령 진입에서 한 번 정해 넘긴다.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import crypto from 'node:crypto';
7
+ import { dataLabel, defaults, loadConfig, eventsPath, writeAtomic, git, noBom, readRegular, UsageError } from './pm-config.mjs';
8
+ import { stageList } from './pm-sources.mjs';
9
+ import { planMemory, applyMemory } from './pm-memory.mjs';
10
+
11
+ export const ITEM_STATUS = ['todo', 'doing', 'done'];
12
+ export const STAGE_STATUS = ['started', 'done', 'skipped', 'failed', 'stopped'];
13
+ const LOCK_WAIT_MS = 5000;
14
+ const STEP_MS = 50;
15
+ // 정리 잠금(items.json.lock.break)이 이보다 오래 남으면 정리 중 죽은 것으로 보고 지운다. 정리는 수 ms.
16
+ const BREAK_STALE_MS = 3000;
17
+ // wx 가 이 코드면 잠금이 있거나 지우는 중(Windows)이라 기다린다.
18
+ const BUSY_CODES = new Set(['EEXIST', 'EPERM', 'EACCES', 'EBUSY']);
19
+
20
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
+ const rand = () => crypto.randomBytes(6).toString('hex');
22
+ // 초 단위 UTC 시각.
23
+ export const isoSec = (ms) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
24
+ const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);
25
+
26
+ // ---------- 잠금 ----------
27
+
28
+ // 기록된 pid 가 죽었는지. 살아 있거나 알 수 없으면 거짓.
29
+ function pidDead(pid) {
30
+ try {
31
+ process.kill(pid, 0);
32
+ return false;
33
+ } catch (e) {
34
+ return e.code === 'ESRCH';
35
+ }
36
+ }
37
+
38
+ // 낡은 잠금 후보: 기록된 pid 가 죽었고 createdAt 이 LOCK_WAIT_MS 보다 오래됨. 정상 보유는 수 ms 라 이 둘을 함께 본다.
39
+ // pid 만 보면 다른 pid 공간(WSL 등)의 살아 있는 잠금을 죽었다고 볼 수 있다.
40
+ const staleLock = (info, now) =>
41
+ Boolean(info) && Number.isInteger(info.pid) && info.pid > 0 && now - Date.parse(info.createdAt) > LOCK_WAIT_MS && pidDead(info.pid);
42
+ const sameLock = (a, b) => Boolean(a && b) && a.pid === b.pid && a.token === b.token && a.createdAt === b.createdAt;
43
+
44
+ // 잠금 파일 내용. 없으면 null, 읽거나 풀지 못하면 빈 객체(낡았는지 모름, 기다림).
45
+ function readLock(file, fsx) {
46
+ try {
47
+ return JSON.parse(readRegular(file, 'utf8', fsx));
48
+ } catch (e) {
49
+ return e.code === 'ENOENT' ? null : {};
50
+ }
51
+ }
52
+
53
+ // 낡은 잠금 정리. 정리 잠금(items.json.lock.break)을 wx 로 쥔 프로세스 하나만 한다.
54
+ // 그 안에서 잠금을 다시 읽어 처음 본 잠금과 같고(pid, token, createdAt) 여전히 낡았을 때만 지운다.
55
+ // 처음 읽은 뒤 주인이 풀고 다른 프로세스가 새로 잡은 잠금은 내용이 달라 지우지 않는다.
56
+ // 정리 잠금이 이미 있으면 정리를 맡기고 돌아간다. BREAK_STALE_MS 보다 오래 남은 정리 잠금(정리 중 죽음)만 지운다.
57
+ // 돌려줌: 낡은 잠금을 지웠으면 참.
58
+ function breakStale(file, seen, fsx) {
59
+ const brk = `${file}.break`;
60
+ try {
61
+ fsx.writeFileSync(brk, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }) + '\n', { flag: 'wx' });
62
+ } catch (e) {
63
+ if (!BUSY_CODES.has(e.code)) return false;
64
+ try {
65
+ // 낡은 정리 잠금: 낡음을 판정한 그 내용(원문)을 기억해 두고, 지우기 직전 다시 읽어 같을 때만 지운다.
66
+ // 두 대기자가 같은 옛 정리 잠금을 낡았다고 봐도 뒤쪽이 그 사이 새로 생긴 정리 잠금을 지우지 않게 한다.
67
+ // 나이는 내용의 createdAt, 읽을 수 없으면 파일 mtime 으로 잰다.
68
+ const seenRaw = readRegular(brk, 'utf8', fsx);
69
+ let born = NaN;
70
+ try {
71
+ born = Date.parse(JSON.parse(seenRaw).createdAt);
72
+ } catch {
73
+ // 깨진 정리 잠금
74
+ }
75
+ if (!Number.isFinite(born)) born = fsx.statSync(brk).mtimeMs;
76
+ if (Date.now() - born > BREAK_STALE_MS && readRegular(brk, 'utf8', fsx) === seenRaw) fsx.rmSync(brk, { force: true });
77
+ } catch {
78
+ // 그 사이 지워짐
79
+ }
80
+ return false;
81
+ }
82
+ let removed = false;
83
+ try {
84
+ const cur = readLock(file, fsx);
85
+ if (sameLock(cur, seen) && staleLock(cur, Date.now())) {
86
+ fsx.rmSync(file, { force: true });
87
+ removed = true;
88
+ }
89
+ } catch {
90
+ // 지우지 못함. 기다리며 다시 본다
91
+ }
92
+ try {
93
+ fsx.rmSync(brk, { force: true });
94
+ } catch {
95
+ // 남으면 BREAK_STALE_MS 뒤 다른 프로세스가 지운다
96
+ }
97
+ return removed;
98
+ }
99
+
100
+ // items.json.lock 을 wx 로 만든다. 있으면 50 ms 간격으로 최대 waitMs 기다린다.
101
+ // Windows 에서 지우는 중인 파일에 wx 하면 EPERM(또는 EACCES, EBUSY)이라 EEXIST 처럼 기다린다.
102
+ // 낡은 잠금은 breakStale 로만 정리한다.
103
+ // 돌려줌: { file, mine }. 시간 초과는 UsageError(잠금 경로, pid, createdAt, 수동 삭제 안내).
104
+ export async function acquireLock(dataDir, { fsx = fs, waitMs = LOCK_WAIT_MS, label = '' } = {}) {
105
+ const file = path.join(dataDir, 'items.json.lock');
106
+ const mine = { pid: process.pid, createdAt: isoSec(Date.now()), token: rand() };
107
+ const body = JSON.stringify(mine) + '\n';
108
+ const until = Date.now() + waitMs;
109
+ let last = {};
110
+ let seenLock = false; // 기다리는 동안 잠금 파일이 한 번이라도 보였는지
111
+ 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
+ }
120
+ const info = readLock(file, fsx);
121
+ // 그 사이 풀림: 바로 다시. 지우는 중(EPERM 등)이면 아래에서 기다린다
122
+ if (info === null && code === 'EEXIST' && Date.now() < until) continue;
123
+ if (info) seenLock = true;
124
+ if (info && info.pid !== undefined) last = info;
125
+ 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
+ }
133
+ await sleep(STEP_MS);
134
+ }
135
+ }
136
+
137
+ // 잠금 해제: 내용의 pid 와 무작위 값이 자기 것일 때만 지운다.
138
+ export function releaseLock(h, fsx = fs) {
139
+ const info = readLock(h.file, fsx);
140
+ if (info && info.pid === h.mine.pid && info.token === h.mine.token) fsx.rmSync(h.file, { force: true });
141
+ }
142
+
143
+ // ---------- items.json ----------
144
+
145
+ const EMPTY_ITEMS = () => ({ notes: {}, items: [], storyCommits: {}, storyStates: {}, stageEvents: [] });
146
+
147
+ // 잠금 안에서 쓰는 원본 읽기. 모르는 키는 그대로 둔다. 깨진 파일은 고치지 않고 거부.
148
+ function readItemsRaw(file, fsx) {
149
+ let text;
150
+ try {
151
+ text = readRegular(file, 'utf8', fsx);
152
+ } catch (e) {
153
+ if (e.code === 'ENOENT') return EMPTY_ITEMS();
154
+ throw new UsageError(`items.json 을 읽지 못함(${e.code || e.message})`);
155
+ }
156
+ let v;
157
+ try {
158
+ v = JSON.parse(noBom(text));
159
+ } catch {
160
+ throw new UsageError('items.json 이 JSON 이 아님. check 로 확인하고 고친 뒤 다시 실행');
161
+ }
162
+ if (!obj(v)) throw new UsageError('items.json 이 객체가 아님. 고친 뒤 다시 실행');
163
+ for (const [k, mk] of Object.entries(EMPTY_ITEMS())) {
164
+ if (v[k] === undefined) v[k] = mk;
165
+ else if (Array.isArray(mk) !== Array.isArray(v[k]) || typeof v[k] !== 'object' || v[k] === null) throw new UsageError(`items.json 의 ${k} 형식이 틀림. 고친 뒤 다시 실행`);
166
+ }
167
+ return v;
168
+ }
169
+
170
+ // 잠금 안에서 items.json 을 다시 읽어 change(v) 로 고치고 원자적으로 쓴다. 돌려줌: change 의 값.
171
+ export async function updateItems(dataDir, change, { fsx = fs, waitMs, label = '' } = {}) {
172
+ const h = await acquireLock(dataDir, { fsx, waitMs, label });
173
+ try {
174
+ const file = path.join(dataDir, 'items.json');
175
+ const v = readItemsRaw(file, fsx);
176
+ const r = change(v);
177
+ await writeAtomic(file, JSON.stringify(v, null, 2) + '\n', fsx);
178
+ return r;
179
+ } finally {
180
+ releaseLock(h, fsx);
181
+ }
182
+ }
183
+
184
+ // ---------- 갱신 명령의 바꾸기 함수 ----------
185
+
186
+ const need = (o, key, cmd) => {
187
+ if (typeof o[key] !== 'string' || !o[key]) throw new UsageError(`${cmd} 에는 --${key} 가 필요함`);
188
+ // 객체 칸 이름으로 쓰면 JSON 에 남지 않는 이름(쓰기 성공 출력 뒤 기록이 사라지던 것)
189
+ if (o[key] === '__proto__') throw new UsageError(`--${key} 에 __proto__ 는 쓰지 않음`);
190
+ return o[key];
191
+ };
192
+ const oneOf = (v, list) => {
193
+ if (!list.includes(v)) throw new UsageError(`--status 는 ${list.join(', ')} 가운데 하나(받은 값 ${v})`);
194
+ };
195
+
196
+ // --at 형식: 날짜, 시각(초 선택), 시간대(Z 나 +09:00) 모두 있는 ISO 8601. git log --format=%cI 가 이 꼴.
197
+ const AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;
198
+
199
+ // item: 같은 key 는 대체하고 시작 시각 s 는 유지. 새 key 는 끝에 추가.
200
+ // s 는 doing 으로 기록할 때만 정한다(이미 있으면 유지). 처음부터 done 이나 todo 면 s 없음(소요시간 "-").
201
+ // --at 이 있으면 지금 대신 그 시각을 t 로 쓴다. 지난 커밋을 커밋 시각으로 기록할 때.
202
+ // --commit 은 4~40자 16진 해시(story --commit 과 같은 규칙). hash 칸에 두고 진척도 표 commit Hash 칸에 앞 7자리로 보인다. --commit 이 없으면 있던 hash 를 유지.
203
+ export function itemChange(o, now) {
204
+ const key = need(o, 'key', 'item');
205
+ const status = need(o, 'status', 'item');
206
+ oneOf(status, ITEM_STATUS);
207
+ const text = need(o, 'text', 'item');
208
+ let at = now;
209
+ if (o.at !== undefined) {
210
+ at = typeof o.at === 'string' && AT_RE.test(o.at) ? Date.parse(o.at) : NaN;
211
+ if (!Number.isFinite(at)) throw new UsageError(`--at 은 시간대가 붙은 ISO 8601 시각(예 2026-10-29T12:34:56+09:00): ${o.at}`);
212
+ }
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
+ const t = isoSec(at);
215
+ return (v) => {
216
+ const i = v.items.findIndex((x) => obj(x) && x.key === key);
217
+ const prev = i >= 0 ? v.items[i] : null;
218
+ const s = (prev && prev.s) || (status === 'doing' ? t : undefined);
219
+ const next = s ? { key, t, s, status, text } : { key, t, status, text };
220
+ if (o.note !== undefined) next.note = o.note;
221
+ if (o.issue !== undefined) next.issue = o.issue;
222
+ if (o.commit !== undefined) next.hash = o.commit;
223
+ else if (prev && typeof prev.hash === 'string') next.hash = prev.hash;
224
+ if (prev) v.items[i] = next;
225
+ else v.items.push(next);
226
+ // 시작 시각이 있던 항목만 "시작 시각 유지"(처음부터 done, todo 로 쓴 항목은 시작 시각이 없음).
227
+ return prev ? `item ${key}: 대체(${prev.status} 에서 ${status})${prev.s ? ', 시작 시각 유지' : ''}. 항목 ${v.items.length}개` : `item ${key}: 추가(${status}). 항목 ${v.items.length}개`;
228
+ };
229
+ }
230
+
231
+ // note: notes 의 key 칸 단위 대체. 빈 값은 그 칸을 지운다.
232
+ function noteChange(o) {
233
+ const key = need(o, 'key', 'note');
234
+ const fields = ['note', 'issue'].filter((k) => o[k] !== undefined);
235
+ if (!fields.length) throw new UsageError('note 에는 --note 또는 --issue 가 필요함');
236
+ return (v) => {
237
+ const had = Object.prototype.hasOwnProperty.call(v.notes, key);
238
+ const cur = obj(v.notes[key]) ? { ...v.notes[key] } : {};
239
+ for (const k of fields) {
240
+ if (o[k] === '') delete cur[k];
241
+ else cur[k] = o[k];
242
+ }
243
+ v.notes[key] = cur;
244
+ const n = Object.keys(v.notes).length;
245
+ return had ? `note ${key}: 대체(${fields.join(', ')}). notes ${n}개` : `note ${key}: 추가. notes ${n}개`;
246
+ };
247
+ }
248
+
249
+ // stage: stageEvents 에 {t, stage, status[, label]} 추가. 단계 목록에 없는 id 면 새 단계 행.
250
+ function stageChange(o, now, known) {
251
+ const id = need(o, 'id', 'stage');
252
+ const status = need(o, 'status', 'stage');
253
+ oneOf(status, STAGE_STATUS);
254
+ const t = isoSec(now);
255
+ return (v) => {
256
+ const ev = { t, stage: id, status };
257
+ if (o.label) ev.label = o.label;
258
+ const fresh = !known(v).includes(id);
259
+ v.stageEvents.push(ev);
260
+ return `stage ${id}: ${status} 기록함${fresh ? `. 새 단계 행(${o.label || id})` : ''}`;
261
+ };
262
+ }
263
+
264
+ // story: --status 는 storyStates, --commit 은 storyCommits. 설정에 없는 id 면 새 스토리 행(이름은 --name 또는 id).
265
+ function storyChange(o, now, configIds) {
266
+ const id = need(o, 'id', 'story');
267
+ if ((o.status === undefined) === (o.commit === undefined)) throw new UsageError('story 에는 --status 또는 --commit 가운데 하나가 필요함');
268
+ if (o.status !== undefined) oneOf(o.status, ITEM_STATUS);
269
+ if (o.commit !== undefined && !/^[0-9a-f]{4,40}$/i.test(o.commit)) throw new UsageError(`--commit 은 4~40자 16진 해시(받은 값 ${o.commit})`);
270
+ const t = isoSec(now);
271
+ return (v) => {
272
+ const known = new Set([...configIds, ...Object.keys(v.storyCommits), ...Object.keys(v.storyStates)]);
273
+ const fresh = !known.has(id);
274
+ const box = o.commit !== undefined ? v.storyCommits : v.storyStates;
275
+ const prev = obj(box[id]) || {};
276
+ const next = o.commit !== undefined ? { hash: o.commit, t } : { status: o.status, t };
277
+ const name = o.name !== undefined ? o.name : prev.name;
278
+ if (name) next.name = name;
279
+ box[id] = next;
280
+ const what = o.commit !== undefined ? `커밋 ${o.commit}` : o.status;
281
+ return `story ${id}: ${what} 기록함${fresh ? `. 새 스토리 행(${name || id})` : ''}`;
282
+ };
283
+ }
284
+
285
+ // ---------- init ----------
286
+
287
+ const NEXT = ['다음 할 일', ' 1. references/authoring.md 를 읽고 req-spec.json, reqs.json, flow.json, arch.json 작성', ' 2. check 로 검사. FAIL 0 까지 최대 3회', ' 3. start 로 서버 시작'];
288
+
289
+ 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
+ const inside = (rel) => rel && !rel.startsWith('..') && !path.isAbsolute(rel);
298
+
299
+ // 자료 폴더를 git 제외 목록(git rev-parse --git-path info/exclude)에 한 줄 추가. 추적 파일은 바꾸지 않는다. 실패는 알림만.
300
+ function gitExclude(ctx) {
301
+ let top;
302
+ let excl;
303
+ try {
304
+ top = path.resolve(git(ctx.project, ['rev-parse', '--show-toplevel']));
305
+ excl = path.resolve(ctx.project, git(ctx.project, ['rev-parse', '--git-path', 'info/exclude']));
306
+ } catch {
307
+ return 'git 제외 목록: git 저장소 아님, 건너뜀';
308
+ }
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
+ if (!inside(rel)) return 'git 제외 목록: 자료 폴더가 저장소 밖이라 건너뜀';
312
+ const line = `/${slash(rel)}/`;
313
+ const shown = inside(path.relative(ctx.project, excl)) ? slash(path.relative(ctx.project, excl)) : 'info/exclude';
314
+ let text = '';
315
+ try {
316
+ text = fs.readFileSync(excl, 'utf8');
317
+ } catch {
318
+ text = '';
319
+ }
320
+ if (text.split(/\r?\n/).some((l) => l.trim() === line)) return 'git 제외 목록: 이미 있음';
321
+ try {
322
+ git(ctx.project, ['check-ignore', '-q', ctx.dataDir]);
323
+ return 'git 제외 목록: 이미 git 무시 대상이라 추가 안 함';
324
+ } catch {
325
+ // 무시 대상 아님. 아래에서 추가
326
+ }
327
+ try {
328
+ fs.mkdirSync(path.dirname(excl), { recursive: true });
329
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
330
+ fs.appendFileSync(excl, (text && !text.endsWith('\n') ? eol : '') + line + eol);
331
+ } catch (e) {
332
+ return `알림: git 제외 목록에 추가하지 못함(${e.code || e.message})`;
333
+ }
334
+ return `git 제외 목록 추가: ${line} (${shown})`;
335
+ }
336
+
337
+ // 없을 때만 만든다. 돌려줌: 출력 줄.
338
+ function makeOnce(file, label, text) {
339
+ if (fs.existsSync(file)) return `그대로 둠: ${label}`;
340
+ fs.writeFileSync(file, text, { flag: 'wx' });
341
+ return `새로 만듦: ${label}`;
342
+ }
343
+
344
+ async function init(ctx, o, io, skillDir, now) {
345
+ // 쓰기 전에 메모리 대상을 모두 검사한다(거부면 아무것도 만들지 않음).
346
+ const plan = planMemory({ kind: o.memory, memoryDir: o['memory-dir'], project: ctx.project, runtime: ctx.runtime });
347
+ if (o.title !== undefined && !o.title) throw new UsageError('--title 은 빈 문자열이 아니어야 함');
348
+ const label = dataLabel(ctx);
349
+ fs.mkdirSync(ctx.dataDir, { recursive: true });
350
+ io.out(`자료 폴더: ${label}`);
351
+ const cfg = defaults(ctx.project, ctx.runtime);
352
+ delete cfg.commitRegex;
353
+ delete cfg.agentLabelPattern;
354
+ if (o.title) cfg.title = o.title;
355
+ io.out(makeOnce(path.join(ctx.dataDir, 'config.json'), `${label}config.json`, JSON.stringify(cfg, null, 2) + '\n'));
356
+ io.out(makeOnce(path.join(ctx.dataDir, 'items.json'), `${label}items.json`, JSON.stringify(EMPTY_ITEMS(), null, 2) + '\n'));
357
+ const stFile = path.join(ctx.dataDir, 'state.json');
358
+ const hadState = fs.existsSync(stFile);
359
+ let st = {};
360
+ try {
361
+ st = obj(JSON.parse(readRegular(stFile))) || {};
362
+ } catch {
363
+ st = {};
364
+ }
365
+ await writeAtomic(stFile, JSON.stringify({ ...st, skillDir, runtime: ctx.runtime, memory: plan.memory, updatedAt: isoSec(now) }, null, 2) + '\n');
366
+ if (!hadState) io.out(`새로 만듦: ${label}state.json`);
367
+ io.out(gitExclude(ctx));
368
+ for (const l of applyMemory(plan, ctx.project)) io.out(l);
369
+ for (const l of NEXT) io.out(l);
370
+ return 0;
371
+ }
372
+
373
+ // ---------- CLI ----------
374
+
375
+ export async function command(cmd, ctx, o, io, { skillDir } = {}) {
376
+ const now = Date.now();
377
+ if (o._.length > 1) throw new UsageError(`${cmd} 는 위치 인자를 받지 않음: ${o._.slice(1).join(' ')}`);
378
+ if (cmd === 'init') return init(ctx, o, io, skillDir, now);
379
+ const label = dataLabel(ctx);
380
+ let change;
381
+ if (cmd === 'item') change = itemChange(o, now);
382
+ else if (cmd === 'note') change = noteChange(o);
383
+ else if (cmd === 'stage') {
384
+ const { config } = loadConfig(ctx);
385
+ const ev = eventsPath(config, ctx);
386
+ const hasEvents = Boolean(ev) && fs.existsSync(ev);
387
+ change = stageChange(o, now, (v) => stageList(config, hasEvents, v.stageEvents.filter(obj)).map((s) => s.id));
388
+ } else if (cmd === 'story') {
389
+ const { config } = loadConfig(ctx);
390
+ change = storyChange(o, now, config.stories.map((s) => s.id));
391
+ } else throw new UsageError(`${cmd} 는 pm-data.mjs 가 맡지 않음`);
392
+ io.out(await updateItems(ctx.dataDir, change, { label }));
393
+ return 0;
394
+ }
@@ -0,0 +1,77 @@
1
+ // pm-html.mjs: 화면 조각 공통. escape, 색과 수 검사, 글 폭 추정, 줄 나눔, 공통 CSS, 탭 머리, favicon.
2
+ // CSS, favicon, 탭 머리는 옛 진척도 서버와 같은 바이트(같은 모양 원칙).
3
+
4
+ // 본문과 속성 모두 & < > " ' 를 바꾼다.
5
+ export const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
6
+
7
+ // 속성 값: esc 에 더해 줄바꿈과 탭도 문자 참조로.
8
+ export const attr = (s) => esc(s).replace(/\n/g, '&#10;').replace(/\r/g, '&#13;').replace(/\t/g, '&#9;');
9
+
10
+ // 색은 #rrggbb 만. 그 밖은 기본 색.
11
+ export const color = (v, fallback) => (typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v) ? v : fallback);
12
+
13
+ // 유한 수만 참. 문자열 수는 받지 않음.
14
+ export const finite = (v) => typeof v === 'number' && Number.isFinite(v);
15
+
16
+ // 10px 글꼴 기준 글 폭 추정(한글 10.5px, 그 밖 6px).
17
+ export const textW = (t) => [...String(t || '')].reduce((a, ch) => a + (ch.charCodeAt(0) > 0x2e7f ? 10.5 : 6), 0);
18
+
19
+ // 11px 글꼴 기준 폭(px)으로 줄을 나눈다(띄어쓰기에서 끊음).
20
+ export function wrap(t, px) {
21
+ const out = [];
22
+ let cur = '';
23
+ for (const w of String(t || '').split(' ')) {
24
+ if (cur && textW(cur + ' ' + w) * 1.1 > px) {
25
+ out.push(cur);
26
+ cur = w;
27
+ } else cur = cur ? cur + ' ' + w : w;
28
+ }
29
+ if (cur) out.push(cur);
30
+ return out;
31
+ }
32
+
33
+ // 진척 막대 모양 아이콘. 외부 파일 없이 data URI 로 넣는다.
34
+ export const FAVICON = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#1b5e20"/><rect x="5" y="20" width="5" height="7" fill="#a5d6a7"/><rect x="13.5" y="13" width="5" height="14" fill="#c8e6c9"/><rect x="22" y="6" width="5" height="21" fill="#fff"/></svg>');
35
+
36
+ // 공통 CSS. 쓰는 쪽은 `<style>\n${CSS}\n</style>` 꼴로 넣는다.
37
+ export const CSS = `body{font:14px/1.5 "Malgun Gothic",system-ui,sans-serif;margin:16px;color:#222;background:#f6f7f9}
38
+ h1{font-size:20px;margin:0 0 4px}.meta{color:#666;font-size:12px;margin-bottom:12px}
39
+ .bar{height:8px;background:#ddd;border-radius:4px;max-width:520px;margin:6px 0 16px}.bar i{display:block;height:100%;background:#2e7d32;border-radius:4px}
40
+ table{border-collapse:collapse;width:100%;background:#fff;box-shadow:0 1px 3px #0001}
41
+ th,td{border-bottom:1px solid #eee;border-right:1px solid #e3e3e3;padding:6px 10px;text-align:left;vertical-align:top}
42
+ th:last-child,td:last-child{border-right:0}
43
+ thead th{background:#fafafa;font-size:13px;border-bottom:2px solid #ddd}
44
+ td.t{white-space:nowrap;color:#555;font-family:Consolas,monospace;font-size:12px;width:90px}td.n{color:#666;font-size:12px}
45
+ td.q{text-align:right;color:#888;font-size:12px;width:36px}td.d{text-align:right;font-family:Consolas,monospace;font-size:12px;white-space:nowrap}
46
+ td.h{font-family:Consolas,monospace;font-size:12px;color:#555;white-space:nowrap}td.p{color:#b3261e;font-size:12px}
47
+ tr.sec td{font-weight:bold;padding-top:14px;border-bottom:2px solid #ccc}
48
+ tr.sec.done td{color:#2e7d32}tr.sec.doing td{color:#b26a00}tr.sec.todo td{color:#555}
49
+ tr.doing td{background:#fff8e6}tr.todo td{color:#777}
50
+ .b{display:inline-block;font-size:11px;padding:1px 6px;border-radius:8px;white-space:nowrap}
51
+ .b.done{background:#e3f3e4;color:#2e7d32}.b.doing{background:#ffe9b8;color:#8a5200}.b.todo{background:#eee;color:#666}
52
+ small{color:#888;margin-right:4px}
53
+ .tabs{border-bottom:1px solid #ccc;margin:0 0 12px}.tabs a{display:inline-block;padding:6px 16px;margin-right:4px;border:1px solid #ccc;border-bottom:0;border-radius:6px 6px 0 0;background:#e9ebef;color:#333;text-decoration:none;position:relative;top:1px}
54
+ .tabs a.on{background:#fff;font-weight:bold}
55
+ h2{font-size:16px;margin:18px 0 6px}.b.part{background:#e1ecfb;color:#1a4f9c}.b.miss{background:#fde2e0;color:#b3261e}.b.unv{background:#f3e5f5;color:#6a1b9a}.b.excl{background:#eceff1;color:#546e7a}
56
+ .s{display:inline-block;font-family:Consolas,monospace;font-size:12px;padding:0 4px;margin:1px;border-radius:4px;background:#eee;color:#777}.s.ok{background:#e3f3e4;color:#2e7d32}td.st{width:144px;min-width:144px;max-width:144px;white-space:normal;font-size:0}td.st .s{width:34px;box-sizing:border-box;text-align:center}
57
+ .fwrap{overflow-x:auto;background:#fff;box-shadow:0 1px 3px #0001;border-radius:6px;padding:8px;margin-bottom:6px}.dot{display:inline-block;width:10px;height:10px;border-radius:5px;margin-right:6px}
58
+ svg.flow text{font-family:"Malgun Gothic",system-ui,sans-serif}svg.flow .no{font-size:11px;fill:#fff;text-anchor:middle;font-weight:bold}svg.flow .lb{font-size:14px;font-weight:bold;fill:#222}
59
+ svg.flow .dt{font-size:11px;fill:#555}svg.flow .who{font-size:10px;fill:#999;font-family:Consolas,monospace}svg.flow .ac{font-size:10px;fill:#b26a00;text-anchor:end}
60
+ svg.flow .et{font-size:10px;fill:#555;text-anchor:middle}svg.flow .bt{font-size:10px;fill:#b3261e}svg.flow .pl rect{fill:#fbfbfb}
61
+ svg.flow .fl{stroke-width:2;stroke-dasharray:6 5;animation:fd 1s linear infinite}@keyframes fd{to{stroke-dashoffset:-11}}
62
+ svg.arch text{font-family:"Malgun Gothic",system-ui,sans-serif}svg.arch .zl{font-size:13px;font-weight:bold}svg.arch .nl{font-size:13px;font-weight:bold;fill:#222}
63
+ svg.arch .ns{font-size:10.5px;fill:#555}svg.arch .ck{font-size:10px;fill:#333}svg.arch .el{font-size:10px;fill:#333}svg.arch .tag{font-size:9.5px;fill:#b26a00;text-anchor:end}
64
+ svg.arch .api{stroke-width:2.6;stroke-dasharray:8 6;animation:fd2 1.2s linear infinite}@keyframes fd2{to{stroke-dashoffset:-14}}
65
+ svg.arch .control{stroke-width:1.6}svg.arch .data{stroke-width:1.4;stroke-dasharray:4 4}
66
+ @media (prefers-reduced-motion: reduce){svg.flow .n,svg.flow .fl,svg.arch .api,svg.arch .nd{animation:none}}`;
67
+
68
+ // 탭 순서와 이름.
69
+ export const TABS = [
70
+ ['history', '진척도'],
71
+ ['req', '구현요건'],
72
+ ['workflow', 'workflow'],
73
+ ['arch', '구성도'],
74
+ ];
75
+
76
+ // 서버 화면의 탭 머리. cur 는 켜진 탭 id.
77
+ export const tabsHtml = (cur) => `<div class="tabs">${TABS.map(([k, name]) => `<a href="/?tab=${k}"${cur === k ? ' class="on"' : ''}>${name}</a>`).join('')}</div>`;