karajan-code 4.21.0 → 4.22.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 CHANGED
@@ -9,8 +9,8 @@
9
9
  </p>
10
10
 
11
11
  <p align="center">
12
- <a href="https://www.npmjs.com/package/karajan-code"><img src="https://img.shields.io/npm/v/karajan-code.svg" alt="npm version"></a>
13
- <a href="https://www.npmjs.com/package/karajan-code"><img src="https://img.shields.io/npm/dw/karajan-code.svg" alt="npm downloads"></a>
12
+ <a href="https://www.npmjs.com/package/@karajan-family/code"><img src="https://img.shields.io/npm/v/%40karajan-family%2Fcode.svg" alt="npm version"></a>
13
+ <a href="https://www.npmjs.com/package/@karajan-family/code"><img src="https://img.shields.io/npm/dw/%40karajan-family%2Fcode.svg" alt="npm downloads"></a>
14
14
  <a href="https://github.com/manufosela/karajan-code/actions"><img src="https://github.com/manufosela/karajan-code/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
15
15
  <a href="https://www.gnu.org/licenses/agpl-3.0"><img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License"></a>
16
16
  <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg" alt="Node.js"></a>
@@ -58,7 +58,7 @@ kj init && kj env install && kj harden && kj review --install-gate
58
58
  git config core.hooksPath .karajan/hooks
59
59
  ```
60
60
 
61
- Requires git and at least one AI agent CLI — two enables cross-AI review; three enables arbitration. All install routes (npm, binaries, brew, Python wrapper) in the [install docs](https://karajancode.com/docs/v4/install/).
61
+ 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/).
62
62
 
63
63
  ## The daily loop
64
64
 
@@ -85,6 +85,8 @@ With a declared `.karajan/policy.yml`, the policy layer enforces at three tiers,
85
85
 
86
86
  Tier B is the guarantee floor — hosts without hooks lose A, never B; C re-verifies both. Security-class rules and consumer defaults are non-exemptable at every tier: no escape, no arbitration, no grant.
87
87
 
88
+ Every chokepoint decision lands in a hash-chained log (`kj policy anchor` seals its head in git), and `kj policy report` turns that log into evidence of process: per rule, how often it warned, denied or was exempted, which denials are still open, which grants are alive, expired or renewed — so a rule "gains teeth" on data, not on a hunch, and a renewed exception is read for what it is: the policy asking to change.
89
+
88
90
  ## Headless mode
89
91
 
90
92
  The classic multiagent pipeline lives on for CI and automation: `kj run "<task>"` orchestrates coder/reviewer/tester subprocess roles unattended, with the same gates. Agents and CI pass `--non-interactive` (or `KJ_NON_INTERACTIVE=1`): safe gates auto-answer, FAIL findings stop the run with a real exit code. `kj advanced` lists the full surface. [Headless mode docs](https://karajancode.com/docs/v4/headless/).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.21.0",
3
+ "version": "4.22.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",
@@ -38,7 +38,8 @@
38
38
  "packages/ai-trash",
39
39
  "packages/core",
40
40
  "packages/hu-board",
41
- "packages/governance"
41
+ "packages/governance",
42
+ "packages/console"
42
43
  ],
43
44
  "imports": {
44
45
  "#utils/*": "./src/utils/*",
@@ -122,6 +123,7 @@
122
123
  },
123
124
  "devDependencies": {
124
125
  "@eslint/js": "^10.0.1",
126
+ "smol-toml": "^1.7.0",
125
127
  "@vitest/coverage-v8": "^4.1.8",
126
128
  "esbuild": "^0.28.0",
127
129
  "eslint": "^10.4.1",
@@ -192,6 +192,7 @@ function render() {
192
192
  case 'board': return renderBoard();
193
193
  case 'sessions': return renderSessions();
194
194
  case 'graph': return renderGraph();
195
+ case 'governance': return renderGovernance();
195
196
  default: return renderDashboard();
196
197
  }
197
198
  }
@@ -24,6 +24,7 @@
24
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
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
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>
27
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>
28
29
  <a class="nav-btn" href="/rag.html" title="RAG retrieval-quality dashboard: chunk counts, embedder provider, db size.">RAG</a>
29
30
  <a class="nav-btn" href="/wiki.html" title="Search the per-project QMD semantic wiki (docs, plans, reviews).">Wiki</a>
@@ -59,6 +60,7 @@
59
60
  <script src="/utils/board-view.js"></script>
60
61
  <script src="/utils/dashboard-view.js"></script>
61
62
  <script src="/utils/graph-view.js"></script>
63
+ <script src="/utils/governance-view.js"></script>
62
64
  <script src="/utils/project-picker-view.js"></script>
63
65
  <script src="/utils/story-detail-view.js"></script>
64
66
  <script src="/utils/preflight-view.js"></script>
@@ -826,6 +826,28 @@ a:hover {
826
826
  }
827
827
 
828
828
  /* ---- Empty State ---- */
829
+ /* GUI-B (KJC-TSK-0772) — governance view */
830
+ .gov-dir { display: flex; gap: 0.5rem; align-items: center; }
831
+ .gov-dir__input { min-width: 18rem; padding: 0.3rem 0.5rem; font-family: inherit; }
832
+ .gov-btn { padding: 0.3rem 0.75rem; cursor: pointer; }
833
+ .gov-note { margin: 0.5rem 0; opacity: 0.85; }
834
+ .gov-note--warn { color: #d9a400; }
835
+ .gov-red { color: #e5484d; }
836
+ .gov-small { font-size: 1.1rem; line-height: 1.4; }
837
+ .gov-scroll { overflow-x: auto; max-width: 100%; }
838
+ .gov-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
839
+ .gov-table th, .gov-table td { padding: 0.4rem 0.6rem; text-align: left; border-bottom: 1px solid rgba(128, 128, 128, 0.25); white-space: nowrap; }
840
+ .gov-badge { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 999px; font-size: 0.75rem; border: 1px solid currentColor; }
841
+ .gov-badge--deny { color: #e5484d; }
842
+ .gov-badge--warn { color: #d9a400; }
843
+ .gov-badge--security { color: #b44dff; }
844
+ .gov-row--soon td { background: rgba(217, 164, 0, 0.12); }
845
+ .gov-list { margin: 0.5rem 0 1rem 1.25rem; }
846
+ .gov-grant { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin: 0.75rem 0; }
847
+ .gov-grant select, .gov-grant input { padding: 0.3rem 0.5rem; font-family: inherit; }
848
+ .gov-pre { white-space: pre-wrap; font-size: 0.85rem; margin: 0 0 1rem; max-height: 18rem; overflow: auto; }
849
+ .gov-pre:empty { display: none; }
850
+
829
851
  .empty-state {
830
852
  text-align: center;
831
853
  padding: 60px 20px;
@@ -0,0 +1,149 @@
1
+ // GUI-B (KJC-TSK-0772) — Governance view: the "acta". A live exception is
2
+ // invisible; here every rule shows its friction, every grant its owner and
3
+ // expiry, every renewal its count. Read-only (actions land in GUI-C).
4
+ // Classic script (no exports), same conventions as dashboard-view.js:
5
+ // api() from utils/api.js, esc() from formatters.js.
6
+
7
+ const GOV_DIR_KEY = 'kj.governance.dir';
8
+ let govBusy = false;
9
+
10
+ async function renderGovernance() {
11
+ const app = document.getElementById('app');
12
+ // Server-push re-renders the current view on every event; never wipe a
13
+ // form the user is typing in or a proposal still being translated.
14
+ const typing = () => { const a = document.activeElement; return govBusy || Boolean(a && a.id && a.id.startsWith('gov-') && app.contains(a)); };
15
+ if (typing()) return;
16
+ const dir = localStorage.getItem(GOV_DIR_KEY) || '';
17
+ app.innerHTML = '<div class="loading"><div class="loading__spinner"></div><p>Loading governance...</p></div>';
18
+ try {
19
+ const data = await api('/api/governance' + (dir ? '?dir=' + encodeURIComponent(dir) : ''));
20
+ if (typing()) return; // the user started typing while we fetched: keep their form
21
+ if (!data || data.ok === false) {
22
+ app.innerHTML = govDirForm((data && data.dir) || dir) + `<div class="empty-state"><div class="empty-state__title">Governance unavailable</div><div class="empty-state__text">${esc((data && data.error) || 'governance unavailable')}</div><div class="empty-state__path">Type the absolute directory of a project that ran <code>kj harden</code> and press Load.</div></div>`;
23
+ return;
24
+ }
25
+ localStorage.setItem(GOV_DIR_KEY, data.dir);
26
+ app.innerHTML = govDirForm(data.dir) + govIdentity(data.identity) + govChain(data.report, data.anchor) + govRules(data.policy, data.report) + govGrants(data.report.grants, data.policy) + govSignals(data.report.signals);
27
+ } catch (err) {
28
+ app.innerHTML = govDirForm(dir) + `<div class="empty-state"><div class="empty-state__title">Governance unavailable</div><div class="empty-state__text">${esc(err.message)}</div></div>`;
29
+ }
30
+ }
31
+
32
+ function govSetDir() {
33
+ localStorage.setItem(GOV_DIR_KEY, document.getElementById('gov-dir').value.trim());
34
+ renderGovernance();
35
+ }
36
+
37
+ // GUI-C (KJC-TSK-0773): actions call the same CLI commands through the board.
38
+ async function govPost(path, body) {
39
+ const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dir: localStorage.getItem(GOV_DIR_KEY) || '', ...body }) });
40
+ const data = await res.json().catch(() => ({ ok: false, error: `API error: ${res.status}` }));
41
+ return data;
42
+ }
43
+
44
+ function govSay(text, warn) {
45
+ const el = document.getElementById('gov-msg');
46
+ if (el) { el.textContent = text; el.className = warn ? 'gov-note gov-note--warn' : 'gov-note'; }
47
+ }
48
+
49
+ // Spoken rule: propose shows the diff kj would write; apply asks first, then --yes.
50
+ async function govRule(apply) {
51
+ const text = document.getElementById('gov-rule-text').value.trim();
52
+ const out = document.getElementById('gov-rule-out');
53
+ if (!text) return govSay('say the rule first', true);
54
+ if (apply && !(await showConfirm(`Write this rule into .karajan/policy.yml?\n\n${text}\n\nThe diff shown below is what lands.`, { title: 'Apply rule', okLabel: 'Apply' }))) return;
55
+ govRuleText = text;
56
+ govRuleOut = apply ? 'applying…' : 'translating…';
57
+ out.textContent = govRuleOut;
58
+ govBusy = true;
59
+ const r = await govPost('/api/governance/rule', { text, apply: apply === true }).finally(() => { govBusy = false; });
60
+ govRuleOut = r.output || r.error || '';
61
+ const pre = document.getElementById('gov-rule-out');
62
+ if (pre) pre.textContent = govRuleOut;
63
+ if (!r.ok) return govSay(r.error || 'rule refused', true);
64
+ if (apply) { govRuleText = ''; renderGovernance(); }
65
+ }
66
+
67
+ async function govAnchor() {
68
+ const r = await govPost('/api/governance/anchor', {});
69
+ if (!r.ok) return govSay(r.error || 'anchor failed', true);
70
+ renderGovernance();
71
+ }
72
+
73
+ async function govGrant() {
74
+ const rule = document.getElementById('gov-grant-rule').value.trim();
75
+ const until = document.getElementById('gov-grant-until').value;
76
+ const reason = document.getElementById('gov-grant-reason').value.trim();
77
+ if (!rule || !until || !reason) return govSay('rule, until and reason are required — an exception without who/why/until is a hole', true);
78
+ const iso = new Date(`${until}T23:59:59Z`).toISOString();
79
+ if (!(await showConfirm(`Grant a standing exception to ${rule} until ${iso}?\n\n${reason}\n\nIt is recorded with this clone's declared identity and expires on its own.`, { title: 'Grant exception', okLabel: 'Grant' }))) return;
80
+ const r = await govPost('/api/governance/grant', { rule, until: iso, reason });
81
+ if (!r.ok) return govSay(r.error || 'grant refused', true);
82
+ renderGovernance();
83
+ }
84
+
85
+ const govDirForm = (dir) => `
86
+ <div class="section-header"><span class="section-header__title">Governance</span>
87
+ <span class="gov-dir"><input id="gov-dir" class="gov-dir__input" value="${esc(dir)}" placeholder="/absolute/project/dir" aria-label="Project directory">
88
+ <button class="gov-btn" onclick="govSetDir()">Load</button></span></div>`;
89
+
90
+ const govIdentity = (id) => id && id.declared
91
+ ? `<p class="gov-note">Identity declared for this clone: <strong>${esc(id.gh_user)}</strong> · ${esc(id.git_email)}</p>`
92
+ : '<p class="gov-note gov-note--warn">Identity NOT declared for this clone — <code>kj identity set</code> (the Sentinel fails closed on gh and mutating git until then).</p>';
93
+
94
+ function govChain(report, anchor) {
95
+ const d = report.decisions;
96
+ const chain = report.chain.ok
97
+ ? `<div class="stat-card__value stat-card__value--green">intact</div><div class="stat-card__label">chain · ${report.chain.length} decisions</div>`
98
+ : `<div class="stat-card__value gov-red">BROKEN</div><div class="stat-card__label">at entry ${esc(String(report.chain.at))} — ${esc(report.chain.reason || '')}</div>`;
99
+ const anchorBtn = report.chain.ok && (anchor.stale || !anchor.sealed) ? ' <button class="gov-btn" onclick="govAnchor()">Anchor now</button>' : '';
100
+ const anch = !anchor.sealed
101
+ ? `<div class="stat-card__value stat-card__value--yellow">none</div><div class="stat-card__label">anchor${anchorBtn}</div>`
102
+ : `<div class="stat-card__value ${anchor.stale ? 'stat-card__value--yellow' : 'stat-card__value--green'}">${anchor.length}/${anchor.current}</div><div class="stat-card__label">anchored · ${anchor.stale ? 're-seal pending' : 'up to date'}${anchorBtn}</div>`;
103
+ const cps = Object.entries(d.chokepoints || {}).map(([k, v]) => `${esc(k)} ${v}`).join(' · ') || 'none';
104
+ return `<div class="stats-grid">
105
+ <div class="stat-card">${chain}</div><div class="stat-card">${anch}</div>
106
+ <div class="stat-card"><div class="stat-card__value">${d.allow}</div><div class="stat-card__label">allow</div></div>
107
+ <div class="stat-card"><div class="stat-card__value ${d.deny ? 'gov-red' : ''}">${d.deny}</div><div class="stat-card__label">deny · ${d.open} open</div></div>
108
+ <div class="stat-card"><div class="stat-card__value stat-card__value--purple">${d.exempt}</div><div class="stat-card__label">exempt</div></div>
109
+ <div class="stat-card"><div class="stat-card__value gov-small">${esc(cps)}</div><div class="stat-card__label">chokepoints</div></div></div>`;
110
+ }
111
+
112
+ function govRules(policy, report) {
113
+ const rows = new Map();
114
+ for (const r of policy.rules || []) rows.set(r.rule_id, { ...r, warns: 0, denies: 0, exempts: 0, open: 0 });
115
+ for (const i of policy.invariants || []) rows.set(i.id, { rule_id: i.id, enforcement: i.enforcement, class: null, warns: 0, denies: 0, exempts: 0, open: 0 });
116
+ for (const r of report.rules || []) rows.set(r.rule_id, { ...(rows.get(r.rule_id) || {}), ...r });
117
+ const head = policy.declared
118
+ ? (policy.error ? `<p class="gov-note gov-note--warn">policy.yml invalid: ${esc(policy.error)}</p>` : '')
119
+ : '<p class="gov-note">No <code>.karajan/policy.yml</code> declared — only the consumer defaults apply. Speak a rule: <code>kj policy add "…"</code>.</p>';
120
+ if (rows.size === 0) return `<div class="section-header"><span class="section-header__title">Rules</span></div>${head}<p class="gov-note">No rule has warned or denied yet.</p>${govRuleBox()}`;
121
+ const body = [...rows.values()].sort((a, b) => (b.denies + b.warns) - (a.denies + a.warns)).map((r) => `
122
+ <tr><td><code>${esc(r.rule_id)}</code></td><td><span class="gov-badge gov-badge--${esc(r.enforcement || 'warn')}">${esc(r.enforcement || 'warn')}</span>${r.class === 'security' ? ' <span class="gov-badge gov-badge--security" title="non-exemptable: no escape, no arbitration, no grant">security</span>' : ''}</td>
123
+ <td>${r.warns}</td><td class="${r.denies ? 'gov-red' : ''}">${r.denies}</td><td>${r.exempts}</td><td class="${r.open ? 'gov-red' : ''}">${r.open}</td></tr>`).join('');
124
+ return `<div class="section-header"><span class="section-header__title">Rules by friction</span><span class="section-header__count">${rows.size}</span></div>${head}
125
+ <div class="gov-scroll"><table class="gov-table"><thead><tr><th>rule</th><th>enforcement</th><th>warn</th><th>deny</th><th>exempt</th><th>open</th></tr></thead><tbody>${body}</tbody></table></div>${govRuleBox()}`;
126
+ }
127
+
128
+ // Typed text and the last proposal survive the server-push re-renders.
129
+ let govRuleText = '';
130
+ let govRuleOut = '';
131
+ const govRuleBox = () => `<div class="gov-grant"><input id="gov-rule-text" class="gov-dir__input" value="${esc(govRuleText)}" oninput="govRuleText=this.value" placeholder="say a rule: the coder never writes .env files" aria-label="Spoken rule">
132
+ <button class="gov-btn" onclick="govRule(false)">Propose</button><button class="gov-btn" onclick="govRule(true)">Apply</button></div><pre id="gov-rule-out" class="gov-pre" aria-live="polite">${esc(govRuleOut)}</pre>`;
133
+
134
+ function govGrants(g, policy) {
135
+ const soon = new Set((g.soon || []).map((e) => e.ts || e.expiresAt + e.rule_id));
136
+ const row = (e) => `<tr class="${soon.has(e.ts || e.expiresAt + e.rule_id) ? 'gov-row--soon' : ''}"><td><code>${esc(e.rule_id)}</code></td><td>${esc(e.expiresAt || '')}</td><td>${esc((e.who && e.who.git) || '?')}</td><td>${esc(e.justification || '')}</td></tr>`;
137
+ const alive = (g.alive || []).length
138
+ ? `<div class="gov-scroll"><table class="gov-table"><thead><tr><th>rule</th><th>until</th><th>granted by</th><th>why</th></tr></thead><tbody>${g.alive.map(row).join('')}</tbody></table></div>`
139
+ : '<p class="gov-note">No standing exception alive.</p>';
140
+ const renewals = (g.renewals || []).map((r) => `<li class="gov-red"><code>${esc(r.rule_id)}</code> granted ${r.count} times — a renewed exception is the policy asking to change</li>`).join('');
141
+ // Only non-security rules can be granted: the inexemptable never gets a form.
142
+ const grantable = (policy.rules || []).filter((r) => r.class !== 'security').map((r) => `<option value="${esc(r.rule_id)}">${esc(r.rule_id)}</option>`).join('');
143
+ const form = grantable
144
+ ? `<div class="gov-grant"><select id="gov-grant-rule" aria-label="Rule">${grantable}</select><input id="gov-grant-until" type="date" aria-label="Until"><input id="gov-grant-reason" class="gov-dir__input" placeholder="why, written now" aria-label="Reason"><button class="gov-btn" onclick="govGrant()">Grant until</button></div>`
145
+ : '<p class="gov-note">Nothing grantable: no declared non-security rule.</p>';
146
+ return `<div class="section-header"><span class="section-header__title">Standing exceptions</span><span class="section-header__count">${(g.alive || []).length} alive · ${(g.soon || []).length} expiring · ${(g.expired || []).length} expired · ${g.point || 0} one-off</span></div>${alive}${renewals ? `<ul class="gov-list">${renewals}</ul>` : ''}${form}<p id="gov-msg" class="gov-note"></p>`;
147
+ }
148
+
149
+ const govSignals = (signals) => `<div class="section-header"><span class="section-header__title">Signals</span></div>${(signals || []).length ? `<ul class="gov-list">${signals.map((s) => `<li>⚠ ${esc(s)}</li>`).join('')}</ul>` : '<p class="gov-note">None.</p>'}`;
@@ -0,0 +1,140 @@
1
+ // GUI-A (KJC-TSK-0771, epic KJC-PCS-0076) — governance snapshot of ONE
2
+ // project for the dashboard: the declared policy, the deterministic report
3
+ // (`kj policy report --json`), the anchor state and the clone's declared
4
+ // identity. The board stays decoupled from the CLI tree (KJC-TSK-0632): the
5
+ // report comes from the kj binary, everything else is read from the
6
+ // project's .karajan/ files. Read-only; actions land in GUI-C.
7
+ import { Router } from "express";
8
+ import { existsSync, readFileSync, statSync } from "node:fs";
9
+ import { join, resolve } from "node:path";
10
+ import yaml from "js-yaml";
11
+ import { runCommand } from "karajan-core/process";
12
+
13
+ const router = Router();
14
+
15
+ const readYaml = (file) => {
16
+ if (!existsSync(file)) return { found: false, data: null, error: null };
17
+ try { return { found: true, data: yaml.load(readFileSync(file, "utf8")), error: null }; }
18
+ catch (err) { return { found: true, data: null, error: String(err.message || err) }; }
19
+ };
20
+
21
+ /** policy.yml → flat rules the view can list (rule ids match the engine's). */
22
+ function policySummary(dir) {
23
+ const { found, data, error } = readYaml(join(dir, ".karajan", "policy.yml"));
24
+ if (!found) return { declared: false, rules: [], invariants: [], error: null };
25
+ if (error || !data || typeof data !== "object") return { declared: true, rules: [], invariants: [], error: error || "policy.yml is not a mapping" };
26
+ const rules = [];
27
+ for (const [role, caps] of Object.entries(data.roles || {})) {
28
+ for (const [cap, spec] of Object.entries(caps || {})) {
29
+ if (!spec || typeof spec !== "object") continue;
30
+ for (const kind of ["deny", "allow"]) {
31
+ if (Array.isArray(spec[kind])) rules.push({ rule_id: `roles.${role}.${cap}.${kind}`, role, cap, kind, patterns: spec[kind], enforcement: spec.enforcement || "warn", class: spec.class || null });
32
+ }
33
+ }
34
+ }
35
+ const invariants = (Array.isArray(data.invariants) ? data.invariants : []).map((i) => ({ ...i, enforcement: i?.enforcement || "warn" }));
36
+ return { declared: true, version: data.version ?? null, rules, invariants, error: null };
37
+ }
38
+
39
+ function anchorState(dir, chainLength) {
40
+ const file = join(dir, ".karajan", "policy-anchor.json");
41
+ if (!existsSync(file)) return { sealed: false, length: 0, current: chainLength, stale: chainLength > 0 };
42
+ try {
43
+ const a = JSON.parse(readFileSync(file, "utf8"));
44
+ const length = Number(a.length) || 0;
45
+ return { sealed: true, head: a.head ?? null, ts: a.ts ?? null, length, current: chainLength, stale: chainLength !== length };
46
+ } catch (err) { return { sealed: false, length: 0, current: chainLength, stale: true, error: String(err.message || err) }; }
47
+ }
48
+
49
+ function identityState(dir) {
50
+ const { found, data } = readYaml(join(dir, ".karajan", "identity.local.yml"));
51
+ const ok = found && data && typeof data === "object" && data.gh_user && data.git_email;
52
+ return ok ? { declared: true, gh_user: String(data.gh_user), git_email: String(data.git_email) } : { declared: false };
53
+ }
54
+
55
+ /** The board may run from a package inside a monorepo: climb to the nearest .karajan/. */
56
+ function nearestProject(start) {
57
+ let cur = resolve(start);
58
+ for (;;) {
59
+ if (existsSync(join(cur, ".karajan"))) return cur;
60
+ const up = resolve(cur, "..");
61
+ if (up === cur) return resolve(start);
62
+ cur = up;
63
+ }
64
+ }
65
+
66
+ router.get("/", async (req, res) => {
67
+ // GUI-B: without dir, the project the board was started for (same rule as
68
+ // the config editor: KJ_PROJECT_DIR || cwd) — the view remembers the rest.
69
+ const raw = (typeof req.query.dir === "string" && req.query.dir.trim()) || nearestProject(process.env.KJ_PROJECT_DIR || process.cwd());
70
+ const dir = resolve(raw);
71
+ if (!existsSync(join(dir, ".karajan")) || !statSync(join(dir, ".karajan")).isDirectory()) {
72
+ // Data, not a server error: the view shows the attempted dir and lets the user fix it.
73
+ return res.json({ ok: false, error: "not a karajan project: no .karajan/ directory", dir });
74
+ }
75
+ let report;
76
+ try {
77
+ // exit 1 = broken chain: the JSON still carries chain.ok=false — data, not a server error.
78
+ const r = await runCommand("kj", ["policy", "report", "--json"], { cwd: dir });
79
+ if (r.exitCode === 127) return res.status(503).json({ ok: false, error: "kj not installed", installable: true });
80
+ const line = String(r.stdout || "").trim().split("\n").findLast((l) => l.startsWith("{"));
81
+ report = line ? JSON.parse(line) : null;
82
+ if (!report) return res.status(502).json({ ok: false, error: "kj policy report produced no JSON", exitCode: r.exitCode, stderr: String(r.stderr || "").slice(0, 500) });
83
+ } catch (err) {
84
+ if (err?.code === "ENOENT") return res.status(503).json({ ok: false, error: "kj not installed", installable: true });
85
+ return res.status(500).json({ ok: false, error: String(err.message || err) });
86
+ }
87
+ res.json({ ok: true, dir, policy: policySummary(dir), report, anchor: anchorState(dir, report.chain?.length ?? 0), identity: identityState(dir) });
88
+ });
89
+
90
+ // GUI-C (KJC-TSK-0773): actions go through the SAME CLI commands the
91
+ // terminal uses — the inexemptable (security, defaults.*) is refused by kj
92
+ // itself and its message travels back verbatim; nothing is re-implemented.
93
+ const projectOf = (req) => {
94
+ const raw = (typeof req.body?.dir === "string" && req.body.dir.trim()) || nearestProject(process.env.KJ_PROJECT_DIR || process.cwd());
95
+ const dir = resolve(raw);
96
+ return existsSync(join(dir, ".karajan")) ? dir : null;
97
+ };
98
+
99
+ const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
100
+
101
+ async function runKj(res, dir, args) {
102
+ try {
103
+ const r = await runCommand("kj", args, { cwd: dir });
104
+ // kj logs with colours; the board shows text (ESC built by code: no control char in a regex literal).
105
+ const output = `${r.stdout || ""}${r.stderr || ""}`.replaceAll(ANSI, "").trim();
106
+ if (r.exitCode === 127) return res.status(503).json({ ok: false, error: "kj not installed", installable: true });
107
+ if (r.exitCode !== 0) return res.status(409).json({ ok: false, error: output || `kj exited ${r.exitCode}`, exitCode: r.exitCode });
108
+ return res.json({ ok: true, output });
109
+ } catch (err) {
110
+ if (err?.code === "ENOENT") return res.status(503).json({ ok: false, error: "kj not installed", installable: true });
111
+ return res.status(500).json({ ok: false, error: String(err.message || err) });
112
+ }
113
+ }
114
+
115
+ router.post("/grant", async (req, res) => {
116
+ const dir = projectOf(req);
117
+ if (!dir) return res.status(404).json({ ok: false, error: "not a karajan project" });
118
+ const { rule, until, reason } = req.body || {};
119
+ if (!rule || !until || !String(reason || "").trim()) return res.status(400).json({ ok: false, error: "rule, until (ISO) and reason are required — an exception without who/why/until is a hole" });
120
+ if (!identityState(dir).declared) return res.status(409).json({ ok: false, error: "identity not declared for this clone — run kj identity set first (the grant must be attributable)" });
121
+ return runKj(res, dir, ["policy", "grant", "--rule", String(rule), "--until", String(until), "--reason", String(reason).trim()]);
122
+ });
123
+
124
+ // Spoken rule: `kj policy add <text>` proposes the diff; ONLY apply:true adds
125
+ // --yes. The engine validates the vocabulary; the human confirms in the board.
126
+ router.post("/rule", async (req, res) => {
127
+ const dir = projectOf(req);
128
+ if (!dir) return res.status(404).json({ ok: false, error: "not a karajan project" });
129
+ const text = String(req.body?.text || "").trim();
130
+ if (!text) return res.status(400).json({ ok: false, error: "text is required — say the rule" });
131
+ return runKj(res, dir, ["policy", "add", text, ...(req.body?.apply === true ? ["--yes"] : [])]);
132
+ });
133
+
134
+ router.post("/anchor", async (req, res) => {
135
+ const dir = projectOf(req);
136
+ if (!dir) return res.status(404).json({ ok: false, error: "not a karajan project" });
137
+ return runKj(res, dir, ["policy", "anchor"]);
138
+ });
139
+
140
+ export default router;
@@ -10,6 +10,7 @@ import apiRoutes from './routes/api.js';
10
10
  import pipelineRoutes from './routes/pipeline.js';
11
11
  import ragRoutes from './routes/rag.js';
12
12
  import wikiRoutes from './routes/wiki.js';
13
+ import governanceRoutes from './routes/governance.js';
13
14
  import { authMiddleware } from './auth.js';
14
15
  import { getOrCreateToken, getTokenPath } from './token-store.js';
15
16
  import { reapZombieSessions } from './zombie-reaper.js';
@@ -301,6 +302,7 @@ async function main() {
301
302
  app.use('/api/pipeline', authMiddleware(), pipelineRoutes);
302
303
  app.use('/api/rag', authMiddleware(), ragRoutes);
303
304
  app.use('/api/wiki', authMiddleware(), wikiRoutes);
305
+ app.use('/api/governance', authMiddleware(), governanceRoutes);
304
306
 
305
307
  // SPA fallback: serve index.html for non-API, non-static routes
306
308
  app.get('/{*splat}', (_req, res) => {
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { spawn } from "node:child_process";
6
6
  import readline from "node:readline/promises";
7
7
  import { stdin as input, stdout as output } from "node:process";
8
+ import { tomlPath } from "./toml-value.js";
8
9
 
9
10
  const rl = readline.createInterface({ input, output });
10
11
  const REGISTRY_PATH = path.join(os.homedir(), ".karajan", "instances.json");
@@ -259,10 +260,10 @@ async function setupCodexMcp({ rootDir, kjHome }) {
259
260
  const block = [
260
261
  '[mcp_servers."karajan-mcp"]',
261
262
  'command = "node"',
262
- `args = ["${path.join(rootDir, "src", "mcp", "server.js")}"]`,
263
- `cwd = "${rootDir}"`,
263
+ `args = [${tomlPath(path.join(rootDir, "src", "mcp", "server.js"))}]`,
264
+ `cwd = ${tomlPath(rootDir)}`,
264
265
  '[mcp_servers."karajan-mcp".env]',
265
- `KJ_HOME = "${kjHome}"`
266
+ `KJ_HOME = ${tomlPath(kjHome)}`
266
267
  ].join("\n");
267
268
 
268
269
  const updated = upsertCodexMcpBlock(toml, block);
@@ -11,6 +11,7 @@ import fs from "node:fs/promises";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
+ import { tomlPath } from "./toml-value.js";
14
15
 
15
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
17
  const ROOT_DIR = path.resolve(__dirname, "..");
@@ -92,10 +93,10 @@ async function setupCodexMcp(kjHome) {
92
93
  const block = [
93
94
  '[mcp_servers."karajan-mcp"]',
94
95
  'command = "node"',
95
- `args = ["${path.join(ROOT_DIR, "src", "mcp", "server.js")}"]`,
96
- `cwd = "${ROOT_DIR}"`,
96
+ `args = [${tomlPath(path.join(ROOT_DIR, "src", "mcp", "server.js"))}]`,
97
+ `cwd = ${tomlPath(ROOT_DIR)}`,
97
98
  '[mcp_servers."karajan-mcp".env]',
98
- `KJ_HOME = "${kjHome}"`
99
+ `KJ_HOME = ${tomlPath(kjHome)}`
99
100
  ].join("\n");
100
101
 
101
102
  const updated = upsertCodexMcpBlock(toml, block);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * A filesystem path as a TOML value (KJC-BUG-0151, issue #1426 from dfosela).
3
+ *
4
+ * In a double-quoted TOML string `\` opens an escape sequence, so a Windows
5
+ * path like `C:\Users\...` is not valid TOML: `\U` is read as a unicode escape.
6
+ * kj wrote the karajan-mcp block that way and codex then failed to load its
7
+ * ENTIRE config — `codex login` included. kj thought it had configured the MCP;
8
+ * what it had done was break a tool the user had not touched.
9
+ *
10
+ * A literal string (single quotes) interprets nothing, which is exactly what a
11
+ * path needs. Only a path containing a single quote falls back to the basic
12
+ * form, escaped properly — never left broken.
13
+ */
14
+ export function tomlPath(value) {
15
+ const s = String(value);
16
+ if (!s.includes("'")) return `'${s}'`;
17
+ return `"${s.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
18
+ }
@@ -23,7 +23,7 @@ function createAiTrashCheck() {
23
23
  ok: false,
24
24
  severity: "warn",
25
25
  detail: "kj-trash not found — destructive ops unprotected",
26
- fix: "Install karajan-code globally (npm i -g karajan-code) so kj-trash is on PATH, then run `kj-trash install --claude-code`",
26
+ fix: "Install karajan-code globally (npm i -g @karajan-family/code) so kj-trash is on PATH, then run `kj-trash install --claude-code`",
27
27
  };
28
28
  },
29
29
  };
@@ -109,7 +109,7 @@ export function createKarajanMcpCheck() {
109
109
  detail: result.ok
110
110
  ? result.detail
111
111
  : `karajan-mcp did not respond: ${result.detail} — OPTIONAL in v4: your agent runs kj directly; only shell-less hosts need MCP`,
112
- fix: result.ok ? undefined : "Only if you need MCP: install via npm (`npm i -g karajan-code`) — the standalone binary does not bundle it",
112
+ fix: result.ok ? undefined : "Only if you need MCP: install via npm (`npm i -g @karajan-family/code`) — the standalone binary does not bundle it",
113
113
  extra: { binPath },
114
114
  };
115
115
  },
@@ -45,7 +45,7 @@ export function createNativeBuildCheck({ loadDb = defaultLoadDb, isPnpm = isPnpm
45
45
  ok: false,
46
46
  severity: "warn",
47
47
  detail: "better-sqlite3 native build was skipped — pnpm blocks dependency build scripts by default",
48
- fix: "Approve the build then reinstall: `pnpm approve-builds better-sqlite3` — or install with npm: `npm i -g karajan-code`",
48
+ fix: "Approve the build then reinstall: `pnpm approve-builds better-sqlite3` — or install with npm: `npm i -g @karajan-family/code`",
49
49
  };
50
50
  }
51
51
  return {
@@ -55,7 +55,7 @@ export function createNativeBuildCheck({ loadDb = defaultLoadDb, isPnpm = isPnpm
55
55
  // modules by design — name the consequence (RAG/board/MCP need the
56
56
  // npm install) instead of implying the install is broken.
57
57
  detail: `better-sqlite3 failed to load: ${firstLine} — DB-backed features (RAG, board, MCP) unavailable; the standalone binary does not bundle native modules`,
58
- fix: "Reinstall to rebuild native modules: `npm i -g karajan-code`",
58
+ fix: "Reinstall to rebuild native modules: `npm i -g @karajan-family/code`",
59
59
  };
60
60
  }
61
61
  },
@@ -9,6 +9,37 @@ import { existsSync, readFileSync } from "node:fs";
9
9
  import { isAbsolute, join } from "node:path";
10
10
  import { runCommand } from "../utils/process.js";
11
11
  import { loadPrivacyList, scanPaths } from "../privacy/scan.js";
12
+ import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
13
+
14
+ // KJC-TSK-0769 — the effect boundary: what ships is re-evaluated against the
15
+ // policy IN FORCE now, not the one each PR was merged under. Artifact rules
16
+ // only: diff-threshold invariants are PR-scoped by definition — skipped, and
17
+ // said — so no line metric is needed (without a tag, the whole tree counts).
18
+ async function policyRangeCheck(projectDir) {
19
+ const { policy, errors } = loadPolicy({ projectDir });
20
+ if (errors.length > 0) return { name: "policy", ok: false, detail: `policy.yml invalid — ${errors[0]}` };
21
+ const described = await runCommand("git", ["-C", projectDir, "describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"]);
22
+ const tag = described.exitCode === 0 ? described.stdout.trim() : null;
23
+ const scope = tag ? `${tag}..HEAD` : "whole history (no previous tag)";
24
+ const prScoped = new Set((policy.invariants ?? []).filter((i) => i.kind === "diff-threshold").map((i) => i.id));
25
+ try {
26
+ const listed = await runCommand("git", tag
27
+ ? ["-C", projectDir, "diff", `${tag}..HEAD`, "--name-only"]
28
+ : ["-C", projectDir, "ls-tree", "-r", "--name-only", "HEAD"]);
29
+ if (listed.exitCode !== 0) throw new Error((listed.stderr || "git failed").trim());
30
+ const files = listed.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
31
+ const violations = checkStagedDiff(policy, { role: "coder", files, netLinesAdded: null }).filter((v) => !prScoped.has(v.rule_id));
32
+ const hard = violations.filter((v) => v.enforcement === "deny");
33
+ const skipped = prScoped.size > 0 ? `; ${prScoped.size} diff-threshold invariant(s) skipped (PR-scoped)` : "";
34
+ if (hard.length > 0) {
35
+ const list = hard.map((v) => `[${v.rule_id}]${v.file ? ` ${v.file}` : ""}`).join(", ");
36
+ return { name: "policy", ok: false, detail: `${hard.length} deny violation(s) in ${scope} against the current policy: ${list}` };
37
+ }
38
+ return { name: "policy", ok: true, detail: `${scope} clean against the current policy (${violations.length} warning(s))${skipped}` };
39
+ } catch (err) {
40
+ return { name: "policy", ok: false, detail: `could not evaluate ${scope}: ${err.message}` };
41
+ }
42
+ }
12
43
 
13
44
  const semverCmp = (a, b) => {
14
45
  const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
@@ -93,10 +124,39 @@ async function declaredItems(projectDir, config, version) {
93
124
  return checks;
94
125
  }
95
126
 
127
+ /**
128
+ * MIG-B (KJC-TSK-0752, ADR 0004): while a package dual-publishes under two npm
129
+ * names, their `latest` dist-tags must move in LOCKSTEP. A torn dual-publish —
130
+ * one name released, the other not — is invisible from the repo (both installs
131
+ * "work") and every surface that teaches one name silently diverges from the
132
+ * other. The pair is read from scripts/dual-publish.mjs, which is the one
133
+ * place that knows it; no dual script, no check.
134
+ */
135
+ export async function dualPublishCheck(projectDir, pkg, run = runCommand) {
136
+ const script = join(projectDir, "scripts", "dual-publish.mjs");
137
+ if (!pkg?.name || !existsSync(script)) return null;
138
+ const src = readFileSync(script, "utf8");
139
+ const legacy = (src.match(/LEGACY_NAME = "([^"]+)"/) || [])[1];
140
+ const scoped = (src.match(/SCOPED_NAME = "([^"]+)"/) || [])[1];
141
+ if (!legacy || !scoped || pkg.name !== legacy) return null;
142
+ const latest = async (name) => {
143
+ const out = await run("npm", ["view", name, "dist-tags.latest"], { cwd: projectDir });
144
+ if (out.exitCode !== 0) throw new Error(`npm view ${name} failed`);
145
+ return (out.stdout || "").trim();
146
+ };
147
+ try {
148
+ const [a, b] = await Promise.all([latest(legacy), latest(scoped)]);
149
+ const ok = Boolean(a) && a === b;
150
+ return { name: "dual-publish", ok, detail: ok ? `${legacy} and ${scoped} both at ${a}` : `dist-tags diverge: ${legacy}@${a || "?"} vs ${scoped}@${b || "?"} — a torn dual-publish; publish the missing name before releasing on top` };
151
+ } catch (err) {
152
+ return { name: "dual-publish", ok: false, detail: `could not read npm dist-tags (${err.message}) — the lockstep cannot be verified, and unverified is not ok` };
153
+ }
154
+ }
155
+
96
156
  export async function runReleaseCheck({ projectDir = process.cwd(), config = {} } = {}) {
97
157
  const { checks, version, pkg } = await genericChecks(projectDir);
98
158
  const pack = await packPrivacyCheck(projectDir, pkg);
99
- if (pack) checks.push(pack);
100
- checks.push(...await declaredItems(projectDir, config, version));
159
+ const dual = await dualPublishCheck(projectDir, pkg);
160
+ checks.push(...(pack ? [pack] : []), ...(dual ? [dual] : []), await policyRangeCheck(projectDir), ...await declaredItems(projectDir, config, version));
101
161
  return { ok: checks.every((c) => c.ok), version, checks };
102
162
  }