karajan-code 3.5.1 → 3.6.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.
@@ -0,0 +1,14 @@
1
+ // @karajan/ai-trash — public entry (skeleton).
2
+ // Real surface (snapshot, restore, list, manifest) lands in KJC-TSK-0388
3
+ // commits 2-6 and KJC-TSK-0389 (git ops). For now we expose a single
4
+ // `version` export so consumers / tests can confirm the package loads.
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+ import { dirname, join } from "node:path";
9
+
10
+ const here = dirname(fileURLToPath(import.meta.url));
11
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
12
+
13
+ export const version = pkg.version;
14
+ export const name = pkg.name;
@@ -0,0 +1,52 @@
1
+ // Claude Code settings patcher (KJC-TSK-0390 commit 3). Idempotently
2
+ // adds a PreToolUse hook entry that pipes Bash commands through
3
+ // `kj-trash hook`. Preserves any unrelated keys and existing hooks so
4
+ // the user's other settings are untouched.
5
+
6
+ import { promises as fs } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { dirname, join } from "node:path";
9
+
10
+ export const DEFAULT_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
11
+ export const HOOK_COMMAND = "kj-trash hook";
12
+ const MATCHER = "Bash";
13
+
14
+ async function readSettings(path) {
15
+ try {
16
+ return JSON.parse(await fs.readFile(path, "utf8"));
17
+ } catch (err) {
18
+ if (err.code === "ENOENT") return {};
19
+ throw err;
20
+ }
21
+ }
22
+
23
+ function hasHookCommand(matcherEntry, command) {
24
+ return (matcherEntry.hooks ?? []).some((h) => h.type === "command" && h.command === command);
25
+ }
26
+
27
+ export function patchSettings(settings, command = HOOK_COMMAND, matcher = MATCHER) {
28
+ const next = { ...settings };
29
+ next.hooks = { ...(next.hooks ?? {}) };
30
+ const preToolUse = Array.isArray(next.hooks.PreToolUse) ? next.hooks.PreToolUse.slice() : [];
31
+ let entry = preToolUse.find((e) => e.matcher === matcher);
32
+ let mutated = false;
33
+ if (!entry) {
34
+ entry = { matcher, hooks: [{ type: "command", command }] };
35
+ preToolUse.push(entry);
36
+ mutated = true;
37
+ } else if (!hasHookCommand(entry, command)) {
38
+ entry.hooks = [...(entry.hooks ?? []), { type: "command", command }];
39
+ mutated = true;
40
+ }
41
+ next.hooks.PreToolUse = preToolUse;
42
+ return { settings: next, mutated };
43
+ }
44
+
45
+ export async function installClaudeCodeHook({ path = DEFAULT_SETTINGS_PATH, command = HOOK_COMMAND } = {}) {
46
+ const settings = await readSettings(path);
47
+ const { settings: next, mutated } = patchSettings(settings, command);
48
+ if (!mutated) return { path, mutated: false };
49
+ await fs.mkdir(dirname(path), { recursive: true });
50
+ await fs.writeFile(path, JSON.stringify(next, null, 2) + "\n", { encoding: "utf8" });
51
+ return { path, mutated: true };
52
+ }
@@ -0,0 +1,60 @@
1
+ // Append-only JSONL audit log for the trash store. Events are written one
2
+ // JSON object per line so the file can be `tail -f`-d and parsed
3
+ // line-by-line without holding the whole log in memory.
4
+
5
+ import { promises as fs } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { lockdownFile } from "./permissions.js";
9
+
10
+ const HOME = homedir();
11
+
12
+ function redactPath(p) {
13
+ if (typeof p !== "string" || !p) return p;
14
+ if (p === HOME) return "~";
15
+ if (p.startsWith(`${HOME}/`)) return `~/${p.slice(HOME.length + 1)}`;
16
+ return p;
17
+ }
18
+
19
+ function redactPayload(payload) {
20
+ if (!payload || typeof payload !== "object") return payload;
21
+ const out = {};
22
+ for (const [k, v] of Object.entries(payload)) {
23
+ if (typeof v === "string" && (k.includes("Path") || k === "cwd")) {
24
+ out[k] = redactPath(v);
25
+ } else {
26
+ out[k] = v;
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+
32
+ export async function appendLogEntry(rootDir, event, payload = {}) {
33
+ if (!event) throw new Error("ai-trash: log event required");
34
+ const file = join(rootDir, "log.jsonl");
35
+ const line =
36
+ JSON.stringify({
37
+ ts: new Date().toISOString(),
38
+ event,
39
+ ...redactPayload(payload),
40
+ }) + "\n";
41
+ await fs.mkdir(rootDir, { recursive: true });
42
+ await fs.appendFile(file, line, { encoding: "utf8", mode: 0o600 });
43
+ await lockdownFile(file);
44
+ }
45
+
46
+ export async function readLog(rootDir) {
47
+ const file = join(rootDir, "log.jsonl");
48
+ try {
49
+ const raw = await fs.readFile(file, "utf8");
50
+ return raw
51
+ .split("\n")
52
+ .filter(Boolean)
53
+ .map((l) => JSON.parse(l));
54
+ } catch (err) {
55
+ if (err.code === "ENOENT") return [];
56
+ throw err;
57
+ }
58
+ }
59
+
60
+ export { redactPath };
@@ -0,0 +1,91 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { ulid } from "./ulid.js";
4
+
5
+ export const SCHEMA_VERSION = 1;
6
+
7
+ export async function loadManifest(rootDir) {
8
+ const file = join(rootDir, "manifest.json");
9
+ try {
10
+ const raw = await fs.readFile(file, "utf8");
11
+ const parsed = JSON.parse(raw);
12
+ if (parsed?.schemaVersion !== SCHEMA_VERSION) {
13
+ throw new Error(`ai-trash: unsupported manifest schemaVersion ${parsed?.schemaVersion}`);
14
+ }
15
+ if (!Array.isArray(parsed.entries)) parsed.entries = [];
16
+ return parsed;
17
+ } catch (err) {
18
+ if (err.code === "ENOENT") return { schemaVersion: SCHEMA_VERSION, entries: [] };
19
+ throw err;
20
+ }
21
+ }
22
+
23
+ export async function saveManifest(rootDir, manifest) {
24
+ const file = join(rootDir, "manifest.json");
25
+ await fs.mkdir(dirname(file), { recursive: true });
26
+ const tmp = `${file}.${process.pid}.tmp`;
27
+ await fs.writeFile(tmp, JSON.stringify(manifest, null, 2), {
28
+ encoding: "utf8",
29
+ mode: 0o600,
30
+ });
31
+ await fs.rename(tmp, file);
32
+ }
33
+
34
+ export function addEntry(manifest, partial) {
35
+ if (!partial?.sourcePath) throw new Error("ai-trash: sourcePath required");
36
+ if (typeof partial.sizeBytes !== "number" || partial.sizeBytes < 0) {
37
+ throw new Error("ai-trash: sizeBytes must be a non-negative number");
38
+ }
39
+ const now = Date.now();
40
+ const entry = {
41
+ id: ulid(now),
42
+ createdAt: now,
43
+ sourcePath: partial.sourcePath,
44
+ snapshotPath: partial.snapshotPath ?? null,
45
+ sizeBytes: partial.sizeBytes,
46
+ command: partial.command ?? null,
47
+ origin: partial.origin ?? "unknown",
48
+ };
49
+ manifest.entries.push(entry);
50
+ return entry;
51
+ }
52
+
53
+ export function listEntries(manifest) {
54
+ return [...manifest.entries].sort((a, b) => a.id.localeCompare(b.id));
55
+ }
56
+
57
+ export function removeEntry(manifest, id) {
58
+ const idx = manifest.entries.findIndex((e) => e.id === id);
59
+ if (idx < 0) return null;
60
+ return manifest.entries.splice(idx, 1)[0];
61
+ }
62
+
63
+ export function expireBefore(manifest, cutoffMs) {
64
+ const expired = [];
65
+ manifest.entries = manifest.entries.filter((e) => {
66
+ if (e.createdAt < cutoffMs) {
67
+ expired.push(e);
68
+ return false;
69
+ }
70
+ return true;
71
+ });
72
+ return expired;
73
+ }
74
+
75
+ export function enforceLruQuota(manifest, maxBytes) {
76
+ if (typeof maxBytes !== "number" || maxBytes < 0) {
77
+ throw new Error("ai-trash: maxBytes must be a non-negative number");
78
+ }
79
+ const sorted = listEntries(manifest);
80
+ const total = sorted.reduce((sum, e) => sum + e.sizeBytes, 0);
81
+ const evicted = [];
82
+ let running = total;
83
+ for (const entry of sorted) {
84
+ if (running <= maxBytes) break;
85
+ evicted.push(entry);
86
+ running -= entry.sizeBytes;
87
+ }
88
+ const evictedIds = new Set(evicted.map((e) => e.id));
89
+ manifest.entries = manifest.entries.filter((e) => !evictedIds.has(e.id));
90
+ return evicted;
91
+ }
@@ -0,0 +1,26 @@
1
+ import { promises as fs } from "node:fs";
2
+
3
+ const DIR_MODE = 0o700;
4
+ const FILE_MODE = 0o600;
5
+
6
+ export async function ensureSecureDir(path) {
7
+ await fs.mkdir(path, { recursive: true, mode: DIR_MODE });
8
+ await fs.chmod(path, DIR_MODE);
9
+ }
10
+
11
+ export async function lockdownFile(path) {
12
+ await fs.chmod(path, FILE_MODE);
13
+ }
14
+
15
+ export async function assertOwnedByCurrentUser(path) {
16
+ if (process.platform === "win32") return;
17
+ const stats = await fs.stat(path);
18
+ if (typeof process.getuid !== "function") return;
19
+ if (stats.uid !== process.getuid()) {
20
+ throw new Error(
21
+ `ai-trash: ${path} is owned by uid ${stats.uid}, expected ${process.getuid()}`
22
+ );
23
+ }
24
+ }
25
+
26
+ export { DIR_MODE, FILE_MODE };
@@ -0,0 +1,66 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { ulid } from "./ulid.js";
4
+ import { ensureSecureDir, lockdownFile } from "./permissions.js";
5
+ import { appendLogEntry } from "./logger.js";
6
+
7
+ async function statOrNull(path) {
8
+ try {
9
+ return await fs.lstat(path);
10
+ } catch (err) {
11
+ if (err.code === "ENOENT") return null;
12
+ throw err;
13
+ }
14
+ }
15
+
16
+ export async function snapshotFile(rootDir, sourcePath, options = {}) {
17
+ const stat = await statOrNull(sourcePath);
18
+ if (!stat) throw new Error(`ai-trash: source not found: ${sourcePath}`);
19
+ if (stat.isDirectory()) {
20
+ throw new Error("ai-trash: directory snapshots not implemented in MVP");
21
+ }
22
+ const id = ulid();
23
+ const storeDir = join(rootDir, "store", id);
24
+ await ensureSecureDir(storeDir);
25
+ const snapshotPath = join(storeDir, basename(sourcePath));
26
+ await fs.copyFile(sourcePath, snapshotPath);
27
+ await lockdownFile(snapshotPath);
28
+ const entry = {
29
+ id,
30
+ createdAt: Date.now(),
31
+ sourcePath,
32
+ snapshotPath,
33
+ sizeBytes: stat.size,
34
+ command: options.command ?? null,
35
+ origin: options.origin ?? "unknown",
36
+ };
37
+ await appendLogEntry(rootDir, "snapshot.create", {
38
+ id,
39
+ sourcePath,
40
+ snapshotPath,
41
+ sizeBytes: stat.size,
42
+ });
43
+ return entry;
44
+ }
45
+
46
+ export async function restoreSnapshot(rootDir, entry, targetPath) {
47
+ const target = targetPath ?? entry.sourcePath;
48
+ const exists = await statOrNull(target);
49
+ if (exists) throw new Error(`ai-trash: refusing to overwrite ${target}`);
50
+ await fs.mkdir(join(target, ".."), { recursive: true });
51
+ await fs.copyFile(entry.snapshotPath, target);
52
+ await appendLogEntry(rootDir, "snapshot.restore", {
53
+ id: entry.id,
54
+ snapshotPath: entry.snapshotPath,
55
+ targetPath: target,
56
+ });
57
+ return target;
58
+ }
59
+
60
+ export async function purgeSnapshot(rootDir, entry) {
61
+ await fs.rm(join(rootDir, "store", entry.id), { recursive: true, force: true });
62
+ await appendLogEntry(rootDir, "snapshot.purge", {
63
+ id: entry.id,
64
+ sizeBytes: entry.sizeBytes,
65
+ });
66
+ }
@@ -0,0 +1,66 @@
1
+ // Tiny ULID generator (Crockford base32, 26 chars, monotonic within ms).
2
+ // We keep this self-contained to avoid pulling a runtime dep into the
3
+ // safety-net package — fewer transitive deps = fewer supply-chain surfaces
4
+ // that an attacker could pivot through to reach the trash store.
5
+
6
+ import { randomBytes } from "node:crypto";
7
+
8
+ const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
9
+
10
+ let lastMs = 0;
11
+ let lastRandom = new Uint8Array(10);
12
+
13
+ function encodeTime(ms) {
14
+ let out = "";
15
+ for (let i = 9; i >= 0; i--) {
16
+ out = ENCODING[ms % 32] + out;
17
+ ms = Math.floor(ms / 32);
18
+ }
19
+ return out;
20
+ }
21
+
22
+ function encodeRandom(bytes) {
23
+ let out = "";
24
+ for (let i = 0; i < 16; i++) {
25
+ const byteIndex = Math.floor((i * 5) / 8);
26
+ const bitOffset = (i * 5) % 8;
27
+ const hi = bytes[byteIndex] ?? 0;
28
+ const lo = bytes[byteIndex + 1] ?? 0;
29
+ const value = ((hi << 8) | lo) >> (11 - bitOffset);
30
+ out += ENCODING[value & 31];
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function bumpRandom(buf) {
36
+ for (let i = buf.length - 1; i >= 0; i--) {
37
+ if (buf[i] === 0xff) {
38
+ buf[i] = 0;
39
+ continue;
40
+ }
41
+ buf[i] += 1;
42
+ return buf;
43
+ }
44
+ throw new Error("ulid: random overflow within the same millisecond");
45
+ }
46
+
47
+ export function ulid(nowMs = Date.now()) {
48
+ if (nowMs === lastMs) {
49
+ lastRandom = bumpRandom(lastRandom);
50
+ } else {
51
+ lastMs = nowMs;
52
+ lastRandom = new Uint8Array(randomBytes(10));
53
+ }
54
+ return encodeTime(nowMs) + encodeRandom(lastRandom);
55
+ }
56
+
57
+ export function decodeTimeFromUlid(id) {
58
+ const slice = id.slice(0, 10);
59
+ let ms = 0;
60
+ for (const ch of slice) {
61
+ const v = ENCODING.indexOf(ch);
62
+ if (v < 0) throw new Error(`ulid: invalid char ${ch}`);
63
+ ms = ms * 32 + v;
64
+ }
65
+ return ms;
66
+ }
@@ -18,7 +18,7 @@
18
18
  * this from prepublishOnly does not recurse.
19
19
  */
20
20
 
21
- import { execFileSync } from "node:child_process";
21
+ import { execFileSync, spawnSync } from "node:child_process";
22
22
  import fs from "node:fs";
23
23
  import os from "node:os";
24
24
  import path from "node:path";
@@ -101,6 +101,19 @@ try {
101
101
  }
102
102
  console.log("verify-pack: sqlite-vec + @karajan/core resolve ✓");
103
103
 
104
+ // 4. kj-trash (ai-trash safety binary) must ship + link onto PATH and boot
105
+ // without a module-resolution crash. It was silently absent from the tarball
106
+ // until KJC-BUG-0089 — every npm user got "kj-trash not found".
107
+ const trashBin = path.join(tmpDir, "node_modules", ".bin", "kj-trash");
108
+ if (!fs.existsSync(trashBin)) fail(`kj-trash binary missing after install: ${trashBin}`);
109
+ const trash = spawnSync(trashBin, ["--help"], { encoding: "utf8", env: childEnv });
110
+ // The CLI exits 2 on --help (usage); only a real crash (module not found, or
111
+ // an unexpected non-0/2 exit) is a failure.
112
+ if (![0, 2].includes(trash.status) || /ERR_MODULE_NOT_FOUND|Cannot find/.test(trash.stderr || "")) {
113
+ fail("`kj-trash --help` crashed on a clean install", trash.stderr || `exit ${trash.status}`);
114
+ }
115
+ console.log("verify-pack: kj-trash ships + boots ✓");
116
+
104
117
  console.log(`\n✓ verify-pack: karajan-code@${expectedVersion} installs clean and runs.`);
105
118
  } finally {
106
119
  if (tgzPath && fs.existsSync(tgzPath)) fs.rmSync(tgzPath, { force: true });
@@ -23,7 +23,7 @@ function createAiTrashCheck() {
23
23
  ok: false,
24
24
  severity: "warn",
25
25
  detail: "kj-trash not found — destructive ops unprotected",
26
- fix: "Reinstall karajan-code (kj-trash ships with the workspace) and run `kj-trash install --claude-code`",
26
+ fix: "Install karajan-code globally (npm i -g karajan-code) so kj-trash is on PATH, then run `kj-trash install --claude-code`",
27
27
  };
28
28
  },
29
29
  };
@@ -406,6 +406,10 @@ export function registerMeta(program, { pkgVersion }) {
406
406
  .option("--no-config", "Skip lint/format/commit config files (hooks only)")
407
407
  .option("--no-ci", "Skip CI quality workflows")
408
408
  .option("--no-guidelines", "Skip AI-agent guideline files (AGENTS.md/CLAUDE.md)")
409
+ .option("--only <dirs...>", "Harden only these language roots (e.g. frontend backend)")
410
+ .option("--exclude <globs...>", "Skip language roots matching these globs (e.g. wrappers)")
411
+ .option("--report", "Read-only: show what kj would add/improve, change nothing")
412
+ .option("--interactive", "Adopt the kj standard piece by piece (default keeps yours)")
409
413
  .option("--dry-run", "Show what would change without writing")
410
414
  .option("--json", "Emit machine-readable JSON")
411
415
  .action(async (flags) => {
@@ -415,6 +419,10 @@ export function registerMeta(program, { pkgVersion }) {
415
419
  config: flags.config !== false,
416
420
  ci: flags.ci !== false,
417
421
  guidelines: flags.guidelines !== false,
422
+ only: flags.only ?? [],
423
+ exclude: flags.exclude ?? [],
424
+ report: Boolean(flags.report),
425
+ interactive: Boolean(flags.interactive),
418
426
  dryRun: Boolean(flags.dryRun),
419
427
  json: Boolean(flags.json),
420
428
  logger,
package/src/cli.js CHANGED
@@ -41,6 +41,21 @@ program
41
41
  .allowUnknownOption(true)
42
42
  .allowExcessArguments(true);
43
43
 
44
+ // KJC-TSK-0571: the command list is long (30+). Orient a newcomer to the few
45
+ // commands they actually need; the rest are advanced/internal.
46
+ program.addHelpText(
47
+ "after",
48
+ `
49
+ Getting started (the basics — start here):
50
+ kj init Set up Karajan in this project (run once)
51
+ kj run "<task>" Build or fix something end-to-end (coder + review)
52
+ kj plan "<task>" Break a task into user stories first, then run them
53
+ kj doctor Check your environment is ready
54
+ kj harden Add quality git hooks + CI to any repo
55
+
56
+ Everything else is advanced or internal — you rarely call it directly.`
57
+ );
58
+
44
59
  registerPipeline(program, { pkgVersion: PKG_VERSION });
45
60
  registerPlan(program, { pkgVersion: PKG_VERSION });
46
61
  registerRolesSkills(program, { pkgVersion: PKG_VERSION });
@@ -116,7 +131,6 @@ program.action(async (_opts, command) => {
116
131
  try {
117
132
  await migrateKjToKarajan();
118
133
  } catch (err) {
119
- // eslint-disable-next-line no-console
120
134
  console.warn(`\x1b[33m[warn]\x1b[0m home migration skipped: ${err.message}`);
121
135
  }
122
136
 
@@ -9,6 +9,9 @@
9
9
  import { existsSync, readFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
 
12
+ import { compareHarden, formatAdvisoryReport } from "../harden/advisory.js";
13
+ import { interactiveHarden } from "../harden/interactive.js";
14
+ import { createWizard, isTTY } from "../utils/wizard.js";
12
15
  import { installConfigsForRoots } from "../harden/config-engine.js";
13
16
  import { installGuidelines } from "../harden/guidelines-engine.js";
14
17
  import { commandsForLanguage } from "../harden/hook-commands.js";
@@ -23,6 +26,21 @@ const JS_TEST_CMD = {
23
26
  mocha: "npx mocha",
24
27
  };
25
28
 
29
+ /**
30
+ * Per-repo harden scope, persisted in package.json `kj.harden` so a repo with
31
+ * sub-tools (e.g. a python wrapper) excludes them once instead of on every
32
+ * invocation (KJC-TSK-0564). CLI flags are merged on top.
33
+ */
34
+ function readHardenConfig(projectDir) {
35
+ const pkgPath = join(projectDir, "package.json");
36
+ if (!existsSync(pkgPath)) return {};
37
+ try {
38
+ return JSON.parse(readFileSync(pkgPath, "utf8")).kj?.harden ?? {};
39
+ } catch {
40
+ return {};
41
+ }
42
+ }
43
+
26
44
  /**
27
45
  * Resolve the hook's lint/format/test commands for the primary language.
28
46
  * Non-JS stacks use their native toolchain (go/ruff/…); JS/TS is built from
@@ -60,9 +78,46 @@ export async function hardenCommand({
60
78
  guidelines = true,
61
79
  dryRun = false,
62
80
  json = false,
81
+ report = false,
82
+ interactive = false,
83
+ only = [],
84
+ exclude = [],
63
85
  logger = console,
64
86
  } = {}) {
65
- const roots = detectStackRoots(projectDir);
87
+ const repoCfg = readHardenConfig(projectDir);
88
+ const onlyDirs = [...(repoCfg.only ?? []), ...only];
89
+ const excludeDirs = [...(repoCfg.exclude ?? []), ...exclude];
90
+
91
+ // --report is read-only: compare against the kj standard and print/emit the
92
+ // advisory (Advisory B, KJC-TSK-0566), honouring the same scope as an install
93
+ // so it never proposes a root that `kj harden` would skip. Writes nothing.
94
+ if (report) {
95
+ const result = compareHarden({ projectDir, profile, only: onlyDirs, exclude: excludeDirs });
96
+ if (json) logger.info?.(JSON.stringify(result));
97
+ else for (const line of formatAdvisoryReport(result)) logger.info?.(line);
98
+ return { ok: true, report: true, ...result };
99
+ }
100
+
101
+ // --interactive: adopt the kj standard piece by piece, default-safe (keep the
102
+ // user's own). Only meaningful with a TTY — without one (CI, --json) we fall
103
+ // through to the normal seed-if-absent install so nothing breaks (KJC-TSK-0567).
104
+ if (interactive && isTTY() && !json) {
105
+ const wizard = createWizard();
106
+ try {
107
+ return await interactiveHarden({
108
+ projectDir,
109
+ profile,
110
+ only: onlyDirs,
111
+ exclude: excludeDirs,
112
+ ask: (prompt, def) => wizard.confirm(prompt, def),
113
+ logger,
114
+ });
115
+ } finally {
116
+ wizard.close();
117
+ }
118
+ }
119
+
120
+ const roots = detectStackRoots(projectDir, { only: onlyDirs, exclude: excludeDirs });
66
121
  const cmds = await resolveCmds(projectDir, roots[0]?.language ?? null);
67
122
  let result;
68
123
  try {
@@ -102,6 +157,6 @@ export async function hardenCommand({
102
157
  for (const c of out.configs) logger.info?.(` • ${c.file}: ${c.action}`);
103
158
  for (const w of out.workflows) logger.info?.(` • ${w.file}: ${w.action}`);
104
159
  for (const g of out.guidelines) logger.info?.(` • ${g.file}: ${g.action}`);
105
- if (!dryRun) logger.info?.("core.hooksPath set. Verify later with `kj check` (H-E).");
160
+ if (!dryRun) logger.info?.("core.hooksPath set. Verify later with `kj check`.");
106
161
  return out;
107
162
  }