@kaydash9999/banker-plugins 0.23.3 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +56 -0
  4. package/README.md +14 -6
  5. package/codex/manifest.json +6 -2
  6. package/codex/skills/teamwork/scripts/teamwork.mjs +3 -3
  7. package/codex/transform-matrix.md +6 -2
  8. package/package.json +1 -1
  9. package/skills/build-testbox/SKILL.md +213 -0
  10. package/skills/build-testbox/scripts/args.mjs +156 -0
  11. package/skills/build-testbox/scripts/browser-cell.mjs +181 -0
  12. package/skills/build-testbox/scripts/browsers.mjs +164 -0
  13. package/skills/build-testbox/scripts/containers.mjs +237 -0
  14. package/skills/build-testbox/scripts/detect.mjs +166 -0
  15. package/skills/build-testbox/scripts/proc.mjs +91 -0
  16. package/skills/build-testbox/scripts/report.mjs +109 -0
  17. package/skills/build-testbox/scripts/setup.mjs +144 -0
  18. package/skills/build-testbox/scripts/testbox.mjs +148 -0
  19. package/skills/git-init/SKILL.md +152 -0
  20. package/skills/git-init/scripts/git-init.mjs +440 -0
  21. package/skills/git-init/scripts/marker-block.mjs +321 -0
  22. package/skills/git-init/scripts/payload-scan.mjs +186 -0
  23. package/skills/init-dir/SKILL.md +172 -0
  24. package/skills/init-dir/scripts/init-dir.mjs +308 -0
  25. package/skills/init-dir/scripts/marker-block.mjs +321 -0
  26. package/skills/init-dir/scripts/templates.mjs +100 -0
  27. package/skills/init-dir/templates/KB/README.md +15 -0
  28. package/skills/init-dir/templates/docs/README.md +14 -0
  29. package/skills/init-dir/templates/logs/README.md +14 -0
  30. package/skills/init-dir/templates/logs/gitignore +3 -0
  31. package/skills/init-dir/templates/mockup/README.md +7 -0
  32. package/skills/init-dir/templates/scripts/README.md +39 -0
  33. package/skills/init-dir/templates/scripts/lib/service.mjs +596 -0
  34. package/skills/init-dir/templates/scripts/restart.cmd +13 -0
  35. package/skills/init-dir/templates/scripts/restart.sh +10 -0
  36. package/skills/init-dir/templates/scripts/service.conf +5 -0
  37. package/skills/init-dir/templates/scripts/start.cmd +13 -0
  38. package/skills/init-dir/templates/scripts/start.sh +10 -0
  39. package/skills/init-dir/templates/scripts/status.cmd +13 -0
  40. package/skills/init-dir/templates/scripts/status.sh +10 -0
  41. package/skills/init-dir/templates/scripts/stop.cmd +13 -0
  42. package/skills/init-dir/templates/scripts/stop.sh +10 -0
  43. package/skills/init-dir/templates/tests/README.md +15 -0
  44. package/skills/init-dir/templates/tests/run.cmd +13 -0
  45. package/skills/init-dir/templates/tests/run.mjs +160 -0
  46. package/skills/init-dir/templates/tests/run.sh +10 -0
  47. package/skills/init-dir/templates/tests/tests.conf +7 -0
  48. package/skills/init-dir/templates/work/README.md +8 -0
  49. package/skills/progress-mon/README.md +11 -8
  50. package/skills/progress-mon/SKILL.md +53 -16
  51. package/skills/progress-mon/assets/fixture/README.md +1 -1
  52. package/skills/progress-mon/assets/fixture/adr.json +35 -0
  53. package/skills/progress-mon/assets/fixture/arch.json +1 -1
  54. package/skills/progress-mon/assets/fixture/flow.json +2 -2
  55. package/skills/progress-mon/assets/fixture/variants/arch-auto/arch.json +1 -1
  56. package/skills/progress-mon/assets/fixture/variants/legacy-init/rules.json +6 -0
  57. package/skills/progress-mon/assets/fixture/variants/legacy-init/state.json +5 -0
  58. package/skills/progress-mon/references/adr.md +184 -0
  59. package/skills/progress-mon/references/authoring.md +13 -5
  60. package/skills/progress-mon/scripts/pm-adr-view.mjs +101 -0
  61. package/skills/progress-mon/scripts/pm-adr.mjs +515 -0
  62. package/skills/progress-mon/scripts/pm-check.mjs +59 -22
  63. package/skills/progress-mon/scripts/pm-data.mjs +1 -1
  64. package/skills/progress-mon/scripts/pm-html.mjs +1 -0
  65. package/skills/progress-mon/scripts/pm-memory.mjs +113 -28
  66. package/skills/progress-mon/scripts/pm-render.mjs +6 -4
  67. package/skills/progress-mon/scripts/pm-server.mjs +2 -1
  68. package/skills/progress-mon/scripts/pm-sources.mjs +10 -0
  69. package/skills/progress-mon/scripts/progress-mon.mjs +3 -2
  70. package/skills/tune-tmux/SKILL.md +235 -0
  71. package/skills/tune-tmux/scripts/app-notes.mjs +38 -0
  72. package/skills/tune-tmux/scripts/marker-block.mjs +321 -0
  73. package/skills/tune-tmux/scripts/psmux-caps.json +71 -0
  74. package/skills/tune-tmux/scripts/tmux-config.mjs +251 -0
  75. package/skills/tune-tmux/scripts/tune-tmux.mjs +284 -0
  76. package/skills/ultra-chain/SKILL.md +52 -22
  77. package/skills/ultra-chain/references/chain-ledger.mjs +93 -11
  78. package/skills/ultra-chain/references/chain-report.mjs +13 -2
@@ -0,0 +1,515 @@
1
+ // pm-adr.mjs: ADR 탭의 검사. adr.json 두 층 스키마 검사, 참조 검증과 gap 정리, docs/adr 갈라짐 경고, 밀도, 집계, check 대조 기록.
2
+ // 화면 렌더는 pm-adr-view.mjs.
3
+ // 두 층: project(검증, 흐름, 증거)는 프로젝트에 하나, decisions[](최적화 로직, 확장 대응, 포기와 얻은 것)는 결정마다.
4
+ // 서버 화면은 참조를 글로만 보이고 파일을 읽거나 링크하지 않는다. 참조 파일은 check 에서만 크기 상한 안의 일반 파일로 읽는다.
5
+ // 스키마 위반은 FAIL, 근거가 없어 비운 항목({ gap: 사유 })은 밀도 WARN, 확인 못 한 참조는 FAIL 이나 WARN(references/adr.md).
6
+ import crypto from 'node:crypto';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { git, noBom, readRegular, writeAtomic } from './pm-config.mjs';
10
+
11
+ const ADR_FILE = 'adr.json';
12
+ const ADR_CHECK_FILE = 'adr-check.json';
13
+ const REF_READ_MAX = 4 * 1024 * 1024;
14
+ export const STATUS = { accepted: '채택', proposed: '제안', superseded: '대체됨' };
15
+ const REF_KINDS = ['file', 'test', 'commit', 'cmd'];
16
+ const HASH = /^[0-9a-f]{4,40}$/i;
17
+ export const DECISION_SLOTS = [
18
+ ['optimization', '최적화 로직'],
19
+ ['scaling', '확장 대응'],
20
+ ['tradeoffs', '포기와 얻은 것'],
21
+ ];
22
+
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');
27
+ // json 경로 조각(['decisions', 0, 'scaling']) 을 글(decisions[0].scaling)로.
28
+ const jp = (parts) => parts.reduce((a, k) => (typeof k === 'number' ? `${a}[${k}]` : a ? `${a}.${k}` : k), '') || '$';
29
+
30
+ // ---------- 스키마 ----------
31
+
32
+ // 칸 꼴: 빈 문자열이 아닌 글, 글(빈 값 허용), 배열, 객체.
33
+ const KIND = {
34
+ str: [str, '빈 문자열이 아닌 문자열이 아님'],
35
+ text: [(v) => typeof v === 'string', '문자열이 아님'],
36
+ arr: [Array.isArray, '배열이 아님'],
37
+ obj: [(v) => Boolean(obj(v)), '객체가 아님'],
38
+ };
39
+
40
+ // 검사 결과 모음. schema: 스키마 위반, claim: 증거 없는 주장(구현됨, 검증됨), refFail, refWarn: 참조 확인 결과,
41
+ // refs: 확인할 참조, modRefs: 모듈 id 참조, claims: 참조 확인 뒤 판정할 주장(검증한 모듈, 구현된 기능).
42
+ // schema 와 claim 은 둘 다 FAIL 이지만 정리 단계는 schema 만 파일을 옮긴다.
43
+ function state() {
44
+ const S = { schema: [], claim: [], claims: [], refFail: [], refWarn: [], refs: [], modRefs: [], ids: new Set() };
45
+ S.bad = (parts, msg) => S.schema.push({ p: jp(parts), msg });
46
+ S.unproven = (parts, msg) => S.claim.push({ p: jp(parts), msg });
47
+ return S;
48
+ }
49
+
50
+ // 주장에는 증거: 증거 칸에서 실제로 확인된 참조(꼴이 맞고 참조 확인에서 FAIL 도 WARN 도 없음)가 0개면 증거 없음.
51
+ // 확인 못 한 참조(출력 파일 없는 명령, 파일에 없는 시험 이름, git 이 꺼진 커밋)는 증거로 세지 않는다.
52
+ const MODULE_WAY = '증거를 지어내지 말고 그 모듈을 미검증으로 옮김: status "unverified" 와 why(사유)';
53
+ const FEATURE_WAY = '증거를 지어내지 말고 그 기능을 구현 못 함 목록으로 옮김: notImplemented 에 feature 와 why(사유)';
54
+
55
+ // o[key] 의 꼴 검사. 돌려줌: 맞으면 참. optional 이면 없어도 참.
56
+ function want(S, o, key, parts, kind, optional = false) {
57
+ const v = o[key];
58
+ if (v === undefined) {
59
+ if (!optional) S.bad([...parts, key], '칸 없음');
60
+ return optional;
61
+ }
62
+ const [ok, msg] = KIND[kind];
63
+ if (!ok(v)) S.bad([...parts, key], msg);
64
+ return ok(v);
65
+ }
66
+
67
+ const fields = (S, o, parts, list) => list.forEach(([k, kind, optional]) => want(S, o, k, parts, kind, optional));
68
+
69
+ function gapOk(S, v, parts) {
70
+ if (str(v.gap)) return true;
71
+ S.bad([...parts, 'gap'], 'gap 에 사유 없음');
72
+ return false;
73
+ }
74
+
75
+ // 여섯 항목 칸 하나. 내용 꼴이나 { gap: 사유 }. 칸이 없거나 꼴이 틀리면 FAIL.
76
+ function slot(S, holder, key, parts, body) {
77
+ const v = holder[key];
78
+ const p = [...parts, key];
79
+ if (v === undefined) return S.bad(p, '칸 없음');
80
+ if (isGap(v)) return gapOk(S, v, p);
81
+ if (body.kind === 'arr' ? !Array.isArray(v) : !obj(v)) return S.bad(p, body.kind === 'arr' ? '배열이거나 { gap: 사유 } 가 아님' : '객체이거나 { gap: 사유 } 가 아님');
82
+ body.check(v, p);
83
+ }
84
+
85
+ const REF_FIELDS = {
86
+ file: [['path', 'str'], ['line', 'line', true]],
87
+ test: [['path', 'str'], ['name', 'str']],
88
+ commit: [['hash', 'hash']],
89
+ cmd: [['cmd', 'str'], ['output', 'str', true]],
90
+ };
91
+ const REF_KIND_CHECK = {
92
+ ...KIND,
93
+ line: [(v) => Number.isInteger(v) && v >= 1, '1 이상 정수가 아님'],
94
+ hash: [(v) => typeof v === 'string' && HASH.test(v), '4~40자 16진 해시가 아님'],
95
+ };
96
+
97
+ // 참조 칸 하나의 꼴. 돌려줌: 맞으면 참(선택 칸이 없어도 참).
98
+ function refField(S, x, parts, [k, kind, optional]) {
99
+ const v = x[k];
100
+ if (v === undefined && optional) return true;
101
+ const [test, msg] = REF_KIND_CHECK[kind];
102
+ if (test(v)) return true;
103
+ S.bad([...parts, k], v === undefined ? '칸 없음' : msg);
104
+ return false;
105
+ }
106
+
107
+ // 참조 하나. 꼴이 맞으면 확인할 목록(S.refs)에 넣는다.
108
+ function ref(S, x, parts) {
109
+ if (isGap(x)) return gapOk(S, x, parts);
110
+ if (!obj(x)) return S.bad(parts, '참조가 객체가 아님');
111
+ if (!REF_KINDS.includes(x.kind)) return S.bad([...parts, 'kind'], `${REF_KINDS.join(', ')} 가운데 하나가 아님: ${x.kind}`);
112
+ const ok = REF_FIELDS[x.kind].map((f) => refField(S, x, parts, f)).every(Boolean);
113
+ if (x.note !== undefined && typeof x.note !== 'string') S.bad([...parts, 'note'], '문자열이 아님');
114
+ if (ok) S.refs.push({ ref: x, parts });
115
+ }
116
+
117
+ function refList(S, o, key, parts, optional = false) {
118
+ if (!want(S, o, key, parts, 'arr', optional) || o[key] === undefined) return;
119
+ o[key].forEach((x, i) => ref(S, x, [...parts, key, i]));
120
+ }
121
+
122
+ function modules(S, o, parts) {
123
+ if (o.modules === undefined) return;
124
+ if (!(Array.isArray(o.modules) && o.modules.every(str))) return S.bad([...parts, 'modules'], '문자열 배열이 아님');
125
+ o.modules.forEach((id, i) => S.modRefs.push({ id, parts: [...parts, 'modules', i] }));
126
+ }
127
+
128
+ // 객체 배열의 원소마다 check. 객체가 아니면 FAIL.
129
+ function each(S, list, parts, check) {
130
+ list.forEach((x, i) => (obj(x) ? check(x, [...parts, i]) : S.bad([...parts, i], '객체가 아님')));
131
+ }
132
+
133
+ // 모듈 하나. 검증한 모듈(status 없음이나 "verified")은 evidence 에 gap 아닌 참조 1개 이상, 미검증 모듈("unverified")은 why.
134
+ function moduleItem(S, x, p) {
135
+ const unverified = x.status === 'unverified';
136
+ if (x.status !== undefined && !['verified', 'unverified'].includes(x.status)) S.bad([...p, 'status'], `verified, unverified 가운데 하나가 아님: ${x.status}`);
137
+ fields(S, x, p, [['id', 'str'], ['module', 'str'], ['result', 'str', unverified], ['why', 'str', !unverified]]);
138
+ if (str(x.id) && S.ids.has(x.id)) S.bad([...p, 'id'], `모듈 id 겹침: ${x.id}`);
139
+ if (str(x.id)) S.ids.add(x.id);
140
+ refList(S, x, 'evidence', p, unverified);
141
+ if (!unverified && Array.isArray(x.evidence)) S.claims.push({ parts: [...p, 'evidence'], n: x.evidence.length, msg: `증거 없음: 확인된 검증 증거가 없음(FAIL, WARN 없이 확인된 참조 0개). ${MODULE_WAY}` });
142
+ }
143
+
144
+ function verification(S, list, parts) {
145
+ each(S, list, parts, (x, p) => moduleItem(S, x, p));
146
+ }
147
+
148
+ function flow(S, f, parts) {
149
+ fields(S, f, parts, [['input', 'text'], ['logic', 'text'], ['output', 'text']]);
150
+ refList(S, f, 'refs', parts, true);
151
+ }
152
+
153
+ function proof(S, v, parts) {
154
+ if (want(S, v, 'implemented', parts, 'arr')) {
155
+ each(S, v.implemented, [...parts, 'implemented'], (x, p) => {
156
+ want(S, x, 'feature', p, 'str');
157
+ modules(S, x, p);
158
+ refList(S, x, 'proofs', p);
159
+ if (Array.isArray(x.proofs)) S.claims.push({ parts: [...p, 'proofs'], n: x.proofs.length, msg: `증거 없음: 확인된 구현 증명이 없음(FAIL, WARN 없이 확인된 참조 0개). ${FEATURE_WAY}` });
160
+ });
161
+ }
162
+ if (want(S, v, 'notImplemented', parts, 'arr')) each(S, v.notImplemented, [...parts, 'notImplemented'], (x, p) => fields(S, x, p, [['feature', 'str'], ['why', 'str']]));
163
+ }
164
+
165
+ function project(S, p) {
166
+ const parts = ['project'];
167
+ slot(S, p, 'verification', parts, { kind: 'arr', check: (v, q) => verification(S, v, q) });
168
+ slot(S, p, 'flow', parts, { kind: 'obj', check: (v, q) => flow(S, v, q) });
169
+ slot(S, p, 'proof', parts, { kind: 'obj', check: (v, q) => proof(S, v, q) });
170
+ // 검증과 증거 칸 전체를 gap 으로 둘 수 없음(주장에는 증거). 흐름의 gap 은 밀도 WARN
171
+ if (isGap(p.verification) && str(p.verification.gap)) S.unproven([...parts, 'verification'], `증거 없음: 검증 칸 전체를 gap 으로 둘 수 없음. 모듈을 적고 ${MODULE_WAY}`);
172
+ if (isGap(p.proof) && str(p.proof.gap)) S.unproven([...parts, 'proof'], `증거 없음: 증거 칸 전체를 gap 으로 둘 수 없음. implemented 를 비우고 ${FEATURE_WAY}`);
173
+ }
174
+
175
+ function options(S, d, parts) {
176
+ if (!want(S, d, 'options', parts, 'arr')) return;
177
+ each(S, d.options, [...parts, 'options'], (x, p) => fields(S, x, p, [['name', 'str'], ['pros', 'text'], ['cons', 'text']]));
178
+ if (d.options.length < 2 && !str(d.rejected)) S.bad([...parts, 'options'], `선택지 ${d.options.length}개. 2개 미만이면 rejected(다른 선택지를 버린 사유) 필요`);
179
+ }
180
+
181
+ function tradeoffs(S, v, parts) {
182
+ if (want(S, v, 'gaveUp', parts, 'arr')) each(S, v.gaveUp, [...parts, 'gaveUp'], (x, p) => fields(S, x, p, [['what', 'str'], ['why', 'str']]));
183
+ if (want(S, v, 'gained', parts, 'arr')) each(S, v.gained, [...parts, 'gained'], (x, p) => fields(S, x, p, [['what', 'str'], ['purpose', 'str']]));
184
+ }
185
+
186
+ function decision(S, d, parts, seen) {
187
+ fields(S, d, parts, [['id', 'str'], ['title', 'str'], ['context', 'str'], ['decision', 'str'], ['why', 'str'], ['rejected', 'str', true]]);
188
+ if (str(d.id) && seen.has(d.id)) S.bad([...parts, 'id'], `결정 id 겹침: ${d.id}`);
189
+ seen.add(d.id);
190
+ if (!Object.hasOwn(STATUS, d.status)) S.bad([...parts, 'status'], `${Object.keys(STATUS).join(', ')} 가운데 하나가 아님: ${d.status}`);
191
+ if (d.date !== undefined && !(typeof d.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(d.date))) S.bad([...parts, 'date'], `YYYY-MM-DD 가 아님: ${d.date}`);
192
+ options(S, d, parts);
193
+ slot(S, d, 'optimization', parts, { kind: 'obj', check: (v, q) => (want(S, v, 'process', q, 'str'), modules(S, v, q), refList(S, v, 'refs', q)) });
194
+ slot(S, d, 'scaling', parts, { kind: 'obj', check: (v, q) => (fields(S, v, q, [['argument', 'str'], ['limits', 'text', true]]), refList(S, v, 'refs', q)) });
195
+ slot(S, d, 'tradeoffs', parts, { kind: 'obj', check: (v, q) => tradeoffs(S, v, q) });
196
+ refList(S, d, 'sources', parts, true);
197
+ }
198
+
199
+ // 두 층 스키마 검사. 돌려줌: 검사 결과 S(schema, refs, modRefs, claims). 주장 판정은 참조 확인 뒤(examine).
200
+ export function adrSchema(v) {
201
+ const S = state();
202
+ const a = obj(v);
203
+ if (!a) return S.bad([], '객체가 아님'), S;
204
+ if (want(S, a, 'project', [], 'obj')) project(S, a.project);
205
+ if (want(S, a, 'decisions', [], 'arr')) {
206
+ const seen = new Set();
207
+ each(S, a.decisions, ['decisions'], (d, p) => decision(S, d, p, seen));
208
+ }
209
+ for (const m of S.modRefs) if (!S.ids.has(m.id)) S.bad(m.parts, `모듈 없음: ${m.id}`);
210
+ return S;
211
+ }
212
+
213
+ // ---------- 참조 확인 ----------
214
+
215
+ const within = (a, b) => {
216
+ const rel = path.relative(a, b);
217
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
218
+ };
219
+ const realOr = (p) => {
220
+ try {
221
+ return fs.realpathSync(p);
222
+ } catch {
223
+ return path.resolve(p);
224
+ }
225
+ };
226
+
227
+ // 프로젝트 루트 안 상대 경로의 일반 파일. 돌려줌: { file } | { err }. 절대 경로, .., 루트 밖 링크, 없음, 폴더와 특수 파일은 err.
228
+ function localFile(root, p) {
229
+ if (path.isAbsolute(p) || /^[\\/]/.test(p) || /^[A-Za-z]:/.test(p)) return { err: `절대 경로: ${p}` };
230
+ if (p.split(/[\\/]/).includes('..')) return { err: `.. 이 든 경로: ${p}` };
231
+ let real;
232
+ try {
233
+ real = fs.realpathSync(path.resolve(root, p));
234
+ } catch {
235
+ return { err: `파일 없음: ${p}` };
236
+ }
237
+ if (!within(root, real)) return { err: `프로젝트 루트 밖을 가리키는 링크: ${p}` };
238
+ if (!fs.statSync(real).isFile()) return { err: `일반 파일이 아님(폴더나 특수 파일): ${p}` };
239
+ return { file: real };
240
+ }
241
+
242
+ // 크기 상한 안의 일반 파일 글. 넘으면 null.
243
+ function readRef(file) {
244
+ try {
245
+ return readRegular(file, 'utf8', fs, REF_READ_MAX);
246
+ } catch (e) {
247
+ if (e.code === 'EFBIG') return null;
248
+ throw e;
249
+ }
250
+ }
251
+
252
+ const lineCount = (t) => (t ? t.split('\n').length - (t.endsWith('\n') ? 1 : 0) : 0);
253
+ const TOO_BIG = `크기 상한(${REF_READ_MAX / 1024 / 1024} MB)을 넘어 내용을 확인 못 함`;
254
+
255
+ function fileRef(env, x) {
256
+ const got = localFile(env.root, x.path);
257
+ if (got.err) return { fail: got.err };
258
+ if (x.line === undefined) return null;
259
+ const text = readRef(got.file);
260
+ if (text === null) return { warn: `${TOO_BIG}: ${x.path}` };
261
+ const n = lineCount(text);
262
+ return x.line > n ? { fail: `줄 ${x.line} 이 줄 수 ${n} 을 넘음: ${x.path}` } : null;
263
+ }
264
+
265
+ function testRef(env, x) {
266
+ const got = localFile(env.root, x.path);
267
+ if (got.err) return { fail: got.err };
268
+ const text = readRef(got.file);
269
+ if (text === null) return { warn: `${TOO_BIG}: ${x.path}` };
270
+ return text.includes(x.name) ? null : { warn: `시험 이름이 파일에 없음: ${x.path} 의 ${x.name}` };
271
+ }
272
+
273
+ // 커밋은 git 으로만 확인. sources.git 이 꺼졌거나 저장소가 아니면 WARN(확인 못 함).
274
+ function commitRef(env, x) {
275
+ if (!env.gitOn) return { warn: `sources.git 이 꺼져 있어 커밋을 확인 못 함: ${x.hash}` };
276
+ if (env.repo === undefined) {
277
+ try {
278
+ git(env.project, ['rev-parse', '--git-dir']);
279
+ env.repo = true;
280
+ } catch {
281
+ env.repo = false;
282
+ }
283
+ }
284
+ if (!env.repo) return { warn: `git 저장소가 아니거나 git 이 없어 커밋을 확인 못 함: ${x.hash}` };
285
+ try {
286
+ git(env.project, ['cat-file', '-e', `${x.hash}^{commit}`]);
287
+ return null;
288
+ } catch {
289
+ return { fail: `커밋 없음(짧은 해시가 여럿과 겹쳐도 같음): ${x.hash}` };
290
+ }
291
+ }
292
+
293
+ function cmdRef(env, x) {
294
+ if (x.output === undefined) return { warn: `출력 파일이 없어 명령 결과를 확인 못 함: ${x.cmd}` };
295
+ const got = localFile(env.root, x.output);
296
+ return got.err ? { fail: got.err } : null;
297
+ }
298
+
299
+ const VERIFY = { file: fileRef, test: testRef, commit: commitRef, cmd: cmdRef };
300
+
301
+ // S.refs 를 실제로 확인해 S.refFail, S.refWarn 에 넣는다.
302
+ function verifyRefs(S, ctx, config) {
303
+ const env = { project: ctx.project, root: realOr(ctx.project), gitOn: Boolean(config.sources && config.sources.git), repo: undefined };
304
+ for (const { ref: x, parts } of S.refs) {
305
+ const r = VERIFY[x.kind](env, x);
306
+ if (r && r.fail) S.refFail.push({ parts, msg: r.fail });
307
+ else if (r) S.refWarn.push({ parts, msg: r.warn });
308
+ }
309
+ }
310
+
311
+ // 참조 확인 뒤 주장 판정. 증거 칸의 참조 가운데 확인할 목록에 들었고(꼴이 맞고 gap 아님) FAIL, WARN 이 없는 것만 센다.
312
+ function judgeClaims(S) {
313
+ const bad = new Set([...S.refFail, ...S.refWarn].map((x) => jp(x.parts)));
314
+ const good = new Set(S.refs.map((x) => jp(x.parts)).filter((p) => !bad.has(p)));
315
+ S.good = good;
316
+ for (const c of S.claims) {
317
+ const ok = Array.from({ length: c.n }, (_, i) => jp([...c.parts, i])).some((p) => good.has(p));
318
+ if (!ok) S.unproven(c.parts, c.msg);
319
+ }
320
+ }
321
+
322
+ // 스키마, 참조 확인, 주장 판정을 차례로. 돌려줌: S
323
+ function examine(v, ctx, config) {
324
+ const S = adrSchema(v);
325
+ verifyRefs(S, ctx, config);
326
+ judgeClaims(S);
327
+ return S;
328
+ }
329
+
330
+ // 두 원본 갈라짐: docs/adr/*.md 가운데 어떤 결정의 sources 도 가리키지 않는 파일.
331
+ function divergence(ctx, v) {
332
+ let names;
333
+ try {
334
+ names = fs.readdirSync(path.join(ctx.project, 'docs', 'adr'), { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith('.md')).map((e) => e.name);
335
+ } catch {
336
+ return [];
337
+ }
338
+ const norm = (p) => String(p).replace(/\\/g, '/').replace(/^(\.\/)+/, '');
339
+ const pointed = new Set(arr(v.decisions).flatMap((d) => arr(obj(d) && d.sources).filter((x) => obj(x) && typeof x.path === 'string').map((x) => norm(x.path))));
340
+ return names.sort().filter((n) => !pointed.has(`docs/adr/${n}`)).map((n) => `docs/adr/${n} 를 가리키는 결정 없음`);
341
+ }
342
+
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 }
347
+ function readAdr(dataDir) {
348
+ let text;
349
+ try {
350
+ text = readRegular(path.join(dataDir, ADR_FILE));
351
+ } catch (e) {
352
+ return e.code === 'ENOENT' ? { missing: true } : { error: `읽기 실패(${e.code || 'error'})` };
353
+ }
354
+ const body = noBom(text);
355
+ try {
356
+ return { value: JSON.parse(body), sha: adrSha(body) };
357
+ } catch (e) {
358
+ return { error: `JSON 아님: ${e.message}` };
359
+ }
360
+ }
361
+
362
+ // 검사 전체(check 와 정리가 같이 씀). 돌려줌: { missing } | { error } | { value, sha, S, diverge }
363
+ function inspectAdr(ctx, config) {
364
+ const got = readAdr(ctx.dataDir);
365
+ if (!Object.hasOwn(got, 'value')) return got;
366
+ const S = examine(got.value, ctx, config);
367
+ const diverge = S.schema.length ? [] : divergence(ctx, got.value);
368
+ return { value: got.value, sha: got.sha, S, diverge };
369
+ }
370
+
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 });
374
+ export function checkAdr(ctx, config, r) {
375
+ const got = inspectAdr(ctx, config);
376
+ if (got.missing) return NO_ADR();
377
+ if (got.error) return r.fail(ADR_FILE, '$', got.error), NO_ADR();
378
+ for (const x of [...got.S.schema, ...got.S.claim]) r.fail(ADR_FILE, x.p, x.msg);
379
+ for (const x of got.S.refFail) r.fail(ADR_FILE, jp(x.parts), `참조: ${x.msg}`);
380
+ for (const x of got.S.refWarn) r.warn(ADR_FILE, jp(x.parts), `참조: ${x.msg}`);
381
+ for (const msg of got.diverge) r.warn(ADR_FILE, 'sources:', msg);
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`);
391
+ }
392
+
393
+ // json 경로 조각 자리에 값을 넣는다.
394
+ function setAt(root, parts, value) {
395
+ const holder = parts.slice(0, -1).reduce((o, k) => o[k], root);
396
+ holder[parts[parts.length - 1]] = value;
397
+ }
398
+
399
+ const two = (n) => String(n).padStart(2, '0');
400
+ // 현지 시각 YYYYMMDD-HHMMSS.
401
+ const stamp = (ms) => {
402
+ const d = new Date(ms);
403
+ return `${d.getFullYear()}${two(d.getMonth() + 1)}${two(d.getDate())}-${two(d.getHours())}${two(d.getMinutes())}${two(d.getSeconds())}`;
404
+ };
405
+
406
+ // 스키마 FAIL 이 남은 adr.json 을 같은 폴더의 adr.json.rejected-<시각>(있으면 -2, -3 ...)으로 옮긴다. 결정 기록을 잃지 않게
407
+ // 지우지 않는다. 새 이름은 COPYFILE_EXCL 로 만들어 있는 파일을 덮지 않고, 사본을 만든 뒤에만 원본을 지운다. 돌려줌: 옮긴 경로.
408
+ function reject(file, now) {
409
+ const base = `${file}.rejected-${stamp(now)}`;
410
+ for (let i = 1; ; i++) {
411
+ const dest = i === 1 ? base : `${base}-${i}`;
412
+ try {
413
+ fs.copyFileSync(file, dest, fs.constants.COPYFILE_EXCL);
414
+ } catch (e) {
415
+ if (e.code === 'EEXIST') continue;
416
+ throw e;
417
+ }
418
+ fs.rmSync(file, { force: true });
419
+ return dest;
420
+ }
421
+ }
422
+
423
+ // init 정리 단계(결정 I). 스키마 위반이 남으면 adr.json 을 adr.json.rejected-<시각> 으로 옮겨 자료 없음으로, 참조 FAIL 만
424
+ // 있으면 그 참조를 { gap: "참조 확인 실패: ..." } 로 바꿔 파일을 남긴다. 돌려줌: 출력 줄 목록.
425
+ export async function cleanAdr(ctx, config, now = Date.now()) {
426
+ const file = path.join(ctx.dataDir, ADR_FILE);
427
+ const got = inspectAdr(ctx, config);
428
+ if (got.missing) return ['정리: adr.json 없음'];
429
+ const schema = got.error ? 1 : got.S.schema.length;
430
+ if (schema) return [`정리: adr.json 을 ${reject(file, now)} 로 옮김(스키마 FAIL ${schema}건이 남아 자료 없음으로 둠)`];
431
+ const fails = got.S.refFail;
432
+ if (!fails.length) return ['정리: adr.json 참조 FAIL 0, 바꾼 것 없음'];
433
+ for (const f of fails) setAt(got.value, f.parts, { gap: `참조 확인 실패: ${f.msg}` });
434
+ await writeAtomic(file, JSON.stringify(got.value, null, 2) + '\n');
435
+ const out = [`정리: adr.json 참조 FAIL ${fails.length}건을 gap 으로 바꿈: ${fails.map((f) => jp(f.parts)).join(', ')}`];
436
+ // 바꾼 뒤 증거가 모두 gap 이 된 주장은 자동으로 옮기지 않는다(check 가 FAIL 로 보고, 에이전트가 판단해 옮김)
437
+ const left = examine(got.value, ctx, config).claim;
438
+ if (left.length) out.push(`정리: 증거가 남지 않은 주장 ${left.length}건(자동으로 옮기지 않음, check 의 FAIL 참고): ${left.map((x) => x.p).join(', ')}`);
439
+ return out;
440
+ }
441
+
442
+ // ---------- 밀도 ----------
443
+
444
+ const content = (v) => (isGap(v) ? null : v);
445
+
446
+ // 여섯 항목 칸 가운데 비었거나(gap, 없음) 채운 칸.
447
+ function slotsOf(p, ds) {
448
+ const list = [
449
+ ['project.verification', p.verification],
450
+ ['project.flow', p.flow],
451
+ ['project.proof', p.proof],
452
+ ];
453
+ ds.forEach((d, i) => DECISION_SLOTS.forEach(([k]) => list.push([`decisions[${i}].${k}`, d[k]])));
454
+ return list;
455
+ }
456
+
457
+ // 서술 칸: 결정의 최적화 로직(검증한 과정)과 확장 대응. gap 이 아닌 객체만(gap 은 근거 없음 칸으로 센다). 돌려줌: [이름, 칸]
458
+ const NARRATIVE = ['optimization', 'scaling'];
459
+ function narrativeSlots(v) {
460
+ return arr(obj(v) && v.decisions).flatMap((d, i) => (obj(d) ? NARRATIVE.filter((k) => obj(d[k]) && !isGap(d[k])).map((k) => [`decisions[${i}].${k}`, d[k]]) : []));
461
+ }
462
+
463
+ // 참조 없는 서술: 서술 칸 가운데 refs 에 확인된 참조가 0개인 칸 이름. 규칙은 WARN 이지만 배지와 check 마지막 줄에 늘 센다.
464
+ // good(json 경로 글): 확인된 참조인가. check 는 참조 확인을 마친 집합(FAIL, WARN 없음). 화면은 파일을 읽지 않으므로
465
+ // 기본값은 꼴이 맞고 gap 이 아닌 참조(적힌 참조)다.
466
+ export function bareSlots(v, good) {
467
+ const written = good ? null : new Set(adrSchema(v).refs.map((x) => jp(x.parts)));
468
+ const ok = good || ((p) => written.has(p));
469
+ return narrativeSlots(v)
470
+ .filter(([n, x]) => !arr(x.refs).some((_, j) => ok(`${n}.refs[${j}]`)))
471
+ .map(([n]) => n);
472
+ }
473
+
474
+ // 돌려줌: density 줄 [{tab: 'adr', item, value, min, miss}]. v 가 없으면(파일 없음, 깨짐) 모두 0.
475
+ // 증거 없는 모듈과 기능은 밀도가 아니라 FAIL(adrSchema 의 claim)이라 여기서 세지 않는다. bare: 참조 없는 서술 칸(없으면 적힌 참조로 잼).
476
+ export function adrRows(v, bare = bareSlots(v)) {
477
+ const rows = [];
478
+ const add = (item, value, min, miss = []) => rows.push({ tab: 'adr', item, value, min, miss });
479
+ const a = obj(v) || {};
480
+ const p = obj(a.project) || {};
481
+ const ds = arr(a.decisions).filter(obj);
482
+ const slots = slotsOf(p, ds);
483
+ const empty = slots.filter(([, x]) => x === undefined || isGap(x)).map(([n]) => n);
484
+ add('빈 항목', slots.length - empty.length, slots.length, empty);
485
+ const ver = arr(content(p.verification)).filter(obj);
486
+ add('검증', ver.length, 1);
487
+ const f = obj(content(p.flow)) || {};
488
+ const fmiss = [['input', '입력'], ['logic', '처리'], ['output', '출력']].filter(([k]) => !str(f[k])).map(([, n]) => n);
489
+ add('흐름', 3 - fmiss.length, 3, fmiss);
490
+ const impl = arr(obj(content(p.proof)) && p.proof.implemented).filter(obj);
491
+ add('증거', impl.length, 1);
492
+ add('결정', ds.length, 1);
493
+ const tmiss = ds.filter((d) => !(obj(content(d.tradeoffs)) && arr(d.tradeoffs.gaveUp).length && arr(d.tradeoffs.gained).length)).map((d) => d.id);
494
+ add('포기얻음', ds.length - tmiss.length, ds.length, tmiss);
495
+ const narr = narrativeSlots(a).length;
496
+ add('서술참조', narr - bare.length, narr, bare);
497
+ return rows;
498
+ }
499
+
500
+ // adr.json 안의 { gap } 수(여섯 항목 칸과 참조 자리).
501
+ function gapCount(v) {
502
+ if (Array.isArray(v)) return v.reduce((a, x) => a + gapCount(x), 0);
503
+ if (!obj(v)) return 0;
504
+ return isGap(v) ? 1 : Object.values(v).reduce((a, x) => a + gapCount(x), 0);
505
+ }
506
+
507
+ // 화면 배지와 check 끝 요약: 근거 없음(gap) 칸, 미검증 모듈, 구현 못 한 기능, 참조 없는 서술 칸 수. 모두 미검증이거나
508
+ // 서술 칸에 참조가 없어도 0칸만 보이는 오해를 막는다. bare: 참조 없는 서술 칸(없으면 적힌 참조로 잼).
509
+ export function adrTally(v, bare = bareSlots(v)) {
510
+ const p = obj(obj(v) && v.project) || {};
511
+ const unverified = arr(p.verification).filter((x) => obj(x) && x.status === 'unverified').length;
512
+ const notImplemented = arr(obj(p.proof) && p.proof.notImplemented).filter(obj).length;
513
+ const gaps = gapCount(v);
514
+ return { text: `근거 없음 ${gaps}칸, 미검증 ${unverified}, 구현 못 함 ${notImplemented}, 참조 없는 서술 ${bare.length}`, any: gaps + unverified + notImplemented + bare.length > 0 };
515
+ }
@@ -3,7 +3,9 @@
3
3
  // 구성도와 workflow 기하는 렌더와 같은 배치 계산(archLayout, archFlaws, flowLayout)에 검사한다.
4
4
  // 밀도(4.9절)는 init 이 만든 자료 폴더(state.json 의 memory)나 inventory.json 이 있을 때만 재고, 미달은 WARN(종료 코드 영향 없음).
5
5
  // 항목마다 재는 밀도 미달 줄은 끝에 못 채운 대상 이름을 붙인다(`: a, b 외 n`).
6
- // 끝에 `density <탭> ok|low` 4줄. 형식 시험 자료와 옮긴 자료(둘 다 init 기록 없음)는 밀도를 재지 않는다.
6
+ // ADR(adr.json)의 스키마, 참조, docs/adr 갈라짐은 pm-adr.mjs 가 검사한다.
7
+ // 끝에 `density <탭> ok|low` 5줄(init 기록이 있을 때)과 늘 `adr 근거 없음 n칸, 미검증 n, 구현 못 함 n, 참조 없는 서술 n` 한 줄. ADR 밀도는 adr.json 이 있으면 늘 잰다.
8
+ // 형식 시험 자료와 옮긴 자료(둘 다 init 기록 없음)는 밀도를 재지 않는다.
7
9
  import fs from 'node:fs';
8
10
  import path from 'node:path';
9
11
  import { loadConfig, noBom, readRegular, UsageError } from './pm-config.mjs';
@@ -12,6 +14,7 @@ import { ITEM_STATUS, STAGE_STATUS } from './pm-data.mjs';
12
14
  import { archLayout, archFlaws, flowLayout, nameFits, FLOW } from './pm-render.mjs';
13
15
  import { color, finite, textW, wrap, TABS } from './pm-html.mjs';
14
16
  import { memoryWarnings } from './pm-memory.mjs';
17
+ import { checkAdr, adrRows, cleanAdr, adrTally, writeAdrCheck } from './pm-adr.mjs';
15
18
 
16
19
  const arr = (v) => (Array.isArray(v) ? v : []);
17
20
  const obj = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);
@@ -438,7 +441,8 @@ const lines23 = (n) => n >= 2 && n <= 3;
438
441
 
439
442
  // 돌려줌: { rows: [{tab, item, value, min, miss}], low: Set(탭) }. value < min 이면 미달.
440
443
  // miss 는 항목마다 재는 줄(상자, 단계, 흐름, 명세, 기타 항목)에서 기준을 못 채운 대상 이름. 수만 재는 줄은 빈 배열.
441
- export function density({ arch, flow, req, items, inv, storyCount }) {
444
+ // adr: adr.json 값, adrBare: check 가 참조 확인으로 잰 참조 없는 서술 칸(없으면 적힌 참조로 잼).
445
+ export function density({ arch, flow, req, items, inv, storyCount, adr, adrBare }) {
442
446
  const rows = [];
443
447
  const add = (tab, item, value, min, miss = []) => rows.push({ tab, item, value, min, miss });
444
448
  // 대상마다 ok 를 재어 값, 하한(대상 수), 못 채운 대상 이름을 한 줄로.
@@ -479,6 +483,13 @@ export function density({ arch, flow, req, items, inv, storyCount }) {
479
483
  each('workflow', '실패분기', lanes, (l) => arr(l.branches).length, (l) => l.id);
480
484
  each('workflow', '외부', lanes, (l) => arr(l.externals).length, (l) => l.id);
481
485
  each('workflow', '흐름설명', lanes, (l) => sentences(l.note) >= 3, (l) => `${l.id}(${sentences(l.note)}문장)`);
486
+ reqRows({ add, each }, req, items, storyCount);
487
+ rows.push(...adrRows(adr, adrBare));
488
+ return { rows, low: new Set(rows.filter((x) => x.value < x.min).map((x) => x.tab)) };
489
+ }
490
+
491
+ // 요구, 명세, 기록 탭 줄. add, each: density 의 줄 추가 함수.
492
+ function reqRows({ add, each }, req, items, storyCount) {
482
493
  const at = (x, alt) => (req.where && req.where.get(x)) || alt;
483
494
  if (req.users.length) each('req', '요구', req.users, (u) => str(u.text) && str(u.source), (u, i) => at(u, `users[${i}]`));
484
495
  else add('req', '요구', 0, 1);
@@ -488,7 +499,6 @@ export function density({ arch, flow, req, items, inv, storyCount }) {
488
499
  const stated = req.specs.filter((x) => x.state !== undefined);
489
500
  each('req', '비고', stated, (x) => str(x.note), (x) => at(x, `specs[${req.specs.indexOf(x)}]`));
490
501
  each('history', '비고', items, (x) => str(x.note), (x, i) => (str(x.key) ? x.key : `items[${i}]`));
491
- return { rows, low: new Set(rows.filter((x) => x.value < x.min).map((x) => x.tab)) };
492
502
  }
493
503
 
494
504
  // 밀도 미달 줄. 못 채운 대상이 있으면 `: ` 뒤에 앞 10개, 넘으면 `외 n`.
@@ -503,7 +513,25 @@ export function densityLine(x) {
503
513
 
504
514
  // ---------- 실행 ----------
505
515
 
506
- // 검사 전체. 돌려줌: { lines: [출력 줄], fails }
516
+ // state.json 의 memory(init 기록). 없거나 깨졌으면 null.
517
+ function memoryOf(ctx) {
518
+ try {
519
+ const state = obj(JSON.parse(readRegular(path.join(ctx.dataDir, 'state.json'))));
520
+ return (state && obj(state.memory)) || null;
521
+ } catch {
522
+ return null;
523
+ }
524
+ }
525
+
526
+ // 메모리 경고를 WARN 줄로. 경고 줄에 뒤따르는 대안 줄은 그 WARN 한 줄에 붙인다.
527
+ function memoryInto(r, memory, project, env) {
528
+ for (const l of memoryWarnings(memory, project, env)) {
529
+ if (l.startsWith('경고: ')) r.warn('state.json', 'memory', l.slice(4));
530
+ else if (r.lines.length) r.lines[r.lines.length - 1].msg += `. ${l}`;
531
+ }
532
+ }
533
+
534
+ // 검사 전체. 자료를 바꾸지 않는다. 돌려줌: { lines: [출력 줄], fails, adr: check 대조 기록 { sha, tally } (adr.json 이 없거나 깨졌으면 null) }
507
535
  export function runCheck(ctx, env = process.env) {
508
536
  const r = sink();
509
537
  const config = checkConfig(ctx, r);
@@ -514,36 +542,45 @@ export function runCheck(ctx, env = process.env) {
514
542
  const req = checkReq(ctx.dataDir, r, ids);
515
543
  const flow = checkFlow(ctx.dataDir, r);
516
544
  const arch = checkArch(ctx.dataDir, r, config.text.plannedPrefix);
545
+ const got = checkAdr(ctx, config, r);
546
+ const adr = got.value;
547
+ const adrBare = got.bare;
517
548
  const inv = checkInventory(ctx, r);
518
- let state = null;
519
- try {
520
- state = obj(JSON.parse(readRegular(path.join(ctx.dataDir, 'state.json'))));
521
- } catch {
522
- state = null;
523
- }
524
- const memory = state && obj(state.memory);
525
- // 경고 줄에 뒤따르는 대안 줄을 붙여 WARN 한 줄로.
526
- for (const l of memoryWarnings(memory, ctx.project, env)) {
527
- if (l.startsWith('경고: ')) r.warn('state.json', 'memory', l.slice(4));
528
- else if (r.lines.length) r.lines[r.lines.length - 1].msg += `. ${l}`;
529
- }
549
+ const memory = memoryOf(ctx);
550
+ memoryInto(r, memory, ctx.project, env);
530
551
  const out = r.lines.map((x) => `${x.level} ${x.file} ${x.path} ${x.msg}`);
531
552
  if (config.sources.workflowDirs === 'auto' && !workflowRoots(config, ctx, env).length) out.push('WARN workflowDirs auto: 찾은 폴더 0');
532
- let dens = null;
533
- if (inv || memory) {
534
- dens = density({ arch, flow, req, items: items.items, inv, storyCount: ids.length });
535
- for (const x of dens.rows) if (x.value < x.min) out.push(densityLine(x));
536
- }
553
+ // 밀도는 init 기록이나 inventory.json 이 있을 때만. 다만 ADR 밀도는 adr.json 이 있으면 늘 낸다
554
+ const dens = inv || memory ? density({ arch, flow, req, items: items.items, inv, storyCount: ids.length, adr, adrBare }) : null;
555
+ const rows = dens ? dens.rows : adr ? adrRows(adr, adrBare) : [];
556
+ const tally = adrTally(adr, adrBare).text;
557
+ return { ...finish(out, rows, dens, tally), adr: got.sha ? { sha: got.sha, tally } : null };
558
+ }
559
+
560
+ // 밀도 미달 줄, 합계 줄, 탭별 밀도 요약, ADR 집계(근거 없음 칸, 미검증, 구현 못 함, 참조 없는 서술. 늘 한 줄)를 붙인다. 돌려줌: { lines, fails }
561
+ function finish(out, rows, dens, tally) {
562
+ for (const x of rows) if (x.value < x.min) out.push(densityLine(x));
537
563
  const fails = out.filter((l) => l.startsWith('FAIL ')).length;
538
564
  out.push(`check: FAIL ${fails}, WARN ${out.length - fails}`);
539
565
  if (dens) for (const [k] of TABS) out.push(`density ${k} ${dens.low.has(k) ? 'low' : 'ok'}`);
566
+ out.push(`adr ${tally}`);
540
567
  return { lines: out, fails };
541
568
  }
542
569
 
570
+ // --adr-cleanup: 검사 전에 init 정리 단계(pm-adr.mjs cleanAdr)를 적용한다. 그 밖에는 자료를 바꾸지 않고,
571
+ // 화면 대조용 adr-check.json 만 쓴다. 쓰지 못하면 stderr 경고(검사 결과와 종료 코드는 그대로).
543
572
  export async function command(cmd, ctx, o, io) {
544
573
  if (cmd !== 'check') throw new UsageError(`${cmd} 는 pm-check.mjs 가 맡지 않음`);
545
574
  if (o._.length > 1) throw new UsageError(`check 는 위치 인자를 받지 않음: ${o._.slice(1).join(' ')}`);
546
- const { lines, fails } = runCheck(ctx);
575
+ if (o['adr-cleanup'] && ctx.exists) for (const l of await cleanAdr(ctx, loadConfig(ctx).config)) io.out(l);
576
+ const { lines, fails, adr } = runCheck(ctx);
547
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
+ }
548
585
  return fails ? 1 : 0;
549
586
  }
@@ -284,7 +284,7 @@ function storyChange(o, now, configIds) {
284
284
 
285
285
  // ---------- init ----------
286
286
 
287
- const NEXT = ['다음 할 일', ' 1. references/authoring.md 를 읽고 req-spec.json, reqs.json, flow.json, arch.json 작성', ' 2. check 로 검사. FAIL 0 까지 최대 3회', ' 3. start 로 서버 시작'];
287
+ const NEXT = ['다음 할 일', ' 1. references/authoring.md 를 읽고 req-spec.json, reqs.json, flow.json, arch.json, adr.json 작성', ' 2. check 로 검사. FAIL 0 까지 최대 3회', ' 3. start 로 서버 시작'];
288
288
 
289
289
  const slash = (p) => p.split(path.sep).join('/');
290
290
  const realOr = (p) => {