prooflist 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # ProofList
2
+
3
+ **증거 없으면 완료 불가.** Claude 전용 개발·테스트 체크리스트 — 프로젝트마다 임베디드되는 CLI + 로컬 감독 대시보드.
4
+
5
+ 화면 작업은 Claude가 직접 작성·실행한 자동 UI 테스트(모바일=Maestro)의 **실행 결과물(스크린샷·pass/fail)이 붙어야만** `done`으로 체크된다. 자동화로 못 잡는 영역(`human-gate`)은 사람 서명 전까지 Claude가 절대 완료할 수 없다. 완료 강제는 하네스 Stop 훅이 한다 — Claude의 자기 절제가 아니라.
6
+
7
+ ## 왜
8
+
9
+ todolist 관리에서 두 가지 문제를 동시에 해결한다:
10
+
11
+ - **(A) 관리 가시성** — 기획→작업단위 분할→리스트→상태/퍼센트→완료마다 갱신이 한눈에. 퍼센트는 트리 롤업으로 자동 계산(수동 입력 누락 차단).
12
+ - **(B) 테스트 정직성** — Claude가 화면을 안 보고 "됐다"고 하거나, 못 하는 영역도 했다고 하는 문제를 증거 게이트로 차단. 특히 모바일앱.
13
+
14
+ ## 운영자는 Claude
15
+
16
+ 사람이 손으로 체크하는 todo 앱이 아니다. `pl` CLI가 Claude의 1차 인터페이스(JSON-first 출력 + `nextAction` 지시문), 웹 대시보드는 승기의 **감독 패널**(주장 vs 증거를 대조하는 신뢰 원장).
17
+
18
+ ## 워크플로우
19
+
20
+ ```
21
+ pl init # .prooflist/ 스캐폴드 + Stop 훅 자동 배선
22
+ pl plan # 작업 트리 + 각 작업 kind(logic/screen/human-gate)
23
+ pl start <id>
24
+ pl test <id> # .maestro/<id>.yaml 실행 → 스샷·pass 증거 생성
25
+ pl done <id> # 게이트 통과 시에만 done. 거부 시 nextAction 따름
26
+ pl signoff <id> # human-gate 작업을 사람이 서명 완료
27
+ pl ui # 로컬 감독 대시보드
28
+ pl gate --check # 무결성 검사(Stop 훅이 호출)
29
+ ```
30
+
31
+ ## 위협 모델 (Claude의 우회 패턴 차단)
32
+
33
+ | ID | 패턴 | 방어 |
34
+ |---|---|---|
35
+ | F1 | 화면 안 보고 "됐다" | screen done은 통과 Maestro 아티팩트 필수 |
36
+ | F2 | 못 하는 영역 했다고 함 | `human-gate`는 사람 서명만 |
37
+ | F3 | kind를 screen→logic으로 낮춤 | touchedPaths로 검증·차단, 사람이 잠금 |
38
+ | F4 | 오래된/재사용 아티팩트 | 해시 + 최신성 검사 |
39
+ | F5 | checklist.json 손편집 | Stop 훅이 전체 무결성 재검증 |
40
+
41
+ ## 상태
42
+
43
+ 설계·구현 계획 완료, 구현 진행 중. 모바일(Maestro) 먼저, 웹(Playwright)은 후속.
44
+
45
+ - 설계 스펙·구현 계획은 포트폴리오 메타 repo(`prooflist-design.md` / `prooflist-plan.md`)에 있음.
46
+
47
+ ## 스택
48
+
49
+ TypeScript(ESM, Node 20+) · vitest · commander · node:http(대시보드, 무프레임워크) · Maestro.
@@ -0,0 +1,150 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { maestroAdapter } from "../runner.js";
4
+ // --- 작은 파일시스템 헬퍼들 (탐지는 repo를 들여다보는 게 본분) ---
5
+ function exists(...parts) {
6
+ return existsSync(join(...parts));
7
+ }
8
+ function isDir(p) {
9
+ try {
10
+ return statSync(p).isDirectory();
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ // repoRoot 기준 첫 N단계까지 디렉터리/파일을 훑되, node_modules·.git은 가지치기.
17
+ function walk(root, maxDepth = 4) {
18
+ const out = [];
19
+ const SKIP = new Set(["node_modules", ".git", "dist", "build", ".next"]);
20
+ const rec = (dir, depth) => {
21
+ if (depth > maxDepth)
22
+ return;
23
+ let entries;
24
+ try {
25
+ entries = readdirSync(dir);
26
+ }
27
+ catch {
28
+ return;
29
+ }
30
+ for (const name of entries) {
31
+ const full = join(dir, name);
32
+ out.push(full);
33
+ if (isDir(full) && !SKIP.has(name))
34
+ rec(full, depth + 1);
35
+ }
36
+ };
37
+ rec(root, 0);
38
+ return out;
39
+ }
40
+ function readPackageJson(repoRoot) {
41
+ const p = join(repoRoot, "package.json");
42
+ if (!existsSync(p))
43
+ return null;
44
+ try {
45
+ return JSON.parse(readFileSync(p, "utf8"));
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ function pkgHasKeyOrDep(repoRoot, key) {
52
+ const pkg = readPackageJson(repoRoot);
53
+ if (!pkg)
54
+ return false;
55
+ if (pkg[key])
56
+ return true;
57
+ return Boolean(pkg.dependencies?.[key] || pkg.devDependencies?.[key]);
58
+ }
59
+ const det = (framework, scenarioFiles, used) => ({ framework, used, scenarioFiles });
60
+ // --- Detox: .detoxrc* 파일 | package.json의 detox 키 | e2e/*.e2e.{js,ts} ---
61
+ export const detoxAdapter = {
62
+ framework: "detox",
63
+ detect(repoRoot) {
64
+ const all = walk(repoRoot);
65
+ const scenarioFiles = all.filter((f) => /\.e2e\.(js|ts)$/.test(f));
66
+ const hasRc = all.some((f) => /(^|\/)\.detoxrc[^/]*$/.test(f));
67
+ const used = hasRc || pkgHasKeyOrDep(repoRoot, "detox") || scenarioFiles.length > 0;
68
+ return det("detox", scenarioFiles, used);
69
+ },
70
+ };
71
+ // --- Playwright: playwright.config.{ts,js} | e2e//tests/ 하위 *.spec.{ts,js} ---
72
+ export const playwrightAdapter = {
73
+ framework: "playwright",
74
+ detect(repoRoot) {
75
+ const hasConfig = exists(repoRoot, "playwright.config.ts") || exists(repoRoot, "playwright.config.js");
76
+ const all = walk(repoRoot);
77
+ const scenarioFiles = all.filter((f) => /\.spec\.(ts|js)$/.test(f) && /(^|\/)(e2e|tests)\//.test(f));
78
+ const used = hasConfig || scenarioFiles.length > 0;
79
+ return det("playwright", scenarioFiles, used);
80
+ },
81
+ };
82
+ // --- Cypress: cypress/ 디렉터리 | cypress.config.{ts,js}; 시나리오 cypress/e2e/** ---
83
+ export const cypressAdapter = {
84
+ framework: "cypress",
85
+ detect(repoRoot) {
86
+ const hasConfig = exists(repoRoot, "cypress.config.ts") || exists(repoRoot, "cypress.config.js");
87
+ const hasDir = isDir(join(repoRoot, "cypress"));
88
+ const all = walk(repoRoot);
89
+ const scenarioFiles = all.filter((f) => /(^|\/)cypress\/e2e\//.test(f) && statSafe(f));
90
+ const used = hasConfig || hasDir;
91
+ return det("cypress", scenarioFiles, used);
92
+ },
93
+ };
94
+ // --- XCUITest: *UITests 디렉터리 | *.xctest ---
95
+ export const xcuitestAdapter = {
96
+ framework: "xcuitest",
97
+ detect(repoRoot) {
98
+ const all = walk(repoRoot);
99
+ const scenarioFiles = all.filter((f) => (/UITests$/.test(f) && isDir(f)) || /\.xctest$/.test(f));
100
+ return det("xcuitest", scenarioFiles, scenarioFiles.length > 0);
101
+ },
102
+ };
103
+ // --- Espresso: androidTest 디렉터리 | build.gradle 내 espresso ---
104
+ export const espressoAdapter = {
105
+ framework: "espresso",
106
+ detect(repoRoot) {
107
+ const all = walk(repoRoot);
108
+ const androidTestDirs = all.filter((f) => /(^|\/)androidTest$/.test(f) && isDir(f));
109
+ const gradleHasEspresso = all
110
+ .filter((f) => /build\.gradle(\.kts)?$/.test(f))
111
+ .some((f) => {
112
+ try {
113
+ return /espresso/i.test(readFileSync(f, "utf8"));
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ });
119
+ const used = androidTestDirs.length > 0 || gradleHasEspresso;
120
+ return det("espresso", androidTestDirs, used);
121
+ },
122
+ };
123
+ // --- Appium: wdio.conf* | package.json의 appium dep/키 ---
124
+ export const appiumAdapter = {
125
+ framework: "appium",
126
+ detect(repoRoot) {
127
+ const all = walk(repoRoot);
128
+ const wdio = all.filter((f) => /(^|\/)wdio\.conf[^/]*\.(js|ts)$/.test(f));
129
+ const used = wdio.length > 0 || pkgHasKeyOrDep(repoRoot, "appium");
130
+ return det("appium", wdio, used);
131
+ },
132
+ };
133
+ function statSafe(p) {
134
+ try {
135
+ return statSync(p).isFile();
136
+ }
137
+ catch {
138
+ return false;
139
+ }
140
+ }
141
+ // 전 어댑터 레지스트리. Maestro만 run(Tier2)을 가짐 (§5.2).
142
+ export const adapters = [
143
+ maestroAdapter,
144
+ detoxAdapter,
145
+ appiumAdapter,
146
+ xcuitestAdapter,
147
+ espressoAdapter,
148
+ playwrightAdapter,
149
+ cypressAdapter,
150
+ ];
@@ -0,0 +1,15 @@
1
+ const UI_PATTERNS = [/\.tsx$/, /\.jsx$/, /\/screens?\//i, /\/components?\//i, /App\.(t|j)sx$/];
2
+ export function expectedKind(touchedPaths) {
3
+ const touchesUI = touchedPaths.some((p) => UI_PATTERNS.some((re) => re.test(p)));
4
+ return touchesUI ? "screen" : "logic";
5
+ }
6
+ // F3: Claude가 게이트 회피하려 screen→logic으로 낮춘 경우 탐지.
7
+ export function kindMismatch(task) {
8
+ if (task.kindLocked)
9
+ return false; // 사람이 잠근 분류는 신뢰
10
+ if (task.status !== "done")
11
+ return false;
12
+ if (task.kind !== "logic")
13
+ return false;
14
+ return expectedKind(task.touchedPaths) === "screen";
15
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ import { loadChecklist, saveChecklist, getTask, setStatus, updateTask, rollupPercent } from "./store.js";
3
+ import { canComplete, checkIntegrity } from "./gate.js";
4
+ import { isEvidenceValid, stateHash } from "./evidence.js";
5
+ import { runMaestro } from "./runner.js";
6
+ import { codeFingerprint, latestCodeChange, flowContent, artifactExists } from "./util.js";
7
+ import { detectFrameworks } from "./detect.js";
8
+ import { isMainModule } from "./ismain.js";
9
+ // Tier1 자문(advisory) 경고. 절대 gate enforcement(ok/violations)에 영향 주지 않음. (Task 13 Step 4)
10
+ // 이미 계산한 detections 를 받는다 — status/gate 에서 detectFrameworks 를 두 번 돌지 않도록.
11
+ function tierOneWarnings(fw) {
12
+ if (fw.length === 0)
13
+ return ["no test framework detected in this repo (Tier1) — screen tasks cannot reach ran-pass without one"];
14
+ return [];
15
+ }
16
+ // 작업에서 검증을 통과한 모든 증거 항목을 반환 (fs 사실을 코어에 주입).
17
+ // 유일한 validity 출처 — validEvidenceEntry/hasValidEvidence/dashboard 가 모두 이걸 쓴다.
18
+ export function validEvidenceEntries(dir, taskId) {
19
+ const c = loadChecklist(dir);
20
+ const t = getTask(c, taskId);
21
+ if (!t)
22
+ return [];
23
+ const expected = stateHash(flowContent(dir, taskId), codeFingerprint(dir));
24
+ const latest = latestCodeChange(dir, t.touchedPaths);
25
+ return t.evidence.filter((ev) => isEvidenceValid(ev, { latestCodeChange: latest, expectedHash: expected, fileExists: artifactExists(dir, ev.path) }).ok);
26
+ }
27
+ // 작업에서 실제로 검증을 통과한 증거 1건을 반환 (fs 사실을 코어에 주입).
28
+ export function validEvidenceEntry(dir, taskId) {
29
+ return validEvidenceEntries(dir, taskId)[0];
30
+ }
31
+ // 작업에 유효 아티팩트가 있는지 — validEvidenceEntry 한 곳에서 판정.
32
+ export function hasValidEvidence(dir, taskId) {
33
+ return !!validEvidenceEntry(dir, taskId);
34
+ }
35
+ export function runCli(argv, dir) {
36
+ const [cmd, ...rest] = argv;
37
+ switch (cmd) {
38
+ case "done": {
39
+ const id = rest[0];
40
+ const c = loadChecklist(dir);
41
+ const t = getTask(c, id);
42
+ if (!t)
43
+ return { ok: false, reason: `unknown task ${id}` };
44
+ const v = canComplete(t, { hasValidEvidence: hasValidEvidence(dir, id) });
45
+ if (!v.ok)
46
+ return v;
47
+ saveChecklist(dir, setStatus(c, id, "done"));
48
+ return { ok: true, id, status: "done" };
49
+ }
50
+ case "gate": { // gate --check
51
+ const c = loadChecklist(dir);
52
+ const fw = detectFrameworks(dir);
53
+ const violations = checkIntegrity(c, (taskId) => hasValidEvidence(dir, taskId));
54
+ return { ok: violations.length === 0, violations, warnings: tierOneWarnings(fw) };
55
+ }
56
+ case "start": {
57
+ const c = loadChecklist(dir);
58
+ saveChecklist(dir, setStatus(c, rest[0], "in_progress"));
59
+ return { ok: true, id: rest[0], status: "in_progress" };
60
+ }
61
+ case "signoff": {
62
+ // signoff is human-only and intentionally NOT a state-changing CLI action — this removes the
63
+ // Bash-reachable forge path. The genuine human channel is the dashboard '내가 확인함' button. (F2)
64
+ return {
65
+ ok: false,
66
+ reason: "signoff is human-only and not available via the CLI",
67
+ nextAction: "Ask 승기 to sign off task '" + rest[0] + "' via the dashboard '내가 확인함' button.",
68
+ };
69
+ }
70
+ case "status": {
71
+ const c = loadChecklist(dir);
72
+ const fw = detectFrameworks(dir);
73
+ return {
74
+ ok: true,
75
+ tasks: c.tasks.map((t) => ({ id: t.id, status: t.status, kind: t.kind, percent: rollupPercent(c, t.id) })),
76
+ frameworks: fw,
77
+ warnings: tierOneWarnings(fw),
78
+ };
79
+ }
80
+ case "test": {
81
+ const id = rest[0];
82
+ const c = loadChecklist(dir);
83
+ const t = getTask(c, id);
84
+ if (!t)
85
+ return { ok: false, reason: `unknown task ${id}` };
86
+ // 실제 maestro 호출은 동기 진입점에서만; 단위테스트는 runMaestro를 직접 검증(Task 6).
87
+ return { ok: true, id, note: "use the binary entrypoint to actually run maestro" };
88
+ }
89
+ case "set-kind": {
90
+ const id = rest[0];
91
+ const kind = rest[1];
92
+ const c = loadChecklist(dir);
93
+ const t = getTask(c, id);
94
+ if (!t)
95
+ return { ok: false, reason: `unknown task ${id}` };
96
+ if (kind !== "logic" && kind !== "screen" && kind !== "human-gate")
97
+ return { ok: false, reason: `invalid kind '${rest[1]}'`, nextAction: `Use one of: logic | screen | human-gate.` };
98
+ if (t.kindLocked)
99
+ return {
100
+ ok: false,
101
+ reason: `kind of '${id}' is locked by the human`,
102
+ nextAction: `Ask 승기 to change the kind of '${id}'; Claude cannot modify a human-locked classification.`,
103
+ };
104
+ saveChecklist(dir, updateTask(c, id, { kind }));
105
+ return { ok: true, id, kind };
106
+ }
107
+ default:
108
+ return { ok: false, reason: `unknown command ${cmd}` };
109
+ }
110
+ }
111
+ // 실제 진입점(테스트 외): 인자 파싱 + JSON 출력. test/init은 부수효과 때문에 여기서 처리.
112
+ if (isMainModule(import.meta.url)) {
113
+ (async () => {
114
+ const argv = process.argv.slice(2).filter((a) => !a.startsWith("--"));
115
+ const human = process.argv.includes("--human");
116
+ const dir = process.cwd();
117
+ if (argv[0] === "test") {
118
+ const { execFileSync } = await import("node:child_process");
119
+ const id = argv[1];
120
+ const ev = await runMaestro({
121
+ taskId: id,
122
+ flowContent: flowContent(dir, id),
123
+ codeFingerprint: codeFingerprint(dir),
124
+ exec: async () => {
125
+ const outDir = `.prooflist/artifacts/${id}`;
126
+ try {
127
+ execFileSync("maestro", ["test", `.maestro/${id}.yaml`, "--format", "junit", "--output", outDir], { cwd: dir });
128
+ return { code: 0, screenshots: [] };
129
+ }
130
+ catch {
131
+ return { code: 1, screenshots: [] };
132
+ }
133
+ },
134
+ });
135
+ const c = loadChecklist(dir);
136
+ saveChecklist(dir, updateTask(c, id, { evidence: [...(getTask(c, id)?.evidence ?? []), ev] }));
137
+ console.log(JSON.stringify({ ok: ev.passed, id, evidence: ev }));
138
+ process.exit(ev.passed ? 0 : 1);
139
+ }
140
+ if (argv[0] === "init") {
141
+ const { initProject } = await import("./init.js");
142
+ const out = initProject(dir);
143
+ console.log(JSON.stringify(out));
144
+ process.exit(0);
145
+ }
146
+ const out = runCli(argv, dir);
147
+ if (human)
148
+ console.log(JSON.stringify(out, null, 2));
149
+ else
150
+ console.log(JSON.stringify(out));
151
+ process.exit(out.ok === false ? 1 : 0);
152
+ })();
153
+ }
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env node
2
+ import { createServer } from "node:http";
3
+ import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
4
+ import { join, dirname, normalize, extname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { loadChecklist, getTask, updateTask, saveChecklist } from "./store.js";
7
+ import { validEvidenceEntries } from "./cli.js";
8
+ import { detectFrameworks, bestStrength } from "./detect.js";
9
+ import { clearRepoMtimeMemo } from "./util.js";
10
+ import { isMainModule } from "./ismain.js";
11
+ // flat status → design status. in_progress 만 다름.
12
+ function mapStatus(s) {
13
+ return s === "in_progress" ? "in-progress" : s;
14
+ }
15
+ const shortHash = (h) => (h ? h.slice(0, 8) + "…" : "");
16
+ // ev.path 아래 아티팩트 파일을 화면용 shot 목록으로 합성.
17
+ // 주의: shot 은 디렉터리 파일 목록일 뿐 per-shot 판정이 아니다 — 게이트가 이미 run 통과를
18
+ // 보장했으므로 모든 항목을 pass 로 표기한다 (각 파일을 개별 검증하는 게 아님).
19
+ function synthShots(dir, evPath) {
20
+ const abs = join(dir, ".prooflist/artifacts", evPath);
21
+ if (!existsSync(abs) || !statSync(abs).isDirectory())
22
+ return [];
23
+ return readdirSync(abs)
24
+ .filter((f) => statSync(join(abs, f)).isFile())
25
+ .map((f) => ({ cap: f, result: "pass" }));
26
+ }
27
+ // 검증을 통과한 증거 1건을 화면 표시 모양으로 변환. (검증 안 된 항목은 alarm 패널이 claim 으로 처리)
28
+ function displayEvidence(dir, t, valid) {
29
+ const ev = valid[0];
30
+ if (!ev)
31
+ return undefined;
32
+ const video = valid.find((e) => e.type === "video") ?? (ev.type === "video" ? ev : undefined);
33
+ return {
34
+ type: t.kind === "screen" ? "screen" : "test",
35
+ framework: ev.framework ?? null,
36
+ runAt: ev.runAt,
37
+ hash: shortHash(ev.hash),
38
+ result: "pass",
39
+ ran: true,
40
+ stale: false,
41
+ scenario: false,
42
+ tests: "",
43
+ video: video ? video.path : null,
44
+ shots: t.kind === "screen" ? synthShots(dir, ev.path) : undefined,
45
+ watchNote: ev.note,
46
+ };
47
+ }
48
+ // 권위 있는 강도/알람 계산. operator-Claude 가 위조할 수 없도록 SERVER 가 산정한다 (F1).
49
+ function authoritative(dir, t) {
50
+ const valid = validEvidenceEntries(dir, t.id);
51
+ // strength 는 검증을 통과한 증거에서만 도출 (evidenceStrength: video+humanVerified → visual-verified 등).
52
+ let strength;
53
+ if (valid.length > 0) {
54
+ // evidenceStrength(detect.ts) 가 강도의 단일 출처다 — 여기서 병렬 규칙을 재구현하지 않는다.
55
+ const derived = bestStrength(valid);
56
+ // 좁은 back-compat 승격: tier 필드 자체가 없는 **레거시(Task 9-era)** 증거만 대상.
57
+ // 그런 항목은 evidenceStrength 가 none/scenario 로 보지만, isEvidenceValid 를 통과한 유효 증거는
58
+ // 정의상 실제 실행·통과(passed+fresh+hash+exists)이므로 ran-pass 자격이 있다. tier 를 가진
59
+ // 증거는 절대 건드리지 않는다 — 그쪽은 evidenceStrength 의 판정을 그대로 보존한다.
60
+ const allLegacy = valid.every((e) => e.tier === undefined);
61
+ strength =
62
+ allLegacy && (derived === "none" || derived === "scenario") ? "ran-pass" : derived;
63
+ }
64
+ else
65
+ strength = t.evidence.length ? "scenario" : "none"; // 증거가 있으나 무효=stale/scenario, 없으면 none
66
+ const status = mapStatus(t.status);
67
+ const alarm = status === "done" && (strength === "none" || strength === "scenario");
68
+ const verifiedVideo = valid.find((e) => e.type === "video" && e.humanVerified === true);
69
+ return {
70
+ strength,
71
+ alarm,
72
+ humanVerified: !!verifiedVideo,
73
+ verifiedAt: verifiedVideo?.verifiedAt,
74
+ valid,
75
+ };
76
+ }
77
+ function nodeFor(dir, t, all, visited) {
78
+ // 사이클 방어: 현재 경로에 이미 방문한 노드면 다시 내려가지 않는다 (손편집 parent 순환 → 무한루프 방지).
79
+ const seen = new Set(visited);
80
+ seen.add(t.id);
81
+ const children = all
82
+ .filter((c) => c.parent === t.id && !seen.has(c.id))
83
+ .map((c) => nodeFor(dir, c, all, seen));
84
+ const node = {
85
+ id: t.id,
86
+ title: t.title,
87
+ kind: t.kind,
88
+ platform: t.platform,
89
+ locked: t.kindLocked,
90
+ status: mapStatus(t.status),
91
+ children,
92
+ };
93
+ // 부모(자식 보유) 노드는 강도/증거를 생략 — 자동 집계만. leaf claim 만 권위 강도 계산.
94
+ if (children.length === 0 && t.kind !== "human-gate") {
95
+ const a = authoritative(dir, t);
96
+ node.strength = a.strength;
97
+ node.alarm = a.alarm;
98
+ if (a.humanVerified) {
99
+ node.humanVerified = true;
100
+ if (a.verifiedAt)
101
+ node.verifiedAt = a.verifiedAt;
102
+ }
103
+ const ev = displayEvidence(dir, t, a.valid);
104
+ if (ev)
105
+ node.evidence = ev;
106
+ // done 인데 유효 증거 없음 → ⚠ 알람 패널은 claim 으로 렌더 (Task 9 동작 유지).
107
+ if (a.alarm)
108
+ node.claim = t.title || "완료";
109
+ }
110
+ // human-gate / needs-human: evidence·claim·strength 없음 (클라가 🔒/진행 렌더)
111
+ return node;
112
+ }
113
+ // 7개 Tier1 프레임워크의 표시명 (display-cased).
114
+ const FW_DISPLAY = {
115
+ maestro: "Maestro",
116
+ detox: "Detox",
117
+ appium: "Appium",
118
+ xcuitest: "XCUITest",
119
+ espresso: "Espresso",
120
+ playwright: "Playwright",
121
+ cypress: "Cypress",
122
+ };
123
+ const ALL_FRAMEWORKS = [
124
+ "maestro", "detox", "appium", "xcuitest", "espresso", "playwright", "cypress",
125
+ ];
126
+ function buildTier1(dir) {
127
+ const detected = detectFrameworks(dir); // used 인 것만
128
+ const usedSet = new Set(detected.map((d) => d.framework));
129
+ const frameworks = ALL_FRAMEWORKS.map((fw) => {
130
+ const used = usedSet.has(fw);
131
+ const tier2 = fw === "maestro"; // v1: 실제 실행(Tier2) 러너는 Maestro 만.
132
+ return {
133
+ name: FW_DISPLAY[fw],
134
+ status: used ? "active" : "unused",
135
+ tier2,
136
+ note: used ? "탐지됨" : "미설정",
137
+ };
138
+ });
139
+ const warnings = [];
140
+ if (usedSet.size === 0) {
141
+ warnings.push({ level: "red", text: "테스트 프레임워크 아예 없음 — screen 작업은 ran-pass 도달 불가" });
142
+ }
143
+ else {
144
+ const nonMaestroUsed = [...usedSet].some((f) => f !== "maestro");
145
+ if (nonMaestroUsed) {
146
+ warnings.push({
147
+ level: "orange",
148
+ text: "실제 실행(Tier2) 가능 = Maestro 뿐. 나머지는 탐지만 가능(시나리오 존재 확인).",
149
+ });
150
+ }
151
+ }
152
+ return { frameworks, warnings };
153
+ }
154
+ export function buildLedger(dir) {
155
+ // 매 렌더마다 repo-mtime 메모를 비워 fresh walk 강제 — 장수 서버가 세션 내내 staleness 를
156
+ // 과소보고(stale-lenient drift)하지 않게. 이 렌더 안의 많은 작업끼리는 여전히 1 walk 로 메모. (F4)
157
+ clearRepoMtimeMemo();
158
+ const c = loadChecklist(dir);
159
+ const ids = new Set(c.tasks.map((t) => t.id));
160
+ // root = parent 없음 OR parent 가 존재하지 않는 id (orphan) → 트리에서 사라지지 않도록 top-level 로 노출.
161
+ const roots = c.tasks.filter((t) => t.parent === null || !ids.has(t.parent));
162
+ return {
163
+ tier1: buildTier1(dir),
164
+ groups: roots.map((t) => nodeFor(dir, t, c.tasks, new Set())),
165
+ };
166
+ }
167
+ // 사람 전용 영상 확인 경로. 작업의 video 증거에 humanVerified+verifiedAt 를 set 하고 영속화한다.
168
+ // MCP/CLI(Claude 접근 가능) 경로가 아니다 — 브라우저 버튼만 호출. (F1)
169
+ export function verifyVideo(dir, taskId) {
170
+ const c = loadChecklist(dir);
171
+ const t = getTask(c, taskId);
172
+ if (!t)
173
+ return { ok: false, reason: `unknown task ${taskId}` };
174
+ // VALID video 를 우선 대상으로 한다 — authoritative()/displayEvidence 가 humanVerified 를
175
+ // VALID video 에서만 읽으므로, stale video 를 stamp 하면 UI 가 반응하지 않는다. 유효 video 가
176
+ // 없으면 첫 video 로 폴백(아직 검증 전이라도 사람이 확인할 수 있게).
177
+ // validEvidenceEntries 는 별도 로드본이라 참조가 다르다 — 안정 키(path+runAt+hash)로 대조한다.
178
+ const key = (e) => `${e.path}|${e.runAt}|${e.hash}`;
179
+ const validVideoKeys = new Set(validEvidenceEntries(dir, taskId).filter((e) => e.type === "video").map(key));
180
+ let idx = t.evidence.findIndex((e) => e.type === "video" && validVideoKeys.has(key(e)));
181
+ if (idx < 0)
182
+ idx = t.evidence.findIndex((e) => e.type === "video");
183
+ if (idx < 0)
184
+ return { ok: false, reason: `no video evidence on ${taskId}` };
185
+ const verifiedAt = new Date().toISOString();
186
+ const evidence = t.evidence.map((e, i) => i === idx ? { ...e, humanVerified: true, verifiedAt } : e);
187
+ saveChecklist(dir, updateTask(c, taskId, { evidence }));
188
+ return { ok: true };
189
+ }
190
+ // 사람 전용 signoff 경로. human-gate 작업을 done 으로 만들고 신뢰 marker(signedAt)를 영속화한다.
191
+ // MCP/CLI(Claude 접근 가능) 경로가 아니다 — 브라우저 '내가 확인함' 버튼만 호출. F2가 signedAt 을 존중한다. (F2)
192
+ export function signoffTask(dir, id, signedAt = new Date().toISOString()) {
193
+ const c = loadChecklist(dir);
194
+ const t = getTask(c, id);
195
+ if (!t)
196
+ return { ok: false, id, reason: `unknown task ${id}` };
197
+ saveChecklist(dir, updateTask(c, id, { status: "done", signedAt }));
198
+ return { ok: true, id, status: "done", signedAt };
199
+ }
200
+ const CONTENT_TYPES = {
201
+ ".html": "text/html; charset=utf-8",
202
+ ".png": "image/png",
203
+ ".jpg": "image/jpeg",
204
+ ".jpeg": "image/jpeg",
205
+ ".gif": "image/gif",
206
+ ".svg": "image/svg+xml",
207
+ ".webp": "image/webp",
208
+ ".json": "application/json; charset=utf-8",
209
+ ".txt": "text/plain; charset=utf-8",
210
+ ".xml": "application/xml; charset=utf-8",
211
+ ".mp4": "video/mp4",
212
+ ".mov": "video/quicktime",
213
+ ".webm": "video/webm",
214
+ };
215
+ function readBody(req) {
216
+ return new Promise((resolve) => {
217
+ let data = "";
218
+ req.on("data", (c) => (data += c));
219
+ req.on("end", () => resolve(data));
220
+ });
221
+ }
222
+ export function startDashboard(dir, port = 4477) {
223
+ const here = dirname(fileURLToPath(import.meta.url));
224
+ const indexPath = join(here, "web", "index.html");
225
+ const server = createServer(async (req, res) => {
226
+ const url = (req.url || "/").split("?")[0];
227
+ if (req.method === "GET" && (url === "/" || url === "/index.html")) {
228
+ if (!existsSync(indexPath)) {
229
+ res.writeHead(404);
230
+ res.end("index.html not found");
231
+ return;
232
+ }
233
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
234
+ res.end(readFileSync(indexPath));
235
+ return;
236
+ }
237
+ if (req.method === "GET" && url === "/api/checklist") {
238
+ res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
239
+ res.end(JSON.stringify(buildLedger(dir)));
240
+ return;
241
+ }
242
+ if (req.method === "POST" && url === "/api/signoff") {
243
+ const body = await readBody(req);
244
+ let id = "";
245
+ try {
246
+ id = JSON.parse(body || "{}").id;
247
+ }
248
+ catch { /* ignore */ }
249
+ const out = signoffTask(dir, id);
250
+ res.writeHead(out.ok === false ? 400 : 200, { "content-type": "application/json; charset=utf-8" });
251
+ res.end(JSON.stringify(out));
252
+ return;
253
+ }
254
+ // 사람 전용 영상 확인 — 브라우저 버튼만 호출. Claude(MCP/CLI) 경로 없음. (F1)
255
+ if (req.method === "POST" && url === "/api/verify-video") {
256
+ const body = await readBody(req);
257
+ let id = "";
258
+ try {
259
+ id = JSON.parse(body || "{}").id;
260
+ }
261
+ catch { /* ignore */ }
262
+ const out = verifyVideo(dir, id);
263
+ res.writeHead(out.ok === false ? 400 : 200, { "content-type": "application/json; charset=utf-8" });
264
+ res.end(JSON.stringify(out));
265
+ return;
266
+ }
267
+ if (req.method === "GET" && url.startsWith("/artifacts/")) {
268
+ const rel = decodeURIComponent(url.slice("/artifacts/".length));
269
+ const base = join(dir, ".prooflist", "artifacts");
270
+ const target = normalize(join(base, rel));
271
+ if (!target.startsWith(base + (base.endsWith("/") ? "" : "/"))) {
272
+ res.writeHead(403);
273
+ res.end("forbidden");
274
+ return;
275
+ }
276
+ if (!existsSync(target) || !statSync(target).isFile()) {
277
+ res.writeHead(404);
278
+ res.end("not found");
279
+ return;
280
+ }
281
+ res.writeHead(200, { "content-type": CONTENT_TYPES[extname(target).toLowerCase()] || "application/octet-stream" });
282
+ res.end(readFileSync(target));
283
+ return;
284
+ }
285
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
286
+ res.end("not found");
287
+ });
288
+ // localhost 전용 바인드 — /api/signoff·/artifacts 를 LAN 에 노출하지 않는다.
289
+ server.listen(port, "127.0.0.1", () => {
290
+ const addr = server.address();
291
+ const p = typeof addr === "object" && addr ? addr.port : port;
292
+ console.log(`prooflist dashboard → http://localhost:${p}`);
293
+ });
294
+ return server;
295
+ }
296
+ // 바이너리로 실행될 때만 자동 기동.
297
+ if (isMainModule(import.meta.url))
298
+ startDashboard(process.cwd());