karajan-code 4.28.1 → 4.28.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,7 +41,29 @@ This repo runs under its own environment: every commit to karajan-code carries a
41
41
 
42
42
  ## Install
43
43
 
44
- Tell your agent — in the directory where you want to work:
44
+ Two ways in — pick your comfort level.
45
+
46
+ ### Maggle mode — you never touch the terminal
47
+
48
+ Not a developer, or you just want it running? Paste this into your AI assistant (Claude Code, Codex, Cursor, VS Code):
49
+
50
+ ```text
51
+ Set up Karajan in this project and start it for me: read
52
+ https://karajancode.com/go.md and do everything it says. I'm not a developer —
53
+ don't ask me technical questions, just get it running and tell me when it's ready.
54
+ ```
55
+
56
+ It installs Karajan, prepares the project, and opens a single window with the board and your agent inside — stopping only if a step truly needs you (sudo or an account). Already installed? One command:
57
+
58
+ ```sh
59
+ kj go # or: kj go --window (the agent's terminal embedded in the board)
60
+ ```
61
+
62
+ `kj go` detects your agent (asks which only if you have several), prepares the project silently the first time, and drops you into a conversation that already follows the method. Your agent account and login stay yours — kj never touches credentials.
63
+
64
+ ### Developer mode — you stay in control
65
+
66
+ Want your agent to walk each step and stop at every permission? Paste:
45
67
 
46
68
  ```text
47
69
  I want to use Karajan in this project: read https://karajancode.com/start.md
@@ -59,14 +81,6 @@ kj init && kj env install && kj harden && kj review --install-gate
59
81
  git config core.hooksPath .karajan/hooks
60
82
  ```
61
83
 
62
- Already installed and just want to work — even with no computing background? One command:
63
-
64
- ```sh
65
- kj go
66
- ```
67
-
68
- It detects your agent (Claude Code or Codex; asks which one only if you have both), prepares the project silently the first time, opens the board in your browser, and drops you into a conversation that already follows the method. Your agent account and login stay yours — kj never touches credentials. Prefer everything in ONE browser window? `kj go --window` embeds the agent's real terminal inside the board (loopback-only, single-session token).
69
-
70
84
  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/).
71
85
 
72
86
  ## The daily loop
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.28.1",
3
+ "version": "4.28.2",
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",
@@ -33,8 +33,25 @@ const pkgName = pkg.name;
33
33
 
34
34
  // Subprocess env: strip CLAUDECODE (Claude Code blocks nested non-interactive
35
35
  // runs otherwise) and force a non-interactive, quiet npm.
36
+ // KJC-BUG-0171: the tarball's postinstall registers karajan-mcp in
37
+ // ~/.claude.json / ~/.codex / ~/.karajan pointing at THIS install's path. Every
38
+ // verify install lives in a throwaway prefix, so those paths go stale the moment
39
+ // the temp dir is cleaned — a CONNECTION_CLOSED MCP left in the real user config.
40
+ // Redirect HOME so every global-config write lands in an isolated dir removed
41
+ // with the rest; seed a git identity so any commit still works without ~/.gitconfig.
42
+ const homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), "kj-verify-home-"));
36
43
  const { CLAUDECODE: _omit, ...cleanEnv } = process.env;
37
- const childEnv = { ...cleanEnv, npm_config_yes: "true", CI: "1" };
44
+ const childEnv = {
45
+ ...cleanEnv,
46
+ npm_config_yes: "true",
47
+ CI: "1",
48
+ HOME: homeTmp,
49
+ USERPROFILE: homeTmp,
50
+ GIT_AUTHOR_NAME: "kj verify",
51
+ GIT_AUTHOR_EMAIL: "verify@kj.local",
52
+ GIT_COMMITTER_NAME: "kj verify",
53
+ GIT_COMMITTER_EMAIL: "verify@kj.local",
54
+ };
38
55
 
39
56
  function run(cmd, args, opts = {}) {
40
57
  return execFileSync(cmd, args, {
@@ -271,6 +288,7 @@ try {
271
288
  console.log(`\n✓ verify-pack: ${pkgName}@${expectedVersion} installs clean and runs.`);
272
289
  } finally {
273
290
  if (tgzPath && fs.existsSync(tgzPath)) fs.rmSync(tgzPath, { force: true });
291
+ if (homeTmp && fs.existsSync(homeTmp)) fs.rmSync(homeTmp, { recursive: true, force: true });
274
292
  if (tmpDir && fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
275
293
  if (gTmp && fs.existsSync(gTmp)) fs.rmSync(gTmp, { recursive: true, force: true });
276
294
  if (pnpmTmp && fs.existsSync(pnpmTmp)) fs.rmSync(pnpmTmp, { recursive: true, force: true });
@@ -8,9 +8,11 @@
8
8
  * ~/.karajan/kj.config.yml (strategy: manual — structural fixes needed)
9
9
  */
10
10
 
11
+ import { existsSync } from "node:fs";
11
12
  import fs from "node:fs/promises";
12
13
  import os from "node:os";
13
14
  import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
14
16
  import { exists } from "../utils/fs.js";
15
17
  import { getConfigPath } from "../config.js";
16
18
  import { resolveRoleMdPath, loadFirstExisting } from "../roles/base-role.js";
@@ -132,6 +134,89 @@ function createClaudeConfigCheck() {
132
134
  };
133
135
  }
134
136
 
137
+ /**
138
+ * KJC-BUG-0171: the karajan-mcp server path registered in ~/.claude.json.
139
+ * Returns the args entry that points at the MCP server (…/mcp/server.js), or
140
+ * null when karajan-mcp is not registered.
141
+ */
142
+ export function karajanMcpServerPath(config) {
143
+ const server = config?.mcpServers?.["karajan-mcp"];
144
+ const args = Array.isArray(server?.args) ? server.args : [];
145
+ return args.find((a) => typeof a === "string" && /[\\/]mcp[\\/]server\.js$/.test(a)) ?? null;
146
+ }
147
+
148
+ /**
149
+ * Repair a stale karajan-mcp registration in place: re-point it to this
150
+ * install's server when that server exists, or drop the dead entry when this
151
+ * install has no bundled server (the standalone binary). `serverExists` is
152
+ * injectable for tests. Mutates `config`; returns "repointed" | "removed" | null.
153
+ */
154
+ export function repairKarajanMcpPath(config, correctServerPath, serverExists) {
155
+ const server = config?.mcpServers?.["karajan-mcp"];
156
+ if (!server) return null;
157
+ if (correctServerPath && serverExists(correctServerPath)) {
158
+ server.args = [correctServerPath];
159
+ server.cwd = path.resolve(path.dirname(correctServerPath), "..", "..");
160
+ return "repointed";
161
+ }
162
+ delete config.mcpServers["karajan-mcp"];
163
+ return "removed";
164
+ }
165
+
166
+ /**
167
+ * karajan-mcp registration path validity (~/.claude.json) — KJC-BUG-0171.
168
+ * A verify/temp install used to register the MCP at a throwaway prefix; once
169
+ * cleaned, the path 404s and every session's MCP fails with CONNECTION_CLOSED,
170
+ * and the health check never caught it (it spawns its OWN bundled server).
171
+ */
172
+ function createStaleMcpPathCheck() {
173
+ return {
174
+ name: "agent-config:karajan-mcp-path",
175
+ label: "MCP registration path (~/.claude.json)",
176
+ // KJC-BUG-0172: AUTO, not PROMPT — the runner never remediates a WARN
177
+ // check whose strategy is PROMPT, so the repair was inert under `kj doctor
178
+ // -y`. Re-pointing a dead entry to the current server (or dropping it) is
179
+ // deterministic and reversible on an already-broken entry; `--check-only`
180
+ // stays available to detect without touching the config.
181
+ strategy: STRATEGY.AUTO,
182
+ describe: "Re-point karajan-mcp in ~/.claude.json to this install's server (or drop a dead entry)",
183
+ async detect() {
184
+ const claudeJsonPath = path.join(os.homedir(), ".claude.json");
185
+ let config;
186
+ try {
187
+ config = JSON.parse(await fs.readFile(claudeJsonPath, "utf8"));
188
+ } catch {
189
+ return { ok: true, severity: "info", detail: "Not present or unparseable (skipped)" };
190
+ }
191
+ const registered = karajanMcpServerPath(config);
192
+ if (!registered) return { ok: true, severity: "info", detail: "karajan-mcp not registered (skipped)" };
193
+ if (await exists(registered)) return { ok: true, severity: "info", detail: "karajan-mcp path valid" };
194
+ const correct = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "mcp", "server.js");
195
+ return {
196
+ ok: false,
197
+ severity: "warn",
198
+ detail: `karajan-mcp points at a path that no longer exists: ${registered} — this is the CONNECTION_CLOSED cause`,
199
+ fix: `Re-point karajan-mcp to ${correct} (or remove the entry) in ~/.claude.json`,
200
+ extra: { claudeJsonPath, correct },
201
+ };
202
+ },
203
+ async remediate({ extra }) {
204
+ try {
205
+ const config = JSON.parse(await fs.readFile(extra.claudeJsonPath, "utf8"));
206
+ const action = repairKarajanMcpPath(config, extra.correct, existsSync);
207
+ if (!action) return { fixed: false, detail: "karajan-mcp entry vanished before repair" };
208
+ await fs.writeFile(extra.claudeJsonPath, `${JSON.stringify(config, null, 2)}\n`);
209
+ return {
210
+ fixed: true,
211
+ detail: action === "repointed" ? `Re-pointed karajan-mcp to ${extra.correct}` : "Removed the dead karajan-mcp entry",
212
+ };
213
+ } catch (err) {
214
+ return { fixed: false, detail: `Failed to repair ~/.claude.json: ${err.message}` };
215
+ }
216
+ },
217
+ };
218
+ }
219
+
135
220
  /**
136
221
  * Codex config (~/.codex/config.toml) validity.
137
222
  */
@@ -211,6 +296,7 @@ export function getConfigFileChecks() {
211
296
  createReviewRulesCheck(),
212
297
  createCoderRulesCheck(),
213
298
  createClaudeConfigCheck(),
299
+ createStaleMcpPathCheck(),
214
300
  createCodexConfigCheck(),
215
301
  createKjConfigYamlCheck(),
216
302
  ];
@@ -234,12 +234,15 @@ export function registerPipeline(program, { pkgVersion }) {
234
234
  .option("--check", "Verify the recorded verdict matches the staged diff (exit 0/1, hook-friendly)")
235
235
  .option("--range <range>", "Review a git range (e.g. main..HEAD) instead of the staged diff")
236
236
  .option("--install-gate", "Enable the pre-commit review gate for this project (creates .karajan/review-gate)")
237
+ .option("--prune", "Garbage-collect verdicts in .karajan/reviews older than the TTL (they age out as diffs change)")
238
+ .option("--prune-days <n>", "TTL in days for --prune (default: 14)")
239
+ .option("--dry-run", "With --prune: report what would be removed without deleting")
237
240
  .option("--no-sonar", "Skip the deterministic Sonar pre-gate that runs before the cross-AI verdict")
238
241
  .action(async (task, flags) => {
239
242
  await withConfig(pkgVersion, "review", flags, async ({ config, logger }) => {
240
243
  // ENV-B1 (KJC-TSK-0637): the gate mode records a verdict tied to
241
244
  // the exact diff so the pre-commit hook can enforce cross-AI review.
242
- if (flags.staged || flags.check || flags.range || flags.installGate) {
245
+ if (flags.staged || flags.check || flags.range || flags.installGate || flags.prune) {
243
246
  await reviewGateCommand({ config, logger, flags: { ...flags, task } });
244
247
  return;
245
248
  }
@@ -8,7 +8,7 @@
8
8
  * no session and no flags → exit 1.
9
9
  */
10
10
 
11
- import { enrollPhone } from "../harden/phone-sign.js";
11
+ import { addSigner, enrollPhone } from "../harden/phone-sign.js";
12
12
  import { readIdentity, writeIdentity } from "../identity/store.js";
13
13
  import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
14
14
  import { compareIdentity } from "../identity/compare.js";
@@ -27,12 +27,15 @@ export async function identityCommand({ action = "show", config, flags = {}, dep
27
27
  // KJC-TSK-0822: enrola la clave PÚBLICA del móvil (la privada nunca toca esta máquina).
28
28
  if (action === "enroll-phone") {
29
29
  try {
30
+ // KJC-TSK-0831 (ADR 0010): añade al PADRÓN versionado del repo (compartible,
31
+ // base para la verificación en CI) y mantiene la clave legacy en ~/.karajan.
32
+ addSigner(flags.publicKeyBase64, { projectDir, label: flags.label });
30
33
  enrollPhone(flags.publicKeyBase64, { home: deps.home });
31
34
  } catch (err) {
32
35
  log(`kj identity enroll-phone: ${err.message}`);
33
36
  return 1;
34
37
  }
35
- log("Móvil enrolado: la clave pública quedó en ~/.karajan/supervisor-phone.json. A partir de ahora, sellar el supervisor pedirá la firma de tu móvil.");
38
+ log("Móvil enrolado: la clave pública se añadió al padrón .karajan/supervisor-signers.json (commítealo) y a ~/.karajan/supervisor-phone.json. Sellar el supervisor pedirá la firma de un móvil del padrón.");
36
39
  return 0;
37
40
  }
38
41
 
@@ -8,7 +8,7 @@
8
8
  import { existsSync } from "node:fs";
9
9
  import { isAbsolute, join } from "node:path";
10
10
  import { runCommand } from "../utils/process.js";
11
- import { checkVerdict, diffHash } from "../review/verdict-store.js";
11
+ import { checkVerdict, diffHash, pruneVerdicts } from "../review/verdict-store.js";
12
12
  import { runOneShotReview } from "../review/one-shot-review.js";
13
13
  import { runSolomonArbitration } from "../review/solomon-arbitration.js";
14
14
  import { ensureGateTrackable } from "../review/gate-gitignore.js";
@@ -144,6 +144,21 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
144
144
  return { installed: true };
145
145
  }
146
146
 
147
+ // KJC-BUG-0173: manual GC of the verdict store. Opportunistic pruning runs
148
+ // on every saved verdict; this lets a human sweep on demand, with --dry-run
149
+ // to see what would go without deleting.
150
+ if (flags.prune) {
151
+ const dryRun = Boolean(flags.dryRun || flags.checkOnly);
152
+ const days = Number(flags.pruneDays);
153
+ const res = await pruneVerdicts({ projectDir, dryRun, ...(Number.isFinite(days) && days > 0 ? { maxAgeDays: days } : {}) });
154
+ console.log(
155
+ dryRun
156
+ ? `kj review --prune (dry run): ${res.expired.length} of ${res.scanned} verdict(s) older than ${res.maxAgeDays}d would be removed`
157
+ : `kj review --prune: removed ${res.removed} verdict(s) older than ${res.maxAgeDays}d (${res.scanned - res.removed} kept)`,
158
+ );
159
+ return { pruned: res.removed, dryRun };
160
+ }
161
+
147
162
  const diff = await rawDiff(flags.range);
148
163
 
149
164
  const card = await enforceCardFirst({ config, projectDir });
@@ -12,7 +12,7 @@ import { join } from "node:path";
12
12
 
13
13
  import { upsertManagedBlock } from "../utils/managed-markers.js";
14
14
  import { runCommand } from "../utils/process.js";
15
- import { hookBody, PROFILE_HOOKS, SHEBANG } from "./hook-templates.js";
15
+ import { hookBody, PROFILE_HOOKS, SHEBANG, toPortableHooksDir } from "./hook-templates.js";
16
16
 
17
17
  const BLOCK_VERSION = 1;
18
18
  const HOOKS_DIR = join(".karajan", "hooks");
@@ -67,7 +67,10 @@ export async function installHooks({
67
67
  const globalCfg = await runCommand("git", ["config", "--global", "core.hooksPath"], { cwd: projectDir });
68
68
  const rawGlobal = globalCfg.exitCode === 0 ? globalCfg.stdout.trim() : "";
69
69
  if (rawGlobal && rawGlobal !== HOOKS_DIR) {
70
- globalHooksDir = rawGlobal.startsWith("~") ? `$HOME${rawGlobal.slice(1)}` : rawGlobal;
70
+ // KJC-BUG-0169: normalize to a machine-independent form HERE (generation,
71
+ // on the human's machine) so the provenance stores the portable dir and CI
72
+ // recomputes the SAME bytes — an absolute home path is not verifiable there.
73
+ globalHooksDir = toPortableHooksDir(rawGlobal);
71
74
  }
72
75
 
73
76
  const absHooksDir = join(projectDir, HOOKS_DIR);
@@ -27,16 +27,24 @@ export const PROFILE_HOOKS = {
27
27
  // hooks dir, silently disabling personal guards. When a previous global dir
28
28
  // is known, every generated hook ends by chaining its namesake there —
29
29
  // guarded, so machines without it are unaffected.
30
+ // KJC-BUG-0161 / 0169 (ADR 0009): a committed hook must not bake a machine's
31
+ // absolute home path — a dir under the home resolves through $HOME so the same
32
+ // generated content is valid on every machine and its provenance stays
33
+ // verifiable IN CI (a different home), with no local filesystem layout leaking
34
+ // into a public repo. Normalization happens ONCE, at generation, on the human's
35
+ // machine; the stored/rendered value is already portable, so a render on any
36
+ // other home (CI) reproduces identical bytes. `home` is injectable for tests.
37
+ export function toPortableHooksDir(dir, home = homedir()) {
38
+ if (!dir) return dir;
39
+ if (dir.startsWith("$HOME")) return dir;
40
+ if (dir.startsWith("~")) return `$HOME${dir.slice(1)}`;
41
+ if (dir === home || dir.startsWith(`${home}/`)) return `$HOME${dir.slice(home.length)}`;
42
+ return dir;
43
+ }
44
+
30
45
  function chainToGlobal(hook, globalHooksDir) {
31
46
  if (!globalHooksDir) return [];
32
- // KJC-BUG-0161 (ADR 0009): a committed hook must not bake a machine's
33
- // absolute home path — resolve through $HOME at runtime so the same
34
- // generated content is valid on every machine and its provenance stays
35
- // verifiable (and no local filesystem layout leaks into a public repo).
36
- const home = homedir();
37
- const dir = globalHooksDir === home || globalHooksDir.startsWith(`${home}/`)
38
- ? `$HOME${globalHooksDir.slice(home.length)}`
39
- : globalHooksDir;
47
+ const dir = toPortableHooksDir(globalHooksDir);
40
48
  return [
41
49
  "# Chain the machine's previous global hook (kj harden keeps it active).",
42
50
  `if [ -x "${dir}/${hook}" ]; then`,
@@ -114,12 +122,19 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
114
122
  "# the same precise pattern (tool MENTIONS stay legal; attribution not).",
115
123
  "# The guard's own definition files are excluded by literal path — they",
116
124
  "# CONTAIN the pattern; excluding a path that does not exist is harmless.",
117
- `if git diff --cached -- . ':(exclude).github/workflows/kj-no-ai-attribution.yml' ':(exclude)src/harden/hook-templates.js' ':(exclude)src/harden/workflow-templates.js' ':(exclude)src/harden/sentinel-hooks.js' ':(exclude)scripts/ai-attribution-guard.yml' ':(exclude)tests/harden/attribution-guard.test.js' ':(exclude)tests/harden/sentinel-hooks.test.js' | grep '^+' | grep -qiE '${AI_ATTRIBUTION}|generated with \\[?claude'; then`,
125
+ "# KJC-BUG-0170: the generated supervisor hooks (.karajan/hooks/pre-commit,",
126
+ "# commit-msg) also CONTAIN the pattern by design — the bootstrap commit",
127
+ "# that versions them must not self-detect in the consumer repo.",
128
+ `if git diff --cached -- . ':(exclude).github/workflows/kj-no-ai-attribution.yml' ':(exclude)src/harden/hook-templates.js' ':(exclude)src/harden/workflow-templates.js' ':(exclude)src/harden/sentinel-hooks.js' ':(exclude)scripts/ai-attribution-guard.yml' ':(exclude)tests/harden/attribution-guard.test.js' ':(exclude)tests/harden/sentinel-hooks.test.js' ':(exclude).karajan/hooks/pre-commit' ':(exclude).karajan/hooks/commit-msg' | grep '^+' | grep -qiE '${AI_ATTRIBUTION}|generated with \\[?claude'; then`,
118
129
  " echo 'kj harden: AI attribution is not allowed in committed content'; exit 1",
119
130
  "fi",
120
131
  "# v4 review gate (ENV-C1, opt-in via `kj review --install-gate`):",
121
132
  "# a staged diff only enters with a recorded cross-AI approved verdict.",
122
- "if [ -f .karajan/review-gate ]; then",
133
+ "# KJC-BUG-0165: the gate is active once the marker is COMMITTED (HEAD),",
134
+ "# not merely present — so the bootstrap commit that INTRODUCES it is",
135
+ "# exempt (no gate can exist before it does), and it cannot be bypassed",
136
+ "# by deleting the working-tree file.",
137
+ "if git cat-file -e HEAD:.karajan/review-gate 2>/dev/null; then",
123
138
  " if ! command -v kj >/dev/null 2>&1; then",
124
139
  " echo 'kj: review gate is enabled but kj is not installed — see karajancode.com/docs/getting-started/installation'; exit 1",
125
140
  " fi",
@@ -32,21 +32,78 @@ async function relayConfig(fetchFn) {
32
32
  }
33
33
  const SIGN_PAGE = "https://karajancode.com/sign";
34
34
  const POLL_INTERVAL_MS = 2000;
35
- const TTL_MS = 120000;
35
+ const TTL_MS = 60000;
36
+
37
+ /** Spinner en su sitio (una línea, con \r) solo en TTY; no-op fuera de TTY o en tests. */
38
+ function makeSpinner() {
39
+ if (!process.stdout?.isTTY) return { tick() {}, stop() {} };
40
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
41
+ let i = 0;
42
+ return {
43
+ tick() { process.stdout.write(`\r${frames[i++ % frames.length]} esperando la firma del móvil…`); },
44
+ stop() { process.stdout.write("\r\x1b[K"); },
45
+ };
46
+ }
36
47
 
37
48
  const phoneKeyPath = (home) => join(home ?? homedir(), ".karajan", "supervisor-phone.json");
38
49
 
50
+ // KJC-TSK-0831 (ADR 0010): el padrón de firmantes vive VERSIONADO en el repo,
51
+ // no una única clave local — así un equipo comparte la confianza y el CI puede
52
+ // verificar (0823) contra el mismo conjunto. La legacy ~/.karajan sigue valiendo.
53
+ const signersPath = (projectDir) => join(projectDir ?? process.cwd(), ".karajan", "supervisor-signers.json");
54
+
55
+ /** Valida una clave pública ed25519 raw en base64 canónico (32 bytes). */
56
+ export function validatePhonePublicKey(publicKeyBase64) {
57
+ const raw = Buffer.from(publicKeyBase64 ?? "", "base64");
58
+ if (raw.length !== 32 || raw.toString("base64") !== publicKeyBase64) {
59
+ throw new Error("la clave debe ser base64 canónico de EXACTAMENTE 32 bytes (ed25519 raw)");
60
+ }
61
+ return publicKeyBase64;
62
+ }
63
+
39
64
  export const isPhoneEnrolled = ({ home } = {}) => existsSync(phoneKeyPath(home));
40
65
 
41
66
  export const readEnrolledKey = ({ home } = {}) =>
42
67
  JSON.parse(readFileSync(phoneKeyPath(home), "utf8")).publicKey;
43
68
 
44
- /** Valida y persiste la clave pública del móvil (base64 del raw de 32 bytes). */
45
- export function enrollPhone(publicKeyBase64, { home } = {}) {
46
- const raw = Buffer.from(publicKeyBase64 ?? "", "base64");
47
- if (raw.length !== 32 || raw.toString("base64") !== publicKeyBase64) {
48
- throw new Error("la clave debe ser base64 canónico de EXACTAMENTE 32 bytes (ed25519 raw)");
69
+ /** Claves públicas del padrón versionado del repo (o [] si no hay). */
70
+ export function readSigners({ projectDir } = {}) {
71
+ try {
72
+ const doc = JSON.parse(readFileSync(signersPath(projectDir), "utf8"));
73
+ return Array.isArray(doc?.signers) ? doc.signers.map((s) => s.publicKey).filter(Boolean) : [];
74
+ } catch {
75
+ return [];
49
76
  }
77
+ }
78
+
79
+ /**
80
+ * Conjunto de claves AUTORIZADAS a sellar: el padrón del repo ∪ la clave legacy
81
+ * de ~/.karajan (compat). Sin fallbacks silenciosos: si no hay ninguna, el Set
82
+ * está vacío y la verificación fallará ruidosamente.
83
+ */
84
+ export function readEnrolledKeys({ projectDir, home } = {}) {
85
+ const keys = new Set(readSigners({ projectDir }));
86
+ if (isPhoneEnrolled({ home })) keys.add(readEnrolledKey({ home }));
87
+ return keys;
88
+ }
89
+
90
+ /** Añade una clave pública al padrón versionado del repo (dedup). Acto humano. */
91
+ export function addSigner(publicKeyBase64, { projectDir, label } = {}) {
92
+ validatePhonePublicKey(publicKeyBase64);
93
+ const path = signersPath(projectDir);
94
+ let doc = { signers: [] };
95
+ try { doc = JSON.parse(readFileSync(path, "utf8")); } catch { /* new file */ }
96
+ if (!Array.isArray(doc.signers)) doc.signers = [];
97
+ if (doc.signers.some((s) => s.publicKey === publicKeyBase64)) return doc;
98
+ doc.signers.push({ publicKey: publicKeyBase64, label: label ?? null, enrolledAt: new Date().toISOString() });
99
+ mkdirSync(join(path, ".."), { recursive: true });
100
+ writeFileSync(path, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
101
+ return doc;
102
+ }
103
+
104
+ /** Valida y persiste la clave pública del móvil (legacy: clave única ~/.karajan). */
105
+ export function enrollPhone(publicKeyBase64, { home } = {}) {
106
+ validatePhonePublicKey(publicKeyBase64);
50
107
  mkdirSync(join(phoneKeyPath(home), ".."), { recursive: true });
51
108
  const record = { publicKey: publicKeyBase64, enrolledAt: new Date().toISOString() };
52
109
  writeFileSync(phoneKeyPath(home), `${JSON.stringify(record, null, 2)}\n`, "utf8");
@@ -98,22 +155,34 @@ export async function requestPhoneSignature({ project, files, kjVersion, logger
98
155
  const signUrl = `${SIGN_PAGE}?c=${cid}`;
99
156
  drawQr(signUrl);
100
157
  logger.info?.(`phone-sign: escanea el QR o abre ${signUrl} y firma en el móvil (caduca en ${TTL_MS / 1000}s)`);
158
+ logger.info?.("phone-sign: esperando la firma del móvil…");
159
+ const spinner = deps.spinner ?? makeSpinner();
101
160
  const deadline = now() + TTL_MS;
102
161
  while (now() < deadline) {
103
162
  const res = await fetchFn(`${relay.url}/${cid}?key=${relay.apiKey}`);
104
- if (!res.ok) throw new Error(`phone-sign: fallo consultando la petición de firma (HTTP ${res.status})`);
163
+ if (!res.ok) { spinner.stop(); throw new Error(`phone-sign: fallo consultando la petición de firma (HTTP ${res.status})`); }
105
164
  const fields = (await res.json()).fields ?? {};
106
165
  if (fields.state?.stringValue === "signed") {
107
- const enrolled = readEnrolledKey({ home: deps.home });
108
- if (fields.publicKey?.stringValue !== enrolled) {
109
- return { ok: false, reason: "la publicKey del doc no coincide con la enrolada — el doc es transporte, la verdad es la enrolada" };
166
+ spinner.stop();
167
+ // KJC-TSK-0831 (ADR 0010): la clave que firma debe estar en el PADRÓN
168
+ // autorizado (repo ∪ legacy). El doc es transporte; la verdad es el padrón.
169
+ const authorized = readEnrolledKeys({ projectDir: deps.projectDir, home: deps.home });
170
+ const signerKey = fields.publicKey?.stringValue;
171
+ if (!signerKey || !authorized.has(signerKey)) {
172
+ return { ok: false, reason: "la publicKey que firma no está en el padrón autorizado — el doc es transporte, la verdad es el padrón" };
110
173
  }
111
174
  const payload = canonicalPayload({ cid, nonce, project, files });
112
- const good = verifyPhoneSignature({ payload, signature: fields.signature?.stringValue ?? "", publicKey: enrolled });
113
- return good ? { ok: true } : { ok: false, reason: "firma ed25519 inválida para el payload canónico" };
175
+ const signature = fields.signature?.stringValue ?? "";
176
+ const good = verifyPhoneSignature({ payload, signature, publicKey: signerKey });
177
+ if (!good) return { ok: false, reason: "firma ed25519 inválida para el payload canónico" };
178
+ // KJC-TSK-0823: devuelve el desafío + firma para que el sello los GRABE en
179
+ // la provenance y CI pueda re-verificar server-side (clave del padrón).
180
+ const filesHash = createHash("sha256").update(JSON.stringify(files)).digest("hex");
181
+ return { ok: true, signer: signerKey, signature, challenge: { cid, nonce, project, filesHash } };
114
182
  }
115
- logger.info?.("phone-sign: esperando la firma del móvil…");
183
+ spinner.tick();
116
184
  await sleep(POLL_INTERVAL_MS);
117
185
  }
186
+ spinner.stop();
118
187
  return { ok: false, reason: "caducado" };
119
188
  }
@@ -123,6 +123,7 @@ export async function commitSupervisorRegeneration({
123
123
  // es OBLIGATORIA — jamás se degrada a solo-nonce. Firma los MISMOS
124
124
  // files/hashes de la provenance (normalizados al par {file, sha256}).
125
125
  const phone = deps.phone ?? { enrolled: isPhoneEnrolled, request: requestPhoneSignature };
126
+ let signatureBlock = null;
126
127
  if (phone.enrolled({})) {
127
128
  const signed = await phone.request({
128
129
  project: basename(projectDir),
@@ -133,6 +134,11 @@ export async function commitSupervisorRegeneration({
133
134
  if (!signed.ok) {
134
135
  throw new Error(`capa 5: firma del móvil rechazada (${signed.reason}) — con móvil enrolado el sello exige su firma (PRP-0023)`);
135
136
  }
137
+ // KJC-TSK-0823: graba el desafío + firma en la provenance para que CI
138
+ // re-verifique server-side que un humano (clave del padrón) aprobó.
139
+ if (signed.challenge && signed.signature) {
140
+ signatureBlock = { ...signed.challenge, signer: signed.signer, signature: signed.signature };
141
+ }
136
142
  }
137
143
  const who = readIdentity(projectDir);
138
144
  const provenance = {
@@ -141,6 +147,7 @@ export async function commitSupervisorRegeneration({
141
147
  generation,
142
148
  who: who ? { gh: who.gh_user ?? null, git: who.git_email ?? null, grade: "declarada" } : null,
143
149
  files: hashed,
150
+ ...(signatureBlock ? { signature: signatureBlock } : {}),
144
151
  };
145
152
  writeFileSync(join(projectDir, PROVENANCE_FILE), `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
146
153
  recordGateDecision(projectDir, {
@@ -16,6 +16,11 @@ import { runCommand } from "../utils/process.js";
16
16
 
17
17
  const STORE_DIR = path.join(".karajan", "reviews");
18
18
 
19
+ // KJC-BUG-0173: a verdict is keyed by one exact diff hash, so once that diff is
20
+ // committed or changed its hash is never looked up again — anything older than
21
+ // this TTL is dead weight and safe to shed. Nothing live stays staged for weeks.
22
+ export const VERDICT_TTL_DAYS = 14;
23
+
19
24
  export function diffHash(diff) {
20
25
  // trimEnd: runners differ on the final newline (execa strips it, raw
21
26
  // git keeps it) — trailing whitespace must not void a verdict.
@@ -36,9 +41,44 @@ export async function saveVerdict(projectDir, diff, verdict) {
36
41
  const file = verdictPath(projectDir, hash);
37
42
  await fs.mkdir(path.dirname(file), { recursive: true });
38
43
  await fs.writeFile(file, `${JSON.stringify(record, null, 2)}\n`);
44
+ // KJC-BUG-0173: a review is the natural moment to shed dead verdicts.
45
+ // Best-effort — hygiene must never break a save.
46
+ try { await pruneVerdicts({ projectDir }); } catch { /* hygiene, not correctness */ }
39
47
  return record;
40
48
  }
41
49
 
50
+ /**
51
+ * Opportunistic GC for the verdict store: removes verdict files whose mtime is
52
+ * older than `maxAgeDays`. A verdict only counts for a byte-identical diff, so
53
+ * an aged one can never be re-checked. `now`/`dryRun` injectable for tests.
54
+ * @returns {Promise<{scanned:number, removed:number, expired:string[], dryRun:boolean, maxAgeDays:number}>}
55
+ */
56
+ export async function pruneVerdicts({ projectDir, maxAgeDays = VERDICT_TTL_DAYS, now = Date.now, dryRun = false } = {}) {
57
+ const dir = path.join(projectDir || process.cwd(), STORE_DIR);
58
+ const cutoff = now() - maxAgeDays * 86400000;
59
+ let names;
60
+ try {
61
+ names = await fs.readdir(dir);
62
+ } catch {
63
+ return { scanned: 0, removed: 0, expired: [], dryRun, maxAgeDays };
64
+ }
65
+ const jsons = names.filter((n) => n.endsWith(".json"));
66
+ const expired = [];
67
+ for (const name of jsons) {
68
+ try {
69
+ const st = await fs.stat(path.join(dir, name));
70
+ if (st.mtimeMs < cutoff) expired.push(name);
71
+ } catch { /* vanished mid-scan */ }
72
+ }
73
+ let removed = 0;
74
+ if (!dryRun) {
75
+ for (const name of expired) {
76
+ try { await fs.unlink(path.join(dir, name)); removed += 1; } catch { /* already gone */ }
77
+ }
78
+ }
79
+ return { scanned: jsons.length, removed, expired, dryRun, maxAgeDays };
80
+ }
81
+
42
82
  export async function loadVerdict(projectDir, hash) {
43
83
  try {
44
84
  return JSON.parse(await fs.readFile(verdictPath(projectDir, hash), "utf8"));