karajan-code 4.24.0 → 4.26.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 +1 -1
- package/package.json +1 -1
- package/packages/hu-board/package.json +5 -2
- package/packages/hu-board/public/app.js +37 -43
- package/packages/hu-board/public/index.html +19 -11
- package/packages/hu-board/public/styles.css +29 -14
- package/packages/hu-board/public/utils/graph-view.js +1 -1
- package/packages/hu-board/public/utils/init-listeners.js +1 -14
- package/packages/hu-board/public/utils/maggle-mode.js +13 -4
- package/packages/hu-board/public/utils/project-actions.js +4 -5
- package/packages/hu-board/public/utils/project-picker-view.js +1 -1
- package/packages/hu-board/public/utils/terminal-panel.js +132 -0
- package/packages/hu-board/src/db.js +13 -0
- package/packages/hu-board/src/server.js +25 -0
- package/packages/hu-board/src/sync.js +50 -7
- package/packages/hu-board/src/terminal-wire.js +96 -0
- package/packages/hu-board/src/terminal.js +122 -0
- package/src/cli/register-meta.js +2 -0
- package/src/commands/go.js +34 -5
- package/src/commands/policy.js +17 -7
- package/src/commands/review-gate.js +8 -5
- package/src/harden/sentinel-hooks.js +32 -31
- package/src/harden/workflow-templates.js +3 -1
- package/src/policy/exceptions.js +49 -13
|
@@ -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.
|
|
@@ -418,8 +456,13 @@ export function syncPlanFile(filePath) {
|
|
|
418
456
|
// with a SPEC about "Linux Assistant", the basename here would
|
|
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
|
-
|
|
422
|
-
|
|
459
|
+
// KJC-BUG-0162: the default backlog plan is named "brain-backlog" — a
|
|
460
|
+
// DEFAULT, not a user choice. Every project synced through it ended up
|
|
461
|
+
// named "brain-backlog", indistinguishable in the picker. Only a real,
|
|
462
|
+
// user-given plan name may override the repo-derived name.
|
|
463
|
+
const userGivenName = data.name && data.name !== 'brain-backlog' ? data.name : null;
|
|
464
|
+
const projectName = userGivenName
|
|
465
|
+
|| (canonicalDir ? titleCaseBasename(basename(canonicalDir)) : null)
|
|
423
466
|
|| slugToTitle(data.task || '')
|
|
424
467
|
|| projectId;
|
|
425
468
|
|
|
@@ -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
|
+
}
|
package/src/cli/register-meta.js
CHANGED
|
@@ -154,6 +154,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
154
154
|
program
|
|
155
155
|
.command("go")
|
|
156
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")
|
|
157
158
|
.action(async (flags) => {
|
|
158
159
|
await withConfig(pkgVersion, "go", flags, async ({ config, logger }) => {
|
|
159
160
|
await goCommand({ config, logger, flags });
|
|
@@ -371,6 +372,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
371
372
|
.option("--rule <rule_id>", "Regla exacta (p.ej. roles.coder.write.deny)")
|
|
372
373
|
.option("--until <iso>", "Caducidad ISO-8601 (p.ej. 2026-09-01T00:00:00Z)")
|
|
373
374
|
.option("--reason <text>", "Justificación escrita en el momento")
|
|
375
|
+
.option("--global", "Ámbito global (~/.karajan): vale para todos tus proyectos de esta máquina — KJC-TSK-0813")
|
|
374
376
|
.action(async (flags) => {
|
|
375
377
|
await withConfig(pkgVersion, "policy-grant", flags, async ({ config }) => {
|
|
376
378
|
process.exitCode = await policyCommand({ action: "grant", config, flags });
|
package/src/commands/go.js
CHANGED
|
@@ -46,12 +46,13 @@ export function buildGoPrompt() {
|
|
|
46
46
|
async function defaultPrepare({ config, logger }) {
|
|
47
47
|
await envInstallCommand({ config, logger, flags: { yes: true } });
|
|
48
48
|
}
|
|
49
|
-
export async function defaultBoard({ config, logger, runBoard = boardCommand }) {
|
|
49
|
+
export async function defaultBoard({ config, logger, runBoard = boardCommand, openPath = "/?maggle=1" }) {
|
|
50
50
|
const port = config.hu_board?.port || 4000;
|
|
51
51
|
await runBoard({ action: "start", port, bind: "127.0.0.1", logger });
|
|
52
52
|
// /?maggle=1 switches the frontend to plain language (KJC-TSK-0810) —
|
|
53
|
-
// the muggle's window opens already speaking their language.
|
|
54
|
-
|
|
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 });
|
|
55
56
|
}
|
|
56
57
|
function defaultLaunch(agent, prompt) {
|
|
57
58
|
// Interactive session: the muggle LIVES here. CLAUDECODE is stripped so a
|
|
@@ -96,13 +97,41 @@ export async function goCommand({ config = {}, logger = console, flags = {}, dep
|
|
|
96
97
|
logger.info?.("Preparando tu proyecto (solo la primera vez)…");
|
|
97
98
|
await (deps.prepare ?? defaultPrepare)({ config, logger });
|
|
98
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
|
+
}
|
|
99
112
|
// The board is the muggle's window — unless the project turned it off
|
|
100
113
|
// (hu_board.enabled false is respected: KJC-BUG-0152). A board failure is
|
|
101
114
|
// said and never stops the conversation from starting.
|
|
115
|
+
let boardOpened = false;
|
|
102
116
|
if (config.hu_board?.enabled !== false) {
|
|
103
|
-
|
|
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.");
|
|
104
134
|
}
|
|
105
|
-
const prompt = (deps.prompt ?? buildGoPrompt)();
|
|
106
135
|
logger.info?.("Abriendo tu conversación… (escribe ahí lo que necesites, en tu idioma)");
|
|
107
136
|
const code = await (deps.launch ?? defaultLaunch)(chosen, prompt);
|
|
108
137
|
process.exitCode = code;
|
package/src/commands/policy.js
CHANGED
|
@@ -12,7 +12,7 @@ import { createHash } from "node:crypto";
|
|
|
12
12
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { checkStagedDiff, evalToolCall, loadPolicy } from "../policy/engine.js";
|
|
15
|
-
import { loadExceptionRecords, loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
|
|
15
|
+
import { loadExceptionRecords, loadGlobalExceptionRecords, loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
|
|
16
16
|
import { buildPolicyReport } from "../policy/report.js";
|
|
17
17
|
import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
|
|
18
18
|
import { readIdentity } from "../identity/store.js";
|
|
@@ -110,12 +110,16 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
|
|
|
110
110
|
// regla re-concedida N veces es la política real pidiendo que la
|
|
111
111
|
// cambien por su cauce. Se cuenta contra TODAS las permanentes previas
|
|
112
112
|
// (vencidas incluidas: precisamente esas son la sedimentación).
|
|
113
|
-
|
|
113
|
+
// KJC-TSK-0813: --global escribe en ~/.karajan (todos los proyectos de
|
|
114
|
+
// la máquina). El contador de renovaciones cuenta sobre la FUSIÓN.
|
|
115
|
+
const scope = flags.global ? "global" : "project";
|
|
116
|
+
const previous = loadStandingExceptions(projectDir, { home: deps.home }).standing.filter((e) => e.rule_id === rule).length;
|
|
114
117
|
const rec = recordPolicyException({
|
|
115
|
-
projectDir,
|
|
118
|
+
projectDir, scope, deps: { home: deps.home },
|
|
116
119
|
entry: { rule_id: rule, justification: reason.trim(), scopeKind: "permanente", expiresAt: until },
|
|
117
120
|
});
|
|
118
|
-
|
|
121
|
+
const ambit = scope === "global" ? " [ámbito global — todos tus proyectos de esta máquina]" : "";
|
|
122
|
+
logger.info?.(`✓ excepción permanente registrada${ambit}: [${rec.rule_id}] hasta ${rec.expiresAt} — concedida por ${rec.who?.git ?? "?"} (${rec.who?.grade ?? "?"})`);
|
|
119
123
|
if (previous >= 1) {
|
|
120
124
|
logger.warn?.(`⚠ ${previous + 1}ª concesión sobre esta regla — una excepción que se renueva ya no es una excepción: considera cambiar la política por su cauce (PR a .karajan/policy.yml)`);
|
|
121
125
|
}
|
|
@@ -134,7 +138,11 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
|
|
|
134
138
|
try {
|
|
135
139
|
decisionLines = readFileSync(join(projectDir, ".karajan", "policy-decisions.jsonl"), "utf8").split("\n").filter((l) => l.trim());
|
|
136
140
|
} catch { /* sin decisiones aún: el informe lo dice con ceros */ }
|
|
137
|
-
|
|
141
|
+
// KJC-TSK-0813 (AC3): el informe cubre TAMBIÉN el almacén global — cada
|
|
142
|
+
// registro llega con su origin FÍSICO estampado por la carga.
|
|
143
|
+
const proj = loadExceptionRecords(projectDir);
|
|
144
|
+
const glob = loadGlobalExceptionRecords({ home: deps.home });
|
|
145
|
+
const exc = { records: [...proj.records, ...glob.records], discarded: proj.discarded + glob.discarded };
|
|
138
146
|
const soonDays = Number(flags.soon ?? 7);
|
|
139
147
|
const report = buildPolicyReport({ decisionLines, exceptionRecords: exc.records, policy, soonDays: Number.isFinite(soonDays) ? soonDays : 7 });
|
|
140
148
|
if (flags.json) {
|
|
@@ -152,8 +160,10 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
|
|
|
152
160
|
const meta = [r.enforcement && `enforcement=${r.enforcement}`, r.class && `class=${r.class}`].filter(Boolean).join(" ");
|
|
153
161
|
logger.info?.(` [${r.rule_id}] ${meta} — warn ${r.warns} · deny ${r.denies} · exempt ${r.exempts} · abiertas ${r.open}`);
|
|
154
162
|
}
|
|
155
|
-
|
|
156
|
-
|
|
163
|
+
// Con 0 globales el resumen queda EXACTAMENTE como siempre (hay consumidores de texto).
|
|
164
|
+
const globals = g.alive.filter((e) => e.origin === "global").length;
|
|
165
|
+
logger.info?.(`concesiones: vivas ${g.alive.length} (${globals > 0 ? `${globals} globales, ` : ""}próximas a vencer ${g.soon.length}) · vencidas ${g.expired.length} · puntuales ${g.point}`);
|
|
166
|
+
for (const e of g.alive) logger.info?.(` [${e.rule_id}] hasta ${e.expiresAt}${e.origin === "global" ? " [global]" : ""} — ${e.who?.git ?? "?"}: ${e.justification ?? "sin justificación"}`);
|
|
157
167
|
logger.info?.(report.signals.length > 0 ? "señales:" : "señales: ninguna");
|
|
158
168
|
for (const s of report.signals) logger.warn?.(` ⚠ ${s}`);
|
|
159
169
|
return chain.ok ? 0 : 1;
|
|
@@ -52,6 +52,13 @@ async function rawDiff(range, extraArgs = []) {
|
|
|
52
52
|
return res.stdout;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// KJC-TSK-0813 (AC3): la exención dice su PROCEDENCIA — un standing global
|
|
56
|
+
// (concedido para toda la máquina) no pasa por uno del proyecto. El texto
|
|
57
|
+
// de las de proyecto queda EXACTAMENTE como estaba.
|
|
58
|
+
export const formatExempted = (e) => (e.standing
|
|
59
|
+
? `⚠ policy standing [${e.rule_id}] — excepción permanente${e.standing.origin === "global" ? " GLOBAL" : ""} viva hasta ${e.standing.expiresAt} (${e.standing.justification || "sin justificación"})`
|
|
60
|
+
: `⚠ policy exempt [${e.rule_id}] — excepción registrada en .karajan/policy-exceptions.jsonl (${e.justification})`);
|
|
61
|
+
|
|
55
62
|
function printVerdict(record) {
|
|
56
63
|
if (record.verdict === "approved") {
|
|
57
64
|
const ws = record.workspace ? ` [${record.workspace}]` : "";
|
|
@@ -257,11 +264,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
257
264
|
});
|
|
258
265
|
const at = (v) => (v.file ? ` (${v.file})` : "");
|
|
259
266
|
for (const w of gate.warns) console.log(`⚠ policy [${w.rule_id}] ${w.reason}${at(w)}`);
|
|
260
|
-
for (const e of gate.exempted)
|
|
261
|
-
console.log(e.standing
|
|
262
|
-
? `⚠ policy standing [${e.rule_id}] — excepción permanente viva hasta ${e.standing.expiresAt} (${e.standing.justification || "sin justificación"})`
|
|
263
|
-
: `⚠ policy exempt [${e.rule_id}] — excepción registrada en .karajan/policy-exceptions.jsonl (${e.justification})`);
|
|
264
|
-
}
|
|
267
|
+
for (const e of gate.exempted) console.log(formatExempted(e));
|
|
265
268
|
// GOV-C (KJC-TSK-0747): las decisiones de chokepoint dejan rastro
|
|
266
269
|
// hash-encadenado — cada deny, cada excepción, y el allow del commit.
|
|
267
270
|
const chokepoint = flags.check ? "commit" : "review";
|