@kaydash9999/banker-plugins 0.16.2 → 0.17.0
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 +44 -0
- package/README.md +15 -10
- package/bin/banker.js +2 -2
- package/codex/manifest.json +2 -1
- package/codex/transform-matrix.md +2 -1
- package/commands/setup.md +2 -2
- package/hooks/progress-plan.mjs +716 -0
- package/hooks/progress.mjs +378 -34
- package/hooks/register.mjs +24 -1
- package/package.json +1 -1
- package/skills/3d-intro-build/references/assemble.mjs +10 -2
- package/skills/3d-intro-build/references/curate.mjs +10 -2
- package/skills/3d-intro-build/references/preview-lib.mjs +2 -0
- package/skills/3d-intro-build/references/serve.mjs +10 -2
- package/skills/3d-intro-build/references/verify-intro.mjs +10 -2
- package/skills/motion-graphic-make/references/curate.mjs +10 -2
- package/skills/motion-graphic-make/references/preview-lib.mjs +2 -0
- package/skills/motion-graphic-make/references/serve.mjs +10 -2
- package/skills/obsidizer/obsidize.mjs +10 -2
- package/skills/omc-patch/SKILL.md +96 -21
- package/skills/omc-patch/scripts/omc-patch.mjs +164 -62
- package/skills/omc-patch/scripts/probe-pm.mjs +192 -0
- package/skills/ultra-chain/SKILL.md +475 -0
- package/skills/ultra-chain/references/capture.mjs +232 -0
- package/skills/ultra-chain/references/chain-ledger.mjs +491 -0
- package/skills/ultra-chain/references/chain-report.mjs +257 -0
- package/skills/ultra-chain/references/preview-lib.mjs +258 -0
- package/skills/ultra-chain/references/serve.mjs +139 -0
- package/skills/vertical-pptx/verify_a4p.mjs +10 -2
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Run ledger for /banker:ultra-chain. A run is one folder that holds the chain's artifacts and two files:
|
|
3
|
+
// run.json (when, where and from which commit the run started) and events.jsonl (one JSON line per stage
|
|
4
|
+
// event). The time of every event is taken here, never written by the model. Pure Node, no dependencies.
|
|
5
|
+
//
|
|
6
|
+
// node chain-ledger.mjs init --base <folder> [--runtime claude|codex] [--exclude]
|
|
7
|
+
// node chain-ledger.mjs event <run> --stage <id> --status <status> [--file <event.json>] [--round <n>]
|
|
8
|
+
// [--summary <text>] [--reason <text>]
|
|
9
|
+
// node chain-ledger.mjs boxes <run> --file <boxes.mjs report> [--stage <id>] [--round <n>]
|
|
10
|
+
// [--unverified <box,...> --why <text>]
|
|
11
|
+
// node chain-ledger.mjs show <run>
|
|
12
|
+
//
|
|
13
|
+
// Exit codes: 0 done, 1 a file could not be read or written (a missing or broken --file included),
|
|
14
|
+
// 2 a usage error or a rejected event.
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
export const STAGES = [
|
|
22
|
+
{ id: "preflight", label: "사전 점검" },
|
|
23
|
+
{ id: "research", label: "자료 조사" },
|
|
24
|
+
{ id: "interview", label: "모호성 제거" },
|
|
25
|
+
{ id: "plan", label: "합의 계획" },
|
|
26
|
+
{ id: "mock", label: "목업" },
|
|
27
|
+
{ id: "build", label: "구현" },
|
|
28
|
+
{ id: "qa", label: "OS 시험과 합의 검증" },
|
|
29
|
+
{ id: "visual", label: "시각 검토" },
|
|
30
|
+
{ id: "trace", label: "과정 시각화" },
|
|
31
|
+
{ id: "share", label: "결과 공유" },
|
|
32
|
+
];
|
|
33
|
+
export const STAGE_IDS = STAGES.map((s) => s.id);
|
|
34
|
+
export const STATUSES = ["started", "done", "failed", "skipped", "stopped", "note"];
|
|
35
|
+
const TERMINAL = new Set(["done", "failed", "skipped", "stopped"]);
|
|
36
|
+
const NEEDS_REASON = new Set(["failed", "skipped", "stopped"]);
|
|
37
|
+
const VERDICTS = ["APPROVE", "ITERATE", "REJECT", "INCONCLUSIVE", "PASS", "FAIL", "STOPPED"];
|
|
38
|
+
const CONTROL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
|
39
|
+
|
|
40
|
+
export class UsageError extends Error {}
|
|
41
|
+
|
|
42
|
+
// ── values ───────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
// A path the report may link to: relative to the run folder, forward slashes, no `..`, no drive or scheme.
|
|
45
|
+
export function runPath(p) {
|
|
46
|
+
if (typeof p !== "string" || !p || p.length > 300 || /[\u0000-\u001F]/.test(p)) return null;
|
|
47
|
+
const s = p.replace(/\\/g, "/");
|
|
48
|
+
if (s.startsWith("/") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(s)) return null;
|
|
49
|
+
const parts = s.split("/").filter((x) => x !== ".");
|
|
50
|
+
if (!parts.length || parts.some((x) => x === ".." || x === "")) return null;
|
|
51
|
+
return parts.join("/");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const text = (max) => (v) => (typeof v === "string" && v.length <= max ? v.replace(CONTROL, "") : undefined);
|
|
55
|
+
const int = (lo, hi) => (v) => (Number.isInteger(v) && v >= lo && v <= hi ? v : undefined);
|
|
56
|
+
const num = (lo, hi) => (v) => (typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi ? v : undefined);
|
|
57
|
+
const oneOf = (...xs) => (v) => (xs.includes(v) ? v : undefined);
|
|
58
|
+
const bool = () => (v) => (typeof v === "boolean" ? v : undefined);
|
|
59
|
+
const path = () => (v) => runPath(v) ?? undefined;
|
|
60
|
+
const COUNT = int(0, 10_000_000);
|
|
61
|
+
|
|
62
|
+
// Each field: [check, required, what the check wants]. A check returns the cleaned value or undefined.
|
|
63
|
+
const RECORDS = {
|
|
64
|
+
artifacts: { max: 50, fields: { label: [text(200), false, "200자 이하 글"], path: [path(), true, "실행 폴더 기준 상대 경로"] } },
|
|
65
|
+
tests: { max: 60, fields: {
|
|
66
|
+
where: [text(100), true, "100자 이하 글"], os: [oneOf("linux", "darwin", "win32", "other"), false, "linux, darwin, win32, other"],
|
|
67
|
+
status: [oneOf("pass", "fail", "unverified"), true, "pass, fail, unverified"], tests: [COUNT, false, "0 이상 정수"],
|
|
68
|
+
pass: [COUNT, false, "0 이상 정수"], fail: [COUNT, false, "0 이상 정수"], skipped: [COUNT, false, "0 이상 정수"],
|
|
69
|
+
note: [text(500), false, "500자 이하 글"], log: [path(), false, "실행 폴더 기준 상대 경로"] } },
|
|
70
|
+
visual: { max: 100, fields: {
|
|
71
|
+
name: [text(100), true, "100자 이하 글"], mock: [path(), false, "실행 폴더 기준 상대 경로"],
|
|
72
|
+
actual: [path(), false, "실행 폴더 기준 상대 경로"], score: [num(0, 100), false, "0~100 수"],
|
|
73
|
+
verdict: [oneOf("pass", "revise", "fail", "unverified"), false, "pass, revise, fail, unverified"], intended: [bool(), false, "true, false"],
|
|
74
|
+
note: [text(500), false, "500자 이하 글"] } },
|
|
75
|
+
checklist: { max: 60, fields: {
|
|
76
|
+
item: [text(300), true, "300자 이하 글"], why: [text(500), false, "500자 이하 글"], ref: [path(), false, "실행 폴더 기준 상대 경로"] } },
|
|
77
|
+
seats: { max: 20, fields: {
|
|
78
|
+
id: [text(60), true, "60자 이하 글"], lens: [text(30), false, "30자 이하 글"], model: [text(100), false, "100자 이하 글"],
|
|
79
|
+
verdict: [oneOf("APPROVE", "ITERATE", "REJECT", "ERROR"), true, "APPROVE, ITERATE, REJECT, ERROR"] } },
|
|
80
|
+
};
|
|
81
|
+
const EVENT_FIELDS = {
|
|
82
|
+
stage: [oneOf(...STAGE_IDS), true, STAGE_IDS.join(", ")], status: [oneOf(...STATUSES), true, STATUSES.join(", ")],
|
|
83
|
+
summary: [text(4000), false, "4000자 이하 글"], reason: [text(1000), false, "1000자 이하 글"], round: [int(1, 99), false, "1~99 정수"],
|
|
84
|
+
};
|
|
85
|
+
const VERDICT_FIELDS = {
|
|
86
|
+
by: [text(100), true, "100자 이하 글"], result: [oneOf(...VERDICTS), true, VERDICTS.join(", ")], note: [text(1000), false, "1000자 이하 글"],
|
|
87
|
+
};
|
|
88
|
+
const EVENT_LISTS = ["artifacts", "tests", "visual", "checklist"];
|
|
89
|
+
|
|
90
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
91
|
+
|
|
92
|
+
// Checks the plain fields of one record; unknown keys are refused so a typo does not vanish silently.
|
|
93
|
+
function checkFields(obj, fields, where, extra = []) {
|
|
94
|
+
if (!isObject(obj)) throw new UsageError(`${where}: 객체가 아닙니다`);
|
|
95
|
+
const unknown = Object.keys(obj).filter((k) => !Object.prototype.hasOwnProperty.call(fields, k) && !extra.includes(k));
|
|
96
|
+
if (unknown.length) throw new UsageError(`${where}: 모르는 항목 ${unknown.join(", ")}`);
|
|
97
|
+
const out = {};
|
|
98
|
+
for (const [key, [check, required, want]] of Object.entries(fields)) {
|
|
99
|
+
if (obj[key] === undefined) {
|
|
100
|
+
if (required) throw new UsageError(`${where}.${key}: 꼭 있어야 합니다(${want})`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const value = check(obj[key]);
|
|
104
|
+
if (value === undefined) throw new UsageError(`${where}.${key}: ${want} 이어야 합니다`);
|
|
105
|
+
out[key] = value;
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function checkList(list, spec, where) {
|
|
111
|
+
if (!Array.isArray(list) || list.length > spec.max) throw new UsageError(`${where}: ${spec.max}개 이하 배열이어야 합니다`);
|
|
112
|
+
return list.map((item, i) => checkFields(item, spec.fields, `${where}[${i}]`));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const BLOCKERS = { max: 50, fields: { item: [text(1000), true, "1000자 이하 글"] } };
|
|
116
|
+
|
|
117
|
+
function checkVerdict(v) {
|
|
118
|
+
const out = checkFields(v, VERDICT_FIELDS, "verdict", ["seats", "blockers"]);
|
|
119
|
+
if (v.seats !== undefined) out.seats = checkList(v.seats, RECORDS.seats, "verdict.seats");
|
|
120
|
+
if (v.blockers !== undefined) {
|
|
121
|
+
if (!Array.isArray(v.blockers)) throw new UsageError("verdict.blockers: 글의 배열이어야 합니다");
|
|
122
|
+
out.blockers = checkList(v.blockers.map((b) => ({ item: b })), BLOCKERS, "verdict.blockers").map((b) => b.item);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Validates one event and returns its cleaned copy (paths normalized, control characters removed).
|
|
128
|
+
export function checkEvent(raw) {
|
|
129
|
+
const event = checkFields(raw, EVENT_FIELDS, "event", [...EVENT_LISTS, "verdict"]);
|
|
130
|
+
if (NEEDS_REASON.has(event.status) && !event.reason) throw new UsageError(`event.reason: status 가 ${event.status} 이면 이유가 있어야 합니다`);
|
|
131
|
+
for (const key of EVENT_LISTS) {
|
|
132
|
+
if (raw[key] !== undefined) event[key] = checkList(raw[key], RECORDS[key], key);
|
|
133
|
+
}
|
|
134
|
+
if (raw.verdict !== undefined) event.verdict = checkVerdict(raw.verdict);
|
|
135
|
+
return event;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── run folder ───────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
function git(cwd, args) {
|
|
141
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
142
|
+
return r.status === 0 ? r.stdout.trim() : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
146
|
+
|
|
147
|
+
export function runId(now, rand) {
|
|
148
|
+
const d = `${pad(now.getFullYear() % 100)}${pad(now.getMonth() + 1)}${pad(now.getDate())}`;
|
|
149
|
+
return `${d}-${pad(now.getHours())}${pad(now.getMinutes())}-${rand}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Puts the run base into the repo's local exclude file (.git/info/exclude), never into a tracked file.
|
|
153
|
+
// Both paths are compared as real paths: git reports the real one (/private/var on macOS) while the base
|
|
154
|
+
// may come through a link (/var, a symlinked home, a Windows short name).
|
|
155
|
+
function addExclude(root, baseDir) {
|
|
156
|
+
const rel = relative(realpathSync.native(root), realpathSync.native(baseDir)).split("\\").join("/");
|
|
157
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) throw new Error("실행 폴더가 저장소 밖이라 제외 파일에 넣지 않았습니다");
|
|
158
|
+
const gitPath = git(root, ["rev-parse", "--git-path", "info/exclude"]);
|
|
159
|
+
if (!gitPath) return false;
|
|
160
|
+
const file = isAbsolute(gitPath) ? gitPath : join(root, gitPath);
|
|
161
|
+
const line = `/${rel}/`;
|
|
162
|
+
const now = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
163
|
+
if (now.split(/\r?\n/).includes(line)) return true;
|
|
164
|
+
mkdirSync(join(file, ".."), { recursive: true });
|
|
165
|
+
appendFileSync(file, `${now && !now.endsWith("\n") ? "\n" : ""}# banker ultra-chain runs\n${line}\n`);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function repoInfo(cwd) {
|
|
170
|
+
const root = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
171
|
+
if (!root) return { repo: null, baseCommit: null, branch: null };
|
|
172
|
+
return { repo: root, baseCommit: git(root, ["rev-parse", "--verify", "-q", "HEAD"]), branch: git(root, ["rev-parse", "--abbrev-ref", "HEAD"]) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Whether git ignores the run folder; with `exclude`, a folder it does not ignore goes to .git/info/exclude.
|
|
176
|
+
// A failed write (a read-only .git in a sandbox) is reported in excludeError, not thrown: the run folder
|
|
177
|
+
// already exists and the chain can go on.
|
|
178
|
+
function ignoreState(root, dir, baseDir, exclude) {
|
|
179
|
+
if (!root) return { ignored: null, excluded: false };
|
|
180
|
+
const ignored = git(root, ["check-ignore", "-q", "--", realpathSync.native(dir)]) !== null;
|
|
181
|
+
if (ignored || !exclude) return { ignored, excluded: false };
|
|
182
|
+
try {
|
|
183
|
+
return { ignored, excluded: addExclude(root, baseDir) };
|
|
184
|
+
} catch (e) {
|
|
185
|
+
return { ignored, excluded: false, excludeError: String(e.code || e.message) };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Folders the stages write into, made up front so a shell redirect into them never fails.
|
|
190
|
+
export const RUN_FOLDERS = ["mock/expected", "actual", "visual", "qa", "boxes"];
|
|
191
|
+
|
|
192
|
+
// Creates <base>/<id>/ with run.json and an empty events.jsonl. opts: base, cwd, runtime, exclude, now, rand.
|
|
193
|
+
export function initRun(opts) {
|
|
194
|
+
const o = { cwd: process.cwd(), runtime: null, exclude: false, now: new Date(), rand: randomBytes(2).toString("hex"), ...opts };
|
|
195
|
+
if (!o.base) throw new UsageError("--base 가 필요합니다");
|
|
196
|
+
if (/[\u0000-\u001F]/.test(o.base)) throw new UsageError("--base 에 제어 문자가 있습니다");
|
|
197
|
+
if (o.runtime !== null && !["claude", "codex"].includes(o.runtime)) throw new UsageError("--runtime 은 claude, codex 중 하나입니다");
|
|
198
|
+
const info = repoInfo(o.cwd);
|
|
199
|
+
const baseDir = resolve(o.cwd, o.base);
|
|
200
|
+
const id = runId(o.now, o.rand);
|
|
201
|
+
const dir = join(baseDir, id);
|
|
202
|
+
if (existsSync(dir)) throw new Error(`이미 있는 실행 폴더입니다: ${dir}`);
|
|
203
|
+
mkdirSync(dir, { recursive: true });
|
|
204
|
+
const meta = { schema: 1, id, createdAt: o.now.toISOString(), runtime: o.runtime, ...info };
|
|
205
|
+
writeFileSync(join(dir, "run.json"), `${JSON.stringify(meta, null, 1)}\n`);
|
|
206
|
+
writeFileSync(join(dir, "events.jsonl"), "");
|
|
207
|
+
for (const f of RUN_FOLDERS) mkdirSync(join(dir, f), { recursive: true });
|
|
208
|
+
return { dir, id, ...ignoreState(info.repo, dir, baseDir, o.exclude) };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function readRun(dir) {
|
|
212
|
+
const file = join(dir, "run.json");
|
|
213
|
+
if (!existsSync(file)) throw new Error(`실행 폴더가 아닙니다(run.json 없음): ${dir}`);
|
|
214
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// A cut-off last line (no newline) gets one before the new event, so only the cut line is lost.
|
|
218
|
+
function needsNewline(file) {
|
|
219
|
+
if (!existsSync(file)) return false;
|
|
220
|
+
const text = readFileSync(file, "utf8");
|
|
221
|
+
return text.length > 0 && !text.endsWith("\n");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function appendEvent(dir, raw, now = new Date()) {
|
|
225
|
+
readRun(dir);
|
|
226
|
+
const event = { t: now.toISOString(), ...checkEvent(raw) };
|
|
227
|
+
const file = join(dir, "events.jsonl");
|
|
228
|
+
appendFileSync(file, `${needsNewline(file) ? "\n" : ""}${JSON.stringify(event)}\n`);
|
|
229
|
+
return event;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Every readable line, in order. A line that is not an event (a cut-off write) is reported, not fatal.
|
|
233
|
+
export function readEvents(dir) {
|
|
234
|
+
const file = join(dir, "events.jsonl");
|
|
235
|
+
const lines = existsSync(file) ? readFileSync(file, "utf8").split("\n") : [];
|
|
236
|
+
const events = [];
|
|
237
|
+
const bad = [];
|
|
238
|
+
lines.forEach((line, i) => {
|
|
239
|
+
if (!line.trim()) return;
|
|
240
|
+
try {
|
|
241
|
+
const e = JSON.parse(line);
|
|
242
|
+
if (typeof e.t !== "string" || !STAGE_IDS.includes(e.stage) || !STATUSES.includes(e.status)) throw new Error("shape");
|
|
243
|
+
events.push(e);
|
|
244
|
+
} catch {
|
|
245
|
+
bad.push(i + 1);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
return { events, bad };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── summary ──────────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
// First start and, when the stage has ended, its last end. A stage entered again (a fix loop) keeps its
|
|
254
|
+
// first start; it counts as ended only when its latest mark is an ending one.
|
|
255
|
+
function stageTimes(mine, last) {
|
|
256
|
+
const first = mine.find((e) => e.status === "started") || mine.find((e) => e.status !== "note");
|
|
257
|
+
const start = first ? first.t : null;
|
|
258
|
+
const end = last && TERMINAL.has(last.status) ? last.t : null;
|
|
259
|
+
return { start, end, ms: start && end ? Math.max(0, Date.parse(end) - Date.parse(start)) : null };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function lastWords(mine) {
|
|
263
|
+
const said = [...mine].reverse().find((e) => e.summary || e.reason);
|
|
264
|
+
if (!said) return null;
|
|
265
|
+
return said.summary || said.reason;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// The latest verdict of the stage, with the round it came from: what a resumed run must not lose.
|
|
269
|
+
function lastVerdict(mine) {
|
|
270
|
+
const e = [...mine].reverse().find((x) => x.verdict);
|
|
271
|
+
if (!e) return null;
|
|
272
|
+
return { round: e.round ?? null, by: e.verdict.by, result: e.verdict.result, blockers: e.verdict.blockers ?? [] };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function stageSummary(stage, events) {
|
|
276
|
+
const mine = events.filter((e) => e.stage === stage.id);
|
|
277
|
+
const last = mine.filter((e) => e.status !== "note").at(-1);
|
|
278
|
+
const numbered = mine.filter((e) => e.round).map((e) => e.round);
|
|
279
|
+
return { id: stage.id, label: stage.label, status: last ? last.status : "pending", ...stageTimes(mine, last),
|
|
280
|
+
rounds: new Set(numbered).size, maxRound: numbered.length ? Math.max(...numbered) : 0, events: mine.length,
|
|
281
|
+
summary: lastWords(mine), lastVerdict: lastVerdict(mine) };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const FINISHED = new Set(["done", "skipped"]);
|
|
285
|
+
|
|
286
|
+
// The run is halted only while its latest mark (not a note) is a stop or a failure: resuming an earlier
|
|
287
|
+
// stage afterwards (--from) makes it run again.
|
|
288
|
+
function haltedStage(events, stages) {
|
|
289
|
+
const last = events.filter((e) => e.status !== "note").at(-1);
|
|
290
|
+
if (!last || (last.status !== "stopped" && last.status !== "failed")) return null;
|
|
291
|
+
return stages.find((s) => s.id === last.stage);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// overall: stopped or failed while the latest mark is one, done when every stage is done or skipped, else
|
|
295
|
+
// running. current: the stage to resume from; the halted stage, else the first started stage in chain
|
|
296
|
+
// order, else the first stage not done or skipped (one that stopped earlier included).
|
|
297
|
+
export function summarize(events) {
|
|
298
|
+
const stages = STAGES.map((s) => stageSummary(s, events));
|
|
299
|
+
const halted = haltedStage(events, stages);
|
|
300
|
+
const current = halted || stages.find((s) => s.status === "started") || stages.find((s) => !FINISHED.has(s.status));
|
|
301
|
+
let overall = "running";
|
|
302
|
+
if (halted) overall = halted.status;
|
|
303
|
+
else if (!current) overall = "done";
|
|
304
|
+
return { overall, current: current ? current.id : null, stages };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── boxes.mjs report ─────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
const NOT_RUN = new Set([127, 9009, 97]);
|
|
310
|
+
|
|
311
|
+
function boxStatus(b, s) {
|
|
312
|
+
if (b.stopped) return ["unverified", "중단됨"];
|
|
313
|
+
if (!b.reachable) return ["unverified", b.error ? `접속 실패: ${b.error}` : "접속 실패"];
|
|
314
|
+
if (b.timedOut) return ["fail", "시간 초과"];
|
|
315
|
+
if (NOT_RUN.has(b.exit)) return ["unverified", `시험이 돌지 못함(종료 코드 ${b.exit})`];
|
|
316
|
+
if (b.noTests) return ["unverified", "시험 0건"];
|
|
317
|
+
return tapStatus(b, s);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// The TAP count outranks a zero exit: failures are failures, and a run where every test was skipped proved
|
|
321
|
+
// nothing on that box.
|
|
322
|
+
function tapStatus(b, s) {
|
|
323
|
+
if (s.fail > 0) return ["fail", `시험 실패 ${s.fail}건`];
|
|
324
|
+
if (b.exit !== 0) return ["fail", `종료 코드 ${b.exit}`];
|
|
325
|
+
if (Number.isInteger(s.tests) && s.tests > 0 && s.pass === 0) return ["unverified", "시험이 모두 건너뜀"];
|
|
326
|
+
return ["pass", ""];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// The log link survives only when the log lies inside the run folder (boxes.mjs run --out <run>/...).
|
|
330
|
+
function boxLog(b, dir) {
|
|
331
|
+
if (typeof b.log !== "string" || !b.log) return null;
|
|
332
|
+
return runPath(relative(dir, resolve(b.log)).split("\\").join("/"));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// A box that never answered has nothing to clean up, so only a reachable one can report a failed cleanup.
|
|
336
|
+
const cleanupNote = (b) => ((b.reachable || b.stopped) && b.cleaned === false ? "박스 정리 실패" : "");
|
|
337
|
+
|
|
338
|
+
function boxEntry(b, dir) {
|
|
339
|
+
const s = isObject(b.summary) ? b.summary : {};
|
|
340
|
+
const [status, why] = boxStatus(b, s);
|
|
341
|
+
const failures = Array.isArray(s.failures) ? s.failures.slice(0, 5).map(String) : [];
|
|
342
|
+
const notes = [why, cleanupNote(b), ...failures].filter(Boolean);
|
|
343
|
+
const entry = { where: `box:${String(b.name).slice(0, 90)}`, os: ["linux", "darwin", "win32"].includes(b.os) ? b.os : "other", status };
|
|
344
|
+
for (const k of ["tests", "pass", "fail", "skipped"]) if (Number.isInteger(s[k]) && s[k] >= 0) entry[k] = s[k];
|
|
345
|
+
if (notes.length) entry.note = notes.join("; ").slice(0, 500);
|
|
346
|
+
const log = boxLog(b, dir);
|
|
347
|
+
if (log) entry.log = log;
|
|
348
|
+
return entry;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// One tests[] entry per box of a `boxes.mjs run` report.
|
|
352
|
+
export function boxesToTests(report, dir) {
|
|
353
|
+
if (!isObject(report) || !Array.isArray(report.boxes)) throw new UsageError("boxes.mjs run 의 출력 JSON 이 아닙니다(boxes 배열 없음)");
|
|
354
|
+
return report.boxes.slice(0, 60).map((b) => boxEntry(isObject(b) ? b : {}, dir));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── command line ─────────────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
const FLAGS = new Set(["--base", "--runtime", "--stage", "--status", "--file", "--round", "--summary", "--reason", "--unverified", "--why"]);
|
|
360
|
+
|
|
361
|
+
export function parseArgs(argv) {
|
|
362
|
+
const o = { _: [] };
|
|
363
|
+
for (let i = 0; i < argv.length; i++) {
|
|
364
|
+
const a = argv[i];
|
|
365
|
+
const eq = a.indexOf("=");
|
|
366
|
+
const key = a.startsWith("--") && eq > 0 ? a.slice(0, eq) : a;
|
|
367
|
+
if (key === "--exclude") o.exclude = true;
|
|
368
|
+
else if (FLAGS.has(key)) {
|
|
369
|
+
const value = eq > 0 && a.startsWith("--") ? a.slice(eq + 1) : argv[++i];
|
|
370
|
+
if (value === undefined) throw new UsageError(`${key} 에 값이 없습니다`);
|
|
371
|
+
o[key.slice(2)] = value;
|
|
372
|
+
} else if (a.startsWith("--")) throw new UsageError(`모르는 옵션 ${a}`);
|
|
373
|
+
else o._.push(a);
|
|
374
|
+
}
|
|
375
|
+
return o;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Text of a JSON file whatever wrote it: a UTF-8 BOM is dropped, and UTF-16 (Windows PowerShell 5.1's `>`)
|
|
379
|
+
// is decoded.
|
|
380
|
+
export function decodeText(buf) {
|
|
381
|
+
if (buf[0] === 0xff && buf[1] === 0xfe) return buf.subarray(2).toString("utf16le");
|
|
382
|
+
if (buf[0] === 0xfe && buf[1] === 0xff) return Buffer.from(buf.subarray(2)).swap16().toString("utf16le");
|
|
383
|
+
const text = buf.toString("utf8");
|
|
384
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function readJson(file) {
|
|
388
|
+
try {
|
|
389
|
+
return JSON.parse(decodeText(readFileSync(file)));
|
|
390
|
+
} catch (e) {
|
|
391
|
+
throw new Error(`JSON 파일을 읽지 못했습니다(${file}): ${e.message}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// The event file gives the body; flags may add the plain fields but never contradict the file.
|
|
396
|
+
function eventFrom(o) {
|
|
397
|
+
const body = o.file ? readJson(o.file) : {};
|
|
398
|
+
if (!isObject(body)) throw new UsageError("--file 의 JSON 은 객체여야 합니다");
|
|
399
|
+
const flags = { stage: o.stage, status: o.status, summary: o.summary, reason: o.reason, round: o.round && Number(o.round) };
|
|
400
|
+
for (const [k, v] of Object.entries(flags)) {
|
|
401
|
+
if (v === undefined) continue;
|
|
402
|
+
if (body[k] !== undefined && body[k] !== v) throw new UsageError(`--${k} 와 --file 의 ${k} 가 다릅니다`);
|
|
403
|
+
body[k] = v;
|
|
404
|
+
}
|
|
405
|
+
return body;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function runDir(o) {
|
|
409
|
+
if (o._.length !== 2) throw new UsageError("실행 폴더 하나를 주십시오");
|
|
410
|
+
return resolve(o._[1]);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// --unverified <box,...>: boxes whose logs show the tests never really ran (no TAP count to tell), recorded as
|
|
414
|
+
// unverified with --why from the start, since the ledger only appends.
|
|
415
|
+
export function markUnverified(tests, names, why) {
|
|
416
|
+
if (names === undefined) return tests;
|
|
417
|
+
if (!why) throw new UsageError("--unverified 에는 --why <이유> 가 함께 필요합니다");
|
|
418
|
+
const wanted = new Set(String(names).split(",").map((x) => x.trim()).filter(Boolean));
|
|
419
|
+
const known = new Set(tests.map((t) => t.where.replace(/^box:/, "")));
|
|
420
|
+
const unknown = [...wanted].filter((n) => !known.has(n));
|
|
421
|
+
if (unknown.length) throw new UsageError(`보고서에 없는 박스: ${unknown.join(", ")}`);
|
|
422
|
+
return tests.map((t) => (wanted.has(t.where.replace(/^box:/, ""))
|
|
423
|
+
? { ...t, status: "unverified", note: [why, t.note].filter(Boolean).join("; ").slice(0, 500) } : t));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// The line the chain says as a stage starts, ends or records a round, and adds to its resume memo:
|
|
427
|
+
// `[ultra-chain] 3/9 합의 계획: ralph-qa ITERATE 2회차`. A record command hands it back as `progress`, so
|
|
428
|
+
// saying it needs no words of the model's own.
|
|
429
|
+
const PROGRESS_WORD = { started: "시작", done: "끝", failed: "실패", skipped: "건너뜀", stopped: "멈춤", note: "기록" };
|
|
430
|
+
|
|
431
|
+
// A stage it does not know keeps its own name; a verdict on an end that is not a plain note keeps the
|
|
432
|
+
// end's word before it; the line stays one line.
|
|
433
|
+
export function progressLine(e) {
|
|
434
|
+
const i = STAGES.findIndex((s) => s.id === e.stage);
|
|
435
|
+
const where = i >= 0 ? `${i}/${STAGES.length - 1} ${STAGES[i].label}` : `?/${STAGES.length - 1} ${e.stage}`;
|
|
436
|
+
const word = PROGRESS_WORD[e.status] ?? String(e.status);
|
|
437
|
+
const verdict = e.verdict ? `${e.verdict.by} ${e.verdict.result}` : "";
|
|
438
|
+
const what = !verdict ? word : e.status === "note" ? verdict : `${word}, ${verdict}`;
|
|
439
|
+
const round = Number.isInteger(e.round) ? ` ${e.round}회차` : "";
|
|
440
|
+
return `[ultra-chain] ${where}: ${what}${round}`.replace(/\s+/g, " ");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const withProgress = (e) => ({ ...e, progress: progressLine(e) });
|
|
444
|
+
|
|
445
|
+
const COMMANDS = {
|
|
446
|
+
init: (o) => initRun({ base: o.base, runtime: o.runtime ?? null, exclude: Boolean(o.exclude) }),
|
|
447
|
+
event: (o) => withProgress(appendEvent(runDir(o), eventFrom(o))),
|
|
448
|
+
boxes: (o) => {
|
|
449
|
+
const dir = runDir(o);
|
|
450
|
+
if (!o.file) throw new UsageError("--file <boxes.mjs run 출력 JSON> 이 필요합니다");
|
|
451
|
+
const report = readJson(o.file);
|
|
452
|
+
const tests = markUnverified(boxesToTests(report, dir), o.unverified, o.why);
|
|
453
|
+
const count = (s) => tests.filter((t) => t.status === s).length;
|
|
454
|
+
const untracked = Number.isInteger(report.untracked) ? `, 보내지 않은 새 파일 ${report.untracked}개` : "";
|
|
455
|
+
const summary = `박스 ${tests.length}대: 통과 ${count("pass")}, 실패 ${count("fail")}, 미검증 ${count("unverified")}${untracked}`;
|
|
456
|
+
return withProgress(appendEvent(dir, { stage: o.stage ?? "qa", status: "note", summary, tests, ...(o.round ? { round: Number(o.round) } : {}) }));
|
|
457
|
+
},
|
|
458
|
+
// show is the first command of a resume, so it also re-makes the stage folders an older run may lack.
|
|
459
|
+
show: (o) => {
|
|
460
|
+
const dir = runDir(o);
|
|
461
|
+
const { id } = readRun(dir);
|
|
462
|
+
for (const f of RUN_FOLDERS) mkdirSync(join(dir, f), { recursive: true });
|
|
463
|
+
const { events, bad } = readEvents(dir);
|
|
464
|
+
return { id, dir, ...summarize(events), bad };
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
export function main(argv, out = process.stdout, err = process.stderr) {
|
|
469
|
+
try {
|
|
470
|
+
const o = parseArgs(argv);
|
|
471
|
+
const run = COMMANDS[o._[0]];
|
|
472
|
+
if (!run) throw new UsageError("명령은 init, event, boxes, show 중 하나입니다");
|
|
473
|
+
out.write(`${JSON.stringify(run(o), null, 1)}\n`);
|
|
474
|
+
return 0;
|
|
475
|
+
} catch (e) {
|
|
476
|
+
err.write(`chain-ledger: ${e.message}\n`);
|
|
477
|
+
return e instanceof UsageError ? 2 : 1;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Run as a command, also when reached through a symlinked path: Node resolves
|
|
482
|
+
// symlinks for import.meta.url but not for process.argv[1].
|
|
483
|
+
const invokedDirectly = () => {
|
|
484
|
+
try {
|
|
485
|
+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
486
|
+
} catch {
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
if (invokedDirectly()) process.exitCode = main(process.argv.slice(2));
|