karajan-code 4.20.1 → 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 +5 -3
- package/package.json +4 -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/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/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 +81 -0
- package/src/claims/extract.js +56 -0
- package/src/claims/turn.js +54 -0
- package/src/cli/advanced-commands.js +2 -2
- package/src/cli/register-meta.js +70 -5
- package/src/commands/claims.js +41 -0
- package/src/commands/harden.js +8 -0
- package/src/commands/identity.js +60 -0
- package/src/commands/init.js +1 -1
- package/src/commands/policy.js +84 -3
- package/src/commands/review-gate.js +4 -2
- package/src/environment/playbook.js +2 -1
- package/src/guards/duplicate-members.js +86 -0
- package/src/harden/hook-templates.js +40 -1
- package/src/harden/sentinel-hooks.js +267 -15
- package/src/identity/bootstrap.js +48 -0
- package/src/identity/compare.js +38 -0
- package/src/identity/detect.js +51 -0
- package/src/identity/store.js +66 -0
- package/src/policy/exceptions.js +14 -4
- package/src/policy/report.js +99 -0
- 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/unparseable-verdict.js +50 -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>
|
|
@@ -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.
|
|
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",
|
|
@@ -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) => {
|
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/checks/ai-trash.js
CHANGED
|
@@ -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
|
};
|
package/src/checks/mcp-health.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|