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.
@@ -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
- await saveSnapshot(name, label ?? new Date().toISOString());
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
- const claudeCheck = spawnSync("which", ["claude"], { stdio: "pipe" });
1317
- if (claudeCheck.status !== 0) {
1386
+ if (!isClaudeAvailable()) {
1318
1387
  return res.json({ passed: true, skipped: true, reason: "claude CLI 없음 — 테스트 생략" });
1319
1388
  }
1320
- const result = spawnSync("claude", [
1321
- "-p",
1322
- "--model",
1323
- "haiku",
1324
- `이 프로젝트에서 PR 전에 돌려야 테스트 명령어를 찾아서 실행해줘. .github/workflows/ 디렉토리, package.json scripts, Makefile 등을 확인해. typecheck과 test를 우선 실행하고 결과를 알려줘. 명령어와 결과만 간단히.`,
1325
- ], { cwd: wtPath, encoding: "utf8", stdio: "pipe", timeout: 120_000 });
1326
- const output = result.stdout?.trim() || "";
1327
- const failed = result.status !== 0 || /fail|error|FAIL|ERROR/i.test(output);
1328
- broadcast({ type: "log", name, source: "sanjang", data: failed ? "❌ 테스트 실패" : "✅ 테스트 통과" });
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: !failed,
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 prompt = buildConflictPrompt(conflictFiles);
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.7",
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": "./dist/bin/sanjang.js"
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",