@kaydash9999/banker-plugins 0.15.1 → 0.16.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.
Files changed (43) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +60 -0
  4. package/README.md +31 -9
  5. package/codex/manifest.json +6 -3
  6. package/codex/transform-matrix.md +2 -0
  7. package/commands/progress.md +10 -0
  8. package/commands/setup.md +1 -1
  9. package/hooks/graceful-pause.mjs +31 -71
  10. package/hooks/hooks.json +11 -1
  11. package/hooks/progress-fallback.mjs +10 -0
  12. package/hooks/progress.mjs +427 -0
  13. package/hooks/register.mjs +82 -5
  14. package/package.json +1 -1
  15. package/skills/3d-intro-build/SKILL.md +235 -122
  16. package/skills/3d-intro-build/references/NOTICE.md +2 -1
  17. package/skills/3d-intro-build/references/assemble.mjs +164 -108
  18. package/skills/3d-intro-build/references/azure-adapter.mjs +115 -29
  19. package/skills/3d-intro-build/references/curate.mjs +573 -267
  20. package/skills/3d-intro-build/references/intro-fixes.css +61 -0
  21. package/skills/3d-intro-build/references/panel-glass.css +55 -0
  22. package/skills/3d-intro-build/references/preview-lib.mjs +256 -0
  23. package/skills/3d-intro-build/references/prompts.md +32 -9
  24. package/skills/3d-intro-build/references/serve.mjs +111 -103
  25. package/skills/3d-intro-build/references/step-nav.css +59 -0
  26. package/skills/3d-intro-build/references/step-nav.js +503 -0
  27. package/skills/3d-intro-build/references/verify-intro.mjs +474 -0
  28. package/skills/3d-intro-build/references/verify.md +45 -0
  29. package/skills/3d-intro-build/references/video-pool.mjs +780 -0
  30. package/skills/3d-intro-setup/SKILL.md +49 -12
  31. package/skills/3d-intro-setup/references/azure-adapter.mjs +115 -29
  32. package/skills/3d-intro-setup/references/video-pool.mjs +780 -0
  33. package/skills/lineage/SKILL.md +12 -8
  34. package/skills/lineage/lineage.py +190 -59
  35. package/skills/motion-graphic-make/SKILL.md +73 -28
  36. package/skills/motion-graphic-make/references/curate.mjs +607 -0
  37. package/skills/motion-graphic-make/references/preview-lib.mjs +256 -0
  38. package/skills/motion-graphic-make/references/serve.mjs +131 -0
  39. package/skills/omc-patch/SKILL.md +143 -0
  40. package/skills/omc-patch/scripts/omc-patch.mjs +577 -0
  41. package/skills/remains/SKILL.md +125 -0
  42. package/skills/remains/scripts/boxes.mjs +448 -0
  43. package/skills/trouble-shooting/SKILL.md +50 -0
@@ -0,0 +1,125 @@
1
+ ---
2
+ name: remains
3
+ description: "남은 버그, 하자, 미검증 항목을 찾아 표로 보여 줌. 테스트박스가 등록돼 있으면 각 OS 에서 시험을 돌려 확인함. 읽기와 시험 실행만 하고 고치지 않음. 'remains'/'남은 버그'/'남은 하자'/'미검증 항목'/'뭐가 남았어' 시 사용."
4
+ argument-hint: "[기준 커밋] [--no-boxes]"
5
+ ---
6
+
7
+ # remains - 남은 버그, 하자, 미검증 항목 표
8
+
9
+ 지금 작업에 남은 버그, 하자, 미검증 항목을 찾아 표 하나로 보여 준다. 테스트박스가 등록돼 있으면 같은 시험을 각 OS 에서 돌려 미검증을 줄인다.
10
+ 읽기와 시험 실행만 한다. 답변은 한글.
11
+
12
+ ## 원칙
13
+
14
+ - 고치지 않는다. 코드 수정, 커밋, push, 의존성 설치, 유료 API 호출을 하지 않는다. 무엇을 고칠지는 사용자가 표를 보고 정한다.
15
+ - 확인한 것과 확인하지 못한 것을 섞지 않는다. 돌려 보지 않은 것은 통과로 적지 않고 미검증으로 적는다.
16
+ - 박스가 없거나 접속되지 않으면 다른 원격 실행(CI 를 돌리려는 push 등)으로 대신하지 않는다. 그 OS 는 미검증으로 남긴다.
17
+ - 표와 근거에 비밀값(키, 토큰, 비밀번호, 크레덴셜 파일 내용)을 싣지 않는다.
18
+
19
+ ## 1. 범위
20
+
21
+ - 대상은 현재 저장소다(`git rev-parse --show-toplevel`). 저장소가 아니면 현재 폴더와 이번 대화의 작업만 본다.
22
+ - 기준점은 인자로 준 커밋이다. 없으면 마지막 태그(`git describe --tags --abbrev=0`), 그것도 없으면 기본 브랜치와의 merge-base 를 쓴다.
23
+ - 기준점 이후의 커밋, 커밋하지 않은 변경, push 하지 않은 커밋을 모두 범위에 넣는다.
24
+
25
+ ## 2. 남은 항목 모으기 (읽기만)
26
+
27
+ | 출처 | 보는 것 |
28
+ |---|---|
29
+ | 이번 대화 | 미검증이나 보류로 남긴 것, 검토의 비차단 지적, 사용자가 미룬 결정, 보고했지만 확인하지 않은 주장 |
30
+ | 저장소 기록 | 인계 문서(예: `.omc/handoffs/`), CHANGELOG `[Unreleased]` 의 `UNVALIDATED` 나 미검증 표기, 문서의 미검증 절 |
31
+ | 코드 표시 | 범위 안에서 바뀐 파일의 `TODO`, `FIXME`, `XXX`, `HACK`, 시험의 `skip`, `only`, `todo` |
32
+ | git 상태 | 커밋하지 않은 변경, push 하지 않은 커밋, 추적하지 않는 새 파일 |
33
+ | 메모리와 노트 | 있으면 그 안의 남은 지적 |
34
+
35
+ ## 3. 이 머신에서 시험
36
+
37
+ - 프로젝트의 시험 명령을 찾는다. `package.json` 의 `scripts.test`, CI 워크플로의 시험 단계, `Makefile`, pytest 설정, `*.test.mjs`(node:test) 순으로 본다. 찾은 명령과 그 근거를 확인 범위에 적는다.
38
+ - 의존성을 설치하지 않는다. 의존성이 없어 못 돌리면 미검증으로 적는다.
39
+ - 실패한 시험은 이름과 첫 오류 줄을 근거로 남긴다. 건너뛴 시험은 건너뛴 이유를 확인하고, 환경 때문이면 미검증으로 적는다.
40
+ - 프로젝트에 설정되고 이미 설치된 린트, 형식 검사, 타입 검사가 있으면 함께 돌린다. 검사만 하는 방식(`--check` 등)으로 돌리고, 파일을 고치는 `--write`, `--fix` 는 쓰지 않는다.
41
+
42
+ ## 4. 테스트박스 (등록돼 있으면)
43
+
44
+ 박스 작업은 이 스킬 폴더의 `scripts/boxes.mjs` 가 한다(Codex 에서는 `~/.codex/skills/banker-remains/scripts/boxes.mjs`). Node 만 쓰고, 이 머신에 `ssh`, `scp`, `git`, 박스에 `tar` 가 있어야 한다. `--no-boxes` 를 받으면 이 단계를 건너뛰고 확인 범위에 그렇게 적는다.
45
+
46
+ 1. `node <스킬 폴더>/scripts/boxes.mjs list` 로 등록을 본다. 등록 파일이 없으면 확인 범위에 "테스트박스 미등록" 이라 적고 넘어간다.
47
+ 2. `node <스킬 폴더>/scripts/boxes.mjs probe` 로 접속을 확인한다.
48
+ - 접속되지 않는 박스는 그 OS 를 미검증으로 적고, ssh 가 낸 이유를 근거로 둔다.
49
+ - 시험에 필요한 런타임이 없는 박스(예: `--node-test` 인데 `node` 가 `null`)는 `--box` 로 빼고 미검증으로 적는다.
50
+ 3. 같은 시험을 모든 박스에서 동시에 돌린다. 저장소 루트에서 실행한다.
51
+ - node:test 프로젝트: `node <스킬 폴더>/scripts/boxes.mjs run --node-test --ref worktree`
52
+ - 그 밖: `node <스킬 폴더>/scripts/boxes.mjs run --cmd "<시험 명령>" --ref worktree`
53
+ - Claude Code 에서는 Bash 도구의 시간 제한이 시험보다 짧을 수 있으므로 백그라운드로 실행한다.
54
+ - `--node-test` 는 `*.test.mjs` 만 돌린다. 다른 종류의 시험(Python, `.test.ts` 등)은 `--cmd` 로 따로 돌리거나 그 OS 에서 미검증으로 적는다.
55
+ - 보내는 것은 추적하는 파일의 커밋된 내용 그대로다(줄 끝 변환 없음). `--ref worktree` 는 커밋하지 않은 추적 파일의 변경까지 보낸다. 추적하지 않는 새 파일은 보내지 않는다(출력의 `untracked`). 그 파일이 시험에 필요하면 미검증으로 적는다.
56
+ - Windows 박스에서는 명령이 `.cmd` 파일 안에서 돈다. 명령이 OS 마다 달라야 하면 `--box <이름>` 으로 나눠 돌린다.
57
+ - 오래 걸리면 `--timeout <분>`(기본 20)을 늘린다.
58
+ 4. 출력 JSON 의 박스별 `summary`(tests, pass, fail, skipped, failures)와 `log` 파일을 근거로 쓴다. 종료 코드는 다음과 같다.
59
+ - 0: 모든 박스가 통과
60
+ - 1: 접속 실패, 시험 실패, 시간 초과, 시험 0건(`noTests`), 정리 실패(`cleaned: false`) 중 하나가 있음
61
+ - 2: 사용법이나 등록 파일 오류
62
+ - 130, 143: SIGINT, SIGTERM 으로 멈춤(정리는 마침)
63
+ 5. 박스의 실패를 바로 버그로 적지 않는다. 먼저 로그로 원인을 구분한다.
64
+ - 명령을 찾지 못한 종료 코드(Linux, macOS 는 127, Windows 는 9009)는 그 박스에 런타임이 없다는 뜻이라 미검증이다.
65
+ - 97(보낸 트리로 들어가지 못함)은 도구나 박스 문제라 미검증으로 적고 이유를 남긴다.
66
+ - `noTests` 는 시험이 하나도 돌지 않았다는 뜻이라 통과가 아니라 미검증이다.
67
+ - 한 OS 에서만 실패하면, 제품의 결함은 버그로, 시험이 실행 환경을 잘못 가정한 것은 하자로 적는다.
68
+
69
+ 박스에 둔 것은 `run` 이 끝날 때 지운다. 시간 초과, 연결 끊김, 중단(SIGINT, SIGTERM) 때는 박스에서 시작한 프로세스를 먼저 끝낸다. Linux, macOS 는 `mktemp -d` 로 만든 개인 폴더를 쓴다. 지우지 못하면 결과의 `cleaned` 가 `false` 이고, 그 박스를 하자로 적는다.
70
+ 알려진 한계: 이중 fork 로 프로세스 트리를 벗어난 프로세스는 끝나지 않는다. `pgrep` 이 없는 박스에서는 기록된 프로세스 하나만 끝난다.
71
+
72
+ ### 등록 파일
73
+
74
+ `~/.config/banker/test-boxes.json` 이다(`BANKER_TEST_BOXES` 로 다른 파일을 가리킬 수 있다).
75
+
76
+ ```json
77
+ {
78
+ "boxes": [
79
+ { "name": "macos", "os": "darwin", "ssh": "user@mac-host", "path": ["/usr/local/bin"] },
80
+ { "name": "windows", "os": "win32", "ssh": "user@windows-host" },
81
+ { "name": "linux", "os": "linux", "ssh": "root@linux-host", "runAs": "nobody" }
82
+ ]
83
+ }
84
+ ```
85
+
86
+ | 항목 | 뜻 |
87
+ |---|---|
88
+ | `name` | 박스 이름(영문이나 숫자로 시작, 영문, 숫자, `.`, `_`, `-`) |
89
+ | `os` | `linux`, `darwin`, `win32` 중 하나 |
90
+ | `ssh` | `user@host`. 암호 없이 SSH 키로 접속돼야 한다 |
91
+ | `port`, `identity` | 선택. SSH 포트와 키 파일 경로 |
92
+ | `path` | 선택. Linux, macOS 박스에서 PATH 앞에 붙일 절대 경로(비대화형 SSH 의 PATH 에 node 가 없을 때) |
93
+ | `runAs` | 선택. root 로 접속하는 Linux 박스에서 시험을 대신 돌릴 계정(예: `nobody`) |
94
+
95
+ - 비밀번호나 토큰 칸은 받지 않는다. 모르는 칸이 있으면 오타로 보고 멈춘다.
96
+ - 파일은 사용자가 만든다. 사용자가 박스 정보를 주면, 이 형식으로 만들기 전에 내용을 보여 주고 확인받는다.
97
+ - 처음 접속하는 박스의 호스트 키는 `~/.ssh/known_hosts` 에 더해진다(`StrictHostKeyChecking=accept-new`). 이미 아는 박스의 키가 바뀌면 접속하지 않는다.
98
+
99
+ ## 5. 분류
100
+
101
+ | 종류 | 뜻 | 예 |
102
+ |---|---|---|
103
+ | 버그 | 동작이 틀렸고 재현된다 | 실패한 시험, 틀린 결과, 한 OS 에서만 나는 오류 |
104
+ | 하자 | 동작은 하지만 품질에 문제가 있다 | 규칙 위반, 문서와 코드의 불일치, 결함을 못 잡는 시험, 남은 임시 파일 |
105
+ | 미검증 | 확인하지 못했다 | 박스가 없는 OS, 실제 외부 API, 손으로만 하는 단계, 돌리지 못한 시험 |
106
+
107
+ - 심각도는 세 단계다.
108
+ - 높음: 데이터 손실, 보안, 사용자 작업이 막힘
109
+ - 중간: 기능 일부가 틀리지만 우회할 수 있음
110
+ - 낮음: 불편, 문서, 정리
111
+
112
+ ## 6. 출력
113
+
114
+ 1. 첫 줄: 종류별 남은 항목 수와 가장 심각한 항목.
115
+ 2. 확인 범위: 기준점, 돌린 시험과 결과(이 머신과 박스별 통과, 실패, 건너뜀), 확인하지 못한 OS.
116
+ 3. 표:
117
+
118
+ | # | 항목 | 종류 | 심각도 | 근거 | 확인한 곳 | 다음 조치 |
119
+ |---|---|---|---|---|---|---|
120
+ | 1 | 무엇이 남았나 | 버그 | 높음 | `파일:줄`, 실패한 시험 이름, 명령 | Windows 박스 | 무엇을 하면 닫히나 |
121
+
122
+ - 근거는 다시 확인할 수 있게 쓴다(파일과 줄, 명령, 시험 이름, 로그 경로).
123
+ - 심각도가 높은 것부터 놓는다.
124
+ - 남은 항목이 없으면 표 대신 "남은 항목 없음" 과 확인 범위를 쓴다.
125
+ - 마지막 줄에는 사용자가 고를 다음 행동을 쓴다(예: 고칠 항목 고르기). 고치는 일은 요청을 받은 뒤에 한다.
@@ -0,0 +1,448 @@
1
+ #!/usr/bin/env node
2
+ // Test boxes for /banker:remains: list them, check that they answer, and run a project's tests on them.
3
+ // Pure Node, no dependencies. This machine needs ssh, scp and git; a box needs tar (Windows 10 1803+ has it).
4
+ //
5
+ // node boxes.mjs list
6
+ // node boxes.mjs probe [--box <name>]...
7
+ // node boxes.mjs run (--node-test | --cmd "<command>") [--ref <commit>|worktree] [--box <name>]... [--timeout <min>] [--out <dir>]
8
+ //
9
+ // Boxes are registered in ~/.config/banker/test-boxes.json, or the file BANKER_TEST_BOXES names. Logins are
10
+ // SSH keys only: a password field is refused. `run` sends the tracked files of --ref (default HEAD; `worktree`
11
+ // adds uncommitted changes to tracked files) and runs the command on every box at once. A Linux or macOS box
12
+ // works in a private `mktemp -d` folder. When a run times out, loses its connection or is stopped (SIGINT,
13
+ // SIGTERM), what it started on the box is ended before the folder is removed; each box reports `cleaned`.
14
+ import { spawn, spawnSync } from "node:child_process";
15
+ import { randomBytes } from "node:crypto";
16
+ import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
17
+ import { homedir, tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ const OSES = new Set(["linux", "darwin", "win32"]);
22
+ const FIELDS = new Set(["name", "os", "ssh", "port", "identity", "path", "runAs"]);
23
+ const SECRETISH = /pass|secret|token|credential/i;
24
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
25
+ const TARGET = /^[A-Za-z0-9][A-Za-z0-9._-]*@[A-Za-z0-9][A-Za-z0-9.-]*$/;
26
+ const REMOTE_DIR = /^\/tmp\/banker-remains\.[A-Za-z0-9]+$/;
27
+ // What ssh and scp print about the connection itself, as opposed to what the remote command prints.
28
+ const SSH_ERR = /^(ssh|scp): |Connection (closed|refused|reset|timed out)|Host key verification failed|Permission denied \(|Could not resolve hostname|No route to host|Broken pipe/m;
29
+ const UPLOAD_MS = 10 * 60 * 1000;
30
+ const CLEANUP_MS = 2 * 60 * 1000;
31
+ const PROBE_MS = 30 * 1000;
32
+ const MAX_OUTPUT = 50 * 1024 * 1024;
33
+ const BASE = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=accept-new"];
34
+ const USAGE = `usage:
35
+ node boxes.mjs list
36
+ node boxes.mjs probe [--box <name>]...
37
+ node boxes.mjs run (--node-test | --cmd "<command>") [--ref <commit>|worktree] [--box <name>]... [--timeout <min>] [--out <dir>]
38
+ `;
39
+ const EXAMPLE = { boxes: [
40
+ { name: "macos", os: "darwin", ssh: "user@mac-host", path: ["/usr/local/bin"] },
41
+ { name: "windows", os: "win32", ssh: "user@windows-host" },
42
+ { name: "linux", os: "linux", ssh: "root@linux-host", runAs: "nobody" },
43
+ ] };
44
+
45
+ export const configFile = () => process.env.BANKER_TEST_BOXES || join(homedir(), ".config", "banker", "test-boxes.json");
46
+
47
+ // ── registration ─────────────────────────────────────────────────────────────
48
+
49
+ const OPTIONAL = [
50
+ ["port", (v) => Number.isInteger(v) && v > 0 && v < 65536, "port 는 1~65535 정수입니다"],
51
+ ["identity", (v) => typeof v === "string" && v.length > 0, "identity 는 키 파일 경로입니다"],
52
+ ["path", (v, b) => b.os !== "win32" && Array.isArray(v) && v.every((p) => /^\/[\w./+-]*$/.test(String(p))),
53
+ "path 는 Linux, macOS 박스에서만 쓰는 절대 경로 목록입니다"],
54
+ ["runAs", (v, b) => b.os === "linux" && String(b.ssh).startsWith("root@") && /^[a-z_][a-z0-9_-]*$/.test(String(v)),
55
+ "runAs 는 root 로 접속하는 Linux 박스에서 시험을 대신 돌릴 계정 이름입니다"],
56
+ ];
57
+
58
+ function checkShape(b, where) {
59
+ if (!b || typeof b !== "object" || Array.isArray(b)) return `${where}: 객체가 아닙니다`;
60
+ const extra = Object.keys(b).filter((k) => !FIELDS.has(k));
61
+ if (extra.some((k) => SECRETISH.test(k))) return `${where}: 비밀번호나 토큰은 적지 않습니다(SSH 키 접속만 지원): ${extra.join(", ")}`;
62
+ if (extra.length) return `${where}: 모르는 항목 ${extra.join(", ")} (쓸 수 있는 항목: ${[...FIELDS].join(", ")})`;
63
+ return null;
64
+ }
65
+
66
+ function checkRequired(b, where) {
67
+ if (!NAME.test(String(b.name ?? ""))) return `${where}: name 은 영문이나 숫자로 시작하고 영문, 숫자, '.', '_', '-' 만 씁니다`;
68
+ if (!OSES.has(b.os)) return `${where}: os 는 linux, darwin, win32 중 하나입니다`;
69
+ if (!TARGET.test(String(b.ssh ?? ""))) return `${where}: ssh 는 user@host 형식이고 영문이나 숫자로 시작합니다`;
70
+ return null;
71
+ }
72
+
73
+ function checkOptional(b, where) {
74
+ const bad = OPTIONAL.find(([k, ok]) => b[k] !== undefined && !ok(b[k], b));
75
+ return bad ? `${where}: ${bad[2]}` : null;
76
+ }
77
+
78
+ // The reason a registered box is unusable, or null.
79
+ export function checkBox(b, i) {
80
+ const where = `boxes[${i}]`;
81
+ return checkShape(b, where) || checkRequired(b, where) || checkOptional(b, where);
82
+ }
83
+
84
+ // No file means no boxes. A file that is there but wrong stops with the reason, so a typo is never ignored.
85
+ export function loadBoxes(file = configFile()) {
86
+ if (!existsSync(file)) return { file, missing: true, boxes: [] };
87
+ let data;
88
+ try {
89
+ data = JSON.parse(readFileSync(file, "utf8"));
90
+ } catch (e) {
91
+ throw new Error(`${file}: JSON 이 아닙니다 (${e.message})`);
92
+ }
93
+ if (!Array.isArray(data?.boxes)) throw new Error(`${file}: "boxes" 배열이 없습니다`);
94
+ const seen = new Set();
95
+ data.boxes.forEach((b, i) => {
96
+ const why = checkBox(b, i) || (seen.has(b.name) ? `boxes[${i}]: 이름 ${b.name} 이 두 번 나옵니다` : null);
97
+ if (why) throw new Error(`${file}: ${why}`);
98
+ seen.add(b.name);
99
+ });
100
+ return { file, missing: false, boxes: data.boxes };
101
+ }
102
+
103
+ // ── commands for a box ───────────────────────────────────────────────────────
104
+
105
+ const extras = (b, portFlag) => [...(b.port ? [portFlag, String(b.port)] : []), ...(b.identity ? ["-i", b.identity] : [])];
106
+ export const sshBase = (b) => [...BASE, ...extras(b, "-p")];
107
+ export const scpBase = (b) => [...BASE, ...extras(b, "-P")];
108
+
109
+ const shq = (s) => `'${String(s).replace(/'/g, "'\\''")}'`;
110
+ const pathPrefix = (b) => (b.path?.length ? `PATH=${b.path.map(shq).join(":")}:"$PATH"; ` : "");
111
+ // Linux and macOS steps go through sh, whatever the login shell is (zsh does not split $L).
112
+ const viaSh = (script) => `sh -c ${shq(script)}`;
113
+ const encodePs = (script) => Buffer.from(script, "utf16le").toString("base64");
114
+
115
+ export const nodeTestCommand = (os, files) =>
116
+ ["node --test --test-reporter=tap", ...files.map((f) => (os === "win32" ? `"${f}"` : shq(f)))].join(" ");
117
+
118
+ // The script that runs on the box from the top of the sent tree. On Linux and macOS it first leaves its process
119
+ // id beside itself, so a stopped run can end everything it started. On Windows it is a .cmd file: `call` returns
120
+ // from a batch command such as npm, and the command stays out of the ssh and cmd /c quoting.
121
+ export function runScript(b, prefix, command) {
122
+ if (b.os === "win32") {
123
+ const lines = ["@echo off", `cd /d "%~dp0${prefix}" || exit /b 97`, `call ${command}`, "exit /b %ERRORLEVEL%"];
124
+ return { ext: ".cmd", text: lines.map((l) => `${l}\r\n`).join("") };
125
+ }
126
+ const path = b.path?.length ? [`${pathPrefix(b)}export PATH`] : [];
127
+ const lines = ["#!/bin/sh", 'echo $$ > "$(dirname "$0")/pid"', `cd "$(dirname "$0")/${prefix}" || exit 97`, ...path, command];
128
+ return { ext: ".sh", text: `${lines.join("\n")}\n` };
129
+ }
130
+
131
+ // Windows sessions start in the profile folder, which only the account can write, so the run uses relative
132
+ // names there and spaces in the profile path do not matter. Ending a run finds the processes whose command
133
+ // line runs this box's run.cmd (another box's longer name does not match) and ends each one's tree.
134
+ function winSteps(b, name) {
135
+ const stop = `$ProgressPreference = 'SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*${name}\\run.cmd*' -and $_.ProcessId -ne $PID } | ForEach-Object { taskkill /F /T /PID $_.ProcessId | Out-Null }; `
136
+ + `Remove-Item -Recurse -Force -ErrorAction SilentlyContinue '${name}', '${name}.tar', '${name}.cmd'; if (Test-Path '${name}') { exit 1 } else { exit 0 }`;
137
+ return {
138
+ upload: `${b.ssh}:`,
139
+ run: `cmd /c "mkdir ${name} && tar -xf ${name}.tar -C ${name} && del ${name}.tar && move /y ${name}.cmd ${name}\\run.cmd >nul && ${name}\\run.cmd"`,
140
+ cleanup: `cmd /c "rmdir /s /q ${name} 2>nul & del /q ${name}.tar ${name}.cmd 2>nul & if exist ${name} (exit /b 1) else (exit /b 0)"`,
141
+ abort: `powershell -NoProfile -NonInteractive -EncodedCommand ${encodePs(stop)}`,
142
+ };
143
+ }
144
+
145
+ // Linux and macOS work in the private folder `mktemp -d` made. A root login hands the run to runAs, with a
146
+ // HOME of its own. Ending a run takes the process tree under the recorded id first, then the folder.
147
+ function posixSteps(b, name, dir) {
148
+ const go = b.runAs
149
+ ? `mkdir -p "$D/home" && chown -R ${b.runAs} "$D" && runuser -u ${b.runAs} -- env HOME="$D/home" sh "$D/run.sh"`
150
+ : 'sh "$D/run.sh"';
151
+ const tree = 'L=; w() { L="$L $1"; for c in $(pgrep -P "$1"); do w "$c"; done; }';
152
+ return {
153
+ upload: `${b.ssh}:${dir}/`,
154
+ run: viaSh(`D=${dir}; cd "$D" && tar -xf ${name}.tar && rm -f ${name}.tar && mv ${name}.sh run.sh && ${go}`),
155
+ cleanup: viaSh(`rm -rf ${dir} && [ ! -e ${dir} ]`),
156
+ // The recorded id is used only while it is still this run's sh: the file may be the run account's, and ids get reused.
157
+ abort: viaSh(`D=${dir}; P=$(cat "$D/pid" 2>/dev/null); case "$P" in ''|*[!0-9]*) P=;; esac; `
158
+ + `if [ -n "$P" ] && ps -o args= -p "$P" 2>/dev/null | grep -qF "$D/run.sh"; then ${tree}; w "$P"; kill -s TERM $L 2>/dev/null; `
159
+ + `sleep 1; kill -s KILL $L 2>/dev/null; fi; rm -rf "$D" && [ ! -e "$D" ]`),
160
+ };
161
+ }
162
+
163
+ export const remoteSteps = (b, name, dir) => (b.os === "win32" ? winSteps(b, name) : posixSteps(b, name, dir));
164
+
165
+ // ── results ──────────────────────────────────────────────────────────────────
166
+
167
+ const lastLine = (s) => String(s || "").split(/\r?\n/).map((l) => l.trim()).filter(Boolean).at(-1) || "";
168
+
169
+ // node --test --test-reporter=tap: the summary lines and the names of failing tests (TODO tests left out),
170
+ // or null without a summary.
171
+ export function parseTap(text) {
172
+ const s = String(text || "");
173
+ if (!/^# tests \d+/m.test(s)) return null;
174
+ const n = (k) => Number((s.match(new RegExp(`^# ${k} (\\d+)`, "m")) || [])[1]);
175
+ const names = [...s.matchAll(/^\s*not ok \d+ - (.+?)\s*$/gm)].map((m) => m[1]).filter((t) => !/#\s*TODO\b/i.test(t));
176
+ return { tests: n("tests"), pass: n("pass"), fail: n("fail"), skipped: n("skipped"), todo: n("todo"),
177
+ cancelled: n("cancelled"), failures: [...new Set(names)].slice(0, 30) };
178
+ }
179
+
180
+ const PROBE_POSIX = "uname -sm; node -v 2>/dev/null || echo node-missing; git --version 2>/dev/null || echo git-missing; "
181
+ + "command -v tar >/dev/null 2>&1 && echo tar-ok || echo tar-missing";
182
+ const PROBE_WIN = 'cmd /c "ver & (node -v 2>nul || echo node-missing) & (git --version 2>nul || echo git-missing)'
183
+ + ' & (where tar >nul 2>nul && echo tar-ok || echo tar-missing)"';
184
+
185
+ export async function probeBox(b, exec) {
186
+ const remote = b.os === "win32" ? PROBE_WIN : pathPrefix(b) + PROBE_POSIX;
187
+ const r = await exec("ssh", [...sshBase(b), b.ssh, remote], { timeoutMs: PROBE_MS });
188
+ if (r.status !== 0) return { name: b.name, os: b.os, reachable: false, error: lastLine(r.stderr) || "ssh 실패" };
189
+ const out = String(r.stdout || "");
190
+ return {
191
+ name: b.name, os: b.os, reachable: true, system: out.split(/\r?\n/).map((l) => l.trim()).find(Boolean) || "",
192
+ node: (out.match(/^v\d+\.\d+\.\d+/m) || [null])[0], git: /git version/.test(out), tar: /tar-ok/.test(out),
193
+ };
194
+ }
195
+
196
+ // ── run ──────────────────────────────────────────────────────────────────────
197
+
198
+ // The files one box gets, named for that box: two registrations of one account cannot collide.
199
+ function stage(b, plan, name) {
200
+ const dir = join(plan.scriptDir, b.name);
201
+ mkdirSync(dir, { recursive: true });
202
+ const tar = join(dir, `${name}.tar`);
203
+ try {
204
+ linkSync(plan.tar, tar);
205
+ } catch {
206
+ copyFileSync(plan.tar, tar);
207
+ }
208
+ const s = runScript(b, plan.prefix, plan.commandFor(b.os));
209
+ const script = join(dir, `${name}${s.ext}`);
210
+ writeFileSync(script, s.text);
211
+ return [tar, script];
212
+ }
213
+
214
+ // ssh did not start (no status, no timeout), or ssh itself failed rather than the remote command.
215
+ const sshFailed = (r) => (r.status === null && !r.timedOut) || (r.status === 255 && SSH_ERR.test(String(r.stderr || "")));
216
+
217
+ async function makeDir(b, exec) {
218
+ if (b.os === "win32") return { dir: null };
219
+ const r = await exec("ssh", [...sshBase(b), b.ssh, "mktemp -d /tmp/banker-remains.XXXXXXXX"], { timeoutMs: PROBE_MS });
220
+ const dir = String(r.stdout || "").trim();
221
+ if (r.status !== 0) return { error: lastLine(r.stderr) || "ssh 실패" };
222
+ return REMOTE_DIR.test(dir) ? { dir } : { error: `mktemp -d 가 예상과 다른 경로를 냈습니다: ${dir.slice(0, 80)}` };
223
+ }
224
+
225
+ async function attempt(b, plan, exec, files, steps) {
226
+ const base = { name: b.name, os: b.os, reachable: false, exit: null, timedOut: false };
227
+ const up = await exec("scp", [...scpBase(b), ...files, steps.upload], { timeoutMs: UPLOAD_MS });
228
+ if (up.status !== 0) return { ...base, error: lastLine(up.stderr) || "scp 실패" };
229
+ const run = await exec("ssh", [...sshBase(b), b.ssh, steps.run], { timeoutMs: plan.timeoutMs });
230
+ const stopped = Boolean(plan.ctl?.stopping);
231
+ if (sshFailed(run)) return { ...base, error: lastLine(run.stderr) || "ssh 실패", abort: true, stopped };
232
+ const summary = parseTap(run.stdout);
233
+ return { ...base, reachable: true, exit: run.status, timedOut: Boolean(run.timedOut), summary, noTests: summary?.tests === 0,
234
+ stdout: run.stdout, stderr: run.stderr, abort: Boolean(run.timedOut) || stopped, stopped };
235
+ }
236
+
237
+ // One box: a private folder, upload, run, then clean up. A run that did not end on its own is ended first.
238
+ export async function runOnBox(b, plan, exec) {
239
+ const t0 = Date.now();
240
+ const place = await makeDir(b, exec);
241
+ if (place.error) return { name: b.name, os: b.os, reachable: false, exit: null, timedOut: false, error: place.error, cleaned: true, ms: Date.now() - t0 };
242
+ const name = `${plan.id}-${b.name}`;
243
+ const steps = remoteSteps(b, name, place.dir);
244
+ let result = { name: b.name, os: b.os, reachable: false, exit: null, timedOut: false, error: "중단됨", abort: true };
245
+ try {
246
+ result = await attempt(b, plan, exec, stage(b, plan, name), steps);
247
+ } finally {
248
+ const end = await exec("ssh", [...sshBase(b), b.ssh, result.abort ? steps.abort : steps.cleanup], { timeoutMs: CLEANUP_MS });
249
+ result = { ...result, cleaned: end.status === 0 };
250
+ }
251
+ const { abort, ...rest } = result;
252
+ return { ...rest, ms: Date.now() - t0 };
253
+ }
254
+
255
+ function git(cwd, args) {
256
+ const r = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
257
+ if (r.status !== 0) throw new Error(`git ${args.filter((a) => !a.includes("=")).join(" ")} 실패: ${lastLine(r.stderr) || r.error?.message || ""}`);
258
+ return r.stdout;
259
+ }
260
+
261
+ // The commit to send. `worktree` is a commit object made from the tracked files as they are now
262
+ // (git stash create: no stash entry, no change to the work tree). Untracked files are never sent.
263
+ function resolveTree(repo, ref) {
264
+ const status = git(repo, ["status", "--porcelain", "--untracked-files=all"]).split("\n").filter(Boolean);
265
+ const untracked = status.filter((l) => l.startsWith("??")).length;
266
+ if (ref !== "worktree") {
267
+ return { sha: git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]).trim(), untracked, modified: status.length - untracked };
268
+ }
269
+ const made = git(repo, ["-c", "user.name=banker-remains", "-c", "user.email=banker-remains@localhost", "stash", "create"]).trim();
270
+ return { sha: made || git(repo, ["rev-parse", "HEAD"]).trim(), untracked, modified: 0 };
271
+ }
272
+
273
+ function testFiles(repo, sha) {
274
+ const files = git(repo, ["ls-tree", "-r", "-z", "--name-only", sha]).split("\0")
275
+ .filter((f) => f.endsWith(".test.mjs") && !f.split("/").includes("node_modules"));
276
+ if (!files.length) throw new Error("*.test.mjs 시험 파일이 없습니다. --cmd 로 시험 명령을 주십시오");
277
+ return files;
278
+ }
279
+
280
+ function writeLogs(results, logDir) {
281
+ mkdirSync(logDir, { recursive: true });
282
+ return results.map(({ stdout, stderr, ...r }) => {
283
+ if (stdout === undefined && stderr === undefined) return r;
284
+ const log = join(logDir, `${r.name}.log`);
285
+ writeFileSync(log, `${stdout || ""}\n--- stderr ---\n${stderr || ""}`);
286
+ return { ...r, log };
287
+ });
288
+ }
289
+
290
+ const passed = (r) => r.reachable && r.exit === 0 && !r.timedOut && !r.noTests && r.cleaned !== false;
291
+
292
+ async function runAll(boxes, a, { exec, out, ctl }, repo) {
293
+ const { sha, untracked, modified } = resolveTree(repo, a.ref);
294
+ const files = a.nodeTest ? testFiles(repo, sha) : null;
295
+ const commandFor = (os) => (files ? nodeTestCommand(os, files) : a.command);
296
+ const id = `banker-remains-${randomBytes(4).toString("hex")}`;
297
+ const work = join(tmpdir(), id);
298
+ mkdirSync(work);
299
+ try {
300
+ // Committed bytes, whatever this machine's line-end settings: on Windows core.autocrlf=true, and core.eol=native
301
+ // under `* text=auto`, would both turn LF into CRLF for every box.
302
+ git(repo, ["-c", "core.autocrlf=false", "-c", "core.eol=lf", "archive", "--format=tar", "--prefix=repo/", "-o", join(work, `${id}.tar`), sha]);
303
+ const plan = { id, tar: join(work, `${id}.tar`), prefix: "repo", scriptDir: work, commandFor, timeoutMs: a.timeoutMin * 60000, ctl };
304
+ const results = await Promise.all(boxes.map((b) => runOnBox(b, plan, exec)));
305
+ const logDir = a.out || join(tmpdir(), "banker-remains-logs", id);
306
+ const command = Object.fromEntries([...new Set(boxes.map((b) => b.os))].map((os) => [os, commandFor(os)]));
307
+ const report = { id, ref: a.ref, sha, untracked, modified, command, out: logDir, boxes: writeLogs(results, logDir) };
308
+ out.write(`${JSON.stringify(report, null, 1)}\n`);
309
+ return results.every(passed) ? 0 : 1;
310
+ } finally {
311
+ rmSync(work, { recursive: true, force: true });
312
+ }
313
+ }
314
+
315
+ // ── command line ─────────────────────────────────────────────────────────────
316
+
317
+ const VALUE = { "--box": "boxes", "--ref": "ref", "--cmd": "command", "--timeout": "timeoutMin", "--out": "out" };
318
+
319
+ function takeOption(a, rest, i) {
320
+ const k = rest[i];
321
+ if (k === "--node-test") {
322
+ a.nodeTest = true;
323
+ return i;
324
+ }
325
+ const field = VALUE[k];
326
+ if (!field) throw new Error(`모르는 옵션 ${k}`);
327
+ const v = rest[i + 1];
328
+ if (v === undefined || v.startsWith("--")) throw new Error(`${k} 에 값이 없습니다`);
329
+ if (field === "boxes") a.boxes.push(v);
330
+ else a[field] = field === "timeoutMin" ? Number(v) : v;
331
+ return i + 1;
332
+ }
333
+
334
+ function checkArgs(a) {
335
+ if (!(Number.isFinite(a.timeoutMin) && a.timeoutMin > 0)) throw new Error("--timeout 은 0보다 큰 분 단위 숫자입니다");
336
+ if (a.cmd !== "run") return a;
337
+ if (a.nodeTest && a.command) throw new Error("--node-test 와 --cmd 는 하나만 씁니다");
338
+ if (!a.nodeTest && !a.command) throw new Error('run 에는 --cmd "<명령>" 이나 --node-test 가 필요합니다');
339
+ return a;
340
+ }
341
+
342
+ export function parseArgs(argv) {
343
+ const [cmd, ...rest] = argv;
344
+ if (!["list", "probe", "run"].includes(cmd)) throw new Error("명령은 list, probe, run 중 하나입니다");
345
+ const a = { cmd, boxes: [], ref: "HEAD", nodeTest: false, command: null, timeoutMin: 20, out: null };
346
+ for (let i = 0; i < rest.length; i++) i = takeOption(a, rest, i);
347
+ return checkArgs(a);
348
+ }
349
+
350
+ function list(reg, out, err) {
351
+ out.write(`${JSON.stringify({ file: reg.file, missing: reg.missing, boxes: reg.boxes }, null, 1)}\n`);
352
+ if (reg.missing) {
353
+ err.write(`테스트박스가 등록돼 있지 않습니다. ${reg.file} 에 이 형식으로 적습니다`
354
+ + `(기본 위치 ~/.config/banker/test-boxes.json, BANKER_TEST_BOXES 로 바꿈, SSH 키 접속만):\n${JSON.stringify(EXAMPLE, null, 1)}\n`);
355
+ }
356
+ return 0;
357
+ }
358
+
359
+ async function probeAll(boxes, exec, out) {
360
+ const results = await Promise.all(boxes.map((b) => probeBox(b, exec)));
361
+ out.write(`${JSON.stringify(results, null, 1)}\n`);
362
+ return results.every((r) => r.reachable) ? 0 : 1;
363
+ }
364
+
365
+ function pick(reg, names) {
366
+ const unknown = names.filter((n) => !reg.boxes.some((b) => b.name === n));
367
+ if (unknown.length) throw new Error(`등록되지 않은 박스: ${unknown.join(", ")}`);
368
+ const boxes = names.length ? reg.boxes.filter((b) => names.includes(b.name)) : reg.boxes;
369
+ if (!boxes.length) throw new Error(`등록된 테스트박스가 없습니다 (${reg.file}). list 로 등록 형식을 봅니다`);
370
+ return boxes;
371
+ }
372
+
373
+ // ssh and scp started by this process, so a stop can end them.
374
+ const children = new Set();
375
+
376
+ // SIGINT or SIGTERM during a run: end the ssh and scp in flight, so every box goes on to end its run and clean
377
+ // up; a second signal leaves at once. Returns the function that removes the handlers.
378
+ export function installStop(ctl, { on = process, kill = () => { for (const c of children) c.kill(); }, exit = process.exit } = {}) {
379
+ const stop = (sig) => {
380
+ if (ctl.stopping) exit(sig === "SIGINT" ? 130 : 143);
381
+ ctl.stopping = sig;
382
+ kill();
383
+ };
384
+ for (const s of ["SIGINT", "SIGTERM"]) on.on(s, stop);
385
+ return () => { for (const s of ["SIGINT", "SIGTERM"]) on.off(s, stop); };
386
+ }
387
+
388
+ async function runWithStop(boxes, a, deps, repo) {
389
+ const ctl = { stopping: null };
390
+ const remove = installStop(ctl, { on: deps.signals });
391
+ try {
392
+ const code = await runAll(boxes, a, { ...deps, ctl }, repo);
393
+ return ctl.stopping ? (ctl.stopping === "SIGINT" ? 130 : 143) : code;
394
+ } finally {
395
+ remove();
396
+ }
397
+ }
398
+
399
+ const withDefaults = (d) => ({ file: configFile(), out: process.stdout, err: process.stderr, exec: spawnExec, cwd: process.cwd(), signals: process, ...d });
400
+
401
+ // Exit 0: done and every box passed. 1: a box was unreachable, failed, timed out, ran no tests or was left
402
+ // unclean. 2: usage or setup error. 130 and 143: stopped by SIGINT and SIGTERM, after cleaning up.
403
+ export async function main(argv, deps = {}) {
404
+ const d = withDefaults(deps);
405
+ try {
406
+ const a = parseArgs(argv);
407
+ const reg = loadBoxes(d.file);
408
+ if (a.cmd === "list") return list(reg, d.out, d.err);
409
+ const boxes = pick(reg, a.boxes);
410
+ if (a.cmd === "probe") return await probeAll(boxes, d.exec, d.out);
411
+ return await runWithStop(boxes, a, d, git(d.cwd, ["rev-parse", "--show-toplevel"]).trim());
412
+ } catch (e) {
413
+ d.err.write(`${e.message}\n${/^명령은|옵션|값이|--/.test(e.message) ? USAGE : ""}`);
414
+ return 2;
415
+ }
416
+ }
417
+
418
+ // ssh and scp as child processes, with a time limit. A timed-out child is killed and the result returns at once,
419
+ // even if a grandchild still holds its pipes. Output past MAX_OUTPUT is dropped.
420
+ export function spawnExec(file, args, { timeoutMs = UPLOAD_MS } = {}) {
421
+ return new Promise((resolve) => {
422
+ const child = spawn(file, args, { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
423
+ children.add(child);
424
+ const buf = { stdout: "", stderr: "" };
425
+ for (const k of ["stdout", "stderr"]) {
426
+ child[k].setEncoding("utf8");
427
+ child[k].on("data", (d) => { if (buf[k].length < MAX_OUTPUT) buf[k] += d; });
428
+ }
429
+ let timedOut = false;
430
+ const done = (status, error) => {
431
+ clearTimeout(timer);
432
+ children.delete(child);
433
+ resolve({ status, stdout: buf.stdout, stderr: error ? `${buf.stderr}\n${error.message}` : buf.stderr, timedOut });
434
+ };
435
+ const timer = setTimeout(() => { timedOut = true; child.kill(); done(null, null); }, timeoutMs);
436
+ child.on("error", (e) => done(null, e));
437
+ child.on("close", (code) => done(code, null));
438
+ });
439
+ }
440
+
441
+ const isMain = () => {
442
+ try {
443
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
444
+ } catch {
445
+ return false;
446
+ }
447
+ };
448
+ if (isMain()) main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: trouble-shooting
3
+ description: "문제를 조사해 현상, 문제점, 원인, 해결방법, 조치계획을 세우고 표로 간결히 보고함. `--no-plan` 이면 해결방법까지만 함. 원인은 근거로 확인하고, 고치지 않고 보고만 함. 'trouble-shooting'/'트러블슈팅'/'원인 분석'/'장애 분석'/'문제 해결 보고' 시 사용."
4
+ argument-hint: "[--no-plan] [문제 설명]"
5
+ ---
6
+
7
+ # trouble-shooting - 현상, 문제점, 원인, 해결방법, 조치계획 표
8
+
9
+ 문제를 조사해 현상, 문제점, 원인, 해결방법, 조치계획을 세우고 표 하나로 간결히 보고한다. `--no-plan` 이면 조치계획을 빼고 해결방법까지만 한다.
10
+ 답변은 한글.
11
+
12
+ ## 원칙
13
+
14
+ - 보고만 한다. 고치거나 설정을 바꾸거나 커밋하거나 push 하지 않는다. 디버그 출력 추가, 의존성 설치, 서비스 재시작도 하지 않는다. 조치는 사용자가 지시한 뒤에 한다.
15
+ - 원인은 근거로 확인한다. 확인하지 못한 원인은 "추정" 이라 쓰고 확신 수준(0.00~1.00)과 확인할 방법을 붙인다.
16
+ - 현상 칸에는 관찰한 사실만 쓴다. 영향은 문제점 칸에, 발생 이유는 원인 칸에 쓴다.
17
+ - 재현은 읽기 명령과 시험 실행처럼 안전한 것만 바로 한다. 비용이 들거나 되돌리기 어려운 재현(유료 API, 삭제, 배포)과 운영 DB, 운영 서버 접근은 읽기만 하더라도 먼저 묻고, 승인을 받은 뒤에만 한다.
18
+ - 보고에 싣는 모든 내용(표, 근거, 오류 원문, 로그, 명령 출력, 인용한 파일 줄)에서 비밀값(키, 토큰, 비밀번호)은 가린다.
19
+
20
+ ## 1. 대상
21
+
22
+ - 인자로 준 문제 설명, 이번 대화의 오류와 증상, 사용자가 붙여 넣은 로그를 본다.
23
+ - 무엇이 문제인지 알 수 없으면 조사 전에 한 번만 묻는다.
24
+
25
+ ## 2. 조사
26
+
27
+ 1. 현상: 언제, 어디서, 무엇이 어떻게 나타나는지 관찰한 사실을 모은다. 오류 메시지 원문과 재현 조건을 적는다.
28
+ 2. 재현: 안전한 범위에서 같은 현상을 다시 낸다. 재현하지 못하면 현상 칸에 "재현 안 됨" 이라 쓰고, 근거에 시도한 방법을 적는다.
29
+ 3. 문제점: 그 현상이 왜 문제인지 적는다. 누구에게 어떤 영향이 얼마나 있는지(범위, 심각도)를 쓴다.
30
+ 4. 원인: 가설을 세우고 증거로 확인하거나 기각한다. 증상이 아니라 근본 원인을 찾는다. 원인에 닿을 때까지 "왜" 를 묻는다.
31
+ 5. 해결방법: 원인을 없애는 방법을 적는다. 선택지가 여럿이면 권고안을 하나 고르고 이유를 짧게 쓴다. 증상만 가리는 방법은 임시방편이라고 밝힌다.
32
+ 6. 조치계획: 할 일을 순서대로 적는다. 단계마다 확인 방법을 붙이고, 마지막에 되돌리는 방법을 적는다. 필요하면 담당과 시점을 적는다. `--no-plan` 이면 이 단계를 건너뛴다.
33
+
34
+ 근거는 다시 확인할 수 있게 남긴다(파일과 줄, 로그 줄, 명령과 출력).
35
+
36
+ ## 3. 보고
37
+
38
+ 1. 첫 줄: 결론 한 줄. 무엇이 왜 문제이고 무엇을 하면 되는지 쓴다.
39
+ 2. 표: 문제마다 한 행이다. 원인이 같은 증상은 한 행으로 묶는다.
40
+
41
+ | # | 현상 | 문제점 | 원인 | 해결방법 | 조치계획 |
42
+ |---|---|---|---|---|---|
43
+ | 1 | 관찰한 사실 | 왜 문제인가, 영향 | 근본 원인(확인 또는 추정) | 원인을 없애는 방법 | 1. 할 일 2. 확인 3. 되돌리기 |
44
+
45
+ - `--no-plan` 이면 조치계획 열을 뺀다.
46
+ - 칸은 짧게 쓴다. 한 칸에 한두 문장이다. 조치계획은 번호를 붙인 짧은 단계로 쓰고, 단계가 많으면 표에는 요약만 두고 근거 아래에 자세히 쓴다.
47
+ - 추정인 원인은 칸에 "(추정, 확신 0.60)" 처럼 표시한다.
48
+
49
+ 3. 근거: 표 아래에 행 번호별로 근거를 짧게 적는다. 추정인 원인은 확인할 방법도 적는다.
50
+ 4. 마지막 줄: 사용자가 고를 다음 행동을 쓴다. 예를 들어 조치계획대로 진행할지 묻는다. `--no-plan` 이면 조치계획까지 세울지 묻는다.