karajan-code 4.0.0 → 4.0.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.0.0",
3
+ "version": "4.0.1",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -9,6 +9,7 @@
9
9
  import { existsSync, readFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
 
12
+ import { loadConfig } from "../config.js";
12
13
  import { compareHarden, formatAdvisoryReport } from "../harden/advisory.js";
13
14
  import { interactiveHarden } from "../harden/interactive.js";
14
15
  import { createWizard, isTTY } from "../utils/wizard.js";
@@ -120,9 +121,16 @@ export async function hardenCommand({
120
121
 
121
122
  const roots = detectStackRoots(projectDir, { only: onlyDirs, exclude: excludeDirs });
122
123
  const cmds = await resolveCmds(projectDir, roots[0]?.language ?? null);
124
+ // KJC-TSK-0648: branch-first guard — the base branch only moves via PR.
125
+ // Resolved from the project's kj config (default "main"); best-effort so
126
+ // harden keeps working on repos that never ran kj init.
127
+ let baseBranch = "main";
128
+ try {
129
+ baseBranch = (await loadConfig(projectDir))?.base_branch || "main";
130
+ } catch { /* no kj config — default stands */ }
123
131
  let result;
124
132
  try {
125
- result = await installHooks({ projectDir, profile, cmds, dryRun });
133
+ result = await installHooks({ projectDir, profile, cmds, dryRun, baseBranch });
126
134
  } catch (err) {
127
135
  if (json) logger.info?.(JSON.stringify({ ok: false, error: err.message }));
128
136
  else logger.error?.(`kj harden: ${err.message}`);
@@ -41,7 +41,9 @@ You are the orchestrator; Karajan governs the method. Follow this on every task:
41
41
  5. **Security checklist** (you absorb the security role): validate all inputs,
42
42
  never commit secrets or keys, no deprecated APIs, parameterized queries,
43
43
  sanitize anything user-controlled before it reaches HTML/shell/SQL.
44
- 6. **Ship small**: Conventional Commits, atomic PRs (~150 net lines), each PR
44
+ 6. **Branch first**: never commit on the base branch — create a branch per
45
+ task/bug and every change reaches the base only through a PR.
46
+ 7. **Ship small**: Conventional Commits, atomic PRs (~150 net lines), each PR
45
47
  compiles and passes tests on its own.
46
48
 
47
49
  Useful commands: \`kj rag query\` · \`kj review --staged\` · \`kj review --check\` ·
@@ -33,11 +33,25 @@ export async function installHooks({
33
33
  profile = "standard",
34
34
  cmds = {},
35
35
  dryRun = false,
36
+ baseBranch = null,
36
37
  } = {}) {
37
38
  if (!isGitRepo(projectDir)) throw new Error(`Not a git repository: ${projectDir}`);
38
39
  const hooks = PROFILE_HOOKS[profile];
39
40
  if (!hooks) throw new Error(`Unknown harden profile: ${profile}`);
40
41
 
42
+ // KJC-TSK-0645: setting a repo-local hooksPath ECLIPSES the machine's
43
+ // global hooks dir — personal guards (AI-attribution commit-msg, protected
44
+ // branch pre-push) would silently stop applying here. Detect the previous
45
+ // global dir and have every generated hook chain to it. `~` is emitted as
46
+ // `$HOME` so the committed hook stays portable across machines (the -x
47
+ // guard silences it where the dir doesn't exist).
48
+ let globalHooksDir = null;
49
+ const globalCfg = await runCommand("git", ["config", "--global", "core.hooksPath"], { cwd: projectDir });
50
+ const rawGlobal = globalCfg.exitCode === 0 ? globalCfg.stdout.trim() : "";
51
+ if (rawGlobal && rawGlobal !== HOOKS_DIR) {
52
+ globalHooksDir = rawGlobal.startsWith("~") ? `$HOME${rawGlobal.slice(1)}` : rawGlobal;
53
+ }
54
+
41
55
  const absHooksDir = join(projectDir, HOOKS_DIR);
42
56
  const results = [];
43
57
  for (const hook of hooks) {
@@ -47,7 +61,7 @@ export async function installHooks({
47
61
  source,
48
62
  blockId: `hook:${hook}`,
49
63
  version: BLOCK_VERSION,
50
- body: hookBody(hook, cmds),
64
+ body: hookBody(hook, cmds, { globalHooksDir, baseBranch }),
51
65
  style: "hash",
52
66
  });
53
67
  results.push({ hook, target, action });
@@ -21,10 +21,42 @@ export const PROFILE_HOOKS = {
21
21
  };
22
22
 
23
23
  /** Build the managed body for a single hook. */
24
- export function hookBody(hook, cmds = {}) {
24
+ // KJC-TSK-0645: `core.hooksPath .karajan/hooks` eclipses the user's global
25
+ // hooks dir, silently disabling personal guards. When a previous global dir
26
+ // is known, every generated hook ends by chaining its namesake there —
27
+ // guarded, so machines without it are unaffected.
28
+ function chainToGlobal(hook, globalHooksDir) {
29
+ if (!globalHooksDir) return [];
30
+ return [
31
+ "# Chain the machine's previous global hook (kj harden keeps it active).",
32
+ `if [ -x "${globalHooksDir}/${hook}" ]; then`,
33
+ ` "${globalHooksDir}/${hook}" "$@"; rc=$?`,
34
+ ' [ "$rc" -eq 0 ] || exit "$rc"',
35
+ "fi",
36
+ ];
37
+ }
38
+
39
+ // KJC-TSK-0648: branch-first, enforced. The first external session of the
40
+ // v4 environment committed on local main following literal instructions —
41
+ // the playbook now orders "branch first" and this guard backs it up.
42
+ function baseBranchGuard(baseBranch) {
43
+ if (!baseBranch) return [];
44
+ return [
45
+ "# Branch-first guard — the base branch only moves via PR.",
46
+ 'if [ "$KJ_ALLOW_BASE_COMMIT" != "1" ]; then',
47
+ ' current_branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")',
48
+ ` if [ "$current_branch" = "${baseBranch}" ]; then`,
49
+ ` echo 'kj harden: direct commits on ${baseBranch} are not allowed — create a branch and open a PR (KJ_ALLOW_BASE_COMMIT=1 to override)'; exit 1`,
50
+ " fi",
51
+ "fi",
52
+ ];
53
+ }
54
+
55
+ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch = null } = {}) {
56
+ const chain = chainToGlobal(hook, globalHooksDir);
25
57
  switch (hook) {
26
58
  case "pre-commit": {
27
- const lines = ["# Lint + format the working tree with the project's native tools."];
59
+ const lines = [...baseBranchGuard(baseBranch), "# Lint + format the working tree with the project's native tools."];
28
60
  if (cmds.lint) lines.push(`${cmds.lint} || { echo 'kj harden: lint failed'; exit 1; }`);
29
61
  if (cmds.format) lines.push(`${cmds.format} || { echo 'kj harden: format check failed'; exit 1; }`);
30
62
  if (!cmds.lint && !cmds.format) lines.push("# (no lint/format command detected for this stack)");
@@ -38,7 +70,7 @@ export function hookBody(hook, cmds = {}) {
38
70
  " kj review --check || { echo 'kj: no approved cross-AI verdict for the staged diff — run `kj review --staged`'; exit 1; }",
39
71
  "fi"
40
72
  );
41
- return lines.join("\n");
73
+ return [...lines, ...chain].join("\n");
42
74
  }
43
75
  case "commit-msg":
44
76
  // Pure POSIX — Conventional Commits header, length cap, AI-attribution.
@@ -52,6 +84,7 @@ export function hookBody(hook, cmds = {}) {
52
84
  `if grep -qiE '${AI_ATTRIBUTION}' "$msg_file"; then`,
53
85
  " echo 'kj harden: AI attribution is not allowed in commit messages'; exit 1",
54
86
  "fi",
87
+ ...chain,
55
88
  ].join("\n");
56
89
  case "pre-push": {
57
90
  const lines = [
@@ -64,7 +97,7 @@ export function hookBody(hook, cmds = {}) {
64
97
  ];
65
98
  if (cmds.test) lines.push(`${cmds.test} || { echo 'kj harden: tests failed'; exit 1; }`);
66
99
  else lines.push("# (no test command detected for this stack)");
67
- return lines.join("\n");
100
+ return [...lines, ...chain].join("\n");
68
101
  }
69
102
  case "post-merge":
70
103
  return [
@@ -72,6 +105,7 @@ export function hookBody(hook, cmds = {}) {
72
105
  "if command -v kj >/dev/null 2>&1; then",
73
106
  " kj rag index --since auto >/dev/null 2>&1 || true",
74
107
  "fi",
108
+ ...chain,
75
109
  ].join("\n");
76
110
  default:
77
111
  throw new Error(`Unknown hook: ${hook}`);