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 +49 -0
- package/dist/adapters/index.js +150 -0
- package/dist/classify.js +15 -0
- package/dist/cli.js +153 -0
- package/dist/dashboard.js +298 -0
- package/dist/detect.js +43 -0
- package/dist/evidence.js +15 -0
- package/dist/gate.js +37 -0
- package/dist/hook.js +18 -0
- package/dist/init.js +117 -0
- package/dist/ismain.js +18 -0
- package/dist/mcp.js +106 -0
- package/dist/runner.js +54 -0
- package/dist/store.js +54 -0
- package/dist/types.js +1 -0
- package/dist/util.js +88 -0
- package/dist/web/index.html +853 -0
- package/package.json +37 -0
- package/plugin/.claude-plugin/plugin.json +10 -0
- package/plugin/.mcp.json +9 -0
- package/plugin/README.md +29 -0
- package/plugin/commands/pl-status.md +11 -0
- package/plugin/skills/prooflist/SKILL.md +33 -0
- package/skill/SKILL.md +33 -0
package/dist/detect.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { adapters } from "./adapters/index.js";
|
|
2
|
+
// Tier1: 전 어댑터의 detect를 돌려 실제로 쓰는(used) 것만 보고. (§5.2)
|
|
3
|
+
export function detectFrameworks(repoRoot) {
|
|
4
|
+
return adapters.map((a) => a.detect(repoRoot)).filter((d) => d.used);
|
|
5
|
+
}
|
|
6
|
+
// 강도 순서, 약→강. (§6.1)
|
|
7
|
+
export const STRENGTH_ORDER = [
|
|
8
|
+
"none",
|
|
9
|
+
"scenario",
|
|
10
|
+
"ran-pass",
|
|
11
|
+
"visual-verified",
|
|
12
|
+
];
|
|
13
|
+
// 단일 증거의 강도. PURE-DERIVE: 저장된 ev.strength 와 ev.note(watch 힌트)는 절대 신뢰/사용하지
|
|
14
|
+
// 않고, 검증 가능한 사실(type/tier/passed/humanVerified)에서만 도출한다. operator-Claude가
|
|
15
|
+
// strength:"visual-verified"를 위조하거나 watch 분석으로 자기-등급을 올릴 수 없게 하는 F1 방어. (§6.1)
|
|
16
|
+
//
|
|
17
|
+
// 주의: strength는 v1에서 **표시/자문용(advisory)** — 게이트(canComplete/checkIntegrity)는 strength를
|
|
18
|
+
// 보지 않고 isEvidenceValid(passed+fresh+hash+exists)로만 강제한다. §6.1의 kind별 최소 강도 강제는
|
|
19
|
+
// 결정론 게이트를 바꿔야 하므로(3차 개정 불변식: 게이트 불변) 후속 단계. 이 함수가 load-bearing이라고
|
|
20
|
+
// 가정하지 말 것.
|
|
21
|
+
export function evidenceStrength(ev) {
|
|
22
|
+
if (ev.type === "video" && ev.humanVerified === true)
|
|
23
|
+
return "visual-verified";
|
|
24
|
+
if (ev.type === "video")
|
|
25
|
+
return "ran-pass"; // 실행 녹화이나 아직 사람 확인 전
|
|
26
|
+
if (ev.tier === "run" && ev.passed === true)
|
|
27
|
+
return "ran-pass";
|
|
28
|
+
if (ev.tier === "run")
|
|
29
|
+
return "scenario"; // 실행했으나 실패 — 시나리오는 존재
|
|
30
|
+
if (ev.tier === "detect")
|
|
31
|
+
return "scenario";
|
|
32
|
+
return "none";
|
|
33
|
+
}
|
|
34
|
+
// 증거 묶음에서 가장 강한 강도, 비어 있으면 none. (§6.1)
|
|
35
|
+
export function bestStrength(evidence) {
|
|
36
|
+
let best = "none";
|
|
37
|
+
for (const ev of evidence) {
|
|
38
|
+
const s = evidenceStrength(ev);
|
|
39
|
+
if (STRENGTH_ORDER.indexOf(s) > STRENGTH_ORDER.indexOf(best))
|
|
40
|
+
best = s;
|
|
41
|
+
}
|
|
42
|
+
return best;
|
|
43
|
+
}
|
package/dist/evidence.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export function stateHash(flowContent, codeFingerprint) {
|
|
3
|
+
return createHash("sha256").update(flowContent).update("\0").update(codeFingerprint).digest("hex");
|
|
4
|
+
}
|
|
5
|
+
export function isEvidenceValid(ev, f) {
|
|
6
|
+
if (!ev.passed)
|
|
7
|
+
return { ok: false, reason: "evidence did not pass" };
|
|
8
|
+
if (!f.fileExists)
|
|
9
|
+
return { ok: false, reason: "artifact file missing" };
|
|
10
|
+
if (ev.hash !== f.expectedHash)
|
|
11
|
+
return { ok: false, reason: "artifact hash mismatch (stale or reused)" };
|
|
12
|
+
if (new Date(ev.runAt).getTime() < new Date(f.latestCodeChange).getTime())
|
|
13
|
+
return { ok: false, reason: "artifact older than latest code change" };
|
|
14
|
+
return { ok: true };
|
|
15
|
+
}
|
package/dist/gate.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { kindMismatch } from "./classify.js";
|
|
2
|
+
// pl done 시 단일 작업 완료 가능 판정.
|
|
3
|
+
export function canComplete(task, f) {
|
|
4
|
+
if (task.kind === "human-gate")
|
|
5
|
+
return {
|
|
6
|
+
ok: false,
|
|
7
|
+
reason: "human-gate task cannot be completed by Claude",
|
|
8
|
+
nextAction: `Ask 승기 to sign off task '${task.id}' via the dashboard '내가 확인함' button (Claude cannot sign off).`,
|
|
9
|
+
};
|
|
10
|
+
if (task.kind === "screen" && !f.hasValidEvidence)
|
|
11
|
+
return {
|
|
12
|
+
ok: false,
|
|
13
|
+
reason: "screen task has no valid passing Maestro artifact",
|
|
14
|
+
nextAction: `Run 'pl test ${task.id}' and attach a passing Maestro artifact before claiming done.`,
|
|
15
|
+
};
|
|
16
|
+
return { ok: true };
|
|
17
|
+
}
|
|
18
|
+
// pl gate --check (Stop 훅) 시 전체 무결성 검사.
|
|
19
|
+
// isValidEvidence(taskId): 해당 작업에 유효 아티팩트가 있는지 — CLI가 fs로 채워 주입.
|
|
20
|
+
export function checkIntegrity(c, isValidEvidence) {
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const t of c.tasks) {
|
|
23
|
+
if (t.status !== "done")
|
|
24
|
+
continue;
|
|
25
|
+
// signedAt is set only by the dashboard human-only signoff route; like all checklist.json state it is F5-hand-editable (documented limit — full closure needs the §11 isolated verifier).
|
|
26
|
+
if (t.kind === "human-gate" && !t.signedAt)
|
|
27
|
+
out.push({ taskId: t.id, rule: "F2", reason: "human-gate marked done without human signoff",
|
|
28
|
+
nextAction: `Revert task '${t.id}' to needs-human; only 승기 can sign it off (via the dashboard).` });
|
|
29
|
+
if (t.kind === "screen" && !isValidEvidence(t.id))
|
|
30
|
+
out.push({ taskId: t.id, rule: "F1", reason: "screen task done without valid Maestro artifact",
|
|
31
|
+
nextAction: `Run 'pl test ${t.id}' to produce passing evidence, or revert it.` });
|
|
32
|
+
if (kindMismatch(t))
|
|
33
|
+
out.push({ taskId: t.id, rule: "F3", reason: "logic task touched UI files (kind downgraded)",
|
|
34
|
+
nextAction: `Set kind of '${t.id}' to screen ('pl set-kind ${t.id} screen') and run 'pl test ${t.id}'.` });
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
package/dist/hook.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runCli } from "./cli.js";
|
|
3
|
+
import { isMainModule } from "./ismain.js";
|
|
4
|
+
// Claude Code Stop 훅 계약: 차단하려면 stdout에 {"decision":"block","reason":...} 출력.
|
|
5
|
+
export function gateHook(dir) {
|
|
6
|
+
const out = runCli(["gate", "--check"], dir);
|
|
7
|
+
if (out.ok)
|
|
8
|
+
return { continue: true };
|
|
9
|
+
const lines = out.violations.map((v) => `- [${v.rule}] ${v.taskId}: ${v.reason}\n → ${v.nextAction}`);
|
|
10
|
+
return {
|
|
11
|
+
decision: "block",
|
|
12
|
+
reason: `ProofList gate blocked turn end. Unverified work:\n${lines.join("\n")}`,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
if (isMainModule(import.meta.url)) {
|
|
16
|
+
console.log(JSON.stringify(gateHook(process.cwd())));
|
|
17
|
+
process.exit(0); // 차단은 decision 필드로, 종료코드 아님
|
|
18
|
+
}
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { saveChecklist, emptyChecklist } from "./store.js";
|
|
5
|
+
import { isMainModule } from "./ismain.js";
|
|
6
|
+
const HOOK_CMD = "npx prooflist-hook";
|
|
7
|
+
const GITIGNORE_ENTRY = ".prooflist/artifacts/";
|
|
8
|
+
const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
9
|
+
// .claude/settings.json 변이를 메모리에서 준비한다 (Stop 훅 머지). 파일은 쓰지 않는다.
|
|
10
|
+
// 반환: { prepared } = 쓸 준비된 객체+신규여부, 또는 { reason } = 깨끗한 실패(덮어쓰지 않음).
|
|
11
|
+
function prepareSettings(dir) {
|
|
12
|
+
const sp = join(dir, ".claude/settings.json");
|
|
13
|
+
let settings = {};
|
|
14
|
+
if (existsSync(sp)) {
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = JSON.parse(readFileSync(sp, "utf8"));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return {
|
|
21
|
+
reason: ".claude/settings.json is not valid JSON; fix it and re-run 'pl init' (refusing to overwrite).",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (!isPlainObject(parsed)) {
|
|
25
|
+
return { reason: ".claude/settings.json is not a JSON object; refusing to overwrite." };
|
|
26
|
+
}
|
|
27
|
+
settings = parsed;
|
|
28
|
+
}
|
|
29
|
+
if (!isPlainObject(settings.hooks)) {
|
|
30
|
+
if (settings.hooks === undefined)
|
|
31
|
+
settings.hooks = {};
|
|
32
|
+
else
|
|
33
|
+
return { reason: ".claude/settings.json 'hooks' is not an object; refusing to overwrite." };
|
|
34
|
+
}
|
|
35
|
+
const hooks = settings.hooks;
|
|
36
|
+
if (hooks.Stop === undefined)
|
|
37
|
+
hooks.Stop = [];
|
|
38
|
+
else if (!Array.isArray(hooks.Stop))
|
|
39
|
+
return { reason: ".claude/settings.json 'hooks.Stop' is not an array; refusing to overwrite." };
|
|
40
|
+
const stop = hooks.Stop;
|
|
41
|
+
const already = stop.some((g) => Array.isArray(g?.hooks) && g.hooks.some((h) => h?.command === HOOK_CMD));
|
|
42
|
+
if (!already)
|
|
43
|
+
stop.push({ hooks: [{ type: "command", command: HOOK_CMD }] });
|
|
44
|
+
return { prepared: { path: sp, config: settings, added: !already } };
|
|
45
|
+
}
|
|
46
|
+
// .mcp.json 변이를 메모리에서 준비한다 (prooflist stdio 서버 머지). 파일은 쓰지 않는다.
|
|
47
|
+
// settings.json과 동일한 가드된-파스 견고성.
|
|
48
|
+
function prepareMcpJson(dir) {
|
|
49
|
+
const mp = join(dir, ".mcp.json");
|
|
50
|
+
let config = {};
|
|
51
|
+
if (existsSync(mp)) {
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(readFileSync(mp, "utf8"));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { reason: ".mcp.json is not valid JSON; fix it and re-run 'pl init' (refusing to overwrite)." };
|
|
58
|
+
}
|
|
59
|
+
if (!isPlainObject(parsed)) {
|
|
60
|
+
return { reason: ".mcp.json is not a JSON object; refusing to overwrite." };
|
|
61
|
+
}
|
|
62
|
+
config = parsed;
|
|
63
|
+
}
|
|
64
|
+
if (config.mcpServers === undefined)
|
|
65
|
+
config.mcpServers = {};
|
|
66
|
+
else if (!isPlainObject(config.mcpServers))
|
|
67
|
+
return { reason: ".mcp.json 'mcpServers' is not an object; refusing to overwrite." };
|
|
68
|
+
const servers = config.mcpServers;
|
|
69
|
+
const already = servers.prooflist !== undefined;
|
|
70
|
+
if (!already)
|
|
71
|
+
servers.prooflist = { type: "stdio", command: "npx", args: ["prooflist-mcp"] };
|
|
72
|
+
return { prepared: { path: mp, config, added: !already } };
|
|
73
|
+
}
|
|
74
|
+
function writePrepared(p) {
|
|
75
|
+
writeFileSync(p.path, JSON.stringify(p.config, null, 2) + "\n");
|
|
76
|
+
}
|
|
77
|
+
// .gitignore에 .prooflist/artifacts/ 를 보장한다 (스샷·영상·junit은 git 추적 제외;
|
|
78
|
+
// checklist.json은 추적). 멱등 — 이미 있으면 건드리지 않는다. 없으면 생성/추가.
|
|
79
|
+
function ensureGitignore(dir) {
|
|
80
|
+
const gp = join(dir, ".gitignore");
|
|
81
|
+
let body = "";
|
|
82
|
+
if (existsSync(gp)) {
|
|
83
|
+
body = readFileSync(gp, "utf8");
|
|
84
|
+
const has = body
|
|
85
|
+
.split(/\r?\n/)
|
|
86
|
+
.some((l) => l.trim() === GITIGNORE_ENTRY || l.trim() === ".prooflist/artifacts");
|
|
87
|
+
if (has)
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
const prefix = body.length > 0 && !body.endsWith("\n") ? "\n" : "";
|
|
91
|
+
const block = `${prefix}\n# ProofList: evidence artifacts (screenshots/videos/junit) — not tracked; checklist.json IS tracked\n${GITIGNORE_ENTRY}\n`;
|
|
92
|
+
writeFileSync(gp, body + block);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
export function initProject(dir) {
|
|
96
|
+
// 1) .prooflist 스캐폴드 (멱등 — 먼저 해도 안전).
|
|
97
|
+
mkdirSync(join(dir, ".prooflist/artifacts"), { recursive: true });
|
|
98
|
+
if (!existsSync(join(dir, ".prooflist/checklist.json")))
|
|
99
|
+
saveChecklist(dir, emptyChecklist());
|
|
100
|
+
mkdirSync(join(dir, ".claude"), { recursive: true });
|
|
101
|
+
// 2) BOTH 설정 파일을 검증/준비만 한다 — 둘 다 통과하기 전엔 아무것도 쓰지 않는다(부분 와이어링 금지).
|
|
102
|
+
const settings = prepareSettings(dir);
|
|
103
|
+
if ("reason" in settings)
|
|
104
|
+
return { ok: false, reason: settings.reason };
|
|
105
|
+
const mcp = prepareMcpJson(dir);
|
|
106
|
+
if ("reason" in mcp)
|
|
107
|
+
return { ok: false, reason: mcp.reason };
|
|
108
|
+
// 3) 둘 다 검증 성공한 뒤에만 쓴다.
|
|
109
|
+
writePrepared(settings.prepared);
|
|
110
|
+
writePrepared(mcp.prepared);
|
|
111
|
+
// 4) .gitignore에 artifacts 제외 보장 (멱등, append-only — JSON 파손 위험 없음).
|
|
112
|
+
const gitignored = ensureGitignore(dir);
|
|
113
|
+
return { ok: true, wired: settings.prepared.added, mcpWired: mcp.prepared.added, gitignored };
|
|
114
|
+
}
|
|
115
|
+
if (isMainModule(import.meta.url)) {
|
|
116
|
+
console.log(JSON.stringify(initProject(process.cwd())));
|
|
117
|
+
}
|
package/dist/ismain.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
// "이 모듈이 직접 실행됐나?" 판정 — npm bin **심볼릭 링크**로 실행돼도 동작한다.
|
|
4
|
+
// process.argv[1].endsWith("x.js")는 bin 심링크(예: /opt/homebrew/bin/prooflist-init)로
|
|
5
|
+
// 실행하면 argv[1]이 심링크 경로라 false가 되어 엔트리포인트가 안 돈다. 그래서
|
|
6
|
+
// argv[1]을 realpath로 풀어 import.meta.url(이미 실제 경로)과 비교한다. 테스트에서
|
|
7
|
+
// import할 때는 argv[1]이 vitest 러너라 false → 부수효과 없음.
|
|
8
|
+
export function isMainModule(metaUrl) {
|
|
9
|
+
const argv1 = process.argv[1];
|
|
10
|
+
if (!argv1)
|
|
11
|
+
return false;
|
|
12
|
+
try {
|
|
13
|
+
return metaUrl === pathToFileURL(realpathSync(argv1)).href;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ProofList MCP 서버 — 게이트 코어를 first-class 툴로 노출.
|
|
3
|
+
//
|
|
4
|
+
// 보안 척추(절대 깨지 않음):
|
|
5
|
+
// - MCP = 편의 기능이지 ENFORCEMENT가 아니다. enforcement는 Stop 훅(pl gate --check)에 남는다.
|
|
6
|
+
// - signoff/init/ui 는 절대 MCP 툴로 노출하지 않는다 (노출된 툴 = Claude가 호출 가능한 액션 = F2 구멍).
|
|
7
|
+
// 이 파일은 signoff/init/ui 핸들러·import를 물리적으로 포함하지 않는다.
|
|
8
|
+
// - 게이트 판정 툴은 runCli(...)에 위임한다 — CLI와 동일한 코드/판정.
|
|
9
|
+
//
|
|
10
|
+
// 단위 테스트 가능한 코어(listTools)는 MCP SDK를 import하지 않는다.
|
|
11
|
+
// SDK는 파일 끝의 가드된 stdio 진입점에서만 사용한다.
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { runCli } from "./cli.js";
|
|
14
|
+
import { loadChecklist, saveChecklist, planTasks } from "./store.js";
|
|
15
|
+
import { isMainModule } from "./ismain.js";
|
|
16
|
+
// 정확히 이 6개 안전 툴만 — signoff/init/ui는 절대 없음.
|
|
17
|
+
export function listTools() {
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
name: "pl_plan",
|
|
21
|
+
description: "Fold a task tree into the checklist (shared planTasks core).",
|
|
22
|
+
inputSchema: {
|
|
23
|
+
tasks: z
|
|
24
|
+
.array(z.object({
|
|
25
|
+
id: z.string(),
|
|
26
|
+
title: z.string(),
|
|
27
|
+
kind: z.enum(["logic", "screen", "human-gate"]).optional(),
|
|
28
|
+
parent: z.string().nullable().optional(),
|
|
29
|
+
platform: z.enum(["ios", "android", "web", "cli"]).optional(),
|
|
30
|
+
touchedPaths: z.array(z.string()).optional(),
|
|
31
|
+
}))
|
|
32
|
+
.describe("Task specs to add (missing fields get sensible defaults)."),
|
|
33
|
+
},
|
|
34
|
+
handler: (args, dir) => {
|
|
35
|
+
const tasks = args.tasks ?? [];
|
|
36
|
+
const c = loadChecklist(dir);
|
|
37
|
+
// planTasks는 순수 — addTask가 중복 id에 throw하면 아무것도 저장되지 않는다(스토어 무손상).
|
|
38
|
+
try {
|
|
39
|
+
const next = planTasks(c, tasks);
|
|
40
|
+
saveChecklist(dir, next);
|
|
41
|
+
return { ok: true, added: tasks.length };
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
reason: e?.message ?? String(e),
|
|
47
|
+
nextAction: "Use unique task ids; check 'pl_status' for existing ids before planning.",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "pl_start",
|
|
54
|
+
description: "Mark a task in_progress.",
|
|
55
|
+
inputSchema: { taskId: z.string() },
|
|
56
|
+
handler: (args, dir) => runCli(["start", args.taskId], dir),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "pl_test",
|
|
60
|
+
description: "Run/attach evidence for a task (gate core).",
|
|
61
|
+
inputSchema: { taskId: z.string() },
|
|
62
|
+
handler: (args, dir) => runCli(["test", args.taskId], dir),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "pl_done",
|
|
66
|
+
description: "Attempt to complete a task — gate core decides (F1/F2 enforced).",
|
|
67
|
+
inputSchema: { taskId: z.string() },
|
|
68
|
+
handler: (args, dir) => runCli(["done", args.taskId], dir),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: "pl_status",
|
|
72
|
+
description: "Report checklist status and rollups.",
|
|
73
|
+
inputSchema: {},
|
|
74
|
+
handler: (_args, dir) => runCli(["status"], dir),
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "pl_gate",
|
|
78
|
+
description: "Run the integrity gate (pl gate --check) — same verdict as the Stop hook.",
|
|
79
|
+
inputSchema: {},
|
|
80
|
+
handler: (_args, dir) => runCli(["gate", "--check"], dir),
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
// ── 가드된 stdio 진입점 (SDK는 여기서만 import) ─────────────────────────────
|
|
85
|
+
// stdio MCP 서버는 절대 stdout에 쓰면 안 된다(JSON-RPC 파손) — 로그는 stderr만.
|
|
86
|
+
if (isMainModule(import.meta.url)) {
|
|
87
|
+
(async () => {
|
|
88
|
+
const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
|
|
89
|
+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
90
|
+
const server = new McpServer({ name: "prooflist", version: "0.1.0" });
|
|
91
|
+
for (const t of listTools()) {
|
|
92
|
+
server.registerTool(t.name, { description: t.description, inputSchema: t.inputSchema }, async (args) => {
|
|
93
|
+
const r = t.handler(args, process.cwd());
|
|
94
|
+
return {
|
|
95
|
+
content: [{ type: "text", text: JSON.stringify(r) }],
|
|
96
|
+
structuredContent: r,
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
await server.connect(new StdioServerTransport());
|
|
101
|
+
console.error("prooflist MCP server connected (stdio)");
|
|
102
|
+
})().catch((e) => {
|
|
103
|
+
console.error("prooflist MCP server failed:", e);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
});
|
|
106
|
+
}
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { stateHash } from "./evidence.js";
|
|
4
|
+
export async function runMaestro(o) {
|
|
5
|
+
const result = await o.exec(o.taskId);
|
|
6
|
+
const runAt = o.runAt ?? new Date().toISOString();
|
|
7
|
+
return {
|
|
8
|
+
type: "maestro",
|
|
9
|
+
path: `${o.taskId}/${runAt.replace(/[:.]/g, "-")}`,
|
|
10
|
+
runAt,
|
|
11
|
+
passed: result.code === 0,
|
|
12
|
+
hash: stateHash(o.flowContent, o.codeFingerprint),
|
|
13
|
+
framework: "maestro",
|
|
14
|
+
tier: "run",
|
|
15
|
+
// strength는 저장하지 않는다 — evidenceStrength(detect.ts)가 도출한다. 저장하면
|
|
16
|
+
// "저장된 strength 불신" 원칙(F1)과 충돌하고 도출값과 어긋날 수 있다.
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
// 영상 증거 팩토리(기기 불필요). type:"video", tier:"run", passed:true 로 만들되 strength·humanVerified
|
|
20
|
+
// 는 절대 set하지 않는다 — strength는 evidenceStrength가 도출하고, humanVerified는 이후 사람 확인 경로만
|
|
21
|
+
// set한다. 따라서 갓 캡처한 영상은 ran-pass로 도출되고, 사람이 확인해야 visual-verified가 된다. (§6.1, F1)
|
|
22
|
+
export function makeVideoEvidence(o) {
|
|
23
|
+
const runAt = o.runAt ?? new Date().toISOString();
|
|
24
|
+
return {
|
|
25
|
+
type: "video",
|
|
26
|
+
path: o.videoPath,
|
|
27
|
+
runAt,
|
|
28
|
+
passed: true,
|
|
29
|
+
hash: stateHash(o.flowContent, o.codeFingerprint),
|
|
30
|
+
framework: "maestro",
|
|
31
|
+
tier: "run",
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
// Maestro Tier1 탐지: .maestro/ 디렉터리의 *.yaml. detect.ts 레지스트리가 재사용. (§5.2)
|
|
35
|
+
export function detectMaestro(repoRoot) {
|
|
36
|
+
const dir = join(repoRoot, ".maestro");
|
|
37
|
+
let scenarioFiles = [];
|
|
38
|
+
if (existsSync(dir)) {
|
|
39
|
+
try {
|
|
40
|
+
scenarioFiles = readdirSync(dir)
|
|
41
|
+
.filter((f) => /\.ya?ml$/.test(f))
|
|
42
|
+
.map((f) => join(dir, f));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
scenarioFiles = [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { framework: "maestro", used: scenarioFiles.length > 0, scenarioFiles };
|
|
49
|
+
}
|
|
50
|
+
export const maestroAdapter = {
|
|
51
|
+
framework: "maestro",
|
|
52
|
+
detect: detectMaestro,
|
|
53
|
+
run: (taskId, exec) => runMaestro({ taskId, flowContent: "", codeFingerprint: "", exec }),
|
|
54
|
+
};
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
export const CHECKLIST_PATH = ".prooflist/checklist.json";
|
|
4
|
+
export const emptyChecklist = () => ({ version: 1, tasks: [] });
|
|
5
|
+
export function loadChecklist(dir) {
|
|
6
|
+
const p = join(dir, CHECKLIST_PATH);
|
|
7
|
+
if (!existsSync(p))
|
|
8
|
+
return emptyChecklist();
|
|
9
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
10
|
+
}
|
|
11
|
+
export function saveChecklist(dir, c) {
|
|
12
|
+
const p = join(dir, CHECKLIST_PATH);
|
|
13
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
14
|
+
writeFileSync(p, JSON.stringify(c, null, 2) + "\n");
|
|
15
|
+
}
|
|
16
|
+
export const getTask = (c, id) => c.tasks.find((t) => t.id === id);
|
|
17
|
+
export function addTask(c, t) {
|
|
18
|
+
if (getTask(c, t.id))
|
|
19
|
+
throw new Error(`duplicate task id: ${t.id}`);
|
|
20
|
+
return { ...c, tasks: [...c.tasks, t] };
|
|
21
|
+
}
|
|
22
|
+
// pl_plan 공유 코어 — 각 spec을 기존 addTask로 폴드해 새 Checklist를 만든다(순수).
|
|
23
|
+
// 누락 필드는 합리적 기본값으로 채운다. (CLI에는 plan 명령이 없는 알려진 단순화 — MCP가 store를 통해 직접 사용.)
|
|
24
|
+
export function planTasks(c, specs) {
|
|
25
|
+
return specs.reduce((acc, spec) => {
|
|
26
|
+
const task = {
|
|
27
|
+
parent: null,
|
|
28
|
+
kind: "logic",
|
|
29
|
+
kindLocked: false,
|
|
30
|
+
status: "todo",
|
|
31
|
+
platform: "cli",
|
|
32
|
+
touchedPaths: [],
|
|
33
|
+
evidence: [],
|
|
34
|
+
...spec,
|
|
35
|
+
};
|
|
36
|
+
return addTask(acc, task);
|
|
37
|
+
}, c);
|
|
38
|
+
}
|
|
39
|
+
export function setStatus(c, id, status) {
|
|
40
|
+
return { ...c, tasks: c.tasks.map((t) => (t.id === id ? { ...t, status } : t)) };
|
|
41
|
+
}
|
|
42
|
+
export function updateTask(c, id, patch) {
|
|
43
|
+
return { ...c, tasks: c.tasks.map((t) => (t.id === id ? { ...t, ...patch } : t)) };
|
|
44
|
+
}
|
|
45
|
+
const childrenOf = (c, id) => c.tasks.filter((t) => t.parent === id);
|
|
46
|
+
export function rollupPercent(c, id) {
|
|
47
|
+
const kids = childrenOf(c, id);
|
|
48
|
+
if (kids.length === 0) {
|
|
49
|
+
const t = getTask(c, id);
|
|
50
|
+
return t?.status === "done" ? 100 : 0;
|
|
51
|
+
}
|
|
52
|
+
const sum = kids.reduce((acc, k) => acc + rollupPercent(c, k.id), 0);
|
|
53
|
+
return Math.round(sum / kids.length);
|
|
54
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, statSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
export function codeFingerprint(dir) {
|
|
5
|
+
try {
|
|
6
|
+
const head = execSync("git rev-parse HEAD", { cwd: dir }).toString().trim();
|
|
7
|
+
const dirty = execSync("git status --porcelain", { cwd: dir }).toString().trim();
|
|
8
|
+
return `${head}:${dirty.length}`;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return "no-git";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
// repo의 최신 소스 파일 mtime(ms). touchedPaths가 비었거나 모두 사라진 작업의
|
|
15
|
+
// freshness 기준선으로 쓰인다 — epoch(0) 면죄부 대신 "가장 최근 코드 변경" 보수 기준. (F4)
|
|
16
|
+
// 가지치기: node_modules/.git/dist/build/.next/.prooflist/artifacts (아티팩트 자신의 mtime은 코드변경이 아님).
|
|
17
|
+
// 메모이즈: 한 프로세스(예: pl gate --check 1회) 안에서 dir당 1번만 walk. CLI/훅은 매 호출이 새 프로세스라 stale 우려 없음.
|
|
18
|
+
const repoMtimeMemo = new Map();
|
|
19
|
+
export function repoLatestMtime(dir) {
|
|
20
|
+
const cached = repoMtimeMemo.get(dir);
|
|
21
|
+
if (cached !== undefined)
|
|
22
|
+
return cached;
|
|
23
|
+
const SKIP = new Set(["node_modules", ".git", "dist", "build", ".next"]);
|
|
24
|
+
let max = 0;
|
|
25
|
+
const rec = (cur, depth) => {
|
|
26
|
+
if (depth > 6)
|
|
27
|
+
return;
|
|
28
|
+
let entries;
|
|
29
|
+
try {
|
|
30
|
+
entries = readdirSync(cur);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
for (const name of entries) {
|
|
36
|
+
const full = join(cur, name);
|
|
37
|
+
let st;
|
|
38
|
+
try {
|
|
39
|
+
st = statSync(full);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (st.isDirectory()) {
|
|
45
|
+
if (SKIP.has(name))
|
|
46
|
+
continue;
|
|
47
|
+
// .prooflist/artifacts 가지치기 — 아티팩트 자신의 mtime이 "코드 변경"으로 잡히지 않게.
|
|
48
|
+
if (name === "artifacts" && full.endsWith(join(".prooflist", "artifacts")))
|
|
49
|
+
continue;
|
|
50
|
+
rec(full, depth + 1);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
if (st.mtimeMs > max)
|
|
54
|
+
max = st.mtimeMs;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
rec(dir, 0);
|
|
59
|
+
repoMtimeMemo.set(dir, max);
|
|
60
|
+
return max;
|
|
61
|
+
}
|
|
62
|
+
// 메모 무효화. 장수 서버(대시보드)는 매 렌더마다 호출해 fresh walk 를 강제한다 —
|
|
63
|
+
// 안 그러면 서버 시작 시점의 repo-latest mtime 을 세션 내내 캐시해 staleness 를 과소보고(stale-lenient)할 수 있다.
|
|
64
|
+
// CLI/훅은 매 호출이 새 프로세스라 영향 없음 (프로세스당 1 walk 메모 이점 유지).
|
|
65
|
+
export function clearRepoMtimeMemo() {
|
|
66
|
+
repoMtimeMemo.clear();
|
|
67
|
+
}
|
|
68
|
+
export function latestCodeChange(dir, paths) {
|
|
69
|
+
let max = 0;
|
|
70
|
+
for (const p of paths) {
|
|
71
|
+
const abs = join(dir, p);
|
|
72
|
+
if (existsSync(abs))
|
|
73
|
+
max = Math.max(max, statSync(abs).mtimeMs);
|
|
74
|
+
}
|
|
75
|
+
// touchedPaths가 무언가로 해석됐으면 그대로(기존 동작 불변). 아니면 repo-latest mtime으로
|
|
76
|
+
// 폴백 — 그것도 0(빈 repo)이면 기존처럼 epoch. (F4: 빈 touchedPaths의 freshness 면죄부 차단)
|
|
77
|
+
if (max > 0)
|
|
78
|
+
return new Date(max).toISOString();
|
|
79
|
+
const fallback = repoLatestMtime(dir);
|
|
80
|
+
return new Date(fallback || 0).toISOString();
|
|
81
|
+
}
|
|
82
|
+
export function flowContent(dir, taskId) {
|
|
83
|
+
const p = join(dir, `.maestro/${taskId}.yaml`);
|
|
84
|
+
return existsSync(p) ? readFileSync(p, "utf8") : "";
|
|
85
|
+
}
|
|
86
|
+
export function artifactExists(dir, relPath) {
|
|
87
|
+
return existsSync(join(dir, ".prooflist/artifacts", relPath));
|
|
88
|
+
}
|