@kaydash9999/banker-plugins 0.23.3 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +56 -0
  4. package/README.md +14 -6
  5. package/codex/manifest.json +6 -2
  6. package/codex/skills/teamwork/scripts/teamwork.mjs +3 -3
  7. package/codex/transform-matrix.md +6 -2
  8. package/package.json +1 -1
  9. package/skills/build-testbox/SKILL.md +213 -0
  10. package/skills/build-testbox/scripts/args.mjs +156 -0
  11. package/skills/build-testbox/scripts/browser-cell.mjs +181 -0
  12. package/skills/build-testbox/scripts/browsers.mjs +164 -0
  13. package/skills/build-testbox/scripts/containers.mjs +237 -0
  14. package/skills/build-testbox/scripts/detect.mjs +166 -0
  15. package/skills/build-testbox/scripts/proc.mjs +91 -0
  16. package/skills/build-testbox/scripts/report.mjs +109 -0
  17. package/skills/build-testbox/scripts/setup.mjs +144 -0
  18. package/skills/build-testbox/scripts/testbox.mjs +148 -0
  19. package/skills/git-init/SKILL.md +152 -0
  20. package/skills/git-init/scripts/git-init.mjs +440 -0
  21. package/skills/git-init/scripts/marker-block.mjs +321 -0
  22. package/skills/git-init/scripts/payload-scan.mjs +186 -0
  23. package/skills/init-dir/SKILL.md +172 -0
  24. package/skills/init-dir/scripts/init-dir.mjs +308 -0
  25. package/skills/init-dir/scripts/marker-block.mjs +321 -0
  26. package/skills/init-dir/scripts/templates.mjs +100 -0
  27. package/skills/init-dir/templates/KB/README.md +15 -0
  28. package/skills/init-dir/templates/docs/README.md +14 -0
  29. package/skills/init-dir/templates/logs/README.md +14 -0
  30. package/skills/init-dir/templates/logs/gitignore +3 -0
  31. package/skills/init-dir/templates/mockup/README.md +7 -0
  32. package/skills/init-dir/templates/scripts/README.md +39 -0
  33. package/skills/init-dir/templates/scripts/lib/service.mjs +596 -0
  34. package/skills/init-dir/templates/scripts/restart.cmd +13 -0
  35. package/skills/init-dir/templates/scripts/restart.sh +10 -0
  36. package/skills/init-dir/templates/scripts/service.conf +5 -0
  37. package/skills/init-dir/templates/scripts/start.cmd +13 -0
  38. package/skills/init-dir/templates/scripts/start.sh +10 -0
  39. package/skills/init-dir/templates/scripts/status.cmd +13 -0
  40. package/skills/init-dir/templates/scripts/status.sh +10 -0
  41. package/skills/init-dir/templates/scripts/stop.cmd +13 -0
  42. package/skills/init-dir/templates/scripts/stop.sh +10 -0
  43. package/skills/init-dir/templates/tests/README.md +15 -0
  44. package/skills/init-dir/templates/tests/run.cmd +13 -0
  45. package/skills/init-dir/templates/tests/run.mjs +160 -0
  46. package/skills/init-dir/templates/tests/run.sh +10 -0
  47. package/skills/init-dir/templates/tests/tests.conf +7 -0
  48. package/skills/init-dir/templates/work/README.md +8 -0
  49. package/skills/progress-mon/README.md +11 -8
  50. package/skills/progress-mon/SKILL.md +53 -16
  51. package/skills/progress-mon/assets/fixture/README.md +1 -1
  52. package/skills/progress-mon/assets/fixture/adr.json +35 -0
  53. package/skills/progress-mon/assets/fixture/arch.json +1 -1
  54. package/skills/progress-mon/assets/fixture/flow.json +2 -2
  55. package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +1 -1
  56. package/skills/progress-mon/assets/fixture/variants/legacy-init/rules.json +6 -0
  57. package/skills/progress-mon/assets/fixture/variants/legacy-init/state.json +5 -0
  58. package/skills/progress-mon/references/adr.md +184 -0
  59. package/skills/progress-mon/references/authoring.md +13 -5
  60. package/skills/progress-mon/scripts/pm-adr-view.mjs +101 -0
  61. package/skills/progress-mon/scripts/pm-adr.mjs +515 -0
  62. package/skills/progress-mon/scripts/pm-check.mjs +59 -22
  63. package/skills/progress-mon/scripts/pm-data.mjs +1 -1
  64. package/skills/progress-mon/scripts/pm-html.mjs +1 -0
  65. package/skills/progress-mon/scripts/pm-memory.mjs +113 -28
  66. package/skills/progress-mon/scripts/pm-render.mjs +6 -4
  67. package/skills/progress-mon/scripts/pm-server.mjs +2 -1
  68. package/skills/progress-mon/scripts/pm-sources.mjs +10 -0
  69. package/skills/progress-mon/scripts/progress-mon.mjs +3 -2
  70. package/skills/tune-tmux/SKILL.md +235 -0
  71. package/skills/tune-tmux/scripts/app-notes.mjs +38 -0
  72. package/skills/tune-tmux/scripts/marker-block.mjs +321 -0
  73. package/skills/tune-tmux/scripts/psmux-caps.json +71 -0
  74. package/skills/tune-tmux/scripts/tmux-config.mjs +251 -0
  75. package/skills/tune-tmux/scripts/tune-tmux.mjs +284 -0
  76. package/skills/ultra-chain/SKILL.md +52 -22
  77. package/skills/ultra-chain/references/chain-ledger.mjs +93 -11
  78. package/skills/ultra-chain/references/chain-report.mjs +13 -2
@@ -0,0 +1,321 @@
1
+ /**
2
+ * marker-block: puts one marked block of lines into a text file, replaces it or takes it out, and leaves every
3
+ * byte outside the block as it was.
4
+ *
5
+ * The canonical copy is skills/init-dir/scripts/marker-block.mjs. tune-tmux and git-init carry byte-identical
6
+ * copies, since Codex installs every skill folder on its own; scripts/sync-adapter.js mirrors and checks them.
7
+ * Edit the canonical copy only.
8
+ *
9
+ * Policy (one place for every skill that edits a user's file):
10
+ * - Markers are whole lines `<open> <id>:start <close>` and `<open> <id>:end <close>`, with the comment syntax
11
+ * given (`#` or `<!-- -->`). A marker inside a sentence, an unpaired marker, an end before a start or a second
12
+ * block is refused: nothing is cut on a guess.
13
+ * - Bytes outside the block stay as they were. CRLF files get CRLF blocks, a byte order mark stays in front.
14
+ * A file that is not UTF-8 is refused.
15
+ * - A folder, FIFO, socket or device at the name is refused without being read.
16
+ * - A symbolic link is refused (refused: "symlink") unless followSymlink is set; then the file it leads to is
17
+ * written and the link stays a link. A link to nothing is refused either way ("dangling").
18
+ * - A file with two or more hard links is refused ("hardlink"): the rename below would split them.
19
+ * - A file owned by another user is refused ("owner"). The permission bits of the file carry over to the new
20
+ * file. Windows has no owner or permission bits to check.
21
+ * - Writes go to a temp file in the same folder and a rename. A failed write removes the temp file.
22
+ * - When the bytes change, the first original is kept in `<file>.<skill>.bak`; an existing backup is never
23
+ * overwritten.
24
+ *
25
+ * Results are { ok, changed, refused?, message, path, file }. Callers map refused to exit status 1.
26
+ */
27
+ import {
28
+ chmodSync,
29
+ closeSync,
30
+ constants,
31
+ fstatSync,
32
+ lstatSync,
33
+ openSync,
34
+ readFileSync,
35
+ readlinkSync,
36
+ realpathSync,
37
+ renameSync,
38
+ rmSync,
39
+ statSync,
40
+ writeFileSync,
41
+ } from "node:fs";
42
+ import { basename, dirname, join, resolve } from "node:path";
43
+
44
+ /** Comment syntaxes. */
45
+ export const HTML = Object.freeze({ open: "<!--", close: "-->" });
46
+ export const HASH = Object.freeze({ open: "#", close: "" });
47
+
48
+ const BOM = "\u{FEFF}";
49
+ const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
50
+ const MAX_BYTES = 32 * 1024 * 1024;
51
+ const NEW_FILE_MODE = 0o644;
52
+ const BACKUP_MODE = 0o600;
53
+
54
+ /** The start and end marker lines for id in a comment syntax. */
55
+ export function markers(comment, id) {
56
+ const tail = comment.close ? ` ${comment.close}` : "";
57
+ return { start: `${comment.open} ${id}:start${tail}`, end: `${comment.open} ${id}:end${tail}` };
58
+ }
59
+
60
+ const refusal = (code, message, path) => ({ ok: false, changed: false, refused: code, message, path });
61
+ const bare = (line) => line.replace(/\r?\n$/, "").replace(/[ \t]+$/, "");
62
+ const eolOf = (text) => (text.includes("\r\n") ? "\r\n" : "\n");
63
+ const readlinkOr = (path) => {
64
+ try {
65
+ return readlinkSync(path);
66
+ } catch {
67
+ return "?";
68
+ }
69
+ };
70
+ const nativeReal = (path) => {
71
+ try {
72
+ return realpathSync.native(path);
73
+ } catch {
74
+ return resolve(path);
75
+ }
76
+ };
77
+ const realOrNull = (path) => {
78
+ try {
79
+ return realpathSync(path);
80
+ } catch {
81
+ return null;
82
+ }
83
+ };
84
+
85
+ // Where the bytes live: the path itself, or with followSymlink the file a link leads to.
86
+ function locate(path, follow) {
87
+ const lst = lstatSync(path, { throwIfNoEntry: false });
88
+ if (!lst) return { file: path, exists: false };
89
+ if (!lst.isSymbolicLink()) return { file: path, exists: true };
90
+ const target = readlinkOr(path);
91
+ if (!follow) return { refuse: refusal("symlink", `심볼릭 링크라 그대로 둠: ${path} -> ${target}`, path) };
92
+ const real = realOrNull(path);
93
+ if (real === null) return { refuse: refusal("dangling", `끊어진 링크라 그대로 둠: ${path} -> ${target}`, path) };
94
+ return { file: real, exists: true, link: path };
95
+ }
96
+
97
+ // Why the file at file may not be read or replaced, or null.
98
+ function fileProblem(st, file, uid) {
99
+ if (!st.isFile()) return refusal("special", `일반 파일이 아니라 그대로 둠: ${file}`, file);
100
+ if (st.nlink > 1) return refusal("hardlink", `하드 링크가 ${st.nlink}개라 그대로 둠: ${file}`, file);
101
+ if (uid !== undefined && st.uid !== uid) return refusal("owner", `다른 사용자 소유라 그대로 둠: ${file}`, file);
102
+ if (st.size > MAX_BYTES) return refusal("too-big", `32MiB 보다 커서 그대로 둠: ${file}`, file);
103
+ return null;
104
+ }
105
+
106
+ // The file's stat and bytes, opened without blocking so a FIFO planted after the look never waits. id: the same
107
+ // file's bigint stat for telling files apart (an NTFS file id passes 2^53, so as a Number two files can share one).
108
+ function readChecked(file, uid) {
109
+ const first = statSync(file);
110
+ const problem = fileProblem(first, file, uid);
111
+ if (problem) return { refuse: problem };
112
+ const fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
113
+ try {
114
+ const st = fstatSync(fd);
115
+ const late = fileProblem(st, file, uid);
116
+ return late ? { refuse: late } : { st, id: fstatSync(fd, { bigint: true }), bytes: readFileSync(fd) };
117
+ } finally {
118
+ closeSync(fd);
119
+ }
120
+ }
121
+
122
+ // What one line is: "start", "end", "inline" (a marker inside other text) or null.
123
+ function kindOf(line, mk) {
124
+ const b = bare(line);
125
+ if (b === mk.start) return "start";
126
+ if (b === mk.end) return "end";
127
+ return line.includes(mk.start) || line.includes(mk.end) ? "inline" : null;
128
+ }
129
+
130
+ // Takes line i of kind into state; returns why the markers are broken, or null.
131
+ function step(state, kind, i) {
132
+ if (kind === "inline") return `문장 안에 표지가 있음(${i + 1}행)`;
133
+ if (kind === "start") {
134
+ if (state.open !== -1 || state.found) return `표지 짝이 맞지 않거나 블록이 둘 이상(${i + 1}행)`;
135
+ state.open = i;
136
+ } else if (kind === "end") {
137
+ if (state.open === -1) return `끝 표지가 시작 표지보다 먼저(${i + 1}행)`;
138
+ state.found = { first: state.open, last: i };
139
+ state.open = -1;
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /** The block's first and last line in lines, null when there is none, or { error } when markers are broken. */
145
+ export function findBlock(lines, mk) {
146
+ const state = { open: -1, found: null };
147
+ for (let i = 0; i < lines.length; i++) {
148
+ const error = step(state, kindOf(lines[i], mk), i);
149
+ if (error) return { error };
150
+ }
151
+ if (state.open !== -1) return { error: `짝 없는 시작 표지(${state.open + 1}행)` };
152
+ return state.found;
153
+ }
154
+
155
+ // Whether line i sits inside an open wrap region (more wrap opens than closes before it).
156
+ function insideWrap(lines, i, wrap) {
157
+ let depth = 0;
158
+ for (let j = 0; j < i; j++) {
159
+ const b = bare(lines[j]);
160
+ if (b === wrap.open) depth++;
161
+ else if (b === wrap.close && depth > 0) depth--;
162
+ }
163
+ return depth > 0;
164
+ }
165
+
166
+ // The span [first, last] widened to wrap lines directly around it, and whether new text must bring its own wrap.
167
+ function spanOf(lines, found, wrap) {
168
+ if (!wrap) return { ...found, wrapNew: false };
169
+ const wrapped = found.first > 0 && bare(lines[found.first - 1]) === wrap.open && bare(lines[found.last + 1] ?? "") === wrap.close;
170
+ if (wrapped) return { first: found.first - 1, last: found.last + 1, wrapNew: true };
171
+ return { ...found, wrapNew: !insideWrap(lines, found.first, wrap) };
172
+ }
173
+
174
+ const blockText = (mk, body, wrap, eol) => {
175
+ const inner = [mk.start, ...body.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n"), mk.end];
176
+ return [...(wrap ? [wrap.open, ...inner, wrap.close] : inner), ""].join(eol);
177
+ };
178
+
179
+ // The text with the block put in, replaced or (body null) taken out. Appending puts one line break between the
180
+ // text and the block; taking a block out at the end of the file takes that break back, so add then remove gives
181
+ // the bytes from before.
182
+ function render(text, found, o) {
183
+ const lines = text.split(/(?<=\n)/);
184
+ const eol = eolOf(text);
185
+ if (!found) {
186
+ if (o.body === null) return text;
187
+ return text === "" ? blockText(o.mk, o.body, o.wrap, eol) : text + eol + blockText(o.mk, o.body, o.wrap, eol);
188
+ }
189
+ const span = spanOf(lines, found, o.wrap);
190
+ const before = lines.slice(0, span.first).join("");
191
+ const after = lines.slice(span.last + 1).join("");
192
+ if (o.body !== null) return before + blockText(o.mk, o.body, span.wrapNew ? o.wrap : null, eol) + after;
193
+ return after === "" ? before.replace(/\r?\n$/, "") : before + after;
194
+ }
195
+
196
+ function checkBody(o) {
197
+ if (o.body === null) return;
198
+ const forbidden = [o.mk.start, o.mk.end, ...(o.wrap ? [o.wrap.open, o.wrap.close] : [])];
199
+ if (o.body.split(/\r?\n/).some((line) => forbidden.includes(bare(line)))) throw new Error("block body holds a marker line");
200
+ }
201
+
202
+ // The new bytes for the old ones, or { refuse } when the old ones may not be edited.
203
+ function nextBytes(bytes, o, file) {
204
+ let text;
205
+ try {
206
+ text = UTF8.decode(bytes);
207
+ } catch {
208
+ return { refuse: refusal("encoding", `UTF-8 이 아니라 그대로 둠: ${file}`, file) };
209
+ }
210
+ const bom = text.startsWith(BOM) ? BOM : "";
211
+ const plain = text.slice(bom.length);
212
+ const found = findBlock(plain.split(/(?<=\n)/), o.mk);
213
+ if (found?.error) return { refuse: refusal("markers", `표지 손상이라 그대로 둠: ${file}: ${found.error}`, file) };
214
+ return { bytes: Buffer.from(bom + render(plain, found, o), "utf8") };
215
+ }
216
+
217
+ const options = (opts) => ({
218
+ mk: markers(opts.comment, opts.id),
219
+ body: opts.body ?? null,
220
+ wrap: opts.wrap ?? null,
221
+ skill: opts.skill,
222
+ follow: opts.followSymlink === true,
223
+ uid: "uid" in opts ? opts.uid : process.getuid?.(),
224
+ });
225
+
226
+ /**
227
+ * Works out what applyBlock would write, without writing: { ok, changed, refused?, message, path, file, key,
228
+ * before, after, mode }. key names the file (device and inode, or the path of a file not there yet).
229
+ */
230
+ export function planBlock(path, opts) {
231
+ const o = options(opts);
232
+ checkBody(o);
233
+ const at = locate(path, o.follow);
234
+ if (at.refuse) return at.refuse;
235
+ if (!at.exists) {
236
+ const after = o.body === null ? null : Buffer.from(blockText(o.mk, o.body, o.wrap, "\n"), "utf8");
237
+ return { ok: true, changed: after !== null, path, file: at.file, key: `path:${resolve(at.file)}`, before: null, after, mode: NEW_FILE_MODE };
238
+ }
239
+ const read = readChecked(at.file, o.uid);
240
+ if (read.refuse) return read.refuse;
241
+ const next = nextBytes(read.bytes, o, at.file);
242
+ if (next.refuse) return next.refuse;
243
+ const changed = !next.bytes.equals(read.bytes);
244
+ // ino 0 (a file system without file ids) tells nothing apart: then the path as the file system spells it is the
245
+ // key (realpathSync.native gives one spelling for names that differ only in case where case is ignored).
246
+ const key = read.id.ino === 0n ? `path:${nativeReal(at.file)}` : `ino:${read.id.dev}:${read.id.ino}`;
247
+ return { ok: true, changed, path, file: at.file, key, before: read.bytes, after: next.bytes, mode: read.st.mode & 0o7777 };
248
+ }
249
+
250
+ // Writes data to a fresh temp file beside file and renames it over file.
251
+ function writeAtomic(file, data, mode) {
252
+ const tmp = join(dirname(file), `.${basename(file)}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`);
253
+ let fd = openSync(tmp, "wx", mode);
254
+ try {
255
+ writeFileSync(fd, data);
256
+ closeSync(fd);
257
+ fd = null;
258
+ if (process.platform !== "win32") chmodSync(tmp, mode);
259
+ renameSync(tmp, file);
260
+ } catch (e) {
261
+ if (fd !== null) closeSync(fd);
262
+ rmSync(tmp, { force: true });
263
+ throw e;
264
+ }
265
+ }
266
+
267
+ // Keeps the first original: writes <file>.<skill>.bak only when nothing is at that name yet.
268
+ function backup(file, skill, bytes) {
269
+ const bak = `${file}.${skill}.bak`;
270
+ if (lstatSync(bak, { throwIfNoEntry: false })) return null;
271
+ const fd = openSync(bak, "wx", BACKUP_MODE);
272
+ try {
273
+ writeFileSync(fd, bytes);
274
+ } finally {
275
+ closeSync(fd);
276
+ }
277
+ return bak;
278
+ }
279
+
280
+ /** Writes what planBlock worked out. A plan that changes nothing or was refused is returned as a result. */
281
+ export function commitBlock(plan, skill) {
282
+ if (!plan.ok) return plan;
283
+ const result = { ok: true, changed: plan.changed, path: plan.path, file: plan.file };
284
+ if (!plan.changed) return { ...result, message: `변경 없음: ${plan.path}` };
285
+ if (plan.before === null && plan.after !== null) {
286
+ const fd = openSync(plan.file, "wx", plan.mode);
287
+ try {
288
+ writeFileSync(fd, plan.after);
289
+ } finally {
290
+ closeSync(fd);
291
+ }
292
+ return { ...result, created: true, message: `새로 만듦: ${plan.path}` };
293
+ }
294
+ const bak = backup(plan.file, skill, plan.before);
295
+ writeAtomic(plan.file, plan.after, plan.mode);
296
+ return { ...result, backup: bak, message: `바꿈: ${plan.path}` };
297
+ }
298
+
299
+ /** Puts in, replaces or (body null) takes out the block in one file. dryRun plans only. */
300
+ export function applyBlock(path, opts) {
301
+ const plan = planBlock(path, opts);
302
+ if (opts.dryRun || !plan.ok) return plan;
303
+ return commitBlock(plan, opts.skill);
304
+ }
305
+
306
+ /**
307
+ * Applies blocks to several files, each entry { path, ...opts }. Every file is planned first: one refusal and
308
+ * none is written. Two entries naming one file (after links are followed) write it once, by the first entry.
309
+ */
310
+ export function applyBlocks(entries, { dryRun = false } = {}) {
311
+ const plans = entries.map((e) => ({ entry: e, plan: planBlock(e.path, e) }));
312
+ const refused = plans.filter((p) => !p.plan.ok).map((p) => p.plan);
313
+ if (refused.length > 0) return { ok: false, refused, results: plans.map((p) => p.plan) };
314
+ const seen = new Map();
315
+ const results = plans.map(({ entry, plan }) => {
316
+ if (seen.has(plan.key)) return { ok: true, changed: false, path: plan.path, file: plan.file, sameAs: seen.get(plan.key), message: `같은 파일이라 한 번만 씀: ${plan.path}` };
317
+ seen.set(plan.key, plan.path);
318
+ return dryRun ? plan : commitBlock(plan, entry.skill);
319
+ });
320
+ return { ok: true, refused: [], results };
321
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * templates: the folders init-dir manages, the purpose of each, and the template files a newly made folder gets.
3
+ *
4
+ * The template sources live in ../templates/<folder>/. A few are renamed on the way out: gitignore becomes
5
+ * .gitignore (a real .gitignore in the skill would apply to the skill folder itself and is dropped by npm).
6
+ * .cmd files are written with CRLF line ends, .sh files with LF and mode 0755. Files are created with "wx", so
7
+ * an existing file is never overwritten.
8
+ */
9
+ import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ /** The managed folders, in the order they are listed. */
14
+ export const FOLDERS = ["logs", "scripts", "work", "mockup", "tests", "docs", "KB"];
15
+
16
+ /** One line per folder: what it is for and the rule to keep. */
17
+ export const PURPOSES = {
18
+ logs: "서비스 로그. 표준 출력은 `<이름>.out.log`, 표준 오류는 `<이름>.err.log` 로 나눠 저장. 100MB 마다 로테이션, 스트림마다 현재 파일 포함 최대 7개",
19
+ scripts:
20
+ "서비스 관리 스크립트. `./scripts/start.sh`, `./scripts/stop.sh`, `./scripts/status.sh`, `./scripts/restart.sh` 로 실행(Windows 는 같은 이름의 `.cmd`). 실행할 명령은 `scripts/service.conf` 의 `command=`",
21
+ work: "사용자와 상호작용하는 작업공간. 사용자가 코딩 에이전트에게 전달할 파일을 여기에 둠. 비밀값 금지",
22
+ mockup: "목업 파일을 관리하는 공간",
23
+ tests:
24
+ "단위 시험(`tests/unit/`)과 통합 시험(`tests/integration/`) 스크립트. `./tests/run.sh`(Windows `tests\\run.cmd`)가 스위트마다 한 줄로 결과를 간결하게 출력. 스위트 명령은 `tests/tests.conf`",
25
+ docs: "문서를 관리하는 영역. evidence와 ADR도 포함하여 보관한다.",
26
+ KB: "knowledge base. OMC 나 OMX 위키 밖에서 사용자가 알고 있어야 할 내용을 보관하는 2차 위키. 색인은 `KB/README.md`",
27
+ };
28
+
29
+ const TEMPLATE_DIR = fileURLToPath(new URL("../templates/", import.meta.url));
30
+ const SCRIPT_NAMES = ["start", "stop", "status", "restart"];
31
+
32
+ // What each folder gets: a source path under templates/<folder>/, or { to, from } to rename, or { to } for an
33
+ // empty file.
34
+ const FILES = {
35
+ logs: ["README.md", { from: "gitignore", to: ".gitignore" }],
36
+ scripts: [...SCRIPT_NAMES.map((n) => `${n}.sh`), ...SCRIPT_NAMES.map((n) => `${n}.cmd`), "service.conf", "lib/service.mjs", "README.md"],
37
+ work: ["README.md"],
38
+ mockup: ["README.md"],
39
+ tests: ["run.sh", "run.cmd", "run.mjs", "tests.conf", "README.md", { to: "unit/.gitkeep" }, { to: "integration/.gitkeep" }],
40
+ docs: ["README.md", { to: "evidence/.gitkeep" }, { to: "adr/.gitkeep" }],
41
+ KB: ["README.md"],
42
+ };
43
+
44
+ const crlf = (text) => text.replace(/\r?\n/g, "\r\n");
45
+ const lf = (text) => text.replace(/\r\n/g, "\n");
46
+
47
+ // The bytes a target file gets, line ends set by its kind.
48
+ function contentOf(folder, spec) {
49
+ if (spec.from === undefined) return Buffer.alloc(0);
50
+ const text = readFileSync(join(TEMPLATE_DIR, folder, ...spec.from.split("/")), "utf8");
51
+ return Buffer.from(spec.to.endsWith(".cmd") ? crlf(text) : lf(text), "utf8");
52
+ }
53
+
54
+ /** The template files of folder: [{ rel, data, mode }], rel relative to the project root with "/" separators. */
55
+ export function templateFiles(folder) {
56
+ const specs = FILES[folder];
57
+ if (!specs) throw new Error(`unknown folder: ${folder}`);
58
+ return specs.map((s) => {
59
+ const spec = typeof s === "string" ? { from: s, to: s } : s;
60
+ return { rel: `${folder}/${spec.to}`, data: contentOf(folder, spec), mode: spec.to.endsWith(".sh") ? 0o755 : 0o644 };
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Writes the template files of folder into dest (by default root/folder), never over an existing file. Returns the
66
+ * rel paths written. opts.failAt=<rel> makes it throw right after writing that file (a failure midway, for tests).
67
+ */
68
+ export function writeTemplates(root, folder, dest = join(root, folder), opts = {}) {
69
+ const written = [];
70
+ for (const f of templateFiles(folder)) {
71
+ const path = join(dest, ...f.rel.split("/").slice(1));
72
+ mkdirSync(dirname(path), { recursive: true });
73
+ const fd = openSync(path, "wx", f.mode);
74
+ try {
75
+ writeFileSync(fd, f.data);
76
+ } finally {
77
+ closeSync(fd);
78
+ }
79
+ if (process.platform !== "win32") chmodSync(path, f.mode);
80
+ written.push(f.rel);
81
+ if (opts.failAt === f.rel) throw new Error(`주입한 실패: ${f.rel}`);
82
+ }
83
+ return written;
84
+ }
85
+
86
+ /** The summary lines for the folders present, in FOLDERS order. */
87
+ export function summaryLines(present) {
88
+ return FOLDERS.filter((f) => present.includes(f)).map((f) => `- \`${f}/\`: ${PURPOSES[f]}`);
89
+ }
90
+
91
+ /** The instructions-file block body: the same bytes for the same folders present. */
92
+ export function blockBody(present) {
93
+ return [
94
+ "## 프로젝트 폴더 (banker init-dir)",
95
+ "",
96
+ "이 프로젝트의 관리 폴더와 목적. 해당하는 파일은 그 폴더에 둔다. 이 블록은 init-dir 이 다시 쓴다(직접 고치지 않음).",
97
+ "",
98
+ ...summaryLines(present),
99
+ ].join("\n");
100
+ }
@@ -0,0 +1,15 @@
1
+ # KB
2
+
3
+ knowledge base. OMC 나 OMX 위키 밖에서 사용자가 알고 있어야 할 내용을 두는 2차 위키. banker init-dir 이 만든 틀이다.
4
+
5
+ - 대상: 사용자가 직접 읽고 판단해야 하는 내용(운영 연락처 위치, 업무 규칙, 외부 시스템 사용법, 반복되는 결정의 배경)
6
+ - 에이전트용 지식은 OMC 위키(`.omc/wiki`)나 OMX 위키(`omx_wiki/`)에 둔다. 같은 내용을 두 곳에 쓰지 않는다
7
+ - 비밀값을 적지 않는다
8
+
9
+ ## 색인 규칙
10
+
11
+ - 페이지 하나에 주제 하나. 파일 이름은 `<주제>.md`(소문자, 하이픈)
12
+ - 새 페이지를 만들면 아래 색인에 한 줄을 더한다: `- [제목](파일.md): 한 줄 요약`
13
+ - 페이지 첫 줄은 `# 제목`, 둘째 단락에 마지막 확인 날짜
14
+
15
+ ## 색인
@@ -0,0 +1,14 @@
1
+ # docs
2
+
3
+ 프로젝트 문서를 관리하는 폴더. evidence 와 ADR 도 여기에 보관한다. banker init-dir 이 만든 틀이다.
4
+
5
+ | 폴더 | 둘 것 |
6
+ |---|---|
7
+ | `docs/` | 설계, 사용법, 운영 절차 문서 |
8
+ | `docs/evidence/` | evidence. 구현한 기능이 동작함을 다른 사람이 확인할 수 있는 근거(시험 출력, 실행 기록, 화면 캡처, 측정 결과) |
9
+ | `docs/adr/` | ADR(Architecture Decision Record). 결정 하나에 파일 하나. 맥락, 선택지, 결론과 그 이유 |
10
+
11
+ - 문서 하나에 주제 하나. 파일 이름은 주제를 드러내게 짓는다
12
+ - evidence 는 무엇을 언제 어떻게 확인했는지 남긴다(예: `2026-11-01-login-e2e.md`). 지어낸 결과를 쓰지 않는다
13
+ - ADR 파일 이름은 번호와 주제(예: `0001-db-choice.md`). 결정을 뒤집으면 새 ADR 을 쓰고, 옛 ADR 에 대체한 ADR 번호를 적는다
14
+ - banker progress-mon 의 ADR 탭은 이 폴더의 ADR 과 evidence 를 결정과 증거의 근거 출처로 참조한다
@@ -0,0 +1,14 @@
1
+ # logs
2
+
3
+ 서비스 로그 폴더. banker init-dir 이 만든 틀이다.
4
+
5
+ - 서비스마다 표준 출력과 표준 오류를 나눠 저장
6
+ - `<이름>.out.log`: 표준 출력
7
+ - `<이름>.err.log`: 표준 오류
8
+ - 로테이션: 쓰기 전에 "현재 크기 + 쓸 양" 이 100MB 를 넘으면 돌림
9
+ - `<이름>.out.log` 가 `<이름>.out.1.log` 로, `.1` 이 `.2` 로 밀림
10
+ - 현재 파일 포함 스트림마다 최대 7개. 가장 오래된 `.6` 은 지움
11
+ - 스트림당 최대 700MB
12
+ - `<이름>.pid`: 실행 중인 감독과 자식의 PID
13
+ - 로그를 쓰는 도구: `./scripts/start.sh`(Windows `scripts\start.cmd`)
14
+ - 이 폴더의 로그는 git 에 올리지 않음(`.gitignore`)
@@ -0,0 +1,3 @@
1
+ *
2
+ !.gitignore
3
+ !README.md
@@ -0,0 +1,7 @@
1
+ # mockup
2
+
3
+ 목업 파일을 관리하는 폴더. banker init-dir 이 만든 틀이다.
4
+
5
+ - 화면 목업, 와이어프레임, 시안 이미지, 정적 HTML 목업을 둔다
6
+ - 파일 이름에 화면 이름과 판을 적는다(예: `login-v2.html`)
7
+ - 승인된 목업은 문서(`docs/`)나 구현 이슈에서 이 경로로 가리킨다
@@ -0,0 +1,39 @@
1
+ # scripts
2
+
3
+ 서비스 관리 스크립트. banker init-dir 이 만든 틀이다.
4
+
5
+ | 명령 | Windows | 하는 일 |
6
+ |---|---|---|
7
+ | `./scripts/start.sh` | `scripts\start.cmd` | `service.conf` 의 `command` 를 감독 프로세스로 시작 |
8
+ | `./scripts/stop.sh` | `scripts\stop.cmd` | 서비스와 감독 프로세스를 멈춤 |
9
+ | `./scripts/status.sh` | `scripts\status.cmd` | 감독과 자식의 실행 여부, 로그 크기를 따로 보고 |
10
+ | `./scripts/restart.sh` | `scripts\restart.cmd` | stop 뒤 start |
11
+
12
+ - 설정: `service.conf` 의 `name=`(기본 `app`), `command=`(한 줄)
13
+ - 로그: `logs/<name>.out.log`(표준 출력), `logs/<name>.err.log`(표준 오류). 100MB 마다 로테이션, 스트림마다 현재 파일 포함 최대 7개
14
+ - PID 파일: `logs/<name>.pid`(감독 PID, 자식 PID, 시작 시각, 자식 명령)
15
+ - 필요: Node 18 이상. 없으면 종료 3. 다른 Node 를 쓰려면 환경 변수 `NODE` 에 경로
16
+ - 종료 코드: 0 성공, 1 실패(시작 2초 안에 멈춤, 멈추지 못함, status 에서 실행 중이 아님), 2 설정 없음, 3 Node 없음
17
+
18
+ ## 동작
19
+
20
+ - `start` 는 분리된 감독 프로세스를 띄운다. 감독이 `command` 를 자식으로 실행하고 출력을 로그 파일에 쓴다
21
+ - `stop` 은 감독에 SIGTERM 을 보낸다. 감독이 자식의 프로세스 그룹에 넘기고, 10초 뒤에도 살아 있으면 강제로 끝낸다. Windows 는 `taskkill /T`, 10초 뒤 `taskkill /T /F`
22
+ - 감독이 죽으면 자식은 다음 출력 때 SIGPIPE 로 죽을 수 있다. `status` 는 감독과 자식을 따로 보고한다. 감독 없이 자식만 남았으면 `stop` 이 자식을 끝낸다
23
+ - Windows 는 감독이 죽으면 셸(cmd.exe)도 함께 끝나고, 셸이 띄운 프로그램은 남는다. 부모가 기록된 셸 PID 이고 명령줄에 `command=` 가 들어 있으면 그 프로그램을 자식으로 보고 `status`, `stop` 이 다룬다
24
+ - PID 파일의 PID 는 확인한 뒤에만 이 서비스로 본다. 시작 시각이 마지막 부팅보다 이르면 낡은 파일이다. 명령줄에 `service.mjs` 와 `__supervise`(감독) 또는 기록된 `command`(자식)가 없으면 다른 프로세스다. 명령줄은 Linux 는 `/proc/<pid>/cmdline` 다음 `ps`, 그 밖의 POSIX 는 `ps`, Windows 는 PowerShell `Get-CimInstance` 로 읽는다. 다른 프로세스로 판정한 PID 에는 신호를 보내지 않고 사유를 출력한다
25
+ - 살아 있는 PID 의 명령줄을 끝내 읽지 못하면(`/proc` 도 `ps` 도 없음) start, stop, status, restart 는 `확인 불가: <사유>` 와 종료 1. 신호를 보내지 않고 PID 파일을 남긴다
26
+ - `start` 를 동시에 여러 번 실행해도 감독은 하나만 뜬다. `start` 는 `logs/<name>.start.lock` 을 새 파일로만 만들어(자기 PID 와 시각을 씀) 잡고, 끝나면 지운다. 잠금이 있으면 최대 `SERVICE_START_CHECK_MS` + 5초 기다린 뒤 이어서 진행하므로 나중 실행은 `이미 실행 중` 을 보고한다
27
+ - 잠금의 PID 가 죽었거나 다른 프로그램이거나, 잠금이 30초 + `SERVICE_START_CHECK_MS` 보다 오래되면 낡은 잠금으로 보고 지운 뒤 다시 잡는다
28
+ - 낡은 잠금은 한 번에 `start` 하나만 지운다. `logs/<name>.start.lock.break` 를 새 파일로만 만든 `start` 가 잠금을 다시 읽어, 낡다고 본 그 잠금일 때만 지운다. 그 사이 다른 `start` 가 새로 잡은 잠금은 지우지 않는다. `.break` 가 5초 넘게 남으면 다음 `start` 가 지운다. 정리는 보통 수 ms 라 정리하던 `start` 가 죽었거나 멈춘 것으로 본다
29
+ - Windows 에서 지우는 중인 잠금이나 `.break` 가 내는 EPERM, EACCES, EBUSY 는 실패가 아니라 바쁨으로 보고 다시 시도한다(기다림 상한은 같다)
30
+ - 잠금이나 `.break` 자리에 링크나 폴더가 있으면 거부하고 종료 1
31
+ - 로테이션이 실패하면(Windows 에서 다른 프로그램이 로그를 연 경우 등) 표준 오류 로그에 `service:` 경고 한 줄을 쓰고 현재 파일에 이어 쓴다
32
+
33
+ ## 조정용 환경 변수
34
+
35
+ | 변수 | 기본 | 뜻 |
36
+ |---|---|---|
37
+ | `SERVICE_LOG_ROTATE_BYTES` | 104857600 | 로테이션 기준 바이트 |
38
+ | `SERVICE_STOP_GRACE_MS` | 10000 | 강제 종료 전 기다리는 시간 |
39
+ | `SERVICE_START_CHECK_MS` | 2000 | 시작 뒤 살아 있는지 확인하기까지 기다리는 시간. 시작 잠금의 대기와 낡음 기준도 이만큼 늘어남 |