sanjang 0.3.7 → 0.4.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/dashboard/app.js +78 -3
- package/dist/lib/config.js +62 -0
- package/dist/lib/engine/ai.d.ts +25 -0
- package/dist/lib/engine/ai.js +100 -0
- package/dist/lib/engine/change-report.d.ts +9 -1
- package/dist/lib/engine/change-report.js +51 -25
- package/dist/lib/engine/config-hotfix.js +56 -2
- package/dist/lib/engine/conflict.d.ts +6 -4
- package/dist/lib/engine/conflict.js +48 -8
- package/dist/lib/engine/diagnostics.js +42 -7
- package/dist/lib/engine/self-heal.d.ts +6 -1
- package/dist/lib/engine/self-heal.js +68 -3
- package/dist/lib/engine/smart-pr.js +2 -8
- package/dist/lib/engine/snapshot.d.ts +1 -0
- package/dist/lib/engine/snapshot.js +24 -0
- package/dist/lib/engine/suggest.d.ts +11 -2
- package/dist/lib/engine/suggest.js +50 -1
- package/dist/lib/engine/worktree.d.ts +5 -2
- package/dist/lib/engine/worktree.js +51 -3
- package/dist/lib/server.js +171 -16
- package/package.json +3 -3
package/dist/lib/server.js
CHANGED
|
@@ -6,6 +6,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import express from "express";
|
|
8
8
|
import { WebSocket, WebSocketServer } from "ws";
|
|
9
|
+
import { isClaudeAvailable, extractJson } from "./engine/ai.js";
|
|
9
10
|
import { detectTestCommand, loadConfig } from "./config.js";
|
|
10
11
|
import { applyCacheToWorktree, buildCache, isCacheValid } from "./engine/cache.js";
|
|
11
12
|
import { buildChangeReport, generateReportSummary } from "./engine/change-report.js";
|
|
@@ -19,7 +20,7 @@ import { buildClaudePrPrompt, buildFallbackPrBody } from "./engine/pr.js";
|
|
|
19
20
|
import { getProcessInfo, setConfig, startCamp, stopAllCamps, stopCamp } from "./engine/process.js";
|
|
20
21
|
import { diagnoseFromLogs, executeHeal } from "./engine/self-heal.js";
|
|
21
22
|
import { generatePrDescription } from "./engine/smart-pr.js";
|
|
22
|
-
import { listSnapshots, restoreSnapshot, saveSnapshot } from "./engine/snapshot.js";
|
|
23
|
+
import { generateSnapshotLabel, listSnapshots, restoreSnapshot, saveSnapshot } from "./engine/snapshot.js";
|
|
23
24
|
import { getAll, getOne, remove, setCampsDir, upsert } from "./engine/state.js";
|
|
24
25
|
import { suggestTasks } from "./engine/suggest.js";
|
|
25
26
|
import { detectWarp, openWarpTab, removeLaunchConfig } from "./engine/warp.js";
|
|
@@ -27,6 +28,11 @@ import { CampWatcher } from "./engine/watcher.js";
|
|
|
27
28
|
import { addWorktree, campPath, getProjectRoot, listBranches, removeWorktree, setProjectRoot, startBranchRefresh, } from "./engine/worktree.js";
|
|
28
29
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
30
|
// ---------------------------------------------------------------------------
|
|
31
|
+
// Pre-ship test signal patterns
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
const TEST_SUCCESS_SIGNALS = /(?:0 errors|no errors|all tests? passed|tests? passed|passed\s+\d+|0 fail)/i;
|
|
34
|
+
const TEST_FAIL_SIGNALS = /(?:fail|error|FAIL|ERROR)/i;
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
30
36
|
// Error translation
|
|
31
37
|
// ---------------------------------------------------------------------------
|
|
32
38
|
function runGit(args, cwd) {
|
|
@@ -575,6 +581,69 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
575
581
|
// Project info — used by dashboard header
|
|
576
582
|
const projectName = projectRoot.split("/").pop() ?? "project";
|
|
577
583
|
app.get("/api/project", (_req, res) => res.json({ name: projectName }));
|
|
584
|
+
// -------------------------------------------------------------------------
|
|
585
|
+
// AI route inference — fallback when pattern matching fails
|
|
586
|
+
// -------------------------------------------------------------------------
|
|
587
|
+
const INFER_ROUTE_CACHE_MAX = 500;
|
|
588
|
+
const inferRouteCache = new Map();
|
|
589
|
+
app.post("/api/infer-route", async (req, res) => {
|
|
590
|
+
const { filePath, framework } = req.body ?? {};
|
|
591
|
+
if (!filePath || typeof filePath !== "string" || filePath.length > 500 || /[\n\r]/.test(filePath)) {
|
|
592
|
+
return res.status(400).json({ route: null });
|
|
593
|
+
}
|
|
594
|
+
// Check cache first
|
|
595
|
+
const cacheKey = `${filePath}::${framework || ""}`;
|
|
596
|
+
if (inferRouteCache.has(cacheKey)) {
|
|
597
|
+
return res.json({ route: inferRouteCache.get(cacheKey) ?? null });
|
|
598
|
+
}
|
|
599
|
+
if (!isClaudeAvailable()) {
|
|
600
|
+
return res.json({ route: null });
|
|
601
|
+
}
|
|
602
|
+
const frameworkHint = framework ? ` The project uses ${framework}.` : "";
|
|
603
|
+
const prompt = `You are a frontend routing expert. Given a file path in a web project, determine the URL route it corresponds to.${frameworkHint}
|
|
604
|
+
|
|
605
|
+
File path: ${filePath}
|
|
606
|
+
|
|
607
|
+
Respond with ONLY a JSON object (no markdown, no code fences):
|
|
608
|
+
{"route":"/the/route"}
|
|
609
|
+
|
|
610
|
+
Rules:
|
|
611
|
+
- Return the URL path a user would visit in the browser to see this file's content
|
|
612
|
+
- For component/layout/utility files that don't map to a route, return {"route":null}
|
|
613
|
+
- Dynamic segments like [id] should become "1" (placeholder)
|
|
614
|
+
- Remove "index" from the end of routes
|
|
615
|
+
- Route must start with "/"`;
|
|
616
|
+
try {
|
|
617
|
+
const route = await new Promise((resolve) => {
|
|
618
|
+
const proc = spawn("claude", ["-p", prompt, "--model", "haiku", "--output-format", "text"], {
|
|
619
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
620
|
+
});
|
|
621
|
+
let stdout = "";
|
|
622
|
+
proc.stdout.on("data", (d) => { stdout += d; });
|
|
623
|
+
proc.on("close", (code) => {
|
|
624
|
+
if (code !== 0 || !stdout.trim()) {
|
|
625
|
+
resolve(null);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const parsed = extractJson(stdout);
|
|
629
|
+
resolve(parsed && typeof parsed.route === "string" ? parsed.route : null);
|
|
630
|
+
});
|
|
631
|
+
proc.on("error", () => resolve(null));
|
|
632
|
+
setTimeout(() => { proc.kill(); resolve(null); }, 15_000);
|
|
633
|
+
});
|
|
634
|
+
// Evict oldest entry when cache is full
|
|
635
|
+
if (inferRouteCache.size >= INFER_ROUTE_CACHE_MAX) {
|
|
636
|
+
const oldest = inferRouteCache.keys().next().value;
|
|
637
|
+
inferRouteCache.delete(oldest);
|
|
638
|
+
}
|
|
639
|
+
inferRouteCache.set(cacheKey, route);
|
|
640
|
+
return res.json({ route });
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
inferRouteCache.set(cacheKey, null);
|
|
644
|
+
return res.json({ route: null });
|
|
645
|
+
}
|
|
646
|
+
});
|
|
578
647
|
app.get("/api/ports", async (_req, res) => res.json(await scanPorts()));
|
|
579
648
|
app.get("/api/branches", async (_req, res) => {
|
|
580
649
|
try {
|
|
@@ -682,7 +751,7 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
682
751
|
let healed = false;
|
|
683
752
|
for (const action of autoFixable) {
|
|
684
753
|
broadcast({ type: "log", name, source: "sanjang", data: ` → ${action.message}` });
|
|
685
|
-
const result = executeHeal(action, wtPath, projectRoot, freshConfig.setup, freshConfig.copyFiles);
|
|
754
|
+
const result = executeHeal(action, wtPath, projectRoot, freshConfig.setup, freshConfig.copyFiles, processInfo.feLogs);
|
|
686
755
|
if (result.success)
|
|
687
756
|
healed = true;
|
|
688
757
|
}
|
|
@@ -772,7 +841,8 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
772
841
|
if (!getOne(name))
|
|
773
842
|
return res.status(404).json({ error: "not found" });
|
|
774
843
|
try {
|
|
775
|
-
|
|
844
|
+
const snapshotLabel = label ?? (await generateSnapshotLabel(name));
|
|
845
|
+
await saveSnapshot(name, snapshotLabel);
|
|
776
846
|
res.json({ saved: true });
|
|
777
847
|
}
|
|
778
848
|
catch (err) {
|
|
@@ -1313,21 +1383,66 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
1313
1383
|
const wtPath = campPath(name);
|
|
1314
1384
|
broadcast({ type: "log", name, source: "sanjang", data: "🧪 테스트 확인 중..." });
|
|
1315
1385
|
try {
|
|
1316
|
-
|
|
1317
|
-
if (claudeCheck.status !== 0) {
|
|
1386
|
+
if (!isClaudeAvailable()) {
|
|
1318
1387
|
return res.json({ passed: true, skipped: true, reason: "claude CLI 없음 — 테스트 생략" });
|
|
1319
1388
|
}
|
|
1320
|
-
const
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1389
|
+
const prompt = `이 프로젝트에서 PR 전에 돌려야 할 테스트 명령어를 찾아서 실행해줘.
|
|
1390
|
+
.github/workflows/ 디렉토리, package.json scripts, Makefile 등을 확인해.
|
|
1391
|
+
typecheck과 test를 우선 실행해.
|
|
1392
|
+
|
|
1393
|
+
실행이 끝나면 반드시 아래 형식의 JSON만 출력해. 다른 텍스트는 넣지 마.
|
|
1394
|
+
\`\`\`json
|
|
1395
|
+
{
|
|
1396
|
+
"passed": true 또는 false,
|
|
1397
|
+
"summary": "결과를 한 줄로 요약",
|
|
1398
|
+
"commands": ["실행한 명령어1", "실행한 명령어2"],
|
|
1399
|
+
"details": "상세 출력 (에러가 있으면 여기에)"
|
|
1400
|
+
}
|
|
1401
|
+
\`\`\`
|
|
1402
|
+
|
|
1403
|
+
판단 기준:
|
|
1404
|
+
- 모든 테스트와 타입체크가 통과하면 passed: true
|
|
1405
|
+
- 하나라도 실패하면 passed: false
|
|
1406
|
+
- "0 errors", "no errors found" 같은 메시지는 성공이다
|
|
1407
|
+
- exit code 0이면 성공이다`;
|
|
1408
|
+
const { stdout: rawOutput, exitCode } = await new Promise((resolve) => {
|
|
1409
|
+
const child = spawn("claude", ["-p", "--model", "haiku", prompt], {
|
|
1410
|
+
cwd: wtPath,
|
|
1411
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1412
|
+
});
|
|
1413
|
+
let out = "";
|
|
1414
|
+
child.stdout?.on("data", (d) => { out += d.toString(); });
|
|
1415
|
+
child.stderr?.on("data", () => { });
|
|
1416
|
+
const timer = setTimeout(() => { child.kill(); resolve({ stdout: out, exitCode: null }); }, 120_000);
|
|
1417
|
+
child.on("close", (code) => { clearTimeout(timer); resolve({ stdout: out, exitCode: code }); });
|
|
1418
|
+
});
|
|
1419
|
+
const output = rawOutput.trim();
|
|
1420
|
+
// 1차: JSON 파싱 시도 — claude가 structured JSON을 반환한 경우
|
|
1421
|
+
let passed = null;
|
|
1422
|
+
const parsed = extractJson(output);
|
|
1423
|
+
if (parsed && typeof parsed.passed === "boolean") {
|
|
1424
|
+
passed = parsed.passed;
|
|
1425
|
+
}
|
|
1426
|
+
// 2차 fallback: 정규식 판단 (JSON 파싱 실패 시)
|
|
1427
|
+
if (passed === null) {
|
|
1428
|
+
if (exitCode !== 0 && exitCode !== null) {
|
|
1429
|
+
passed = false;
|
|
1430
|
+
}
|
|
1431
|
+
else if (TEST_SUCCESS_SIGNALS.test(output)) {
|
|
1432
|
+
// 성공 시그널이 있으면 fail/error 단어가 있어도 통과로 판단
|
|
1433
|
+
passed = true;
|
|
1434
|
+
}
|
|
1435
|
+
else if (TEST_FAIL_SIGNALS.test(output)) {
|
|
1436
|
+
passed = false;
|
|
1437
|
+
}
|
|
1438
|
+
else {
|
|
1439
|
+
// fail/error 시그널도 없으면 exit code 기준
|
|
1440
|
+
passed = exitCode === 0;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
broadcast({ type: "log", name, source: "sanjang", data: passed ? "✅ 테스트 통과" : "❌ 테스트 실패" });
|
|
1329
1444
|
res.json({
|
|
1330
|
-
passed
|
|
1445
|
+
passed,
|
|
1331
1446
|
output: output.slice(0, 3000),
|
|
1332
1447
|
});
|
|
1333
1448
|
}
|
|
@@ -1501,7 +1616,16 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
1501
1616
|
stdio: "pipe",
|
|
1502
1617
|
}).stdout || "";
|
|
1503
1618
|
const conflictFiles = parseConflictFiles(statusOut);
|
|
1504
|
-
const
|
|
1619
|
+
const conflictDetails = conflictFiles.map((f) => {
|
|
1620
|
+
try {
|
|
1621
|
+
const content = readFileSync(join(wtPath, f), "utf8");
|
|
1622
|
+
return { path: f, sections: parseConflictSections(content) };
|
|
1623
|
+
}
|
|
1624
|
+
catch {
|
|
1625
|
+
return { path: f, sections: [] };
|
|
1626
|
+
}
|
|
1627
|
+
});
|
|
1628
|
+
const prompt = buildConflictPrompt(conflictFiles, conflictDetails);
|
|
1505
1629
|
const child = spawn("claude", ["-p", prompt, "--output-format", "text"], {
|
|
1506
1630
|
cwd: wtPath,
|
|
1507
1631
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -1820,6 +1944,37 @@ export async function createApp(projectRoot, options = {}) {
|
|
|
1820
1944
|
upsert(record);
|
|
1821
1945
|
broadcast({ type: "playground-created", name, data: record });
|
|
1822
1946
|
res.status(201).json(record);
|
|
1947
|
+
// AI 힌트 비동기 생성 (fire-and-forget — 완료 시 WebSocket으로 전송)
|
|
1948
|
+
if (isClaudeAvailable()) {
|
|
1949
|
+
const hintWtPath = wtPath;
|
|
1950
|
+
const hintName = name;
|
|
1951
|
+
const hintDesc = description.trim();
|
|
1952
|
+
const lsProc = spawn("ls", ["-1", hintWtPath], { stdio: ["pipe", "pipe", "pipe"] });
|
|
1953
|
+
let lsOut = "";
|
|
1954
|
+
lsProc.stdout.on("data", (d) => { lsOut += d; });
|
|
1955
|
+
lsProc.on("close", () => {
|
|
1956
|
+
const fileList = lsOut.trim() || "(파일 목록 없음)";
|
|
1957
|
+
const prompt = [
|
|
1958
|
+
`프로젝트 설명: ${hintDesc}`,
|
|
1959
|
+
`프로젝트 파일 구조:\n${fileList}`,
|
|
1960
|
+
"",
|
|
1961
|
+
"이 작업을 시작하려면 어떤 파일을 먼저 열어야 하나? 한국어 2-3줄로 간결하게 알려줘.",
|
|
1962
|
+
].join("\n");
|
|
1963
|
+
const claudeProc = spawn("claude", ["-p", "--model", "haiku", prompt], {
|
|
1964
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1965
|
+
});
|
|
1966
|
+
let claudeOut = "";
|
|
1967
|
+
claudeProc.stdout.on("data", (d) => { claudeOut += d; });
|
|
1968
|
+
claudeProc.on("close", (code) => {
|
|
1969
|
+
if (code === 0 && claudeOut.trim()) {
|
|
1970
|
+
broadcast({ type: "camp-hint", name: hintName, data: claudeOut.trim() });
|
|
1971
|
+
}
|
|
1972
|
+
});
|
|
1973
|
+
claudeProc.on("error", () => { });
|
|
1974
|
+
setTimeout(() => { claudeProc.kill(); }, 18_000);
|
|
1975
|
+
});
|
|
1976
|
+
lsProc.on("error", () => { });
|
|
1977
|
+
}
|
|
1823
1978
|
setupCampDeps(name, wtPath, freshConfig2, broadcast, () => triggerStart(name));
|
|
1824
1979
|
}
|
|
1825
1980
|
catch (err) {
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sanjang",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "AI dev environment for vibe coders — camp isolation, self-healing, smart PR",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"sanjang": "
|
|
7
|
+
"sanjang": "dist/bin/sanjang.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/sanjang.js",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"license": "MIT",
|
|
28
28
|
"repository": {
|
|
29
29
|
"type": "git",
|
|
30
|
-
"url": "https://github.com/paul-sherpas/sanjang.git"
|
|
30
|
+
"url": "git+https://github.com/paul-sherpas/sanjang.git"
|
|
31
31
|
},
|
|
32
32
|
"keywords": [
|
|
33
33
|
"dev-tools",
|