@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +68 -0
- package/PRIVACY.md +1 -0
- package/README.md +39 -13
- package/bin/banker.js +118 -22
- package/bin/lib/codex-hooks.mjs +116 -18
- package/bin/lib/codex-names.mjs +4 -0
- package/bin/lib/codex-routes.mjs +93 -1
- package/bin/lib/command-skill.cjs +37 -6
- package/codex/command-skills/front-qa/SKILL.md +1 -1
- package/codex/command-skills/setup/SKILL.md +4 -4
- package/codex/hooks.json +5 -5
- package/codex/manifest.json +18 -15
- package/codex/skills/progress/scripts/progress.mjs +44 -14
- package/codex/skills/teamwork/SKILL.md +143 -0
- package/codex/skills/teamwork/scripts/teamwork.mjs +1007 -0
- package/codex/transform-matrix.md +5 -3
- package/hooks/obsidize-hook.mjs +75 -32
- package/hooks/telemetry-count-codex.mjs +238 -57
- package/hooks/update-fetch.mjs +2 -1
- package/hooks/update-notify.mjs +82 -20
- package/package.json +3 -1
- package/skills/3d-intro-build/SKILL.md +101 -20
- package/skills/3d-intro-build/references/NOTICE.md +9 -2
- package/skills/3d-intro-build/references/assemble.mjs +113 -22
- package/skills/3d-intro-build/references/azure-adapter.mjs +110 -24
- package/skills/3d-intro-build/references/clip-post.mjs +443 -0
- package/skills/3d-intro-build/references/dive-mode.mjs +299 -0
- package/skills/3d-intro-build/references/intro-fixes.css +11 -0
- package/skills/3d-intro-build/references/prompts.md +51 -4
- package/skills/3d-intro-build/references/scrub-engine.js +1 -0
- package/skills/3d-intro-build/references/step-nav.css +29 -0
- package/skills/3d-intro-build/references/step-nav.js +284 -106
- package/skills/3d-intro-build/references/verify-intro.mjs +373 -32
- package/skills/3d-intro-build/references/verify.md +6 -0
- package/skills/3d-intro-build/references/video-pool.mjs +142 -12
- package/skills/3d-intro-setup/SKILL.md +20 -7
- package/skills/3d-intro-setup/references/azure-adapter.mjs +110 -24
- package/skills/3d-intro-setup/references/video-pool.mjs +142 -12
- package/skills/all-in-one/SKILL.md +125 -86
- package/skills/omc-patch/SKILL.md +1 -1
- package/skills/payload-mon/SKILL.md +1 -1
- package/skills/play-qa/SKILL.md +6 -4
- package/skills/progress-mon/README.md +164 -0
- package/skills/progress-mon/SKILL.md +165 -0
- package/skills/progress-mon/assets/fixture/README.md +1 -0
- package/skills/progress-mon/assets/fixture/arch.json +33 -0
- package/skills/progress-mon/assets/fixture/commits.json +7 -0
- package/skills/progress-mon/assets/fixture/config.json +30 -0
- package/skills/progress-mon/assets/fixture/events.jsonl +7 -0
- package/skills/progress-mon/assets/fixture/flow.json +44 -0
- package/skills/progress-mon/assets/fixture/items.json +14 -0
- package/skills/progress-mon/assets/fixture/req-spec.json +31 -0
- package/skills/progress-mon/assets/fixture/reqs.json +11 -0
- package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +58 -0
- package/skills/progress-mon/assets/fixture/variants/check-auto/arch.json +15 -0
- package/skills/progress-mon/assets/fixture/variants/check-overlap/arch.json +14 -0
- package/skills/progress-mon/assets/fixture/variants/history-manual/config.json +13 -0
- package/skills/progress-mon/assets/fixture/variants/history-manual/items.json +7 -0
- package/skills/progress-mon/assets/fixture/variants/workflow-wide/flow.json +35 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a01.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a02.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a03.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/agent-a04.jsonl +1 -0
- package/skills/progress-mon/assets/fixture/workflows/wf_ex1/journal.jsonl +7 -0
- package/skills/progress-mon/references/arch.md +280 -0
- package/skills/progress-mon/references/authoring.md +199 -0
- package/skills/progress-mon/references/config.md +115 -0
- package/skills/progress-mon/references/flow.md +75 -0
- package/skills/progress-mon/references/inventory.md +58 -0
- package/skills/progress-mon/references/items.md +74 -0
- package/skills/progress-mon/references/req-spec.md +74 -0
- package/skills/progress-mon/references/reqs.md +30 -0
- package/skills/progress-mon/scripts/index.js +31 -0
- package/skills/progress-mon/scripts/pm-check.mjs +549 -0
- package/skills/progress-mon/scripts/pm-config.mjs +343 -0
- package/skills/progress-mon/scripts/pm-data.mjs +394 -0
- package/skills/progress-mon/scripts/pm-html.mjs +77 -0
- package/skills/progress-mon/scripts/pm-memory.mjs +333 -0
- package/skills/progress-mon/scripts/pm-render.mjs +1644 -0
- package/skills/progress-mon/scripts/pm-server.mjs +781 -0
- package/skills/progress-mon/scripts/pm-sources.mjs +501 -0
- package/skills/progress-mon/scripts/progress-mon.mjs +167 -0
- package/skills/ralph-qa/SKILL.md +107 -17
- package/skills/ralph-qa/references/gate-evidence.mjs +457 -0
- package/skills/ralph-qa/references/payload-scan.mjs +19 -2
- package/skills/ralph-qa/references/run-folder.mjs +299 -0
- package/skills/ralph-qa/references/seat-payload.mjs +60 -13
- package/skills/ralph-qa/references/seat-run.mjs +200 -159
- package/skills/remains/SKILL.md +2 -0
- package/skills/remains/scripts/boxes.mjs +6 -2
- package/skills/setup-bypass-permissions/scripts/codex-config.mjs +19 -8
- package/skills/setup-mcp/SKILL.md +1 -1
- package/skills/setup-omc-hud/SKILL.md +2 -1
- package/skills/setup-omc-hud/assets/codex-config.mjs +19 -8
- package/skills/ultra-chain/SKILL.md +32 -14
- package/skills/ultra-chain/references/chain-ledger.mjs +111 -3
- package/skills/ultra-init/SKILL.md +30 -22
- package/skills/ultraqa/NOTICE.md +34 -0
- package/skills/ultraqa/SKILL.md +197 -0
- package/skills/ultraqa/references/gate-evidence.mjs +457 -0
- package/skills/ultraqa/references/run-folder.mjs +299 -0
- package/skills/ultraqa/references/tmux-drive.md +52 -0
- package/skills/ultraqa/references/uqa-run.mjs +339 -0
- package/skills/update-banker/SKILL.md +1 -1
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
// pm-sources.mjs: 진척도 자료 출처 읽기. events.jsonl, git log, 워크플로 journal, items.json, 탭 자료 JSON.
|
|
2
|
+
// 없는 출처는 빈 값. 커밋과 에이전트 라벨 판정 함수 4개, 시각 해석, 읽기 캐시(계획 4.3, 4.6, 4.7절).
|
|
3
|
+
// 판정과 렌더는 now 인자만 쓴다. 시각을 직접 재지 않는다(캐시 유효 시간은 단조 시계).
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import vm from 'node:vm';
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { eventsPath, stagesFor, isDir, noBom, readRegular } from './pm-config.mjs';
|
|
10
|
+
|
|
11
|
+
// ---------- 시각 ----------
|
|
12
|
+
|
|
13
|
+
// Z 없는 19자 옛 형식(YYYY-MM-DDTHH:MM:SS)은 현지 시각으로 읽는다(옛 서버의 Date.parse 와 같음). 옛 자료에는 UTC 값을
|
|
14
|
+
// Z 없이 쓴 줄도 섞여 있지만 고치지 않는다. 그래야 옛 화면(포트 30000)과 같은 시각이 나온다.
|
|
15
|
+
// Z 나 시차(+09:00)가 있으면 그 값 그대로. 읽지 못하면 0.
|
|
16
|
+
export function parseTime(v) {
|
|
17
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
|
|
18
|
+
if (typeof v !== 'string' || !v) return 0;
|
|
19
|
+
const ms = Date.parse(v);
|
|
20
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 캐시 유효 시간용 단조 시계(ms). 벽시계가 아니라 판정에 쓰지 않는다.
|
|
24
|
+
const mono = () => Number(process.hrtime.bigint() / 1000000n);
|
|
25
|
+
|
|
26
|
+
// ---------- 파일 읽기 캐시 ----------
|
|
27
|
+
// (경로, mtime, 크기)가 같으면 파싱 결과를 다시 쓴다.
|
|
28
|
+
|
|
29
|
+
const fileCache = new Map();
|
|
30
|
+
|
|
31
|
+
function cached(file, parse) {
|
|
32
|
+
let st;
|
|
33
|
+
try {
|
|
34
|
+
st = fs.statSync(file);
|
|
35
|
+
} catch {
|
|
36
|
+
fileCache.delete(file);
|
|
37
|
+
return { missing: true };
|
|
38
|
+
}
|
|
39
|
+
const hit = fileCache.get(file);
|
|
40
|
+
// ctime 도 본다: mtime 이 거친 파일 시스템에서 같은 크기로 바뀐 자료를 놓치지 않게
|
|
41
|
+
if (hit && hit.parse === parse && hit.mtimeMs === st.mtimeMs && hit.ctimeMs === st.ctimeMs && hit.size === st.size) return hit.out;
|
|
42
|
+
let out;
|
|
43
|
+
try {
|
|
44
|
+
const text = readRegular(file);
|
|
45
|
+
out = { value: parse(noBom(text)) };
|
|
46
|
+
} catch (e) {
|
|
47
|
+
out = { error: e instanceof SyntaxError ? 'JSON 아님' : `읽기 실패(${e.code || 'error'})` };
|
|
48
|
+
}
|
|
49
|
+
fileCache.set(file, { parse, mtimeMs: st.mtimeMs, ctimeMs: st.ctimeMs, size: st.size, out });
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const parseJson = (t) => JSON.parse(t);
|
|
54
|
+
// JSONL: 깨진 줄과 객체가 아닌 줄은 건너뛴다.
|
|
55
|
+
const parseLines = (t) =>
|
|
56
|
+
t
|
|
57
|
+
.split('\n')
|
|
58
|
+
.map((l) => {
|
|
59
|
+
try {
|
|
60
|
+
const o = JSON.parse(l);
|
|
61
|
+
return o && typeof o === 'object' && !Array.isArray(o) ? o : null;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
.filter(Boolean);
|
|
67
|
+
|
|
68
|
+
// JSON 파일. 돌려줌: { value } | { missing: true } | { error }
|
|
69
|
+
export const readJsonFile = (file) => cached(file, parseJson);
|
|
70
|
+
// JSONL 파일의 객체 줄 목록. 없거나 못 읽으면 빈 목록.
|
|
71
|
+
export const readLines = (file) => cached(file, parseLines).value || [];
|
|
72
|
+
|
|
73
|
+
// ---------- 사용자 정규식 ----------
|
|
74
|
+
|
|
75
|
+
const REGEX_MS = 200;
|
|
76
|
+
const REGEX_INPUT_MAX = 1000;
|
|
77
|
+
const regexCtx = vm.createContext({});
|
|
78
|
+
const regexRun = new vm.Script(
|
|
79
|
+
'(() => { const re = new RegExp(src); return inputs.map((s) => { const m = re.exec(s); return m ? [m[0], m.groups ? m.groups.id : undefined] : null; }); })()',
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// 사용자 정규식(commitRegex, agentLabelPattern)을 입력 묶음에 한 번 돌린다. 돌려줌: 입력마다 [일치, 그룹 id] 또는 null.
|
|
83
|
+
// 역추적이 폭발하는 정규식이 이벤트 루프를 멈추지 않게 vm 의 시간 제한(REGEX_MS) 안에서만 돌리고, 입력은 앞 REGEX_INPUT_MAX 자만 본다.
|
|
84
|
+
// 시간을 넘으면 거짓 판정 대신 예외(서버는 500 과 server.log, 명령은 오류 줄).
|
|
85
|
+
export function regexBatch(src, key, inputs) {
|
|
86
|
+
regexCtx.src = src;
|
|
87
|
+
regexCtx.inputs = inputs.map((x) => String(x).slice(0, REGEX_INPUT_MAX));
|
|
88
|
+
try {
|
|
89
|
+
return regexRun.runInContext(regexCtx, { timeout: REGEX_MS });
|
|
90
|
+
} catch (e) {
|
|
91
|
+
if (e && e.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') throw new Error(`${key} 가 ${REGEX_MS} ms 안에 끝나지 않음(역추적이 폭발하는 정규식). config.json 의 ${key} 를 고침`);
|
|
92
|
+
throw e;
|
|
93
|
+
} finally {
|
|
94
|
+
regexCtx.inputs = null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------- 판정 함수(계획 4.3절) ----------
|
|
99
|
+
|
|
100
|
+
// 커밋 제목마다 스토리(subjectStories 는 묶음, storyOfSubject 는 하나). commitRegex 가 있으면 그룹 id 값이 설정 id 일 때.
|
|
101
|
+
// 없으면 설정 id 를 긴 것부터 보아 제목이 id 로 시작하고 뒤가 `:`, `(`, 공백, 또는 소문자 한 자 뒤 `:`, `(`, 공백이면 그 id.
|
|
102
|
+
export function subjectStories(subjects, ids, commitRegex) {
|
|
103
|
+
if (!ids.length) return subjects.map(() => null);
|
|
104
|
+
if (commitRegex) {
|
|
105
|
+
const hits = regexBatch(commitRegex, 'commitRegex', subjects.map((s) => (typeof s === 'string' ? s : '')));
|
|
106
|
+
return hits.map((h, i) => (typeof subjects[i] === 'string' && h && h[1] && ids.includes(h[1]) ? h[1] : null));
|
|
107
|
+
}
|
|
108
|
+
const byLength = [...ids].sort((a, b) => b.length - a.length);
|
|
109
|
+
return subjects.map((s) => (typeof s === 'string' ? byLength.find((id) => s.startsWith(id) && /^([:( ]|[a-z][:( ])/.test(s.slice(id.length))) || null : null));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const storyOfSubject = (subject, ids, commitRegex) => subjectStories([subject], ids, commitRegex)[0];
|
|
113
|
+
|
|
114
|
+
// 스토리 시작 시각용: 라벨을 - 로 나눈 토큰을 왼쪽부터 보아 설정 id 와 정확히 같은 첫 토큰.
|
|
115
|
+
export function storyOfStartLabel(label, ids) {
|
|
116
|
+
if (!ids.length) return null;
|
|
117
|
+
for (const tok of String(label || '').split('-')) if (ids.includes(tok)) return tok;
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 진행 중 스토리용. agentLabelPattern 이 있으면 라벨의 첫 일치 값이 설정 id 일 때.
|
|
122
|
+
// 없으면 토큰을 왼쪽부터 보아, 토큰이 id 로 시작하고 바로 뒤가 끝이거나 숫자가 아닌 첫 토큰. 같은 토큰에 둘 이상이면 긴 id.
|
|
123
|
+
export function storyOfBusyLabel(label, ids, pattern) {
|
|
124
|
+
if (!ids.length) return null;
|
|
125
|
+
const l = String(label || '');
|
|
126
|
+
if (pattern) {
|
|
127
|
+
const [m] = regexBatch(pattern, 'agentLabelPattern', [l]);
|
|
128
|
+
return m && ids.includes(m[0]) ? m[0] : null;
|
|
129
|
+
}
|
|
130
|
+
for (const tok of l.split('-')) {
|
|
131
|
+
let best = null;
|
|
132
|
+
for (const id of ids) {
|
|
133
|
+
if (!tok.startsWith(id) || /^\d/.test(tok.slice(id.length))) continue;
|
|
134
|
+
if (!best || id.length > best.length) best = id;
|
|
135
|
+
}
|
|
136
|
+
if (best) return best;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 에이전트 라벨 묶음마다 { id: 진행 중 스토리 또는 null, hidden: 행 숨김 }. 판정은 storyOfBusyLabel, hiddenAgent 와 같다.
|
|
142
|
+
// 패턴이 있으면 regexBatch 를 한 번만 불러 시간 상한(REGEX_MS)이 라벨마다가 아니라 요청마다 하나다(에이전트가 수백이어도).
|
|
143
|
+
export function agentJudge(labels, ids, pattern) {
|
|
144
|
+
if (!pattern) return labels.map((l) => {
|
|
145
|
+
const id = storyOfBusyLabel(l, ids, null);
|
|
146
|
+
return { id, hidden: id !== null };
|
|
147
|
+
});
|
|
148
|
+
const hits = regexBatch(pattern, 'agentLabelPattern', labels.map((l) => String(l || '')));
|
|
149
|
+
return hits.map((m) => ({ id: m && ids.includes(m[0]) ? m[0] : null, hidden: m !== null }));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 에이전트 행 숨김. 패턴이 있으면 라벨에 일치가 있을 때(스토리가 아니어도). 없으면 진행 중 스토리가 있을 때.
|
|
153
|
+
export function hiddenAgent(label, ids, pattern) {
|
|
154
|
+
if (pattern) return regexBatch(pattern, 'agentLabelPattern', [String(label || '')])[0] !== null;
|
|
155
|
+
return storyOfBusyLabel(label, ids, null) !== null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------- 단계 ----------
|
|
159
|
+
|
|
160
|
+
// 단계 기록(events.jsonl 과 items.json 의 stageEvents)을 시각 순으로 한 상태 전이로 합친다.
|
|
161
|
+
// 단계마다 마지막 기록의 { s: doing|done|stopped, t: 완료 시각 또는 시작 시각, start: 첫 시작 시각 }.
|
|
162
|
+
export function stageStates(records) {
|
|
163
|
+
const st = {};
|
|
164
|
+
const list = records.map((e, i) => ({ e, i, t: parseTime(e.t) }));
|
|
165
|
+
list.sort((a, b) => a.t - b.t || a.i - b.i);
|
|
166
|
+
for (const { e, t } of list) {
|
|
167
|
+
if (typeof e.stage !== 'string') continue;
|
|
168
|
+
const cur = Object.prototype.hasOwnProperty.call(st, e.stage) ? st[e.stage] : null;
|
|
169
|
+
const start = (cur && cur.start) || (e.status === 'started' ? t : 0);
|
|
170
|
+
// 마지막 기록이 상태를 정한다: 완료 뒤에 다시 시작한 단계(ultra-chain 재실행)는 진행 중
|
|
171
|
+
if (e.status === 'started') st[e.stage] = { s: 'doing', t, start };
|
|
172
|
+
else if (e.status === 'done' || e.status === 'skipped') st[e.stage] = { s: 'done', t, start };
|
|
173
|
+
else if (e.status === 'failed' || e.status === 'stopped') st[e.stage] = { s: 'stopped', t, start };
|
|
174
|
+
}
|
|
175
|
+
return st;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------- git ----------
|
|
179
|
+
|
|
180
|
+
const gitCache = new Map();
|
|
181
|
+
const git = (cwd, args) => execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
|
|
182
|
+
|
|
183
|
+
const readText = (p) => {
|
|
184
|
+
try {
|
|
185
|
+
return readRegular(p);
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
const HASH = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
191
|
+
|
|
192
|
+
// project 에서 위로 올라가며 찾은 .git. 폴더면 그 폴더, 파일(worktree, 하위 모듈)이면 gitdir 값.
|
|
193
|
+
// common 은 브랜치와 packed-refs 가 있는 폴더(worktree 는 commondir 값). 없으면 null.
|
|
194
|
+
function gitDirs(project) {
|
|
195
|
+
for (let d = path.resolve(project); ; ) {
|
|
196
|
+
const dot = path.join(d, '.git');
|
|
197
|
+
let st = null;
|
|
198
|
+
try {
|
|
199
|
+
st = fs.statSync(dot);
|
|
200
|
+
} catch {
|
|
201
|
+
st = null;
|
|
202
|
+
}
|
|
203
|
+
if (st && st.isDirectory()) return { dir: dot, common: dot };
|
|
204
|
+
if (st && st.isFile()) {
|
|
205
|
+
const m = /^gitdir:\s*(.+?)\s*$/m.exec(readText(dot) || '');
|
|
206
|
+
if (!m) return undefined;
|
|
207
|
+
const dir = path.resolve(d, m[1]);
|
|
208
|
+
const c = readText(path.join(dir, 'commondir'));
|
|
209
|
+
return { dir, common: c ? path.resolve(dir, c.trim()) : dir };
|
|
210
|
+
}
|
|
211
|
+
const up = path.dirname(d);
|
|
212
|
+
if (up === d) return null;
|
|
213
|
+
d = up;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// HEAD 커밋 해시를 git 을 띄우지 않고 파일에서 읽는다(요청마다 부르는 캐시 키, 저사양 대상).
|
|
218
|
+
// 분리된 HEAD 는 해시 그대로, 브랜치는 느슨한 ref 파일, 없으면 packed-refs.
|
|
219
|
+
// 저장소가 아니거나 브랜치에 커밋이 없으면 null. 읽지 못하는 꼴(reftable, 깨진 HEAD 등)은 undefined.
|
|
220
|
+
export function gitHead(project) {
|
|
221
|
+
const g = gitDirs(project);
|
|
222
|
+
if (!g) return g;
|
|
223
|
+
const head = readText(path.join(g.dir, 'HEAD'));
|
|
224
|
+
if (head === null || fs.existsSync(path.join(g.common, 'reftable'))) return undefined;
|
|
225
|
+
const t = head.trim();
|
|
226
|
+
if (HASH.test(t)) return t;
|
|
227
|
+
const m = /^ref:\s*(refs\/\S+)$/.exec(t);
|
|
228
|
+
if (!m) return undefined;
|
|
229
|
+
const ref = m[1];
|
|
230
|
+
// worktree 마다 따로인 ref 는 그 worktree 폴더, 그 밖은 공용 폴더.
|
|
231
|
+
const base = /^refs\/(?:bisect|worktree|rewritten)\//.test(ref) ? g.dir : g.common;
|
|
232
|
+
const loose = readText(path.join(base, ...ref.split('/')));
|
|
233
|
+
if (loose !== null) return HASH.test(loose.trim()) ? loose.trim() : undefined;
|
|
234
|
+
const packed = readText(path.join(g.common, 'packed-refs'));
|
|
235
|
+
for (const line of (packed || '').split('\n')) {
|
|
236
|
+
const [h, name] = line.trim().split(' ');
|
|
237
|
+
if (name === ref && HASH.test(h)) return h;
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// git log 원문 줄(%h|%cI|%s). 캐시 키는 HEAD 해시(gitHead, 읽지 못하면 git rev-parse HEAD).
|
|
243
|
+
// git log 는 키가 바뀔 때만 띄운다. git 이 없거나 커밋이 없으면 null. HEAD 를 읽었는데 git log 가 실패하면(git 없음 등)
|
|
244
|
+
// 그 실패(null)도 같은 키로 캐시해 요청마다 git 을 다시 띄우지 않는다. HEAD 가 바뀌면 다시 띄운다.
|
|
245
|
+
export function gitLog(project, max) {
|
|
246
|
+
let head = gitHead(project);
|
|
247
|
+
if (head === undefined) {
|
|
248
|
+
try {
|
|
249
|
+
head = git(project, ['rev-parse', 'HEAD']).trim();
|
|
250
|
+
} catch {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (!head) return null;
|
|
255
|
+
const key = `${project}\n${head}\n${max}`;
|
|
256
|
+
if (gitCache.has(key)) return gitCache.get(key);
|
|
257
|
+
let lines;
|
|
258
|
+
try {
|
|
259
|
+
lines = git(project, ['log', '-n', String(max), '--format=%h|%cI|%s']).split('\n').filter(Boolean);
|
|
260
|
+
} catch {
|
|
261
|
+
lines = null;
|
|
262
|
+
}
|
|
263
|
+
for (const k of gitCache.keys()) if (k.startsWith(`${project}\n`)) gitCache.delete(k);
|
|
264
|
+
gitCache.set(key, lines);
|
|
265
|
+
return lines;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// 해시 앞부분이 같으면 같은 커밋(짧은 쪽 4자 이상).
|
|
269
|
+
const sameHash = (a, b) => a.length >= 4 && b.length >= 4 && (a.startsWith(b) || b.startsWith(a));
|
|
270
|
+
|
|
271
|
+
// git log 줄에서 HEAD 표시와 스토리별 마지막 커밋. 제목에 id 가 없는 커밋은 items.json 의 storyCommits {id: 해시}.
|
|
272
|
+
// log 에 없는 storyCommits 해시도 완료로 친다(시각은 값의 t, 없으면 0).
|
|
273
|
+
export function gitInfo(lines, ids, storyCommits = {}, commitRegex = null) {
|
|
274
|
+
const commits = {};
|
|
275
|
+
// %h|%cI|%s: 제목에 | 가 있어도 앞 두 칸만 나눈다
|
|
276
|
+
const rows = (lines || []).map((l) => {
|
|
277
|
+
const [h, t, ...rest] = l.split('|');
|
|
278
|
+
return [h, t, rest.join('|')];
|
|
279
|
+
});
|
|
280
|
+
const found = subjectStories(rows.map((r) => r[2] || null), ids, commitRegex);
|
|
281
|
+
rows.forEach(([h, t], i) => {
|
|
282
|
+
const id = found[i];
|
|
283
|
+
if (id && !commits[id]) commits[id] = { h, t: Date.parse(t) };
|
|
284
|
+
});
|
|
285
|
+
for (const [id, v] of Object.entries(storyCommits || {})) {
|
|
286
|
+
if (commits[id]) continue;
|
|
287
|
+
const h = typeof v === 'string' ? v : v && typeof v.hash === 'string' ? v.hash : '';
|
|
288
|
+
if (!h) continue;
|
|
289
|
+
const hit = rows.find((r) => sameHash(r[0], h));
|
|
290
|
+
commits[id] = hit ? { h: hit[0], t: Date.parse(hit[1]) } : { h: h.slice(0, 7), t: parseTime(v && v.t) };
|
|
291
|
+
}
|
|
292
|
+
const [h0, , s0] = rows[0] || [];
|
|
293
|
+
return { head: rows.length ? h0 + ' ' + s0 : '-', commits };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------- 워크플로 journal ----------
|
|
297
|
+
|
|
298
|
+
const listDir = (p) => {
|
|
299
|
+
try {
|
|
300
|
+
return fs.readdirSync(p).sort();
|
|
301
|
+
} catch {
|
|
302
|
+
return [];
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// Claude Code 자동 메모리 slug: 절대 경로의 영문자와 숫자 밖 글자를 - 로.
|
|
307
|
+
export const projectSlug = (project) => path.resolve(project).replace(/[^A-Za-z0-9]/g, '-');
|
|
308
|
+
|
|
309
|
+
// workflows 루트 목록(그 아래 실행 폴더마다 journal.jsonl, agent-<id>.jsonl). 읽기만 한다.
|
|
310
|
+
// "auto": CLAUDE_CONFIG_DIR 또는 ~/.claude 의 projects/<slug>/<세션>/subagents/workflows. slug 는 대소문자 무시.
|
|
311
|
+
export function workflowRoots(config, ctx, env = process.env) {
|
|
312
|
+
const v = config.sources.workflowDirs;
|
|
313
|
+
// 폴더가 아닌 경로(특수 파일 등)는 listDir 이 빈 목록으로 보고, 그 아래 파일은 readRegular 로만 읽는다
|
|
314
|
+
if (Array.isArray(v)) return v.map((p) => path.resolve(ctx.project, p));
|
|
315
|
+
if (v !== 'auto') return [];
|
|
316
|
+
const base = path.join(env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'projects');
|
|
317
|
+
const slug = projectSlug(ctx.project).toLowerCase();
|
|
318
|
+
const out = [];
|
|
319
|
+
for (const name of listDir(base)) {
|
|
320
|
+
if (name.toLowerCase() !== slug) continue;
|
|
321
|
+
for (const session of listDir(path.join(base, name))) {
|
|
322
|
+
const root = path.join(base, name, session, 'subagents', 'workflows');
|
|
323
|
+
if (isDir(root)) out.push(root);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const statOf = (stat, p) => {
|
|
330
|
+
try {
|
|
331
|
+
return stat(p);
|
|
332
|
+
} catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
// 끝나지 않은 에이전트(started 뒤 result, failed, error, skipped 없음). journal 이 maxAgeH 시간 넘게,
|
|
338
|
+
// agent 파일이 staleMin 분 넘게 멈춘 것은 뺀다. maxSessions 가 0 보다 크면 최근 루트 그 수만.
|
|
339
|
+
// stat 은 파일 시각 함수(시험에서 주입). since 는 agent 파일 생성 시각.
|
|
340
|
+
export function activeAgents(roots, now, { staleMin = 15, maxAgeH = 24, maxSessions = 0 } = {}, stat = fs.statSync) {
|
|
341
|
+
let use = roots;
|
|
342
|
+
if (maxSessions > 0 && roots.length > maxSessions) {
|
|
343
|
+
use = roots
|
|
344
|
+
.map((r, i) => ({ r, i, t: (statOf(stat, r) || { mtimeMs: 0 }).mtimeMs }))
|
|
345
|
+
.sort((a, b) => b.t - a.t || a.i - b.i)
|
|
346
|
+
.slice(0, maxSessions)
|
|
347
|
+
.map((x) => x.r);
|
|
348
|
+
}
|
|
349
|
+
const out = [];
|
|
350
|
+
for (const root of use) {
|
|
351
|
+
for (const d of listDir(root)) {
|
|
352
|
+
const j = path.join(root, d, 'journal.jsonl');
|
|
353
|
+
const st = statOf(stat, j);
|
|
354
|
+
if (!st || now - st.mtimeMs > maxAgeH * 3600e3) continue;
|
|
355
|
+
const open = new Map();
|
|
356
|
+
for (const o of readLines(j)) {
|
|
357
|
+
if (o.type === 'started') open.set(o.key, o);
|
|
358
|
+
else if (o.key && (o.type === 'result' || o.type === 'failed' || o.type === 'error' || o.type === 'skipped')) open.delete(o.key);
|
|
359
|
+
}
|
|
360
|
+
for (const o of open.values()) {
|
|
361
|
+
const a = statOf(stat, path.join(root, d, `agent-${o.agentId}.jsonl`));
|
|
362
|
+
if (!a || now - a.mtimeMs > staleMin * 60e3) continue;
|
|
363
|
+
out.push({ label: o.label || '?', since: a.birthtimeMs });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 진행 중 스캔 캐시: 같은 루트와 설정이면 5초(단조 시계) 안, 같은 now 근처에서 다시 쓴다.
|
|
371
|
+
const agentCache = new Map();
|
|
372
|
+
function activeAgentsCached(roots, now, opts, stat) {
|
|
373
|
+
if (stat !== fs.statSync) return activeAgents(roots, now, opts, stat);
|
|
374
|
+
const key = JSON.stringify([roots, opts]);
|
|
375
|
+
const hit = agentCache.get(key);
|
|
376
|
+
const m = mono();
|
|
377
|
+
if (hit && m - hit.at < 5000 && Math.abs(now - hit.now) < 5000) return hit.list;
|
|
378
|
+
const list = activeAgents(roots, now, opts, stat);
|
|
379
|
+
agentCache.set(key, { at: m, now, list });
|
|
380
|
+
return list;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// 스토리마다 첫 에이전트 시작 시각(agent 파일 생성 시각의 최소). 모든 루트, 상한 없음.
|
|
384
|
+
// journal 마다 (mtime, id 목록) 가 같으면 결과를 다시 쓴다.
|
|
385
|
+
const startCache = new Map();
|
|
386
|
+
export function storyStarts(roots, ids, stat = fs.statSync) {
|
|
387
|
+
const out = {};
|
|
388
|
+
if (!ids.length) return out;
|
|
389
|
+
const idsKey = ids.join('\n');
|
|
390
|
+
for (const root of roots) {
|
|
391
|
+
for (const d of listDir(root)) {
|
|
392
|
+
const j = path.join(root, d, 'journal.jsonl');
|
|
393
|
+
const st = statOf(stat, j);
|
|
394
|
+
if (!st) continue;
|
|
395
|
+
const useCache = stat === fs.statSync;
|
|
396
|
+
const hit = useCache && startCache.get(j);
|
|
397
|
+
let found;
|
|
398
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.idsKey === idsKey) found = hit.found;
|
|
399
|
+
else {
|
|
400
|
+
found = {};
|
|
401
|
+
for (const o of readLines(j)) {
|
|
402
|
+
const id = o.type === 'started' && storyOfStartLabel(o.label, ids);
|
|
403
|
+
if (!id) continue;
|
|
404
|
+
const a = statOf(stat, path.join(root, d, `agent-${o.agentId}.jsonl`));
|
|
405
|
+
if (!a) continue;
|
|
406
|
+
if (!(id in found) || a.birthtimeMs < found[id]) found[id] = a.birthtimeMs;
|
|
407
|
+
}
|
|
408
|
+
if (useCache) startCache.set(j, { mtimeMs: st.mtimeMs, idsKey, found });
|
|
409
|
+
}
|
|
410
|
+
for (const [id, b] of Object.entries(found)) if (!(id in out) || b < out[id]) out[id] = b;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ---------- 모아 읽기 ----------
|
|
417
|
+
|
|
418
|
+
const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
|
|
419
|
+
|
|
420
|
+
// items.json. 없거나 깨졌으면 빈 값.
|
|
421
|
+
export function readItems(dataDir) {
|
|
422
|
+
const r = readJsonFile(path.join(dataDir, 'items.json'));
|
|
423
|
+
const v = obj(r.value);
|
|
424
|
+
return {
|
|
425
|
+
notes: obj(v.notes),
|
|
426
|
+
items: Array.isArray(v.items) ? v.items.filter((x) => x && typeof x === 'object') : [],
|
|
427
|
+
storyCommits: obj(v.storyCommits),
|
|
428
|
+
storyStates: obj(v.storyStates),
|
|
429
|
+
stageEvents: Array.isArray(v.stageEvents) ? v.stageEvents.filter((x) => x && typeof x === 'object') : [],
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// 스토리 목록: 설정 stories 뒤에 story 명령으로 더한 id(storyCommits, storyStates 차례). 이름은 값의 name 또는 id.
|
|
434
|
+
export function storyList(config, items) {
|
|
435
|
+
const out = config.stories.map((s) => ({ id: s.id, name: s.name || '' }));
|
|
436
|
+
const seen = new Set(out.map((s) => s.id));
|
|
437
|
+
for (const src of [items.storyCommits, items.storyStates]) {
|
|
438
|
+
for (const [id, v] of Object.entries(src)) {
|
|
439
|
+
if (seen.has(id)) continue;
|
|
440
|
+
seen.add(id);
|
|
441
|
+
out.push({ id, name: (v && typeof v === 'object' && typeof v.name === 'string' && v.name) || id });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return out;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// 단계 목록: 설정(또는 events 가 있을 때 기본 10단계) 뒤에 stageEvents 의 새 id(처음 나온 차례, 이름은 label 또는 id).
|
|
448
|
+
export function stageList(config, hasEvents, stageEvents) {
|
|
449
|
+
const out = stagesFor(config, hasEvents);
|
|
450
|
+
const seen = new Set(out.map((s) => s.id));
|
|
451
|
+
for (const e of stageEvents) {
|
|
452
|
+
if (typeof e.stage !== 'string') continue;
|
|
453
|
+
const label = typeof e.label === 'string' && e.label ? e.label : '';
|
|
454
|
+
if (!seen.has(e.stage)) {
|
|
455
|
+
seen.add(e.stage);
|
|
456
|
+
out.push({ id: e.stage, label: label || e.stage, added: !label });
|
|
457
|
+
} else if (label) {
|
|
458
|
+
const s = out.find((x) => x.id === e.stage);
|
|
459
|
+
if (s.added) {
|
|
460
|
+
s.label = label;
|
|
461
|
+
s.added = false;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return out.map(({ id, label }) => ({ id, label }));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// 렌더 입력 하나로 모은다. inject: { gitLines, stat, roots, env } 는 시험과 대등 확인에서 출처를 바꿔 넣는다.
|
|
469
|
+
export function collect(ctx, config, now, inject = {}) {
|
|
470
|
+
const src = config.sources;
|
|
471
|
+
const items = readItems(ctx.dataDir);
|
|
472
|
+
const stories = storyList(config, items);
|
|
473
|
+
const ids = stories.map((s) => s.id);
|
|
474
|
+
const evFile = eventsPath(config, ctx);
|
|
475
|
+
const hasEvents = Boolean(evFile) && fs.existsSync(evFile);
|
|
476
|
+
const events = hasEvents ? readLines(evFile) : [];
|
|
477
|
+
const stages = stageList(config, hasEvents, items.stageEvents);
|
|
478
|
+
const lines = inject.gitLines !== undefined ? inject.gitLines : src.git ? gitLog(ctx.project, src.gitMax) : null;
|
|
479
|
+
const g = gitInfo(lines, ids, items.storyCommits, config.commitRegex);
|
|
480
|
+
const roots = inject.roots || workflowRoots(config, ctx, inject.env);
|
|
481
|
+
const stat = inject.stat || fs.statSync;
|
|
482
|
+
const agents = activeAgentsCached(roots, now, { staleMin: src.agentStaleMin, maxAgeH: src.journalMaxAgeH, maxSessions: src.journalMaxSessions }, stat);
|
|
483
|
+
const tab = (name) => readJsonFile(path.join(ctx.dataDir, name));
|
|
484
|
+
return {
|
|
485
|
+
head: g.head,
|
|
486
|
+
commits: g.commits,
|
|
487
|
+
stages,
|
|
488
|
+
stageState: stageStates([...events, ...items.stageEvents]),
|
|
489
|
+
stories,
|
|
490
|
+
ids,
|
|
491
|
+
storyStates: items.storyStates,
|
|
492
|
+
notes: items.notes,
|
|
493
|
+
items: items.items,
|
|
494
|
+
agents,
|
|
495
|
+
starts: storyStarts(roots, ids, stat),
|
|
496
|
+
reqSpec: tab('req-spec.json'),
|
|
497
|
+
reqs: tab('reqs.json'),
|
|
498
|
+
flow: tab('flow.json'),
|
|
499
|
+
arch: tab('arch.json'),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// progress-mon.mjs: CLI 진입. 인자 해석, 런타임 판별, 하위 명령 분기, --help, 종료 코드.
|
|
3
|
+
// 종료 코드: 0 성공, 1 거부나 사용법 오류, 2 예기치 못한 오류, 3 status 꺼짐.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { resolveContext, configOrFail, dataLabel, writeAtomic, readRegular, UsageError } from './pm-config.mjs';
|
|
8
|
+
import { serve, start, stop, status } from './pm-server.mjs';
|
|
9
|
+
|
|
10
|
+
const CLI_PATH = fileURLToPath(import.meta.url);
|
|
11
|
+
const SKILL_DIR = path.resolve(path.dirname(CLI_PATH), '..');
|
|
12
|
+
|
|
13
|
+
export const HELP = `progress-mon: 프로젝트 진척도 웹 화면
|
|
14
|
+
|
|
15
|
+
사용: node progress-mon.mjs <명령> [인자]
|
|
16
|
+
|
|
17
|
+
명령
|
|
18
|
+
init --memory claude-md|agents-md|auto-memory|none [--memory-dir <dir>] [--title <t>]
|
|
19
|
+
자료 폴더와 config.json, items.json 을 만들고 갱신 규칙 등록
|
|
20
|
+
start [--port <n>] [--port-strict] [--host <사설 IPv4>]
|
|
21
|
+
서버를 분리 실행하고 URL 출력. 같은 프로젝트 서버가 떠 있으면 재사용
|
|
22
|
+
stop 서버 정지. 상태 파일의 PID 가 이 서버일 때만
|
|
23
|
+
status 실행 여부, URL, PID, 자료 폴더, 탭별 자료 유무
|
|
24
|
+
export <file> [--now <ISO>]
|
|
25
|
+
외부 자원 없는 단일 HTML
|
|
26
|
+
item --key <k> --status todo|doing|done --text <t> [--note <t>] [--issue <t>] [--at <ISO>] [--commit <hash>]
|
|
27
|
+
note --key <k> [--note <t>] [--issue <t>]
|
|
28
|
+
stage --id <id> --status started|done|skipped|failed|stopped [--label <t>]
|
|
29
|
+
story --id <id> (--status todo|doing|done | --commit <hash>) [--name <t>]
|
|
30
|
+
check 자료 파일 검사. FAIL, WARN 한 줄씩
|
|
31
|
+
|
|
32
|
+
공통 인자
|
|
33
|
+
--runtime claude|codex 자료 폴더 기본값(.omc/progress-mon/, .omx/progress-mon/)
|
|
34
|
+
--project <dir> 프로젝트 루트
|
|
35
|
+
--data <dir> 자료 폴더 직접 지정
|
|
36
|
+
|
|
37
|
+
종료 코드: 0 성공, 1 거부나 사용법 오류, 2 예기치 못한 오류, 3 status 꺼짐
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
// 값을 받는 인자와 값 없는 인자.
|
|
41
|
+
const VALUE_FLAGS = new Set(['runtime', 'project', 'data', 'port', 'host', 'memory', 'memory-dir', 'title', 'now', 'key', 'status', 'text', 'note', 'issue', 'id', 'label', 'commit', 'name', 'at']);
|
|
42
|
+
const BOOL_FLAGS = new Set(['port-strict', 'help']);
|
|
43
|
+
|
|
44
|
+
// 서버 밖 명령을 맡는 모듈. 쓰는 명령에서만 적재한다.
|
|
45
|
+
const MODS = {
|
|
46
|
+
init: 'pm-data.mjs',
|
|
47
|
+
item: 'pm-data.mjs',
|
|
48
|
+
note: 'pm-data.mjs',
|
|
49
|
+
stage: 'pm-data.mjs',
|
|
50
|
+
story: 'pm-data.mjs',
|
|
51
|
+
export: 'pm-render.mjs',
|
|
52
|
+
check: 'pm-check.mjs',
|
|
53
|
+
};
|
|
54
|
+
const SERVER_CMDS = new Set(['serve', 'start', 'stop', 'status']);
|
|
55
|
+
// 자료 폴더가 없으면 init 먼저(폴더를 만들지 않음).
|
|
56
|
+
const NEED_DATA = new Set(['serve', 'start', 'item', 'note', 'stage', 'story']);
|
|
57
|
+
|
|
58
|
+
export function parseArgs(argv) {
|
|
59
|
+
const o = { _: [] };
|
|
60
|
+
for (let i = 0; i < argv.length; i++) {
|
|
61
|
+
const a = argv[i];
|
|
62
|
+
if (a === '-h') {
|
|
63
|
+
o.help = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (!a.startsWith('--')) {
|
|
67
|
+
o._.push(a);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const eq = a.indexOf('=');
|
|
71
|
+
const name = eq > 0 ? a.slice(2, eq) : a.slice(2);
|
|
72
|
+
if (BOOL_FLAGS.has(name)) {
|
|
73
|
+
if (eq > 0) throw new UsageError(`${a.slice(0, eq)} 는 값을 받지 않음`);
|
|
74
|
+
o[name] = true;
|
|
75
|
+
} else if (VALUE_FLAGS.has(name)) {
|
|
76
|
+
const v = eq > 0 ? a.slice(eq + 1) : argv[++i];
|
|
77
|
+
if (v === undefined) throw new UsageError(`--${name} 에 값이 필요함`);
|
|
78
|
+
o[name] = v;
|
|
79
|
+
} else throw new UsageError(`모르는 인자: ${a}`);
|
|
80
|
+
}
|
|
81
|
+
return o;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function portArg(v) {
|
|
85
|
+
if (v === undefined) return undefined;
|
|
86
|
+
if (!/^\d+$/.test(v) || Number(v) < 1024 || Number(v) > 65535) throw new UsageError(`--port 는 1024~65535 정수: ${v}`);
|
|
87
|
+
return Number(v);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 자료 폴더가 이미 있을 때만 state.json 의 skillDir, runtime 을 맞춘다. 값이 같으면 쓰지 않는다.
|
|
91
|
+
async function touchState(ctx) {
|
|
92
|
+
if (!ctx.exists) return;
|
|
93
|
+
const file = path.join(ctx.dataDir, 'state.json');
|
|
94
|
+
let cur = {};
|
|
95
|
+
try {
|
|
96
|
+
cur = JSON.parse(readRegular(file));
|
|
97
|
+
} catch {
|
|
98
|
+
cur = {};
|
|
99
|
+
}
|
|
100
|
+
if (cur && cur.skillDir === SKILL_DIR && cur.runtime === ctx.runtime) return;
|
|
101
|
+
const next = { ...(cur && typeof cur === 'object' && !Array.isArray(cur) ? cur : {}), skillDir: SKILL_DIR, runtime: ctx.runtime, updatedAt: new Date().toISOString() };
|
|
102
|
+
try {
|
|
103
|
+
await writeAtomic(file, JSON.stringify(next, null, 2) + '\n');
|
|
104
|
+
} catch {
|
|
105
|
+
// 못 써도 명령은 계속. 다음 실행이 다시 맞춘다
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function main(argv, io, cwd = process.cwd()) {
|
|
110
|
+
const o = parseArgs(argv);
|
|
111
|
+
const cmd = o._[0];
|
|
112
|
+
if (o.help || !cmd) {
|
|
113
|
+
io.write(HELP);
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
if (!SERVER_CMDS.has(cmd) && !MODS[cmd]) throw new UsageError(`모르는 명령: ${cmd}. --help 참고`);
|
|
117
|
+
const ctx = resolveContext({ cwd, project: o.project, runtime: o.runtime, data: o.data });
|
|
118
|
+
if (ctx.warn) io.err(ctx.warn);
|
|
119
|
+
if (NEED_DATA.has(cmd) && !ctx.exists) throw new UsageError(`자료 폴더 없음(${dataLabel(ctx)}). init 먼저 실행`);
|
|
120
|
+
await touchState(ctx);
|
|
121
|
+
if (MODS[cmd]) {
|
|
122
|
+
const mod = await import(`./${MODS[cmd]}`);
|
|
123
|
+
return mod.command(cmd, ctx, o, io, { skillDir: SKILL_DIR });
|
|
124
|
+
}
|
|
125
|
+
if (cmd === 'stop') return stop(ctx, io);
|
|
126
|
+
if (cmd === 'status') return status(ctx, io);
|
|
127
|
+
const config = configOrFail(ctx);
|
|
128
|
+
const opts = { port: portArg(o.port), portStrict: Boolean(o['port-strict']), host: o.host };
|
|
129
|
+
if (cmd === 'start') return start(ctx, config, opts, io, { cliPath: CLI_PATH });
|
|
130
|
+
const srv = await serve(ctx, config, opts, io);
|
|
131
|
+
const quit = () => srv.close().then(() => process.exit(0));
|
|
132
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGBREAK']) process.on(sig, quit);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const stdio = {
|
|
137
|
+
write: (s) => process.stdout.write(s),
|
|
138
|
+
out: (line) => process.stdout.write(line + '\n'),
|
|
139
|
+
err: (line) => process.stderr.write(line + '\n'),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// 직접 실행인지: 실행 경로와 이 파일을 실제 경로로 풀어 비교한다(junction, symlink 로 불러도 같음).
|
|
143
|
+
const real = (p) => {
|
|
144
|
+
try {
|
|
145
|
+
return fs.realpathSync(p);
|
|
146
|
+
} catch {
|
|
147
|
+
return path.resolve(p);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const same = (a, b) => (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
151
|
+
if (process.argv[1] && same(real(process.argv[1]), real(CLI_PATH))) {
|
|
152
|
+
main(process.argv.slice(2), stdio).then(
|
|
153
|
+
(code) => {
|
|
154
|
+
// serve 는 null: 리스너가 프로세스를 붙잡는다.
|
|
155
|
+
if (code !== null) process.exitCode = code;
|
|
156
|
+
},
|
|
157
|
+
(e) => {
|
|
158
|
+
if (e instanceof UsageError) {
|
|
159
|
+
stdio.err(`오류: ${e.message}`);
|
|
160
|
+
process.exitCode = 1;
|
|
161
|
+
} else {
|
|
162
|
+
stdio.err(`오류: 예기치 못한 오류(${(e && e.message) || e})`);
|
|
163
|
+
process.exitCode = 2;
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
}
|