karajan-code 2.19.4 → 2.20.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 CHANGED
@@ -345,6 +345,18 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
345
345
 
346
346
  ## Recent releases
347
347
 
348
+ > **v2.20.0 released** — Minor. **HU Board polish + UX papercuts** cluster: 5 cards closed across two net-new features, two PG housekeeping syncs for work that had already landed, and one docs refresh.
349
+ >
350
+ > KJC-TSK-0397 (PR #801): **`kj plan generate` now prepends a `[PREFLIGHT-000]` HU to every plan** and gates every functional HU on it. The HU's acceptance tests are stack-aware shell commands — Node gets `node --version` + `npm install` + conditional `npm test` / `npm run lint`; Python gets `python --version` + `pip install -r requirements.txt` (or `poetry install`) + `pytest --collect-only`; Firebase projects get `firebase projects:list`; GCP projects get `gcloud auth list`; everyone gets `git status --porcelain`. Idempotent: a plan that already has a HU titled `PREFLIGHT-000` / "verificar entorno" is left untouched. Opt out per-invocation with `--no-preflight-hu`. The point: Karajan owns the plumbing, the user no longer has to remember to add a 'verify env' step to every task file.
351
+ >
352
+ > KJC-TSK-0395 (PR #802): **`kj init` learns a config scope wizard plus `--global` / `--local` flags**. The wizard now asks where the config should land — `~/.karajan/kj.config.yml` (global) or `./.karajan/kj.config.yml` (local override). `loadConfig` refuses to load a project that has a local config without a global counterpart with an actionable message — the override-on-top-of-base invariant. Use case: a repo with `coder=claude`, another with `coder=opencode`, without editing YAML by hand.
353
+ >
354
+ > KJC-TSK-0396 (PG sync, originally PRs #702 + #703): **HU Board `⏹ Stop` button** aborts every `kj run` associated with a plan via SIGTERM → SIGKILL escalation, resets running HUs to pending. Already shipped in v2.10.x; today's release closes the PG card with the canonical commits as evidence.
355
+ >
356
+ > KJC-TSK-0377 (PG sync, originally PR #683): **auto-cleanup ampliado** to also catch `s_*`, `plan-*`, `auto-tmp_*`, `auto-test_*` prefixes during the boot ephemeral-project sweep. Already shipped in v2.12.x.
357
+ >
358
+ > KJC-TSK-0385 (PR #800): **`docs/task-templates/spec-conventions.md` gains Section 8 (`spec_section` REQUIRED when numbered SPEC headings present) and Section 9 (`acceptance_tests` shape, 2-4 tests, gherkin + shell mix)**. Plus `plan-generate.md` switches two stale `~/.kj/plans/` paths to `~/.karajan/plans/`.
359
+ >
348
360
  > **v2.19.4 released** — Patch. **`kj resume` now continues from where it stopped** + **autoInit no longer commits zombies on the user's main**. Two bugs closed in one release.
349
361
  >
350
362
  > KJC-BUG-0058 (PR #798, reported by [@aitormf](https://github.com/aitormf)): a session that stopped during Sonar would re-run the full pre-loop on `kj resume <id>` — HU-reviewer, intent, discover, triage, domainCurator, **researcher, architect, planner** all from scratch — doubling token cost and breaking the value-prop of the command. Root cause: `resumeFlow` (flow-runner.js:280) called `runFlow` without rehydrating stage state, and the session never persisted stage outputs in the first place. **Fix**: two new mutators in `src/session/mutators.js` (`setStageResult` mirrors into `stage_results[name]` + `stages_completed[]`; `setStageBundle` adds `stage_bundles[name]` for cross-stage context like `researchContext`, `architectContext`, `plannedTask`). Two closures inside `runPreLoopStages` (`persistStage` + `resumeSkip`) wrap every cacheable stage. `init-context.js` rehydrates `ctx.stageResults` from the loaded session before invoking the pre-loop. Triage is *not* skipped on resume — it produces `roleOverrides` the Brain decisor depends on and is cheap to re-run.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.19.4",
3
+ "version": "2.20.0",
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",
@@ -23,6 +23,8 @@ export function registerPipeline(program, { pkgVersion }) {
23
23
  .description("Initialize config, review rules and SonarQube")
24
24
  .option("--no-interactive", "Skip wizard, use defaults (for CI/scripts)")
25
25
  .option("--scaffold-ci", "Scaffold Karajan CI Gateway workflow files")
26
+ .option("--global", "Save config to ~/.karajan/kj.config.yml (skip the scope wizard)")
27
+ .option("--local", "Save config to ./.karajan/kj.config.yml (skip the scope wizard)")
26
28
  .action(async (flags) => {
27
29
  await withConfig(pkgVersion, "init", flags, async ({ config: _config, logger }) => {
28
30
  await initCommand({ logger, flags });
@@ -42,6 +42,7 @@ export function registerPlan(program, { pkgVersion }) {
42
42
  .option("--no-plan-review", "Skip the high-level plan reviewer pass (gaps / deps / overlap / order)")
43
43
  .option("--skip-spec-review", "Bypass the spec-reviewer pre-pipeline audit")
44
44
  .option("--quick", "Sketch mode — skip every quality pass after the initial planner call")
45
+ .option("--no-preflight-hu", "Skip auto-injection of [PREFLIGHT-000] HU at the top of the generated plan")
45
46
  .option("-y, --yes", "Skip the project-name prompt (use the auto-derived default).")
46
47
  .option("--no-interactive", "Force non-interactive mode (no prompts, use defaults).")
47
48
  .action(async (task, flags) => {
@@ -100,6 +101,7 @@ export function registerPlan(program, { pkgVersion }) {
100
101
  quick: Boolean(flags.quick),
101
102
  yes: Boolean(flags.yes),
102
103
  interactive: flags.interactive,
104
+ preflightHu: flags.preflightHu !== false, // KJC-TSK-0397
103
105
  },
104
106
  });
105
107
  });
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { getConfigPath, loadConfig, writeConfig } from "../config.js";
4
+ import { getConfigPath, getProjectConfigPath, loadConfig, writeConfig } from "../config.js";
5
5
  import { sonarUp, checkVmMaxMapCount } from "../sonar/manager.js";
6
6
  import { exists, ensureDir } from "../utils/fs.js";
7
7
  import { getKarajanHome } from "../utils/paths.js";
@@ -579,19 +579,50 @@ async function ensureGitignoreEntries(projectDir, logger) {
579
579
  logger.info(`Added to .gitignore: ${missing.map(e => e.pattern).join(", ")}`);
580
580
  }
581
581
 
582
+ /**
583
+ * KJC-TSK-0395: decide where the config is written based on the flags
584
+ * and (when interactive) a wizard prompt. Returns { configPath, scope }.
585
+ * Throws when both --global and --local are passed (mutually exclusive).
586
+ */
587
+ export async function resolveConfigScope({ flags, interactive }) {
588
+ if (flags?.global && flags?.local) {
589
+ throw new Error("Cannot pass both --global and --local — pick one scope for the config.");
590
+ }
591
+ if (flags?.global) return { configPath: getConfigPath(), scope: "global" };
592
+ if (flags?.local) return { configPath: getProjectConfigPath(process.cwd()), scope: "local" };
593
+ if (interactive) {
594
+ const wizard = createWizard();
595
+ try {
596
+ const choice = await wizard.select(
597
+ "Where do you want to save the config? (default: global)",
598
+ [
599
+ { value: "global", label: `Global (${getConfigPath()} — applies to all projects)` },
600
+ { value: "local", label: `Local (${getProjectConfigPath(process.cwd())} — overrides global for this project only)` },
601
+ ]
602
+ );
603
+ return { configPath: choice === "local" ? getProjectConfigPath(process.cwd()) : getConfigPath(), scope: choice };
604
+ } finally {
605
+ wizard.close();
606
+ }
607
+ }
608
+ // Non-interactive + no flags → legacy behaviour: global. Keeps CI scripts working.
609
+ return { configPath: getConfigPath(), scope: "global" };
610
+ }
611
+
582
612
  export async function initCommand({ logger, flags = {} }) {
583
613
  const karajanHome = getKarajanHome();
584
614
  await ensureDir(karajanHome);
585
615
  logger.info(`Ensured ${karajanHome} exists`);
586
616
 
587
- const configPath = getConfigPath();
617
+ const interactive = flags.noInteractive !== true && isTTY();
618
+ const { configPath, scope } = await resolveConfigScope({ flags, interactive });
619
+ logger.info(`Config scope: ${scope} → ${configPath}`);
588
620
  const karajanDir = path.join(process.cwd(), ".karajan");
589
621
  await ensureDir(karajanDir);
590
622
  const reviewRulesPath = path.resolve(karajanDir, "review-rules.md");
591
623
  const coderRulesPath = path.resolve(karajanDir, "coder-rules.md");
592
624
 
593
625
  const { config, exists: configExists } = await loadConfig();
594
- const interactive = flags.noInteractive !== true && isTTY();
595
626
 
596
627
  // Auto-detect language from OS locale for non-interactive mode
597
628
  if (!interactive && !configExists) {
@@ -14,6 +14,7 @@ import { withBrainRecovery } from "../../brain/with-brain-recovery.js";
14
14
  import { buildStandbyState } from "../../brain/standby-store.js";
15
15
  import { printResumeHint } from "../../utils/display/resume-hint.js";
16
16
  import { runSpecReview } from "../../spec-review/run-spec-review.js";
17
+ import { prependPreflightHu } from "../../plan/preflight-hu.js";
17
18
  import { formatPlan, formatHuTable } from "./_shared.js";
18
19
 
19
20
  /**
@@ -414,6 +415,15 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
414
415
  }
415
416
  }
416
417
 
418
+ // KJC-TSK-0397: inject a [PREFLIGHT-000] HU at the top of the plan so
419
+ // every functional HU gates on a clean environment (deps + build + tests
420
+ // + lint + git clean, plus cloud auth when applicable). Idempotent: a
421
+ // plan that already has a preflight HU is left untouched. Disable with
422
+ // `--no-preflight-hu`.
423
+ if (flags.preflightHu !== false) {
424
+ await prependPreflightHu(plan, projectDir);
425
+ }
426
+
417
427
  const planId = await savePlan(projectDir, plan);
418
428
  const { plansDir } = await import("../../plan/plan-store.js");
419
429
  const path = await import("node:path");
@@ -114,6 +114,18 @@ export async function loadConfig(projectDir) {
114
114
  // Load project config (.karajan/kj.config.yml)
115
115
  const projectConfig = await loadProjectConfig(projectDir);
116
116
 
117
+ // KJC-TSK-0395: enforce the local-overrides-global hierarchy. A project
118
+ // config with no global counterpart is almost always user error (copied
119
+ // a .karajan dir from another repo, edited the wrong file, etc.) and
120
+ // would silently behave like a global config without the defaults
121
+ // people expect. Refuse with an actionable message.
122
+ if (projectConfig && !globalExists) {
123
+ throw new Error(
124
+ `Local config found at ${getProjectConfigPath(projectDir)} but no global config exists.\n` +
125
+ "Local configs are merged ON TOP of the global one — run `kj init --global` first to create the base."
126
+ );
127
+ }
128
+
117
129
  // Merge: DEFAULTS < global < project
118
130
  let merged = mergeDeep(DEFAULTS, globalConfig);
119
131
  if (projectConfig) {
@@ -0,0 +1,102 @@
1
+ /**
2
+ * KJC-TSK-0397: auto-inject a [PREFLIGHT-000] HU at the top of every
3
+ * plan generated by `kj plan generate` so the sub-pipeline gates on a
4
+ * clean environment before burning tokens on functional HUs.
5
+ *
6
+ * Design points:
7
+ * - Pure shell tests — no Karajan-specific runtime, just commands
8
+ * that exit 0 when the environment is ready.
9
+ * - Stack-aware: Node, Python and Firebase signals come straight
10
+ * from detectProjectStack() + on-disk file probes. Adding a new
11
+ * stack is one entry in `composePreflightTests`.
12
+ * - Idempotent: re-running over a plan that already has a HU with
13
+ * id `PREFLIGHT-000` or a title containing 'preflight' / 'verificar
14
+ * entorno' returns the plan unchanged. Required because the same
15
+ * planner output may flow through structural-pass + plan-fixer
16
+ * before reaching savePlan.
17
+ * - All other HUs' `blocked_by` get the PREFLIGHT-000 id appended
18
+ * (unless they already have it, again idempotent). Their own
19
+ * dependencies are preserved.
20
+ */
21
+ import { existsSync } from "node:fs";
22
+ import { join } from "node:path";
23
+
24
+ import { detectProjectStack } from "../utils/stack-detect.js";
25
+
26
+ const PREFLIGHT_ID = "PREFLIGHT-000";
27
+
28
+ /**
29
+ * Compose stack-aware shell acceptance tests. Always includes a
30
+ * `git status --porcelain` clean-tree check; everything else is
31
+ * additive based on what the project actually uses.
32
+ */
33
+ export function composePreflightTests(stack, projectDir = ".") {
34
+ const tests = [
35
+ { type: "shell", content: "git status --porcelain | (! grep -q .)" },
36
+ ];
37
+ const lang = stack?.language;
38
+ if (lang === "javascript" || lang === "typescript") {
39
+ tests.push({ type: "shell", content: "node --version | grep -qE '^v(2[0-9]|[3-9][0-9])'" });
40
+ if (existsSync(join(projectDir, "package.json"))) {
41
+ tests.push({ type: "shell", content: "npm install --no-audit --no-fund" });
42
+ tests.push({ type: "shell", content: "test -z \"$(npm run | grep -E '^ test$')\" || npm test" });
43
+ tests.push({ type: "shell", content: "test -z \"$(npm run | grep -E '^ lint$')\" || npm run lint" });
44
+ }
45
+ } else if (lang === "python") {
46
+ tests.push({ type: "shell", content: "python --version | grep -qE 'Python 3\\.(1[0-9]|[2-9][0-9])'" });
47
+ if (existsSync(join(projectDir, "requirements.txt"))) {
48
+ tests.push({ type: "shell", content: "pip install -r requirements.txt" });
49
+ } else if (existsSync(join(projectDir, "pyproject.toml"))) {
50
+ tests.push({ type: "shell", content: "test -f poetry.lock && poetry install || pip install -e ." });
51
+ }
52
+ tests.push({ type: "shell", content: "command -v pytest && pytest --collect-only || true" });
53
+ }
54
+ if (existsSync(join(projectDir, "firebase.json"))) {
55
+ tests.push({ type: "shell", content: "firebase projects:list" });
56
+ }
57
+ if (existsSync(join(projectDir, ".gcloudignore"))) {
58
+ tests.push({ type: "shell", content: "gcloud auth list --filter=status:ACTIVE --format='value(account)' | (! test -z \"$(cat)\")" });
59
+ }
60
+ return tests;
61
+ }
62
+
63
+ /**
64
+ * Return true when the plan already has a preflight-style HU (by id or
65
+ * title). Pattern-matches conservatively to avoid duplicate injection.
66
+ */
67
+ export function hasPreflightHu(plan) {
68
+ if (!plan?.hus?.length) return false;
69
+ return plan.hus.some((hu) => {
70
+ if (hu.id === PREFLIGHT_ID) return true;
71
+ const title = (hu.title || hu.description || "").toLowerCase();
72
+ return title.includes("preflight-000") || title.includes("verificar entorno") || title.includes("preflight check");
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Prepend a [PREFLIGHT-000] HU to the plan and append its id to the
78
+ * `blocked_by` of every other HU. Returns the mutated plan reference
79
+ * for convenience; mutation is in place so callers don't need to
80
+ * reassign. No-op when the plan already has a preflight HU.
81
+ */
82
+ export async function prependPreflightHu(plan, projectDir) {
83
+ if (!plan?.hus) return plan;
84
+ if (hasPreflightHu(plan)) return plan;
85
+ const stack = await detectProjectStack(projectDir);
86
+ const preflight = {
87
+ id: PREFLIGHT_ID,
88
+ title: "[PREFLIGHT-000] Verificar entorno de desarrollo listo",
89
+ description: "[PREFLIGHT-000] Block functional HUs until the project's environment is reproducible: deps installed, build/test/lint passing, git tree clean, cloud auth ready when applicable.",
90
+ task_type: "infra",
91
+ blocked_by: [],
92
+ reuse: [],
93
+ acceptance_tests: composePreflightTests(stack, projectDir),
94
+ spec_section: null,
95
+ };
96
+ for (const hu of plan.hus) {
97
+ if (!Array.isArray(hu.blocked_by)) hu.blocked_by = [];
98
+ if (!hu.blocked_by.includes(PREFLIGHT_ID)) hu.blocked_by.push(PREFLIGHT_ID);
99
+ }
100
+ plan.hus.unshift(preflight);
101
+ return plan;
102
+ }