karajan-code 4.25.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/package.json +1 -1
- package/packages/hu-board/public/app.js +37 -43
- package/packages/hu-board/public/index.html +15 -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 +10 -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/src/db.js +13 -0
- package/packages/hu-board/src/sync.js +6 -1
- package/src/cli/register-meta.js +1 -0
- 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/package.json
CHANGED
|
@@ -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
|
|
|
@@ -21,17 +21,7 @@
|
|
|
21
21
|
</div>
|
|
22
22
|
</div>
|
|
23
23
|
<nav class="header__nav">
|
|
24
|
-
<button class="nav-btn active" data-view="
|
|
25
|
-
<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>
|
|
26
|
-
<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>
|
|
27
|
-
<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>
|
|
28
|
-
<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>
|
|
29
|
-
<a class="nav-btn" href="/pipeline.html" title="Live observability of pipeline runs (Karajan v2.7+): stage timings, agent calls, decision logs.">Pipeline</a>
|
|
30
|
-
<a class="nav-btn" href="/rag.html" title="RAG retrieval-quality dashboard: chunk counts, embedder provider, db size.">RAG</a>
|
|
31
|
-
<a class="nav-btn" href="/wiki.html" title="Search the per-project QMD semantic wiki (docs, plans, reviews).">Wiki</a>
|
|
32
|
-
<select class="project-select" id="project-select">
|
|
33
|
-
<option value="">All Projects</option>
|
|
34
|
-
</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>
|
|
35
25
|
<div class="header__controls">
|
|
36
26
|
<button class="control-btn" id="help-btn" title="What does each view do? Quick reference for Board / Graph / Dashboard / Sessions / Pipeline.">?</button>
|
|
37
27
|
<button class="control-btn" id="config-btn" title="Configurar Karajan (modelos, agentes, tope de iteraciones, …)">⚙</button>
|
|
@@ -44,6 +34,20 @@
|
|
|
44
34
|
</nav>
|
|
45
35
|
</header>
|
|
46
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
|
+
|
|
47
51
|
<main class="main" id="app">
|
|
48
52
|
<div class="loading">
|
|
49
53
|
<div class="loading__spinner"></div>
|
|
@@ -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',
|
|
@@ -86,7 +86,10 @@ function applyMaggleChrome() {
|
|
|
86
86
|
if (!nav) return;
|
|
87
87
|
const KEEP = { board: MAGGLE_LABELS['nav.board'], dashboard: MAGGLE_LABELS['nav.dashboard'] };
|
|
88
88
|
const advanced = [];
|
|
89
|
-
|
|
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')) {
|
|
90
93
|
const view = btn.dataset.view;
|
|
91
94
|
if (view && KEEP[view]) {
|
|
92
95
|
btn.title = `${btn.textContent.trim()} — ${btn.title}`;
|
|
@@ -118,8 +121,11 @@ function applyMaggleChrome() {
|
|
|
118
121
|
expert.remove();
|
|
119
122
|
}
|
|
120
123
|
});
|
|
121
|
-
//
|
|
122
|
-
|
|
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);
|
|
123
129
|
}
|
|
124
130
|
|
|
125
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) => {
|
|
@@ -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.
|
|
@@ -456,7 +456,12 @@ export function syncPlanFile(filePath) {
|
|
|
456
456
|
// with a SPEC about "Linux Assistant", the basename here would
|
|
457
457
|
// misleadingly say "Hu Board" — that's why plan.name wins.
|
|
458
458
|
// 3. slugToTitle of the task / projectId as a last resort.
|
|
459
|
-
|
|
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
|
|
460
465
|
|| (canonicalDir ? titleCaseBasename(basename(canonicalDir)) : null)
|
|
461
466
|
|| slugToTitle(data.task || '')
|
|
462
467
|
|| projectId;
|
package/src/cli/register-meta.js
CHANGED
|
@@ -372,6 +372,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
372
372
|
.option("--rule <rule_id>", "Regla exacta (p.ej. roles.coder.write.deny)")
|
|
373
373
|
.option("--until <iso>", "Caducidad ISO-8601 (p.ej. 2026-09-01T00:00:00Z)")
|
|
374
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")
|
|
375
376
|
.action(async (flags) => {
|
|
376
377
|
await withConfig(pkgVersion, "policy-grant", flags, async ({ config }) => {
|
|
377
378
|
process.exitCode = await policyCommand({ action: "grant", config, flags });
|
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";
|
|
@@ -43,6 +43,7 @@ export const CARD = new RegExp(${JSON.stringify(CARD_REF_RE.source)}, "i");
|
|
|
43
43
|
export const BASE_BRANCHES = new Set(["main", "master"]);
|
|
44
44
|
export const load = () => { try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return {}; } };
|
|
45
45
|
export const save = (state) => writeFileSync(STATE, JSON.stringify(state, null, 2));
|
|
46
|
+
export const doc = (a) => " — doc: https://karajancode.com/docs/guides/sentinel/#" + a;
|
|
46
47
|
export const session = (state, sid) => {
|
|
47
48
|
state.sessions ||= {};
|
|
48
49
|
return (state.sessions[sid] ||= { edited_sources: [], edited_tests: [], escapes: [], errors: [], blocks: 0 });
|
|
@@ -108,9 +109,9 @@ export const recordEscape = (sid, escape, tool) => {
|
|
|
108
109
|
const args = ["policy", "seal", "--escape", escape];
|
|
109
110
|
if (tool) args.push("--tool", String(tool));
|
|
110
111
|
const r = spawnSync("kj", args, { cwd: ROOT, encoding: "utf8" });
|
|
111
|
-
if (r.error || r.status !== 0) console.error("
|
|
112
|
+
if (r.error || r.status !== 0) console.error("karajan sentinel: escape " + escape + " usado pero NO sellado en el decision log (kj policy seal " + (r.error ? r.error.message : "exit " + r.status) + ") — revisa kj; el escape queda solo en sentinel-state.json" + doc("escapes"));
|
|
112
113
|
} catch (e) {
|
|
113
|
-
console.error("
|
|
114
|
+
console.error("karajan sentinel: escape " + escape + " usado pero NO sellado en el decision log: " + e.message + doc("escapes"));
|
|
114
115
|
}
|
|
115
116
|
};
|
|
116
117
|
`;
|
|
@@ -121,7 +122,7 @@ const POST_BODY = `#!/usr/bin/env node
|
|
|
121
122
|
// a tool call (PostToolUse, always exit 0).
|
|
122
123
|
import { relative } from "node:path";
|
|
123
124
|
import { spawnSync } from "node:child_process";
|
|
124
|
-
import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
|
|
125
|
+
import { doc, CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
|
|
125
126
|
const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII", "KJ_ALLOW_POLICY", "KJ_ALLOW_IDENTITY", "KJ_ALLOW_BOARD"];
|
|
126
127
|
let raw = "";
|
|
127
128
|
process.stdin.on("data", (d) => { raw += d; });
|
|
@@ -233,7 +234,7 @@ import { spawnSync } from "node:child_process";
|
|
|
233
234
|
import { readFileSync } from "node:fs";
|
|
234
235
|
import { homedir } from "node:os";
|
|
235
236
|
import { join } from "node:path";
|
|
236
|
-
import { load, save, branchOf, violations, ROOT } from "./sentinel-lib.mjs";
|
|
237
|
+
import { doc, load, save, branchOf, violations, ROOT } from "./sentinel-lib.mjs";
|
|
237
238
|
// KJC-TSK-0774: when the turn ends green with cards closed, remind the user
|
|
238
239
|
// where to look — the board URL if it runs (pid file alive), else how to start it.
|
|
239
240
|
const boardHint = () => {
|
|
@@ -274,10 +275,10 @@ process.stdin.on("end", () => {
|
|
|
274
275
|
state.tamper_blocks = (state.tamper_blocks || 0) + 1;
|
|
275
276
|
save(state);
|
|
276
277
|
if (state.tamper_blocks > 3) {
|
|
277
|
-
console.error("
|
|
278
|
+
console.error("karajan sentinel: fail-open tras 3 bloqueos por manipulacion sin restaurar — corre kj harden y avisa a tu usuario" + doc("supervisor"));
|
|
278
279
|
process.exit(0);
|
|
279
280
|
}
|
|
280
|
-
console.error("
|
|
281
|
+
console.error("karajan sentinel: scripts del supervisor modificados fuera de kj harden (" + tampered.join(", ") + ") — restaura con kj harden antes de terminar; si lo cambiaste tu (humano), reinstalar lo deja en verde." + doc("supervisor"));
|
|
281
282
|
process.exit(2);
|
|
282
283
|
}
|
|
283
284
|
state.tamper_blocks = 0;
|
|
@@ -296,7 +297,7 @@ process.stdin.on("end", () => {
|
|
|
296
297
|
}
|
|
297
298
|
const s = state.sessions?.[sid];
|
|
298
299
|
if (!s) {
|
|
299
|
-
if (claimsBlock) { console.error("
|
|
300
|
+
if (claimsBlock) { console.error("karajan sentinel: el turno NO puede terminar:" + String.fromCharCode(10) + "- " + claimsBlock + doc("claims")); process.exit(2); }
|
|
300
301
|
if (claimsNote) console.log(JSON.stringify({ systemMessage: claimsNote }));
|
|
301
302
|
process.exit(0);
|
|
302
303
|
}
|
|
@@ -306,9 +307,9 @@ process.stdin.on("end", () => {
|
|
|
306
307
|
s.blocks = 0;
|
|
307
308
|
const notes = [];
|
|
308
309
|
if (claimsNote) notes.push(claimsNote);
|
|
309
|
-
if ((s.escapes || []).length) notes.push("
|
|
310
|
+
if ((s.escapes || []).length) notes.push("karajan sentinel: esta sesion uso " + s.escapes.length + " escape(s): " + s.escapes.join(", ") + " — decision registrada; detalle en kj sentinel status.");
|
|
310
311
|
if ((s.closed_cards || []).length) {
|
|
311
|
-
notes.push("
|
|
312
|
+
notes.push("karajan sentinel: card(s) cerrada(s) en este turno: " + s.closed_cards.join(", ") + " — el board las muestra: " + boardHint());
|
|
312
313
|
s.closed_cards = [];
|
|
313
314
|
}
|
|
314
315
|
save(state);
|
|
@@ -319,11 +320,11 @@ process.stdin.on("end", () => {
|
|
|
319
320
|
if (s.blocks > 3) {
|
|
320
321
|
(s.errors ||= []).push("fail-open: 3 bloqueos consecutivos sin resolver — el sentinel se aparta para no colgar la sesion");
|
|
321
322
|
save(state);
|
|
322
|
-
console.error("
|
|
323
|
+
console.error("karajan sentinel: fail-open tras 3 bloqueos sin resolver — revisa kj sentinel status con tu usuario" + doc("stop-gate"));
|
|
323
324
|
process.exit(0);
|
|
324
325
|
}
|
|
325
326
|
save(state);
|
|
326
|
-
console.error("
|
|
327
|
+
console.error("karajan sentinel: el turno NO puede terminar con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + "\\nResuelve las violaciones (o pide a tu usuario el escape) y termina de nuevo. Estado: kj sentinel status" + doc("stop-gate"));
|
|
327
328
|
process.exit(2);
|
|
328
329
|
} catch { /* fail open */ }
|
|
329
330
|
process.exit(0);
|
|
@@ -340,7 +341,7 @@ import { dirname, join, relative, resolve } from "node:path";
|
|
|
340
341
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
341
342
|
import { spawnSync } from "node:child_process";
|
|
342
343
|
import { homedir } from "node:os";
|
|
343
|
-
import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
344
|
+
import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
344
345
|
const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
|
|
345
346
|
const PUBLISH = /\\bnpm\\s+publish\\b|\\bfirebase\\s+deploy\\b|\\bgh\\s+release\\s+create\\b/;
|
|
346
347
|
const PUSH = /\\bgit\\s+push\\b/;
|
|
@@ -357,7 +358,7 @@ process.stdin.on("end", () => {
|
|
|
357
358
|
const target = input.file_path || input.notebook_path;
|
|
358
359
|
const relT = target ? relative(ROOT, String(target)).replaceAll("\\\\", "/") : "";
|
|
359
360
|
if (relT && PROTECTED.test(relT)) {
|
|
360
|
-
console.error("
|
|
361
|
+
console.error("karajan sentinel: ese fichero es parte del supervisor (" + relT + ") — solo el humano desmonta el sentinel, editalo fuera de la sesion." + doc("supervisor"));
|
|
361
362
|
process.exit(2);
|
|
362
363
|
}
|
|
363
364
|
}
|
|
@@ -367,7 +368,7 @@ process.stdin.on("end", () => {
|
|
|
367
368
|
// substitution) cannot be resolved from command text: that vector is
|
|
368
369
|
// caught by the Stop gate's checksum verification, which blocks the turn.
|
|
369
370
|
if (tool === "Bash" && PROTECTED.test(String(input.command || "").replaceAll("\\\\", "/"))) {
|
|
370
|
-
console.error("
|
|
371
|
+
console.error("karajan sentinel: los ficheros del supervisor no se tocan desde Bash — consultalos con la tool Read/Grep; solo el humano los modifica, fuera de la sesion." + doc("supervisor"));
|
|
371
372
|
process.exit(2);
|
|
372
373
|
}
|
|
373
374
|
if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
|
|
@@ -411,7 +412,7 @@ process.stdin.on("end", () => {
|
|
|
411
412
|
if (bad === null) return true;
|
|
412
413
|
if (!escNoted.has(name)) {
|
|
413
414
|
escNoted.add(name);
|
|
414
|
-
console.error("
|
|
415
|
+
console.error("karajan sentinel: " + name + "=1 presente pero IGNORADO — el escape solo vale en un comando simple y este contiene " + JSON.stringify(bad) + " fuera de comillas simples (sin ; | & $ ( ) backtick ni salto de linea: parte el comando o usa -F fichero)" + doc("escapes"));
|
|
415
416
|
}
|
|
416
417
|
return false;
|
|
417
418
|
};
|
|
@@ -530,7 +531,7 @@ process.stdin.on("end", () => {
|
|
|
530
531
|
if (problems.length > 0) {
|
|
531
532
|
if (escOn("KJ_ALLOW_IDENTITY")) { recordEscape(sid, "KJ_ALLOW_IDENTITY", tool); }
|
|
532
533
|
else {
|
|
533
|
-
console.error("
|
|
534
|
+
console.error("karajan sentinel: identity lock (ADR 0005) —" + problems.map((p) => ESC_NL + "- " + p).join("") + ESC_NL + "(KJ_ALLOW_IDENTITY=1 = excepcion consciente, queda registrada)" + doc("identity"));
|
|
534
535
|
process.exit(2);
|
|
535
536
|
}
|
|
536
537
|
}
|
|
@@ -553,7 +554,7 @@ process.stdin.on("end", () => {
|
|
|
553
554
|
};
|
|
554
555
|
const laneDeny = (what, lane) => {
|
|
555
556
|
if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); return false; }
|
|
556
|
-
console.error("
|
|
557
|
+
console.error("karajan sentinel: " + what + " vive en otro carril (" + lane + ") de este repo — cada sesion muta solo SU worktree (MONO-0). Cruce deliberado: KJ_ALLOW_CROSS_LANE=1, queda registrado." + doc("cross-lane"));
|
|
557
558
|
return true;
|
|
558
559
|
};
|
|
559
560
|
if (EDIT_TOOLS.includes(tool)) {
|
|
@@ -581,7 +582,7 @@ process.stdin.on("end", () => {
|
|
|
581
582
|
if (/(^|[;&|(\\s])(cd|pushd)([ \\t]|$)/.test(cmd)) {
|
|
582
583
|
if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
583
584
|
else {
|
|
584
|
-
console.error("
|
|
585
|
+
console.error("karajan sentinel: cd/pushd en un comando mutador — las rutas posteriores no son verificables por el guard de carriles (MONO-0); usa git -C / npm --prefix / rutas ABSOLUTAS sin cd (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
|
|
585
586
|
process.exit(2);
|
|
586
587
|
}
|
|
587
588
|
}
|
|
@@ -600,7 +601,7 @@ process.stdin.on("end", () => {
|
|
|
600
601
|
if (/>{1,2}[ \\t]*["']?[\\$\`]/.test(cmd)) {
|
|
601
602
|
if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
602
603
|
else {
|
|
603
|
-
console.error("
|
|
604
|
+
console.error("karajan sentinel: redireccion con destino tras variable/sustitucion — el guard de carriles no puede verificarlo (MONO-0); usa una ruta LITERAL (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
|
|
604
605
|
process.exit(2);
|
|
605
606
|
}
|
|
606
607
|
}
|
|
@@ -666,7 +667,7 @@ process.stdin.on("end", () => {
|
|
|
666
667
|
if (/\\$\\(|\`/.test(cmd) || quotedPathWithSpaces(cmd)) {
|
|
667
668
|
if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
668
669
|
else {
|
|
669
|
-
console.error("
|
|
670
|
+
console.error("karajan sentinel: sustitucion de comandos o ruta entrecomillada con espacios en un comando mutador — no verificable por el guard de carriles (MONO-0); usa valores/rutas LITERALES sin sustitucion (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
|
|
670
671
|
process.exit(2);
|
|
671
672
|
}
|
|
672
673
|
}
|
|
@@ -675,7 +676,7 @@ process.stdin.on("end", () => {
|
|
|
675
676
|
if (!segs.every((s) => !/[\\$]/.test(s) || SAFE_EXP_SEG.test(s))) {
|
|
676
677
|
if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
677
678
|
else {
|
|
678
|
-
console.error("
|
|
679
|
+
console.error("karajan sentinel: expansion de shell en una herramienta generica de fichero/interprete — el objetivo no es verificable por el guard de carriles (MONO-0); usa rutas LITERALES o un runner del toolchain (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
|
|
679
680
|
process.exit(2);
|
|
680
681
|
}
|
|
681
682
|
}
|
|
@@ -708,7 +709,7 @@ process.stdin.on("end", () => {
|
|
|
708
709
|
const actorRole = process.env.KJ_POLICY_ROLE || "coder";
|
|
709
710
|
const pres = spawnSync("kj", ["policy", "eval", "--strict", "--role", actorRole, "--tool", tool, "--input", JSON.stringify(input)], { cwd: ROOT, encoding: "utf8" });
|
|
710
711
|
if (pres.error || pres.status === null) {
|
|
711
|
-
console.error("
|
|
712
|
+
console.error("karajan sentinel: .karajan/policy.yml declara enforcement pero kj no es ejecutable — la policy no se puede evaluar; restaura kj en el PATH (npm i -g @karajan-family/code, o el nombre legacy karajan-code) o retira la policy conscientemente." + doc("policy"));
|
|
712
713
|
process.exit(2);
|
|
713
714
|
}
|
|
714
715
|
if (pres.status === 2) {
|
|
@@ -717,13 +718,13 @@ process.stdin.on("end", () => {
|
|
|
717
718
|
const secure = !!(v && v.class === "security");
|
|
718
719
|
if (!secure && escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
|
|
719
720
|
else {
|
|
720
|
-
console.error("
|
|
721
|
+
console.error("karajan sentinel: policy deny [" + ((v && v.rule_id) || "policy") + "] " + ((v && v.reason) || "la tool call viola la policy del proyecto") + (secure ? " [security — sin escape ni arbitraje]" : " (KJ_ALLOW_POLICY=1 = excepcion consciente, queda registrada; el commit exigira ademas KJ_POLICY_REASON)") + doc("policy"));
|
|
721
722
|
process.exit(2);
|
|
722
723
|
}
|
|
723
724
|
} else if (pres.status !== 0) {
|
|
724
725
|
if (escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
|
|
725
726
|
else {
|
|
726
|
-
console.error("
|
|
727
|
+
console.error("karajan sentinel: kj policy eval fallo (exit " + pres.status + ") — la policy declarada no se pudo evaluar, deny por defecto; diagnostica con kj policy check y corrige .karajan/policy.yml fuera de la sesion (o KJ_ALLOW_POLICY=1 = excepcion consciente, queda registrada)." + doc("policy"));
|
|
727
728
|
process.exit(2);
|
|
728
729
|
}
|
|
729
730
|
}
|
|
@@ -746,7 +747,7 @@ process.stdin.on("end", () => {
|
|
|
746
747
|
if (hard.length && blockOn) {
|
|
747
748
|
if (escOn("KJ_ALLOW_STEWARD")) { recordEscape(sid, "KJ_ALLOW_STEWARD", tool); }
|
|
748
749
|
else {
|
|
749
|
-
console.error("
|
|
750
|
+
console.error("karajan sentinel: steward — el estado del proyecto bloquea empezar (barrido " + rep.sweptAt + "):" + String.fromCharCode(10) + hard.map((r) => "- " + r.id + ": " + (r.evidence || "") + " -> " + (r.remedy || r.renew || "kj steward sweep")).join(String.fromCharCode(10)) + String.fromCharCode(10) + "(KJ_ALLOW_STEWARD=1 = excepcion consciente de esta sesion, queda registrada)" + doc("steward"));
|
|
750
751
|
process.exit(2);
|
|
751
752
|
}
|
|
752
753
|
}
|
|
@@ -768,8 +769,8 @@ process.stdin.on("end", () => {
|
|
|
768
769
|
if (why) {
|
|
769
770
|
if (escOn("KJ_ALLOW_NO_CARD")) { recordEscape(sid, "KJ_ALLOW_NO_CARD", tool); process.exit(0); }
|
|
770
771
|
console.error(why === "base"
|
|
771
|
-
? "
|
|
772
|
-
: "
|
|
772
|
+
? "karajan sentinel: no se editan fuentes en la rama base '" + branch + "' — crea la card (kj hu add) y la rama: git checkout -b feat/<CARD-ID>-descripcion. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)" + doc("card-first")
|
|
773
|
+
: "karajan sentinel: la rama '" + branch + "' no referencia ninguna card — crea/mueve la card a running (kj hu add | kj hu move) y usa una rama feat/<CARD-ID>-descripcion. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)" + doc("card-first"));
|
|
773
774
|
process.exit(2);
|
|
774
775
|
}
|
|
775
776
|
}
|
|
@@ -787,7 +788,7 @@ process.stdin.on("end", () => {
|
|
|
787
788
|
if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
|
|
788
789
|
if (escOn("KJ_ALLOW_BOARD")) { recordEscape(sid, "KJ_ALLOW_BOARD", tool); }
|
|
789
790
|
else {
|
|
790
|
-
console.error("
|
|
791
|
+
console.error("karajan sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\\n" + pend.map((p) => "- " + pendingText(p)).join("\\n") + "\\n(KJ_ALLOW_BOARD=1 = excepcion consciente, queda registrada)" + doc("board-sync"));
|
|
791
792
|
process.exit(2);
|
|
792
793
|
}
|
|
793
794
|
}
|
|
@@ -802,7 +803,7 @@ process.stdin.on("end", () => {
|
|
|
802
803
|
const body = bodyFile[2] || bodyFile[3] || bodyFile[4];
|
|
803
804
|
const g = spawnSync("kj", ["claims", "gate", "--transcript", transcript, "--file", body], { cwd: ROOT, encoding: "utf8" });
|
|
804
805
|
if (!g.error && g.status === 2) {
|
|
805
|
-
console.error("
|
|
806
|
+
console.error("karajan sentinel: claims — un dato del cuerpo de la PR esta DESMENTIDO por las salidas de este turno:\\n" + (g.stderr || "").trim() + "\\nVerificalo o marcalo como no comprobado antes de crear la PR. Detalle: kj claims check --transcript " + transcript + " --file " + body + doc("claims"));
|
|
806
807
|
process.exit(2);
|
|
807
808
|
}
|
|
808
809
|
}
|
|
@@ -813,7 +814,7 @@ process.stdin.on("end", () => {
|
|
|
813
814
|
if (res.status !== 0) {
|
|
814
815
|
let items = "";
|
|
815
816
|
try { items = (JSON.parse(res.stdout).checks || []).filter((c) => !c.ok).map((c) => "\\n- " + c.name + ": " + c.detail).join(""); } catch { /* raw output */ }
|
|
816
|
-
console.error("
|
|
817
|
+
console.error("karajan sentinel: release check en ROJO — no se publica ni despliega hasta resolverlo:" + (items || "\\n- corre kj release check para el detalle") + "\\n(KJ_ALLOW_RELEASE=1 = excepcion consciente, queda registrada)" + doc("release"));
|
|
817
818
|
process.exit(2);
|
|
818
819
|
}
|
|
819
820
|
} else if (PUSH.test(cmd)) {
|
|
@@ -827,7 +828,7 @@ process.stdin.on("end", () => {
|
|
|
827
828
|
v = violations({ ...sess, pending_moves: [] }, branchOf());
|
|
828
829
|
}
|
|
829
830
|
if (v.length) {
|
|
830
|
-
console.error("
|
|
831
|
+
console.error("karajan sentinel: git push con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + "\\nResuelve antes de empujar. Estado: kj sentinel status" + doc("push-gate"));
|
|
831
832
|
process.exit(2);
|
|
832
833
|
}
|
|
833
834
|
}
|
|
@@ -40,7 +40,9 @@ export const NO_AI_ATTRIBUTION_WORKFLOW = [
|
|
|
40
40
|
" env:",
|
|
41
41
|
" BASE_REF: ${{ github.base_ref }}",
|
|
42
42
|
" run: |",
|
|
43
|
-
' msgs=$(git log --format=%B "origin/${BASE_REF}...HEAD"
|
|
43
|
+
' if ! msgs=$(git log --format=%B "origin/${BASE_REF}...HEAD"); then',
|
|
44
|
+
' echo "::error::cannot resolve origin/${BASE_REF}...HEAD — the scan did not run"; exit 1',
|
|
45
|
+
" fi",
|
|
44
46
|
" pat='co-authored-by:.*(claude|anthropic|openai|chatgpt|gpt-[0-9]|copilot|gemini|cursor|windsurf|codeium|deepseek)'",
|
|
45
47
|
" pat=\"$pat|(generated|written|authored|assisted|powered) (by|with|using) .*(claude|anthropic|openai|copilot|gemini)\"",
|
|
46
48
|
' if printf "%s" "$msgs" | grep -qiE "$pat"; then',
|
package/src/policy/exceptions.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import { userInfo } from "node:os";
|
|
9
|
+
import { homedir, userInfo } from "node:os";
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
11
|
import { recordPolicyException as kernelRecord } from "@karajan-family/governance";
|
|
12
12
|
|
|
@@ -23,20 +23,25 @@ function defaultIdentity(projectDir) {
|
|
|
23
23
|
* reporta), jamás rompe la evaluación. Las puntuales no dan standing.
|
|
24
24
|
* @returns {{standing: object[], discarded: number}}
|
|
25
25
|
*/
|
|
26
|
-
export function loadStandingExceptions(projectDir) {
|
|
27
|
-
const
|
|
28
|
-
|
|
26
|
+
export function loadStandingExceptions(projectDir, { home } = {}) {
|
|
27
|
+
const proj = loadExceptionRecords(projectDir);
|
|
28
|
+
const glob = loadGlobalExceptionRecords({ home });
|
|
29
|
+
return {
|
|
30
|
+
standing: [...proj.records, ...glob.records].filter((rec) => rec.scopeKind === "permanente"),
|
|
31
|
+
discarded: proj.discarded + glob.discarded,
|
|
32
|
+
};
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* @returns {{records: object[], discarded: number}}
|
|
36
|
+
* Ruta del almacén GLOBAL (~/.karajan): excepciones de la máquina, no de un
|
|
37
|
+
* proyecto. `home` es inyectable SOLO para tests.
|
|
35
38
|
*/
|
|
36
|
-
export
|
|
39
|
+
export const globalExceptionsFile = ({ home } = {}) => join(home ?? homedir(), ".karajan", "policy-exceptions.jsonl");
|
|
40
|
+
|
|
41
|
+
function readRecordsAt(file, origin) {
|
|
37
42
|
let raw;
|
|
38
43
|
try {
|
|
39
|
-
raw = readFileSync(
|
|
44
|
+
raw = readFileSync(file, "utf8");
|
|
40
45
|
} catch {
|
|
41
46
|
return { records: [], discarded: 0 };
|
|
42
47
|
}
|
|
@@ -49,7 +54,9 @@ export function loadExceptionRecords(projectDir) {
|
|
|
49
54
|
// JSON válido pero no-objeto (null, número…) es tan corrupto como el
|
|
50
55
|
// que no parsea: se descarta contando (catch de codex, explícito).
|
|
51
56
|
if (typeof rec !== "object" || rec === null) discarded += 1;
|
|
52
|
-
|
|
57
|
+
// Procedencia FÍSICA (KJC-TSK-0813): el origin es el almacén del que
|
|
58
|
+
// se LEYÓ la línea, pisando lo que la línea declare — no se miente.
|
|
59
|
+
else records.push({ ...rec, origin });
|
|
53
60
|
} catch {
|
|
54
61
|
discarded += 1;
|
|
55
62
|
}
|
|
@@ -57,14 +64,43 @@ export function loadExceptionRecords(projectDir) {
|
|
|
57
64
|
return { records, discarded };
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
/**
|
|
68
|
+
* TODOS los registros del jsonl (permanentes y puntuales) con el mismo parse
|
|
69
|
+
* tolerante — el informe (PL-E) cuenta también las puntuales.
|
|
70
|
+
* @returns {{records: object[], discarded: number}}
|
|
71
|
+
*/
|
|
72
|
+
export function loadExceptionRecords(projectDir) {
|
|
73
|
+
return readRecordsAt(join(projectDir, ".karajan", "policy-exceptions.jsonl"), "project");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @returns {{records: object[], discarded: number}} */
|
|
77
|
+
export function loadGlobalExceptionRecords({ home } = {}) {
|
|
78
|
+
return readRecordsAt(globalExceptionsFile({ home }), "global");
|
|
79
|
+
}
|
|
80
|
+
|
|
60
81
|
function defaultAppend(projectDir, line) {
|
|
61
82
|
const dir = join(projectDir, ".karajan");
|
|
62
83
|
mkdirSync(dir, { recursive: true });
|
|
63
84
|
appendFileSync(join(dir, "policy-exceptions.jsonl"), line, "utf8");
|
|
64
85
|
}
|
|
65
86
|
|
|
87
|
+
const globalAppend = (home) => (_projectDir, line) => {
|
|
88
|
+
const dir = join(home ?? homedir(), ".karajan");
|
|
89
|
+
mkdirSync(dir, { recursive: true });
|
|
90
|
+
appendFileSync(join(dir, "policy-exceptions.jsonl"), line, "utf8");
|
|
91
|
+
};
|
|
92
|
+
|
|
66
93
|
/** @returns {object} the recorded entry (with ts + who resolved). */
|
|
67
|
-
export function recordPolicyException({ projectDir, entry, deps = {} }) {
|
|
68
|
-
|
|
69
|
-
|
|
94
|
+
export function recordPolicyException({ projectDir, entry, deps = {}, scope = "project" }) {
|
|
95
|
+
if (scope !== "project" && scope !== "global") {
|
|
96
|
+
throw new TypeError(`recordPolicyException: scope "${scope}" no existe — solo project o global`);
|
|
97
|
+
}
|
|
98
|
+
// Un global puntual NO existe: lo puntual liga al hash de un artefacto de
|
|
99
|
+
// UN proyecto; el ámbito global solo admite permanentes con caducidad.
|
|
100
|
+
if (scope === "global" && (entry?.scopeKind !== "permanente" || !entry?.expiresAt)) {
|
|
101
|
+
throw new TypeError("recordPolicyException: el ámbito global exige scopeKind permanente con expiresAt — un global puntual no existe");
|
|
102
|
+
}
|
|
103
|
+
const { home, append = scope === "global" ? globalAppend(home) : defaultAppend, identity = defaultIdentity } = deps;
|
|
104
|
+
// El origin escrito es informativo: la carga re-estampa el FÍSICO igualmente.
|
|
105
|
+
return kernelRecord({ projectDir, entry: { ...entry, origin: scope }, deps: { append, identity } });
|
|
70
106
|
}
|