harness-bujang 0.2.0 → 0.3.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 +37 -0
- package/dist/index.js +600 -24
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -20,6 +20,22 @@ npx harness-bujang init --lang=ko
|
|
|
20
20
|
npx harness-bujang init --target=./my-app --no-template
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
### See the chat-room — any stack
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Standalone viewer (works on Next.js, Rails, Django, Express, …) — no setup
|
|
27
|
+
npx harness-bujang chat
|
|
28
|
+
# → opens http://localhost:7777 in your browser
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The standalone viewer reads `.harness/chat.db` directly, so it works on any
|
|
32
|
+
project that uses the SQLite chat backend (the default). For projects that have
|
|
33
|
+
not posted any messages yet, pass `--create` to bootstrap an empty DB and seed:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx harness-bujang chat --create
|
|
37
|
+
```
|
|
38
|
+
|
|
23
39
|
## What it does
|
|
24
40
|
|
|
25
41
|
1. **Scans** the project — framework (Next.js / SvelteKit / Astro / Rails / Django / …), language, DB (Supabase / Prisma / Drizzle / TypeORM), UI lib, payment integration, GitHub user.
|
|
@@ -56,6 +72,27 @@ npx harness-bujang status [path]
|
|
|
56
72
|
|
|
57
73
|
Verifies the install: agent files, `CLAUDE.md` section, learning log, chat-room UI. Counts unfilled `{{...}}` placeholders.
|
|
58
74
|
|
|
75
|
+
### `chat`
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
npx harness-bujang chat [options]
|
|
79
|
+
|
|
80
|
+
Options:
|
|
81
|
+
--target=<path> Project root (default: cwd)
|
|
82
|
+
--port=<number> Preferred port (default: 7777, falls forward if busy)
|
|
83
|
+
--no-open Don't auto-open the browser
|
|
84
|
+
--create Create an empty chat DB + schema if none exists yet
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Boots a standalone HTTP server (Node `http`, no framework) that reads
|
|
88
|
+
`<target>/.harness/chat.db` via the system `sqlite3` CLI and serves the
|
|
89
|
+
KakaoTalk-style chat-room viewer. Supports both reading and writing — the
|
|
90
|
+
input bar at the bottom of each room sends `from='대표님'` (principal) messages
|
|
91
|
+
that any agent can pick up next time they read the chat.
|
|
92
|
+
|
|
93
|
+
Requires the `sqlite3` command-line tool (preinstalled on macOS; `apt-get install
|
|
94
|
+
sqlite3` on Ubuntu/WSL; sqlite-tools binaries on Windows).
|
|
95
|
+
|
|
59
96
|
## How the harness works once installed
|
|
60
97
|
|
|
61
98
|
```
|
package/dist/index.js
CHANGED
|
@@ -204,6 +204,13 @@ async function runInit(args) {
|
|
|
204
204
|
if (interactive) {
|
|
205
205
|
try {
|
|
206
206
|
opts = await promptInteractive(opts, scan);
|
|
207
|
+
if (await isExistingInstall(opts.target)) {
|
|
208
|
+
const overwrite = await confirm({
|
|
209
|
+
message: "Existing harness install detected. Overwrite all files to apply your selections?",
|
|
210
|
+
default: false
|
|
211
|
+
});
|
|
212
|
+
if (overwrite) opts.yes = true;
|
|
213
|
+
}
|
|
207
214
|
} catch (err) {
|
|
208
215
|
if (err && typeof err === "object" && "name" in err && err.name === "ExitPromptError") {
|
|
209
216
|
console.log(c.dim(" (aborted)"));
|
|
@@ -218,6 +225,7 @@ async function runInit(args) {
|
|
|
218
225
|
if (scan.framework.startsWith("Next.js")) {
|
|
219
226
|
console.log(c.dim(` Chat-room UI: ${opts.installTemplate ? "install" : "skip"}`));
|
|
220
227
|
}
|
|
228
|
+
console.log(c.dim(` On conflict: ${opts.yes ? "overwrite" : "skip existing files"}`));
|
|
221
229
|
console.log();
|
|
222
230
|
if (interactive) {
|
|
223
231
|
try {
|
|
@@ -448,6 +456,16 @@ function getFlag(args, name) {
|
|
|
448
456
|
}
|
|
449
457
|
return void 0;
|
|
450
458
|
}
|
|
459
|
+
async function isExistingInstall(target) {
|
|
460
|
+
const probes = [
|
|
461
|
+
path2.join(target, ".claude/agents/director.md"),
|
|
462
|
+
path2.join(target, ".claude/agents/dev-team.md")
|
|
463
|
+
];
|
|
464
|
+
for (const p of probes) {
|
|
465
|
+
if (await exists2(p)) return true;
|
|
466
|
+
}
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
451
469
|
async function exists2(p) {
|
|
452
470
|
try {
|
|
453
471
|
await fs2.access(p);
|
|
@@ -837,11 +855,11 @@ async function upsertEnvVar(envFile, key, value) {
|
|
|
837
855
|
}
|
|
838
856
|
async function confirm2(message) {
|
|
839
857
|
process.stdout.write(`${message} [y/N] `);
|
|
840
|
-
return new Promise((
|
|
858
|
+
return new Promise((resolve5) => {
|
|
841
859
|
process.stdin.setEncoding("utf8");
|
|
842
860
|
process.stdin.once("data", (chunk) => {
|
|
843
861
|
const ans = chunk.toString().trim().toLowerCase();
|
|
844
|
-
|
|
862
|
+
resolve5(ans === "y" || ans === "yes");
|
|
845
863
|
process.stdin.pause();
|
|
846
864
|
});
|
|
847
865
|
});
|
|
@@ -875,7 +893,13 @@ console.log(' \u2713 Transferred ' + total + ' messages');
|
|
|
875
893
|
return scriptPath;
|
|
876
894
|
}
|
|
877
895
|
|
|
878
|
-
// src/
|
|
896
|
+
// src/chat.ts
|
|
897
|
+
import * as http from "http";
|
|
898
|
+
import * as path5 from "path";
|
|
899
|
+
import * as fs5 from "fs";
|
|
900
|
+
import { execFile, spawn } from "child_process";
|
|
901
|
+
import { promisify } from "util";
|
|
902
|
+
var execFileP = promisify(execFile);
|
|
879
903
|
var c4 = {
|
|
880
904
|
bold: (s) => `\x1B[1m${s}\x1B[22m`,
|
|
881
905
|
dim: (s) => `\x1B[2m${s}\x1B[22m`,
|
|
@@ -884,16 +908,559 @@ var c4 = {
|
|
|
884
908
|
yellow: (s) => `\x1B[33m${s}\x1B[39m`,
|
|
885
909
|
cyan: (s) => `\x1B[36m${s}\x1B[39m`
|
|
886
910
|
};
|
|
911
|
+
async function runChat(args) {
|
|
912
|
+
const opts = parseArgs3(args);
|
|
913
|
+
try {
|
|
914
|
+
await execFileP("sqlite3", ["--version"]);
|
|
915
|
+
} catch {
|
|
916
|
+
console.log();
|
|
917
|
+
console.log(c4.red("\u2716 The `sqlite3` command-line tool is required for `bujang chat`."));
|
|
918
|
+
console.log();
|
|
919
|
+
console.log(" macOS: already installed");
|
|
920
|
+
console.log(" Ubuntu/WSL: " + c4.bold("sudo apt-get install sqlite3"));
|
|
921
|
+
console.log(" Fedora: " + c4.bold("sudo dnf install sqlite"));
|
|
922
|
+
console.log(" Windows: https://www.sqlite.org/download.html (sqlite-tools-win-x64)");
|
|
923
|
+
console.log();
|
|
924
|
+
process.exitCode = 1;
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
const dbPath = resolveDbPath(opts.target);
|
|
928
|
+
if (!fs5.existsSync(dbPath)) {
|
|
929
|
+
if (!opts.create) {
|
|
930
|
+
console.log();
|
|
931
|
+
console.log(c4.red(`\u2716 Chat DB not found at ${c4.bold(dbPath)}`));
|
|
932
|
+
console.log();
|
|
933
|
+
console.log(" This usually means the harness has not posted any messages yet,");
|
|
934
|
+
console.log(" or the project does not use the SQLite chat backend.");
|
|
935
|
+
console.log();
|
|
936
|
+
console.log(" Run with " + c4.bold("--create") + " to create an empty DB and schema:");
|
|
937
|
+
console.log(" " + c4.cyan("bujang chat --create"));
|
|
938
|
+
console.log();
|
|
939
|
+
process.exitCode = 1;
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
fs5.mkdirSync(path5.dirname(dbPath), { recursive: true });
|
|
943
|
+
await runSql(dbPath, SCHEMA_SQL);
|
|
944
|
+
const seedId = `seed-${Date.now()}`;
|
|
945
|
+
await runSql(
|
|
946
|
+
dbPath,
|
|
947
|
+
`INSERT INTO harness_messages (id, "from", "to", type, message, severity)
|
|
948
|
+
VALUES ('${seedId}', '\uBD80\uC7A5', '\uB300\uD45C\uB2D8', 'info', '\uD1A1\uBC29\uC774 \uC0DD\uC131\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uCCAB \uBA85\uB839\uC744 \uB0B4\uB824\uC8FC\uC138\uC694.', 'info');`
|
|
949
|
+
);
|
|
950
|
+
console.log(c4.dim(` created empty DB + schema at ${dbPath}`));
|
|
951
|
+
} else {
|
|
952
|
+
await runSql(dbPath, SCHEMA_SQL);
|
|
953
|
+
}
|
|
954
|
+
const port = await findOpenPort(opts.port);
|
|
955
|
+
const server = http.createServer(async (req, res) => {
|
|
956
|
+
const url2 = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
957
|
+
if (req.method === "GET" && url2.pathname === "/") {
|
|
958
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
959
|
+
res.end(renderHtml());
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
if (req.method === "GET" && url2.pathname === "/api/messages") {
|
|
963
|
+
const days = parseInt(url2.searchParams.get("days") ?? "7", 10);
|
|
964
|
+
try {
|
|
965
|
+
const rows = await readMessages(dbPath, days);
|
|
966
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
967
|
+
res.end(JSON.stringify({ data: rows }));
|
|
968
|
+
} catch (err) {
|
|
969
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
970
|
+
res.end(JSON.stringify({ data: [], error: String(err) }));
|
|
971
|
+
}
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (req.method === "POST" && url2.pathname === "/api/messages") {
|
|
975
|
+
try {
|
|
976
|
+
const body = await readBody(req);
|
|
977
|
+
const parsed = JSON.parse(body);
|
|
978
|
+
const id = `chat-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
979
|
+
const from = parsed.from || "\uB300\uD45C\uB2D8";
|
|
980
|
+
const to = parsed.to || "\uBD80\uC7A5";
|
|
981
|
+
const type = parsed.type || "command";
|
|
982
|
+
const message = parsed.message || "";
|
|
983
|
+
const severity = parsed.severity || "info";
|
|
984
|
+
if (!message.trim()) {
|
|
985
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
986
|
+
res.end(JSON.stringify({ error: "message is required" }));
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
await runSql(
|
|
990
|
+
dbPath,
|
|
991
|
+
`INSERT INTO harness_messages (id, "from", "to", type, message, severity)
|
|
992
|
+
VALUES (${q(id)}, ${q(from)}, ${q(to)}, ${q(type)}, ${q(message)}, ${q(severity)});`
|
|
993
|
+
);
|
|
994
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
995
|
+
res.end(JSON.stringify({ data: { id } }));
|
|
996
|
+
} catch (err) {
|
|
997
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
998
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
999
|
+
}
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
res.writeHead(404);
|
|
1003
|
+
res.end("not found");
|
|
1004
|
+
});
|
|
1005
|
+
await new Promise((resolve5) => server.listen(port, "127.0.0.1", resolve5));
|
|
1006
|
+
const url = `http://localhost:${port}`;
|
|
1007
|
+
console.log();
|
|
1008
|
+
console.log(c4.bold(c4.green("\u{1F7E2} \uD558\uB124\uC2A4 \uD1A1\uBC29 viewer")) + c4.dim(" \u2014 " + url));
|
|
1009
|
+
console.log(c4.dim(` db: ${dbPath}`));
|
|
1010
|
+
console.log(c4.dim(` stop: Ctrl+C`));
|
|
1011
|
+
console.log();
|
|
1012
|
+
if (opts.open) {
|
|
1013
|
+
openBrowser(url);
|
|
1014
|
+
}
|
|
1015
|
+
process.on("SIGINT", () => {
|
|
1016
|
+
console.log();
|
|
1017
|
+
console.log(c4.dim(" bye \u{1F44B}"));
|
|
1018
|
+
server.close();
|
|
1019
|
+
process.exit(0);
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
function parseArgs3(args) {
|
|
1023
|
+
const targetRaw = getFlag3(args, "--target") ?? ".";
|
|
1024
|
+
const portRaw = getFlag3(args, "--port");
|
|
1025
|
+
const port = portRaw ? parseInt(portRaw, 10) : 7777;
|
|
1026
|
+
if (!Number.isFinite(port) || port < 1024 || port > 65535) {
|
|
1027
|
+
throw new Error(`--port must be between 1024 and 65535, got "${portRaw}"`);
|
|
1028
|
+
}
|
|
1029
|
+
return {
|
|
1030
|
+
target: path5.resolve(targetRaw),
|
|
1031
|
+
port,
|
|
1032
|
+
open: !args.includes("--no-open"),
|
|
1033
|
+
create: args.includes("--create")
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
function getFlag3(args, name) {
|
|
1037
|
+
for (const a of args) {
|
|
1038
|
+
if (a.startsWith(`${name}=`)) return a.slice(name.length + 1);
|
|
1039
|
+
}
|
|
1040
|
+
const idx = args.indexOf(name);
|
|
1041
|
+
if (idx >= 0 && idx + 1 < args.length && !args[idx + 1].startsWith("--")) {
|
|
1042
|
+
return args[idx + 1];
|
|
1043
|
+
}
|
|
1044
|
+
return void 0;
|
|
1045
|
+
}
|
|
1046
|
+
function resolveDbPath(target) {
|
|
1047
|
+
if (process.env.HARNESS_SQLITE_PATH) return process.env.HARNESS_SQLITE_PATH;
|
|
1048
|
+
return path5.join(target, ".harness", "chat.db");
|
|
1049
|
+
}
|
|
1050
|
+
async function findOpenPort(preferred) {
|
|
1051
|
+
for (let p = preferred; p < preferred + 20; p++) {
|
|
1052
|
+
if (await portIsFree(p)) return p;
|
|
1053
|
+
}
|
|
1054
|
+
throw new Error(`Could not find a free port in range ${preferred}-${preferred + 19}`);
|
|
1055
|
+
}
|
|
1056
|
+
function portIsFree(port) {
|
|
1057
|
+
return new Promise((resolve5) => {
|
|
1058
|
+
const tester = http.createServer();
|
|
1059
|
+
tester.once("error", () => resolve5(false));
|
|
1060
|
+
tester.once("listening", () => {
|
|
1061
|
+
tester.close();
|
|
1062
|
+
resolve5(true);
|
|
1063
|
+
});
|
|
1064
|
+
tester.listen(port, "127.0.0.1");
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
function openBrowser(url) {
|
|
1068
|
+
const platform = process.platform;
|
|
1069
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
|
|
1070
|
+
try {
|
|
1071
|
+
spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
|
|
1072
|
+
} catch {
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
function readBody(req) {
|
|
1076
|
+
return new Promise((resolve5, reject) => {
|
|
1077
|
+
const chunks = [];
|
|
1078
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
1079
|
+
req.on("end", () => resolve5(Buffer.concat(chunks).toString("utf8")));
|
|
1080
|
+
req.on("error", reject);
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
async function readMessages(dbPath, days) {
|
|
1084
|
+
const sql = `
|
|
1085
|
+
SELECT id, timestamp, "from" AS sender, "to" AS recipient, type, message, severity
|
|
1086
|
+
FROM harness_messages
|
|
1087
|
+
WHERE timestamp >= datetime('now', '-${Math.max(1, days | 0)} day')
|
|
1088
|
+
ORDER BY timestamp ASC;
|
|
1089
|
+
`;
|
|
1090
|
+
const { stdout } = await execFileP("sqlite3", ["-json", dbPath, sql], {
|
|
1091
|
+
maxBuffer: 32 * 1024 * 1024
|
|
1092
|
+
});
|
|
1093
|
+
if (!stdout.trim()) return [];
|
|
1094
|
+
const raw = JSON.parse(stdout);
|
|
1095
|
+
return raw.map((r) => ({
|
|
1096
|
+
id: r.id,
|
|
1097
|
+
timestamp: r.timestamp,
|
|
1098
|
+
from: r.sender,
|
|
1099
|
+
to: r.recipient,
|
|
1100
|
+
type: r.type,
|
|
1101
|
+
message: r.message,
|
|
1102
|
+
severity: r.severity
|
|
1103
|
+
}));
|
|
1104
|
+
}
|
|
1105
|
+
async function runSql(dbPath, sql) {
|
|
1106
|
+
await execFileP("sqlite3", [dbPath, sql], { maxBuffer: 1024 * 1024 });
|
|
1107
|
+
}
|
|
1108
|
+
function q(value) {
|
|
1109
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
1110
|
+
}
|
|
1111
|
+
var SCHEMA_SQL = `
|
|
1112
|
+
CREATE TABLE IF NOT EXISTS harness_messages (
|
|
1113
|
+
id TEXT PRIMARY KEY,
|
|
1114
|
+
timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
1115
|
+
"from" TEXT NOT NULL,
|
|
1116
|
+
"to" TEXT NOT NULL,
|
|
1117
|
+
type TEXT NOT NULL CHECK (type IN ('command', 'feedback', 'info', 'report')),
|
|
1118
|
+
message TEXT NOT NULL,
|
|
1119
|
+
severity TEXT CHECK (severity IS NULL OR severity IN ('info', 'warning', 'error')),
|
|
1120
|
+
data TEXT,
|
|
1121
|
+
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
1122
|
+
);
|
|
1123
|
+
CREATE INDEX IF NOT EXISTS harness_messages_timestamp_idx ON harness_messages(timestamp DESC);
|
|
1124
|
+
CREATE INDEX IF NOT EXISTS harness_messages_from_to_idx ON harness_messages("from", "to");
|
|
1125
|
+
`;
|
|
1126
|
+
function renderHtml() {
|
|
1127
|
+
return (
|
|
1128
|
+
/* html */
|
|
1129
|
+
`<!DOCTYPE html>
|
|
1130
|
+
<html lang="ko">
|
|
1131
|
+
<head>
|
|
1132
|
+
<meta charset="utf-8">
|
|
1133
|
+
<title>\uD558\uB124\uC2A4 \uD1A1\uBC29</title>
|
|
1134
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1135
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
1136
|
+
<style>
|
|
1137
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Pretendard", sans-serif; }
|
|
1138
|
+
.chat-bg { background: #b2c7d9; }
|
|
1139
|
+
.chat-bubble-bg { background: #ffeb3b; }
|
|
1140
|
+
@keyframes dot { 0%, 80%, 100% { opacity: 0.3; } 40% { opacity: 1; } }
|
|
1141
|
+
.dot { animation: dot 1.4s infinite; }
|
|
1142
|
+
.dot:nth-child(2) { animation-delay: 0.2s; }
|
|
1143
|
+
.dot:nth-child(3) { animation-delay: 0.4s; }
|
|
1144
|
+
</style>
|
|
1145
|
+
</head>
|
|
1146
|
+
<body class="h-screen overflow-hidden">
|
|
1147
|
+
<div id="root" class="flex h-screen">
|
|
1148
|
+
<div class="flex items-center justify-center w-full text-gray-400">\uBD88\uB7EC\uC624\uB294 \uC911...</div>
|
|
1149
|
+
</div>
|
|
1150
|
+
<script>
|
|
1151
|
+
${CLIENT_JS}
|
|
1152
|
+
</script>
|
|
1153
|
+
</body>
|
|
1154
|
+
</html>`
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
var CLIENT_JS = (
|
|
1158
|
+
/* js */
|
|
1159
|
+
`
|
|
1160
|
+
const ROLES = {
|
|
1161
|
+
'\uB300\uD45C\uB2D8': { icon: '\u{1F454}', color: 'text-purple-700', bg: 'bg-purple-100', label: '\uB300\uD45C\uB2D8' },
|
|
1162
|
+
'\uBD80\uC7A5': { icon: '\u{1F9D1}\u200D\u{1F4BC}', color: 'text-blue-700', bg: 'bg-blue-100', label: '\uBD80\uC7A5' },
|
|
1163
|
+
'consultant': { icon: '\u{1F91D}', color: 'text-indigo-700', bg: 'bg-indigo-100', label: '\uCEE8\uC124\uD134\uD2B8' },
|
|
1164
|
+
'dev-team': { icon: '\u{1F4BB}', color: 'text-violet-700', bg: 'bg-violet-100', label: '\uAC1C\uBC1C\uD300' },
|
|
1165
|
+
'architect-team': { icon: '\u{1F3D7}\uFE0F', color: 'text-cyan-700', bg: 'bg-cyan-100', label: '\uC544\uD0A4\uD14D\uCC98\uD300' },
|
|
1166
|
+
'code-review-team': { icon: '\u{1F4DD}', color: 'text-yellow-700', bg: 'bg-yellow-100', label: '\uCF54\uB4DC\uB9AC\uBDF0\uD300' },
|
|
1167
|
+
'doc-sync-team': { icon: '\u{1F4C4}', color: 'text-orange-700', bg: 'bg-orange-100', label: '\uBB38\uC11C\uAD00\uB9AC\uD300' },
|
|
1168
|
+
'security-team': { icon: '\u{1F6E1}\uFE0F', color: 'text-red-700', bg: 'bg-red-100', label: '\uBCF4\uC548\uD300' },
|
|
1169
|
+
'db-guard-team': { icon: '\u{1F5C4}\uFE0F', color: 'text-green-700', bg: 'bg-green-100', label: 'DB\uD300' },
|
|
1170
|
+
'qa-team': { icon: '\u{1F9EA}', color: 'text-teal-700', bg: 'bg-teal-100', label: 'QA\uD300' },
|
|
1171
|
+
'verifier-team': { icon: '\u2705', color: 'text-emerald-700', bg: 'bg-emerald-100', label: '\uAC80\uC218\uD300' },
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
const ROOMS = [
|
|
1175
|
+
{ id: '\uB300\uD45C\uB2D8', name: '\uB300\uD45C \uBCF4\uACE0', icon: '\u{1F454}', members: ['\uB300\uD45C\uB2D8', 'consultant', '\uBD80\uC7A5'] },
|
|
1176
|
+
{ id: 'consultant', name: '\uCEE8\uC124\uD134\uD2B8', icon: '\u{1F91D}', members: ['consultant', '\uBD80\uC7A5'] },
|
|
1177
|
+
{ id: 'architect-team', name: '\uC544\uD0A4\uD14D\uCC98\uD300', icon: '\u{1F3D7}\uFE0F', members: ['\uBD80\uC7A5', 'architect-team'] },
|
|
1178
|
+
{ id: 'code-review-team', name: '\uCF54\uB4DC\uB9AC\uBDF0\uD300', icon: '\u{1F4DD}', members: ['\uBD80\uC7A5', 'code-review-team'] },
|
|
1179
|
+
{ id: 'doc-sync-team', name: '\uBB38\uC11C\uAD00\uB9AC\uD300', icon: '\u{1F4C4}', members: ['\uBD80\uC7A5', 'doc-sync-team'] },
|
|
1180
|
+
{ id: 'security-team', name: '\uBCF4\uC548\uD300', icon: '\u{1F6E1}\uFE0F', members: ['\uBD80\uC7A5', 'security-team'] },
|
|
1181
|
+
{ id: 'db-guard-team', name: 'DB\uD300', icon: '\u{1F5C4}\uFE0F', members: ['\uBD80\uC7A5', 'db-guard-team'] },
|
|
1182
|
+
{ id: 'qa-team', name: 'QA\uD300', icon: '\u{1F9EA}', members: ['\uBD80\uC7A5', 'qa-team'] },
|
|
1183
|
+
{ id: 'verifier-team', name: '\uAC80\uC218\uD300', icon: '\u2705', members: ['\uBD80\uC7A5', 'verifier-team'] },
|
|
1184
|
+
{ id: 'dev-team', name: '\uAC1C\uBC1C\uD300', icon: '\u{1F4BB}', members: ['\uBD80\uC7A5', 'dev-team'] },
|
|
1185
|
+
];
|
|
1186
|
+
|
|
1187
|
+
const STORAGE_KEY = 'harness-bujang-read';
|
|
1188
|
+
const state = {
|
|
1189
|
+
messages: [],
|
|
1190
|
+
selectedRoom: null,
|
|
1191
|
+
readCounts: JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'),
|
|
1192
|
+
loading: true,
|
|
1193
|
+
};
|
|
1194
|
+
|
|
1195
|
+
function getRole(name) {
|
|
1196
|
+
return ROLES[name] || { icon: '\u{1F4AC}', color: 'text-gray-700', bg: 'bg-gray-100', label: name };
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function getRoleLabel(name) {
|
|
1200
|
+
return (ROLES[name] && ROLES[name].label) || name;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function escapeHtml(s) {
|
|
1204
|
+
return String(s == null ? '' : s)
|
|
1205
|
+
.replace(/&/g, '&')
|
|
1206
|
+
.replace(/</g, '<')
|
|
1207
|
+
.replace(/>/g, '>')
|
|
1208
|
+
.replace(/"/g, '"')
|
|
1209
|
+
.replace(/'/g, ''');
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function formatTime(ts) {
|
|
1213
|
+
const d = new Date(ts);
|
|
1214
|
+
if (isNaN(d.getTime())) return '';
|
|
1215
|
+
const h = d.getHours();
|
|
1216
|
+
const m = String(d.getMinutes()).padStart(2, '0');
|
|
1217
|
+
const ampm = h < 12 ? '\uC624\uC804' : '\uC624\uD6C4';
|
|
1218
|
+
const hour = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
|
1219
|
+
return ampm + ' ' + hour + ':' + m;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function formatDate(ts) {
|
|
1223
|
+
const d = new Date(ts);
|
|
1224
|
+
if (isNaN(d.getTime())) return '';
|
|
1225
|
+
return d.getFullYear() + '\uB144 ' + (d.getMonth() + 1) + '\uC6D4 ' + d.getDate() + '\uC77C';
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function filterMessages(messages, roomId) {
|
|
1229
|
+
if (roomId === 'all') return messages;
|
|
1230
|
+
const room = ROOMS.find((r) => r.id === roomId);
|
|
1231
|
+
if (!room) return [];
|
|
1232
|
+
return messages.filter((m) => {
|
|
1233
|
+
if (!room.members.includes(m.from) || !room.members.includes(m.to)) return false;
|
|
1234
|
+
const smaller = ROOMS.find(
|
|
1235
|
+
(r) =>
|
|
1236
|
+
r.id !== roomId &&
|
|
1237
|
+
r.members.length < room.members.length &&
|
|
1238
|
+
r.members.includes(m.from) &&
|
|
1239
|
+
r.members.includes(m.to),
|
|
1240
|
+
);
|
|
1241
|
+
return !smaller;
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
function getLastMessage(messages, roomId) {
|
|
1246
|
+
const filtered = filterMessages(messages, roomId);
|
|
1247
|
+
return filtered.length ? filtered[filtered.length - 1] : null;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function severityBadge(sev) {
|
|
1251
|
+
if (!sev) return '';
|
|
1252
|
+
const m = { error: 'bg-red-500', warning: 'bg-yellow-500', info: 'bg-green-500' };
|
|
1253
|
+
return '<span class="inline-block px-1.5 py-0.5 text-[10px] font-bold ' + (m[sev] || 'bg-gray-500') + ' text-white rounded mr-1">' +
|
|
1254
|
+
(sev === 'error' ? 'ERROR' : sev === 'warning' ? 'WARN' : 'INFO') + '</span>';
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function render() {
|
|
1258
|
+
const root = document.getElementById('root');
|
|
1259
|
+
if (state.loading) {
|
|
1260
|
+
root.innerHTML = '<div class="flex items-center justify-center w-full text-gray-400">\uBD88\uB7EC\uC624\uB294 \uC911...</div>';
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const errors = state.messages.filter((m) => m.severity === 'error').length;
|
|
1265
|
+
const warnings = state.messages.filter((m) => m.severity === 'warning').length;
|
|
1266
|
+
const infos = state.messages.filter((m) => m.severity === 'info').length;
|
|
1267
|
+
|
|
1268
|
+
let html = '<div class="w-80 border-r border-gray-200 bg-white flex flex-col h-full">';
|
|
1269
|
+
html += '<div class="p-4 border-b border-gray-200">';
|
|
1270
|
+
html += '<h1 class="text-lg font-bold text-gray-900">\uD558\uB124\uC2A4 \uD1A1\uBC29</h1>';
|
|
1271
|
+
html += '<p class="text-xs text-gray-500 mt-1">\uC5D0\uC774\uC804\uD2B8 \uAC04 \uBCF4\uACE0 & \uC9C0\uC2DC \u2014 ' + state.messages.length + '\uAC1C \uBA54\uC2DC\uC9C0</p>';
|
|
1272
|
+
if (state.messages.length > 0) {
|
|
1273
|
+
html += '<div class="flex gap-2 mt-2">';
|
|
1274
|
+
if (errors) html += '<span class="px-2 py-0.5 text-xs font-bold bg-red-100 text-red-700 rounded-full">ERR ' + errors + '</span>';
|
|
1275
|
+
if (warnings) html += '<span class="px-2 py-0.5 text-xs font-bold bg-yellow-100 text-yellow-700 rounded-full">WARN ' + warnings + '</span>';
|
|
1276
|
+
if (infos) html += '<span class="px-2 py-0.5 text-xs font-bold bg-green-100 text-green-700 rounded-full">INFO ' + infos + '</span>';
|
|
1277
|
+
html += '</div>';
|
|
1278
|
+
}
|
|
1279
|
+
html += '</div>';
|
|
1280
|
+
|
|
1281
|
+
html += '<div class="flex-1 overflow-y-auto">';
|
|
1282
|
+
for (const room of ROOMS) {
|
|
1283
|
+
const last = getLastMessage(state.messages, room.id);
|
|
1284
|
+
const count = filterMessages(state.messages, room.id).length;
|
|
1285
|
+
const isSelected = state.selectedRoom === room.id;
|
|
1286
|
+
const unread = count - (state.readCounts[room.id] || 0);
|
|
1287
|
+
html += '<button data-room-id="' + escapeHtml(room.id) + '" class="w-full flex items-center gap-3 px-4 py-3 text-left transition-colors ' +
|
|
1288
|
+
(isSelected ? 'bg-indigo-50' : 'hover:bg-gray-50') + '">';
|
|
1289
|
+
html += '<div class="flex-shrink-0 w-12 h-12 rounded-2xl bg-gray-100 flex items-center justify-center text-2xl">' + room.icon + '</div>';
|
|
1290
|
+
html += '<div class="flex-1 min-w-0">';
|
|
1291
|
+
html += '<div class="flex items-center justify-between"><span class="text-sm font-semibold text-gray-900 truncate">' +
|
|
1292
|
+
escapeHtml(room.name) + ' <span class="text-xs text-gray-400 font-normal ml-1">' + room.members.length + '</span></span>';
|
|
1293
|
+
if (last) html += '<span class="text-xs text-gray-400 flex-shrink-0 ml-2">' + formatTime(last.timestamp) + '</span>';
|
|
1294
|
+
html += '</div>';
|
|
1295
|
+
html += '<div class="flex items-center gap-1 mt-0.5">';
|
|
1296
|
+
if (last && last.severity) html += severityBadge(last.severity);
|
|
1297
|
+
html += '<p class="text-xs text-gray-500 truncate">' + (last ? escapeHtml(last.message) : '\uB300\uD654 \uC5C6\uC74C') + '</p>';
|
|
1298
|
+
html += '</div></div>';
|
|
1299
|
+
if (unread > 0) html += '<span class="flex-shrink-0 min-w-[20px] h-5 px-1.5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center">' + unread + '</span>';
|
|
1300
|
+
html += '</button>';
|
|
1301
|
+
}
|
|
1302
|
+
html += '</div></div>';
|
|
1303
|
+
|
|
1304
|
+
// Right pane
|
|
1305
|
+
html += '<div class="flex-1 flex flex-col chat-bg h-full">';
|
|
1306
|
+
if (!state.selectedRoom) {
|
|
1307
|
+
html += '<div class="flex-1 flex items-center justify-center"><div class="text-center"><p class="text-5xl mb-3">\u{1F3E2}</p><p class="text-sm text-white/80">\uCC44\uD305\uBC29\uC744 \uD074\uB9AD\uD574\uC11C \uC5F4\uC5B4\uC8FC\uC138\uC694</p></div></div>';
|
|
1308
|
+
} else {
|
|
1309
|
+
const roomInfo = ROOMS.find((r) => r.id === state.selectedRoom);
|
|
1310
|
+
const roomMessages = filterMessages(state.messages, state.selectedRoom);
|
|
1311
|
+
html += '<div class="px-5 py-3 bg-white border-b border-gray-200 flex items-center gap-3">';
|
|
1312
|
+
html += '<span class="text-xl">' + (roomInfo ? roomInfo.icon : '\u{1F4AC}') + '</span>';
|
|
1313
|
+
html += '<div><h2 class="text-sm font-semibold text-gray-900">' + escapeHtml(roomInfo ? roomInfo.name : state.selectedRoom) + '</h2>';
|
|
1314
|
+
html += '<p class="text-xs text-gray-400">' + (roomInfo ? roomInfo.members.map(getRoleLabel).join(', ') : '') + '</p></div>';
|
|
1315
|
+
html += '<span class="ml-auto text-xs text-gray-400">' + roomMessages.length + '\uAC1C \uBA54\uC2DC\uC9C0</span></div>';
|
|
1316
|
+
|
|
1317
|
+
html += '<div id="conversation" class="flex-1 overflow-y-auto px-5 py-4">';
|
|
1318
|
+
if (roomMessages.length === 0) {
|
|
1319
|
+
html += '<div class="flex items-center justify-center h-full"><div class="text-center"><p class="text-4xl mb-2">\u{1F4AC}</p><p class="text-sm text-white/80">\uB300\uD654\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.</p></div></div>';
|
|
1320
|
+
} else {
|
|
1321
|
+
// Group by date
|
|
1322
|
+
let lastDate = '';
|
|
1323
|
+
for (const msg of roomMessages) {
|
|
1324
|
+
const date = formatDate(msg.timestamp);
|
|
1325
|
+
if (date !== lastDate) {
|
|
1326
|
+
html += '<div class="flex justify-center my-3"><span class="px-3 py-1 text-xs bg-white/40 text-gray-700 rounded-full">' + date + '</span></div>';
|
|
1327
|
+
lastDate = date;
|
|
1328
|
+
}
|
|
1329
|
+
const role = getRole(msg.from);
|
|
1330
|
+
const isMine = msg.from === '\uB300\uD45C\uB2D8';
|
|
1331
|
+
html += '<div class="mb-3 flex ' + (isMine ? 'justify-end' : 'gap-2') + '">';
|
|
1332
|
+
if (!isMine) {
|
|
1333
|
+
html += '<div class="flex-shrink-0 w-9 h-9 rounded-2xl ' + role.bg + ' flex items-center justify-center text-lg">' + role.icon + '</div>';
|
|
1334
|
+
}
|
|
1335
|
+
html += '<div class="' + (isMine ? 'max-w-[70%]' : 'max-w-[70%]') + '">';
|
|
1336
|
+
if (!isMine) {
|
|
1337
|
+
html += '<p class="text-xs text-gray-700 mb-1">' + escapeHtml(role.label) + '</p>';
|
|
1338
|
+
}
|
|
1339
|
+
html += '<div class="flex items-end gap-1 ' + (isMine ? 'flex-row-reverse' : '') + '">';
|
|
1340
|
+
html += '<div class="px-3 py-2 ' + (isMine ? 'chat-bubble-bg text-gray-900' : 'bg-white text-gray-900') + ' rounded-2xl shadow-sm">';
|
|
1341
|
+
if (msg.severity) html += '<div class="mb-1">' + severityBadge(msg.severity) + '</div>';
|
|
1342
|
+
html += '<p class="text-sm whitespace-pre-wrap break-words">' + escapeHtml(msg.message) + '</p>';
|
|
1343
|
+
html += '</div>';
|
|
1344
|
+
html += '<span class="text-[10px] text-gray-600 flex-shrink-0">' + formatTime(msg.timestamp) + '</span>';
|
|
1345
|
+
html += '</div></div></div>';
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
html += '</div>';
|
|
1349
|
+
|
|
1350
|
+
// Input bar \u2014 for sending principal messages.
|
|
1351
|
+
html += '<div class="px-4 py-3 bg-white border-t border-gray-200 flex gap-2">';
|
|
1352
|
+
html += '<input id="msg-input" type="text" placeholder="\uBA54\uC2DC\uC9C0 \uC785\uB825 (Enter \uC804\uC1A1)" class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:border-indigo-500" />';
|
|
1353
|
+
html += '<button id="send-btn" class="px-4 py-2 text-sm font-semibold bg-indigo-500 text-white rounded-lg hover:bg-indigo-600">\uC804\uC1A1</button>';
|
|
1354
|
+
html += '</div>';
|
|
1355
|
+
}
|
|
1356
|
+
html += '</div>';
|
|
1357
|
+
|
|
1358
|
+
root.innerHTML = html;
|
|
1359
|
+
|
|
1360
|
+
// Re-bind handlers
|
|
1361
|
+
document.querySelectorAll('[data-room-id]').forEach((el) => {
|
|
1362
|
+
el.addEventListener('click', () => {
|
|
1363
|
+
state.selectedRoom = el.getAttribute('data-room-id');
|
|
1364
|
+
const count = filterMessages(state.messages, state.selectedRoom).length;
|
|
1365
|
+
state.readCounts[state.selectedRoom] = count;
|
|
1366
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.readCounts));
|
|
1367
|
+
render();
|
|
1368
|
+
const conv = document.getElementById('conversation');
|
|
1369
|
+
if (conv) conv.scrollTop = conv.scrollHeight;
|
|
1370
|
+
});
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
const input = document.getElementById('msg-input');
|
|
1374
|
+
const sendBtn = document.getElementById('send-btn');
|
|
1375
|
+
if (input && sendBtn) {
|
|
1376
|
+
const send = async () => {
|
|
1377
|
+
const text = input.value.trim();
|
|
1378
|
+
if (!text) return;
|
|
1379
|
+
input.disabled = true;
|
|
1380
|
+
sendBtn.disabled = true;
|
|
1381
|
+
try {
|
|
1382
|
+
const target = state.selectedRoom === '\uB300\uD45C\uB2D8' ? '\uBD80\uC7A5' : (state.selectedRoom || '\uBD80\uC7A5');
|
|
1383
|
+
await fetch('/api/messages', {
|
|
1384
|
+
method: 'POST',
|
|
1385
|
+
headers: { 'content-type': 'application/json' },
|
|
1386
|
+
body: JSON.stringify({
|
|
1387
|
+
from: '\uB300\uD45C\uB2D8',
|
|
1388
|
+
to: target,
|
|
1389
|
+
type: 'command',
|
|
1390
|
+
message: text,
|
|
1391
|
+
severity: 'info',
|
|
1392
|
+
}),
|
|
1393
|
+
});
|
|
1394
|
+
input.value = '';
|
|
1395
|
+
await refresh();
|
|
1396
|
+
const conv = document.getElementById('conversation');
|
|
1397
|
+
if (conv) conv.scrollTop = conv.scrollHeight;
|
|
1398
|
+
} catch (e) {
|
|
1399
|
+
alert('\uC804\uC1A1 \uC2E4\uD328: ' + e.message);
|
|
1400
|
+
}
|
|
1401
|
+
input.disabled = false;
|
|
1402
|
+
sendBtn.disabled = false;
|
|
1403
|
+
input.focus();
|
|
1404
|
+
};
|
|
1405
|
+
sendBtn.addEventListener('click', send);
|
|
1406
|
+
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); });
|
|
1407
|
+
input.focus();
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Auto-scroll the selected room to the bottom on first render of that room.
|
|
1411
|
+
const conv = document.getElementById('conversation');
|
|
1412
|
+
if (conv && conv.dataset.scrolled !== state.selectedRoom) {
|
|
1413
|
+
conv.scrollTop = conv.scrollHeight;
|
|
1414
|
+
conv.dataset.scrolled = state.selectedRoom;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
async function refresh() {
|
|
1419
|
+
try {
|
|
1420
|
+
const res = await fetch('/api/messages?days=14');
|
|
1421
|
+
const json = await res.json();
|
|
1422
|
+
state.messages = (json.data || []).map((m) => ({
|
|
1423
|
+
id: m.id,
|
|
1424
|
+
timestamp: m.timestamp,
|
|
1425
|
+
from: m.from,
|
|
1426
|
+
to: m.to,
|
|
1427
|
+
type: m.type,
|
|
1428
|
+
message: m.message,
|
|
1429
|
+
severity: m.severity || undefined,
|
|
1430
|
+
}));
|
|
1431
|
+
state.loading = false;
|
|
1432
|
+
render();
|
|
1433
|
+
} catch (e) {
|
|
1434
|
+
state.loading = false;
|
|
1435
|
+
render();
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
refresh();
|
|
1440
|
+
setInterval(refresh, 2000);
|
|
1441
|
+
`
|
|
1442
|
+
);
|
|
1443
|
+
|
|
1444
|
+
// src/index.ts
|
|
1445
|
+
var c5 = {
|
|
1446
|
+
bold: (s) => `\x1B[1m${s}\x1B[22m`,
|
|
1447
|
+
dim: (s) => `\x1B[2m${s}\x1B[22m`,
|
|
1448
|
+
green: (s) => `\x1B[32m${s}\x1B[39m`,
|
|
1449
|
+
red: (s) => `\x1B[31m${s}\x1B[39m`,
|
|
1450
|
+
yellow: (s) => `\x1B[33m${s}\x1B[39m`,
|
|
1451
|
+
cyan: (s) => `\x1B[36m${s}\x1B[39m`
|
|
1452
|
+
};
|
|
887
1453
|
var HELP = `
|
|
888
|
-
${
|
|
889
|
-
${
|
|
1454
|
+
${c5.bold("harness-bujang")} \u2014 Korean-style multi-agent harness director for Claude Code
|
|
1455
|
+
${c5.dim("https://github.com/bjcho4141/harness-bujang")}
|
|
890
1456
|
|
|
891
|
-
${
|
|
892
|
-
npx harness-bujang ${
|
|
893
|
-
npx harness-bujang ${
|
|
894
|
-
npx harness-bujang ${
|
|
1457
|
+
${c5.bold("Usage:")}
|
|
1458
|
+
npx harness-bujang ${c5.cyan("init")} [options] Install the harness into a project
|
|
1459
|
+
npx harness-bujang ${c5.cyan("status")} [options] Verify the harness install
|
|
1460
|
+
npx harness-bujang ${c5.cyan("chat")} [options] Open the standalone chat-room viewer (any stack)
|
|
1461
|
+
npx harness-bujang ${c5.cyan("migrate")} --to=<sqlite|supabase> Move chat data between backends
|
|
895
1462
|
|
|
896
|
-
${
|
|
1463
|
+
${c5.bold("Options for init:")}
|
|
897
1464
|
--lang=<ko|en> Agent language (default: en)
|
|
898
1465
|
--chat=<sqlite|supabase> Chat-room backend (default: sqlite \u2014 local file, no setup)
|
|
899
1466
|
--commit-chat Don't gitignore .harness/ (for solo cross-machine sync via git)
|
|
@@ -905,31 +1472,37 @@ ${c4.bold("Options for init:")}
|
|
|
905
1472
|
--no-learning-log Skip learning log seed
|
|
906
1473
|
--yes, -y Skip prompts and overwrite (non-interactive \u2014 for CI / scripts)
|
|
907
1474
|
|
|
908
|
-
${
|
|
1475
|
+
${c5.dim("Run without --yes for an interactive setup (prompts for language, backend, etc.).")}
|
|
1476
|
+
|
|
1477
|
+
${c5.bold("Options for chat:")}
|
|
1478
|
+
--target=<path> Project root (default: cwd)
|
|
1479
|
+
--port=<number> Preferred port (default: 7777, falls forward if busy)
|
|
1480
|
+
--no-open Don't auto-open the browser
|
|
1481
|
+
--create Create an empty chat DB + schema if none exists yet
|
|
909
1482
|
|
|
910
|
-
${
|
|
1483
|
+
${c5.bold("Options for migrate:")}
|
|
911
1484
|
--to=<sqlite|supabase> Required \u2014 target backend
|
|
912
1485
|
--target=<path> Project root (default: cwd)
|
|
913
1486
|
--yes, -y Skip confirmation
|
|
914
1487
|
|
|
915
|
-
${
|
|
916
|
-
${
|
|
1488
|
+
${c5.bold("Examples:")}
|
|
1489
|
+
${c5.dim("# Install Korean Bujang persona, SQLite chat (default \u2014 zero setup)")}
|
|
917
1490
|
npx harness-bujang init --lang=ko
|
|
918
1491
|
|
|
919
|
-
${
|
|
920
|
-
npx harness-bujang
|
|
921
|
-
${
|
|
1492
|
+
${c5.dim("# Open the standalone chat-room \u2014 works on ANY stack (Next.js, Rails, Django, \u2026)")}
|
|
1493
|
+
npx harness-bujang chat
|
|
1494
|
+
${c5.dim("# \u2192 opens http://localhost:7777 in your browser")}
|
|
922
1495
|
|
|
923
|
-
${
|
|
1496
|
+
${c5.dim("# Solo, multiple machines \u2014 sync chat history via git")}
|
|
924
1497
|
npx harness-bujang init --commit-chat
|
|
925
1498
|
|
|
926
|
-
${
|
|
1499
|
+
${c5.dim("# Production project with team sharing \u2014 Supabase backend")}
|
|
927
1500
|
npx harness-bujang init --chat=supabase
|
|
928
1501
|
|
|
929
|
-
${
|
|
1502
|
+
${c5.dim("# Started solo, now scaling up \u2014 promote to cloud")}
|
|
930
1503
|
bujang migrate --to=supabase
|
|
931
1504
|
|
|
932
|
-
${
|
|
1505
|
+
${c5.dim("# Going back to solo / archive \u2014 pull cloud data into local SQLite")}
|
|
933
1506
|
bujang migrate --to=sqlite
|
|
934
1507
|
`;
|
|
935
1508
|
async function main() {
|
|
@@ -942,12 +1515,15 @@ async function main() {
|
|
|
942
1515
|
case "status":
|
|
943
1516
|
await runStatus(args.slice(1));
|
|
944
1517
|
break;
|
|
1518
|
+
case "chat":
|
|
1519
|
+
await runChat(args.slice(1));
|
|
1520
|
+
break;
|
|
945
1521
|
case "migrate":
|
|
946
1522
|
await runMigrate(args.slice(1));
|
|
947
1523
|
break;
|
|
948
1524
|
case "--version":
|
|
949
1525
|
case "-v":
|
|
950
|
-
console.log("0.
|
|
1526
|
+
console.log("0.3.0");
|
|
951
1527
|
break;
|
|
952
1528
|
case "--help":
|
|
953
1529
|
case "-h":
|
|
@@ -955,13 +1531,13 @@ async function main() {
|
|
|
955
1531
|
console.log(HELP);
|
|
956
1532
|
break;
|
|
957
1533
|
default:
|
|
958
|
-
console.error(
|
|
1534
|
+
console.error(c5.red(`Unknown command: ${command}`));
|
|
959
1535
|
console.log(HELP);
|
|
960
1536
|
process.exit(1);
|
|
961
1537
|
}
|
|
962
1538
|
}
|
|
963
1539
|
main().catch((err) => {
|
|
964
|
-
console.error(
|
|
1540
|
+
console.error(c5.red(`
|
|
965
1541
|
\u2716 ${err.message}`));
|
|
966
1542
|
if (process.env.DEBUG) console.error(err.stack);
|
|
967
1543
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "harness-bujang",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Install the Harness-Bujang multi-agent harness into any project — Director, 7 specialist teams, real-time chat-room UI. Korean and English personas. Works with Claude Code, Cursor, Cline, Aider, or any tool that reads .claude/agents/.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"build": "npm run prepare-templates && tsup src/index.ts --format esm --target node20 --clean --shims",
|
|
44
44
|
"dev": "tsx src/index.ts",
|
|
45
45
|
"typecheck": "tsc --noEmit",
|
|
46
|
+
"sandbox-test": "bash scripts/sandbox-test.sh",
|
|
46
47
|
"prepublishOnly": "npm run build"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|