karajan-code 4.51.1 → 4.53.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/package.json +2 -1
- package/scripts/contract-claude.mjs +24 -0
- package/scripts/esbuild-sea.config.mjs +7 -0
- package/scripts/record-agent-fixtures.mjs +98 -0
- package/src/agents/claude-agent.js +8 -0
- package/src/agents/model-registry.js +5 -0
- package/src/agents/opencode-agent.js +16 -1
- package/src/audit/issue-filter.js +19 -1
- package/src/checks/audit-cycle.js +41 -0
- package/src/checks/claude-contract-check.js +27 -0
- package/src/checks/method.js +3 -2
- package/src/checks/release-check.js +23 -1
- package/src/checks/sonar.js +30 -12
- package/src/checks/supply-chain-check.js +47 -0
- package/src/cli/register-meta.js +14 -1
- package/src/cli/register-pipeline.js +9 -0
- package/src/cli/register-plan.js +10 -0
- package/src/commands/check.js +11 -1
- package/src/commands/config.js +2 -0
- package/src/commands/harden.js +3 -0
- package/src/commands/hu.js +24 -3
- package/src/commands/identity.js +4 -1
- package/src/commands/init.js +34 -1
- package/src/commands/plan/generate.js +5 -0
- package/src/commands/plan/license.js +35 -0
- package/src/commands/plan/ready.js +1 -1
- package/src/commands/plan/validate.js +1 -1
- package/src/commands/report.js +14 -0
- package/src/commands/review-gate.js +26 -2
- package/src/commands/worktree.js +35 -2
- package/src/config/defaults.js +10 -1
- package/src/config/overrides.js +8 -0
- package/src/config/schema.js +7 -0
- package/src/environment/playbook.js +2 -0
- package/src/harden/act-sign.js +2 -2
- package/src/harden/branch-protection.js +97 -0
- package/src/harden/phone-invite.js +15 -8
- package/src/harden/phone-texts.js +17 -0
- package/src/harden/security-level.js +18 -2
- package/src/harden/sentinel-hooks.js +2 -1
- package/src/hu/complexity-tier.js +32 -0
- package/src/hu/lane-prune.js +98 -0
- package/src/hu/model-router.js +7 -16
- package/src/hu/routing-metrics.js +16 -0
- package/src/hu/store.js +2 -0
- package/src/mcp/handlers/direct-handlers.js +2 -2
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +2 -0
- package/src/orchestrator/hu-sub-pipeline.js +19 -0
- package/src/orchestrator/stages/hu-reviewer-stage.js +10 -2
- package/src/orchestrator/stages/reviewer-stage.js +8 -1
- package/src/orchestrator/stages/sonar-stage.js +19 -1
- package/src/plan/plan-schema.js +4 -1
- package/src/plan/rework-license.js +38 -0
- package/src/prompts/hu-reviewer.js +5 -2
- package/src/prompts/planner.js +9 -1
- package/src/report/model-tally.js +39 -0
- package/src/review/branch-overlap.js +40 -0
- package/src/review/correction-rounds.js +40 -0
- package/src/review/dep-cooldown.js +87 -0
- package/src/review/one-shot-review.js +5 -2
- package/src/review/parser.js +21 -0
- package/src/review/publish-pr.js +54 -0
- package/src/review/solomon-arbitration.js +2 -2
- package/src/review/verdict-store.js +19 -7
- package/src/roles/sonar-role.js +4 -2
- package/src/sonar/api.js +7 -0
- package/src/utils/injection-guard.js +59 -43
- package/src/utils/injection-patterns.json +36 -0
- package/src/utils/model-selector.js +47 -3
package/src/commands/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
|
|
|
3
3
|
import yaml from "js-yaml";
|
|
4
4
|
import { loadConfig, validateConfig, getConfigPath, writeConfig } from "../config.js";
|
|
5
5
|
import { ensureDir } from "../utils/fs.js";
|
|
6
|
+
import { formatModelProfile } from "../utils/model-selector.js";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
|
|
8
9
|
function resolveEditor() {
|
|
@@ -70,5 +71,6 @@ export async function configCommand({ json = false, edit = false }) {
|
|
|
70
71
|
|
|
71
72
|
console.log(`Config path: ${cfgPath}`);
|
|
72
73
|
console.log(`Exists: ${exists}`);
|
|
74
|
+
console.log(formatModelProfile(config));
|
|
73
75
|
console.log(JSON.stringify(config, null, 2));
|
|
74
76
|
}
|
package/src/commands/harden.js
CHANGED
|
@@ -24,6 +24,7 @@ import { installHarnessHooks } from "../harden/harness-hooks.js";
|
|
|
24
24
|
import { installSentinelHooks } from "../harden/sentinel-hooks.js";
|
|
25
25
|
import { detectStackRoots } from "../harden/stack-roots.js";
|
|
26
26
|
import { installWorkflows } from "../harden/workflow-engine.js";
|
|
27
|
+
import { adviseBranchProtection } from "../harden/branch-protection.js";
|
|
27
28
|
import { detectTestFramework } from "../utils/project-detect.js";
|
|
28
29
|
import { ensureIdentity } from "../identity/bootstrap.js";
|
|
29
30
|
import { commitContract, contractChanges } from "../environment/contract-commit.js";
|
|
@@ -250,6 +251,8 @@ export async function hardenCommand({
|
|
|
250
251
|
out.identityCommand = id.command;
|
|
251
252
|
}
|
|
252
253
|
}
|
|
254
|
+
// KJC-TSK-0928: the method's checks only stop a merge with branch protection; kj says the exact command.
|
|
255
|
+
if (!dryRun) out.branchProtection = await adviseBranchProtection({ projectDir, branch: await resolveBaseBranch(projectDir), logger });
|
|
253
256
|
// KJC-BUG-0161 / ADR 0009 (opción A): --commit versiona la regeneración
|
|
254
257
|
// del supervisor con procedencia sellada. Acto humano — el guard vive en
|
|
255
258
|
// commitSupervisorRegeneration y rechaza sesiones de agente.
|
package/src/commands/hu.js
CHANGED
|
@@ -9,6 +9,8 @@ import { addHu, updateHuStatus } from "../plan/plan-hu-ops.js";
|
|
|
9
9
|
import { generatePlanId } from "../plan/plan-id.js";
|
|
10
10
|
import { savePlan, listPlans, loadPlan } from "../plan/plan-store.js";
|
|
11
11
|
import { detectHostAgent } from "../utils/agent-detect.js";
|
|
12
|
+
import { readIdentity } from "../identity/store.js";
|
|
13
|
+
import { effectiveGitEmail } from "../identity/detect.js";
|
|
12
14
|
|
|
13
15
|
// KJC-TSK-0661 (Jorge's friction): every HU records WHO created it — the
|
|
14
16
|
// board must never show work nobody can trace. Host agent env → that agent;
|
|
@@ -58,7 +60,10 @@ export async function backlogPlan(projectDir) {
|
|
|
58
60
|
return loadPlan(projectDir, planId);
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
|
|
63
|
+
// KJC-TSK-1047 (TEAM-A): who works this clone, the one a started card is reserved to.
|
|
64
|
+
const workerOf = (projectDir) => readIdentity(projectDir)?.gh_user || effectiveGitEmail(projectDir) || null;
|
|
65
|
+
|
|
66
|
+
export async function huCommand({ config = null, action, args = [], flags = {}, deps = {} }) {
|
|
62
67
|
const projectDir = config?.projectDir || process.cwd();
|
|
63
68
|
const emit = (obj, human) => { console.log(flags.json ? JSON.stringify(obj) : human); return obj; };
|
|
64
69
|
|
|
@@ -79,12 +84,14 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
|
|
|
79
84
|
// the card. Older HUs predate the stamp: shown as "?".
|
|
80
85
|
created_by: h.created_by || null, created_at: h.createdAt || null,
|
|
81
86
|
external_ref: h.external_ref || null,
|
|
87
|
+
claimed_by: h.claimed_by || null, claimed_at: h.claimed_at || null,
|
|
82
88
|
});
|
|
83
89
|
}
|
|
84
90
|
}
|
|
85
91
|
if (flags.json) { console.log(JSON.stringify(rows)); return rows; }
|
|
86
92
|
for (const r of rows) {
|
|
87
|
-
const
|
|
93
|
+
const reserved = r.status === "running" && r.claimed_by ? `reserved by ${r.claimed_by}` : null;
|
|
94
|
+
const suffixes = [r.created_at?.slice(0, 10), r.external_ref, reserved].filter(Boolean).map((s) => ` · ${s}`).join("");
|
|
88
95
|
const origin = `${r.plan} · by ${r.created_by || "?"}${suffixes}`;
|
|
89
96
|
console.log(`${(r.short_id || r.id).padEnd(28)} ${r.status.padEnd(8)} ${r.title} [${origin}]`);
|
|
90
97
|
}
|
|
@@ -215,9 +222,23 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
|
|
|
215
222
|
+ `a second concurrent task should live in its own lane: kj worktree start ${slug}`
|
|
216
223
|
);
|
|
217
224
|
}
|
|
225
|
+
// KJC-TSK-1047 (TEAM-A): a running card reserved by someone else moves only with --release.
|
|
226
|
+
const who = deps.who === undefined ? workerOf(projectDir) : deps.who;
|
|
227
|
+
const label = hu.short_id || hu.id;
|
|
228
|
+
// An unidentified caller is not the one who reserved it.
|
|
229
|
+
const takenOver = hu.status === "running" && hu.claimed_by && hu.claimed_by !== who;
|
|
230
|
+
if (takenOver && !flags.release) {
|
|
231
|
+
throw new Error(`${label} is reserved by ${hu.claimed_by} since ${hu.claimed_at} — releasing it is explicit: kj hu move ${ref} ${status} --release`);
|
|
232
|
+
}
|
|
233
|
+
const starting = status === "running" && hu.status !== "running";
|
|
234
|
+
// A running card nobody reserved is reserved by who moves it there again, once kj knows who.
|
|
235
|
+
const unclaimedRunning = status === "running" && !hu.claimed_by && who;
|
|
218
236
|
updateHuStatus(plan, hu.id, status);
|
|
237
|
+
// Starting it, or releasing it to any status, rewrites the reservation: who did it, or nobody.
|
|
238
|
+
if (starting || takenOver || unclaimedRunning) Object.assign(hu, { claimed_by: who ?? null, claimed_at: who ? new Date().toISOString() : null });
|
|
239
|
+
if (starting && !who) console.warn(`⚠ ${label} is not reserved: kj cannot tell who works this clone — declare it with kj identity set`);
|
|
219
240
|
await savePlan(projectDir, plan);
|
|
220
|
-
return emit({ id: hu.id, status }, `✓ ${
|
|
241
|
+
return emit({ id: hu.id, status, claimed_by: hu.claimed_by ?? null }, `✓ ${label} → ${status}`);
|
|
221
242
|
}
|
|
222
243
|
|
|
223
244
|
throw new Error(`unknown action "${action}" — use: add | move | list`);
|
package/src/commands/identity.js
CHANGED
|
@@ -14,6 +14,7 @@ import { signAct } from "../harden/act-sign.js";
|
|
|
14
14
|
import { enrollPhone, isPhoneEnrolled, readEnrolledKey, readEnrolledKeys, validatePhonePublicKey } from "../harden/phone-sign.js";
|
|
15
15
|
import { admitSigner, redeemRecoveryCode } from "../harden/roster.js";
|
|
16
16
|
import { phoneInvite } from "../harden/phone-invite.js";
|
|
17
|
+
import { readSecurityLevel } from "../harden/security-level.js";
|
|
17
18
|
import { humanAct } from "../harden/human-act.js";
|
|
18
19
|
import { readIdentity, writeIdentity } from "../identity/store.js";
|
|
19
20
|
import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
|
|
@@ -75,7 +76,9 @@ export async function identityCommand({ action = "show", config, kjVersion = "un
|
|
|
75
76
|
// KJC-TSK-0986 (HUM-F): the once-per-version invitation to enroll the phone,
|
|
76
77
|
// printed for the SessionStart hook (which cannot import kj) and for scripts.
|
|
77
78
|
if (action === "phone-invite") {
|
|
78
|
-
|
|
79
|
+
// KJC-BUG-0360: the text depends on the level; at max (the default) the acts are refused.
|
|
80
|
+
const { level } = readSecurityLevel({ projectDir: deps.projectDir ?? process.cwd(), home: deps.home });
|
|
81
|
+
const text = phoneInvite({ home: deps.home, version: flags.version, level });
|
|
79
82
|
if (text) log(text);
|
|
80
83
|
return 0;
|
|
81
84
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -26,6 +26,7 @@ import { installQmd } from "../utils/qmd-install.js";
|
|
|
26
26
|
import { registerQmdCollections } from "../utils/qmd-collection.js";
|
|
27
27
|
import { isGitRepo } from "../harden/harden-engine.js";
|
|
28
28
|
import { isPhoneEnrolled } from "../harden/phone-sign.js";
|
|
29
|
+
import { identityCommand } from "./identity.js";
|
|
29
30
|
import { hardenCommand } from "./harden.js";
|
|
30
31
|
import { contractChanges } from "../environment/contract-commit.js";
|
|
31
32
|
import { detectProjectStack } from "../utils/stack-detect.js";
|
|
@@ -300,7 +301,36 @@ export function tellPhoneStep(logger, { enrolled = isPhoneEnrolled() } = {}) {
|
|
|
300
301
|
logger.info(" 1. Open https://karajancode.com/sign on the phone: it creates a key pair and shows the PUBLIC key (the private one never leaves it).");
|
|
301
302
|
logger.info(" 2. From YOUR terminal, never from an agent session: kj identity enroll-phone <public key> --label <name>");
|
|
302
303
|
logger.info(" 3. Keep the recovery code it prints, off this machine: it replaces a lost phone (--recovery <code>).");
|
|
303
|
-
logger.info(" Prefer the four layers alone? kj security normal lowers the level
|
|
304
|
+
logger.info(" Prefer the four layers alone? kj security normal lowers the level from your own terminal while no phone is enrolled (ADR 0020).");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* KJC-TSK-1046 (ADR 0020): with a person at the terminal and no phone, init
|
|
309
|
+
* offers to enroll it now. The wizard only collects the public key: enrolling
|
|
310
|
+
* reads a typed code from /dev/tty, so it runs once the wizard is closed.
|
|
311
|
+
*/
|
|
312
|
+
export async function offerPhoneEnrollment(wizard, logger, { enrolled = isPhoneEnrolled() } = {}) {
|
|
313
|
+
if (enrolled) return null;
|
|
314
|
+
const now = await wizard.confirm("Enroll your phone now? (at the max level, seals and approvals are refused without it)", true);
|
|
315
|
+
const key = now ? String(await wizard.input(" Open https://karajancode.com/sign on the phone and paste the PUBLIC key it shows:", "")).trim() : "";
|
|
316
|
+
if (key) return key;
|
|
317
|
+
logger.info(" -> Phone: not now. Enroll it later from your own terminal: kj identity enroll-phone <public key> --label <name>");
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const enrollThroughIdentity = (key) => identityCommand({ action: "enroll-phone", config: { projectDir: process.cwd() }, flags: { publicKeyBase64: key, label: os.hostname() } });
|
|
322
|
+
|
|
323
|
+
/** Enrolls the key the wizard collected, with every check of the identity command; a failure is said and init goes on. */
|
|
324
|
+
export async function enrollPendingPhone(key, { enroll = enrollThroughIdentity, logger }) {
|
|
325
|
+
if (!key) return false;
|
|
326
|
+
let reason = "see the message above";
|
|
327
|
+
try {
|
|
328
|
+
if ((await enroll(key)) === 0) return true;
|
|
329
|
+
} catch (err) {
|
|
330
|
+
reason = err.message;
|
|
331
|
+
}
|
|
332
|
+
logger.info(` -> Phone not enrolled (${reason}). Try again from your own terminal: kj identity enroll-phone <public key>`);
|
|
333
|
+
return false;
|
|
304
334
|
}
|
|
305
335
|
|
|
306
336
|
/**
|
|
@@ -355,6 +385,7 @@ async function runWizard(config, logger) {
|
|
|
355
385
|
logger.info("");
|
|
356
386
|
|
|
357
387
|
const wizard = createWizard();
|
|
388
|
+
let pendingPhoneKey;
|
|
358
389
|
try {
|
|
359
390
|
const { coder, reviewer } = await askAgents(wizard, available, config, logger);
|
|
360
391
|
|
|
@@ -418,10 +449,12 @@ async function runWizard(config, logger) {
|
|
|
418
449
|
|
|
419
450
|
logger.info("");
|
|
420
451
|
tellPhoneStep(logger);
|
|
452
|
+
pendingPhoneKey = await offerPhoneEnrollment(wizard, logger);
|
|
421
453
|
logger.info("");
|
|
422
454
|
} finally {
|
|
423
455
|
wizard.close();
|
|
424
456
|
}
|
|
457
|
+
await enrollPendingPhone(pendingPhoneKey, { logger });
|
|
425
458
|
|
|
426
459
|
return config;
|
|
427
460
|
}
|
|
@@ -232,6 +232,11 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
|
|
|
232
232
|
// KJC-TSK-0661: provenance stamp (addHu builds its object explicitly,
|
|
233
233
|
// so this goes on the returned reference, not in huData).
|
|
234
234
|
hu.created_by = "planner";
|
|
235
|
+
// KJC-TSK-1061: the classification travels to the HU; validatePlan judges it (KJC-TSK-0717).
|
|
236
|
+
for (const key of ["change_class", "change_criterion", "hardens"]) {
|
|
237
|
+
const value = typeof step === "object" ? step[key] : null;
|
|
238
|
+
if (typeof value === "string" && value.trim()) hu[key] = value.trim();
|
|
239
|
+
}
|
|
235
240
|
if (symbolicId) symbolicToHuId.set(symbolicId, hu.id);
|
|
236
241
|
const deps = typeof step === "object" && Array.isArray(step.dependencies) ? step.dependencies : [];
|
|
237
242
|
stepDeps.push(deps);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kj plan license <planId> <huId> --module <path> (KJC-TSK-0717, SPD-A): scores
|
|
3
|
+
* the module with kj mutate and records the REWORK licence on the HU, with the
|
|
4
|
+
* threshold in force. validatePlan judges it again from the score.
|
|
5
|
+
*/
|
|
6
|
+
import { loadPlan, savePlan } from "../../plan/plan-store.js";
|
|
7
|
+
import { licenseFor, DEFAULT_REWORK_MIN } from "../../plan/rework-license.js";
|
|
8
|
+
import { mutateCommand } from "../mutate.js";
|
|
9
|
+
|
|
10
|
+
/** The module's mutation score in percent, or null when kj mutate gives none. */
|
|
11
|
+
async function scoreModule(projectDir, module) {
|
|
12
|
+
let report = null;
|
|
13
|
+
const logger = { info: (line) => { try { report = JSON.parse(line); } catch { /* a progress line */ } } };
|
|
14
|
+
await mutateCommand({ projectDir, path: module, json: true, maxSurvivors: Number.POSITIVE_INFINITY, logger });
|
|
15
|
+
return Number.isFinite(report?.score) ? report.score : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function planLicenseCommand({ config, planId, huId, module, deps = {} }) {
|
|
19
|
+
const say = deps.say ?? console.log;
|
|
20
|
+
const projectDir = config.projectDir || process.cwd();
|
|
21
|
+
if (!module) { say("kj plan license: --module <path> is required (the module the REWORK regenerates)"); return 1; }
|
|
22
|
+
const plan = await (deps.loadPlan ?? loadPlan)(projectDir, planId);
|
|
23
|
+
if (!plan) { say(`Plan not found: ${planId}`); return 1; }
|
|
24
|
+
const hu = plan.hus?.find((h) => h.id === huId);
|
|
25
|
+
if (!hu) { say(`HU not found: ${huId}`); return 1; }
|
|
26
|
+
const score = await (deps.scoreModule ?? scoreModule)(projectDir, module);
|
|
27
|
+
if (score === null) { say(`kj mutate could not score ${module}: no licence recorded (run kj mutate ${module} to see why)`); return 1; }
|
|
28
|
+
const threshold = config?.method_gates?.rework_mutation_min ?? DEFAULT_REWORK_MIN;
|
|
29
|
+
hu.rework_license = licenseFor({ module, score, threshold });
|
|
30
|
+
await (deps.savePlan ?? savePlan)(projectDir, plan);
|
|
31
|
+
const pct = `${Math.round(threshold * 100)}%`;
|
|
32
|
+
if (hu.rework_license.granted) { say(`✓ REWORK licence granted for ${module} on ${huId}: mutation score ${score}% at or over ${pct}`); return 0; }
|
|
33
|
+
say(`✗ REWORK licence denied for ${module} on ${huId}: mutation score ${score}% under ${pct}. Plan two phases: add an HU with hardens: ${module} (kill the survivors first) and put it in ${huId}'s blocked_by`);
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
@@ -10,7 +10,7 @@ export async function planReadyCommand({ config, planId }) {
|
|
|
10
10
|
const plan = await loadPlan(projectDir, planId);
|
|
11
11
|
if (!plan) { console.error(`Plan not found: ${planId}`); process.exitCode = 1; return; }
|
|
12
12
|
|
|
13
|
-
const validation = validatePlan(plan);
|
|
13
|
+
const validation = validatePlan(plan, { reworkMin: config?.method_gates?.rework_mutation_min });
|
|
14
14
|
if (!validation.valid) {
|
|
15
15
|
console.error("Plan validation failed:");
|
|
16
16
|
for (const err of validation.errors) console.error(` - ${err}`);
|
|
@@ -9,7 +9,7 @@ export async function planValidateCommand({ config, planId }) {
|
|
|
9
9
|
const plan = await loadPlan(projectDir, planId);
|
|
10
10
|
if (!plan) { console.error(`Plan not found: ${planId}`); process.exitCode = 1; return; }
|
|
11
11
|
|
|
12
|
-
const result = validatePlan(plan);
|
|
12
|
+
const result = validatePlan(plan, { reworkMin: config?.method_gates?.rework_mutation_min });
|
|
13
13
|
if (result.valid) {
|
|
14
14
|
console.log(`✅ Plan ${planId} is valid (${plan.hus.length} HUs, status: ${plan.status})`);
|
|
15
15
|
} else {
|
package/src/commands/report.js
CHANGED
|
@@ -4,6 +4,8 @@ import { exists } from "../utils/fs.js";
|
|
|
4
4
|
import { getSessionRoot } from "../utils/paths.js";
|
|
5
5
|
import { loadConfig } from "../config.js";
|
|
6
6
|
import { DEFAULTS } from "../config/defaults.js";
|
|
7
|
+
import { listPlans, loadPlan } from "../plan/plan-store.js";
|
|
8
|
+
import { formatModelTally, modelTally } from "../report/model-tally.js";
|
|
7
9
|
|
|
8
10
|
function parseBudgetFromActivityLog(logText) {
|
|
9
11
|
if (!logText) {
|
|
@@ -269,6 +271,18 @@ async function printSentinelEscapes({ projectDir = process.cwd(), format } = {})
|
|
|
269
271
|
console.log(tally);
|
|
270
272
|
}
|
|
271
273
|
} catch { /* no decision log — no panel to tally */ }
|
|
274
|
+
// KJC-TSK-1057 (MR-E2): what each coder model cost and how often it failed first time.
|
|
275
|
+
try {
|
|
276
|
+
const hus = [];
|
|
277
|
+
for (const meta of await listPlans(projectDir)) hus.push(...((await loadPlan(projectDir, meta.planId))?.hus ?? []));
|
|
278
|
+
const tally = formatModelTally(modelTally(hus));
|
|
279
|
+
if (tally) {
|
|
280
|
+
console.log("");
|
|
281
|
+
console.log(tally);
|
|
282
|
+
}
|
|
283
|
+
} catch (err) {
|
|
284
|
+
console.log(`\nCoder models: the plans could not be read (${err.message})`);
|
|
285
|
+
}
|
|
272
286
|
}
|
|
273
287
|
|
|
274
288
|
function formatDuration(ms) {
|
|
@@ -29,6 +29,9 @@ import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
|
|
|
29
29
|
import { loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
|
|
30
30
|
import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
|
|
31
31
|
import { evaluatePolicyGate } from "../review/policy-gate.js";
|
|
32
|
+
import { correctionNote, reviewCorrectionRounds } from "../review/correction-rounds.js";
|
|
33
|
+
import { cooldownAdvisories, cooldownGate, DEFAULT_COOLDOWN_HOURS, formatCooldown } from "../review/dep-cooldown.js";
|
|
34
|
+
import { branchOverlaps, formatOverlaps } from "../review/branch-overlap.js";
|
|
32
35
|
|
|
33
36
|
// KJC-TSK-0686 (MG-A): card-first is a gate, not a habit. Runs on --staged
|
|
34
37
|
// (before spending sonar/reviewer effort) AND on --check — the pre-commit
|
|
@@ -111,11 +114,12 @@ export const formatExempted = (e) => (e.standing
|
|
|
111
114
|
? `⚠ policy standing [${e.rule_id}] — excepción permanente${e.standing.origin === "global" ? " GLOBAL" : ""} viva hasta ${e.standing.expiresAt} (${e.standing.justification || "sin justificación"})`
|
|
112
115
|
: `⚠ policy exempt [${e.rule_id}] — excepción registrada en .karajan/policy-exceptions.jsonl (${e.justification})`);
|
|
113
116
|
|
|
114
|
-
function printVerdict(record) {
|
|
117
|
+
function printVerdict(record, rounds = 0) {
|
|
115
118
|
if (record.verdict === "approved") {
|
|
116
119
|
const ws = record.workspace ? ` [${record.workspace}]` : "";
|
|
117
120
|
console.log(`✓ APPROVED by ${record.reviewer} (diff ${record.diffHash.slice(0, 12)})${ws}`);
|
|
118
121
|
if (record.summary) console.log(` ${record.summary}`);
|
|
122
|
+
if (rounds > 0) console.log(` ${correctionNote(rounds)}`);
|
|
119
123
|
return;
|
|
120
124
|
}
|
|
121
125
|
console.log(`✗ REJECTED by ${record.reviewer} — ${record.issues.length} blocking issue(s):`);
|
|
@@ -576,8 +580,28 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
576
580
|
}
|
|
577
581
|
}
|
|
578
582
|
|
|
583
|
+
// KJC-TSK-1052 (TEAM-F): another branch in progress touching the same files is said, never blocks.
|
|
584
|
+
if (flags.staged && !flags.range) {
|
|
585
|
+
for (const line of formatOverlaps(await branchOverlaps({ projectDir, stagedFiles: changedFiles, base: config?.base_branch || "main" }))) console.log(line);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// KJC-TSK-0722: a dependency published under the cooldown is said, and weighed by the reviewer.
|
|
589
|
+
const cooldownHours = config?.supply_chain?.cooldown_hours ?? DEFAULT_COOLDOWN_HOURS;
|
|
590
|
+
const cooldownResult = await cooldownAdvisories(diff, { thresholdHours: cooldownHours });
|
|
591
|
+
const cooldown = formatCooldown(cooldownResult, cooldownHours);
|
|
592
|
+
for (const line of cooldown.lines) console.log(line);
|
|
593
|
+
// KJC-TSK-1056: method_gates.dep_cooldown: block stops it here, before any reviewer token.
|
|
594
|
+
const cooldownStop = cooldownGate({ result: cooldownResult, mode: config?.method_gates?.dep_cooldown, standingExceptions: std.standing });
|
|
595
|
+
if (cooldownStop.grant) console.log(`⚠ dep_cooldown lifted by a HUMAN grant [method.dep_cooldown] until ${cooldownStop.grant.expiresAt}`);
|
|
596
|
+
if (cooldownStop.block) {
|
|
597
|
+
console.log(`✗ ${cooldownStop.reason}`);
|
|
598
|
+
process.exitCode = 1;
|
|
599
|
+
return { verdict: "rejected", reviewer: "dep-cooldown", issues: cooldownResult.young.map((p) => ({ severity: "high", description: `${p.name}@${p.version} published ${p.hours} h ago` })) };
|
|
600
|
+
}
|
|
601
|
+
if (cooldown.note) task = `${task || "Review the following diff for correctness, security and maintainability."}\n\n${cooldown.note}`;
|
|
602
|
+
|
|
579
603
|
const record = await runOneShotReview({ diff, task, config, logger, projectDir, sonar: sonarRecord, rag: ragRecord, ui: uiRecord });
|
|
580
|
-
printVerdict(record);
|
|
604
|
+
printVerdict(record, record.verdict === "approved" ? await reviewCorrectionRounds(projectDir, record) : 0);
|
|
581
605
|
process.exitCode = record.verdict === "approved" ? 0 : 1;
|
|
582
606
|
return record;
|
|
583
607
|
}
|
package/src/commands/worktree.js
CHANGED
|
@@ -11,13 +11,15 @@ import path from "node:path";
|
|
|
11
11
|
import { execFile } from "node:child_process";
|
|
12
12
|
import { promisify } from "node:util";
|
|
13
13
|
import { bootstrapWorktree } from "../hu/worktree-bootstrap.js";
|
|
14
|
+
import { classifyLanes, liveProcesses, looseLaneBranches, resolveBase } from "../hu/lane-prune.js";
|
|
15
|
+
import { recordGateDecision } from "../policy/decisions.js";
|
|
14
16
|
|
|
15
17
|
const execFileAsync = promisify(execFile);
|
|
16
18
|
const WORKTREE_DIR = ".kj/worktrees"; // shared with the headless lanes
|
|
17
19
|
|
|
18
20
|
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
19
21
|
|
|
20
|
-
export async function worktreeCommand({ config = null, action, args = [], flags = {} }) {
|
|
22
|
+
export async function worktreeCommand({ config = null, action, args = [], flags = {}, deps = {} }) {
|
|
21
23
|
const projectDir = config?.projectDir || process.cwd();
|
|
22
24
|
const git = (gitArgs, cwd = projectDir) => execFileAsync("git", gitArgs, { cwd });
|
|
23
25
|
const lanePath = (slug) => path.join(projectDir, WORKTREE_DIR, slug);
|
|
@@ -72,5 +74,36 @@ export async function worktreeCommand({ config = null, action, args = [], flags
|
|
|
72
74
|
return { removed: true, slug };
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
|
|
77
|
+
if (action === "prune") {
|
|
78
|
+
const { processes = liveProcesses() } = deps;
|
|
79
|
+
const base = await resolveBase({ git, base: config?.base_branch || "main" });
|
|
80
|
+
const lanes = await classifyLanes({ git, laneRoot: path.join(projectDir, WORKTREE_DIR), base, processes, now: deps.now });
|
|
81
|
+
const abandoned = lanes.filter((l) => l.reasons.length === 0);
|
|
82
|
+
const kept = lanes.filter((l) => l.reasons.length > 0);
|
|
83
|
+
for (const l of kept) console.log(`kept ${l.slug.padEnd(24)} ${l.reasons.join("; ")}`);
|
|
84
|
+
if (lanes.length === 0) console.log("no kj lanes");
|
|
85
|
+
// KJC-TSK-1037: loose kj-hu-* branches of old runs, measured against the same base.
|
|
86
|
+
const { empty: branches, withWork } = await looseLaneBranches({ git, base });
|
|
87
|
+
for (const b of withWork) console.log(`kept branch ${b.branch.padEnd(17)} ${b.reason}`);
|
|
88
|
+
if (flags.dryRun) {
|
|
89
|
+
for (const l of abandoned) console.log(`would retire ${l.slug} (${l.branch ?? "detached"})`);
|
|
90
|
+
for (const b of branches) console.log(`would delete branch ${b}`);
|
|
91
|
+
return { wouldRemove: abandoned, kept, wouldRemoveBranches: branches, branchesKept: withWork };
|
|
92
|
+
}
|
|
93
|
+
for (const b of branches) {
|
|
94
|
+
await git(["branch", "-D", b]);
|
|
95
|
+
console.log(`✓ deleted branch ${b}`);
|
|
96
|
+
}
|
|
97
|
+
for (const l of abandoned) {
|
|
98
|
+
await git(["worktree", "remove", l.path]);
|
|
99
|
+
if (l.branch) await git(["branch", "-D", l.branch]);
|
|
100
|
+
console.log(`✓ retired ${l.slug} (${l.branch ?? "detached"})`);
|
|
101
|
+
}
|
|
102
|
+
if (abandoned.length > 0 || branches.length > 0) {
|
|
103
|
+
recordGateDecision(projectDir, { decision: "allow", chokepoint: "tool", kind: "worktree-prune", lanes: abandoned.map((l) => l.slug), branches });
|
|
104
|
+
}
|
|
105
|
+
return { removed: abandoned, kept, branchesRemoved: branches, branchesKept: withWork };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
throw new Error(`unknown action "${action}" — use: start | list | done | prune`);
|
|
76
109
|
}
|
package/src/config/defaults.js
CHANGED
|
@@ -90,8 +90,17 @@ const DEFAULTS = {
|
|
|
90
90
|
// the turn's final message against that turn's tool outputs. "off" (adoption
|
|
91
91
|
// is explicit) | "warn" (report only) | "block" (a datum DENIED by its own
|
|
92
92
|
// source refuses to end the turn; unbacked data is still only reported).
|
|
93
|
-
claims: "off"
|
|
93
|
+
claims: "off",
|
|
94
|
+
// KJC-TSK-0717 (SPD-A): the module mutation score a REWORK needs (0.8 = 80 %).
|
|
95
|
+
rework_mutation_min: 0.8,
|
|
96
|
+
// KJC-TSK-1056: a dependency published under supply_chain.cooldown_hours
|
|
97
|
+
// ago: "warn" (said in kj review) | "block" (stops the review; a human
|
|
98
|
+
// grant on method.dep_cooldown lifts it). Unverifiable ones never block.
|
|
99
|
+
dep_cooldown: "warn"
|
|
94
100
|
},
|
|
101
|
+
// KJC-TSK-0719 (SPD-C): kj check says the audit is due after this many
|
|
102
|
+
// distinct cards or work commits since the last scored audit.
|
|
103
|
+
audit_cycle: { every_cards: 10, every_commits: 60 },
|
|
95
104
|
review_rules: "./.karajan/review-rules.md",
|
|
96
105
|
coder_rules: "./.karajan/coder-rules.md",
|
|
97
106
|
base_branch: "main",
|
package/src/config/overrides.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { mergeDeep } from "./loader.js";
|
|
18
18
|
import { DEFAULTS } from "./defaults.js";
|
|
19
19
|
import { safeParseConfig, formatConfigIssues } from "./schema.js";
|
|
20
|
+
import { applyModelProfile } from "../utils/model-selector.js";
|
|
20
21
|
|
|
21
22
|
const ROLE_PROVIDER_FLAGS = [
|
|
22
23
|
["planner", "planner"], ["coder", "coder"], ["reviewer", "reviewer"],
|
|
@@ -99,6 +100,13 @@ function applyRoleOverrides(out, flags) {
|
|
|
99
100
|
}
|
|
100
101
|
// reviewerModel also updates reviewer_options
|
|
101
102
|
if (flags.reviewerModel) out.reviewer_options.model = String(flags.reviewerModel);
|
|
103
|
+
|
|
104
|
+
// KJC-TSK-0699: a profile fills only the models nothing above has set.
|
|
105
|
+
const profile = flags.profile ?? out.model_profile;
|
|
106
|
+
if (profile) {
|
|
107
|
+
applyModelProfile(out, String(profile));
|
|
108
|
+
out.model_profile = String(profile);
|
|
109
|
+
}
|
|
102
110
|
}
|
|
103
111
|
|
|
104
112
|
function applyPipelineOverrides(out, flags) {
|
package/src/config/schema.js
CHANGED
|
@@ -242,6 +242,8 @@ export const ConfigSchema = v.looseObject({
|
|
|
242
242
|
))),
|
|
243
243
|
// ENV-D1 (KJC-TSK-0642): where work items live — the integrated HU Board
|
|
244
244
|
// or the user's Planning Game. The v4 playbook renders per backend.
|
|
245
|
+
// KJC-TSK-0699 (MR-B): the default model profile; kj run --profile overrides it.
|
|
246
|
+
model_profile: v.optional(v.picklist(["frugal", "standard", "deep"], "model_profile must be frugal, standard or deep")),
|
|
245
247
|
state_backend: v.optional(v.picklist(
|
|
246
248
|
// KJC-TSK-0709 caught the drift: "external" shipped in v4.5.0 (any
|
|
247
249
|
// board via the agent's own MCP/tools) but this picklist never learned it.
|
|
@@ -249,6 +251,11 @@ export const ConfigSchema = v.looseObject({
|
|
|
249
251
|
"state_backend must be \"hu-board\", \"planning-game\" or \"external\""
|
|
250
252
|
)),
|
|
251
253
|
review_rules: v.optional(v.string()),
|
|
254
|
+
// KJC-TSK-0719 (SPD-C): kj check says the audit is due past these counts.
|
|
255
|
+
audit_cycle: v.optional(v.looseObject({
|
|
256
|
+
every_cards: v.optional(PositiveInt),
|
|
257
|
+
every_commits: v.optional(PositiveInt),
|
|
258
|
+
})),
|
|
252
259
|
coder_rules: v.optional(v.string()),
|
|
253
260
|
base_branch: v.optional(v.string()),
|
|
254
261
|
coder_options: CoderOptions,
|
|
@@ -70,6 +70,8 @@ Invariants (the git gates enforce these — they are not suggestions):
|
|
|
70
70
|
- Every diff is reviewed by a DIFFERENT AI before it is committed
|
|
71
71
|
(\`kj review --staged\`): verdicts bind to the exact diff — change the code
|
|
72
72
|
and it must be reviewed again. Disagree with a rejection? \`kj solomon\`.
|
|
73
|
+
A correction from the other AI is the method working: apply it and move
|
|
74
|
+
on, no apology, and never report it as your mistake.
|
|
73
75
|
${panel ? `${panel}\n` : ""}- Security findings are never overridable — not even by arbitration. You
|
|
74
76
|
absorb the security role: \`kj brief security\` states what must be true.
|
|
75
77
|
Task touches auth, user input, secrets, network or deps? Run
|
package/src/harden/act-sign.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { basename } from "node:path";
|
|
11
11
|
|
|
12
12
|
import { humanActOf } from "./human-act.js";
|
|
13
|
-
import { PHONE_INVITE } from "./phone-
|
|
13
|
+
import { PHONE_ENROLL, PHONE_INVITE } from "./phone-texts.js";
|
|
14
14
|
import { isPhoneEnrolled, requestPhoneSignature } from "./phone-sign.js";
|
|
15
15
|
import { readSecurityLevel } from "./security-level.js";
|
|
16
16
|
|
|
@@ -53,7 +53,7 @@ export async function signAct(id, { projectDir, home, kjVersion, files, changes
|
|
|
53
53
|
const phone = deps.phone ?? { enrolled: isPhoneEnrolled, request: requestPhoneSignature };
|
|
54
54
|
if (!phone.enrolled({ home })) {
|
|
55
55
|
if (level.level === "max" && !bootstrap) {
|
|
56
|
-
throw new Error(`${act.label}: con seguridad máxima este acto se firma con el móvil y no hay ninguno enrolado. ${
|
|
56
|
+
throw new Error(`${act.label}: con seguridad máxima este acto se firma con el móvil y no hay ninguno enrolado. ${PHONE_ENROLL}`);
|
|
57
57
|
}
|
|
58
58
|
logger.warn?.(`${act.label}: sin móvil enrolado solo valen las cuatro capas y la garantía es menor (ADR 0018). ${PHONE_INVITE}`);
|
|
59
59
|
return { level, signature: null };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0928 (M15-B): the method's CI checks become required on the base
|
|
3
|
+
* branch, where no agent reaches. Branch protection needs admin rights, so kj
|
|
4
|
+
* never applies it: it reads what is there and says the exact command.
|
|
5
|
+
*/
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { parse as parseYaml } from "yaml";
|
|
9
|
+
import { runCommand } from "../utils/process.js";
|
|
10
|
+
|
|
11
|
+
// Scheduled or advisory kj workflows: they never block a PR, so they are never required.
|
|
12
|
+
const NOT_REQUIRED = new Set(["kj-mutation.yml", "kj-steward.yml"]);
|
|
13
|
+
|
|
14
|
+
/** The GitHub check name of each job: its `name:` when it has one, its id otherwise. */
|
|
15
|
+
function jobChecks(text) {
|
|
16
|
+
const jobs = parseYaml(text)?.jobs ?? {};
|
|
17
|
+
return Object.entries(jobs).map(([id, job]) => (typeof job?.name === "string" ? job.name : id));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The checks kj harden's own workflows publish, from the files installed in the repo. */
|
|
21
|
+
export function requiredChecksFrom(projectDir) {
|
|
22
|
+
const dir = path.join(projectDir, ".github", "workflows");
|
|
23
|
+
if (!fs.existsSync(dir)) return [];
|
|
24
|
+
return fs.readdirSync(dir)
|
|
25
|
+
.filter((f) => /^kj-.*\.ya?ml$/.test(f) && !NOT_REQUIRED.has(f))
|
|
26
|
+
.toSorted()
|
|
27
|
+
.flatMap((f) => jobChecks(fs.readFileSync(path.join(dir, f), "utf8")));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const shellQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
31
|
+
|
|
32
|
+
// A base branch such as release/1.0 is one path segment of the REST endpoint.
|
|
33
|
+
const protectionPath = ({ owner, repo, branch }) => `repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}/protection`;
|
|
34
|
+
|
|
35
|
+
function protectCommand({ checks, ...target }) {
|
|
36
|
+
const contexts = checks.map((c) => `-f ${shellQuote(`required_status_checks[contexts][]=${c}`)}`).join(" ");
|
|
37
|
+
return `gh api -X PUT ${protectionPath(target)} -F 'required_status_checks[strict]=false' ${contexts} -F enforce_admins=false -F required_pull_request_reviews=null -F restrictions=null -F allow_force_pushes=false`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const addCommand = ({ missing, ...target }) =>
|
|
41
|
+
`gh api -X POST ${protectionPath(target)}/required_status_checks/contexts ${missing.map((c) => `-f ${shellQuote(`contexts[]=${c}`)}`).join(" ")}`;
|
|
42
|
+
|
|
43
|
+
const settingsUrl = ({ owner, repo }) => `https://github.com/${owner}/${repo}/settings/branches`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What the base branch needs for the checks to be required, and how. A command
|
|
47
|
+
* is given only when it cannot undo anything: the full PUT for a branch with no
|
|
48
|
+
* protection at all, the additive contexts call otherwise. Protection without
|
|
49
|
+
* status checks, or unreadable, gets the settings page: a PUT there would wipe
|
|
50
|
+
* the reviews and restrictions already set. Never applies anything, never throws.
|
|
51
|
+
* @returns {Promise<{status: "ok"|"missing"|"unprotected"|"no-checks"|"unverified", command?: string, settings?: string, missing?: string[], reason?: string}>}
|
|
52
|
+
*/
|
|
53
|
+
export async function protectionAdvice({ owner, repo, branch, checks, run = runCommand }) {
|
|
54
|
+
const target = { owner, repo, branch };
|
|
55
|
+
let res;
|
|
56
|
+
try {
|
|
57
|
+
res = await run("gh", ["api", protectionPath(target)]);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return { status: "unverified", reason: err.message, settings: settingsUrl(target), missing: checks };
|
|
60
|
+
}
|
|
61
|
+
if (res.exitCode !== 0) {
|
|
62
|
+
if (/404|not protected/i.test(`${res.stderr}${res.stdout}`)) return { status: "unprotected", command: protectCommand({ ...target, checks }) };
|
|
63
|
+
return { status: "unverified", reason: (res.stderr || res.stdout || "gh failed").trim(), settings: settingsUrl(target), missing: checks };
|
|
64
|
+
}
|
|
65
|
+
const statusChecks = JSON.parse(res.stdout || "{}").required_status_checks;
|
|
66
|
+
if (!statusChecks) return { status: "no-checks", settings: settingsUrl(target), missing: checks };
|
|
67
|
+
const existing = new Set(statusChecks.contexts ?? []);
|
|
68
|
+
const missing = checks.filter((c) => !existing.has(c));
|
|
69
|
+
return missing.length === 0 ? { status: "ok" } : { status: "missing", missing, command: addCommand({ ...target, missing }) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Owner and repo go into a command an admin copies: only the characters GitHub allows in them.
|
|
73
|
+
const GITHUB_REMOTE = /github\.com[^:/]*[:/]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/;
|
|
74
|
+
|
|
75
|
+
/** kj harden's last step: on a GitHub remote, what the base branch needs and who runs it. */
|
|
76
|
+
export async function adviseBranchProtection({ projectDir, branch, logger, run = runCommand }) {
|
|
77
|
+
const checks = requiredChecksFrom(projectDir);
|
|
78
|
+
if (checks.length === 0) return null;
|
|
79
|
+
const remote = await run("git", ["-C", projectDir, "remote", "get-url", "origin"]).catch(() => null);
|
|
80
|
+
const match = remote?.exitCode === 0 ? GITHUB_REMOTE.exec(remote.stdout.trim()) : null;
|
|
81
|
+
if (!match) return null;
|
|
82
|
+
const advice = await protectionAdvice({ owner: match[1], repo: match[2], branch, checks, run });
|
|
83
|
+
if (advice.status === "ok") {
|
|
84
|
+
logger.info?.(` • branch protection: ${branch} requires the ${checks.length} kj check(s)`);
|
|
85
|
+
return advice;
|
|
86
|
+
}
|
|
87
|
+
const list = advice.missing?.join(", ");
|
|
88
|
+
const why = {
|
|
89
|
+
unprotected: `${branch} is not protected: the method's checks do not stop a merge. An admin runs (kj never does):`,
|
|
90
|
+
missing: `${branch} does not require ${advice.missing?.length} kj check(s) (${list}). An admin runs (kj never does):`,
|
|
91
|
+
"no-checks": `${branch} is protected but requires no status checks. An admin enables "Require status checks" and adds ${list} in:`,
|
|
92
|
+
unverified: `could not read the protection of ${branch} (${advice.reason}). An admin checks that ${list} are required in:`,
|
|
93
|
+
}[advice.status];
|
|
94
|
+
logger.warn?.(` ⚠ branch protection: ${why}`);
|
|
95
|
+
logger.warn?.(` ${advice.command ?? advice.settings}`);
|
|
96
|
+
return advice;
|
|
97
|
+
}
|
|
@@ -10,10 +10,11 @@ import { homedir } from "node:os";
|
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
|
|
12
12
|
import { isPhoneEnrolled } from "./phone-sign.js";
|
|
13
|
+
import { phoneTextFor } from "./phone-texts.js";
|
|
14
|
+
import { readSecurityLevel } from "./security-level.js";
|
|
13
15
|
import { STRATEGY } from "../checks/types.js";
|
|
14
16
|
|
|
15
|
-
export
|
|
16
|
-
"El móvil no está enrolado: los sellos (kj harden --commit), las aprobaciones y los comandos del catálogo se firman hoy con un código tecleado, con menos garantía. Enrólalo con kj identity enroll-phone <clave pública de la app>: cada acto queda atado a tu clave privada, que nunca sale del móvil (ADR 0018).";
|
|
17
|
+
export { PHONE_ENROLL, PHONE_INVITE, PHONE_REFUSED_AT_MAX } from "./phone-texts.js";
|
|
17
18
|
|
|
18
19
|
const markerPath = (home) => join(home ?? homedir(), ".karajan", "phone-invite.json");
|
|
19
20
|
|
|
@@ -21,10 +22,10 @@ const markerPath = (home) => join(home ?? homedir(), ".karajan", "phone-invite.j
|
|
|
21
22
|
* The invitation, once per kj version, or null (phone enrolled, or already said
|
|
22
23
|
* for this version). Saying it records it; a marker that cannot be written
|
|
23
24
|
* never silences the invitation.
|
|
24
|
-
* @param {{home?: string, version?: string}} [opts]
|
|
25
|
+
* @param {{home?: string, version?: string, level?: "max"|"normal"}} [opts]
|
|
25
26
|
* @returns {string|null}
|
|
26
27
|
*/
|
|
27
|
-
export function phoneInvite({ home, version = "unknown" } = {}) {
|
|
28
|
+
export function phoneInvite({ home, version = "unknown", level = "max" } = {}) {
|
|
28
29
|
if (isPhoneEnrolled({ home })) return null;
|
|
29
30
|
let seen = null;
|
|
30
31
|
try { seen = JSON.parse(readFileSync(markerPath(home), "utf8")).version; } catch { /* never invited */ }
|
|
@@ -33,11 +34,16 @@ export function phoneInvite({ home, version = "unknown" } = {}) {
|
|
|
33
34
|
mkdirSync(dirname(markerPath(home)), { recursive: true });
|
|
34
35
|
writeFileSync(markerPath(home), `${JSON.stringify({ version, at: new Date().toISOString() })}\n`, "utf8");
|
|
35
36
|
} catch { /* the invitation still goes out */ }
|
|
36
|
-
return
|
|
37
|
+
return phoneTextFor(level);
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
const projectLevel = (projectDir, home) => readSecurityLevel({ projectDir, home }).level;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `kj doctor`: without a phone, a FAILURE at the max level (the default, where
|
|
44
|
+
* the acts are refused) and a warning at the normal level (KJC-BUG-0360).
|
|
45
|
+
*/
|
|
46
|
+
export function createPhoneCheck({ home, projectDir = process.cwd(), readLevel = () => projectLevel(projectDir, home) } = {}) {
|
|
41
47
|
return {
|
|
42
48
|
name: "phone-enrolled",
|
|
43
49
|
label: "móvil enrolado (firma de sellos y aprobaciones)",
|
|
@@ -45,7 +51,8 @@ export function createPhoneCheck({ home } = {}) {
|
|
|
45
51
|
describe: "Whether a phone's public key is enrolled for the supervisor seal and approvals (ADR 0018)",
|
|
46
52
|
async detect() {
|
|
47
53
|
if (isPhoneEnrolled({ home })) return { ok: true, severity: "info", detail: "clave pública en ~/.karajan/supervisor-phone.json" };
|
|
48
|
-
|
|
54
|
+
const level = await readLevel();
|
|
55
|
+
return { ok: false, severity: level === "normal" ? "warn" : "fail", detail: phoneTextFor(level), fix: "kj identity enroll-phone <clave pública de la app>" };
|
|
49
56
|
},
|
|
50
57
|
};
|
|
51
58
|
}
|