@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +17 -0
- package/codex/manifest.json +1 -1
- package/codex/skills/teamwork/scripts/teamwork.mjs +3 -3
- package/package.json +1 -1
- package/skills/git-init/scripts/marker-block.mjs +13 -3
- package/skills/init-dir/SKILL.md +1 -1
- package/skills/init-dir/scripts/marker-block.mjs +13 -3
- package/skills/init-dir/templates/scripts/README.md +4 -1
- package/skills/init-dir/templates/scripts/lib/service.mjs +87 -31
- package/skills/progress-mon/README.md +4 -3
- package/skills/progress-mon/SKILL.md +1 -1
- package/skills/progress-mon/references/adr.md +7 -0
- package/skills/progress-mon/scripts/pm-adr-view.mjs +101 -0
- package/skills/progress-mon/scripts/pm-adr.mjs +32 -102
- package/skills/progress-mon/scripts/pm-check.mjs +16 -7
- package/skills/progress-mon/scripts/pm-render.mjs +1 -1
- package/skills/progress-mon/scripts/pm-sources.mjs +10 -1
- package/skills/tune-tmux/scripts/marker-block.mjs +13 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "banker",
|
|
3
|
-
"version": "0.24.
|
|
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
|
package/codex/manifest.json
CHANGED
|
@@ -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 과
|
|
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.
|
|
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
|
-
|
|
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
|
|
package/skills/init-dir/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
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` 보다 오래되면 낡은 잠금으로 보고
|
|
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
|
|
36
|
-
*
|
|
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,
|
|
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
|
-
//
|
|
355
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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)
|
|
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
|
|
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 는
|
|
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
|
|
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
|
|
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(
|
|
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: 참조 없는 서술
|
|
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
|
|
369
|
-
if (got.error) return r.fail(ADR_FILE, '$', got.error),
|
|
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
|
|
547
|
-
const adrBare = got
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|