karajan-code 4.25.0 → 4.27.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.25.0",
3
+ "version": "4.27.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.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
- selectedProject = scopedProjectSlug || parts[1] || '';
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="board" title="Kanban board: every HU of the selected project, grouped by status (Pending → Certified → Done). Click an HU card to expand the detail panel.">Board</button>
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
- .project-select {
128
- background: var(--bg-primary);
129
- border: 1px solid var(--border);
130
- color: var(--text-primary);
131
- padding: 6px 12px;
132
- border-radius: var(--radius-sm);
133
- font-family: var(--font-sans);
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
- cursor: pointer;
149
+ margin-right: 8px;
136
150
  }
137
151
 
138
- .project-select:focus {
139
- outline: none;
140
- border-color: var(--accent-purple);
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 All Projects selector, the Sessions tab, 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 .header__nav a[href="/pipeline.html"] {
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. Choose one in the dropdown above and I will draw its HU DAG.'
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. Puedes cambiar de proyecto arriba a la derecha.',
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
- for (const btn of nav.querySelectorAll('.nav-btn')) {
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
- // After the LAST nav button, so the plain row reads Tablero | Proyectos | Más.
122
- advanced.at(-1).after(more);
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
- // - populateProjectSelect() (app.js)
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, dropdown and project picker all show the new value.
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
- await populateProjectSelect();
74
- await renderBoard();
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. Use the project selector in the header to switch later.')}
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
- const projectName = data.name
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;
@@ -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 });
@@ -782,10 +783,13 @@ export function registerMeta(program, { pkgVersion }) {
782
783
  .option("--interactive", "Adopt the kj standard piece by piece (default keeps yours)")
783
784
  .option("--dry-run", "Show what would change without writing")
784
785
  .option("--json", "Emit machine-readable JSON")
786
+ .option("--commit", "Versiona la regeneración del supervisor con procedencia sellada (acto humano, ADR 0009)")
785
787
  .action(async (flags) => {
786
788
  await withConfig(pkgVersion, "harden", flags, async ({ logger }) => {
787
789
  await hardenCommand({
788
790
  profile: flags.profile,
791
+ commitSupervisor: flags.commit === true,
792
+ kjVersion: pkgVersion,
789
793
  config: flags.config !== false,
790
794
  ci: flags.ci !== false,
791
795
  guidelines: flags.guidelines !== false,
@@ -14,6 +14,7 @@ import { resolveTestOnPush } from "../harden/test-on-push.js";
14
14
  import { compareHarden, formatAdvisoryReport } from "../harden/advisory.js";
15
15
  import { interactiveHarden } from "../harden/interactive.js";
16
16
  import { createWizard, isTTY } from "../utils/wizard.js";
17
+ import { commitSupervisorRegeneration } from "../harden/supervisor-commit.js";
17
18
  import { installConfigsForRoots } from "../harden/config-engine.js";
18
19
  import { installGuidelines } from "../harden/guidelines-engine.js";
19
20
  import { commandsForLanguage } from "../harden/hook-commands.js";
@@ -88,6 +89,8 @@ export async function hardenCommand({
88
89
  interactive = false,
89
90
  only = [],
90
91
  exclude = [],
92
+ commitSupervisor = false,
93
+ kjVersion = null,
91
94
  logger = console,
92
95
  } = {}) {
93
96
  const repoCfg = readHardenConfig(projectDir);
@@ -199,5 +202,24 @@ export async function hardenCommand({
199
202
  out.identity = id.declared ? id.identity : null;
200
203
  if (id.pending) out.identityPending = id.pending;
201
204
  }
205
+ // KJC-BUG-0161 / ADR 0009 (opción A): --commit versiona la regeneración
206
+ // del supervisor con procedencia sellada. Acto humano — el guard vive en
207
+ // commitSupervisorRegeneration y rechaza sesiones de agente.
208
+ if (commitSupervisor && !dryRun) {
209
+ try {
210
+ out.supervisorCommit = commitSupervisorRegeneration({
211
+ projectDir,
212
+ kjVersion: kjVersion ?? "unknown",
213
+ generation: result.generation ?? { profile },
214
+ logger,
215
+ });
216
+ } catch (err) {
217
+ // Fallar ALTO (catch de codex): un --commit que no commitea no puede
218
+ // devolver ok — quien lo pidió debe verlo, no descubrirlo después.
219
+ logger.error?.(`kj harden: ${err.message}`);
220
+ out.supervisorCommit = { committed: false, reason: err.message };
221
+ out.ok = false;
222
+ }
223
+ }
202
224
  return out;
203
225
  }
@@ -12,7 +12,8 @@ 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 { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
16
+ import { loadExceptionRecords, loadGlobalExceptionRecords, loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
16
17
  import { buildPolicyReport } from "../policy/report.js";
17
18
  import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
18
19
  import { readIdentity } from "../identity/store.js";
@@ -110,12 +111,16 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
110
111
  // regla re-concedida N veces es la política real pidiendo que la
111
112
  // cambien por su cauce. Se cuenta contra TODAS las permanentes previas
112
113
  // (vencidas incluidas: precisamente esas son la sedimentación).
113
- const previous = loadStandingExceptions(projectDir).standing.filter((e) => e.rule_id === rule).length;
114
+ // KJC-TSK-0813: --global escribe en ~/.karajan (todos los proyectos de
115
+ // la máquina). El contador de renovaciones cuenta sobre la FUSIÓN.
116
+ const scope = flags.global ? "global" : "project";
117
+ const previous = loadStandingExceptions(projectDir, { home: deps.home }).standing.filter((e) => e.rule_id === rule).length;
114
118
  const rec = recordPolicyException({
115
- projectDir,
119
+ projectDir, scope, deps: { home: deps.home },
116
120
  entry: { rule_id: rule, justification: reason.trim(), scopeKind: "permanente", expiresAt: until },
117
121
  });
118
- logger.info?.(`✓ excepción permanente registrada: [${rec.rule_id}] hasta ${rec.expiresAt} — concedida por ${rec.who?.git ?? "?"} (${rec.who?.grade ?? "?"})`);
122
+ const ambit = scope === "global" ? " [ámbito global — todos tus proyectos de esta máquina]" : "";
123
+ logger.info?.(`✓ excepción permanente registrada${ambit}: [${rec.rule_id}] hasta ${rec.expiresAt} — concedida por ${rec.who?.git ?? "?"} (${rec.who?.grade ?? "?"})`);
119
124
  if (previous >= 1) {
120
125
  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
126
  }
@@ -134,7 +139,11 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
134
139
  try {
135
140
  decisionLines = readFileSync(join(projectDir, ".karajan", "policy-decisions.jsonl"), "utf8").split("\n").filter((l) => l.trim());
136
141
  } catch { /* sin decisiones aún: el informe lo dice con ceros */ }
137
- const exc = loadExceptionRecords(projectDir);
142
+ // KJC-TSK-0813 (AC3): el informe cubre TAMBIÉN el almacén global — cada
143
+ // registro llega con su origin FÍSICO estampado por la carga.
144
+ const proj = loadExceptionRecords(projectDir);
145
+ const glob = loadGlobalExceptionRecords({ home: deps.home });
146
+ const exc = { records: [...proj.records, ...glob.records], discarded: proj.discarded + glob.discarded };
138
147
  const soonDays = Number(flags.soon ?? 7);
139
148
  const report = buildPolicyReport({ decisionLines, exceptionRecords: exc.records, policy, soonDays: Number.isFinite(soonDays) ? soonDays : 7 });
140
149
  if (flags.json) {
@@ -152,8 +161,10 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
152
161
  const meta = [r.enforcement && `enforcement=${r.enforcement}`, r.class && `class=${r.class}`].filter(Boolean).join(" ");
153
162
  logger.info?.(` [${r.rule_id}] ${meta} — warn ${r.warns} · deny ${r.denies} · exempt ${r.exempts} · abiertas ${r.open}`);
154
163
  }
155
- logger.info?.(`concesiones: vivas ${g.alive.length} (próximas a vencer ${g.soon.length}) · vencidas ${g.expired.length} · puntuales ${g.point}`);
156
- for (const e of g.alive) logger.info?.(` [${e.rule_id}] hasta ${e.expiresAt} — ${e.who?.git ?? "?"}: ${e.justification ?? "sin justificación"}`);
164
+ // Con 0 globales el resumen queda EXACTAMENTE como siempre (hay consumidores de texto).
165
+ const globals = g.alive.filter((e) => e.origin === "global").length;
166
+ 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}`);
167
+ 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
168
  logger.info?.(report.signals.length > 0 ? "señales:" : "señales: ninguna");
158
169
  for (const s of report.signals) logger.warn?.(` ⚠ ${s}`);
159
170
  return chain.ok ? 0 : 1;
@@ -221,7 +232,14 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
221
232
  // --strict (PL-C): con --strict, una violación enforcement=deny devuelve
222
233
  // exit 2 nombrando la regla — el contrato merge-blocking del tier C.
223
234
  const facts = await stagedFacts(projectDir, deps.gitFn, flags.range || null);
224
- const violations = checkStagedDiff(policy, { role: flags.role || "coder", ...facts });
235
+ let violations = checkStagedDiff(policy, { role: flags.role || "coder", ...facts });
236
+ // ADR 0009 (KJC-BUG-0161): en CI la misma exención estructural que en la
237
+ // review — supervisor respaldado por provenance sellada + render canónico.
238
+ const sup = liftSealedSupervisorViolations({ projectDir, violations });
239
+ if (sup.lifted > 0) {
240
+ logger.info?.(`✓ policy: ${sup.lifted} fichero(s) de supervisor verificados contra la provenance sellada (ADR 0009)`);
241
+ violations = sup.violations;
242
+ }
225
243
  const hard = flags.strict ? violations.filter((v) => v.enforcement === "deny") : [];
226
244
  if (flags.json) {
227
245
  logger.info?.(JSON.stringify({ mode: flags.strict ? "strict" : "warn", violations }));
@@ -15,6 +15,7 @@ import { ensureGateTrackable } from "../review/gate-gitignore.js";
15
15
  import { runSonarPregate, formatSonarFinding, addedLinesByFile } from "../review/sonar-pregate.js";
16
16
  import { runMutationPregate, formatSurvivor } from "../review/mutation-pregate.js";
17
17
  import { checkCardFirst } from "../review/card-first.js";
18
+ import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
18
19
  import { checkTestsWithCode } from "../review/tests-with-code.js";
19
20
  import { loadPrivacyList, scanText } from "../privacy/scan.js";
20
21
  import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
@@ -52,6 +53,13 @@ async function rawDiff(range, extraArgs = []) {
52
53
  return res.stdout;
53
54
  }
54
55
 
56
+ // KJC-TSK-0813 (AC3): la exención dice su PROCEDENCIA — un standing global
57
+ // (concedido para toda la máquina) no pasa por uno del proyecto. El texto
58
+ // de las de proyecto queda EXACTAMENTE como estaba.
59
+ export const formatExempted = (e) => (e.standing
60
+ ? `⚠ policy standing [${e.rule_id}] — excepción permanente${e.standing.origin === "global" ? " GLOBAL" : ""} viva hasta ${e.standing.expiresAt} (${e.standing.justification || "sin justificación"})`
61
+ : `⚠ policy exempt [${e.rule_id}] — excepción registrada en .karajan/policy-exceptions.jsonl (${e.justification})`);
62
+
55
63
  function printVerdict(record) {
56
64
  if (record.verdict === "approved") {
57
65
  const ws = record.workspace ? ` [${record.workspace}]` : "";
@@ -255,13 +263,20 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
255
263
  recordException: (entry) => recordPolicyException({ projectDir, entry }),
256
264
  standingExceptions: std.standing,
257
265
  });
266
+ // ADR 0009 (KJC-BUG-0161): la exención ESTRUCTURAL del supervisor — un
267
+ // diff de .karajan/hooks respaldado por la provenance sellada Y el render
268
+ // canónico no es una edición: es la regeneración de kj harden --commit.
269
+ {
270
+ const res = liftSealedSupervisorViolations({ projectDir, violations: gate.denials });
271
+ if (res.lifted > 0) {
272
+ console.log(`✓ policy: ${res.lifted} fichero(s) de supervisor verificados contra la provenance sellada (ADR 0009) — regeneración, no edición`);
273
+ gate.denials = res.violations;
274
+ gate.ok = gate.denials.length === 0 && !gate.invalid;
275
+ }
276
+ }
258
277
  const at = (v) => (v.file ? ` (${v.file})` : "");
259
278
  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
- }
279
+ for (const e of gate.exempted) console.log(formatExempted(e));
265
280
  // GOV-C (KJC-TSK-0747): las decisiones de chokepoint dejan rastro
266
281
  // hash-encadenado — cada deny, cada excepción, y el allow del commit.
267
282
  const chokepoint = flags.check ? "commit" : "review";
@@ -22,6 +22,24 @@ export function isGitRepo(projectDir) {
22
22
  return existsSync(join(projectDir, ".git"));
23
23
  }
24
24
 
25
+ /**
26
+ * KJC-BUG-0161 / ADR 0009: el contenido CANÓNICO de un hook para una
27
+ * generación dada — exactamente lo que installHooks escribiría partiendo
28
+ * de cero. La verificación estructural del supervisor recomputa esto y
29
+ * compara por hash: un fichero con una sola coma manual ya no coincide.
30
+ */
31
+ export function renderCanonicalHook(hook, generation = {}) {
32
+ const { cmds = {}, baseBranch = null, globalHooksDir = null } = generation;
33
+ const { content } = upsertManagedBlock({
34
+ source: HOOK_PREAMBLE,
35
+ blockId: `hook:${hook}`,
36
+ version: BLOCK_VERSION,
37
+ body: hookBody(hook, cmds, { globalHooksDir, baseBranch }),
38
+ style: "hash",
39
+ });
40
+ return content;
41
+ }
42
+
25
43
  /**
26
44
  * Install (or refresh) the profile's hooks under `.karajan/hooks/` and point
27
45
  * `core.hooksPath` there. Idempotent. `dryRun` computes actions without
@@ -76,5 +94,7 @@ export async function installHooks({
76
94
  await runCommand("git", ["config", "core.hooksPath", HOOKS_DIR], { cwd: projectDir });
77
95
  }
78
96
 
79
- return { profile, hooksPath: HOOKS_DIR, dryRun, hooks: results };
97
+ // KJC-BUG-0161: los parámetros con los que SE GENERÓ — lo que la
98
+ // provenance necesita registrar para que CI pueda recomputar y comparar.
99
+ return { profile, hooksPath: HOOKS_DIR, dryRun, hooks: results, generation: { profile, cmds, baseBranch, globalHooksDir } };
80
100
  }