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
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ Already installed and just want to work — even with no computing background? O
|
|
|
65
65
|
kj go
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
It detects your agent (Claude Code or Codex; asks which one only if you have both), prepares the project silently the first time, opens the board in your browser, and drops you into a conversation that already follows the method. Your agent account and login stay yours — kj never touches credentials.
|
|
68
|
+
It detects your agent (Claude Code or Codex; asks which one only if you have both), prepares the project silently the first time, opens the board in your browser, and drops you into a conversation that already follows the method. Your agent account and login stay yours — kj never touches credentials. Prefer everything in ONE browser window? `kj go --window` embeds the agent's real terminal inside the board (loopback-only, single-session token).
|
|
69
69
|
|
|
70
70
|
Requires git and at least one AI agent CLI — two enables cross-AI review; three enables arbitration. The npm route is `npm install -g @karajan-family/code` (published as `karajan-code` before joining the scope; the legacy name still installs the same versions). All install routes (npm, binaries, brew, Python wrapper) in the [install docs](https://karajancode.com/docs/v4/install/).
|
|
71
71
|
|
package/package.json
CHANGED
|
@@ -13,13 +13,16 @@
|
|
|
13
13
|
"test": "vitest run"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"
|
|
16
|
+
"@xterm/xterm": "^6.0.0",
|
|
17
17
|
"better-sqlite3": "^12.10.0",
|
|
18
18
|
"chokidar": "^5.0.0",
|
|
19
19
|
"express": "^5.1.0",
|
|
20
20
|
"express-rate-limit": "^8.5.0",
|
|
21
21
|
"helmet": "^8.1.0",
|
|
22
|
-
"js-yaml": "^4.2.0"
|
|
22
|
+
"js-yaml": "^4.2.0",
|
|
23
|
+
"karajan-core": "*",
|
|
24
|
+
"node-pty": "^1.1.0",
|
|
25
|
+
"ws": "^8.21.3"
|
|
23
26
|
},
|
|
24
27
|
"devDependencies": {
|
|
25
28
|
"supertest": "^7.1.0",
|
|
@@ -163,6 +163,9 @@ let lastOpenedLog = null; // { id, label, tailUrl(offset) }
|
|
|
163
163
|
*/
|
|
164
164
|
function navigate(view) {
|
|
165
165
|
currentView = view;
|
|
166
|
+
// KJC-TSK-0820: going back to the Dashboard unloads the project — it is
|
|
167
|
+
// the only place where a (different) project gets picked.
|
|
168
|
+
if (view === 'dashboard' && !scopedProjectSlug) selectedProject = '';
|
|
166
169
|
window.location.hash = selectedProject ? `${view}/${selectedProject}` : view;
|
|
167
170
|
|
|
168
171
|
// Update active nav button
|
|
@@ -179,14 +182,43 @@ function navigate(view) {
|
|
|
179
182
|
*/
|
|
180
183
|
function selectProject(projectId) {
|
|
181
184
|
selectedProject = projectId;
|
|
182
|
-
document.getElementById('project-select').value = projectId;
|
|
183
185
|
navigate('board');
|
|
184
186
|
}
|
|
185
187
|
|
|
188
|
+
/**
|
|
189
|
+
* KJC-TSK-0820 — two-level nav. Which nav bars are visible: the generic
|
|
190
|
+
* one always; the project sub-bar only while a project is loaded, and
|
|
191
|
+
* never on the Dashboard (that's where projects get picked). Pure.
|
|
192
|
+
* @param {string} view
|
|
193
|
+
* @param {string} projectId
|
|
194
|
+
* @returns {{ generic: boolean, project: boolean }}
|
|
195
|
+
*/
|
|
196
|
+
function projectNavVisibility(view, projectId) {
|
|
197
|
+
return { generic: true, project: Boolean(projectId) && view !== 'dashboard' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Applies projectNavVisibility() to the DOM and stamps the loaded
|
|
202
|
+
* project's name on the sub-bar.
|
|
203
|
+
*/
|
|
204
|
+
function updateProjectNav() {
|
|
205
|
+
const bar = document.getElementById('project-nav');
|
|
206
|
+
if (!bar) return;
|
|
207
|
+
const visible = projectNavVisibility(currentView, selectedProject).project;
|
|
208
|
+
bar.hidden = !visible;
|
|
209
|
+
if (!visible) return;
|
|
210
|
+
const nameEl = document.getElementById('project-nav-name');
|
|
211
|
+
nameEl.textContent = projectNameCache[selectedProject] || humaniseProjectName(selectedProject);
|
|
212
|
+
resolveProjectMeta(selectedProject).then((meta) => {
|
|
213
|
+
if (meta.name) nameEl.textContent = meta.name;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
186
217
|
/**
|
|
187
218
|
* Renders the current view.
|
|
188
219
|
*/
|
|
189
220
|
function render() {
|
|
221
|
+
updateProjectNav();
|
|
190
222
|
switch (currentView) {
|
|
191
223
|
case 'dashboard': return renderDashboard();
|
|
192
224
|
case 'board': return renderBoard();
|
|
@@ -197,46 +229,6 @@ function render() {
|
|
|
197
229
|
}
|
|
198
230
|
}
|
|
199
231
|
|
|
200
|
-
/**
|
|
201
|
-
* Populates the project selector dropdown.
|
|
202
|
-
*/
|
|
203
|
-
async function populateProjectSelect() {
|
|
204
|
-
try {
|
|
205
|
-
const projects = await api('/api/projects');
|
|
206
|
-
const select = document.getElementById('project-select');
|
|
207
|
-
// Which project should appear pre-selected? Order:
|
|
208
|
-
// 1. scopedProjectSlug (URL is /p/<slug>) — locked, never changes
|
|
209
|
-
// 2. selectedProject (route state, ej. #board/<slug>)
|
|
210
|
-
// 3. "" — "All Projects"
|
|
211
|
-
// PR-D: the previous version cleared innerHTML and never marked
|
|
212
|
-
// any option as selected, so the dropdown showed "All Projects"
|
|
213
|
-
// even when scoped to one. handleRoute() set .value separately
|
|
214
|
-
// BUT the value was lost in the race because the matching option
|
|
215
|
-
// didn't exist yet when handleRoute ran. Setting .selected on
|
|
216
|
-
// the option directly avoids the race.
|
|
217
|
-
const desired = scopedProjectSlug || selectedProject || '';
|
|
218
|
-
select.innerHTML = '';
|
|
219
|
-
const all = document.createElement('option');
|
|
220
|
-
all.value = '';
|
|
221
|
-
all.textContent = 'All Projects';
|
|
222
|
-
if (desired === '') all.selected = true;
|
|
223
|
-
select.appendChild(all);
|
|
224
|
-
for (const p of projects) {
|
|
225
|
-
const opt = document.createElement('option');
|
|
226
|
-
opt.value = p.id;
|
|
227
|
-
opt.textContent = p.name || p.id;
|
|
228
|
-
if (p.id === desired) opt.selected = true;
|
|
229
|
-
select.appendChild(opt);
|
|
230
|
-
}
|
|
231
|
-
// Final guard: if `desired` did not match any rendered option
|
|
232
|
-
// (project not on the board yet), still set the value so
|
|
233
|
-
// handleRoute's expectation holds.
|
|
234
|
-
if (desired) select.value = desired;
|
|
235
|
-
} catch {
|
|
236
|
-
// Silently fail — project list will be empty
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
232
|
/**
|
|
241
233
|
* Parses the hash route and renders.
|
|
242
234
|
*/
|
|
@@ -248,13 +240,15 @@ function handleRoute() {
|
|
|
248
240
|
// In scoped mode the project is locked — the hash controls the view
|
|
249
241
|
// only. `board/<slug>` becomes `board` and the slug stays fixed.
|
|
250
242
|
currentView = parts[0] || 'board';
|
|
251
|
-
|
|
243
|
+
// Dashboard never carries a loaded project (KJC-TSK-0820): it is the
|
|
244
|
+
// project picker, so a `#dashboard/<slug>` deep-link drops the slug.
|
|
245
|
+
selectedProject = scopedProjectSlug
|
|
246
|
+
|| (currentView === 'dashboard' ? '' : parts[1] || '');
|
|
252
247
|
|
|
253
248
|
document.querySelectorAll('.nav-btn').forEach((btn) => {
|
|
254
249
|
btn.classList.toggle('active', btn.dataset.view === currentView);
|
|
255
250
|
});
|
|
256
251
|
|
|
257
|
-
document.getElementById('project-select').value = selectedProject;
|
|
258
252
|
render();
|
|
259
253
|
}
|
|
260
254
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>Karajan HU Board</title>
|
|
7
7
|
<link rel="stylesheet" href="/styles.css">
|
|
8
|
+
<link rel="stylesheet" href="/vendor/xterm/css/xterm.css">
|
|
8
9
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%237c3aed'/><text x='16' y='22' text-anchor='middle' fill='white' font-size='16' font-family='sans-serif' font-weight='bold'>K</text></svg>">
|
|
9
10
|
</head>
|
|
10
11
|
<body>
|
|
@@ -20,21 +21,12 @@
|
|
|
20
21
|
</div>
|
|
21
22
|
</div>
|
|
22
23
|
<nav class="header__nav">
|
|
23
|
-
<button class="nav-btn active" data-view="
|
|
24
|
-
<button class="nav-btn" data-view="graph" title="Dependency graph of the selected project's HUs — who blocks whom. Useful before scheduling work.">Graph</button>
|
|
25
|
-
<button class="nav-btn" data-view="dashboard" title="Per-project landing: stats grid + project list. Click a project card to focus the kanban / graph on that project.">Dashboard</button>
|
|
26
|
-
<button class="nav-btn" data-view="sessions" title="Every kj run that has touched the board, newest first. Filter by project; click a session to inspect its iterations, commits and checkpoints.">Sessions</button>
|
|
27
|
-
<button class="nav-btn" data-view="governance" title="The acta: policy rules by friction, the hash-chained decision log and its anchor, standing exceptions with owner and expiry, renewals and signals.">Governance</button>
|
|
28
|
-
<a class="nav-btn" href="/pipeline.html" title="Live observability of pipeline runs (Karajan v2.7+): stage timings, agent calls, decision logs.">Pipeline</a>
|
|
29
|
-
<a class="nav-btn" href="/rag.html" title="RAG retrieval-quality dashboard: chunk counts, embedder provider, db size.">RAG</a>
|
|
30
|
-
<a class="nav-btn" href="/wiki.html" title="Search the per-project QMD semantic wiki (docs, plans, reviews).">Wiki</a>
|
|
31
|
-
<select class="project-select" id="project-select">
|
|
32
|
-
<option value="">All Projects</option>
|
|
33
|
-
</select>
|
|
24
|
+
<button class="nav-btn active" data-view="dashboard" title="Per-project landing: stats grid + project list. Click a project card to load that project — its views appear in the project bar below.">Dashboard</button>
|
|
34
25
|
<div class="header__controls">
|
|
35
26
|
<button class="control-btn" id="help-btn" title="What does each view do? Quick reference for Board / Graph / Dashboard / Sessions / Pipeline.">?</button>
|
|
36
27
|
<button class="control-btn" id="config-btn" title="Configurar Karajan (modelos, agentes, tope de iteraciones, …)">⚙</button>
|
|
37
28
|
<button class="control-btn" id="commands-btn" title="Launch a Karajan CLI command (kj plan, kj architect, kj clean…)">⚡</button>
|
|
29
|
+
<button class="control-btn" id="conversation-btn" title="Conversación con tu agente — la terminal real, dentro del board (ADR 0008)" onclick="openConversationPanel()">💬</button>
|
|
38
30
|
<button class="control-btn" id="restart-btn" title="Restart the HU Board server (preserves URL, the page reconnects automatically)">🔁</button>
|
|
39
31
|
<button class="control-btn" id="sync-btn" title="Re-scan disk for new batches">🔄</button>
|
|
40
32
|
<button class="control-btn" id="force-refresh-btn" title="Force a clean reload — wipes browser caches and reloads. Use when the UI looks stale and a normal refresh isn't fixing it." onclick="window.forceRefresh()">🧹</button>
|
|
@@ -42,6 +34,20 @@
|
|
|
42
34
|
</nav>
|
|
43
35
|
</header>
|
|
44
36
|
|
|
37
|
+
<!-- KJC-TSK-0820: project sub-bar. Hidden until a project is loaded from
|
|
38
|
+
the dashboard; every entry here shows data of THAT project. Switching
|
|
39
|
+
projects = back to Dashboard (the select is gone on purpose). -->
|
|
40
|
+
<nav class="project-nav" id="project-nav" hidden>
|
|
41
|
+
<span class="project-nav__name" id="project-nav-name"></span>
|
|
42
|
+
<button class="nav-btn nav-btn--project" data-view="board" title="Kanban board: every HU of the loaded project, grouped by status (Pending → Certified → Done). Click an HU card to expand the detail panel.">Board</button>
|
|
43
|
+
<button class="nav-btn nav-btn--project" data-view="graph" title="Dependency graph of the loaded project's HUs — who blocks whom. Useful before scheduling work.">Graph</button>
|
|
44
|
+
<button class="nav-btn nav-btn--project" data-view="sessions" title="Every kj run of the loaded project, newest first. Click a session to inspect its iterations, commits and checkpoints.">Sessions</button>
|
|
45
|
+
<button class="nav-btn nav-btn--project" data-view="governance" title="The acta: policy rules by friction, the hash-chained decision log and its anchor, standing exceptions with owner and expiry, renewals and signals.">Governance</button>
|
|
46
|
+
<a class="nav-btn nav-btn--project" href="/pipeline.html" title="Live observability of pipeline runs (Karajan v2.7+): stage timings, agent calls, decision logs.">Pipeline</a>
|
|
47
|
+
<a class="nav-btn nav-btn--project" href="/rag.html" title="RAG retrieval-quality dashboard: chunk counts, embedder provider, db size.">RAG</a>
|
|
48
|
+
<a class="nav-btn nav-btn--project" href="/wiki.html" title="Search the per-project QMD semantic wiki (docs, plans, reviews).">Wiki</a>
|
|
49
|
+
</nav>
|
|
50
|
+
|
|
45
51
|
<main class="main" id="app">
|
|
46
52
|
<div class="loading">
|
|
47
53
|
<div class="loading__spinner"></div>
|
|
@@ -67,6 +73,8 @@
|
|
|
67
73
|
<script src="/utils/preflight-view.js"></script>
|
|
68
74
|
<script src="/utils/plan-rollup-view.js"></script>
|
|
69
75
|
<script src="/utils/log-panel.js"></script>
|
|
76
|
+
<script src="/vendor/xterm/lib/xterm.js"></script>
|
|
77
|
+
<script src="/utils/terminal-panel.js"></script>
|
|
70
78
|
<script src="/utils/hu-actions.js"></script>
|
|
71
79
|
<script src="/utils/project-actions.js"></script>
|
|
72
80
|
<script src="/utils/story-edit-form.js"></script>
|
|
@@ -124,20 +124,36 @@ a:hover {
|
|
|
124
124
|
border-color: var(--accent-purple);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
127
|
+
/* ---- Project sub-bar (KJC-TSK-0820) ----
|
|
128
|
+
* Second nav level, only visible while a project is loaded. Teal, not
|
|
129
|
+
* purple: project-scoped actions must read as a different tier from the
|
|
130
|
+
* generic header. */
|
|
131
|
+
.project-nav {
|
|
132
|
+
display: flex;
|
|
133
|
+
gap: 8px;
|
|
134
|
+
align-items: center;
|
|
135
|
+
flex-wrap: wrap;
|
|
136
|
+
padding: 8px 24px;
|
|
137
|
+
background: var(--bg-card);
|
|
138
|
+
border-bottom: 1px solid var(--border);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.project-nav[hidden] {
|
|
142
|
+
display: none;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.project-nav__name {
|
|
146
|
+
color: var(--accent-teal);
|
|
147
|
+
font-weight: 600;
|
|
134
148
|
font-size: 0.85rem;
|
|
135
|
-
|
|
149
|
+
margin-right: 8px;
|
|
136
150
|
}
|
|
137
151
|
|
|
138
|
-
.project
|
|
139
|
-
|
|
140
|
-
|
|
152
|
+
.nav-btn--project:hover,
|
|
153
|
+
.nav-btn--project.active {
|
|
154
|
+
background: var(--accent-teal-dim);
|
|
155
|
+
color: var(--bg-primary);
|
|
156
|
+
border-color: var(--accent-teal-dim);
|
|
141
157
|
}
|
|
142
158
|
|
|
143
159
|
.header__controls {
|
|
@@ -955,13 +971,12 @@ a:hover {
|
|
|
955
971
|
/* ---- Scoped-project mode (URL: /p/<slug>) ---- */
|
|
956
972
|
/* Hide multi-project affordances when the board is scoped to a single
|
|
957
973
|
* project — the page only makes sense as "this project's Kanban".
|
|
958
|
-
* Multi-project UX (the
|
|
974
|
+
* Multi-project UX (the Dashboard picker, the Sessions tab, the
|
|
959
975
|
* Pipeline page link) adds noise and broke the user's mental model
|
|
960
976
|
* during dogfooding. */
|
|
961
|
-
body.scoped-mode .project-select,
|
|
962
977
|
body.scoped-mode .nav-btn[data-view="sessions"],
|
|
963
978
|
body.scoped-mode .nav-btn[data-view="dashboard"],
|
|
964
|
-
body.scoped-mode
|
|
979
|
+
body.scoped-mode a[href="/pipeline.html"] {
|
|
965
980
|
display: none;
|
|
966
981
|
}
|
|
967
982
|
|
|
@@ -99,7 +99,7 @@ async function renderGraph() {
|
|
|
99
99
|
if (!selectedProject) {
|
|
100
100
|
app.innerHTML = renderEmptyState(
|
|
101
101
|
'Pick a project first',
|
|
102
|
-
'The dependency graph is per-project.
|
|
102
|
+
'The dependency graph is per-project. Pick one on the Dashboard and I will draw its HU DAG.'
|
|
103
103
|
);
|
|
104
104
|
return;
|
|
105
105
|
}
|
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
// the boot block run.
|
|
7
7
|
//
|
|
8
8
|
// Globals consumed from earlier scripts / app.js:
|
|
9
|
-
// - navigate(), render(), handleRoute()
|
|
10
|
-
// populateProjectSelect() (app.js)
|
|
9
|
+
// - navigate(), render(), handleRoute() (app.js)
|
|
11
10
|
// - showCommandLauncher(),
|
|
12
11
|
// nextIsTestValue() (utils/command-launcher.js)
|
|
13
12
|
// - showHelp(), showConfirm(),
|
|
@@ -32,13 +31,6 @@ document.querySelectorAll('.nav-btn').forEach((btn) => {
|
|
|
32
31
|
btn.addEventListener('click', () => navigate(btn.dataset.view));
|
|
33
32
|
});
|
|
34
33
|
|
|
35
|
-
// Project selector
|
|
36
|
-
document.getElementById('project-select').addEventListener('change', (e) => {
|
|
37
|
-
selectedProject = e.target.value;
|
|
38
|
-
window.location.hash = selectedProject ? `${currentView}/${selectedProject}` : currentView;
|
|
39
|
-
render();
|
|
40
|
-
});
|
|
41
|
-
|
|
42
34
|
// Sync button — re-scan disk for new batches
|
|
43
35
|
document.getElementById('sync-btn').addEventListener('click', async () => {
|
|
44
36
|
const btn = document.getElementById('sync-btn');
|
|
@@ -46,7 +38,6 @@ document.getElementById('sync-btn').addEventListener('click', async () => {
|
|
|
46
38
|
btn.textContent = '⏳';
|
|
47
39
|
try {
|
|
48
40
|
await fetch('/api/sync', { method: 'POST' });
|
|
49
|
-
await populateProjectSelect();
|
|
50
41
|
render();
|
|
51
42
|
} catch { /* ignore */ }
|
|
52
43
|
btn.textContent = '🔄';
|
|
@@ -106,7 +97,6 @@ document.addEventListener('click', async (e) => {
|
|
|
106
97
|
});
|
|
107
98
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
108
99
|
// Re-render the project list so the badge reflects the new value.
|
|
109
|
-
await populateProjectSelect();
|
|
110
100
|
render();
|
|
111
101
|
} catch (err) {
|
|
112
102
|
await showError(err.message, { title: 'Failed to update is_test' });
|
|
@@ -130,7 +120,6 @@ document.addEventListener('click', async (e) => {
|
|
|
130
120
|
try {
|
|
131
121
|
const res = await fetch(`/api/projects/${encodeURIComponent(projectId)}`, { method: 'DELETE' });
|
|
132
122
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
133
|
-
await populateProjectSelect();
|
|
134
123
|
render();
|
|
135
124
|
} catch (err) {
|
|
136
125
|
await showError(err.message, { title: 'Failed to delete project' });
|
|
@@ -161,7 +150,6 @@ startStandbyPolling();
|
|
|
161
150
|
|
|
162
151
|
// Initial load — sync disk data first so new batches are visible
|
|
163
152
|
triggerSync().then(() => {
|
|
164
|
-
populateProjectSelect();
|
|
165
153
|
handleRoute();
|
|
166
154
|
subscribeToServerEvents();
|
|
167
155
|
});
|
|
@@ -174,7 +162,6 @@ triggerSync().then(() => {
|
|
|
174
162
|
refreshInterval = setInterval(async () => {
|
|
175
163
|
if (document.getElementById('modal-backdrop').classList.contains('hidden')) {
|
|
176
164
|
await triggerSync();
|
|
177
|
-
await populateProjectSelect();
|
|
178
165
|
await smartRefresh(null);
|
|
179
166
|
}
|
|
180
167
|
}, 60_000);
|
|
@@ -29,7 +29,7 @@ const MAGGLE_LABELS = {
|
|
|
29
29
|
'nav.board': 'Tablero',
|
|
30
30
|
'nav.dashboard': 'Proyectos',
|
|
31
31
|
'nav.more': 'Más',
|
|
32
|
-
'picker.hint': 'Elige un proyecto para ver su tablero.
|
|
32
|
+
'picker.hint': 'Elige un proyecto para ver su tablero. Para cambiar de proyecto, vuelve a «Proyectos».',
|
|
33
33
|
'picker.project': 'proyecto',
|
|
34
34
|
'launcher.title': '📝 Pedir trabajo a Karajan',
|
|
35
35
|
'launcher.planLabel': '📝 Pedir trabajo nuevo',
|
|
@@ -39,6 +39,9 @@ const MAGGLE_LABELS = {
|
|
|
39
39
|
'launcher.cancel': 'Cancelar',
|
|
40
40
|
'launcher.more': 'Más opciones…',
|
|
41
41
|
'launcher.confirm': 'Voy a convertir tu petición en tareas del tablero. Tardará unos minutos y podrás seguir la actividad en esta ventana. ¿Sigo?',
|
|
42
|
+
'conversation.title': 'Habla con tu agente',
|
|
43
|
+
'conversation.hide': 'Ocultar — la conversación sigue viva',
|
|
44
|
+
'conversation.end': 'Terminar la sesión del agente',
|
|
42
45
|
'log.label': 'Actividad',
|
|
43
46
|
'log.connecting': 'conectando…',
|
|
44
47
|
'log.footer': 'Cerrar esta ventana NO detiene el trabajo: sigue en marcha. Vuelve a abrirla con el botón «📜 Ver actividad» del tablero.',
|
|
@@ -83,7 +86,10 @@ function applyMaggleChrome() {
|
|
|
83
86
|
if (!nav) return;
|
|
84
87
|
const KEEP = { board: MAGGLE_LABELS['nav.board'], dashboard: MAGGLE_LABELS['nav.dashboard'] };
|
|
85
88
|
const advanced = [];
|
|
86
|
-
|
|
89
|
+
// KJC-TSK-0820: nav buttons live in TWO bars now — the generic header
|
|
90
|
+
// and the project sub-bar (#project-nav). Fold both; Tablero stays in
|
|
91
|
+
// the sub-bar so it only shows once a project is loaded.
|
|
92
|
+
for (const btn of document.querySelectorAll('.header__nav .nav-btn, .project-nav .nav-btn')) {
|
|
87
93
|
const view = btn.dataset.view;
|
|
88
94
|
if (view && KEEP[view]) {
|
|
89
95
|
btn.title = `${btn.textContent.trim()} — ${btn.title}`;
|
|
@@ -115,8 +121,11 @@ function applyMaggleChrome() {
|
|
|
115
121
|
expert.remove();
|
|
116
122
|
}
|
|
117
123
|
});
|
|
118
|
-
//
|
|
119
|
-
|
|
124
|
+
// In the GENERIC bar (before the control buttons), so the plain header
|
|
125
|
+
// reads Proyectos | Más and the sub-bar keeps only Tablero.
|
|
126
|
+
const controls = nav.querySelector('.header__controls');
|
|
127
|
+
if (controls) controls.before(more);
|
|
128
|
+
else nav.append(more);
|
|
120
129
|
}
|
|
121
130
|
|
|
122
131
|
if (typeof document !== 'undefined' && document.addEventListener) {
|
|
@@ -11,8 +11,7 @@
|
|
|
11
11
|
// - formatDuration() (utils/formatters.js)
|
|
12
12
|
// - projectNameCache (mutable in app.js — assignment crosses
|
|
13
13
|
// scripts transparently)
|
|
14
|
-
// -
|
|
15
|
-
// - renderBoard() (app.js)
|
|
14
|
+
// - render() (app.js)
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* PR-G: rename a project from the header ✎ button. Opens a small
|
|
@@ -67,11 +66,11 @@ window.renameProjectModal = function renameProjectModal(projectId, currentName)
|
|
|
67
66
|
return;
|
|
68
67
|
}
|
|
69
68
|
// Update the cached display name + refresh the board so the
|
|
70
|
-
// header,
|
|
69
|
+
// header, project bar and project picker all show the new value.
|
|
71
70
|
projectNameCache[projectId] = newName;
|
|
72
71
|
try { dlg.close(); } catch { /* ignore */ }
|
|
73
|
-
|
|
74
|
-
await
|
|
72
|
+
// render() (not renderBoard()) so the project bar name refreshes too.
|
|
73
|
+
await render();
|
|
75
74
|
} catch (err) {
|
|
76
75
|
errorEl.style.display = 'block';
|
|
77
76
|
errorEl.textContent = err.message || String(err);
|
|
@@ -74,7 +74,7 @@ async function renderProjectPicker() {
|
|
|
74
74
|
<span class="section-header__count">${sorted.length} ${maggleText('picker.project', 'project')}${sorted.length === 1 ? '' : 's'}</span>
|
|
75
75
|
</div>
|
|
76
76
|
<p style="padding:8px 4px 16px;color:var(--text-muted);font-size:0.9rem">
|
|
77
|
-
${maggleText('picker.hint', 'Pick a project to see its kanban.
|
|
77
|
+
${maggleText('picker.hint', 'Pick a project to see its kanban. To switch later, go back to the Dashboard.')}
|
|
78
78
|
</p>
|
|
79
79
|
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(320px, 1fr));gap:14px">
|
|
80
80
|
${sorted.map((p) => {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// KJC-TSK-0816 (MGL-E, ADR 0008) — el panel «Conversación»: la terminal
|
|
2
|
+
// REAL del agente dentro del board. Script clásico, como el resto.
|
|
3
|
+
//
|
|
4
|
+
// Globals: Terminal (de /vendor/xterm), esc(), maggleText(), showError().
|
|
5
|
+
// El token del pty llega de /api/terminal/start (tras el auth del board)
|
|
6
|
+
// y viaja al WS como SUBPROTOCOLO, nunca en la URL.
|
|
7
|
+
|
|
8
|
+
let conversationState = { ws: null, term: null, panel: null, termId: null, token: null, opening: false };
|
|
9
|
+
|
|
10
|
+
async function openConversationPanel() {
|
|
11
|
+
if (conversationState.panel && conversationState.dead) {
|
|
12
|
+
// La conexión murió (agente terminado o red): reabrir = sesión fresca.
|
|
13
|
+
conversationState.panel.remove();
|
|
14
|
+
conversationState = { ws: null, term: null, panel: null, termId: null, token: null, opening: false };
|
|
15
|
+
}
|
|
16
|
+
if (conversationState.panel) {
|
|
17
|
+
conversationState.panel.style.display = 'flex';
|
|
18
|
+
conversationState.term?.focus();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
// Un doble clic (o ?window=1 + clic) no debe abrir dos sesiones.
|
|
22
|
+
if (conversationState.opening) return;
|
|
23
|
+
conversationState.opening = true;
|
|
24
|
+
let started;
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch('/api/terminal/start', {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
// Sin agente en el body: decide el servidor (kj go --window fija el
|
|
30
|
+
// elegido por env; sin él, claude).
|
|
31
|
+
body: JSON.stringify({}),
|
|
32
|
+
});
|
|
33
|
+
started = await res.json();
|
|
34
|
+
if (!res.ok) throw new Error(started.error || `HTTP ${res.status}`);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
conversationState.opening = false;
|
|
37
|
+
await showError(err.message, { title: 'La conversación no pudo arrancar' });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
conversationState.opening = false;
|
|
41
|
+
conversationState.termId = started.termId;
|
|
42
|
+
conversationState.token = started.token;
|
|
43
|
+
|
|
44
|
+
const panel = document.createElement('section');
|
|
45
|
+
panel.id = 'kj-conversation-panel';
|
|
46
|
+
panel.style.cssText = [
|
|
47
|
+
'position:fixed', 'top:0', 'right:0', 'bottom:0',
|
|
48
|
+
'width:min(720px, 55vw)', 'display:flex', 'flex-direction:column',
|
|
49
|
+
'background:#0b0b0c', 'border-left:1px solid var(--border)',
|
|
50
|
+
'box-shadow:-12px 0 40px rgba(0,0,0,0.45)', 'z-index:9998',
|
|
51
|
+
].join(';');
|
|
52
|
+
panel.innerHTML = `
|
|
53
|
+
<header style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;
|
|
54
|
+
background:var(--bg-primary);border-bottom:1px solid var(--border);flex-shrink:0">
|
|
55
|
+
<strong style="color:var(--text)">${esc(maggleText('conversation.title', 'Conversación con tu agente'))}</strong>
|
|
56
|
+
<div style="display:flex;gap:6px">
|
|
57
|
+
<button id="conv-hide" type="button" class="control-btn" title="${esc(maggleText('conversation.hide', 'Ocultar — la conversación sigue viva'))}"
|
|
58
|
+
style="padding:4px 10px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text);border-radius:var(--radius-sm);cursor:pointer">_</button>
|
|
59
|
+
<button id="conv-end" type="button" class="control-btn" title="${esc(maggleText('conversation.end', 'Terminar la sesión del agente'))}"
|
|
60
|
+
style="padding:4px 10px;border:1px solid var(--border);background:var(--bg-primary);color:#f87171;border-radius:var(--radius-sm);cursor:pointer">✕</button>
|
|
61
|
+
</div>
|
|
62
|
+
</header>
|
|
63
|
+
<div id="conv-term" style="flex:1 1 auto;min-height:0;padding:6px"></div>
|
|
64
|
+
`;
|
|
65
|
+
document.body.appendChild(panel);
|
|
66
|
+
conversationState.panel = panel;
|
|
67
|
+
|
|
68
|
+
const term = new Terminal({
|
|
69
|
+
convertEol: false,
|
|
70
|
+
cursorBlink: true,
|
|
71
|
+
fontFamily: 'JetBrains Mono, monospace',
|
|
72
|
+
fontSize: 13,
|
|
73
|
+
theme: { background: '#0b0b0c' },
|
|
74
|
+
});
|
|
75
|
+
term.open(panel.querySelector('#conv-term'));
|
|
76
|
+
conversationState.term = term;
|
|
77
|
+
|
|
78
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
79
|
+
const ws = new WebSocket(
|
|
80
|
+
`${proto}://${location.host}/api/terminal/ws?termId=${encodeURIComponent(started.termId)}`,
|
|
81
|
+
['kj-terminal', started.token],
|
|
82
|
+
);
|
|
83
|
+
conversationState.ws = ws;
|
|
84
|
+
ws.onmessage = (e) => term.write(typeof e.data === 'string' ? e.data : '');
|
|
85
|
+
ws.onclose = () => {
|
|
86
|
+
conversationState.dead = true;
|
|
87
|
+
term.write('\r\n[conexión cerrada — vuelve a abrir el panel para reconectar]\r\n');
|
|
88
|
+
};
|
|
89
|
+
term.onData((data) => {
|
|
90
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(data);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const sendResize = () => {
|
|
94
|
+
const el = panel.querySelector('#conv-term');
|
|
95
|
+
const cols = Math.max(40, Math.floor(el.clientWidth / 8));
|
|
96
|
+
const rows = Math.max(10, Math.floor(el.clientHeight / 18));
|
|
97
|
+
term.resize(cols, rows);
|
|
98
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
99
|
+
ws.send(`\u0000${JSON.stringify({ type: 'resize', cols, rows })}`);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
ws.onopen = () => { sendResize(); term.focus(); };
|
|
103
|
+
window.addEventListener('resize', sendResize);
|
|
104
|
+
|
|
105
|
+
panel.querySelector('#conv-hide').addEventListener('click', () => {
|
|
106
|
+
panel.style.display = 'none';
|
|
107
|
+
});
|
|
108
|
+
panel.querySelector('#conv-end').addEventListener('click', async () => {
|
|
109
|
+
try {
|
|
110
|
+
await fetch('/api/terminal/stop', {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'Content-Type': 'application/json' },
|
|
113
|
+
body: JSON.stringify({ termId: conversationState.termId, token: conversationState.token }),
|
|
114
|
+
});
|
|
115
|
+
} finally {
|
|
116
|
+
ws.close();
|
|
117
|
+
panel.remove();
|
|
118
|
+
conversationState = { ws: null, term: null, panel: null, termId: null, token: null, opening: false };
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// kj go --window abre el board con ?window=1: la conversación arranca sola.
|
|
124
|
+
if (typeof document !== 'undefined' && document.addEventListener) {
|
|
125
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
126
|
+
try {
|
|
127
|
+
if (new URLSearchParams(location.search).get('window') === '1') openConversationPanel();
|
|
128
|
+
} catch {
|
|
129
|
+
// Sin soporte de URLSearchParams no hay auto-apertura; el botón queda.
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -377,6 +377,19 @@ export function ensureDb() {
|
|
|
377
377
|
* @param {{ id: string, name?: string, first_seen?: string, last_activity?: string, total_stories?: number }} project
|
|
378
378
|
*/
|
|
379
379
|
export function upsertProject(project) {
|
|
380
|
+
// KJC-BUG-0163 — the iron rule: a project NAME is unique on the dashboard.
|
|
381
|
+
// Two projects with the same visible name are indistinguishable in the
|
|
382
|
+
// picker; registering the second fails LOUD instead of blending in.
|
|
383
|
+
if (project.name) {
|
|
384
|
+
const clash = getDb()
|
|
385
|
+
.prepare('SELECT id FROM projects WHERE name = ? AND id != ?')
|
|
386
|
+
.get(project.name, project.id);
|
|
387
|
+
if (clash) {
|
|
388
|
+
throw new Error(
|
|
389
|
+
`board: el nombre "${project.name}" ya pertenece al proyecto "${clash.id}" — dos proyectos no pueden llamarse igual (KJC-BUG-0163); renombra el plan o el directorio de "${project.id}"`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
380
393
|
// is_shared (KJC-PRP-0002 PR3) is set by sync when a plan came from
|
|
381
394
|
// `.karajan-shared/`. COALESCE with the existing column keeps the badge
|
|
382
395
|
// once it's been promoted — a second non-shared plan won't unset it.
|
|
@@ -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,
|