@kaydash9999/banker-plugins 0.24.0 → 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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code skills + commands — QA, audit, docs, architecture, dependency setup, and wiki; the banker plugin marketplace.",
9
- "version": "0.24.0"
9
+ "version": "0.24.1"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "banker",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
4
4
  "description": "Claude Code plugin: QA/audit/docs/architecture skills + commands, a /banker:setup installer for OS dependencies (playwright, etc.), and wiki compaction.",
5
5
  "author": {
6
6
  "name": "smallOpenSource",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.24.1] - 2026-11-02
4
+
5
+ ### Fixed
6
+ - `init-dir`(Windows): `AGENTS.md` 와 `CLAUDE.md` 가 둘 다 있을 때 가끔 `CLAUDE.md` 에 블록을 쓰지 않고도 종료 0 으로 끝나던 것을 고친다. 두 파일이 같은 파일인지 가릴 때 파일 번호(ino)를 Number 로 받았는데, NTFS 파일 ID 는 2^53 을 넘어 아래 자리가 잘리고 연달아 만든 두 파일이 같은 번호가 될 수 있었다(Windows 테스트박스 실측: 300쌍 중 3쌍 충돌). 이제 bigint 로 가리고, 번호가 0 인 파일 시스템은 파일 시스템이 쓰는 경로 표기로 가린다. 같은 표지 블록 모듈을 쓰는 `git-init`, `tune-tmux` 도 같은 판으로 맞췄다. 두 스킬은 파일 하나만 쓰므로 이 결함에 닿지 않았다.
7
+ - `init-dir` 틀의 `service.mjs`: 낡은 시작 잠금이 있을 때 `start` 를 여럿 동시에 실행하면 감독이 둘 뜰 수 있던 틈을 닫는다. 낡은 잠금은 이제 정리 잠금(`logs/<이름>.start.lock.break`)을 새 파일로 만든 `start` 하나만 지우고, 지우기 전에 다시 읽어 그 사이 다른 `start` 가 잡은 잠금은 지우지 않는다. Windows 에서 지우는 중인 잠금이 내는 EPERM, EACCES, EBUSY 는 바쁨으로 보고 기다리고, 잠금을 만들 수 없는 진짜 권한 오류는 바로 사유와 함께 종료 1 로 끝난다. 고친 틀은 새로 만드는 `scripts/` 폴더에 들어간다. `init-dir` 은 이미 있는 폴더의 내용을 건드리지 않으므로 이미 만든 프로젝트의 틀은 그대로다.
8
+ - `teamwork`(Codex 전용): `--file` 이 확인과 열기 사이에 바뀐 파일을 가릴 때도 파일 번호를 bigint 로 비교한다. Windows 에서 다른 파일로 바꿔치기된 것을 놓칠 수 있었다.
9
+
10
+ ### Changed
11
+ - `progress-mon`: ADR 화면이 `check` 결과와 같은지 알린다. 화면은 참조 파일을 열지 않아 적힌 참조로 세고, `check` 는 확인을 마친 참조로 세므로 "참조 없는 서술" 수가 다를 수 있었다. 이제 `check` 가 자료 폴더의 `adr-check.json` 에 집계와 그때 `adr.json` 의 sha256 을 남기고, 화면은 집계 배지 옆에 `check 와 같음`, `check 뒤 adr.json 이 바뀜`, `check 기준: <집계>`(다름), `check 기록 없음` 가운데 하나를 보인다. 기록을 쓰지 못하면 경고만 내고 검사 결과와 종료 코드는 그대로다.
12
+ - `progress-mon`: ADR 화면 렌더를 `scripts/pm-adr-view.mjs` 로 나눴다. 검사, 정리, 밀도, 집계는 `scripts/pm-adr.mjs` 에 남는다.
13
+
14
+ ### Notes
15
+ - Windows 에서 0.24.0 의 `init-dir apply` 를 쓴 프로젝트는 업데이트 뒤 `init-dir status` 로 확인한다. `CLAUDE.md` 나 `AGENTS.md` 가 `stale` 로 나오면 블록이 빠진 것이므로 `init-dir apply` 를 한 번 더 실행하면 채워진다. 이미 블록이 있는 파일은 바뀌지 않는다.
16
+ - 시험 정비: Windows 에서 위 결함 때문에 가끔 실패하던 `init-dir` 시험의 원인을 고쳤다. `3d-intro-build` 의 Sora 배포 확인 시험이 실제 현재 시각을 써 시험 자료의 퇴역일(2026-11-02)이 지나자 실패하던 것을 고정 시각으로 고쳤다(제품 동작은 바뀌지 않음). `progress-mon` 의 ADR 화면 e2e 는 테스트박스 도구 폴더의 Playwright(`BUILD_TESTBOX_PLAYWRIGHT_DIR`)를 먼저 써서 박스에서 건너뛰지 않는다.
17
+ - 훅 코드는 바뀌지 않아 Codex 플러그인 사용자에게 `/hooks` 신뢰를 다시 묻지 않는다.
18
+ - 실측: 테스트박스 3대에서 38ab876 을 시험했다(통과/실패/건너뜀). Rocky Linux 8 2807/0/34, macOS 2799/0/42, Windows 2492/0/344.
19
+
3
20
  ## [0.24.0] - 2026-11-02
4
21
 
5
22
  ### Added
@@ -74,7 +74,7 @@
74
74
  { "name": "ultra-chain", "type": "skill", "target": "both", "reason": "orchestrates nine stages in order: deep-research, ultra-interview, ralplan --deliberate followed by a ralph-qa plan quorum of 3+ reviewers (ralplan has two reviewers and no way to add more), a mock the user approves, ralph --critic=critic (Codex: OMX ralph, which has no --critic) followed in both runtimes by the skill's own critic seat loop, local gates run by ralph-qa's gate-evidence runner (observed records against a baseline taken before the build) and tests on the registered OS boxes, fed to a ralph-qa quorum with --gates (the plan quorum declares no gates), visual review (machine diff against the mock, screenshots, independent judges), --agents=N (default 3) reviewers for every independent verdict with the ledger enforcing the count and the all-approve rule, a process report rendered from its own ledger, and local sharing; Claude=OMC 5, Codex=OMX; body runtime-aware; pure Node helpers, no deps", "supportingFiles": ["references/chain-ledger.mjs", "references/chain-report.mjs", "references/capture.mjs", "references/serve.mjs", "references/preview-lib.mjs"], "caveat": "uses the sibling banker skills deep-research, ultra-interview, ralph-qa and remains (boxes.mjs); box runs need ssh, scp and git with key-only logins in ~/.config/banker/test-boxes.json; screenshots need Playwright with Chromium; serve.mjs and preview-lib.mjs are byte-identical copies of 3d-intro-build's (scripts/sync-adapter.js); never pushes, opens a PR or posts outside without an explicit request" },
75
75
  { "name": "anti-drm", "type": "skill", "target": "both", "reason": "turns on or off rules that steer the agent away from the file extensions a network-separated PC's DRM encrypts (default .txt .pdf .ppt .pptx .doc .docx .hwp .hwpx .xls .xlsx .csv) toward replacements (.md .html .tsv); asks the extensions on first use and keeps them in ~/.config/banker/anti-drm.json; pure Node, no deps", "supportingFiles": ["scripts/anti-drm.mjs", "scripts/instruction-toggle.mjs"], "caveat": "the same places as tone-compact (shared module instruction-toggle.mjs): Claude Code gets <config>/rules/banker-anti-drm.md; Codex gets one marked block wrapped in USER:OMX:POLICY at the end of AGENTS.md, or AGENTS.override.md when it has content of its own. The settings are shared by both runtimes; on and off act on one runtime, while config and on with settings refresh every runtime that holds the rules. It never decrypts or converts files DRM already protects; it only picks the extensions of files the agent newly creates. The organization's security rules come first" },
76
76
  { "name": "theme-selection", "type": "skill", "target": "both", "reason": "applies 74 themes to a project as CSS custom properties, light and dark (148 combinations), with contrast correction; also emits a theme picker (markup and two scripts) and a contrast audit (check), and reads the project's DESIGN.md as more themes; pure Node, no deps, no network", "supportingFiles": ["scripts/themes.mjs", "assets/palettes.json", "assets/LICENSE", "assets/NOTICE.md", "references/integrate.md", "references/contrast.md"], "caveat": "the bundled palettes derive from VoltAgent/awesome-design-md (MIT; LICENSE and NOTICE.md in assets/); theme names only point at the sites the designs reference, and the trademarks belong to their owners; --out overwrites the file it names (refused when that file itself is a symbolic link), so the skill asks first when that file exists" },
77
- { "name": "progress-mon", "type": "skill", "target": "both", "reason": "project progress web screen with five tabs (progress, requirements, workflow, architecture, ADR) on a local server; the ADR tab shows each decision with its optimization check, scaling argument and trade-offs, and the project's module verification, input-to-output flow and proof locations, every reference checked by check; init reads the code and writes the tab data, registers a one-line update rule in project memory (an older rule line is replaced in place with a backup); item, note, stage, story keep it current; pure Node, no deps", "supportingFiles": ["README.md","references/adr.md","references/arch.md","references/authoring.md","references/config.md","references/flow.md","references/inventory.md","references/items.md","references/req-spec.md","references/reqs.md","scripts/index.js","scripts/pm-adr.mjs","scripts/pm-check.mjs","scripts/pm-config.mjs","scripts/pm-data.mjs","scripts/pm-html.mjs","scripts/pm-memory.mjs","scripts/pm-render.mjs","scripts/pm-server.mjs","scripts/pm-sources.mjs","scripts/progress-mon.mjs","assets/fixture/README.md","assets/fixture/config.json","assets/fixture/events.jsonl","assets/fixture/items.json","assets/fixture/commits.json","assets/fixture/req-spec.json","assets/fixture/reqs.json","assets/fixture/flow.json","assets/fixture/arch.json","assets/fixture/adr.json","assets/fixture/workflows/wf_ex1/journal.jsonl","assets/fixture/workflows/wf_ex1/agent-a01.jsonl","assets/fixture/workflows/wf_ex1/agent-a02.jsonl","assets/fixture/workflows/wf_ex1/agent-a03.jsonl","assets/fixture/workflows/wf_ex1/agent-a04.jsonl","assets/fixture/variants/arch-auto/arch.json","assets/fixture/variants/check-auto/arch.json","assets/fixture/variants/check-overlap/arch.json","assets/fixture/variants/history-manual/config.json","assets/fixture/variants/history-manual/items.json","assets/fixture/variants/workflow-wide/flow.json","assets/fixture/variants/legacy-init/rules.json","assets/fixture/variants/legacy-init/state.json"], "caveat": "needs Node 18 or later (above the package engines floor); binds 127.0.0.1 only by default, --host takes a private IPv4 with a warning; data lives in .omc/progress-mon/ (Codex .omx/progress-mon/); on Codex start, stop, status and init each ask for sandbox_permissions require_escalated, and a long-lived screen runs from the user's terminal (serve) since closing the session may stop the server" },
77
+ { "name": "progress-mon", "type": "skill", "target": "both", "reason": "project progress web screen with five tabs (progress, requirements, workflow, architecture, ADR) on a local server; the ADR tab shows each decision with its optimization check, scaling argument and trade-offs, and the project's module verification, input-to-output flow and proof locations, every reference checked by check; init reads the code and writes the tab data, registers a one-line update rule in project memory (an older rule line is replaced in place with a backup); item, note, stage, story keep it current; pure Node, no deps", "supportingFiles": ["README.md","references/adr.md","references/arch.md","references/authoring.md","references/config.md","references/flow.md","references/inventory.md","references/items.md","references/req-spec.md","references/reqs.md","scripts/index.js","scripts/pm-adr.mjs","scripts/pm-adr-view.mjs","scripts/pm-check.mjs","scripts/pm-config.mjs","scripts/pm-data.mjs","scripts/pm-html.mjs","scripts/pm-memory.mjs","scripts/pm-render.mjs","scripts/pm-server.mjs","scripts/pm-sources.mjs","scripts/progress-mon.mjs","assets/fixture/README.md","assets/fixture/config.json","assets/fixture/events.jsonl","assets/fixture/items.json","assets/fixture/commits.json","assets/fixture/req-spec.json","assets/fixture/reqs.json","assets/fixture/flow.json","assets/fixture/arch.json","assets/fixture/adr.json","assets/fixture/workflows/wf_ex1/journal.jsonl","assets/fixture/workflows/wf_ex1/agent-a01.jsonl","assets/fixture/workflows/wf_ex1/agent-a02.jsonl","assets/fixture/workflows/wf_ex1/agent-a03.jsonl","assets/fixture/workflows/wf_ex1/agent-a04.jsonl","assets/fixture/variants/arch-auto/arch.json","assets/fixture/variants/check-auto/arch.json","assets/fixture/variants/check-overlap/arch.json","assets/fixture/variants/history-manual/config.json","assets/fixture/variants/history-manual/items.json","assets/fixture/variants/workflow-wide/flow.json","assets/fixture/variants/legacy-init/rules.json","assets/fixture/variants/legacy-init/state.json"], "caveat": "needs Node 18 or later (above the package engines floor); binds 127.0.0.1 only by default, --host takes a private IPv4 with a warning; data lives in .omc/progress-mon/ (Codex .omx/progress-mon/); on Codex start, stop, status and init each ask for sandbox_permissions require_escalated, and a long-lived screen runs from the user's terminal (serve) since closing the session may stop the server" },
78
78
  { "name": "init-dir", "type": "skill", "target": "both", "reason": "creates seven project folders (logs, scripts, work, mockup, tests, docs, KB), tells the current session what each folder is for, and records it as one marked block in AGENTS.md and CLAUDE.md (whichever exist; AGENTS.md alone for Codex when neither does); only newly created folders get starter files (service start/stop/status/restart scripts with log rotation, a test runner, READMEs), existing folders keep their contents; pure Node, no deps", "supportingFiles": ["scripts/init-dir.mjs","scripts/marker-block.mjs","scripts/templates.mjs","templates/logs/README.md","templates/logs/gitignore","templates/scripts/start.sh","templates/scripts/stop.sh","templates/scripts/status.sh","templates/scripts/restart.sh","templates/scripts/start.cmd","templates/scripts/stop.cmd","templates/scripts/status.cmd","templates/scripts/restart.cmd","templates/scripts/service.conf","templates/scripts/lib/service.mjs","templates/scripts/README.md","templates/work/README.md","templates/mockup/README.md","templates/tests/run.sh","templates/tests/run.cmd","templates/tests/run.mjs","templates/tests/tests.conf","templates/tests/README.md","templates/docs/README.md","templates/KB/README.md"], "caveat": "the starter scripts need Node 18 or later in the target project (NODE names another one); it writes AGENTS.md and CLAUDE.md, changing only the bytes between its markers and keeping the first original as <file>.init-dir.bak; the AGENTS.md block sits inside its own USER:OMX:POLICY pair, AGENTS.override.md is never written (a warning when it has content of its own); a CLAUDE.md symbolic link is refused unless --follow-symlink; .cmd templates are stored with LF and written with CRLF; on Codex a --root outside the workspace, or a started service that must outlive the session, asks for sandbox_permissions require_escalated" },
79
79
  { "name": "git-init", "type": "skill", "target": "both", "reason": "makes the first commit of a folder that is not a git repository yet, so worktrees and other parallel work can start from it: writes a .gitignore that keeps secrets and local state out, scans the files to track for secrets by name and content (payload-scan.mjs, file:line:kind only, never the value), then runs git init, git add . and git commit -m init; a hit stops it before any .git exists; pure Node, no deps, no network", "supportingFiles": ["scripts/git-init.mjs","scripts/marker-block.mjs","scripts/payload-scan.mjs"], "caveat": "needs git on PATH (before git 2.28 the first branch is set with git symbolic-ref) and a commit identity (user.name and user.email, or --local-identity, written to .git/config only); an existing .gitignore keeps its bytes and gets one marked block at its end, the first original kept in .gitignore.git-init.bak, which the block itself ignores; a .gitignore symbolic link is refused unless --follow-symlink; key file names cannot be accepted, a content hit only by its exact file:line:kind with --accept; commit hooks are not skipped; it never adds a remote or pushes; on Codex run asks for sandbox_permissions require_escalated, since the default sandbox may block writes to .git" },
80
80
  { "name": "build-testbox", "type": "skill", "target": "both", "reason": "a test environment this machine builds from its own resources: a browser matrix (Playwright Chromium and WebKit, desktop and mobile device emulation) against a loopback URL and container OSes (Rocky Linux 8, 9; macOS only as a Tart VM on Apple Silicon) run in parallel, every requested cell kept as a pass, fail, unverified or unsupported row with a reason, results.json and summary.md; detect, run, setup; pure Node, no deps", "supportingFiles": ["scripts/testbox.mjs","scripts/args.mjs","scripts/detect.mjs","scripts/browsers.mjs","scripts/browser-cell.mjs","scripts/containers.mjs","scripts/setup.mjs","scripts/report.mjs","scripts/proc.mjs"], "caveat": "Playwright (a global install, or the project's with --playwright-from-project) and docker or podman are optional; a missing one leaves its cells unverified with the reason; run downloads nothing (a missing image or browser build is unverified, never pulled); setup only lists what it would fetch and its rough size, and fetches after the user confirms and it runs again with --yes (exit 4 and network needed when the sources are unreachable); macOS runs only as a Tart VM on an Apple Silicon host (other hosts are unsupported under the Apple SLA), and the skill never downloads or starts the VM; on Codex detect and run ask for sandbox_permissions require_escalated for the container engine and the headless browsers, the server under test on a 127.0.0.1 port is the user's own (approved where it is started), and setup --yes, or a container command that uses the network, asks for network access (--network host lifts that cell's isolation, and the user is told)" },
@@ -906,12 +906,12 @@ function sessionCwd() {
906
906
  function openedPath(fd) { try { return fs.readlinkSync(`/proc/self/fd/${fd}`); } catch { return ''; } }
907
907
 
908
908
  // 연 파일이 확인한 파일과 같은지: 마지막 이름은 O_NOFOLLOW 로 링크를 따라가지 않고(Windows 는 그 플래그가 없어 lstat 으로 링크가 아님을 봄),
909
- // 연 파일의 dev, ino 를 실제 경로의 lstat 과 비교한다. Linux 는 연 파일의 경로도 경계 안인지 다시 봐 그사이 바뀐 폴더를 거부한다
909
+ // 연 파일의 dev, ino 를 실제 경로의 lstat 과 비교한다(bigint: NTFS 파일 ID 는 2^53 을 넘어 Number 로는 다른 두 파일이 같아 보일 수 있음). Linux 는 연 파일의 경로도 경계 안인지 다시 봐 그사이 바뀐 폴더를 거부한다
910
910
  function readChecked(real, roots) {
911
- const before = fs.lstatSync(real);
911
+ const before = fs.lstatSync(real, { bigint: true });
912
912
  const fd = fs.openSync(real, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
913
913
  try {
914
- const st = fs.fstatSync(fd);
914
+ const st = fs.fstatSync(fd, { bigint: true });
915
915
  const opened = openedPath(fd);
916
916
  const moved = !st.isFile() || !before.isFile() || st.dev !== before.dev || st.ino !== before.ino;
917
917
  if (moved || (opened && !roots.some((r) => under(opened, r)))) throw new Error(`확인한 뒤 바뀌었거나 일반 파일이 아님: ${real}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaydash9999/banker-plugins",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
4
4
  "description": "banker — Claude Code + Codex CLI plugin (QA, audit, docs, architecture, wiki skills). Install via Claude Code marketplace, or `npm i -g @kaydash9999/banker-plugins` then `banker setup`.",
5
5
  "bin": {
6
6
  "banker": "bin/banker.js"
@@ -67,6 +67,13 @@ const readlinkOr = (path) => {
67
67
  return "?";
68
68
  }
69
69
  };
70
+ const nativeReal = (path) => {
71
+ try {
72
+ return realpathSync.native(path);
73
+ } catch {
74
+ return resolve(path);
75
+ }
76
+ };
70
77
  const realOrNull = (path) => {
71
78
  try {
72
79
  return realpathSync(path);
@@ -96,7 +103,8 @@ function fileProblem(st, file, uid) {
96
103
  return null;
97
104
  }
98
105
 
99
- // The file's stat and bytes, opened without blocking so a FIFO planted after the look never waits.
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).
100
108
  function readChecked(file, uid) {
101
109
  const first = statSync(file);
102
110
  const problem = fileProblem(first, file, uid);
@@ -105,7 +113,7 @@ function readChecked(file, uid) {
105
113
  try {
106
114
  const st = fstatSync(fd);
107
115
  const late = fileProblem(st, file, uid);
108
- return late ? { refuse: late } : { st, bytes: readFileSync(fd) };
116
+ return late ? { refuse: late } : { st, id: fstatSync(fd, { bigint: true }), bytes: readFileSync(fd) };
109
117
  } finally {
110
118
  closeSync(fd);
111
119
  }
@@ -233,7 +241,9 @@ export function planBlock(path, opts) {
233
241
  const next = nextBytes(read.bytes, o, at.file);
234
242
  if (next.refuse) return next.refuse;
235
243
  const changed = !next.bytes.equals(read.bytes);
236
- const key = `ino:${read.st.dev}:${read.st.ino}`;
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}`;
237
247
  return { ok: true, changed, path, file: at.file, key, before: read.bytes, after: next.bytes, mode: read.st.mode & 0o7777 };
238
248
  }
239
249
 
@@ -111,7 +111,7 @@ argument-hint: "[apply|status] [--only=<폴더,...>] [--follow-symlink] [--dry-r
111
111
  | 로테이션 | 쓰기 전에 "현재 크기 + 쓸 양 > 100MB(104857600 바이트)" 면 돌림. `.log` 가 `.1.log` 로 밀리고 `.6.log` 는 지움. 현재 파일 포함 7개, 스트림당 최대 700MB |
112
112
  | stop | 감독에 SIGTERM. 감독이 자식의 프로세스 그룹에 넘기고 10초 뒤 SIGKILL. 감독이 없으면 자식에 직접. Windows 는 `taskkill /T`, 10초 뒤 `/F` |
113
113
  | status | 감독과 자식의 실행 여부를 따로, 로그 파일 수와 크기 |
114
- | 시작 잠금 | `start` 는 `logs/<이름>.start.lock` 을 O_EXCL 로 만들어 잡고 끝나면 지움. 동시에 여러 번 실행해도 감독은 하나, 나중 실행은 기다린 뒤 `이미 실행 중`. 죽은 PID, 다른 프로그램, 30초 넘은 잠금은 넘겨받음. 링크나 폴더 자리는 거부 |
114
+ | 시작 잠금 | `start` 는 `logs/<이름>.start.lock` 을 O_EXCL 로 만들어 잡고 끝나면 지움. 동시에 여러 번 실행해도 감독은 하나, 나중 실행은 기다린 뒤 `이미 실행 중`. 죽은 PID, 다른 프로그램, 30초 넘은 잠금은 `.start.lock.break` 를 잡은 `start` 하나만 다시 읽어 확인한 뒤 지움(동시 정리에도 감독은 하나). 링크나 폴더 자리는 거부 |
115
115
  | 종료 코드 | 0 성공(status 는 둘 다 실행 중), 1 실패(시작 2초 안에 멈춤, 멈추지 못함, status 에서 실행 중 아님), 2 설정 없음(`command` 비어 있음), 3 Node 18 이상 없음(래퍼) |
116
116
 
117
117
  - 대상 프로젝트에 Node 18 이상이 필요하다. 다른 Node 는 환경 변수 `NODE` 에 경로
@@ -67,6 +67,13 @@ const readlinkOr = (path) => {
67
67
  return "?";
68
68
  }
69
69
  };
70
+ const nativeReal = (path) => {
71
+ try {
72
+ return realpathSync.native(path);
73
+ } catch {
74
+ return resolve(path);
75
+ }
76
+ };
70
77
  const realOrNull = (path) => {
71
78
  try {
72
79
  return realpathSync(path);
@@ -96,7 +103,8 @@ function fileProblem(st, file, uid) {
96
103
  return null;
97
104
  }
98
105
 
99
- // The file's stat and bytes, opened without blocking so a FIFO planted after the look never waits.
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).
100
108
  function readChecked(file, uid) {
101
109
  const first = statSync(file);
102
110
  const problem = fileProblem(first, file, uid);
@@ -105,7 +113,7 @@ function readChecked(file, uid) {
105
113
  try {
106
114
  const st = fstatSync(fd);
107
115
  const late = fileProblem(st, file, uid);
108
- return late ? { refuse: late } : { st, bytes: readFileSync(fd) };
116
+ return late ? { refuse: late } : { st, id: fstatSync(fd, { bigint: true }), bytes: readFileSync(fd) };
109
117
  } finally {
110
118
  closeSync(fd);
111
119
  }
@@ -233,7 +241,9 @@ export function planBlock(path, opts) {
233
241
  const next = nextBytes(read.bytes, o, at.file);
234
242
  if (next.refuse) return next.refuse;
235
243
  const changed = !next.bytes.equals(read.bytes);
236
- const key = `ino:${read.st.dev}:${read.st.ino}`;
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}`;
237
247
  return { ok: true, changed, path, file: at.file, key, before: read.bytes, after: next.bytes, mode: read.st.mode & 0o7777 };
238
248
  }
239
249
 
@@ -24,7 +24,10 @@
24
24
  - PID 파일의 PID 는 확인한 뒤에만 이 서비스로 본다. 시작 시각이 마지막 부팅보다 이르면 낡은 파일이다. 명령줄에 `service.mjs` 와 `__supervise`(감독) 또는 기록된 `command`(자식)가 없으면 다른 프로세스다. 명령줄은 Linux 는 `/proc/<pid>/cmdline` 다음 `ps`, 그 밖의 POSIX 는 `ps`, Windows 는 PowerShell `Get-CimInstance` 로 읽는다. 다른 프로세스로 판정한 PID 에는 신호를 보내지 않고 사유를 출력한다
25
25
  - 살아 있는 PID 의 명령줄을 끝내 읽지 못하면(`/proc` 도 `ps` 도 없음) start, stop, status, restart 는 `확인 불가: <사유>` 와 종료 1. 신호를 보내지 않고 PID 파일을 남긴다
26
26
  - `start` 를 동시에 여러 번 실행해도 감독은 하나만 뜬다. `start` 는 `logs/<name>.start.lock` 을 새 파일로만 만들어(자기 PID 와 시각을 씀) 잡고, 끝나면 지운다. 잠금이 있으면 최대 `SERVICE_START_CHECK_MS` + 5초 기다린 뒤 이어서 진행하므로 나중 실행은 `이미 실행 중` 을 보고한다
27
- - 잠금의 PID 가 죽었거나 다른 프로그램이거나, 잠금이 30초 + `SERVICE_START_CHECK_MS` 보다 오래되면 낡은 잠금으로 보고 넘겨받는다. 잠금 자리에 링크나 폴더가 있으면 거부하고 종료 1
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
28
31
  - 로테이션이 실패하면(Windows 에서 다른 프로그램이 로그를 연 경우 등) 표준 오류 로그에 `service:` 경고 한 줄을 쓰고 현재 파일에 이어 쓴다
29
32
 
30
33
  ## 조정용 환경 변수
@@ -32,14 +32,15 @@
32
32
  *
33
33
  * Two `start` runs at once launch one supervisor: start holds logs/<name>.start.lock (made with O_EXCL, holding its
34
34
  * pid and time, removed when it ends). A later start waits for it, then sees the service running. A lock whose pid
35
- * is dead, is another program, or is older than 30 seconds plus the start check is taken over. A link or a folder
36
- * at the lock's name is refused (exit 1).
35
+ * is dead, is another program, or is older than 30 seconds plus the start check is removed, by one start at a time:
36
+ * the one holding logs/<name>.start.lock.break (O_EXCL), which reads the lock again and removes only the lock it
37
+ * judged stale. A link or a folder at either name is refused (exit 1).
37
38
  *
38
39
  * Environment (tuning): SERVICE_LOG_ROTATE_BYTES (default 104857600), SERVICE_STOP_GRACE_MS (10000),
39
40
  * SERVICE_START_CHECK_MS (2000).
40
41
  */
41
42
  import { spawn, spawnSync } from "node:child_process";
42
- import { closeSync, existsSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
43
+ import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
43
44
  import { uptime } from "node:os";
44
45
  import { dirname, join } from "node:path";
45
46
  import { fileURLToPath } from "node:url";
@@ -62,6 +63,9 @@ const START_CHECK_MS = envInt("SERVICE_START_CHECK_MS", 2000);
62
63
  const BOOT_SLACK_MS = 5000;
63
64
  const LOCK_STALE_MS = 30000 + START_CHECK_MS;
64
65
  const LOCK_WAIT_MS = 5000 + START_CHECK_MS;
66
+ const BREAK_STALE_MS = 5000;
67
+ // On Windows a file being deleted while another process has it open answers EPERM, EACCES or EBUSY: busy, try again.
68
+ const BUSY = new Set(["EEXIST", "EPERM", "EACCES", "EBUSY"]);
65
69
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
66
70
 
67
71
  function parseConf(text) {
@@ -351,27 +355,8 @@ function lockBusy(text, st) {
351
355
  return holders.get(text);
352
356
  }
353
357
 
354
- // Moves a stale lock aside and removes it. When what was moved is not the lock judged stale (another start took
355
- // over first and made a fresh one), it goes back unless a newer lock is there.
356
- function takeOver(file, seen) {
357
- const aside = `${file}.${process.pid}.stale`;
358
- try {
359
- renameSync(file, aside);
360
- } catch {
361
- return; // gone or taken already: the next try sees
362
- }
363
- if (readText(aside) !== seen) {
364
- try {
365
- linkSync(aside, file);
366
- } catch {
367
- // a newer lock is there, or no hard links on this file system
368
- }
369
- }
370
- rmSync(aside, { force: true });
371
- }
372
-
373
- // One try at the lock: { text } held, { error } refused, or {} busy.
374
- function tryLock(file) {
358
+ // One file made with O_EXCL holding this start's pid and time: { text } made, { code } not (EEXIST: there is one).
359
+ function makeLockFile(file) {
375
360
  const text = `${JSON.stringify({ pid: process.pid, at: new Date().toISOString() })}\n`;
376
361
  try {
377
362
  const fd = openSync(file, "wx");
@@ -382,15 +367,86 @@ function tryLock(file) {
382
367
  }
383
368
  return { text };
384
369
  } catch (e) {
385
- const st = lstatSync(file, { throwIfNoEntry: false });
386
- if (!st && e.code !== "EEXIST") throw e;
387
- if (st && !st.isFile()) return { error: `시작 잠금 자리에 일반 파일이 아닌 것(링크나 폴더)이 있어 거부: ${file}` };
388
- const seen = st ? readText(file) : null;
389
- if (st && seen !== null && !lockBusy(seen, st)) takeOver(file, seen);
390
- return {};
370
+ return { code: e.code };
371
+ }
372
+ }
373
+
374
+ // lstat that answers undefined for a missing file and null for one busy being deleted (the next try sees).
375
+ function lstatNow(file) {
376
+ try {
377
+ return lstatSync(file, { throwIfNoEntry: false });
378
+ } catch (e) {
379
+ if (BUSY.has(e.code)) return null;
380
+ throw e;
391
381
  }
392
382
  }
393
383
 
384
+ // rm that leaves a busy file for the next try (a lock left so has a dead pid once this start ends: stale).
385
+ function rmNow(file) {
386
+ try {
387
+ rmSync(file, { force: true });
388
+ } catch (e) {
389
+ if (!BUSY.has(e.code)) throw e;
390
+ }
391
+ }
392
+
393
+ // Removes a break lock older than BREAK_STALE_MS (clearing takes milliseconds, so its start is taken as dead or
394
+ // stuck), and only when it is still the text judged old when read again (a newer break lock is left alone).
395
+ function clearStaleBreak(brk, st) {
396
+ const text = readText(brk);
397
+ const at = Date.parse(lockInfo(text)?.at);
398
+ if (Date.now() - (Number.isNaN(at) ? st.mtimeMs : at) > BREAK_STALE_MS && readText(brk) === text) rmNow(brk);
399
+ }
400
+
401
+ // The break lock could not be made (code): another start holds it, or it was left. { error } for a link or a folder.
402
+ function breakHeld(brk, code) {
403
+ const st = BUSY.has(code) ? lstatNow(brk) : undefined;
404
+ if (st && !st.isFile()) return { error: `시작 잠금 정리 자리에 일반 파일이 아닌 것(링크나 폴더)이 있어 거부: ${brk}` };
405
+ if (st) clearStaleBreak(brk, st);
406
+ return {};
407
+ }
408
+
409
+ // Removes a stale lock (seen: its text as judged). Only the start holding <lock>.break does it, and only when the lock
410
+ // read again under the break lock is the same text and still not busy, so a lock another start made after the
411
+ // first read is never removed. Another start holding the break lock leaves the work to it. Returns { error } when a
412
+ // link or a folder sits at the break lock's name, else {}.
413
+ function breakStale(file, seen) {
414
+ const brk = `${file}.break`;
415
+ const made = makeLockFile(brk);
416
+ if (!made.text) return breakHeld(brk, made.code);
417
+ try {
418
+ const st = lstatNow(file);
419
+ if (st && st.isFile() && readText(file) === seen && !lockBusy(seen, st)) rmNow(file);
420
+ } finally {
421
+ if (readText(brk) === made.text) rmNow(brk);
422
+ }
423
+ return {};
424
+ }
425
+
426
+ // Makes the lock or looks at what is there: { text } made, or { st } (undefined: gone, null: busy being deleted).
427
+ // When nothing is there and the open failed for another reason than EEXIST (no write access and the like), one more
428
+ // open (a Windows file being deleted can be gone by the lstat), then it throws.
429
+ function makeOrSee(file) {
430
+ for (let i = 0; ; i++) {
431
+ const made = makeLockFile(file);
432
+ if (made.text) return made;
433
+ const st = lstatNow(file);
434
+ if (st !== undefined || made.code === "EEXIST") return { st };
435
+ if (i) throw Object.assign(new Error(`시작 잠금을 만들지 못함(${made.code}): ${file}`), { code: made.code });
436
+ }
437
+ }
438
+
439
+ // One try at the lock: { text } held, { error } refused, or {} busy.
440
+ function tryLock(file) {
441
+ const got = makeOrSee(file);
442
+ if (got.text) return got;
443
+ const { st } = got;
444
+ if (!st) return {}; // gone or busy being deleted: the next try sees
445
+ if (!st.isFile()) return { error: `시작 잠금 자리에 일반 파일이 아닌 것(링크나 폴더)이 있어 거부: ${file}` };
446
+ const seen = readText(file);
447
+ return seen !== null && !lockBusy(seen, st) ? breakStale(file, seen) : {};
448
+ }
449
+
394
450
  // logs/<name>.start.lock, waiting up to LOCK_WAIT_MS for another start: { text } or { error }.
395
451
  async function takeLock(file) {
396
452
  for (const until = Date.now() + LOCK_WAIT_MS; ; await sleep(100)) {
@@ -408,7 +464,7 @@ async function start(conf) {
408
464
  try {
409
465
  return await startLocked(conf);
410
466
  } finally {
411
- if (readText(lock) === held.text) rmSync(lock, { force: true });
467
+ if (readText(lock) === held.text) rmNow(lock);
412
468
  }
413
469
  }
414
470
 
@@ -14,7 +14,8 @@
14
14
  | `scripts/pm-sources.mjs` | events.jsonl, git log, 워크플로 journal, `items.json` 읽기. 스토리와 라벨 판정 함수, 시각 해석, 읽기 캐시 |
15
15
  | `scripts/pm-html.mjs` | escape, 색 검사, 유한 수 검사, 글 폭 추정, 줄바꿈, 공통 CSS, 탭 머리 |
16
16
  | `scripts/pm-render.mjs` | 5탭, 단일 HTML 스냅숏(`export`), workflow 배치, 구성도 자동 배치 |
17
- | `scripts/pm-adr.mjs` | ADR 탭: `adr.json` 두 층 스키마 검사, 참조 확인과 정리(`check --adr-cleanup`), `docs/adr/` 갈라짐 경고, 밀도, 렌더 |
17
+ | `scripts/pm-adr.mjs` | ADR 탭 검사: `adr.json` 두 층 스키마 검사, 참조 확인과 정리(`check --adr-cleanup`), `docs/adr/` 갈라짐 경고, 밀도, 집계, check 대조 기록(`adr-check.json`) |
18
+ | `scripts/pm-adr-view.mjs` | ADR 탭 렌더: 화면 본문과 check 대조 배지 |
18
19
  | `scripts/pm-server.mjs` | `serve`, `start`, `stop`, `status`. 바인드, Host 검사, 신원 경로, 상태 파일, 포트 선택, 로그 |
19
20
  | `scripts/pm-data.mjs` | `init`, `item`, `note`, `stage`, `story`(`items.json` 잠금과 낡은 잠금 정리) |
20
21
  | `scripts/pm-memory.mjs` | 메모리 규칙 등록, 옛 판 규칙 줄의 제자리 교체(첫 원본 `.progress-mon.bak`), 규칙 로드 한도 측정 |
@@ -41,7 +42,7 @@
41
42
  - 시험은 임시 HOME, 임시 프로젝트, OS 가 고른 빈 포트에서 돈다. 포트 30000, 3456, 3458 은 쓰지 않는다
42
43
  - 시험은 PATH 의 `git` 으로 임시 저장소를 만든다. git 이 없는 PC 에서는 그 시험 10개가 실패한다(2026-10-30, PATH 에 node 와 mkfifo 만 두고 실측). 스킬 자체는 git 이 없으면 커밋 기록 없이 동작
43
44
  - FIFO 와 `/dev/zero` 시험은 POSIX 만. Windows 에서는 이유를 밝히고 건너뛴다
44
- - ADR 화면 e2e 는 `npm root -g` 의 전역 Playwright Chromium 으로 `export` 한 HTML 을 연다. Playwright 가 없거나 Chromium 을 띄우지 못하면 사유를 밝히고 건너뛴다. `PM_ADR_SHOT=<png 경로>` 를 주면 스크린숏을 그 경로에 남긴다
45
+ - ADR 화면 e2e 는 Playwright Chromium 으로 `export` 한 HTML 을 연다. Playwright 는 `BUILD_TESTBOX_PLAYWRIGHT_DIR`(테스트박스 도구 폴더, `node_modules` 를 담은 폴더)이 있으면 그곳, 없으면 `npm root -g` 에서 찾는다. Playwright 가 없거나 Chromium 을 띄우지 못하면 사유를 밝히고 건너뛴다. `PM_ADR_SHOT=<png 경로>` 를 주면 스크린숏을 그 경로에 남긴다
45
46
 
46
47
  ## 개발
47
48
 
@@ -103,7 +104,7 @@ override 에 banker 블록이나 이 스킬 블록만 있으면 `init`, `status`
103
104
  ```json
104
105
  { "name": "progress-mon", "type": "skill", "target": "both",
105
106
  "reason": "project progress web screen with five tabs (progress, requirements, workflow, architecture, ADR) on a local server; init reads the code and writes the tab data, registers a one-line update rule in project memory; item, note, stage, story keep it current; pure Node, no deps",
106
- "supportingFiles": ["README.md", "references/adr.md", "references/arch.md", "references/authoring.md", "references/config.md", "references/flow.md", "references/inventory.md", "references/items.md", "references/req-spec.md", "references/reqs.md", "scripts/index.js", "scripts/pm-adr.mjs", "scripts/pm-check.mjs", "scripts/pm-config.mjs", "scripts/pm-data.mjs", "scripts/pm-html.mjs", "scripts/pm-memory.mjs", "scripts/pm-render.mjs", "scripts/pm-server.mjs", "scripts/pm-sources.mjs", "scripts/progress-mon.mjs", "assets/fixture/README.md", "assets/fixture/config.json", "assets/fixture/events.jsonl", "assets/fixture/items.json", "assets/fixture/commits.json", "assets/fixture/req-spec.json", "assets/fixture/reqs.json", "assets/fixture/flow.json", "assets/fixture/arch.json", "assets/fixture/adr.json", "assets/fixture/workflows/wf_ex1/journal.jsonl", "assets/fixture/workflows/wf_ex1/agent-a01.jsonl", "assets/fixture/workflows/wf_ex1/agent-a02.jsonl", "assets/fixture/workflows/wf_ex1/agent-a03.jsonl", "assets/fixture/workflows/wf_ex1/agent-a04.jsonl", "assets/fixture/variants/arch-auto/arch.json", "assets/fixture/variants/check-auto/arch.json", "assets/fixture/variants/check-overlap/arch.json", "assets/fixture/variants/history-manual/config.json", "assets/fixture/variants/history-manual/items.json", "assets/fixture/variants/workflow-wide/flow.json", "assets/fixture/variants/legacy-init/rules.json", "assets/fixture/variants/legacy-init/state.json"],
107
+ "supportingFiles": ["README.md", "references/adr.md", "references/arch.md", "references/authoring.md", "references/config.md", "references/flow.md", "references/inventory.md", "references/items.md", "references/req-spec.md", "references/reqs.md", "scripts/index.js", "scripts/pm-adr.mjs", "scripts/pm-adr-view.mjs", "scripts/pm-check.mjs", "scripts/pm-config.mjs", "scripts/pm-data.mjs", "scripts/pm-html.mjs", "scripts/pm-memory.mjs", "scripts/pm-render.mjs", "scripts/pm-server.mjs", "scripts/pm-sources.mjs", "scripts/progress-mon.mjs", "assets/fixture/README.md", "assets/fixture/config.json", "assets/fixture/events.jsonl", "assets/fixture/items.json", "assets/fixture/commits.json", "assets/fixture/req-spec.json", "assets/fixture/reqs.json", "assets/fixture/flow.json", "assets/fixture/arch.json", "assets/fixture/adr.json", "assets/fixture/workflows/wf_ex1/journal.jsonl", "assets/fixture/workflows/wf_ex1/agent-a01.jsonl", "assets/fixture/workflows/wf_ex1/agent-a02.jsonl", "assets/fixture/workflows/wf_ex1/agent-a03.jsonl", "assets/fixture/workflows/wf_ex1/agent-a04.jsonl", "assets/fixture/variants/arch-auto/arch.json", "assets/fixture/variants/check-auto/arch.json", "assets/fixture/variants/check-overlap/arch.json", "assets/fixture/variants/history-manual/config.json", "assets/fixture/variants/history-manual/items.json", "assets/fixture/variants/workflow-wide/flow.json", "assets/fixture/variants/legacy-init/rules.json", "assets/fixture/variants/legacy-init/state.json"],
107
108
  "caveat": "needs Node 18 or later (above the package engines floor); binds 127.0.0.1 only by default, --host takes a private IPv4 with a warning; data lives in .omc/progress-mon/ (Codex .omx/progress-mon/); on Codex start, stop, status and init each ask for sandbox_permissions require_escalated, and a long-lived screen runs from the user's terminal (serve) since closing the session may stop the server" }
108
109
  ```
109
110
 
@@ -114,7 +114,7 @@ ADR 탭이 생기기 전 판으로 init 한 프로젝트는 `adr.json` 이 없
114
114
  | `note` | `--key <k> [--note <t>] [--issue <t>]` | 단계(`stage:<id>`)나 스토리 행의 비고, 문제점. 빈 값은 그 칸을 지움 |
115
115
  | `stage` | `--id <id> --status started\|done\|skipped\|failed\|stopped [--label <t>]` | 단계 상태 기록. 설정에 없는 id 는 새 단계 행 |
116
116
  | `story` | `--id <id> (--status todo\|doing\|done \| --commit <hash>) [--name <t>]` | 스토리 상태나 완료 커밋 기록. 설정에 없는 id 는 새 스토리 행 |
117
- | `check` | `[--adr-cleanup]` | 자료 파일 검사. `FAIL <파일> <json 경로> <이유>`, `WARN ...` 한 줄씩, 끝에 `check: FAIL n, WARN m` 과 밀도 요약 `density <탭> ok\|low`, 마지막 줄 `adr 근거 없음 n칸, 미검증 n, 구현 못 함 n, 참조 없는 서술 n`(늘. 참조 없는 서술은 `refs` 에 확인된 참조가 0개인 `optimization`, `scaling` 칸 수로 규칙은 WARN 이지만 0 이 아니면 화면 배지가 빨강). 밀도 미달 줄 `WARN density <탭> <항목> <값>/<하한>[: <못 채운 대상>]`. `--adr-cleanup` 은 먼저 init 정리 단계를 `adr.json` 에 적용(참조 FAIL 은 `gap`, 스키마 FAIL 이 남으면 같은 폴더의 `adr.json.rejected-<YYYYMMDD-HHMMSS>` 로 옮김. 이름이 있으면 `-2` 부터 번호)하고 `정리:` 줄(옮긴 경로 포함)을 낸 뒤 검사 |
117
+ | `check` | `[--adr-cleanup]` | 자료 파일 검사. `FAIL <파일> <json 경로> <이유>`, `WARN ...` 한 줄씩, 끝에 `check: FAIL n, WARN m` 과 밀도 요약 `density <탭> ok\|low`, 마지막 줄 `adr 근거 없음 n칸, 미검증 n, 구현 못 함 n, 참조 없는 서술 n`(늘. 참조 없는 서술은 `refs` 에 확인된 참조가 0개인 `optimization`, `scaling` 칸 수로 규칙은 WARN 이지만 0 이 아니면 화면 배지가 빨강). 밀도 미달 줄 `WARN density <탭> <항목> <값>/<하한>[: <못 채운 대상>]`. `--adr-cleanup` 은 먼저 init 정리 단계를 `adr.json` 에 적용(참조 FAIL 은 `gap`, 스키마 FAIL 이 남으면 같은 폴더의 `adr.json.rejected-<YYYYMMDD-HHMMSS>` 로 옮김. 이름이 있으면 `-2` 부터 번호)하고 `정리:` 줄(옮긴 경로 포함)을 낸 뒤 검사. 끝에 화면 대조용 `adr-check.json`(check 집계와 `adr.json` sha256)을 쓴다(`references/adr.md`) |
118
118
 
119
119
  - 공통 인자: `--runtime claude|codex`, `--project <dir>`, `--data <dir>`(자료 폴더 직접 지정)
120
120
  - 종료 코드: 0 성공(이미 그 상태 포함), 1 거부나 사용법 오류나 `check` FAIL, 2 예기치 못한 오류, 3 `status` 꺼짐
@@ -66,6 +66,13 @@ init 절차의 ADR 단계와 갱신 규칙(SKILL.md)에서 에이전트가 쓰
66
66
  - 화면은 빈 칸을 `근거 없음: <사유>` 로 보이고, 결정 목록에 결정별 3항목 채움 수(`n/3`)와 빈 항목을 보인다
67
67
  - 집계 배지 `근거 없음 n칸, 미검증 n, 구현 못 함 n, 참조 없는 서술 n`: adr.json 안의 `{ "gap" }` 수(여섯 항목 칸과 참조 자리), `status: "unverified"` 모듈 수, `notImplemented` 기능 수, `refs` 에 확인된 참조가 0개인 서술 칸 수. 화면 맨 위(프로젝트 요약 위)와 `check` 의 마지막 줄 `adr 근거 없음 n칸, 미검증 n, 구현 못 함 n, 참조 없는 서술 n` 에 늘 보임. 0 이면 0(adr.json 이 없으면 check 는 모두 0). 넷 가운데 하나라도 0 이 아니면 배지는 빨강(모든 모듈이 미검증이거나 서술 칸에 참조가 없는데 초록 0칸만 보이지 않게)
68
68
  - `check` 는 참조 확인을 마친 참조(FAIL, WARN 없음)만 센다. 화면은 파일을 읽지 않으므로 꼴이 맞고 `gap` 이 아닌 참조를 센다. 그래서 출력 파일 없는 `cmd` 하나만 적힌 칸은 화면에서 0, `check` 에서 1 로 셀 수 있다
69
+ - check 대조: `check` 명령은 끝에 자료 폴더의 `adr-check.json` 에 그 집계 글, 그때 `adr.json` 글(BOM 을 뗀 뒤)의 sha256, 시각(UTC)을 쓴다. `adr.json` 이 없거나 JSON 이 아니면 쓰지 않는다. 쓰지 못하면 stderr 에 `경고: adr-check.json 쓰기 실패(...)` 만 내고 검사 결과와 종료 코드는 그대로다
70
+ - 화면은 집계 배지 옆에 대조 배지를 보인다
71
+ - 기록이 없거나 꼴이 틀림: `check 기록 없음. 참조 확인은 check`
72
+ - sha256 이 다름: `check(<시각> UTC) 뒤 adr.json 이 바뀜. check 를 다시 실행하면 대조`
73
+ - sha256 과 집계 글이 같음: 초록 `check(<시각> UTC) 와 같음`
74
+ - 집계 글이 다름: 빨강 `check(<시각> UTC) 기준: <check 집계>. 화면은 적힌 참조로 세어 다름. 확인 못 한 참조는 check 의 참조 줄`
75
+ - 대조는 check 를 실행한 때의 참조 파일 기준이다. 그 뒤 참조 파일만 바뀌면(adr.json 은 그대로) 배지는 그대로이므로 다시 확인하려면 check 를 실행한다
69
76
 
70
77
  ## 참조
71
78
 
@@ -0,0 +1,101 @@
1
+ // pm-adr-view.mjs: ADR 탭 화면 렌더. 검사, 밀도, 집계는 pm-adr.mjs.
2
+ // 화면은 참조를 글로만 보이고 파일을 읽거나 링크하지 않는다. 참조 확인은 check 가 하고, 그 집계는 adr-check.json 으로 대조한다.
3
+ import { DECISION_SLOTS, STATUS, adrTally, arr, isGap, obj, str } from './pm-adr.mjs';
4
+ import { esc } from './pm-html.mjs';
5
+
6
+ const gapHtml = (v) => `<span class="b miss">근거 없음: ${esc(v.gap)}</span>`;
7
+
8
+ // 참조 한 줄 글. 파일을 읽거나 링크하지 않는다.
9
+ export function refText(x) {
10
+ if (!obj(x)) return '';
11
+ if (isGap(x)) return gapHtml(x);
12
+ const note = typeof x.note === 'string' && x.note ? ` <small>${esc(x.note)}</small>` : '';
13
+ const line = Number.isInteger(x.line) ? `:${x.line}` : '';
14
+ const out = {
15
+ file: () => `파일 <code>${esc(x.path)}${line}</code>`,
16
+ test: () => `시험 <code>${esc(x.path)}</code> 의 ${esc(x.name)}`,
17
+ commit: () => `커밋 <code>${esc(String(x.hash || '').slice(0, 7))}</code>`,
18
+ cmd: () => `명령 <code>${esc(x.cmd)}</code>, 출력 ${x.output ? `<code>${esc(x.output)}</code>` : '없음'}`,
19
+ }[x.kind];
20
+ return out ? out() + note : esc(JSON.stringify(x)) + note;
21
+ }
22
+
23
+ const refsHtml = (list) => arr(list).map(refText).filter(Boolean).join('<br>') || '<span class="n">참조 없음</span>';
24
+ const table = (head, rows) => `<table><thead><tr>${head.map((h) => `<th>${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.join('') || `<tr><td colspan="${head.length}" class="n">없음</td></tr>`}</tbody></table>`;
25
+ const td = (h, cls = '') => `<td${cls ? ` class="${cls}"` : ''}>${h}</td>`;
26
+ const sec = (title, body) => `<h3>${esc(title)}</h3>${body}`;
27
+ // 칸이 gap 이면 근거 없음, 아니면 body(값).
28
+ const slotHtml = (v, body) => (isGap(v) ? `<div>${gapHtml(v)}</div>` : obj(v) || Array.isArray(v) ? body(v) : `<div>${gapHtml({ gap: '칸 없음' })}</div>`);
29
+
30
+ const evidenceHtml = (x) => (x.status === 'unverified' ? `<span class="b unv">미검증: ${esc(x.why)}</span>${arr(x.evidence).length ? `<br>${refsHtml(x.evidence)}` : ''}` : refsHtml(x.evidence));
31
+
32
+ function verificationHtml(v) {
33
+ return table(['id', '단위 기능', '결과', 'evidence'], arr(v).filter(obj).map((x) => `<tr>${td(esc(x.id), 'h')}${td(esc(x.module))}${td(esc(x.result || '-'))}${td(evidenceHtml(x), 'n')}</tr>`));
34
+ }
35
+
36
+ function flowHtml(f) {
37
+ const rows = [['입력', f.input], ['처리', f.logic], ['출력', f.output]].map(([k, t]) => `<tr>${td(k, 't')}${td(str(t) ? esc(t) : gapHtml({ gap: '빈 칸' }))}</tr>`);
38
+ return `${table(['구분', '내용'], rows)}<div class="meta">참조: ${refsHtml(f.refs)}</div>`;
39
+ }
40
+
41
+ function proofHtml(p) {
42
+ const impl = arr(p.implemented).filter(obj).map((x) => `<tr>${td(esc(x.feature))}${td(esc(arr(x.modules).join(', ')), 'h')}${td(refsHtml(x.proofs), 'n')}</tr>`);
43
+ const not = arr(p.notImplemented).filter(obj).map((x) => `<tr>${td(esc(x.feature))}${td(esc(x.why))}</tr>`);
44
+ return `<div class="meta">구현된 기능과 증명 위치</div>${table(['구현된 기능', '모듈', '증명 위치'], impl)}<div class="meta">구현 못 한 기능</div>${table(['구현 못 한 기능', '사유'], not)}`;
45
+ }
46
+
47
+ const filled = (d) => DECISION_SLOTS.filter(([k]) => obj(d[k]) && !isGap(d[k])).length;
48
+ const emptyNames = (d) => DECISION_SLOTS.filter(([k]) => !(obj(d[k]) && !isGap(d[k]))).map(([, n]) => n);
49
+
50
+ function decisionRow(d) {
51
+ const miss = emptyNames(d);
52
+ const st = STATUS[d.status] || d.status;
53
+ return `<tr>${td(esc(d.id), 'h')}${td(esc(d.title))}${td(esc(st))}${td(esc(d.date || '-'), 't')}${td(`${filled(d)}/3`, 'd')}${td(miss.length ? `<span class="b miss">${esc(miss.join(', '))}</span>` : '-', 'n')}</tr>`;
54
+ }
55
+
56
+ function tradeoffsHtml(t) {
57
+ const gave = arr(t.gaveUp).filter(obj).map((x) => `<tr>${td(esc(x.what))}${td(esc(x.why))}</tr>`);
58
+ const got = arr(t.gained).filter(obj).map((x) => `<tr>${td(esc(x.what))}${td(esc(x.purpose))}</tr>`);
59
+ return `${table(['포기한 것', '이유'], gave)}${table(['얻은 것', '목적'], got)}`;
60
+ }
61
+
62
+ function decisionHtml(d) {
63
+ const opts = arr(d.options).filter(obj).map((x) => `<tr>${td(esc(x.name))}${td(esc(x.pros))}${td(esc(x.cons))}</tr>`);
64
+ const rejected = str(d.rejected) ? `<div class="meta">버린 선택지: ${esc(d.rejected)}</div>` : '';
65
+ const optim = (o) => `<div>${esc(o.process)}</div><div class="meta">참조한 모듈: ${esc(arr(o.modules).join(', ') || '없음')}</div><div class="meta">참조: ${refsHtml(o.refs)}</div>`;
66
+ const scale = (s) => `<div>${esc(s.argument)}</div>${str(s.limits) ? `<div class="meta">한계: ${esc(s.limits)}</div>` : ''}<div class="meta">참조: ${refsHtml(s.refs)}</div>`;
67
+ return `<h2>${esc(d.id)} ${esc(d.title)} <small>${esc(STATUS[d.status] || d.status)}</small></h2>
68
+ <div class="meta">맥락: ${esc(d.context)}</div>${table(['선택지', '장점', '단점'], opts)}${rejected}
69
+ <div><b>결론</b>: ${esc(d.decision)}</div><div class="meta">이유: ${esc(d.why)}</div>
70
+ ${sec('최적화 로직', slotHtml(d.optimization, optim))}${sec('확장 대응', slotHtml(d.scaling, scale))}${sec('포기와 얻은 것', slotHtml(d.tradeoffs, tradeoffsHtml))}
71
+ <div class="meta">근거 출처: ${refsHtml(d.sources)}</div>`;
72
+ }
73
+
74
+ // check 대조. 화면은 적힌 참조로, check 는 확인을 마친 참조로 센다(깨진 참조가 있으면 참조 없는 서술 수가 다름).
75
+ // m.adrCheck: check 가 남긴 adr-check.json({ sha, tally, at }), m.adr.sha: 지금 adr.json 의 sha256. text: 화면 집계 글.
76
+ export function checkNote(m, text) {
77
+ const rec = m.adrCheck && obj(m.adrCheck.value);
78
+ const b = (cls, t) => `<span class="b ${cls}">${esc(t)}</span>`;
79
+ if (!rec || typeof rec.sha !== 'string' || typeof rec.tally !== 'string') return b('unv', 'check 기록 없음. 참조 확인은 check');
80
+ const at = typeof rec.at === 'string' ? rec.at.slice(0, 19).replace('T', ' ') : '';
81
+ const when = at ? `check(${at} UTC)` : 'check';
82
+ if (rec.sha !== (m.adr && m.adr.sha)) return b('unv', `${when} 뒤 adr.json 이 바뀜. check 를 다시 실행하면 대조`);
83
+ if (rec.tally === text) return b('done', `${when} 와 같음`);
84
+ return b('miss', `${when} 기준: ${rec.tally}. 화면은 적힌 참조로 세어 다름. 확인 못 한 참조는 check 의 참조 줄`);
85
+ }
86
+
87
+ // ADR 탭 본문. 자료가 없거나 깨졌으면 다른 탭처럼 안내와 스키마 문서.
88
+ export function adrBody(m, emptyNotice) {
89
+ const v = m.adr && obj(m.adr.value);
90
+ if (!v) return emptyNotice('references/adr.md');
91
+ const p = obj(v.project) || {};
92
+ const ds = arr(v.decisions).filter(obj);
93
+ const tally = adrTally(v);
94
+ return `<div class="meta">의사 결정 기록. 위는 프로젝트 전체(검증, 흐름, 증거), 아래는 결정마다(최적화 로직, 확장 대응, 포기와 얻은 것). 자료: <code>adr.json</code>. 참조는 위치만 보이고 이 화면은 파일을 열지 않음. 참조 확인은 check.</div>
95
+ <div class="meta"><span class="b ${tally.any ? 'miss' : 'done'}">${esc(tally.text)}</span> ${checkNote(m, tally.text)}</div>
96
+ <h2>프로젝트 요약</h2>
97
+ ${sec('검증', slotHtml(p.verification, verificationHtml))}${sec('흐름', slotHtml(p.flow, flowHtml))}${sec('증거', slotHtml(p.proof, proofHtml))}
98
+ <h2>결정 목록 <small>${ds.length}건</small></h2>
99
+ ${table(['id', '제목', '상태', '날짜', '결정별 3항목', '빈 항목'], ds.map(decisionRow))}
100
+ ${ds.map(decisionHtml).join('\n')}`;
101
+ }
@@ -1,27 +1,29 @@
1
- // pm-adr.mjs: ADR 탭. adr.json 두 층 스키마 검사, 참조 검증과 gap 정리, docs/adr 갈라짐 경고, 밀도, 렌더.
1
+ // pm-adr.mjs: ADR 탭의 검사. adr.json 두 층 스키마 검사, 참조 검증과 gap 정리, docs/adr 갈라짐 경고, 밀도, 집계, check 대조 기록.
2
+ // 화면 렌더는 pm-adr-view.mjs.
2
3
  // 두 층: project(검증, 흐름, 증거)는 프로젝트에 하나, decisions[](최적화 로직, 확장 대응, 포기와 얻은 것)는 결정마다.
3
4
  // 서버 화면은 참조를 글로만 보이고 파일을 읽거나 링크하지 않는다. 참조 파일은 check 에서만 크기 상한 안의 일반 파일로 읽는다.
4
5
  // 스키마 위반은 FAIL, 근거가 없어 비운 항목({ gap: 사유 })은 밀도 WARN, 확인 못 한 참조는 FAIL 이나 WARN(references/adr.md).
6
+ import crypto from 'node:crypto';
5
7
  import fs from 'node:fs';
6
8
  import path from 'node:path';
7
9
  import { git, noBom, readRegular, writeAtomic } from './pm-config.mjs';
8
- import { esc } from './pm-html.mjs';
9
10
 
10
11
  const ADR_FILE = 'adr.json';
12
+ const ADR_CHECK_FILE = 'adr-check.json';
11
13
  const REF_READ_MAX = 4 * 1024 * 1024;
12
- const STATUS = { accepted: '채택', proposed: '제안', superseded: '대체됨' };
14
+ export const STATUS = { accepted: '채택', proposed: '제안', superseded: '대체됨' };
13
15
  const REF_KINDS = ['file', 'test', 'commit', 'cmd'];
14
16
  const HASH = /^[0-9a-f]{4,40}$/i;
15
- const DECISION_SLOTS = [
17
+ export const DECISION_SLOTS = [
16
18
  ['optimization', '최적화 로직'],
17
19
  ['scaling', '확장 대응'],
18
20
  ['tradeoffs', '포기와 얻은 것'],
19
21
  ];
20
22
 
21
- const arr = (v) => (Array.isArray(v) ? v : []);
22
- const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);
23
- const str = (v) => typeof v === 'string' && v.trim().length > 0;
24
- const isGap = (v) => Boolean(obj(v)) && Object.hasOwn(v, 'gap');
23
+ export const arr = (v) => (Array.isArray(v) ? v : []);
24
+ export const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);
25
+ export const str = (v) => typeof v === 'string' && v.trim().length > 0;
26
+ export const isGap = (v) => Boolean(obj(v)) && Object.hasOwn(v, 'gap');
25
27
  // json 경로 조각(['decisions', 0, 'scaling']) 을 글(decisions[0].scaling)로.
26
28
  const jp = (parts) => parts.reduce((a, k) => (typeof k === 'number' ? `${a}[${k}]` : a ? `${a}.${k}` : k), '') || '$';
27
29
 
@@ -338,7 +340,10 @@ function divergence(ctx, v) {
338
340
  return names.sort().filter((n) => !pointed.has(`docs/adr/${n}`)).map((n) => `docs/adr/${n} 를 가리키는 결정 없음`);
339
341
  }
340
342
 
341
- // adr.json 읽기. 돌려줌: { missing } | { error } | { value }
343
+ // adr.json 글(BOM 을 뗀 뒤)의 sha256. check 대조 기록과 화면이 같은 adr.json 을 보았는지 가린다.
344
+ export const adrSha = (text) => crypto.createHash('sha256').update(text, 'utf8').digest('hex');
345
+
346
+ // adr.json 읽기. 돌려줌: { missing } | { error } | { value, sha }
342
347
  function readAdr(dataDir) {
343
348
  let text;
344
349
  try {
@@ -346,32 +351,43 @@ function readAdr(dataDir) {
346
351
  } catch (e) {
347
352
  return e.code === 'ENOENT' ? { missing: true } : { error: `읽기 실패(${e.code || 'error'})` };
348
353
  }
354
+ const body = noBom(text);
349
355
  try {
350
- return { value: JSON.parse(noBom(text)) };
356
+ return { value: JSON.parse(body), sha: adrSha(body) };
351
357
  } catch (e) {
352
358
  return { error: `JSON 아님: ${e.message}` };
353
359
  }
354
360
  }
355
361
 
356
- // 검사 전체(check 와 정리가 같이 씀). 돌려줌: { missing } | { error } | { value, S, diverge }
362
+ // 검사 전체(check 와 정리가 같이 씀). 돌려줌: { missing } | { error } | { value, sha, S, diverge }
357
363
  function inspectAdr(ctx, config) {
358
364
  const got = readAdr(ctx.dataDir);
359
365
  if (!Object.hasOwn(got, 'value')) return got;
360
366
  const S = examine(got.value, ctx, config);
361
367
  const diverge = S.schema.length ? [] : divergence(ctx, got.value);
362
- return { value: got.value, S, diverge };
368
+ return { value: got.value, sha: got.sha, S, diverge };
363
369
  }
364
370
 
365
- // check 의 ADR 부분. r: pm-check 의 sink. 돌려줌: { value: 밀도에 쓸 값, bare: 참조 없는 서술 칸 } (없거나 깨졌으면 null).
371
+ // check 의 ADR 부분. r: pm-check 의 sink. 돌려줌: { value: 밀도에 쓸 값, bare: 참조 없는 서술 칸, sha: adr.json sha256 }.
372
+ // adr.json 이 없거나 깨졌으면 { value: null, bare: [], sha: null }.
373
+ const NO_ADR = () => ({ value: null, bare: [], sha: null });
366
374
  export function checkAdr(ctx, config, r) {
367
375
  const got = inspectAdr(ctx, config);
368
- if (got.missing) return null;
369
- if (got.error) return r.fail(ADR_FILE, '$', got.error), null;
376
+ if (got.missing) return NO_ADR();
377
+ if (got.error) return r.fail(ADR_FILE, '$', got.error), NO_ADR();
370
378
  for (const x of [...got.S.schema, ...got.S.claim]) r.fail(ADR_FILE, x.p, x.msg);
371
379
  for (const x of got.S.refFail) r.fail(ADR_FILE, jp(x.parts), `참조: ${x.msg}`);
372
380
  for (const x of got.S.refWarn) r.warn(ADR_FILE, jp(x.parts), `참조: ${x.msg}`);
373
381
  for (const msg of got.diverge) r.warn(ADR_FILE, 'sources:', msg);
374
- return { value: got.value, bare: bareSlots(got.value, (p) => got.S.good.has(p)) };
382
+ return { value: got.value, bare: bareSlots(got.value, (p) => got.S.good.has(p)), sha: got.sha };
383
+ }
384
+
385
+ // check 대조 기록(adr-check.json): check 가 확인을 마친 참조로 센 집계 글과 그때 adr.json 의 sha256.
386
+ // 화면은 파일을 읽지 않고 적힌 참조로 세므로, 이 기록과 대조해 두 수가 다르거나 check 뒤 adr.json 이 바뀌었음을 알린다.
387
+ // rec: { sha, tally }. 쓰기 실패는 UsageError(writeAtomic).
388
+ export async function writeAdrCheck(dataDir, rec, now = Date.now()) {
389
+ const body = { sha: rec.sha, tally: rec.tally, at: new Date(now).toISOString() };
390
+ await writeAtomic(path.join(dataDir, ADR_CHECK_FILE), `${JSON.stringify(body, null, 2)}\n`);
375
391
  }
376
392
 
377
393
  // json 경로 조각 자리에 값을 넣는다.
@@ -497,89 +513,3 @@ export function adrTally(v, bare = bareSlots(v)) {
497
513
  const gaps = gapCount(v);
498
514
  return { text: `근거 없음 ${gaps}칸, 미검증 ${unverified}, 구현 못 함 ${notImplemented}, 참조 없는 서술 ${bare.length}`, any: gaps + unverified + notImplemented + bare.length > 0 };
499
515
  }
500
-
501
- // ---------- 렌더 ----------
502
-
503
- const gapHtml = (v) => `<span class="b miss">근거 없음: ${esc(v.gap)}</span>`;
504
-
505
- // 참조 한 줄 글. 파일을 읽거나 링크하지 않는다.
506
- export function refText(x) {
507
- if (!obj(x)) return '';
508
- if (isGap(x)) return gapHtml(x);
509
- const note = typeof x.note === 'string' && x.note ? ` <small>${esc(x.note)}</small>` : '';
510
- const line = Number.isInteger(x.line) ? `:${x.line}` : '';
511
- const out = {
512
- file: () => `파일 <code>${esc(x.path)}${line}</code>`,
513
- test: () => `시험 <code>${esc(x.path)}</code> 의 ${esc(x.name)}`,
514
- commit: () => `커밋 <code>${esc(String(x.hash || '').slice(0, 7))}</code>`,
515
- cmd: () => `명령 <code>${esc(x.cmd)}</code>, 출력 ${x.output ? `<code>${esc(x.output)}</code>` : '없음'}`,
516
- }[x.kind];
517
- return out ? out() + note : esc(JSON.stringify(x)) + note;
518
- }
519
-
520
- const refsHtml = (list) => arr(list).map(refText).filter(Boolean).join('<br>') || '<span class="n">참조 없음</span>';
521
- const table = (head, rows) => `<table><thead><tr>${head.map((h) => `<th>${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.join('') || `<tr><td colspan="${head.length}" class="n">없음</td></tr>`}</tbody></table>`;
522
- const td = (h, cls = '') => `<td${cls ? ` class="${cls}"` : ''}>${h}</td>`;
523
- const sec = (title, body) => `<h3>${esc(title)}</h3>${body}`;
524
- // 칸이 gap 이면 근거 없음, 아니면 body(값).
525
- const slotHtml = (v, body) => (isGap(v) ? `<div>${gapHtml(v)}</div>` : obj(v) || Array.isArray(v) ? body(v) : `<div>${gapHtml({ gap: '칸 없음' })}</div>`);
526
-
527
- const evidenceHtml = (x) => (x.status === 'unverified' ? `<span class="b unv">미검증: ${esc(x.why)}</span>${arr(x.evidence).length ? `<br>${refsHtml(x.evidence)}` : ''}` : refsHtml(x.evidence));
528
-
529
- function verificationHtml(v) {
530
- return table(['id', '단위 기능', '결과', 'evidence'], arr(v).filter(obj).map((x) => `<tr>${td(esc(x.id), 'h')}${td(esc(x.module))}${td(esc(x.result || '-'))}${td(evidenceHtml(x), 'n')}</tr>`));
531
- }
532
-
533
- function flowHtml(f) {
534
- const rows = [['입력', f.input], ['처리', f.logic], ['출력', f.output]].map(([k, t]) => `<tr>${td(k, 't')}${td(str(t) ? esc(t) : gapHtml({ gap: '빈 칸' }))}</tr>`);
535
- return `${table(['구분', '내용'], rows)}<div class="meta">참조: ${refsHtml(f.refs)}</div>`;
536
- }
537
-
538
- function proofHtml(p) {
539
- const impl = arr(p.implemented).filter(obj).map((x) => `<tr>${td(esc(x.feature))}${td(esc(arr(x.modules).join(', ')), 'h')}${td(refsHtml(x.proofs), 'n')}</tr>`);
540
- const not = arr(p.notImplemented).filter(obj).map((x) => `<tr>${td(esc(x.feature))}${td(esc(x.why))}</tr>`);
541
- return `<div class="meta">구현된 기능과 증명 위치</div>${table(['구현된 기능', '모듈', '증명 위치'], impl)}<div class="meta">구현 못 한 기능</div>${table(['구현 못 한 기능', '사유'], not)}`;
542
- }
543
-
544
- const filled = (d) => DECISION_SLOTS.filter(([k]) => obj(d[k]) && !isGap(d[k])).length;
545
- const emptyNames = (d) => DECISION_SLOTS.filter(([k]) => !(obj(d[k]) && !isGap(d[k]))).map(([, n]) => n);
546
-
547
- function decisionRow(d) {
548
- const miss = emptyNames(d);
549
- const st = STATUS[d.status] || d.status;
550
- return `<tr>${td(esc(d.id), 'h')}${td(esc(d.title))}${td(esc(st))}${td(esc(d.date || '-'), 't')}${td(`${filled(d)}/3`, 'd')}${td(miss.length ? `<span class="b miss">${esc(miss.join(', '))}</span>` : '-', 'n')}</tr>`;
551
- }
552
-
553
- function tradeoffsHtml(t) {
554
- const gave = arr(t.gaveUp).filter(obj).map((x) => `<tr>${td(esc(x.what))}${td(esc(x.why))}</tr>`);
555
- const got = arr(t.gained).filter(obj).map((x) => `<tr>${td(esc(x.what))}${td(esc(x.purpose))}</tr>`);
556
- return `${table(['포기한 것', '이유'], gave)}${table(['얻은 것', '목적'], got)}`;
557
- }
558
-
559
- function decisionHtml(d) {
560
- const opts = arr(d.options).filter(obj).map((x) => `<tr>${td(esc(x.name))}${td(esc(x.pros))}${td(esc(x.cons))}</tr>`);
561
- const rejected = str(d.rejected) ? `<div class="meta">버린 선택지: ${esc(d.rejected)}</div>` : '';
562
- const optim = (o) => `<div>${esc(o.process)}</div><div class="meta">참조한 모듈: ${esc(arr(o.modules).join(', ') || '없음')}</div><div class="meta">참조: ${refsHtml(o.refs)}</div>`;
563
- const scale = (s) => `<div>${esc(s.argument)}</div>${str(s.limits) ? `<div class="meta">한계: ${esc(s.limits)}</div>` : ''}<div class="meta">참조: ${refsHtml(s.refs)}</div>`;
564
- return `<h2>${esc(d.id)} ${esc(d.title)} <small>${esc(STATUS[d.status] || d.status)}</small></h2>
565
- <div class="meta">맥락: ${esc(d.context)}</div>${table(['선택지', '장점', '단점'], opts)}${rejected}
566
- <div><b>결론</b>: ${esc(d.decision)}</div><div class="meta">이유: ${esc(d.why)}</div>
567
- ${sec('최적화 로직', slotHtml(d.optimization, optim))}${sec('확장 대응', slotHtml(d.scaling, scale))}${sec('포기와 얻은 것', slotHtml(d.tradeoffs, tradeoffsHtml))}
568
- <div class="meta">근거 출처: ${refsHtml(d.sources)}</div>`;
569
- }
570
-
571
- // ADR 탭 본문. 자료가 없거나 깨졌으면 다른 탭처럼 안내와 스키마 문서.
572
- export function adrBody(m, emptyNotice) {
573
- const v = m.adr && obj(m.adr.value);
574
- if (!v) return emptyNotice('references/adr.md');
575
- const p = obj(v.project) || {};
576
- const ds = arr(v.decisions).filter(obj);
577
- const tally = adrTally(v);
578
- return `<div class="meta">의사 결정 기록. 위는 프로젝트 전체(검증, 흐름, 증거), 아래는 결정마다(최적화 로직, 확장 대응, 포기와 얻은 것). 자료: <code>adr.json</code>. 참조는 위치만 보이고 이 화면은 파일을 열지 않음. 참조 확인은 check.</div>
579
- <div class="meta"><span class="b ${tally.any ? 'miss' : 'done'}">${esc(tally.text)}</span></div>
580
- <h2>프로젝트 요약</h2>
581
- ${sec('검증', slotHtml(p.verification, verificationHtml))}${sec('흐름', slotHtml(p.flow, flowHtml))}${sec('증거', slotHtml(p.proof, proofHtml))}
582
- <h2>결정 목록 <small>${ds.length}건</small></h2>
583
- ${table(['id', '제목', '상태', '날짜', '결정별 3항목', '빈 항목'], ds.map(decisionRow))}
584
- ${ds.map(decisionHtml).join('\n')}`;
585
- }
@@ -14,7 +14,7 @@ import { ITEM_STATUS, STAGE_STATUS } from './pm-data.mjs';
14
14
  import { archLayout, archFlaws, flowLayout, nameFits, FLOW } from './pm-render.mjs';
15
15
  import { color, finite, textW, wrap, TABS } from './pm-html.mjs';
16
16
  import { memoryWarnings } from './pm-memory.mjs';
17
- import { checkAdr, adrRows, cleanAdr, adrTally } from './pm-adr.mjs';
17
+ import { checkAdr, adrRows, cleanAdr, adrTally, writeAdrCheck } from './pm-adr.mjs';
18
18
 
19
19
  const arr = (v) => (Array.isArray(v) ? v : []);
20
20
  const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);
@@ -531,7 +531,7 @@ function memoryInto(r, memory, project, env) {
531
531
  }
532
532
  }
533
533
 
534
- // 검사 전체. 돌려줌: { lines: [출력 줄], fails }
534
+ // 검사 전체. 자료를 바꾸지 않는다. 돌려줌: { lines: [출력 줄], fails, adr: check 대조 기록 { sha, tally } (adr.json 이 없거나 깨졌으면 null) }
535
535
  export function runCheck(ctx, env = process.env) {
536
536
  const r = sink();
537
537
  const config = checkConfig(ctx, r);
@@ -543,8 +543,8 @@ export function runCheck(ctx, env = process.env) {
543
543
  const flow = checkFlow(ctx.dataDir, r);
544
544
  const arch = checkArch(ctx.dataDir, r, config.text.plannedPrefix);
545
545
  const got = checkAdr(ctx, config, r);
546
- const adr = got && got.value;
547
- const adrBare = got ? got.bare : [];
546
+ const adr = got.value;
547
+ const adrBare = got.bare;
548
548
  const inv = checkInventory(ctx, r);
549
549
  const memory = memoryOf(ctx);
550
550
  memoryInto(r, memory, ctx.project, env);
@@ -553,7 +553,8 @@ export function runCheck(ctx, env = process.env) {
553
553
  // 밀도는 init 기록이나 inventory.json 이 있을 때만. 다만 ADR 밀도는 adr.json 이 있으면 늘 낸다
554
554
  const dens = inv || memory ? density({ arch, flow, req, items: items.items, inv, storyCount: ids.length, adr, adrBare }) : null;
555
555
  const rows = dens ? dens.rows : adr ? adrRows(adr, adrBare) : [];
556
- return finish(out, rows, dens, adrTally(adr, adrBare).text);
556
+ const tally = adrTally(adr, adrBare).text;
557
+ return { ...finish(out, rows, dens, tally), adr: got.sha ? { sha: got.sha, tally } : null };
557
558
  }
558
559
 
559
560
  // 밀도 미달 줄, 합계 줄, 탭별 밀도 요약, ADR 집계(근거 없음 칸, 미검증, 구현 못 함, 참조 없는 서술. 늘 한 줄)를 붙인다. 돌려줌: { lines, fails }
@@ -566,12 +567,20 @@ function finish(out, rows, dens, tally) {
566
567
  return { lines: out, fails };
567
568
  }
568
569
 
569
- // --adr-cleanup: 검사 전에 init 정리 단계(pm-adr.mjs cleanAdr)를 적용한다. 그 밖에는 자료를 바꾸지 않는다.
570
+ // --adr-cleanup: 검사 전에 init 정리 단계(pm-adr.mjs cleanAdr)를 적용한다. 그 밖에는 자료를 바꾸지 않고,
571
+ // 화면 대조용 adr-check.json 만 쓴다. 쓰지 못하면 stderr 경고(검사 결과와 종료 코드는 그대로).
570
572
  export async function command(cmd, ctx, o, io) {
571
573
  if (cmd !== 'check') throw new UsageError(`${cmd} 는 pm-check.mjs 가 맡지 않음`);
572
574
  if (o._.length > 1) throw new UsageError(`check 는 위치 인자를 받지 않음: ${o._.slice(1).join(' ')}`);
573
575
  if (o['adr-cleanup'] && ctx.exists) for (const l of await cleanAdr(ctx, loadConfig(ctx).config)) io.out(l);
574
- const { lines, fails } = runCheck(ctx);
576
+ const { lines, fails, adr } = runCheck(ctx);
575
577
  for (const l of lines) io.out(l);
578
+ if (adr) {
579
+ try {
580
+ await writeAdrCheck(ctx.dataDir, adr);
581
+ } catch (e) {
582
+ io.err(`경고: ${e.message}. 화면의 check 대조가 낡을 수 있음`);
583
+ }
584
+ }
576
585
  return fails ? 1 : 0;
577
586
  }
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import { esc, color, finite, textW, wrap, FAVICON, CSS, TABS, tabsHtml } from './pm-html.mjs';
7
7
  import { configOrFail, loadConfig, UsageError, DEFAULT_REQ_RULE, STORYLESS_REQ_RULE } from './pm-config.mjs';
8
8
  import { collect, parseTime, agentJudge } from './pm-sources.mjs';
9
- import { adrBody } from './pm-adr.mjs';
9
+ import { adrBody } from './pm-adr-view.mjs';
10
10
 
11
11
  const two = (n) => String(n).padStart(2, '0');
12
12
  const hm = (ms) => (ms == null ? '-' : Math.floor(ms / 3600e3) + ':' + two(Math.floor((ms % 3600e3) / 60000)));
@@ -7,6 +7,7 @@ import path from 'node:path';
7
7
  import vm from 'node:vm';
8
8
  import { execFileSync } from 'node:child_process';
9
9
  import { eventsPath, stagesFor, isDir, noBom, readRegular } from './pm-config.mjs';
10
+ import { adrSha } from './pm-adr.mjs';
10
11
 
11
12
  // ---------- 시각 ----------
12
13
 
@@ -67,6 +68,13 @@ const parseLines = (t) =>
67
68
 
68
69
  // JSON 파일. 돌려줌: { value } | { missing: true } | { error }
69
70
  export const readJsonFile = (file) => cached(file, parseJson);
71
+
72
+ // adr.json: 값과 글의 sha256(check 대조 기록 adr-check.json 과 견줌). 돌려줌: { value, sha } | { missing: true } | { error }
73
+ const parseAdr = (t) => ({ json: JSON.parse(t), sha: adrSha(t) });
74
+ export function readAdrFile(file) {
75
+ const r = cached(file, parseAdr);
76
+ return r.value ? { value: r.value.json, sha: r.value.sha } : r;
77
+ }
70
78
  // JSONL 파일의 객체 줄 목록. 없거나 못 읽으면 빈 목록.
71
79
  export const readLines = (file) => cached(file, parseLines).value || [];
72
80
 
@@ -497,6 +505,7 @@ export function collect(ctx, config, now, inject = {}) {
497
505
  reqs: tab('reqs.json'),
498
506
  flow: tab('flow.json'),
499
507
  arch: tab('arch.json'),
500
- adr: tab('adr.json'),
508
+ adr: readAdrFile(path.join(ctx.dataDir, 'adr.json')),
509
+ adrCheck: tab('adr-check.json'),
501
510
  };
502
511
  }
@@ -67,6 +67,13 @@ const readlinkOr = (path) => {
67
67
  return "?";
68
68
  }
69
69
  };
70
+ const nativeReal = (path) => {
71
+ try {
72
+ return realpathSync.native(path);
73
+ } catch {
74
+ return resolve(path);
75
+ }
76
+ };
70
77
  const realOrNull = (path) => {
71
78
  try {
72
79
  return realpathSync(path);
@@ -96,7 +103,8 @@ function fileProblem(st, file, uid) {
96
103
  return null;
97
104
  }
98
105
 
99
- // The file's stat and bytes, opened without blocking so a FIFO planted after the look never waits.
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).
100
108
  function readChecked(file, uid) {
101
109
  const first = statSync(file);
102
110
  const problem = fileProblem(first, file, uid);
@@ -105,7 +113,7 @@ function readChecked(file, uid) {
105
113
  try {
106
114
  const st = fstatSync(fd);
107
115
  const late = fileProblem(st, file, uid);
108
- return late ? { refuse: late } : { st, bytes: readFileSync(fd) };
116
+ return late ? { refuse: late } : { st, id: fstatSync(fd, { bigint: true }), bytes: readFileSync(fd) };
109
117
  } finally {
110
118
  closeSync(fd);
111
119
  }
@@ -233,7 +241,9 @@ export function planBlock(path, opts) {
233
241
  const next = nextBytes(read.bytes, o, at.file);
234
242
  if (next.refuse) return next.refuse;
235
243
  const changed = !next.bytes.equals(read.bytes);
236
- const key = `ino:${read.st.dev}:${read.st.ino}`;
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}`;
237
247
  return { ok: true, changed, path, file: at.file, key, before: read.bytes, after: next.bytes, mode: read.st.mode & 0o7777 };
238
248
  }
239
249