karajan-code 4.22.0 → 4.24.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.
Files changed (40) hide show
  1. package/README.md +9 -0
  2. package/package.json +3 -2
  3. package/packages/hu-board/public/index.html +1 -0
  4. package/packages/hu-board/public/utils/board-view.js +14 -13
  5. package/packages/hu-board/public/utils/command-launcher.js +42 -6
  6. package/packages/hu-board/public/utils/log-panel.js +4 -4
  7. package/packages/hu-board/public/utils/maggle-mode.js +124 -0
  8. package/packages/hu-board/public/utils/modals.js +11 -2
  9. package/packages/hu-board/public/utils/project-picker-view.js +3 -3
  10. package/packages/hu-board/src/auth.js +29 -1
  11. package/scripts/install-binary.ps1 +12 -3
  12. package/scripts/install-binary.sh +13 -3
  13. package/src/audit/basal-cost.js +4 -0
  14. package/src/audit/dead-exports.js +52 -1
  15. package/src/audit/deterministic-summary.js +18 -3
  16. package/src/audit/member-reachability.js +158 -0
  17. package/src/audit/osv-findings.js +1 -0
  18. package/src/claims/cross-check.js +10 -5
  19. package/src/cli/advanced-commands.js +2 -1
  20. package/src/cli/register-meta.js +36 -3
  21. package/src/commands/board.js +19 -4
  22. package/src/commands/claims.js +33 -1
  23. package/src/commands/go.js +110 -0
  24. package/src/commands/hu.js +3 -1
  25. package/src/commands/privacy.js +6 -2
  26. package/src/commands/resume.js +4 -0
  27. package/src/commands/review-gate.js +30 -7
  28. package/src/commands/steward.js +146 -0
  29. package/src/config/defaults.js +6 -1
  30. package/src/harden/sentinel-hooks.js +98 -6
  31. package/src/harden/workflow-engine.js +8 -2
  32. package/src/harden/workflow-templates.js +38 -1
  33. package/src/privacy/scan.js +23 -1
  34. package/src/review/sonar-pregate.js +38 -3
  35. package/src/review/tests-with-code.js +12 -1
  36. package/src/roles/audit-role.js +19 -1
  37. package/src/sonar/scanner.js +34 -8
  38. package/src/steward/invariants.js +190 -0
  39. package/src/steward/phantom-coverage.js +174 -0
  40. package/src/steward/proposed-work.js +68 -0
@@ -16,6 +16,7 @@
16
16
  import { execFile } from "node:child_process";
17
17
  import { promisify } from "node:util";
18
18
  import { createRequire } from "node:module";
19
+ import os from "node:os";
19
20
  import path from "node:path";
20
21
  import fs from "node:fs/promises";
21
22
  import { filterFalsePositives } from "./issue-filter.js";
@@ -26,6 +27,42 @@ const SCAN_TIMEOUT_MS = 120_000;
26
27
  const MAX_DEAD_EXPORTS_REPORTED = 100;
27
28
  const MAX_UNUSED_FILES_REPORTED = 50;
28
29
 
30
+ // KJC-TSK-0794 AC7: a Firebase callable or a declared global-setup reported as
31
+ // dead is the false positive that gets the inventory switched off. When the
32
+ // project declares nothing itself, kj declares FOR it what it can read.
33
+ const KNIP_OWN_CONFIGS = ["knip.json", "knip.jsonc", ".knip.json", "knip.js", "knip.ts", "knip.config.js", "knip.config.ts"];
34
+ // knip's default root entry patterns, restated because an explicit `entry`
35
+ // REPLACES them (package.json main/bin/exports always count on their own).
36
+ const KNIP_DEFAULT_ENTRY = ["{index,cli,main}.{js,mjs,cjs,jsx,ts,mts,cts,tsx}", "src/{index,cli,main}.{js,mjs,cjs,jsx,ts,mts,cts,tsx}"];
37
+
38
+ async function hasOwnKnipConfig(projectDir) {
39
+ for (const f of KNIP_OWN_CONFIGS) {
40
+ try { await fs.access(path.join(projectDir, f)); return true; } catch { /* keep looking */ }
41
+ }
42
+ try { return Boolean(JSON.parse(await fs.readFile(path.join(projectDir, "package.json"), "utf8")).knip); } catch { return false; }
43
+ }
44
+
45
+ /** Entrypoints kj can vouch for: config.audit.entrypoints (the user's word) and
46
+ * firebase.json's functions.source (the platform's word). Null when nothing. */
47
+ async function resolveProjectEntrypoints(projectDir, config) {
48
+ const declared = (config?.audit?.entrypoints || []).filter((e) => typeof e === "string");
49
+ const firebase = [];
50
+ try {
51
+ const fb = JSON.parse(await fs.readFile(path.join(projectDir, "firebase.json"), "utf8"));
52
+ const fns = Array.isArray(fb.functions) ? fb.functions : fb.functions ? [fb.functions] : [];
53
+ for (const f of fns) if (typeof f?.source === "string") firebase.push(f.source);
54
+ } catch { /* no firebase.json — nothing to declare */ }
55
+ return declared.length || firebase.length ? { declared, firebase } : null;
56
+ }
57
+
58
+ function buildEphemeralKnipConfig({ declared, firebase }) {
59
+ const root = { entry: [...KNIP_DEFAULT_ENTRY, ...declared] };
60
+ if (!firebase.length) return root;
61
+ const workspaces = { ".": root };
62
+ for (const src of firebase) workspaces[src] = { entry: ["index.{js,ts}", "src/index.{js,ts}"] };
63
+ return { workspaces };
64
+ }
65
+
29
66
  function hasJsTsStack(stack) {
30
67
  if (!stack) return false;
31
68
  const lang = (stack.language || "").toLowerCase();
@@ -78,11 +115,24 @@ export async function collectDeadExports(projectDir, stack, config = {}, logger
78
115
  return { available: false, reason: `knip binary not resolvable (${err?.message || err}). In the SEA binary install karajan-code via npm to enable.` };
79
116
  }
80
117
 
118
+ const args = [knipBin, "--reporter", "json", "--no-progress", "--no-exit-code"];
119
+ let declaredEntrypoints = null;
120
+ try {
121
+ const eps = await resolveProjectEntrypoints(projectDir, config);
122
+ // a project with its OWN knip config already decided — it is respected
123
+ if (eps && !(await hasOwnKnipConfig(projectDir))) {
124
+ const cfgPath = path.join(await fs.mkdtemp(path.join(os.tmpdir(), "kj-knip-")), "knip.json");
125
+ await fs.writeFile(cfgPath, JSON.stringify(buildEphemeralKnipConfig(eps)));
126
+ args.push("--config", cfgPath);
127
+ declaredEntrypoints = eps;
128
+ }
129
+ } catch { /* declaring is best-effort; the default scan still runs */ }
130
+
81
131
  let raw;
82
132
  try {
83
133
  const { stdout } = await execFileAsync(
84
134
  process.execPath,
85
- [knipBin, "--reporter", "json", "--no-progress", "--no-exit-code"],
135
+ args,
86
136
  { cwd: projectDir, timeout: SCAN_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 },
87
137
  );
88
138
  raw = stdout;
@@ -163,6 +213,7 @@ export async function collectDeadExports(projectDir, stack, config = {}, logger
163
213
  available: true,
164
214
  total: kept.length,
165
215
  suppressedCount: suppressed.length,
216
+ declaredEntrypoints,
166
217
  exports: keptExports,
167
218
  files: keptFiles,
168
219
  suppressed,
@@ -185,9 +185,20 @@ function formatDeadExportsBlock(deadExports) {
185
185
  const lines = ["### Dead Code (knip)"];
186
186
  const exportsTotal = (deadExports.exports || []).length;
187
187
  const filesTotal = (deadExports.files || []).length;
188
+ const reported = exportsTotal + filesTotal;
189
+ const suppressed = deadExports.suppressedCount || 0;
190
+ // AC8 (KJC-TSK-0794): derivative first; then how many ENTERED and how many
191
+ // were FILTERED — a shrinking number must never hide a shrinking scan.
192
+ const prev = deadExports.previous;
193
+ if (prev && typeof prev.exports === "number") {
194
+ const d = reported - (prev.exports + (prev.files || 0));
195
+ lines.push(`- Δ dead code: ${d >= 0 ? "+" : ""}${d} since ${prev.timestamp || "last audit"} (now ${reported})`);
196
+ }
197
+ lines.push(`- ${reported + suppressed} entered the scan, ${suppressed} filtered as declared false positives, ${reported} reported`);
198
+ const eps = deadExports.declaredEntrypoints;
199
+ if (eps) lines.push(`- Declared entrypoints honored: ${[...eps.declared, ...eps.firebase.map((s) => `${s} (firebase functions)`)].join(", ")}`);
188
200
  lines.push(`- Unused exports/types: ${exportsTotal}`);
189
201
  lines.push(`- Unused files: ${filesTotal}`);
190
- if (deadExports.suppressedCount) lines.push(`- Suppressed (FP filter): ${deadExports.suppressedCount}`);
191
202
  const allItems = [...(deadExports.exports || []), ...(deadExports.files || [])];
192
203
  if (allItems.length > 0) {
193
204
  const groups = groupDeadExportsBySeverity(allItems);
@@ -234,8 +245,12 @@ function formatBasalCostBlock(basalCost, growthDelta) {
234
245
  }
235
246
 
236
247
  const dead = Array.isArray(basalCost.deadExports) ? basalCost.deadExports : [];
248
+ // AC8 (KJC-TSK-0794): the DERIVATIVE leads — "went from N to M" is what a
249
+ // reader acts on; the absolute is the secondary datum.
250
+ const dDelta = growthDelta?.deadExports;
251
+ const lead = typeof dDelta === "number" ? `${dDelta >= 0 ? "+" : ""}${dDelta} since last audit — ` : "";
237
252
  if (dead.length > 0) {
238
- lines.push(`- Dead exports: ${dead.length}`);
253
+ lines.push(`- Dead exports: ${lead}${dead.length} total`);
239
254
  for (const de of dead.slice(0, MAX_SAMPLE_DEAD_EXPORTS)) {
240
255
  lines.push(` - \`${de.name}\` in ${de.file}`);
241
256
  }
@@ -243,7 +258,7 @@ function formatBasalCostBlock(basalCost, growthDelta) {
243
258
  lines.push(` - ... and ${dead.length - MAX_SAMPLE_DEAD_EXPORTS} more`);
244
259
  }
245
260
  } else {
246
- lines.push("- Dead exports: 0");
261
+ lines.push(`- Dead exports: ${lead}0`);
247
262
  }
248
263
 
249
264
  if (growthDelta) {
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Member reachability (KJC-TSK-0794, epic KJC-PCS-0082) — which class members
3
+ * no entrypoint can reach, said ONLY inside the perimeter validated with known
4
+ * truth (GREBLA: 31/139 unreachable at dd5a91a checked by hand, 0/108 on their
5
+ * cleaned main): one file, a recognized framework contract, no dynamic dispatch.
6
+ * Everything outside comes out NOT OBSERVABLE with its reason, never as clean —
7
+ * an inflated inventory gets switched off, and then nobody reads the real one.
8
+ */
9
+ import { parse } from "@babel/parser";
10
+ const plugins = (file) => [
11
+ ...(/\.(ts|tsx|mts|cts)$/.test(file) ? ["typescript"] : []),
12
+ ...(/\.(tsx|jsx)$/.test(file) || !/\.(ts|mts|cts)$/.test(file) ? ["jsx"] : []),
13
+ "decorators",
14
+ ];
15
+ // Entrypoints are what the FRAMEWORK calls — declared per framework, VERSIONED
16
+ // (bump on any list change), never a hand-kept list in a run. v1 covers Lit;
17
+ // other frameworks stay NOT OBSERVABLE until their contract is declared here.
18
+ export const ENTRYPOINT_CATALOG = {
19
+ version: 2, // v2: each list says WHICH slot the framework touches — static and instance are different worlds
20
+ lit: {
21
+ instance: ["constructor", "render", "connectedCallback", "disconnectedCallback", "attributeChangedCallback", "adoptedCallback", "firstUpdated", "updated", "willUpdate", "shouldUpdate", "performUpdate", "getUpdateComplete", "createRenderRoot"],
22
+ static: ["properties", "styles", "observedAttributes"],
23
+ },
24
+ };
25
+ const BASES = new Map([["LitElement", "lit"]]);
26
+ function walk(node, visit) {
27
+ if (!node || typeof node.type !== "string") return;
28
+ visit(node);
29
+ for (const key of Object.keys(node)) {
30
+ const child = node[key];
31
+ if (Array.isArray(child)) child.forEach((c) => walk(c, visit));
32
+ else if (child && typeof child.type === "string") walk(child, visit);
33
+ }
34
+ }
35
+ const keyName = (m) => {
36
+ if (m.key?.type === "PrivateName") return `#${m.key.id.name}`;
37
+ if (!m.computed && m.key?.type === "Identifier") return m.key.name;
38
+ return m.key?.type === "StringLiteral" || m.key?.type === "NumericLiteral" ? String(m.key.value) : null;
39
+ };
40
+ const ACCESSOR_KINDS = new Map([["get", "getter"], ["set", "setter"]]);
41
+ const kindOf = (m) => ACCESSOR_KINDS.get(m.kind) ?? (/Method/.test(m.type) ? "method" : "field");
42
+ /** `this.x` / `this['x']` / `this.#x` inside a node, prefixed with the slot the
43
+ * context can actually reach ("static " inside static members, "" otherwise).
44
+ * Over-collecting through functions that rebind `this` only makes members MORE
45
+ * alive — never dead. */
46
+ const thisRefs = (node, prefix = "") => {
47
+ const refs = new Set();
48
+ walk(node, (n) => {
49
+ if (n.type !== "MemberExpression" || n.object?.type !== "ThisExpression") return;
50
+ if (n.property.type === "PrivateName") refs.add(`${prefix}#${n.property.id.name}`);
51
+ else if (!n.computed && n.property.type === "Identifier") refs.add(prefix + n.property.name);
52
+ else if (n.property.type === "StringLiteral" || n.property.type === "NumericLiteral") refs.add(prefix + String(n.property.value));
53
+ });
54
+ return refs;
55
+ };
56
+ const notObs = (name, line, reason) => ({ name, line, observable: false, reason, unreachable: [] });
57
+
58
+ function analyzeClass(node, { staticUses, thisPropCount }) {
59
+ const name = node.id?.name ?? "(anonymous)";
60
+ const line = node.loc.start.line;
61
+ const sup = node.superClass;
62
+ if (!sup) return notObs(name, line, "no framework contract — members may be called from outside the file");
63
+ if (sup.type !== "Identifier") return notObs(name, line, "mixin or computed base class — inherited entrypoints cannot be resolved");
64
+ const framework = BASES.get(sup.name);
65
+ if (!framework) return notObs(name, line, `unknown base class ${sup.name} — its contract is not in the entrypoint catalog (v${ENTRYPOINT_CATALOG.version})`);
66
+
67
+ const cat = ENTRYPOINT_CATALOG[framework];
68
+ const entries = new Set([...cat.instance, ...cat.static.map((s) => `static ${s}`)]);
69
+ const members = new Map(); // slot → { refs, spots, entry }
70
+ // reached at class-definition time (static blocks, static field initializers)
71
+ // or by ClassName.X anywhere in the file — a use is a use, wherever it sits
72
+ const roots = new Set([...(staticUses.get(name) ?? [])].map((s) => `static ${s}`));
73
+ const ctorFields = new Map(); // this._x = v in the constructor: no AST member exists
74
+ for (const m of node.body.body) {
75
+ if (m.type === "StaticBlock") { thisRefs(m, "static ").forEach((r) => roots.add(r)); continue; }
76
+ if (m.type === "TSDeclareMethod" || m.type === "TSIndexSignature") continue;
77
+ if (m.static && /Property/.test(m.type) && m.value) thisRefs(m.value, "static ").forEach((r) => roots.add(r));
78
+ const n = keyName(m);
79
+ if (n === null) return notObs(name, line, `a member name is computed at line ${m.loc.start.line} — the inventory cannot name what it cannot see`);
80
+ const slot = m.static ? `static ${n}` : n;
81
+ const refs = thisRefs(m, m.static ? "static " : "");
82
+ refs.delete(slot); // recursion does not keep itself alive
83
+ const rec = members.get(slot) ?? { refs: new Set(), spots: [], entry: false };
84
+ refs.forEach((r) => rec.refs.add(r));
85
+ rec.spots.push({ line: m.loc.start.line, endLine: m.loc.end.line, kind: kindOf(m) });
86
+ // a decorated member is registered by the framework: it may call or expose it
87
+ if (entries.has(slot) || (m.decorators?.length ?? 0) > 0) rec.entry = true;
88
+ members.set(slot, rec);
89
+ if (n === "constructor" && !m.static) collectCtorAssignments(m, ctorFields);
90
+ }
91
+
92
+ const alive = new Set();
93
+ const queue = [...members.keys()].filter((k) => members.get(k).entry || roots.has(k));
94
+ while (queue.length) {
95
+ const k = queue.pop();
96
+ if (alive.has(k) || !members.has(k)) continue;
97
+ alive.add(k);
98
+ members.get(k).refs.forEach((r) => { if (members.has(r) && !alive.has(r)) queue.push(r); });
99
+ }
100
+ const unreachable = [...members.entries()]
101
+ .filter(([k]) => !alive.has(k))
102
+ .flatMap(([k, rec]) => rec.spots.map((s) => ({ name: k, ...s })))
103
+ .sort((a, b) => a.line - b.line);
104
+ // HEURISTIC, reported apart (AC5): a constructor field whose name appears in
105
+ // `this.X` form exactly ONCE in the whole file exists only to be initialized.
106
+ const constructorFields = [...ctorFields.entries()]
107
+ .filter(([n]) => !members.has(n) && thisPropCount.get(n) === 1)
108
+ .map(([n, l]) => ({ name: n, line: l, heuristic: "single this-appearance in file" }));
109
+ // memberNames: the class's member slots — phantom-coverage (KJC-TSK-0800)
110
+ // needs to tell "a call to a member of THIS class" from any other call.
111
+ return { name, line, observable: true, reason: null, framework, catalogVersion: ENTRYPOINT_CATALOG.version, total: members.size, memberNames: [...members.keys()], unreachable, constructorFields };
112
+ }
113
+
114
+ /** `this.x = …` statements inside the constructor body (first line wins). */
115
+ function collectCtorAssignments(ctor, out) {
116
+ walk(ctor.body, (n) => {
117
+ if (n.type !== "AssignmentExpression" || n.left?.type !== "MemberExpression") return;
118
+ const l = n.left;
119
+ if (l.object?.type !== "ThisExpression" || l.computed || l.property.type !== "Identifier") return;
120
+ if (!out.has(l.property.name)) out.set(l.property.name, n.loc.start.line);
121
+ });
122
+ }
123
+
124
+ /**
125
+ * @param {string} source
126
+ * @param {{file?: string}} [where]
127
+ * @returns {{file: string, observable: boolean, reason: string|null, classes: Array<object>}}
128
+ */
129
+ export function analyzeMemberReachability(source, { file = "<source>" } = {}) {
130
+ let tree;
131
+ try {
132
+ tree = parse(source, { sourceType: "unambiguous", allowReturnOutsideFunction: true, plugins: plugins(file) });
133
+ } catch (err) {
134
+ return { file, observable: false, reason: `could not be parsed (${err.message}) — not read as clean`, classes: [] };
135
+ }
136
+ // String dispatch is the most dangerous false positive: ONE computed access
137
+ // on `this` that is not a literal makes the whole file not observable — a
138
+ // list with garbage is worth less than an honest "I do not know".
139
+ let dynamic = null;
140
+ const staticUses = new Map(); // ClassName → Set of properties used as ClassName.X
141
+ const thisPropCount = new Map(); // property → how many `this.X` appearances in the file
142
+ walk(tree, (n) => {
143
+ if (n.type !== "MemberExpression") return;
144
+ if (n.object?.type === "Identifier" && !n.computed && n.property.type === "Identifier") {
145
+ (staticUses.get(n.object.name) ?? staticUses.set(n.object.name, new Set()).get(n.object.name)).add(n.property.name);
146
+ }
147
+ if (n.object?.type !== "ThisExpression") return;
148
+ if (n.computed && n.property.type !== "StringLiteral" && n.property.type !== "NumericLiteral") { dynamic ??= n.loc.start.line; return; }
149
+ if (!n.computed && n.property.type === "Identifier") thisPropCount.set(n.property.name, (thisPropCount.get(n.property.name) ?? 0) + 1);
150
+ });
151
+ if (dynamic !== null) return { file, observable: false, reason: `computed access on this at line ${dynamic} — string dispatch cannot be followed`, classes: [] };
152
+
153
+ const classes = [];
154
+ walk(tree, (node) => {
155
+ if (node.type === "ClassDeclaration" || node.type === "ClassExpression") classes.push(analyzeClass(node, { staticUses, thisPropCount }));
156
+ });
157
+ return { file, observable: true, reason: null, classes };
158
+ }
@@ -104,6 +104,7 @@ export function parseOsvOutput(raw) {
104
104
  aliases: vuln.aliases || [],
105
105
  severity: extractSeverity(vuln, groups),
106
106
  summary: vuln.summary || vuln.details?.split("\n")[0] || "",
107
+ publishedAt: vuln.published || null, // the ADVISORY's date — the Steward ages by it (KJC-TSK-0789 AC6)
107
108
  package: name,
108
109
  version,
109
110
  ecosystem,
@@ -41,8 +41,13 @@ const SAYS_EMPTY = /(^|\W)(\[\]|\bnone\b|\bno results?\b|\bempty\b|\b0 (results?
41
41
  * @returns {{claims: Array<object>, denied: Array<object>, unbacked: Array<object>}}
42
42
  */
43
43
  export function crossCheck({ text, outputs = [], userSaid = "" }) {
44
- const haystack = [...outputs.map(norm), norm(userSaid)];
45
- const claims = extractClaims(text).map((claim) => ({ ...claim, status: verdictFor(claim, haystack) }));
44
+ // Backing can come from an output OR from the user (repeating their datum is not
45
+ // inventing). The DENIED analysis reads only the OUTPUTS: the user asking "how
46
+ // many cards are left?" mentions the noun without saying anything about emptiness,
47
+ // and must not veto a denial — found by the stop-gate wiring test.
48
+ const sources = outputs.map(norm);
49
+ const haystack = [...sources, norm(userSaid)];
50
+ const claims = extractClaims(text).map((claim) => ({ ...claim, status: verdictFor(claim, haystack, sources) }));
46
51
  return {
47
52
  claims,
48
53
  denied: claims.filter((c) => c.status === DENIED),
@@ -50,15 +55,15 @@ export function crossCheck({ text, outputs = [], userSaid = "" }) {
50
55
  };
51
56
  }
52
57
 
53
- function verdictFor(claim, haystack) {
58
+ function verdictFor(claim, haystack, sources) {
54
59
  if (haystack.some((h) => appearsIn(h, claim))) return BACKED;
55
60
 
56
- // A count stated as non-zero while every source that mentions the same noun says empty:
61
+ // A count stated as non-zero while every OUTPUT that mentions the same noun says empty:
57
62
  // that is the "four cards are waiting" case, and it is the one that blocks.
58
63
  if (claim.kind === "count" && Number(claim.value) > 0) {
59
64
  const noun = nounAfterCount(claim.sentence, claim.value);
60
65
  if (noun) {
61
- const mentions = haystack.filter((h) => h.includes(norm(noun)));
66
+ const mentions = sources.filter((h) => h.includes(norm(noun)));
62
67
  if (mentions.length && mentions.every((h) => SAYS_EMPTY.test(h))) return DENIED;
63
68
  }
64
69
  // Small numbers are prose as often as data ("las dos capas", "3 reglas"): not worth accusing.
@@ -7,6 +7,7 @@
7
7
 
8
8
  /** The few commands a newcomer needs. Shown in `kj --help`. */
9
9
  export const CORE_COMMANDS = [
10
+ "go",
10
11
  "start",
11
12
  "init",
12
13
  "run",
@@ -30,7 +31,7 @@ export const ADVANCED_GROUPS = [
30
31
  { title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan", "tournament"] },
31
32
  { title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
32
33
  { title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
33
- { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims"] },
34
+ { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims", "steward"] },
34
35
  { title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity"] },
35
36
  { title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
36
37
  { title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
@@ -4,9 +4,11 @@ import { researcherCommand } from "../commands/researcher.js";
4
4
  import { architectCommand } from "../commands/architect.js";
5
5
  import { onboardCommand } from "../commands/onboard.js";
6
6
  import { startCommand } from "../commands/start.js";
7
+ import { goCommand } from "../commands/go.js";
7
8
  import { identityCommand } from "../commands/identity.js";
8
9
  import { policyCommand } from "../commands/policy.js";
9
- import { claimsCommand } from "../commands/claims.js";
10
+ import { claimsCommand, claimsGateCommand } from "../commands/claims.js";
11
+ import { stewardSweepCommand } from "../commands/steward.js";
10
12
  import { ragIndexCommand, ragQueryCommand, ragInstallHooksCommand, ragEvalCommand } from "../commands/rag.js";
11
13
  import { qmdQueryCommand } from "../commands/qmd.js";
12
14
  import { ragMcpCommand } from "../commands/rag-mcp.js";
@@ -147,6 +149,17 @@ export function registerMeta(program, { pkgVersion }) {
147
149
  });
148
150
  });
149
151
 
152
+ // MGL-A (KJC-TSK-0808): the muggle launcher — one command, at most one
153
+ // question, and the person lands inside a governed conversation.
154
+ program
155
+ .command("go")
156
+ .description("Arranca Karajan sin saber nada: detecta tu agente, prepara el proyecto, abre el tablero y te deja en la conversación")
157
+ .action(async (flags) => {
158
+ await withConfig(pkgVersion, "go", flags, async ({ config, logger }) => {
159
+ await goCommand({ config, logger, flags });
160
+ });
161
+ });
162
+
150
163
  program
151
164
  .command("start")
152
165
  .description("Single entry point: assess the project and recommend the next step")
@@ -314,12 +327,32 @@ export function registerMeta(program, { pkgVersion }) {
314
327
 
315
328
  // KJC-TSK-0733 PL-A — policy as code: motor determinista en modo warn.
316
329
  // CLM-B (KJC-TSK-0802): the data the AI states, checked against what actually ran.
317
- program.command("claims").description("Afirmaciones con fuente: comprueba los datos que la IA afirma en un turno contra las salidas de ese turno (ADR claims-with-evidence)")
318
- .command("check")
330
+ const stewardCmd = program.command("steward").description("El Steward: gobierna el ESTADO del proyecto — invariantes con caducidad y cuatro veredictos (épica claims/steward)");
331
+ stewardCmd.command("sweep")
332
+ .description("Barrido read-only de los invariantes: deja el informe versionado en .karajan/steward/ (md + json) y sale con 1 solo si algo está ROTO — unknown y not-observable informan con su remedio")
333
+ .option("--if-stale <days>", "Solo barre si el informe tiene más de N días — retomar trabajo con informe fresco no re-barre")
334
+ .option("--json", "Machine-readable")
335
+ .action(async (flags) => {
336
+ await withConfig(pkgVersion, "steward-sweep", flags, async ({ config }) => {
337
+ process.exitCode = await stewardSweepCommand({ flags, config });
338
+ });
339
+ });
340
+ const claimsCmd = program.command("claims").description("Afirmaciones con fuente: comprueba los datos que la IA afirma en un turno contra las salidas de ese turno (ADR claims-with-evidence)");
341
+ claimsCmd.command("check")
319
342
  .description("Cruza el mensaje final del turno con sus salidas — exit 2 solo si un dato está DESMENTIDO por su propia fuente; falla abierto si no puede leer el transcript")
320
343
  .requiredOption("--transcript <path>", "Ruta del transcript de la sesión (la que pasa el hook)")
344
+ .option("--file <path>", "Cruza el contenido de este fichero (cuerpo de PR, card) en vez del mensaje final del turno")
321
345
  .option("--json", "Machine-readable")
322
346
  .action(async (flags) => { process.exitCode = await claimsCommand({ flags }); });
347
+ claimsCmd.command("gate")
348
+ .description("El mismo cruce, gobernado por method_gates.claims del proyecto (off|warn|block) — lo invocan los hooks; off = silencio, block = exit 2 solo con un dato desmentido")
349
+ .requiredOption("--transcript <path>", "Ruta del transcript de la sesión")
350
+ .option("--file <path>", "Cruza el contenido de este fichero (cuerpo de PR, card) en vez del mensaje final del turno")
351
+ .action(async (flags) => {
352
+ await withConfig(pkgVersion, "claims-gate", flags, async ({ config }) => {
353
+ process.exitCode = await claimsGateCommand({ flags, config });
354
+ });
355
+ });
323
356
 
324
357
  const policyCmd = program.command("policy").description("Policy as code (.karajan/policy.yml, vocabulario cerrado): eval/check deterministas, grant con caducidad, anchor del decision log — deny en commit y CI");
325
358
  policyCmd.command("eval")
@@ -87,8 +87,23 @@ async function findAvailablePort(desiredPort, maxTries = 10) {
87
87
  * @returns {string}
88
88
  */
89
89
  export function buildBoardUrl(port, projectSlug) {
90
- const base = `http://localhost:${port}`;
91
- return projectSlug ? `${base}/#board/${projectSlug}` : base;
90
+ return projectSlug ? boardUrl(port, `/#board/${projectSlug}`) : boardUrl(port);
91
+ }
92
+
93
+ /**
94
+ * Base board URL plus an optional absolute path (query and/or hash) —
95
+ * `kj go` opens `/?maggle=1` so the frontend renders in plain language
96
+ * (KJC-TSK-0810). A relative path is a caller bug, not something to fix
97
+ * up silently.
98
+ * @param {number} port
99
+ * @param {string} [path]
100
+ * @returns {string}
101
+ */
102
+ export function boardUrl(port, path = "") {
103
+ if (path && !path.startsWith("/")) {
104
+ throw new Error(`board path must start with "/": ${path}`);
105
+ }
106
+ return `http://localhost:${port}${path}`;
92
107
  }
93
108
 
94
109
  /**
@@ -368,7 +383,7 @@ export function renderBoardBanner({ url, status, projectName }) {
368
383
  return ["", rule, ...content, rule, ""].join("\n");
369
384
  }
370
385
 
371
- export async function boardCommand({ action = "start", port = 4000, bind = "127.0.0.1", logger }) {
386
+ export async function boardCommand({ action = "start", port = 4000, bind = "127.0.0.1", logger, path }) {
372
387
  switch (action) {
373
388
  case "start": {
374
389
  let result;
@@ -422,7 +437,7 @@ export async function boardCommand({ action = "start", port = 4000, bind = "127.
422
437
  logger.info("HU Board is not running. Starting it first...");
423
438
  await startBoard(port);
424
439
  }
425
- const url = `http://localhost:${port}`;
440
+ const url = boardUrl(port, path);
426
441
  // eslint-disable-next-line import-x/no-unresolved -- optional peer; the .catch handles its absence
427
442
  const { default: open } = await import("open").catch(() => ({ default: null }));
428
443
  if (open) {
@@ -9,10 +9,41 @@
9
9
  * It fails OPEN. A verifier that cannot read the transcript says so and gets out
10
10
  * of the way: a broken check must never hold a session hostage.
11
11
  */
12
+ import { readFileSync } from "node:fs";
12
13
  import { readTurn } from "../claims/turn.js";
13
14
  import { crossCheck, formatClaimReport } from "../claims/cross-check.js";
14
15
 
15
- export async function claimsCommand({ flags = {}, logger = console, readTurnFn = readTurn } = {}) {
16
+ /**
17
+ * `kj claims gate` — the same check, run by the Stop hook with the PROJECT's
18
+ * say-so. The hook stays policy-free: kj reads `method_gates.claims` and
19
+ * decides. "off" (default: adoption is explicit) exits 0 in silence; "warn"
20
+ * reports and never blocks; "block" refuses only a datum DENIED by its own
21
+ * source — unbacked data is reported either way, per the accepted ADR:
22
+ * inform always, block almost never.
23
+ */
24
+ export async function claimsGateCommand({ flags = {}, config = {}, logger = console, readTurnFn = readTurn, readFileFn = readFileSync } = {}) {
25
+ const mode = config?.method_gates?.claims ?? "off";
26
+ if (mode !== "warn" && mode !== "block") return 0;
27
+ let turn;
28
+ try {
29
+ turn = readTurnFn(flags.transcript);
30
+ // CLM-C: with --file the ARTIFACT is what gets checked — a PR body, a card, a
31
+ // note — against the same turn's outputs. The final message is what outlives
32
+ // the turn least; the artifact is what outlives it most.
33
+ if (flags.file) turn = { ...turn, text: String(readFileFn(flags.file, "utf8")) };
34
+ } catch {
35
+ return 0; // not observable: a gate that cannot read its inputs gets out of the way
36
+ }
37
+ const result = crossCheck(turn);
38
+ if (result.denied.length && mode === "block") {
39
+ logger.error(formatClaimReport(result));
40
+ return 2;
41
+ }
42
+ if (result.denied.length || result.unbacked.length) logger.error(formatClaimReport(result));
43
+ return 0;
44
+ }
45
+
46
+ export async function claimsCommand({ flags = {}, logger = console, readTurnFn = readTurn, readFileFn = readFileSync } = {}) {
16
47
  const path = flags.transcript;
17
48
  if (!path) {
18
49
  logger.error("kj claims check: --transcript <path> is required");
@@ -21,6 +52,7 @@ export async function claimsCommand({ flags = {}, logger = console, readTurnFn =
21
52
  let turn;
22
53
  try {
23
54
  turn = readTurnFn(path);
55
+ if (flags.file) turn = { ...turn, text: String(readFileFn(flags.file, "utf8")) };
24
56
  } catch (err) {
25
57
  // Not observable: the transcript could not be read. Never reported as clean.
26
58
  const note = `kj claims: transcript not readable (${err.message}) — nothing checked`;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `kj go` (MGL-A, KJC-TSK-0808, epic KJC-PCS-0084) — the muggle launcher.
3
+ * One command for a person with no computing background: detect their agents,
4
+ * ask AT MOST one question, prepare the project silently, open the board, and
5
+ * leave them inside a conversation that already knows the method. What cannot
6
+ * be hidden is said honestly: the agent's account and login are THEIRS — kj
7
+ * never touches credentials. Everything is injectable so tests spawn nothing.
8
+ */
9
+ import { existsSync } from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { spawn } from "node:child_process";
13
+ import { checkBinary } from "../utils/agent-detect.js";
14
+ import { createCliAskQuestion } from "../utils/cli-ask-question.js";
15
+ import { envInstallCommand } from "./env.js";
16
+ import { boardCommand } from "./board.js";
17
+ // The interactive launchers a muggle can live inside (v1: the two the epic
18
+ // names). Auth heuristics are the same cheap file checks reviewer-fallback
19
+ // uses: menu signal, never a spawned process.
20
+ export const MAGGLE_AGENTS = [
21
+ { name: "claude", label: "Claude Code (Anthropic)", authPaths: [".claude.json", ".claude"], install: "npm install -g @anthropic-ai/claude-code", login: "claude (sigue el enlace de inicio de sesión que te muestre)" },
22
+ { name: "codex", label: "Codex (OpenAI)", authPaths: [".codex/auth.json"], install: "npm install -g @openai/codex", login: "codex login" },
23
+ ];
24
+ export async function detectMaggleAgents({ home = os.homedir(), checkBin = checkBinary } = {}) {
25
+ return Promise.all(
26
+ MAGGLE_AGENTS.map(async (a) => {
27
+ let installed = false;
28
+ try { installed = (await checkBin(a.name)).ok; } catch { /* not installed */ }
29
+ const authenticated = installed && a.authPaths.some((p) => existsSync(path.join(home, p)));
30
+ return { ...a, installed, authenticated };
31
+ }),
32
+ );
33
+ }
34
+ /** The session's opening prompt — SHORT and in plain language. The full
35
+ * playbook already lives in the agent files env-install wrote; duplicating it
36
+ * here would dilute it. This prompt sets the tone for a muggle. */
37
+ export function buildGoPrompt() {
38
+ return [
39
+ "Arrancas dentro de un proyecto gobernado por Karajan (las reglas del método ya están en los ficheros de agente de este proyecto: síguelas siempre).",
40
+ "La persona que te habla puede no saber programar. Habla en lenguaje llano: sin jerga, sin siglas sin explicar, y resume cada resultado en una o dos frases antes del detalle.",
41
+ "Antes de cambios importantes, di en llano qué vas a hacer y qué pasará. Si algo falla, explica qué pasó y cuál es el siguiente paso — nunca un volcado de error a secas.",
42
+ "El tablero del proyecto está abierto en su navegador: cuando termines algo, recuérdale que puede verlo ahí.",
43
+ "Empieza presentándote en dos frases y preguntando qué quiere construir o cambiar hoy.",
44
+ ].join("\n");
45
+ }
46
+ async function defaultPrepare({ config, logger }) {
47
+ await envInstallCommand({ config, logger, flags: { yes: true } });
48
+ }
49
+ export async function defaultBoard({ config, logger, runBoard = boardCommand }) {
50
+ const port = config.hu_board?.port || 4000;
51
+ await runBoard({ action: "start", port, bind: "127.0.0.1", logger });
52
+ // /?maggle=1 switches the frontend to plain language (KJC-TSK-0810) —
53
+ // the muggle's window opens already speaking their language.
54
+ await runBoard({ action: "open", port, bind: "127.0.0.1", path: "/?maggle=1", logger });
55
+ }
56
+ function defaultLaunch(agent, prompt) {
57
+ // Interactive session: the muggle LIVES here. CLAUDECODE is stripped so a
58
+ // nested Claude does not refuse to start (the known subprocess quirk).
59
+ const { CLAUDECODE: _omit, ...env } = process.env;
60
+ return new Promise((resolvePromise) => {
61
+ const child = spawn(agent.name, [prompt], { stdio: "inherit", env });
62
+ child.on("exit", (code) => resolvePromise(code ?? 0));
63
+ child.on("error", (err) => {
64
+ console.error(`No se pudo arrancar ${agent.label}: ${err.message}`);
65
+ resolvePromise(1);
66
+ });
67
+ });
68
+ }
69
+ export async function goCommand({ config = {}, logger = console, flags = {}, deps = {} } = {}) {
70
+ const projectDir = config.projectDir || process.cwd();
71
+ const agents = await (deps.detect ?? detectMaggleAgents)();
72
+ const ready = agents.filter((a) => a.installed && a.authenticated);
73
+ const needLogin = agents.filter((a) => a.installed && !a.authenticated);
74
+ if (ready.length === 0) {
75
+ if (needLogin.length > 0) {
76
+ logger.error?.("Tienes el agente instalado pero falta iniciar sesión — eso solo puedes hacerlo tú (la cuenta es tuya; Karajan nunca toca tus credenciales):");
77
+ for (const a of needLogin) logger.error?.(` ${a.label}: ${a.login}`);
78
+ logger.error?.("Cuando hayas iniciado sesión, vuelve a escribir: kj go");
79
+ } else {
80
+ logger.error?.("Karajan trabaja con un agente de IA que aún no tienes instalado. Elige uno, instálalo con su comando, e inicia sesión con TU cuenta (la cuenta es tuya):");
81
+ for (const a of agents) logger.error?.(` ${a.label}: ${a.install}`);
82
+ logger.error?.("Después, vuelve a escribir: kj go");
83
+ }
84
+ process.exitCode = 1;
85
+ return 1;
86
+ }
87
+ let chosen = ready[0];
88
+ if (ready.length > 1) {
89
+ const ask = deps.ask ?? createCliAskQuestion({ flags });
90
+ const answer = await ask("¿Con cuál de tus agentes quieres trabajar?", { options: ready.map((a) => a.name), default: ready[0].name });
91
+ chosen = ready.find((a) => a.name === String(answer).trim()) ?? ready[0];
92
+ }
93
+ logger.info?.(`Trabajarás con ${chosen.label}.`);
94
+ // Prepare ONCE: decisions already taken are never re-asked.
95
+ if (!existsSync(path.join(projectDir, ".karajan", "review-gate"))) {
96
+ logger.info?.("Preparando tu proyecto (solo la primera vez)…");
97
+ await (deps.prepare ?? defaultPrepare)({ config, logger });
98
+ }
99
+ // The board is the muggle's window — unless the project turned it off
100
+ // (hu_board.enabled false is respected: KJC-BUG-0152). A board failure is
101
+ // said and never stops the conversation from starting.
102
+ if (config.hu_board?.enabled !== false) {
103
+ try { await (deps.board ?? defaultBoard)({ config, logger }); } catch (err) { logger.warn?.(`El tablero no pudo abrirse (${err.message}) — la conversación arranca igual.`); }
104
+ }
105
+ const prompt = (deps.prompt ?? buildGoPrompt)();
106
+ logger.info?.("Abriendo tu conversación… (escribe ahí lo que necesites, en tu idioma)");
107
+ const code = await (deps.launch ?? defaultLaunch)(chosen, prompt);
108
+ process.exitCode = code;
109
+ return code;
110
+ }
@@ -43,7 +43,9 @@ export function findTitleMatches(title, allHus) {
43
43
  return { identical, similar };
44
44
  }
45
45
 
46
- async function backlogPlan(projectDir) {
46
+ // Exported for the Steward's proposed-work sync (KJC-TSK-0792): broken
47
+ // invariants land in the same backlog the brain already consumes.
48
+ export async function backlogPlan(projectDir) {
47
49
  const plans = await listPlans(projectDir);
48
50
  const existing = plans.find((p) => p.alias === BACKLOG_NAME || p.name === BACKLOG_NAME);
49
51
  if (existing) return loadPlan(projectDir, existing.planId);