karajan-code 4.23.0 → 4.25.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 +8 -0
- package/package.json +1 -1
- package/packages/hu-board/package.json +5 -2
- package/packages/hu-board/public/index.html +5 -0
- package/packages/hu-board/public/utils/board-view.js +14 -13
- package/packages/hu-board/public/utils/command-launcher.js +42 -6
- package/packages/hu-board/public/utils/log-panel.js +4 -4
- package/packages/hu-board/public/utils/maggle-mode.js +127 -0
- package/packages/hu-board/public/utils/modals.js +11 -2
- package/packages/hu-board/public/utils/project-picker-view.js +3 -3
- package/packages/hu-board/public/utils/terminal-panel.js +132 -0
- package/packages/hu-board/src/server.js +25 -0
- package/packages/hu-board/src/sync.js +44 -6
- package/packages/hu-board/src/terminal-wire.js +96 -0
- package/packages/hu-board/src/terminal.js +122 -0
- package/scripts/install-binary.ps1 +12 -3
- package/scripts/install-binary.sh +13 -3
- package/src/cli/advanced-commands.js +1 -0
- package/src/cli/register-meta.js +13 -0
- package/src/commands/board.js +19 -4
- package/src/commands/go.js +139 -0
- package/src/steward/phantom-coverage.js +44 -7
|
@@ -13,6 +13,9 @@ import wikiRoutes from './routes/wiki.js';
|
|
|
13
13
|
import governanceRoutes from './routes/governance.js';
|
|
14
14
|
import { authMiddleware } from './auth.js';
|
|
15
15
|
import { getOrCreateToken, getTokenPath } from './token-store.js';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import { createTerminalManager } from './terminal.js';
|
|
18
|
+
import { terminalRouter, attachTerminalWs } from './terminal-wire.js';
|
|
16
19
|
import { reapZombieSessions } from './zombie-reaper.js';
|
|
17
20
|
import { reapZombieHus } from './hu-zombie-reaper.js';
|
|
18
21
|
import { setHuStatus as setHuStatusPlanMutation, setHuFailResult as setHuFailResultPlanMutation } from './plan-mutations.js';
|
|
@@ -295,6 +298,8 @@ async function main() {
|
|
|
295
298
|
|
|
296
299
|
// Create Express app
|
|
297
300
|
const app = express();
|
|
301
|
+
// node-pty es CJS nativo y @xterm se localiza por resolve: require clásico.
|
|
302
|
+
const requireCjs = createRequire(import.meta.url);
|
|
298
303
|
app.use(...buildSecurityMiddleware());
|
|
299
304
|
app.use(express.json());
|
|
300
305
|
app.use(express.static(PUBLIC_DIR, { setHeaders: noStoreHeaders, etag: false, lastModified: false }));
|
|
@@ -304,6 +309,25 @@ async function main() {
|
|
|
304
309
|
app.use('/api/wiki', authMiddleware(), wikiRoutes);
|
|
305
310
|
app.use('/api/governance', authMiddleware(), governanceRoutes);
|
|
306
311
|
|
|
312
|
+
// Terminal embebida (KJC-TSK-0816, ADR 0008): la lógica vive en
|
|
313
|
+
// terminal.js; aquí solo el montaje. El agente arranca en el cwd del
|
|
314
|
+
// daemon (kj go arranca el board desde el proyecto) u override por env.
|
|
315
|
+
// xterm se sirve DESDE la dependencia — nada vendorizado al repo.
|
|
316
|
+
const terminalManager = createTerminalManager({
|
|
317
|
+
spawnPty: (...args) => requireCjs('node-pty').spawn(...args),
|
|
318
|
+
cwd: process.env.HU_BOARD_TERMINAL_CWD || process.cwd(),
|
|
319
|
+
promptArg: process.env.HU_BOARD_TERMINAL_PROMPT,
|
|
320
|
+
});
|
|
321
|
+
app.use(
|
|
322
|
+
'/api/terminal',
|
|
323
|
+
authMiddleware(),
|
|
324
|
+
terminalRouter(terminalManager, { defaultAgent: process.env.HU_BOARD_TERMINAL_AGENT || 'claude' }),
|
|
325
|
+
);
|
|
326
|
+
app.use(
|
|
327
|
+
'/vendor/xterm',
|
|
328
|
+
express.static(dirname(requireCjs.resolve('@xterm/xterm/package.json'))),
|
|
329
|
+
);
|
|
330
|
+
|
|
307
331
|
// SPA fallback: serve index.html for non-API, non-static routes
|
|
308
332
|
app.get('/{*splat}', (_req, res) => {
|
|
309
333
|
res.setHeader('Cache-Control', 'no-store, must-revalidate');
|
|
@@ -318,6 +342,7 @@ async function main() {
|
|
|
318
342
|
const isLoopback = LOOPBACK_ADDRESSES.has(bindHost);
|
|
319
343
|
|
|
320
344
|
const server = app.listen(port, bindHost, () => {
|
|
345
|
+
attachTerminalWs(server, terminalManager);
|
|
321
346
|
// Write the PID file so the CLI's `kj board status / stop` and
|
|
322
347
|
// the next `kj plan`'s startBoard() check find this server. The
|
|
323
348
|
// file used to be written only by `kj board start`'s launcher,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { watch } from 'chokidar';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
2
3
|
import { readFileSync, readdirSync, existsSync, rmSync } from 'node:fs';
|
|
3
|
-
import { dirname, join, basename } from 'node:path';
|
|
4
|
+
import { dirname, join, basename, isAbsolute, resolve as resolvePath } from 'node:path';
|
|
4
5
|
import { homedir } from 'node:os';
|
|
5
6
|
import {
|
|
6
7
|
getKjHome,
|
|
@@ -93,7 +94,7 @@ function deriveProjectName(data, fallbackId) {
|
|
|
93
94
|
* @param {string} projectDir
|
|
94
95
|
* @returns {string}
|
|
95
96
|
*/
|
|
96
|
-
function deriveProjectIdFromDir(projectDir) {
|
|
97
|
+
export function deriveProjectIdFromDir(projectDir) {
|
|
97
98
|
if (!projectDir || typeof projectDir !== 'string') return 'unknown';
|
|
98
99
|
// Same slug rule the plan-store uses, so identifiers match if we ever
|
|
99
100
|
// cross-reference `~/.kj/plans/<slug>/` with this project_id.
|
|
@@ -104,6 +105,42 @@ function deriveProjectIdFromDir(projectDir) {
|
|
|
104
105
|
.slice(0, 120);
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
/** git-common-dir del directorio, con el stderr silenciado (no-git = throw). */
|
|
109
|
+
const defaultGitCommonDir = (dir) =>
|
|
110
|
+
execFileSync('git', ['-C', dir, 'rev-parse', '--git-common-dir'], {
|
|
111
|
+
encoding: 'utf8',
|
|
112
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
113
|
+
}).trim();
|
|
114
|
+
|
|
115
|
+
const canonicalDirCache = new Map();
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resolve a projectDir to the REPO's identity (KJC-BUG-0160): a linked
|
|
119
|
+
* worktree (`.claude/worktrees/*`, kj lanes) used to register the same
|
|
120
|
+
* repo as a brand-new project — 13+ same-named entries in a real picker.
|
|
121
|
+
* `git rev-parse --git-common-dir` points every worktree at the main
|
|
122
|
+
* tree's `.git`; its parent is the canonical root. A non-git directory
|
|
123
|
+
* keeps its own path as identity, as always.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} projectDir
|
|
126
|
+
* @param {(dir: string) => string} [gitCommonDir] Inyectable en tests.
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
export function canonicalProjectDir(projectDir, gitCommonDir = defaultGitCommonDir) {
|
|
130
|
+
if (!projectDir || typeof projectDir !== 'string') return projectDir;
|
|
131
|
+
if (canonicalDirCache.has(projectDir)) return canonicalDirCache.get(projectDir);
|
|
132
|
+
let canonical = projectDir;
|
|
133
|
+
try {
|
|
134
|
+
const common = gitCommonDir(projectDir);
|
|
135
|
+
const absCommon = isAbsolute(common) ? common : resolvePath(projectDir, common);
|
|
136
|
+
canonical = dirname(absCommon);
|
|
137
|
+
} catch {
|
|
138
|
+
// Sin git no hay más identidad que la ruta — comportamiento de siempre.
|
|
139
|
+
}
|
|
140
|
+
canonicalDirCache.set(projectDir, canonical);
|
|
141
|
+
return canonical;
|
|
142
|
+
}
|
|
143
|
+
|
|
107
144
|
/**
|
|
108
145
|
* Turn a directory basename into a human-friendly Title Case string,
|
|
109
146
|
* preserving every meaningful word. Unlike `slugToTitle`, this one does
|
|
@@ -379,9 +416,10 @@ export function syncPlanFile(filePath) {
|
|
|
379
416
|
// under a single board entry. Legacy plans without `projectDir` still
|
|
380
417
|
// fall back to `planId` (conservative — they at least continue to show
|
|
381
418
|
// up, just each on its own card).
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
419
|
+
// KJC-BUG-0160: la identidad es el ROOT del repo — un plan creado desde
|
|
420
|
+
// un worktree/carril agrupa bajo el mismo proyecto que el árbol principal.
|
|
421
|
+
const canonicalDir = data.projectDir ? canonicalProjectDir(data.projectDir) : null;
|
|
422
|
+
const projectId = canonicalDir ? deriveProjectIdFromDir(canonicalDir) : data.planId;
|
|
385
423
|
|
|
386
424
|
if (skipIfTombstoned('plan', data.planId, filePath)) return;
|
|
387
425
|
// KJC-BUG-0055 (supersedes KJC-BUG-0050): gating por timestamp.
|
|
@@ -419,7 +457,7 @@ export function syncPlanFile(filePath) {
|
|
|
419
457
|
// misleadingly say "Hu Board" — that's why plan.name wins.
|
|
420
458
|
// 3. slugToTitle of the task / projectId as a last resort.
|
|
421
459
|
const projectName = data.name
|
|
422
|
-
|| (
|
|
460
|
+
|| (canonicalDir ? titleCaseBasename(basename(canonicalDir)) : null)
|
|
423
461
|
|| slugToTitle(data.task || '')
|
|
424
462
|
|| projectId;
|
|
425
463
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// KJC-TSK-0816 — pegamento fino de la terminal embebida: rutas REST para
|
|
2
|
+
// arrancar/parar la sesión y el upgrade WebSocket que puentea el pty.
|
|
3
|
+
// La LÓGICA (tokens, catálogo, ciclo de vida) vive en terminal.js y está
|
|
4
|
+
// testeada con dobles; esto es cableado deliberadamente delgado.
|
|
5
|
+
//
|
|
6
|
+
// El token NO viaja en la URL: el cliente lo manda como subprotocolo
|
|
7
|
+
// WebSocket (['kj-terminal', token]) — las URLs acaban en logs; los
|
|
8
|
+
// subprotocolos, no.
|
|
9
|
+
import { Router } from 'express';
|
|
10
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
11
|
+
|
|
12
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
13
|
+
const WS_PATH = '/api/terminal/ws';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Monta las rutas REST de la terminal sobre un Router de express.
|
|
17
|
+
* @param {ReturnType<import('./terminal.js').createTerminalManager>} manager
|
|
18
|
+
*/
|
|
19
|
+
export function terminalRouter(manager, { defaultAgent = 'claude' } = {}) {
|
|
20
|
+
const router = Router();
|
|
21
|
+
router.post('/start', (req, res) => {
|
|
22
|
+
try {
|
|
23
|
+
res.json(manager.start({ agent: req.body?.agent ?? defaultAgent }));
|
|
24
|
+
} catch (err) {
|
|
25
|
+
res.status(400).json({ error: err.message });
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
router.get('/alive', (_req, res) => res.json({ alive: manager.alive() }));
|
|
29
|
+
router.post('/stop', (req, res) => {
|
|
30
|
+
try {
|
|
31
|
+
manager.stop({ termId: req.body?.termId, token: req.body?.token });
|
|
32
|
+
res.json({ stopped: true });
|
|
33
|
+
} catch (err) {
|
|
34
|
+
res.status(400).json({ error: err.message });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
return router;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Registra el upgrade WS en el servidor http. Entrada del cliente: bytes
|
|
42
|
+
* crudos = teclado; un mensaje que empiece por \x00 es control JSON
|
|
43
|
+
* ({type:'resize', cols, rows}) — el prefijo evita confundir un '{'
|
|
44
|
+
* tecleado con un mensaje de control.
|
|
45
|
+
*
|
|
46
|
+
* @param {import('node:http').Server} server
|
|
47
|
+
* @param {ReturnType<import('./terminal.js').createTerminalManager>} manager
|
|
48
|
+
*/
|
|
49
|
+
export function attachTerminalWs(server, manager) {
|
|
50
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
51
|
+
server.on('upgrade', (req, socket, head) => {
|
|
52
|
+
const url = new URL(req.url, 'http://localhost');
|
|
53
|
+
if (url.pathname !== WS_PATH) return;
|
|
54
|
+
if (!LOOPBACK.has(req.socket.remoteAddress)) {
|
|
55
|
+
socket.destroy();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const termId = url.searchParams.get('termId');
|
|
59
|
+
const protocols = String(req.headers['sec-websocket-protocol'] ?? '')
|
|
60
|
+
.split(',')
|
|
61
|
+
.map((p) => p.trim());
|
|
62
|
+
const token = protocols[1] ?? '';
|
|
63
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
64
|
+
let detach;
|
|
65
|
+
try {
|
|
66
|
+
detach = manager.attach({
|
|
67
|
+
termId,
|
|
68
|
+
token,
|
|
69
|
+
send: (data) => {
|
|
70
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(data);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
} catch (err) {
|
|
74
|
+
ws.close(1008, err.message);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
ws.on('message', (raw) => {
|
|
78
|
+
const text = raw.toString('utf8');
|
|
79
|
+
try {
|
|
80
|
+
if (text.charCodeAt(0) === 0) {
|
|
81
|
+
const msg = JSON.parse(text.slice(1));
|
|
82
|
+
if (msg.type === 'resize') {
|
|
83
|
+
manager.resize({ termId, token, cols: msg.cols, rows: msg.rows });
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
manager.write({ termId, token, data: text });
|
|
88
|
+
} catch {
|
|
89
|
+
// Una trama malformada no tumba la sesión: se ignora y se sigue.
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
ws.on('close', () => detach());
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
return wss;
|
|
96
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// KJC-TSK-0816 (MGL-E, ADR 0008) — la ventana única: la terminal REAL del
|
|
2
|
+
// agente embebida en el board; el proceso es el agente interactivo de
|
|
3
|
+
// siempre, con los hooks del Sentinel intactos. Seguridad de la superficie:
|
|
4
|
+
// server solo en loopback, token aleatorio de 128 bits entregado tras el
|
|
5
|
+
// auth del board y comparado en tiempo constante, y catálogo de agentes
|
|
6
|
+
// CERRADO — por la red viaja qué agente, jamás un comando arbitrario.
|
|
7
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
/** Catálogo cerrado: los mismos agentes que kj go sabe lanzar. */
|
|
10
|
+
export const TERMINAL_AGENTS = Object.freeze({
|
|
11
|
+
claude: { command: 'claude', args: [] },
|
|
12
|
+
codex: { command: 'codex', args: [] },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const tokenMatches = (expected, given) => {
|
|
16
|
+
const a = Buffer.from(String(expected));
|
|
17
|
+
const b = Buffer.from(String(given ?? ''));
|
|
18
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Gestor de LA sesión de terminal del board (singleton por diseño: una
|
|
23
|
+
* conversación por board acota la superficie; multi-terminal queda fuera
|
|
24
|
+
* del alcance del ADR).
|
|
25
|
+
*
|
|
26
|
+
* @param {Object} deps
|
|
27
|
+
* @param {(command: string, args: string[], opts: object) => object} deps.spawnPty
|
|
28
|
+
* Factoría del pty (node-pty.spawn en producción; fake en tests).
|
|
29
|
+
* @param {string} deps.cwd Directorio del proyecto donde vive el agente.
|
|
30
|
+
* @param {Record<string, string|undefined>} [deps.env]
|
|
31
|
+
* @param {string} [deps.promptArg] Prompt inicial del agente (kj go --window
|
|
32
|
+
* lo pasa por env al daemon) — SIEMPRE del lado servidor, jamás de la red.
|
|
33
|
+
*/
|
|
34
|
+
export function createTerminalManager({ spawnPty, cwd, env = process.env, promptArg }) {
|
|
35
|
+
/** @type {{termId: string, token: string, pty: object, subscribers: Set<Function>, buffer: string[]} | null} */
|
|
36
|
+
let session = null;
|
|
37
|
+
|
|
38
|
+
const requireSession = (termId, token) => {
|
|
39
|
+
if (!session || session.termId !== termId) {
|
|
40
|
+
throw new Error('terminal: no hay sesión viva con ese id.');
|
|
41
|
+
}
|
|
42
|
+
if (!tokenMatches(session.token, token)) {
|
|
43
|
+
throw new Error('terminal: token inválido.');
|
|
44
|
+
}
|
|
45
|
+
return session;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
/** Arranca (o devuelve) LA sesión — reutilizar la viva evita que cada
|
|
50
|
+
* recarga multiplique agentes. @param {{agent: string}} params */
|
|
51
|
+
start({ agent }) {
|
|
52
|
+
if (session) return { termId: session.termId, token: session.token, reused: true };
|
|
53
|
+
// hasOwn: sin él, "constructor"/"__proto__" indexarían el prototipo.
|
|
54
|
+
const spec = Object.hasOwn(TERMINAL_AGENTS, agent) ? TERMINAL_AGENTS[agent] : null;
|
|
55
|
+
if (!spec) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`terminal: agente "${agent}" fuera del catálogo (${Object.keys(TERMINAL_AGENTS).join(', ')}).`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
// CLAUDECODE fuera: un Claude anidado se niega a arrancar con ella
|
|
61
|
+
// (la misma peculiaridad que ya maneja kj go).
|
|
62
|
+
const { CLAUDECODE: _omit, ...cleanEnv } = env;
|
|
63
|
+
const args = promptArg ? [...spec.args, promptArg] : spec.args;
|
|
64
|
+
const pty = spawnPty(spec.command, args, {
|
|
65
|
+
name: 'xterm-256color',
|
|
66
|
+
cols: 120,
|
|
67
|
+
rows: 32,
|
|
68
|
+
cwd,
|
|
69
|
+
env: cleanEnv,
|
|
70
|
+
});
|
|
71
|
+
const current = {
|
|
72
|
+
termId: `term-${randomBytes(8).toString('hex')}`,
|
|
73
|
+
token: randomBytes(16).toString('hex'),
|
|
74
|
+
pty,
|
|
75
|
+
subscribers: new Set(),
|
|
76
|
+
buffer: [],
|
|
77
|
+
};
|
|
78
|
+
pty.onData((data) => {
|
|
79
|
+
// Búfer corto para que una reconexión no arranque en negro.
|
|
80
|
+
current.buffer.push(data);
|
|
81
|
+
if (current.buffer.length > 200) current.buffer.shift();
|
|
82
|
+
for (const send of current.subscribers) send(data);
|
|
83
|
+
});
|
|
84
|
+
pty.onExit(() => {
|
|
85
|
+
for (const send of current.subscribers) send('\r\n[la sesión del agente terminó]\r\n');
|
|
86
|
+
// El exit tardío de un pty viejo no debe anular una sesión nueva.
|
|
87
|
+
if (session === current) session = null;
|
|
88
|
+
});
|
|
89
|
+
session = current;
|
|
90
|
+
return { termId: current.termId, token: current.token, reused: false };
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Conecta un receptor de salida. Devuelve el detach; soltar la
|
|
95
|
+
* conexión NO mata el pty — el agente sigue y se puede volver.
|
|
96
|
+
*/
|
|
97
|
+
attach({ termId, token, send }) {
|
|
98
|
+
const s = requireSession(termId, token);
|
|
99
|
+
for (const chunk of s.buffer) send(chunk);
|
|
100
|
+
s.subscribers.add(send);
|
|
101
|
+
return () => s.subscribers.delete(send);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
write({ termId, token, data }) {
|
|
105
|
+
requireSession(termId, token).pty.write(data);
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
resize({ termId, token, cols, rows }) {
|
|
109
|
+
requireSession(termId, token).pty.resize(cols, rows);
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
stop({ termId, token }) {
|
|
113
|
+
const s = requireSession(termId, token);
|
|
114
|
+
s.pty.kill();
|
|
115
|
+
session = null;
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
alive() {
|
|
119
|
+
return session ? { termId: session.termId } : null;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -62,7 +62,10 @@ try {
|
|
|
62
62
|
Write-Host "kj-install: Node $(& node --version) found — installing via npm (full product)..."
|
|
63
63
|
& npm install -g (Get-NpmPkg)
|
|
64
64
|
if ($LASTEXITCODE -ne 0) { throw "npm install failed (exit $LASTEXITCODE)." }
|
|
65
|
-
Write-Host "
|
|
65
|
+
Write-Host ""
|
|
66
|
+
Write-Host " Listo. Ahora escribe: kj go"
|
|
67
|
+
Write-Host ""
|
|
68
|
+
Write-Host " (avanzado: 'kj doctor' revisa la instalacion, 'kj install-tools' completa el stack)"
|
|
66
69
|
return
|
|
67
70
|
}
|
|
68
71
|
|
|
@@ -137,7 +140,10 @@ try {
|
|
|
137
140
|
Add-UserPath $installDir
|
|
138
141
|
$installed = (& (Join-Path $installDir "kj.cmd") --version) 2>$null
|
|
139
142
|
Write-Host "kj-install: installed kj $installed (full product) — kj at $(Join-Path $installDir 'kj.cmd')"
|
|
140
|
-
Write-Host "
|
|
143
|
+
Write-Host ""
|
|
144
|
+
Write-Host " Listo. Ahora escribe: kj go"
|
|
145
|
+
Write-Host ""
|
|
146
|
+
Write-Host " (avanzado: 'kj doctor' revisa la instalacion, 'kj install-tools' completa el stack)"
|
|
141
147
|
return
|
|
142
148
|
}
|
|
143
149
|
|
|
@@ -168,7 +174,10 @@ try {
|
|
|
168
174
|
Add-UserPath $installDir
|
|
169
175
|
$installed = (& $dest --version) 2>$null
|
|
170
176
|
Write-Host "kj-install: installed standalone kj $installed to $dest"
|
|
171
|
-
Write-Host "
|
|
177
|
+
Write-Host ""
|
|
178
|
+
Write-Host " Listo. Ahora escribe: kj go"
|
|
179
|
+
Write-Host ""
|
|
180
|
+
Write-Host " (avanzado: 'kj doctor' revisa la instalacion; para el producto completo, re-ejecuta sin KJ_STANDALONE)"
|
|
172
181
|
} finally {
|
|
173
182
|
Remove-Item -Path $tmp -Recurse -Force -ErrorAction SilentlyContinue
|
|
174
183
|
}
|
|
@@ -71,7 +71,11 @@ if [ "$MODE" = "full" ]; then
|
|
|
71
71
|
if node_ok; then
|
|
72
72
|
echo "kj-install: Node $(node --version) found — installing via npm (full product)..."
|
|
73
73
|
npm install -g "$(npm_pkg)" || die "npm install failed. If it was a permissions error, set a user prefix (npm config set prefix ~/.local) and re-run."
|
|
74
|
-
echo "kj-install: installed $(kj --version 2>/dev/null || echo karajan-code).
|
|
74
|
+
echo "kj-install: installed $(kj --version 2>/dev/null || echo karajan-code)."
|
|
75
|
+
echo ""
|
|
76
|
+
echo " Listo. Ahora escribe: kj go"
|
|
77
|
+
echo ""
|
|
78
|
+
echo " (avanzado: 'kj doctor' revisa la instalacion, 'kj install-tools' completa el stack)"
|
|
75
79
|
exit 0
|
|
76
80
|
fi
|
|
77
81
|
|
|
@@ -125,7 +129,10 @@ if [ "$MODE" = "full" ]; then
|
|
|
125
129
|
installed="$("${INSTALL_DIR}/kj" --version 2>/dev/null || echo '?')"
|
|
126
130
|
echo "kj-install: installed kj ${installed} (full product) — kj at ${INSTALL_DIR}/kj"
|
|
127
131
|
path_hint "$INSTALL_DIR"
|
|
128
|
-
echo "
|
|
132
|
+
echo ""
|
|
133
|
+
echo " Listo. Ahora escribe: kj go"
|
|
134
|
+
echo ""
|
|
135
|
+
echo " (avanzado: 'kj doctor' revisa la instalacion, 'kj install-tools' completa el stack)"
|
|
129
136
|
exit 0
|
|
130
137
|
fi
|
|
131
138
|
|
|
@@ -156,4 +163,7 @@ fi
|
|
|
156
163
|
installed="$("${INSTALL_DIR}/kj" --version 2>/dev/null || echo '?')"
|
|
157
164
|
echo "kj-install: installed standalone kj ${installed} to ${INSTALL_DIR}/kj"
|
|
158
165
|
path_hint "$INSTALL_DIR"
|
|
159
|
-
echo "
|
|
166
|
+
echo ""
|
|
167
|
+
echo " Listo. Ahora escribe: kj go"
|
|
168
|
+
echo ""
|
|
169
|
+
echo " (avanzado: 'kj doctor' revisa la instalacion; para el producto completo, re-ejecuta sin --standalone)"
|
package/src/cli/register-meta.js
CHANGED
|
@@ -4,6 +4,7 @@ import { researcherCommand } from "../commands/researcher.js";
|
|
|
4
4
|
import { architectCommand } from "../commands/architect.js";
|
|
5
5
|
import { onboardCommand } from "../commands/onboard.js";
|
|
6
6
|
import { startCommand } from "../commands/start.js";
|
|
7
|
+
import { goCommand } from "../commands/go.js";
|
|
7
8
|
import { identityCommand } from "../commands/identity.js";
|
|
8
9
|
import { policyCommand } from "../commands/policy.js";
|
|
9
10
|
import { claimsCommand, claimsGateCommand } from "../commands/claims.js";
|
|
@@ -148,6 +149,18 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
148
149
|
});
|
|
149
150
|
});
|
|
150
151
|
|
|
152
|
+
// MGL-A (KJC-TSK-0808): the muggle launcher — one command, at most one
|
|
153
|
+
// question, and the person lands inside a governed conversation.
|
|
154
|
+
program
|
|
155
|
+
.command("go")
|
|
156
|
+
.description("Arranca Karajan sin saber nada: detecta tu agente, prepara el proyecto, abre el tablero y te deja en la conversación")
|
|
157
|
+
.option("--window", "La ventana única (ADR 0008): la conversación vive DENTRO del tablero del navegador")
|
|
158
|
+
.action(async (flags) => {
|
|
159
|
+
await withConfig(pkgVersion, "go", flags, async ({ config, logger }) => {
|
|
160
|
+
await goCommand({ config, logger, flags });
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
151
164
|
program
|
|
152
165
|
.command("start")
|
|
153
166
|
.description("Single entry point: assess the project and recommend the next step")
|
package/src/commands/board.js
CHANGED
|
@@ -87,8 +87,23 @@ async function findAvailablePort(desiredPort, maxTries = 10) {
|
|
|
87
87
|
* @returns {string}
|
|
88
88
|
*/
|
|
89
89
|
export function buildBoardUrl(port, projectSlug) {
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
return projectSlug ? boardUrl(port, `/#board/${projectSlug}`) : boardUrl(port);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Base board URL plus an optional absolute path (query and/or hash) —
|
|
95
|
+
* `kj go` opens `/?maggle=1` so the frontend renders in plain language
|
|
96
|
+
* (KJC-TSK-0810). A relative path is a caller bug, not something to fix
|
|
97
|
+
* up silently.
|
|
98
|
+
* @param {number} port
|
|
99
|
+
* @param {string} [path]
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
export function boardUrl(port, path = "") {
|
|
103
|
+
if (path && !path.startsWith("/")) {
|
|
104
|
+
throw new Error(`board path must start with "/": ${path}`);
|
|
105
|
+
}
|
|
106
|
+
return `http://localhost:${port}${path}`;
|
|
92
107
|
}
|
|
93
108
|
|
|
94
109
|
/**
|
|
@@ -368,7 +383,7 @@ export function renderBoardBanner({ url, status, projectName }) {
|
|
|
368
383
|
return ["", rule, ...content, rule, ""].join("\n");
|
|
369
384
|
}
|
|
370
385
|
|
|
371
|
-
export async function boardCommand({ action = "start", port = 4000, bind = "127.0.0.1", logger }) {
|
|
386
|
+
export async function boardCommand({ action = "start", port = 4000, bind = "127.0.0.1", logger, path }) {
|
|
372
387
|
switch (action) {
|
|
373
388
|
case "start": {
|
|
374
389
|
let result;
|
|
@@ -422,7 +437,7 @@ export async function boardCommand({ action = "start", port = 4000, bind = "127.
|
|
|
422
437
|
logger.info("HU Board is not running. Starting it first...");
|
|
423
438
|
await startBoard(port);
|
|
424
439
|
}
|
|
425
|
-
const url =
|
|
440
|
+
const url = boardUrl(port, path);
|
|
426
441
|
// eslint-disable-next-line import-x/no-unresolved -- optional peer; the .catch handles its absence
|
|
427
442
|
const { default: open } = await import("open").catch(() => ({ default: null }));
|
|
428
443
|
if (open) {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kj go` (MGL-A, KJC-TSK-0808, epic KJC-PCS-0084) — the muggle launcher.
|
|
3
|
+
* One command for a person with no computing background: detect their agents,
|
|
4
|
+
* ask AT MOST one question, prepare the project silently, open the board, and
|
|
5
|
+
* leave them inside a conversation that already knows the method. What cannot
|
|
6
|
+
* be hidden is said honestly: the agent's account and login are THEIRS — kj
|
|
7
|
+
* never touches credentials. Everything is injectable so tests spawn nothing.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { checkBinary } from "../utils/agent-detect.js";
|
|
14
|
+
import { createCliAskQuestion } from "../utils/cli-ask-question.js";
|
|
15
|
+
import { envInstallCommand } from "./env.js";
|
|
16
|
+
import { boardCommand } from "./board.js";
|
|
17
|
+
// The interactive launchers a muggle can live inside (v1: the two the epic
|
|
18
|
+
// names). Auth heuristics are the same cheap file checks reviewer-fallback
|
|
19
|
+
// uses: menu signal, never a spawned process.
|
|
20
|
+
export const MAGGLE_AGENTS = [
|
|
21
|
+
{ name: "claude", label: "Claude Code (Anthropic)", authPaths: [".claude.json", ".claude"], install: "npm install -g @anthropic-ai/claude-code", login: "claude (sigue el enlace de inicio de sesión que te muestre)" },
|
|
22
|
+
{ name: "codex", label: "Codex (OpenAI)", authPaths: [".codex/auth.json"], install: "npm install -g @openai/codex", login: "codex login" },
|
|
23
|
+
];
|
|
24
|
+
export async function detectMaggleAgents({ home = os.homedir(), checkBin = checkBinary } = {}) {
|
|
25
|
+
return Promise.all(
|
|
26
|
+
MAGGLE_AGENTS.map(async (a) => {
|
|
27
|
+
let installed = false;
|
|
28
|
+
try { installed = (await checkBin(a.name)).ok; } catch { /* not installed */ }
|
|
29
|
+
const authenticated = installed && a.authPaths.some((p) => existsSync(path.join(home, p)));
|
|
30
|
+
return { ...a, installed, authenticated };
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
/** The session's opening prompt — SHORT and in plain language. The full
|
|
35
|
+
* playbook already lives in the agent files env-install wrote; duplicating it
|
|
36
|
+
* here would dilute it. This prompt sets the tone for a muggle. */
|
|
37
|
+
export function buildGoPrompt() {
|
|
38
|
+
return [
|
|
39
|
+
"Arrancas dentro de un proyecto gobernado por Karajan (las reglas del método ya están en los ficheros de agente de este proyecto: síguelas siempre).",
|
|
40
|
+
"La persona que te habla puede no saber programar. Habla en lenguaje llano: sin jerga, sin siglas sin explicar, y resume cada resultado en una o dos frases antes del detalle.",
|
|
41
|
+
"Antes de cambios importantes, di en llano qué vas a hacer y qué pasará. Si algo falla, explica qué pasó y cuál es el siguiente paso — nunca un volcado de error a secas.",
|
|
42
|
+
"El tablero del proyecto está abierto en su navegador: cuando termines algo, recuérdale que puede verlo ahí.",
|
|
43
|
+
"Empieza presentándote en dos frases y preguntando qué quiere construir o cambiar hoy.",
|
|
44
|
+
].join("\n");
|
|
45
|
+
}
|
|
46
|
+
async function defaultPrepare({ config, logger }) {
|
|
47
|
+
await envInstallCommand({ config, logger, flags: { yes: true } });
|
|
48
|
+
}
|
|
49
|
+
export async function defaultBoard({ config, logger, runBoard = boardCommand, openPath = "/?maggle=1" }) {
|
|
50
|
+
const port = config.hu_board?.port || 4000;
|
|
51
|
+
await runBoard({ action: "start", port, bind: "127.0.0.1", logger });
|
|
52
|
+
// /?maggle=1 switches the frontend to plain language (KJC-TSK-0810) —
|
|
53
|
+
// the muggle's window opens already speaking their language. With
|
|
54
|
+
// &window=1 (KJC-TSK-0816) the conversation itself lives IN the board.
|
|
55
|
+
await runBoard({ action: "open", port, bind: "127.0.0.1", path: openPath, logger });
|
|
56
|
+
}
|
|
57
|
+
function defaultLaunch(agent, prompt) {
|
|
58
|
+
// Interactive session: the muggle LIVES here. CLAUDECODE is stripped so a
|
|
59
|
+
// nested Claude does not refuse to start (the known subprocess quirk).
|
|
60
|
+
const { CLAUDECODE: _omit, ...env } = process.env;
|
|
61
|
+
return new Promise((resolvePromise) => {
|
|
62
|
+
const child = spawn(agent.name, [prompt], { stdio: "inherit", env });
|
|
63
|
+
child.on("exit", (code) => resolvePromise(code ?? 0));
|
|
64
|
+
child.on("error", (err) => {
|
|
65
|
+
console.error(`No se pudo arrancar ${agent.label}: ${err.message}`);
|
|
66
|
+
resolvePromise(1);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
export async function goCommand({ config = {}, logger = console, flags = {}, deps = {} } = {}) {
|
|
71
|
+
const projectDir = config.projectDir || process.cwd();
|
|
72
|
+
const agents = await (deps.detect ?? detectMaggleAgents)();
|
|
73
|
+
const ready = agents.filter((a) => a.installed && a.authenticated);
|
|
74
|
+
const needLogin = agents.filter((a) => a.installed && !a.authenticated);
|
|
75
|
+
if (ready.length === 0) {
|
|
76
|
+
if (needLogin.length > 0) {
|
|
77
|
+
logger.error?.("Tienes el agente instalado pero falta iniciar sesión — eso solo puedes hacerlo tú (la cuenta es tuya; Karajan nunca toca tus credenciales):");
|
|
78
|
+
for (const a of needLogin) logger.error?.(` ${a.label}: ${a.login}`);
|
|
79
|
+
logger.error?.("Cuando hayas iniciado sesión, vuelve a escribir: kj go");
|
|
80
|
+
} else {
|
|
81
|
+
logger.error?.("Karajan trabaja con un agente de IA que aún no tienes instalado. Elige uno, instálalo con su comando, e inicia sesión con TU cuenta (la cuenta es tuya):");
|
|
82
|
+
for (const a of agents) logger.error?.(` ${a.label}: ${a.install}`);
|
|
83
|
+
logger.error?.("Después, vuelve a escribir: kj go");
|
|
84
|
+
}
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
let chosen = ready[0];
|
|
89
|
+
if (ready.length > 1) {
|
|
90
|
+
const ask = deps.ask ?? createCliAskQuestion({ flags });
|
|
91
|
+
const answer = await ask("¿Con cuál de tus agentes quieres trabajar?", { options: ready.map((a) => a.name), default: ready[0].name });
|
|
92
|
+
chosen = ready.find((a) => a.name === String(answer).trim()) ?? ready[0];
|
|
93
|
+
}
|
|
94
|
+
logger.info?.(`Trabajarás con ${chosen.label}.`);
|
|
95
|
+
// Prepare ONCE: decisions already taken are never re-asked.
|
|
96
|
+
if (!existsSync(path.join(projectDir, ".karajan", "review-gate"))) {
|
|
97
|
+
logger.info?.("Preparando tu proyecto (solo la primera vez)…");
|
|
98
|
+
await (deps.prepare ?? defaultPrepare)({ config, logger });
|
|
99
|
+
}
|
|
100
|
+
const prompt = (deps.prompt ?? buildGoPrompt)();
|
|
101
|
+
// --window (MGL-E, ADR 0008): la conversación vive DENTRO del board — el
|
|
102
|
+
// daemon hereda por env (HU_BOARD_TERMINAL_CWD/AGENT/PROMPT) el cwd, el
|
|
103
|
+
// agente elegido y el prompt inicial, y el pty arranca el agente REAL
|
|
104
|
+
// (harness intacto). La fase 1 sigue siendo el default: sin el flag,
|
|
105
|
+
// nada cambia.
|
|
106
|
+
const windowMode = Boolean(flags.window);
|
|
107
|
+
if (windowMode) {
|
|
108
|
+
process.env.HU_BOARD_TERMINAL_CWD = projectDir;
|
|
109
|
+
process.env.HU_BOARD_TERMINAL_AGENT = chosen.name;
|
|
110
|
+
process.env.HU_BOARD_TERMINAL_PROMPT = prompt;
|
|
111
|
+
}
|
|
112
|
+
// The board is the muggle's window — unless the project turned it off
|
|
113
|
+
// (hu_board.enabled false is respected: KJC-BUG-0152). A board failure is
|
|
114
|
+
// said and never stops the conversation from starting.
|
|
115
|
+
let boardOpened = false;
|
|
116
|
+
if (config.hu_board?.enabled !== false) {
|
|
117
|
+
const openPath = windowMode ? "/?maggle=1&window=1" : "/?maggle=1";
|
|
118
|
+
try {
|
|
119
|
+
await (deps.board ?? defaultBoard)({ config, logger, openPath });
|
|
120
|
+
boardOpened = true;
|
|
121
|
+
} catch (err) {
|
|
122
|
+
logger.warn?.(`El tablero no pudo abrirse (${err.message}) — la conversación arranca igual.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (windowMode && boardOpened) {
|
|
126
|
+
logger.info?.("Tu conversación vive en la ventana del navegador que se acaba de abrir. Si el tablero ya estaba arrancado de antes, reinícialo con kj board stop && kj go --window para que recoja este proyecto.");
|
|
127
|
+
process.exitCode = 0;
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
if (windowMode) {
|
|
131
|
+
// Sin board no hay ventana: se dice y la conversación arranca en la
|
|
132
|
+
// terminal — el maggle nunca se queda sin sesión (catch de codex).
|
|
133
|
+
logger.warn?.("Sin tablero no hay ventana única: abro la conversación aquí mismo.");
|
|
134
|
+
}
|
|
135
|
+
logger.info?.("Abriendo tu conversación… (escribe ahí lo que necesites, en tu idioma)");
|
|
136
|
+
const code = await (deps.launch ?? defaultLaunch)(chosen, prompt);
|
|
137
|
+
process.exitCode = code;
|
|
138
|
+
return code;
|
|
139
|
+
}
|