karajan-code 4.21.0 → 4.23.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 +6 -3
- package/package.json +5 -2
- package/packages/hu-board/public/app.js +1 -0
- package/packages/hu-board/public/index.html +2 -0
- package/packages/hu-board/public/styles.css +22 -0
- package/packages/hu-board/public/utils/governance-view.js +149 -0
- package/packages/hu-board/src/auth.js +29 -1
- package/packages/hu-board/src/routes/governance.js +140 -0
- package/packages/hu-board/src/server.js +2 -0
- package/scripts/install.js +4 -3
- package/scripts/postinstall.js +4 -3
- package/scripts/toml-value.js +18 -0
- package/src/audit/basal-cost.js +4 -0
- package/src/audit/dead-exports.js +52 -1
- package/src/audit/deterministic-summary.js +18 -3
- package/src/audit/member-reachability.js +158 -0
- package/src/audit/osv-findings.js +1 -0
- package/src/checks/ai-trash.js +1 -1
- package/src/checks/mcp-health.js +1 -1
- package/src/checks/native-build.js +2 -2
- package/src/checks/release-check.js +62 -2
- package/src/claims/cross-check.js +86 -0
- package/src/claims/extract.js +56 -0
- package/src/claims/turn.js +54 -0
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +66 -5
- package/src/commands/claims.js +73 -0
- package/src/commands/hu.js +3 -1
- package/src/commands/init.js +1 -1
- package/src/commands/policy.js +84 -3
- package/src/commands/privacy.js +6 -2
- package/src/commands/resume.js +4 -0
- package/src/commands/review-gate.js +34 -9
- package/src/commands/steward.js +146 -0
- package/src/config/defaults.js +6 -1
- package/src/environment/playbook.js +2 -1
- package/src/guards/duplicate-members.js +86 -0
- package/src/harden/sentinel-hooks.js +188 -22
- package/src/harden/workflow-engine.js +8 -2
- package/src/harden/workflow-templates.js +38 -1
- package/src/policy/exceptions.js +14 -4
- package/src/policy/report.js +99 -0
- package/src/privacy/scan.js +23 -1
- package/src/review/card-first.js +4 -1
- package/src/review/one-shot-review.js +10 -4
- package/src/review/parser.js +9 -0
- package/src/review/sonar-pregate.js +38 -3
- package/src/review/tests-with-code.js +12 -1
- package/src/review/unparseable-verdict.js +50 -0
- package/src/roles/audit-role.js +19 -1
- package/src/sonar/scanner.js +34 -8
- package/src/steward/invariants.js +190 -0
- package/src/steward/phantom-coverage.js +137 -0
- package/src/steward/proposed-work.js +68 -0
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
|
|
13
|
-
<a href="https://www.npmjs.com/package
|
|
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>
|
|
@@ -34,6 +34,7 @@ Your AI agent (Claude Code, Codex, Copilot, Antigravity, Cursor…) writes the c
|
|
|
34
34
|
- **Nothing personal ships** — every outbound boundary audits before it leaves the machine: the pre-commit rejects a staged diff carrying your denylisted personal data, hardcoded platform tokens (`ghp_`, `sk-`, `AKIA`…) block outright, `verify-pack`-style tarball scans guard the publish, and `kj privacy scan <dir>` audits any build output. Your denylist lives in `~/.karajan/privacy.yml` — the install asks and writes it for you.
|
|
35
35
|
- **Installing IS activating** — `kj env install` performs the enforcement itself (git hooks, verdict gate, tool gate) instead of trusting the agent to run setup steps, and ends by printing the method into the very conversation that installed it. A commit outside the method is rejected, not narrated.
|
|
36
36
|
- **The turn cannot end red — the Sentinel** — a deterministic supervisor (zero LLM) wired into the harness's synchronous hooks records the method state of the session as tools run, and a Stop hook blocks the agent from ending its turn while method violations are open. The program rules, the agent thinks. See [guarantee levels](#guarantee-levels-governed-vs-supervised).
|
|
37
|
+
- **Trust expires — the Steward** — `kj steward sweep` checks the project's declared guarantees against their freshness and answers one of FOUR verdicts per invariant: ok, broken, *unknown* (evidence expired → refresh it) or *not observable* (nowhere to look → instrument it) — because confusing the last two with green is how a project decays for weeks behind a passing facade. The verdict is versioned in the repo, sealed in the decision chain, and every break lands on the board as PROPOSED work that nothing executes unreviewed. Runs on demand, on `kj resume` when stale, or as an opt-in scheduled Action.
|
|
37
38
|
- **The review panel never runs dry** — nine built-in agents (Claude Code, Codex, GitHub Copilot, Antigravity `agy` — the gemini successor —, Kimi Code, Qwen, OpenCode, Aider, Gemini legacy), and when the configured reviewer exhausts its quota, `kj review` switches to an authenticated candidate with a LOUD notice — or hands you the menu of candidates with their tier (free / subscription / local) and the exact login command. Never a silent failure, never the brain reviewing itself.
|
|
38
39
|
|
|
39
40
|
This repo runs under its own environment: every commit to karajan-code carries a cross-AI verdict.
|
|
@@ -58,7 +59,7 @@ kj init && kj env install && kj harden && kj review --install-gate
|
|
|
58
59
|
git config core.hooksPath .karajan/hooks
|
|
59
60
|
```
|
|
60
61
|
|
|
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/).
|
|
62
|
+
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
63
|
|
|
63
64
|
## The daily loop
|
|
64
65
|
|
|
@@ -85,6 +86,8 @@ With a declared `.karajan/policy.yml`, the policy layer enforces at three tiers,
|
|
|
85
86
|
|
|
86
87
|
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
88
|
|
|
89
|
+
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.
|
|
90
|
+
|
|
88
91
|
## Headless mode
|
|
89
92
|
|
|
90
93
|
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.
|
|
3
|
+
"version": "4.23.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/*",
|
|
@@ -126,12 +127,14 @@
|
|
|
126
127
|
"esbuild": "^0.28.0",
|
|
127
128
|
"eslint": "^10.4.1",
|
|
128
129
|
"eslint-plugin-import-x": "^4.16.2",
|
|
130
|
+
"eslint-plugin-node-security": "5.2.2",
|
|
129
131
|
"eslint-plugin-security": "^4.0.0",
|
|
130
132
|
"globals": "^17.6.0",
|
|
131
133
|
"lint-staged": "^17.0.7",
|
|
132
134
|
"postject": "^1.0.0-alpha.6",
|
|
133
135
|
"prettier": "^3.4.2",
|
|
134
136
|
"simple-git-hooks": "^2.13.1",
|
|
137
|
+
"smol-toml": "^1.7.0",
|
|
135
138
|
"vitest": "^4.1.8"
|
|
136
139
|
}
|
|
137
140
|
}
|
|
@@ -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>'}`;
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
* legacy callers keep working.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { timingSafeEqual } from "node:crypto";
|
|
24
|
+
|
|
23
25
|
/** Loopback addresses we trust without auth, regardless of token state. */
|
|
24
26
|
const LOOPBACK_ADDRESSES = new Set([
|
|
25
27
|
"127.0.0.1",
|
|
@@ -41,7 +43,7 @@ export function authMiddleware() {
|
|
|
41
43
|
if (isLoopback(req)) return next();
|
|
42
44
|
|
|
43
45
|
const token = extractToken(req);
|
|
44
|
-
if (token
|
|
46
|
+
if (tokensMatch(token, expected)) return next();
|
|
45
47
|
|
|
46
48
|
return res.status(401).json({
|
|
47
49
|
error: "Unauthorized",
|
|
@@ -53,6 +55,32 @@ export function authMiddleware() {
|
|
|
53
55
|
};
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Constant-time comparison of a presented token against the expected one.
|
|
60
|
+
*
|
|
61
|
+
* `===` on strings stops at the first differing byte, so the time it takes to
|
|
62
|
+
* reject a token leaks how much of a correct prefix was supplied. Over the
|
|
63
|
+
* loopback interface that is noise — but this middleware exists precisely for
|
|
64
|
+
* the case the header above describes, where the board is bound beyond
|
|
65
|
+
* loopback and the peer is on the LAN. There the difference is measurable, and
|
|
66
|
+
* the token can be recovered a byte at a time.
|
|
67
|
+
*
|
|
68
|
+
* `timingSafeEqual` throws when the buffers differ in length, so the length is
|
|
69
|
+
* checked first. That is not a leak worth closing: the length of the expected
|
|
70
|
+
* token is fixed by whatever wrote `~/.karajan/hu-board/token`, and a token of
|
|
71
|
+
* the wrong length is wrong regardless of its contents.
|
|
72
|
+
*
|
|
73
|
+
* @param {string|undefined} presented
|
|
74
|
+
* @param {string} expected
|
|
75
|
+
* @returns {boolean}
|
|
76
|
+
*/
|
|
77
|
+
function tokensMatch(presented, expected) {
|
|
78
|
+
if (typeof presented !== "string") return false;
|
|
79
|
+
const left = Buffer.from(presented, "utf8");
|
|
80
|
+
const right = Buffer.from(expected, "utf8");
|
|
81
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
/**
|
|
57
85
|
* @param {import('express').Request} req
|
|
58
86
|
* @returns {boolean}
|
|
@@ -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) => {
|
package/scripts/install.js
CHANGED
|
@@ -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 = [
|
|
263
|
-
`cwd =
|
|
263
|
+
`args = [${tomlPath(path.join(rootDir, "src", "mcp", "server.js"))}]`,
|
|
264
|
+
`cwd = ${tomlPath(rootDir)}`,
|
|
264
265
|
'[mcp_servers."karajan-mcp".env]',
|
|
265
|
-
`KJ_HOME =
|
|
266
|
+
`KJ_HOME = ${tomlPath(kjHome)}`
|
|
266
267
|
].join("\n");
|
|
267
268
|
|
|
268
269
|
const updated = upsertCodexMcpBlock(toml, block);
|
package/scripts/postinstall.js
CHANGED
|
@@ -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 = [
|
|
96
|
-
`cwd =
|
|
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 =
|
|
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
|
+
}
|
package/src/audit/basal-cost.js
CHANGED
|
@@ -272,10 +272,14 @@ export async function saveAuditSnapshot(projectDir, metrics) {
|
|
|
272
272
|
|
|
273
273
|
export function computeGrowthDelta(current, previous) {
|
|
274
274
|
if (!previous) return null;
|
|
275
|
+
// AC8 (KJC-TSK-0794): "not measured before" must never read as "unchanged" —
|
|
276
|
+
// the derivative only exists when BOTH snapshots actually measured it.
|
|
277
|
+
const bothMeasured = Array.isArray(current.deadExports) && Array.isArray(previous.deadExports);
|
|
275
278
|
return {
|
|
276
279
|
lines: current.totalLines - previous.totalLines,
|
|
277
280
|
files: current.totalFiles - previous.totalFiles,
|
|
278
281
|
deps: current.dependencies.total - (previous.dependencies?.total ?? 0),
|
|
282
|
+
deadExports: bothMeasured ? current.deadExports.length - previous.deadExports.length : null,
|
|
279
283
|
since: previous.timestamp || null
|
|
280
284
|
};
|
|
281
285
|
}
|