karajan-code 4.21.0 → 4.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/package.json +5 -2
- package/packages/hu-board/public/app.js +1 -0
- package/packages/hu-board/public/index.html +2 -0
- package/packages/hu-board/public/styles.css +22 -0
- package/packages/hu-board/public/utils/governance-view.js +149 -0
- package/packages/hu-board/src/auth.js +29 -1
- package/packages/hu-board/src/routes/governance.js +140 -0
- package/packages/hu-board/src/server.js +2 -0
- package/scripts/install.js +4 -3
- package/scripts/postinstall.js +4 -3
- package/scripts/toml-value.js +18 -0
- package/src/audit/basal-cost.js +4 -0
- package/src/audit/dead-exports.js +52 -1
- package/src/audit/deterministic-summary.js +18 -3
- package/src/audit/member-reachability.js +158 -0
- package/src/audit/osv-findings.js +1 -0
- package/src/checks/ai-trash.js +1 -1
- package/src/checks/mcp-health.js +1 -1
- package/src/checks/native-build.js +2 -2
- package/src/checks/release-check.js +62 -2
- package/src/claims/cross-check.js +86 -0
- package/src/claims/extract.js +56 -0
- package/src/claims/turn.js +54 -0
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +66 -5
- package/src/commands/claims.js +73 -0
- package/src/commands/hu.js +3 -1
- package/src/commands/init.js +1 -1
- package/src/commands/policy.js +84 -3
- package/src/commands/privacy.js +6 -2
- package/src/commands/resume.js +4 -0
- package/src/commands/review-gate.js +34 -9
- package/src/commands/steward.js +146 -0
- package/src/config/defaults.js +6 -1
- package/src/environment/playbook.js +2 -1
- package/src/guards/duplicate-members.js +86 -0
- package/src/harden/sentinel-hooks.js +188 -22
- package/src/harden/workflow-engine.js +8 -2
- package/src/harden/workflow-templates.js +38 -1
- package/src/policy/exceptions.js +14 -4
- package/src/policy/report.js +99 -0
- package/src/privacy/scan.js +23 -1
- package/src/review/card-first.js +4 -1
- package/src/review/one-shot-review.js +10 -4
- package/src/review/parser.js +9 -0
- package/src/review/sonar-pregate.js +38 -3
- package/src/review/tests-with-code.js +12 -1
- package/src/review/unparseable-verdict.js +50 -0
- package/src/roles/audit-role.js +19 -1
- package/src/sonar/scanner.js +34 -8
- package/src/steward/invariants.js +190 -0
- package/src/steward/phantom-coverage.js +137 -0
- package/src/steward/proposed-work.js +68 -0
package/src/cli/register-meta.js
CHANGED
|
@@ -5,6 +5,9 @@ import { architectCommand } from "../commands/architect.js";
|
|
|
5
5
|
import { onboardCommand } from "../commands/onboard.js";
|
|
6
6
|
import { startCommand } from "../commands/start.js";
|
|
7
7
|
import { identityCommand } from "../commands/identity.js";
|
|
8
|
+
import { policyCommand } from "../commands/policy.js";
|
|
9
|
+
import { claimsCommand, claimsGateCommand } from "../commands/claims.js";
|
|
10
|
+
import { stewardSweepCommand } from "../commands/steward.js";
|
|
8
11
|
import { ragIndexCommand, ragQueryCommand, ragInstallHooksCommand, ragEvalCommand } from "../commands/rag.js";
|
|
9
12
|
import { qmdQueryCommand } from "../commands/qmd.js";
|
|
10
13
|
import { ragMcpCommand } from "../commands/rag-mcp.js";
|
|
@@ -311,6 +314,34 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
311
314
|
});
|
|
312
315
|
|
|
313
316
|
// KJC-TSK-0733 PL-A — policy as code: motor determinista en modo warn.
|
|
317
|
+
// CLM-B (KJC-TSK-0802): the data the AI states, checked against what actually ran.
|
|
318
|
+
const stewardCmd = program.command("steward").description("El Steward: gobierna el ESTADO del proyecto — invariantes con caducidad y cuatro veredictos (épica claims/steward)");
|
|
319
|
+
stewardCmd.command("sweep")
|
|
320
|
+
.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")
|
|
321
|
+
.option("--if-stale <days>", "Solo barre si el informe tiene más de N días — retomar trabajo con informe fresco no re-barre")
|
|
322
|
+
.option("--json", "Machine-readable")
|
|
323
|
+
.action(async (flags) => {
|
|
324
|
+
await withConfig(pkgVersion, "steward-sweep", flags, async ({ config }) => {
|
|
325
|
+
process.exitCode = await stewardSweepCommand({ flags, config });
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
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)");
|
|
329
|
+
claimsCmd.command("check")
|
|
330
|
+
.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")
|
|
331
|
+
.requiredOption("--transcript <path>", "Ruta del transcript de la sesión (la que pasa el hook)")
|
|
332
|
+
.option("--file <path>", "Cruza el contenido de este fichero (cuerpo de PR, card) en vez del mensaje final del turno")
|
|
333
|
+
.option("--json", "Machine-readable")
|
|
334
|
+
.action(async (flags) => { process.exitCode = await claimsCommand({ flags }); });
|
|
335
|
+
claimsCmd.command("gate")
|
|
336
|
+
.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")
|
|
337
|
+
.requiredOption("--transcript <path>", "Ruta del transcript de la sesión")
|
|
338
|
+
.option("--file <path>", "Cruza el contenido de este fichero (cuerpo de PR, card) en vez del mensaje final del turno")
|
|
339
|
+
.action(async (flags) => {
|
|
340
|
+
await withConfig(pkgVersion, "claims-gate", flags, async ({ config }) => {
|
|
341
|
+
process.exitCode = await claimsGateCommand({ flags, config });
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
314
345
|
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");
|
|
315
346
|
policyCmd.command("eval")
|
|
316
347
|
.description("Evalúa UNA tool call: imprime {decision, rule_id, reason}; --strict devuelve exit 2 en deny (contrato para adaptadores de hooks)")
|
|
@@ -320,7 +351,6 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
320
351
|
.option("--strict", "Exit 2 si deny")
|
|
321
352
|
.action(async (flags) => {
|
|
322
353
|
await withConfig(pkgVersion, "policy-eval", flags, async ({ config }) => {
|
|
323
|
-
const { policyCommand } = await import("../commands/policy.js");
|
|
324
354
|
process.exitCode = await policyCommand({ action: "eval", config, flags });
|
|
325
355
|
});
|
|
326
356
|
});
|
|
@@ -331,7 +361,6 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
331
361
|
.option("--reason <text>", "Justificación escrita en el momento")
|
|
332
362
|
.action(async (flags) => {
|
|
333
363
|
await withConfig(pkgVersion, "policy-grant", flags, async ({ config }) => {
|
|
334
|
-
const { policyCommand } = await import("../commands/policy.js");
|
|
335
364
|
process.exitCode = await policyCommand({ action: "grant", config, flags });
|
|
336
365
|
});
|
|
337
366
|
});
|
|
@@ -348,10 +377,27 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
348
377
|
.description("Verifica la cadena del decision log y sella su head-hash en .karajan/policy-anchor.json (trackeado) — anclaje temporal en la historia de git, GOV-C2")
|
|
349
378
|
.action(async (flags) => {
|
|
350
379
|
await withConfig(pkgVersion, "policy-anchor", flags, async ({ config }) => {
|
|
351
|
-
const { policyCommand } = await import("../commands/policy.js");
|
|
352
380
|
process.exitCode = await policyCommand({ action: "anchor", config, flags });
|
|
353
381
|
});
|
|
354
382
|
});
|
|
383
|
+
policyCmd.command("seal")
|
|
384
|
+
.description("Sella en el decision log un escape KJ_ALLOW_* usado en tool-time (exempt, chokepoint=tool, identidad declarada del clon) — lo invoca el Sentinel, GOV-F")
|
|
385
|
+
.requiredOption("--escape <name>", "Escape usado (p.ej. KJ_ALLOW_BOARD)")
|
|
386
|
+
.option("--tool <tool>", "Tool que lo usó (Bash, Edit…)")
|
|
387
|
+
.action(async (flags) => {
|
|
388
|
+
await withConfig(pkgVersion, "policy-seal", flags, async ({ config }) => {
|
|
389
|
+
process.exitCode = await policyCommand({ action: "seal", config, flags });
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
policyCmd.command("report")
|
|
393
|
+
.description("Informe determinista del decision log y las concesiones: avisos/denegaciones/exenciones por regla, denegaciones abiertas, concesiones vivas/vencidas/renovadas y señales — cadena rota = exit 1 (PL-E)")
|
|
394
|
+
.option("--soon <days>", "Días para considerar una concesión 'próxima a vencer'", "7")
|
|
395
|
+
.option("--json", "Machine-readable")
|
|
396
|
+
.action(async (flags) => {
|
|
397
|
+
await withConfig(pkgVersion, "policy-report", flags, async ({ config }) => {
|
|
398
|
+
process.exitCode = await policyCommand({ action: "report", config, flags });
|
|
399
|
+
});
|
|
400
|
+
});
|
|
355
401
|
policyCmd.command("check")
|
|
356
402
|
.description("Comprueba el diff (staged, o base...head con --range) contra la policy — warn por defecto; --strict devuelve exit 2 si hay violación enforcement=deny (tier C, merge-blocking)")
|
|
357
403
|
.option("--role <role>", "Rol del agente", "coder")
|
|
@@ -360,7 +406,6 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
360
406
|
.option("--json", "Machine-readable")
|
|
361
407
|
.action(async (flags) => {
|
|
362
408
|
await withConfig(pkgVersion, "policy-check", flags, async ({ config }) => {
|
|
363
|
-
const { policyCommand } = await import("../commands/policy.js");
|
|
364
409
|
process.exitCode = await policyCommand({ action: "check", config, flags });
|
|
365
410
|
});
|
|
366
411
|
});
|
|
@@ -599,9 +644,10 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
599
644
|
|
|
600
645
|
program
|
|
601
646
|
.command("board [action]")
|
|
602
|
-
.description("Manage HU Board (start|stop|status|open|cleanup)")
|
|
647
|
+
.description("Manage HU Board (start|stop|status|open|cleanup) — SIN acción arranca un servidor persistente en segundo plano (equivale a `start`)")
|
|
603
648
|
.option("--port <number>", "Port (default: 4000)", "4000")
|
|
604
649
|
.option("--bind <host>", "Bind host (default: 127.0.0.1; use 0.0.0.0 to expose on LAN — token auth auto-enforced)")
|
|
650
|
+
.option("--force", "Arranca aunque hu_board.enabled sea false en kj.config.yml")
|
|
605
651
|
.action(async (action = "start", opts) => {
|
|
606
652
|
await withConfig(pkgVersion, "board", opts, async ({ config, logger }) => {
|
|
607
653
|
// KJC-TSK-0684 (issue #1287): an external board is the source of
|
|
@@ -612,6 +658,21 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
612
658
|
console.log(`⚠ this project's board lives in ${name} (state_backend: external) — kj does not run a parallel HU Board here.`);
|
|
613
659
|
return;
|
|
614
660
|
}
|
|
661
|
+
// KJC-BUG-0152 (issue #1427): `kj board` with no action starts a persistent server.
|
|
662
|
+
// Doing that while hu_board.enabled is false contradicts kj doctor, which reports the
|
|
663
|
+
// board as skipped — two commands saying opposite things about the same config. The
|
|
664
|
+
// system works or fails loudly; it never does the opposite of what the config says.
|
|
665
|
+
// Only `start` is gated (the bare command defaults to it): stop, status, cleanup and open
|
|
666
|
+
// never bring a server up, and blocking them would take away the way to tidy up.
|
|
667
|
+
if (config?.hu_board?.enabled === false && !opts.force && action === "start") {
|
|
668
|
+
logger.error(
|
|
669
|
+
`hu_board.enabled es false en kj.config.yml — no arranco el HU Board (kj doctor ya lo reporta como omitido).\n` +
|
|
670
|
+
` Para arrancarlo igualmente: kj board ${action} --force\n` +
|
|
671
|
+
` Para dejarlo activado siempre: pon hu_board.enabled: true en kj.config.yml`
|
|
672
|
+
);
|
|
673
|
+
process.exitCode = 1;
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
615
676
|
const port = Number(opts.port) || config.hu_board?.port || 4000;
|
|
616
677
|
const bind = opts.bind || config.hu_board?.bind || "127.0.0.1";
|
|
617
678
|
await boardCommand({ action, port, bind, logger });
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kj claims check --transcript <path>` (CLM-B, KJC-TSK-0802) — checks the data
|
|
3
|
+
* the AI states in a turn against what actually ran in that same turn.
|
|
4
|
+
*
|
|
5
|
+
* Deterministic and free: no model in the loop. Exit 2 only when a datum is
|
|
6
|
+
* DENIED by its own source — a proven hallucination. Everything else is
|
|
7
|
+
* reported, per the accepted ADR: inform always, block almost never.
|
|
8
|
+
*
|
|
9
|
+
* It fails OPEN. A verifier that cannot read the transcript says so and gets out
|
|
10
|
+
* of the way: a broken check must never hold a session hostage.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { readTurn } from "../claims/turn.js";
|
|
14
|
+
import { crossCheck, formatClaimReport } from "../claims/cross-check.js";
|
|
15
|
+
|
|
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 } = {}) {
|
|
47
|
+
const path = flags.transcript;
|
|
48
|
+
if (!path) {
|
|
49
|
+
logger.error("kj claims check: --transcript <path> is required");
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
let turn;
|
|
53
|
+
try {
|
|
54
|
+
turn = readTurnFn(path);
|
|
55
|
+
if (flags.file) turn = { ...turn, text: String(readFileFn(flags.file, "utf8")) };
|
|
56
|
+
} catch (err) {
|
|
57
|
+
// Not observable: the transcript could not be read. Never reported as clean.
|
|
58
|
+
const note = `kj claims: transcript not readable (${err.message}) — nothing checked`;
|
|
59
|
+
if (flags.json) logger.log(JSON.stringify({ ok: true, checked: false, reason: note }));
|
|
60
|
+
else logger.error(note);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const result = crossCheck(turn);
|
|
65
|
+
if (flags.json) {
|
|
66
|
+
logger.log(JSON.stringify({ ok: true, checked: true, denied: result.denied, unbacked: result.unbacked, claims: result.claims.length }));
|
|
67
|
+
} else if (result.denied.length || result.unbacked.length) {
|
|
68
|
+
logger.error(formatClaimReport(result));
|
|
69
|
+
} else {
|
|
70
|
+
logger.log(`kj claims: ${result.claims.length} dato(s) comprobado(s), todos con respaldo en este turno`);
|
|
71
|
+
}
|
|
72
|
+
return result.denied.length ? 2 : 0;
|
|
73
|
+
}
|
package/src/commands/hu.js
CHANGED
|
@@ -43,7 +43,9 @@ export function findTitleMatches(title, allHus) {
|
|
|
43
43
|
return { identical, similar };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
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);
|
package/src/commands/init.js
CHANGED
|
@@ -862,7 +862,7 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
862
862
|
await installAiTrashHook(logger);
|
|
863
863
|
} else {
|
|
864
864
|
logger.warn("ai-trash kj-trash binary not found — destructive ops unprotected.");
|
|
865
|
-
logger.warn(" Install karajan-code globally (npm i -g karajan-code) so kj-trash is on PATH.");
|
|
865
|
+
logger.warn(" Install karajan-code globally (npm i -g @karajan-family/code) so kj-trash is on PATH.");
|
|
866
866
|
}
|
|
867
867
|
}
|
|
868
868
|
|
package/src/commands/policy.js
CHANGED
|
@@ -12,7 +12,10 @@ import { createHash } from "node:crypto";
|
|
|
12
12
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { checkStagedDiff, evalToolCall, loadPolicy } from "../policy/engine.js";
|
|
15
|
-
import { loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
|
|
15
|
+
import { loadExceptionRecords, loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
|
|
16
|
+
import { buildPolicyReport } from "../policy/report.js";
|
|
17
|
+
import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
|
|
18
|
+
import { readIdentity } from "../identity/store.js";
|
|
16
19
|
import { verifyDecisionChain } from "@karajan-family/governance";
|
|
17
20
|
|
|
18
21
|
const execFileAsync = promisify(execFile);
|
|
@@ -123,6 +126,39 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
|
|
|
123
126
|
}
|
|
124
127
|
}
|
|
125
128
|
|
|
129
|
+
// PL-E (KJC-TSK-0767): el informe — evidencia de proceso determinista
|
|
130
|
+
// sobre los dos jsonl. Cadena rota = exit 1: un informe sobre un log
|
|
131
|
+
// manipulado no es un informe. El resto es informativo (exit 0).
|
|
132
|
+
if (action === "report") {
|
|
133
|
+
let decisionLines = [];
|
|
134
|
+
try {
|
|
135
|
+
decisionLines = readFileSync(join(projectDir, ".karajan", "policy-decisions.jsonl"), "utf8").split("\n").filter((l) => l.trim());
|
|
136
|
+
} catch { /* sin decisiones aún: el informe lo dice con ceros */ }
|
|
137
|
+
const exc = loadExceptionRecords(projectDir);
|
|
138
|
+
const soonDays = Number(flags.soon ?? 7);
|
|
139
|
+
const report = buildPolicyReport({ decisionLines, exceptionRecords: exc.records, policy, soonDays: Number.isFinite(soonDays) ? soonDays : 7 });
|
|
140
|
+
if (flags.json) {
|
|
141
|
+
logger.info?.(JSON.stringify({ ...report, exceptions_discarded: exc.discarded }));
|
|
142
|
+
return report.chain.ok ? 0 : 1;
|
|
143
|
+
}
|
|
144
|
+
const { chain, decisions: d, rules, grants: g } = report;
|
|
145
|
+
if (chain.ok) logger.info?.(`✓ decision log: cadena íntegra (${chain.length} decisiones)`);
|
|
146
|
+
else logger.error?.(`✗ decision log: cadena rota en la entrada ${chain.at} (${chain.reason}) — el log ha sido manipulado`);
|
|
147
|
+
if (d.discarded > 0 || exc.discarded > 0) logger.warn?.(`⚠ líneas corruptas descartadas: ${d.discarded} en decisiones, ${exc.discarded} en excepciones`);
|
|
148
|
+
const cps = Object.entries(d.chokepoints).map(([k, v]) => `${k} ${v}`).join(" · ") || "ninguno";
|
|
149
|
+
logger.info?.(`decisiones: allow ${d.allow} · deny ${d.deny} · exempt ${d.exempt} · abiertas ${d.open} (chokepoints: ${cps})`);
|
|
150
|
+
logger.info?.(rules.length > 0 ? "reglas (ordenadas por fricción):" : "reglas: ninguna ha avisado ni denegado todavía");
|
|
151
|
+
for (const r of rules) {
|
|
152
|
+
const meta = [r.enforcement && `enforcement=${r.enforcement}`, r.class && `class=${r.class}`].filter(Boolean).join(" ");
|
|
153
|
+
logger.info?.(` [${r.rule_id}] ${meta} — warn ${r.warns} · deny ${r.denies} · exempt ${r.exempts} · abiertas ${r.open}`);
|
|
154
|
+
}
|
|
155
|
+
logger.info?.(`concesiones: vivas ${g.alive.length} (próximas a vencer ${g.soon.length}) · vencidas ${g.expired.length} · puntuales ${g.point}`);
|
|
156
|
+
for (const e of g.alive) logger.info?.(` [${e.rule_id}] hasta ${e.expiresAt} — ${e.who?.git ?? "?"}: ${e.justification ?? "sin justificación"}`);
|
|
157
|
+
logger.info?.(report.signals.length > 0 ? "señales:" : "señales: ninguna");
|
|
158
|
+
for (const s of report.signals) logger.warn?.(` ⚠ ${s}`);
|
|
159
|
+
return chain.ok ? 0 : 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
126
162
|
if (action === "eval") {
|
|
127
163
|
let input;
|
|
128
164
|
try {
|
|
@@ -131,9 +167,54 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
|
|
|
131
167
|
logger.error?.("policy eval: --input debe ser JSON válido");
|
|
132
168
|
return 1;
|
|
133
169
|
}
|
|
134
|
-
const
|
|
170
|
+
const role = flags.role || "coder";
|
|
171
|
+
const verdict = evalToolCall(policy, { role, tool: flags.tool, input, root: projectDir });
|
|
172
|
+
const strictDeny = verdict.decision === "deny" && Boolean(flags.strict);
|
|
173
|
+
// GOV-F (KJC-TSK-0768): el deny de tool-time (contrato --strict del
|
|
174
|
+
// Sentinel) entra en la MISMA cadena que las decisiones de commit — con
|
|
175
|
+
// el hash del tool_input como artefacto. Un sello fallido se dice ANTES
|
|
176
|
+
// del veredicto (la última línea de stdout sigue siendo el JSON que el
|
|
177
|
+
// Sentinel parsea) y el deny se mantiene: jamás un allow por fallo de registro.
|
|
178
|
+
if (strictDeny) {
|
|
179
|
+
try {
|
|
180
|
+
recordGateDecision(projectDir, {
|
|
181
|
+
decision: "deny", chokepoint: "tool", rule_ids: [verdict.rule_id], role, tool: flags.tool ?? null,
|
|
182
|
+
...(verdict.class ? { class: verdict.class } : {}),
|
|
183
|
+
// El hash es del payload CRUDO recibido (--input tal cual), no de su
|
|
184
|
+
// re-serialización: el mismo JSON con otro orden o espacios es otro
|
|
185
|
+
// artefacto para la auditoría (catch de codex).
|
|
186
|
+
policy_hash: policyFileHash(projectDir), artifact_hash: createHash("sha256").update(String(flags.input ?? ""), "utf8").digest("hex"),
|
|
187
|
+
});
|
|
188
|
+
} catch (err) {
|
|
189
|
+
logger.error?.(`policy eval: deny NO sellado en el decision log (${err.message}) — el deny se mantiene`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
135
192
|
logger.info?.(JSON.stringify(verdict));
|
|
136
|
-
return
|
|
193
|
+
return strictDeny ? 2 : 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// GOV-F (KJC-TSK-0768): un escape KJ_ALLOW_* usado en tool-time es una
|
|
197
|
+
// excepción consciente — se sella como exempt chokepoint=tool con la
|
|
198
|
+
// identidad DECLARADA del clon (identity.local.yml), para que el informe
|
|
199
|
+
// y el anchor la vean. Lo llama el Sentinel; cualquiera puede auditarlo.
|
|
200
|
+
if (action === "seal") {
|
|
201
|
+
if (!flags.escape) {
|
|
202
|
+
logger.error?.("policy seal: --escape <KJ_ALLOW_X> es obligatorio — un escape sin nombre no es auditable");
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const who = readIdentity(projectDir);
|
|
207
|
+
const rec = recordGateDecision(projectDir, {
|
|
208
|
+
decision: "exempt", chokepoint: "tool", escape: flags.escape, tool: flags.tool ?? null,
|
|
209
|
+
who: who ? { gh: who.gh_user ?? null, git: who.git_email ?? null, grade: "declarada" } : null,
|
|
210
|
+
policy_hash: policyFileHash(projectDir),
|
|
211
|
+
});
|
|
212
|
+
logger.info?.(`✓ escape ${rec.escape} sellado (chokepoint tool${rec.tool ? `, ${rec.tool}` : ""})`);
|
|
213
|
+
return 0;
|
|
214
|
+
} catch (err) {
|
|
215
|
+
logger.error?.(`policy seal: ${err.message}`);
|
|
216
|
+
return 1;
|
|
217
|
+
}
|
|
137
218
|
}
|
|
138
219
|
|
|
139
220
|
// check — staged por defecto, base...head con --range (CI). Warn salvo
|
package/src/commands/privacy.js
CHANGED
|
@@ -28,9 +28,12 @@ export async function privacyScanCommand({ paths = [], flags = {}, logger = cons
|
|
|
28
28
|
const warns = findings.filter((f) => f.severity === "warn");
|
|
29
29
|
const ok = blocks.length === 0;
|
|
30
30
|
process.exitCode = ok ? 0 : 1;
|
|
31
|
+
// KJC-TSK-0797 AC4: "nothing found" and "found but explained by context"
|
|
32
|
+
// are different truths — the report says which one it is.
|
|
33
|
+
const discarded = findings.discardedByContext || 0;
|
|
31
34
|
// --json keeps stdout machine-clean: exactly one JSON document, no prose.
|
|
32
35
|
if (flags.json) {
|
|
33
|
-
process.stdout.write(`${JSON.stringify({ ok, blocks: blocks.length, warns: warns.length, findings })}\n`);
|
|
36
|
+
process.stdout.write(`${JSON.stringify({ ok, blocks: blocks.length, warns: warns.length, discardedByContext: discarded, findings })}\n`);
|
|
34
37
|
return { ok, findings };
|
|
35
38
|
}
|
|
36
39
|
for (const f of blocks) logger.error?.(`✗ BLOCK [${f.type}] ${f.source}:${f.line} → ${f.masked}`);
|
|
@@ -38,8 +41,9 @@ export async function privacyScanCommand({ paths = [], flags = {}, logger = cons
|
|
|
38
41
|
if (!list.present) {
|
|
39
42
|
logger.info?.(`hint: no personal denylist found — create ${privacyConfigPath()} (personal: [...], allow: [...]) so YOUR data blocks, not just warns`);
|
|
40
43
|
}
|
|
44
|
+
const context = discarded > 0 ? `; ${discarded} candidate(s) discarded by context — git SHAs / documentation domains` : "";
|
|
41
45
|
logger.info?.(ok
|
|
42
|
-
? `privacy scan: clean of denylist hits (${warns.length} generic warning(s))`
|
|
46
|
+
? `privacy scan: clean of denylist hits (${warns.length} generic warning(s)${context})`
|
|
43
47
|
: `privacy scan: ${blocks.length} personal-data hit(s) — this must not ship`);
|
|
44
48
|
return { ok, findings };
|
|
45
49
|
}
|
package/src/commands/resume.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import { resumeFlow } from "../orchestrator.js";
|
|
3
|
+
import { sweepOnResume } from "./steward.js";
|
|
3
4
|
import { createActivityLog } from "../activity-log.js";
|
|
4
5
|
import { printEvent } from "../utils/display/event-handlers.js";
|
|
5
6
|
import { withCliRunLog } from "../utils/cli-run-log.js";
|
|
@@ -9,6 +10,9 @@ import { createCliAskQuestion } from "../utils/cli-ask-question.js";
|
|
|
9
10
|
export async function resumeCommand({ sessionId, answer, config, logger, flags }) {
|
|
10
11
|
const jsonMode = flags?.json;
|
|
11
12
|
const quietMode = config.output?.quiet !== false;
|
|
13
|
+
// STW-E (KJC-TSK-0793): resuming work is when someone is in front to review
|
|
14
|
+
// the state — sweep if the report went stale; adoption stays explicit.
|
|
15
|
+
await sweepOnResume({ config, logger });
|
|
12
16
|
|
|
13
17
|
// Same wrapper as every other CLI command — without it `.kj/run.log`
|
|
14
18
|
// is never opened during the resume and `kj-tail` stays silent
|
|
@@ -12,7 +12,7 @@ import { checkVerdict, diffHash } 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";
|
|
15
|
-
import { runSonarPregate, formatSonarFinding } from "../review/sonar-pregate.js";
|
|
15
|
+
import { runSonarPregate, formatSonarFinding, addedLinesByFile } from "../review/sonar-pregate.js";
|
|
16
16
|
import { runMutationPregate, formatSurvivor } from "../review/mutation-pregate.js";
|
|
17
17
|
import { checkCardFirst } from "../review/card-first.js";
|
|
18
18
|
import { checkTestsWithCode } from "../review/tests-with-code.js";
|
|
@@ -162,16 +162,23 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
162
162
|
const numstat = await rawDiff(flags.range, ["--numstat"]);
|
|
163
163
|
const added = numstat.split("\n").reduce((acc, l) => acc + (Number(l.split("\t")[0]) || 0), 0);
|
|
164
164
|
if (added > sizeWarn) {
|
|
165
|
+
// KJC-TSK-0795 AC5: say how much of the weight is the module's OWN tests —
|
|
166
|
+
// partitioning is a decision, and it must never split code from its tests.
|
|
167
|
+
const testAdded = numstat.split("\n").reduce((acc, l) => {
|
|
168
|
+
const [a, , ...f] = l.split("\t");
|
|
169
|
+
return acc + (/\/tests?\/|__tests__\/|\.test\.|\.spec\./.test(`/${f.join("\t")}`) ? Number(a) || 0 : 0);
|
|
170
|
+
}, 0);
|
|
171
|
+
const split = testAdded > 0 ? ` (${added - testAdded} source + ${testAdded} accompanying tests — partition by feature, never code from its tests)` : "";
|
|
165
172
|
const sizePolicy = config?.method_gates?.pr_size || "warn";
|
|
166
173
|
if (process.env.KJ_ALLOW_LARGE_PR === "1") {
|
|
167
174
|
console.log(`⚠ pr-size exempt: ${added} lines added — KJ_ALLOW_LARGE_PR=1 (explicit escape hatch)`);
|
|
168
175
|
} else if (sizePolicy === "block") {
|
|
169
|
-
const reason = `${added} lines added exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work, or get your user's explicit OK and re-run with KJ_ALLOW_LARGE_PR=1`;
|
|
176
|
+
const reason = `${added} lines added${split} exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work, or get your user's explicit OK and re-run with KJ_ALLOW_LARGE_PR=1`;
|
|
170
177
|
console.log(`✗ pr-size gate: ${reason}`);
|
|
171
178
|
process.exitCode = 1;
|
|
172
179
|
return { verdict: "rejected", reviewer: "pr-size", issues: [{ severity: "high", description: reason }] };
|
|
173
180
|
} else {
|
|
174
|
-
console.log(`⚠ pr-size: ${added} lines added (guideline ~${sizeWarn}, source: ${sizeSource}) — an oversized warning is not an opinion: partition, or ask your user (method_gates.pr_size: block to harden)`);
|
|
181
|
+
console.log(`⚠ pr-size: ${added} lines added${split} (guideline ~${sizeWarn}, source: ${sizeSource}) — an oversized warning is not an opinion: partition, or ask your user (method_gates.pr_size: block to harden)`);
|
|
175
182
|
}
|
|
176
183
|
}
|
|
177
184
|
}
|
|
@@ -213,7 +220,12 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
213
220
|
}
|
|
214
221
|
}
|
|
215
222
|
|
|
216
|
-
|
|
223
|
+
// KJC-TSK-0795 AC1: hand the gate the numbers so a delete-only diff is
|
|
224
|
+
// exempt — deleting code adds no behavior to test.
|
|
225
|
+
const numstat = (await rawDiff(flags.range, ["--numstat"])).split("\n").map((l) => l.trim()).filter(Boolean)
|
|
226
|
+
.map((l) => { const [a, r, ...f] = l.split(/\s+/); return { file: f.join(" "), added: a === "-" ? 1 : Number(a) || 0, removed: r === "-" ? 0 : Number(r) || 0 }; });
|
|
227
|
+
const tests = checkTestsWithCode({ config, stagedFiles: changedFiles, numstat });
|
|
228
|
+
if (tests.mode === "delete-only") console.log(`⚠ tests-with-code: exempt — ${tests.reason}`);
|
|
217
229
|
if (tests.mode === "warn") console.log(`⚠ tests-with-code: ${tests.reason}`);
|
|
218
230
|
if (tests.mode === "exempt") console.log(`⚠ tests-with-code exempt: ${tests.reason}`);
|
|
219
231
|
if (!tests.ok) {
|
|
@@ -289,8 +301,10 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
289
301
|
console.log(res.ok
|
|
290
302
|
? `✓ verdict ok — approved by ${res.verdict.reviewer} (diff ${res.verdict.diffHash.slice(0, 12)})`
|
|
291
303
|
: `✗ ${res.reason}`);
|
|
292
|
-
// GOV-C: el allow del chokepoint de COMMIT es evidencia — se sella.
|
|
293
|
-
|
|
304
|
+
// GOV-C: el allow del chokepoint de COMMIT es evidencia — se sella. PL-E
|
|
305
|
+
// (KJC-TSK-0767): con las reglas que AVISARON, para que "nace avisando y
|
|
306
|
+
// gana dientes" se decida con datos (kj policy report), no a ciegas.
|
|
307
|
+
if (res.ok) seal("allow", gate.warns.length > 0 ? { warn_rule_ids: gate.warns.map((w) => w.rule_id) } : {});
|
|
294
308
|
process.exitCode = res.ok ? 0 : 1;
|
|
295
309
|
return res;
|
|
296
310
|
}
|
|
@@ -301,15 +315,26 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
301
315
|
// the cross-AI reviewer weighs them. Unavailable sonar degrades loudly.
|
|
302
316
|
let task = flags.task;
|
|
303
317
|
if (flags.sonar !== false) {
|
|
304
|
-
|
|
318
|
+
// KJC-TSK-0795 AC3: only issues on lines this diff ADDS may veto.
|
|
319
|
+
const touchedLines = addedLinesByFile(await rawDiff(flags.range, ["--unified=0"]));
|
|
320
|
+
const pre = await runSonarPregate({ config, stagedFiles: changedFiles, touchedLines, logger });
|
|
305
321
|
if (!pre.available) {
|
|
306
|
-
|
|
322
|
+
// KJC-BUG-0156 (issue #1543): a scan that FAILED must not read like one
|
|
323
|
+
// more config warning — it hid a dead quality gate for 8 straight PRs.
|
|
324
|
+
// Still fail-open (a laptop without Docker still commits), but SAID.
|
|
325
|
+
const chosen = /disabled in config/.test(pre.reason || "");
|
|
326
|
+
console.log(chosen
|
|
327
|
+
? `⚠ sonar pre-gate skipped: ${pre.reason}`
|
|
328
|
+
: `✗ sonar pre-gate UNAVAILABLE — the quality gate did NOT run on this diff: ${pre.reason}`);
|
|
307
329
|
} else {
|
|
308
330
|
const found = [...pre.blocking, ...pre.advisory];
|
|
309
331
|
if (found.length > 0) {
|
|
310
|
-
console.log(`Sonar on the changed
|
|
332
|
+
console.log(`Sonar on the changed lines — ${pre.blocking.length} blocking, ${pre.advisory.length} advisory (project total: ${pre.totalProject}):`);
|
|
311
333
|
for (const f of found) console.log(` - ${formatSonarFinding(f)}`);
|
|
312
334
|
}
|
|
335
|
+
if ((pre.preexisting || []).length > 0) {
|
|
336
|
+
console.log(`⚠ sonar trend: ${pre.preexisting.length} preexisting issue(s) on untouched lines of these files — not this PR's veto, but the debt is real`);
|
|
337
|
+
}
|
|
313
338
|
if (pre.blocking.length > 0) {
|
|
314
339
|
console.log("✗ REJECTED by sonar (deterministic) — fix the blocking findings; the cross-AI reviewer was not invoked.");
|
|
315
340
|
process.exitCode = 1;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kj steward sweep` (STW-B, KJC-TSK-0790, epic KJC-PCS-0081) — a READ-ONLY
|
|
3
|
+
* pass over the Steward invariants that leaves the verdict IN THE REPO:
|
|
4
|
+
* `.karajan/steward/report.md` (a person: verdict, last evidence, and the
|
|
5
|
+
* command that renews each invariant) and `report.json` (a machine — and the
|
|
6
|
+
* BASELINE the next sweep compares against: the versioned report is the
|
|
7
|
+
* shared memory, never a record on one machine). Exit 1 only when something
|
|
8
|
+
* is BROKEN — unknown and not-observable inform with their remedy.
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
VERDICTS, resolveFreshness, evaluateMainCi, evaluateSecurityAudit,
|
|
15
|
+
evaluateVulnAging, evaluateDeadCodeTrend, evaluateCoverageConfig,
|
|
16
|
+
evaluatePhantomCoverage, runInvariants,
|
|
17
|
+
} from "../steward/invariants.js";
|
|
18
|
+
import { loadPreviousAudit } from "../audit/basal-cost.js";
|
|
19
|
+
import { collectOsvFindings } from "../audit/osv-findings.js";
|
|
20
|
+
import { recordGateDecision } from "../policy/decisions.js";
|
|
21
|
+
import { syncProposedWork } from "../steward/proposed-work.js";
|
|
22
|
+
|
|
23
|
+
const stewardDir = (projectDir) => path.join(projectDir, ".karajan", "steward");
|
|
24
|
+
|
|
25
|
+
/** `gh run list` for push runs on the base branch — the sweep's default probe. */
|
|
26
|
+
const ghRuns = (projectDir, baseBranch) => () => {
|
|
27
|
+
const out = execFileSync("gh", ["run", "list", "--branch", baseBranch, "--event", "push", "--limit", "30", "--json", "name,conclusion,createdAt"], { cwd: projectDir, encoding: "utf8", timeout: 30_000 });
|
|
28
|
+
return JSON.parse(out).map((r) => ({ workflow: r.name, conclusion: r.conclusion, createdAt: r.createdAt }));
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } };
|
|
32
|
+
|
|
33
|
+
export async function stewardSweepCommand({ flags = {}, config = {}, logger = console, probes = {} } = {}) {
|
|
34
|
+
const projectDir = config.projectDir || process.cwd();
|
|
35
|
+
const baseBranch = config.base_branch || "main";
|
|
36
|
+
// STW-E (KJC-TSK-0793): --if-stale <days> — resuming work with a fresh
|
|
37
|
+
// report does not re-sweep; the report in the repo stays the ONE source.
|
|
38
|
+
if (flags.ifStale !== undefined) {
|
|
39
|
+
const prev = readJson(path.join(stewardDir(projectDir), "report.json"));
|
|
40
|
+
const ageMs = prev?.sweptAt ? (probes.nowMs ?? Date.now()) - Date.parse(prev.sweptAt) : Infinity;
|
|
41
|
+
if (ageMs < Number(flags.ifStale) * 86_400_000) {
|
|
42
|
+
logger.info?.(`steward: report is fresh (swept ${prev.sweptAt}) — not re-sweeping`);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const freshness = resolveFreshness(config);
|
|
47
|
+
const nowMs = probes.nowMs ?? Date.now();
|
|
48
|
+
const runsFn = probes.runsFn ?? ghRuns(projectDir, baseBranch);
|
|
49
|
+
|
|
50
|
+
// Live osv probe (best-effort): unavailable is null → the invariant answers
|
|
51
|
+
// unknown with its remedy, never a clean bill.
|
|
52
|
+
let vulns = probes.vulns ?? null;
|
|
53
|
+
if (vulns === null) {
|
|
54
|
+
try {
|
|
55
|
+
const osv = await (probes.osvFn ?? collectOsvFindings)(projectDir, logger);
|
|
56
|
+
if (osv?.available) vulns = (osv.vulnerabilities || []).map((v) => ({ id: v.id, severity: v.severity, publishedAt: v.publishedAt ?? null }));
|
|
57
|
+
} catch { /* stays null — unknown */ }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Dead-code baseline: the PREVIOUS report in the repo — shared memory.
|
|
61
|
+
const prevReport = readJson(path.join(stewardDir(projectDir), "report.json"));
|
|
62
|
+
const snapshot = await loadPreviousAudit(projectDir).catch(() => null);
|
|
63
|
+
const deadNow = typeof snapshot?.knipDeadExports?.exports === "number"
|
|
64
|
+
? { deadExports: snapshot.knipDeadExports.exports + (snapshot.knipDeadExports.files || 0) }
|
|
65
|
+
: Array.isArray(snapshot?.deadExports) ? { deadExports: snapshot.deadExports.length } : null;
|
|
66
|
+
|
|
67
|
+
const invariants = [
|
|
68
|
+
{ id: "main-ci", renew: "push to the base branch (or fix the red run)", evaluate: () => evaluateMainCi({ projectDir, baseBranch, freshness: freshness.values, runsFn, nowMs }) },
|
|
69
|
+
{ id: "security-audit", renew: "kj audit --security", evaluate: () => evaluateSecurityAudit({ projectDir, freshness: freshness.values, nowMs }) },
|
|
70
|
+
{ id: "vulnerable-deps", renew: "kj audit (osv-scanner)", evaluate: () => evaluateVulnAging({ vulns, freshness: freshness.values, nowMs }) },
|
|
71
|
+
{ id: "dead-code-trend", renew: "kj audit (knip inventory)", evaluate: () => evaluateDeadCodeTrend({ current: deadNow, previous: prevReport?.deadCodeBaseline ?? null }) },
|
|
72
|
+
{ id: "coverage-config", renew: "configure a coverage threshold", evaluate: () => evaluateCoverageConfig({ projectDir }) },
|
|
73
|
+
{ id: "phantom-coverage", renew: "KJC-TSK-0800 ships the detectors", evaluate: () => evaluatePhantomCoverage() },
|
|
74
|
+
];
|
|
75
|
+
const results = runInvariants(invariants, {}).map((r, i) => ({ ...r, renew: invariants[i].renew }));
|
|
76
|
+
const broken = results.filter((r) => r.verdict === VERDICTS.BROKEN);
|
|
77
|
+
|
|
78
|
+
const report = {
|
|
79
|
+
sweptAt: new Date(nowMs).toISOString(),
|
|
80
|
+
baseBranch,
|
|
81
|
+
freshness: { ...freshness.values, declared: freshness.declared },
|
|
82
|
+
invariants: results,
|
|
83
|
+
deadCodeBaseline: deadNow ? { ...deadNow, timestamp: new Date(nowMs).toISOString() } : (prevReport?.deadCodeBaseline ?? null),
|
|
84
|
+
};
|
|
85
|
+
const mark = { [VERDICTS.OK]: "✓", [VERDICTS.BROKEN]: "✗", [VERDICTS.UNKNOWN]: "?", [VERDICTS.NOT_OBSERVABLE]: "∅" };
|
|
86
|
+
const md = [
|
|
87
|
+
"# Steward report",
|
|
88
|
+
"", `Last swept: ${report.sweptAt}`, "",
|
|
89
|
+
`Freshness: ${freshness.declared ? "declared by the project" : "calibrated defaults (the project declared none)"} — ${Object.entries(freshness.values).map(([k, v]) => `${k}=${v}`).join(", ")}`, "",
|
|
90
|
+
...results.map((r) => [
|
|
91
|
+
`## ${mark[r.verdict] || "?"} ${r.id} — ${r.verdict}`,
|
|
92
|
+
`- evidence: ${r.evidence || "(none)"}`,
|
|
93
|
+
...(r.remedy ? [`- remedy: ${r.remedy}`] : []),
|
|
94
|
+
`- renews with: ${r.renew}`, "",
|
|
95
|
+
]).flat(),
|
|
96
|
+
].join("\n");
|
|
97
|
+
|
|
98
|
+
fs.mkdirSync(stewardDir(projectDir), { recursive: true });
|
|
99
|
+
fs.writeFileSync(path.join(stewardDir(projectDir), "report.md"), md);
|
|
100
|
+
fs.writeFileSync(path.join(stewardDir(projectDir), "report.json"), `${JSON.stringify(report, null, 2)}\n`);
|
|
101
|
+
|
|
102
|
+
// Every sweep is SEALED in the decision chain — the same criterion policy
|
|
103
|
+
// decisions already meet: recorded and verifiable, not just notified.
|
|
104
|
+
try {
|
|
105
|
+
const counts = {};
|
|
106
|
+
for (const r of results) counts[r.verdict] = (counts[r.verdict] || 0) + 1;
|
|
107
|
+
recordGateDecision(projectDir, { kind: "steward-sweep", verdicts: { ok: counts[VERDICTS.OK] || 0, broken: broken.length, unknown: counts[VERDICTS.UNKNOWN] || 0, "not-observable": counts[VERDICTS.NOT_OBSERVABLE] || 0 }, broken_ids: broken.map((b) => b.id) });
|
|
108
|
+
} catch (err) { logger.warn?.(`⚠ steward: the sweep could not be sealed in the decision chain (${err.message})`); }
|
|
109
|
+
|
|
110
|
+
// STW-D (KJC-TSK-0792): every break is PROPOSED work on the board the brain
|
|
111
|
+
// already consumes. AFTER the report and the seal: a board failure never
|
|
112
|
+
// costs either, and it is said loudly — never swallowed.
|
|
113
|
+
try {
|
|
114
|
+
const sync = await syncProposedWork({ projectDir, config, results, sweptAt: report.sweptAt });
|
|
115
|
+
if (!sync.synced) logger.warn?.(`⚠ steward: broken invariants not carded — ${sync.reason}`);
|
|
116
|
+
else if (sync.created || sync.updated || sync.resolved) logger.info?.(`steward board: ${sync.created} card(s) proposed, ${sync.updated} updated, ${sync.resolved} resolved`);
|
|
117
|
+
} catch (err) { logger.warn?.(`⚠ steward: carding the broken invariants failed (${err.message}) — the report and the seal stand`); }
|
|
118
|
+
|
|
119
|
+
// A report nobody can see is not shared state — say it, do not fix their tree.
|
|
120
|
+
try {
|
|
121
|
+
execFileSync("git", ["check-ignore", "-q", path.join(".karajan", "steward", "report.md")], { cwd: projectDir });
|
|
122
|
+
logger.warn?.("⚠ steward: the report path is gitignored — untrack .karajan/steward from .gitignore so the state is shared");
|
|
123
|
+
} catch { /* not ignored — good */ }
|
|
124
|
+
|
|
125
|
+
if (flags.json) {
|
|
126
|
+
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
127
|
+
} else {
|
|
128
|
+
for (const r of results) logger.info?.(`${mark[r.verdict]} ${r.id}: ${r.verdict}${r.verdict === VERDICTS.OK ? "" : ` — ${r.remedy || r.evidence}`}`);
|
|
129
|
+
logger.info?.(broken.length ? `steward: ${broken.length} invariant(s) BROKEN — the report names them (.karajan/steward/report.md)` : "steward: nothing broken — the full state is in .karajan/steward/report.md");
|
|
130
|
+
}
|
|
131
|
+
process.exitCode = broken.length ? 1 : 0;
|
|
132
|
+
return broken.length ? 1 : 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* STW-E — the on-resume mode: when work resumes there is someone in front to
|
|
137
|
+
* review the report, which is the requirement. Only for projects that ADOPTED
|
|
138
|
+
* the Steward (a report exists, or method_gates.steward is declared): the
|
|
139
|
+
* default imposes nothing. Best-effort: a failed sweep never stops a resume.
|
|
140
|
+
*/
|
|
141
|
+
export async function sweepOnResume({ config = {}, logger = console, sweepFn = stewardSweepCommand } = {}) {
|
|
142
|
+
const projectDir = config.projectDir || process.cwd();
|
|
143
|
+
const adopted = fs.existsSync(path.join(stewardDir(projectDir), "report.json")) || Boolean(config.method_gates?.steward);
|
|
144
|
+
if (!adopted) return;
|
|
145
|
+
try { await sweepFn({ flags: { ifStale: 1 }, config, logger }); } catch (err) { logger.warn?.(`⚠ steward: on-resume sweep failed (${err.message}) — resuming anyway`); }
|
|
146
|
+
}
|
package/src/config/defaults.js
CHANGED
|
@@ -80,7 +80,12 @@ const DEFAULTS = {
|
|
|
80
80
|
tests_with_code: "warn",
|
|
81
81
|
// MG-C: informative nudge when the staged diff exceeds this many
|
|
82
82
|
// added lines (0 disables). The project's CI owns any hard budget.
|
|
83
|
-
pr_size_warn: 150
|
|
83
|
+
pr_size_warn: 150,
|
|
84
|
+
// CLM-B (claims-with-evidence ADR): the Stop gate crosses the hard data of
|
|
85
|
+
// the turn's final message against that turn's tool outputs. "off" (adoption
|
|
86
|
+
// is explicit) | "warn" (report only) | "block" (a datum DENIED by its own
|
|
87
|
+
// source refuses to end the turn; unbacked data is still only reported).
|
|
88
|
+
claims: "off"
|
|
84
89
|
},
|
|
85
90
|
review_rules: "./.karajan/review-rules.md",
|
|
86
91
|
coder_rules: "./.karajan/coder-rules.md",
|