karajan-code 2.19.3 → 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,26 @@ 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
+ >
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.
361
+ >
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.
363
+ >
364
+ > KJC-BUG-0060 (PR #797, reported by mjfosela during the v2.19.3 release): `git checkout main` reported `[ahead 27]` of origin/main. Every commit was titled `initial commit`, authored by `manufosela@gmail.com`, and had the **exact same tree as its parent** — completely empty. The reflog held **2 495 such SHAs** accumulated since April 2026. None ever reached origin/main (gh push / CI would have rejected them) so runtime impact was zero, but the local history was noisy and on every release it looked like a sync loss. Root cause: `src/orchestrator/config-init.js::autoInit()` guarded with `!(await exists(projectDir/.git))`, which fails two ways: (a) dogfooding kj on karajan-code itself from a subdir → `exists()` returns false → `git init` re-initializes the *parent's* `.git/` (idempotent, harmless) → `git commit --allow-empty` then resolves upward and lands an empty commit on the parent's main; (b) transient FS hicks (EACCES/ENOENT) flip `exists()` to a false negative. **Fix**: switch the static FS probe for `git rev-parse --is-inside-work-tree`, which performs the same upward search git would use for the commit itself — guard cannot disagree with the operation it guards. And drop the `git commit --allow-empty -m "initial commit"` step entirely: no downstream stage needs a root commit; the 2 495 zombies never broke anything, the seed was decorative and turned out to be the actual user-visible symptom.
365
+ >
366
+ > **If your `kj resume` re-runs researcher/architect, or your `git status` shows mysterious `[ahead N]` after dogfooding kj on a kj-linked source tree, upgrade to v2.19.4.**
367
+ >
348
368
  > **v2.19.3 released** — Patch. **HU Board now reads + writes plans from the canonical home dir** (KJC-BUG-0059). Reported by [@aitormf](https://github.com/aitormf): the top card showed `Directorio del proyecto — no detectado` even when the run had a valid `projectDir` and the coder was reading files from it. Root cause: five board call sites still hard-coded `~/.kj/plans/` as their plans root — leftover from the v2.19.0 home consolidation, which fixed `sync.js` but missed the rest. After the auto-migrator ran (or the user created new plans post-v2.19.0), plans landed under `~/.karajan/plans/<slug>/`; the board kept looking under `~/.kj/plans/<slug>/` and silently found nothing — so `GET /api/projects/:id/preflight` could not extract `projectDir` (the literal Aitor saw), `GET /api/projects/:id/plans-outcome` returned `plans: []` for every project, `DELETE /api/projects/:id` swept the wrong path leaving residue on disk, `DELETE /api/plans/:planId` failed silently, `plan-mutations.plansRoot` wrote new per-HU run logs to the legacy root splitting state across both, and `cleanup-zombies` never GC'd zombies under `~/.karajan/plans/`. PR #795 ships three new exports in `packages/hu-board/src/db.js`: `getHuBoardPlansDir()` (canonical, or `KJ_PLANS_DIR` override), `getHuBoardLegacyPlansDir()` (legacy, null when override set), `getHuBoardPlansDirs()` ordered `[canonical, legacy?]` for read callers. Single-write callers (`plan-mutations`) use the canonical root; read / delete / GC iterate both so users mid-migration with plans still under `~/.kj/` don't regress. 29 hu-board test files / 349 tests still green. **If your board shows "Directorio del proyecto — no detectado" on a project that clearly works in CLI, upgrade to v2.19.3.**
349
369
  >
350
370
  > **v2.19.2 released** — Patch. **SonarQube 401 now triggers automatic token re-bootstrap** instead of failing the run (KJC-BUG-0057). Until v2.19.1, when the Sonar token was missing / stale / revoked / pointed at a recreated Sonar instance, `kj run` / `kj audit` threw `SonarQube authentication failed (HTTP 401)` with the hint "Regenerate with `kj init`" — putting the user in the loop for plumbing Karajan can do itself. PR #793 wires `src/sonar/api.js::sonarFetchOnce` to invoke a new `src/sonar/token-recovery.js::recoverSonarToken()` on the first 401, which reuses `bootstrapSonarToken()` (already shipped in v2.10.2) — probes admin/admin, rotates the default password if still in place, revokes the existing `karajan-cli` token, generates a fresh GLOBAL_ANALYSIS_TOKEN, mutates `config.sonarqube.token` in place AND mirrors it to `~/.karajan/sonar-credentials.json` so future processes pick it up via the normal resolver chain instead of triggering recovery again. The original request retries once with the new token; the user never sees the 401 when recovery succeeds. Per-process latch ensures N endpoints 401-ing trigger ONE bootstrap, not N. Programmatic, **zero LLM involvement** — exactly the kind of plumbing Karajan should never delegate to the agent. Reported by [@aitormf](https://github.com/aitormf).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.19.3",
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) {
@@ -26,17 +26,27 @@ import { exists, ensureDir } from "../utils/fs.js";
26
26
  * Called by the orchestrator before the pipeline starts.
27
27
  */
28
28
  export async function autoInit(projectDir, logger) {
29
- // Ensure git repo exists — without git, diff/reviewer/commit won't work
30
- const gitDir = path.join(projectDir, ".git");
31
- if (!(await exists(gitDir))) {
32
- // Audit follow-up: was execSync with constant strings. Inputs are
33
- // hardcoded, so injection isn't possible, but the project standard
34
- // since #555 is execFileSync everywhere. Migrated for consistency.
35
- const { execFileSync } = await import("node:child_process");
29
+ // Ensure git repo exists — without git, diff/reviewer/commit won't work.
30
+ //
31
+ // KJC-BUG-0060: the previous guard `!exists(projectDir/.git)` was insufficient
32
+ // when projectDir is a subdirectory inside an existing repo (common in dogfooding
33
+ // scenarios where kj-linked runs from the karajan-code source tree). In that case
34
+ // `git commit --allow-empty` would resolve upward to the parent repo and land an
35
+ // empty "initial commit" on the user's main branch. Use `git rev-parse
36
+ // --is-inside-work-tree` instead — it follows the same upward traversal that git
37
+ // does naturally, so a subdir inside a repo correctly reports `true` and we don't
38
+ // touch anything. We also drop the `git commit --allow-empty -m "initial commit"`
39
+ // step: a root commit is not required for any downstream stage (diff, review,
40
+ // coder, sonar) and the empty-commit zombie history was the user-visible symptom.
41
+ const { execFileSync } = await import("node:child_process");
42
+ try {
43
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: projectDir, stdio: "pipe" });
44
+ // Already inside a git work tree (own .git or parent's). Nothing to do.
45
+ } catch {
46
+ // Not inside any repo — create a fresh one. No empty seed commit.
36
47
  try {
37
48
  execFileSync("git", ["init"], { cwd: projectDir, stdio: "pipe" });
38
- execFileSync("git", ["commit", "--allow-empty", "-m", "initial commit"], { cwd: projectDir, stdio: "pipe" });
39
- logger.info("Initialized git repository with empty initial commit");
49
+ logger.info("Initialized git repository (no seed commit)");
40
50
  } catch (err) {
41
51
  logger.warn(`Failed to init git repo: ${err.message}`);
42
52
  }
@@ -172,7 +172,13 @@ export async function initFlowContext({ task, config, logger, emitter, askQuesti
172
172
  })
173
173
  );
174
174
 
175
- ctx.stageResults = {};
175
+ // KJC-BUG-0058: on `kj resume`, rehydrate stage results from the loaded
176
+ // session so completed pre-loop stages (researcher, architect, planner…)
177
+ // are skipped instead of re-executed. `setStageResult` mirrors writes
178
+ // into both `stage_results` (full payload) and `stages_completed` (flat
179
+ // array) — copying `stage_results` here is sufficient because
180
+ // `runPreLoopStages` only checks membership in `stageResults`.
181
+ ctx.stageResults = { ...(ctx.session?.stage_results || {}) };
176
182
  ctx.sonarState = { issuesInitial: null, issuesFinal: null };
177
183
 
178
184
  const preLoopResult = await runPreLoopStages({ config, logger, emitter, eventBase: ctx.eventBase, session: ctx.session, flags, pipelineFlags: ctx.pipelineFlags, coderRole: ctx.coderRole, trackBudget: ctx.trackBudget, task, askQuestion, pgTaskId, pgProject, stageResults: ctx.stageResults, brainCtx: ctx.brainCtx });
@@ -34,6 +34,7 @@ import { refineSkillsSemantically, resolveSkillsMode } from "../../skills/semant
34
34
  import { saveSession } from "../../session/store.js";
35
35
  import {
36
36
  setPreflight, setAutoInstalledSkills, setSkillsRecommended,
37
+ setStageResult, setStageBundle,
37
38
  } from "../../session/mutators.js";
38
39
  import {
39
40
  runResearcherStage, runArchitectStage, runPlannerStage,
@@ -60,18 +61,42 @@ import { tryCiComment } from "../ci-integration.js";
60
61
  import { getIntegration } from "../integrations.js";
61
62
 
62
63
  export async function runPreLoopStages({ config, logger, emitter, eventBase, session, flags, pipelineFlags, coderRole, trackBudget, task, askQuestion, pgTaskId, pgProject, stageResults, brainCtx }) {
64
+ // KJC-BUG-0058: on `kj resume` `stageResults` is rehydrated from
65
+ // `session.stage_results`. Persist after every successful pre-loop stage
66
+ // and skip the work when the slot is already populated. Cost: one
67
+ // `await saveSession` per stage; equivalent to the writes the post-loop
68
+ // path already makes for security / acceptance results.
69
+ const persistStage = async (name, result) => {
70
+ if (!result) return;
71
+ stageResults[name] = result;
72
+ setStageResult(session, name, result);
73
+ try { await saveSession(session); } catch (err) {
74
+ logger.warn(`Could not persist '${name}' stage result: ${err.message}`);
75
+ }
76
+ };
77
+ const resumeSkip = (name) => {
78
+ if (!stageResults[name]) return false;
79
+ logger.info(`[resume] skipping pre-loop stage '${name}' — completed in previous session`);
80
+ emitProgress(emitter, makeEvent("stage:skipped", { ...eventBase, stage: name }, {
81
+ status: "ok",
82
+ message: `Stage '${name}' skipped — already completed on previous run`,
83
+ detail: { cached: true }
84
+ }));
85
+ return true;
86
+ };
87
+
63
88
  // --- HU Reviewer (first stage, before everything else, opt-in) ---
64
89
  const huFile = flags.huFile || null;
65
90
  if (flags.enableHuReviewer !== undefined) pipelineFlags.huReviewerEnabled = Boolean(flags.enableHuReviewer);
66
- if (pipelineFlags.huReviewerEnabled && huFile) {
91
+ if (pipelineFlags.huReviewerEnabled && huFile && !resumeSkip("huReviewer")) {
67
92
  const huResult = await runHuReviewerStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget, huFile, askQuestion, pgStories: null });
68
- stageResults.huReviewer = huResult.stageResult;
93
+ await persistStage("huReviewer", huResult.stageResult);
69
94
  }
70
95
 
71
96
  // --- Intent classifier (deterministic pre-triage, opt-in) ---
72
- if (config.guards?.intent?.enabled) {
97
+ if (config.guards?.intent?.enabled && !resumeSkip("intent")) {
73
98
  const intentResult = classifyIntent(task, config);
74
- stageResults.intent = intentResult;
99
+ await persistStage("intent", intentResult);
75
100
  if (intentResult.classified) {
76
101
  emitProgress(emitter, makeEvent("intent:classified", { ...eventBase, stage: "intent" }, {
77
102
  message: `Intent classified: ${intentResult.taskType} (${intentResult.level}) — ${intentResult.message}`,
@@ -82,21 +107,21 @@ export async function runPreLoopStages({ config, logger, emitter, eventBase, ses
82
107
 
83
108
  // --- Discover (pre-triage, opt-in) ---
84
109
  if (flags.enableDiscover !== undefined) pipelineFlags.discoverEnabled = Boolean(flags.enableDiscover);
85
- if (pipelineFlags.discoverEnabled) {
110
+ if (pipelineFlags.discoverEnabled && !resumeSkip("discover")) {
86
111
  const discoverResult = await runDiscoverStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget });
87
- stageResults.discover = discoverResult.stageResult;
112
+ await persistStage("discover", discoverResult.stageResult);
88
113
  }
89
114
 
90
115
  // --- Triage (always on) — routed through StageRegistry (TSK-0336) ---
91
- // canRun here is `pipelineFlags.triageEnabled !== false`; triageEnabled is
92
- // never set to false in production, so behavior matches the previous
93
- // unconditional call. If a caller ever flips it off, runStage returns null
94
- // and we skip apply+persist with reasonable defaults (no overrides, no
95
- // stageResult).
116
+ // KJC-BUG-0058: NOT skipped on resume. Triage is cheap and emits
117
+ // `roleOverrides` that downstream stages depend on (Brain decisor + the
118
+ // pipelineFlags). Re-running it on resume is the safe path; the heavy
119
+ // stages it gates (researcher, architect, planner) ARE skipped if
120
+ // already complete.
96
121
  const triageCtx = { config, logger, emitter, eventBase, session, coderRole, trackBudget, pipelineFlags };
97
122
  const triageResult = await runStage(stageRegistry.get("triage"), triageCtx) ?? { roleOverrides: null, stageResult: null };
98
123
  applyTriageOverrides(pipelineFlags, triageResult.roleOverrides);
99
- stageResults.triage = triageResult.stageResult;
124
+ await persistStage("triage", triageResult.stageResult);
100
125
 
101
126
  // --- Brain decisor (opt-in, intent-driven routing) ---
102
127
  //
@@ -188,13 +213,13 @@ export async function runPreLoopStages({ config, logger, emitter, eventBase, ses
188
213
 
189
214
  // --- Domain Curator (after triage + skill auto-install, before planning phases) ---
190
215
  const domainHints = triageResult.stageResult?.domainHints || [];
191
- if (domainHints.length > 0 || config.projectDir) {
216
+ if ((domainHints.length > 0 || config.projectDir) && !resumeSkip("domainCurator")) {
192
217
  try {
193
218
  const { domainContext, stageResult: dcStageResult } = await runDomainCuratorStage({
194
219
  config, logger, emitter, eventBase, session, trackBudget,
195
220
  domainHints, askQuestion
196
221
  });
197
- stageResults.domainCurator = dcStageResult;
222
+ await persistStage("domainCurator", dcStageResult);
198
223
  if (domainContext) {
199
224
  config = { ...config, domainContext };
200
225
  }
@@ -213,7 +238,7 @@ export async function runPreLoopStages({ config, logger, emitter, eventBase, ses
213
238
  pgStories = getIntegration("tracker")?.buildHuStoriesFromCard?.(session.pg_card) ?? null;
214
239
  }
215
240
  const huResult = await runHuReviewerStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget, huFile: null, askQuestion, pgStories });
216
- stageResults.huReviewer = huResult.stageResult;
241
+ await persistStage("huReviewer", huResult.stageResult);
217
242
  }
218
243
 
219
244
  // --- Auto-simplify pipeline for simple tasks (before explicit flag overrides) ---
@@ -291,7 +316,7 @@ export async function runPreLoopStages({ config, logger, emitter, eventBase, ses
291
316
 
292
317
 
293
318
  // --- Researcher → Planner ---
294
- const { plannedTask } = await runPlanningPhases({ config: updatedConfig, logger, emitter, eventBase, session, stageResults, pipelineFlags, coderRole, trackBudget, task, askQuestion, brainCtx });
319
+ const { plannedTask } = await runPlanningPhases({ config: updatedConfig, logger, emitter, eventBase, session, stageResults, pipelineFlags, coderRole, trackBudget, task, askQuestion, brainCtx, persistStage, resumeSkip });
295
320
 
296
321
  // --- Update .gitignore with stack-specific entries based on planner/architect output ---
297
322
  const projectDir = updatedConfig.projectDir || process.cwd();
@@ -377,7 +402,7 @@ export async function runPreLoopStages({ config, logger, emitter, eventBase, ses
377
402
 
378
403
  return { plannedTask, updatedConfig };
379
404
  }
380
- async function runPlanningPhases({ config, logger, emitter, eventBase, session, stageResults, pipelineFlags, coderRole, trackBudget, task, askQuestion, brainCtx }) {
405
+ async function runPlanningPhases({ config, logger, emitter, eventBase, session, stageResults, pipelineFlags, coderRole, trackBudget, task, askQuestion, brainCtx, persistStage, resumeSkip }) {
381
406
  let researchContext = null;
382
407
  let plannedTask = task;
383
408
 
@@ -386,41 +411,62 @@ async function runPlanningPhases({ config, logger, emitter, eventBase, session,
386
411
  ? (await import("../brain-coordinator.js")).processRoleOutput
387
412
  : null;
388
413
 
414
+ // KJC-BUG-0058: on resume, rehydrate cross-stage context from session.
415
+ // researchContext / architectContext / plannedTask live ONLY in memory
416
+ // after their owning stage runs; without these the post-loop coder
417
+ // would lose context even though we skipped the upstream LLM call.
418
+ const bundles = session?.stage_bundles || {};
419
+
389
420
  if (pipelineFlags.researcherEnabled) {
390
- const researcherResult = await runResearcherStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget });
391
- researchContext = researcherResult.researchContext;
392
- stageResults.researcher = researcherResult.stageResult;
393
- if (brainCompress) brainCompress(brainCtx, { roleName: "researcher", output: researcherResult.stageResult, iteration: 0 });
421
+ if (resumeSkip("researcher")) {
422
+ researchContext = bundles.researcher?.researchContext || null;
423
+ } else {
424
+ const researcherResult = await runResearcherStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget });
425
+ researchContext = researcherResult.researchContext;
426
+ setStageBundle(session, "researcher", { stageResult: researcherResult.stageResult, researchContext });
427
+ await persistStage("researcher", researcherResult.stageResult);
428
+ if (brainCompress) brainCompress(brainCtx, { roleName: "researcher", output: researcherResult.stageResult, iteration: 0 });
429
+ }
394
430
  }
395
431
 
396
432
  // --- Architect (between researcher and planner) ---
397
433
  let architectContext = null;
398
434
  if (pipelineFlags.architectEnabled) {
399
- const architectResult = await runArchitectStage({
400
- config, logger, emitter, eventBase, session, coderRole, trackBudget,
401
- researchContext,
402
- discoverResult: stageResults.discover || null,
403
- triageLevel: stageResults.triage?.level || null,
404
- askQuestion
405
- });
406
- architectContext = architectResult.architectContext;
407
- stageResults.architect = architectResult.stageResult;
408
- if (brainCompress) brainCompress(brainCtx, { roleName: "architect", output: architectResult.stageResult, iteration: 0 });
435
+ if (resumeSkip("architect")) {
436
+ architectContext = bundles.architect?.architectContext || null;
437
+ } else {
438
+ const architectResult = await runArchitectStage({
439
+ config, logger, emitter, eventBase, session, coderRole, trackBudget,
440
+ researchContext,
441
+ discoverResult: stageResults.discover || null,
442
+ triageLevel: stageResults.triage?.level || null,
443
+ askQuestion
444
+ });
445
+ architectContext = architectResult.architectContext;
446
+ setStageBundle(session, "architect", { stageResult: architectResult.stageResult, architectContext });
447
+ await persistStage("architect", architectResult.stageResult);
448
+ if (brainCompress) brainCompress(brainCtx, { roleName: "architect", output: architectResult.stageResult, iteration: 0 });
449
+ }
409
450
  }
410
451
 
411
452
  const triageDecomposition = stageResults.triage?.shouldDecompose ? stageResults.triage.subtasks : null;
412
453
  if (pipelineFlags.plannerEnabled) {
413
- const plannerRole = resolveRole(config, "planner");
414
- const plannerResult = await runPlannerStage({ config, logger, emitter, eventBase, session, plannerRole, researchContext, architectContext, triageDecomposition, trackBudget });
415
- plannedTask = plannerResult.plannedTask;
416
- stageResults.planner = plannerResult.stageResult;
417
- if (brainCompress) brainCompress(brainCtx, { roleName: "planner", output: plannerResult.stageResult, iteration: 0 });
418
-
419
- await tryCiComment({
420
- config, session, logger,
421
- agent: "Planner",
422
- body: `Plan: ${plannerResult.stageResult?.summary || plannedTask}`
423
- });
454
+ if (resumeSkip("planner")) {
455
+ plannedTask = bundles.planner?.plannedTask || task;
456
+ } else {
457
+ const plannerRole = resolveRole(config, "planner");
458
+ const plannerResult = await runPlannerStage({ config, logger, emitter, eventBase, session, plannerRole, researchContext, architectContext, triageDecomposition, trackBudget });
459
+ plannedTask = plannerResult.plannedTask;
460
+ setStageBundle(session, "planner", { stageResult: plannerResult.stageResult, plannedTask });
461
+ await persistStage("planner", plannerResult.stageResult);
462
+ if (brainCompress) brainCompress(brainCtx, { roleName: "planner", output: plannerResult.stageResult, iteration: 0 });
463
+
464
+ await tryCiComment({
465
+ config, session, logger,
466
+ agent: "Planner",
467
+ body: `Plan: ${plannerResult.stageResult?.summary || plannedTask}`
468
+ });
469
+ }
424
470
  }
425
471
 
426
472
  return { plannedTask };
@@ -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
+ }
@@ -138,6 +138,37 @@ export function setStatus(session, status) {
138
138
  session.status = status;
139
139
  }
140
140
 
141
+ // --- Stage results (KJC-BUG-0058: resume must skip completed stages) ------
142
+
143
+ /**
144
+ * Persist a pre-loop stage result on the session, plus mirror its name in
145
+ * a flat `stages_completed` array used by `resumeFlow` to know what to skip.
146
+ *
147
+ * Both shapes are kept in sync so callers can read either: the rich
148
+ * `stage_results[name]` for the full payload, or the cheap
149
+ * `stages_completed` for a fast membership check.
150
+ */
151
+ export function setStageResult(session, name, result) {
152
+ if (!session.stage_results) session.stage_results = {};
153
+ session.stage_results[name] = result;
154
+ if (!Array.isArray(session.stages_completed)) session.stages_completed = [];
155
+ if (!session.stages_completed.includes(name)) session.stages_completed.push(name);
156
+ }
157
+
158
+ /**
159
+ * Stage *bundles* capture cross-stage context that a stage exports beyond
160
+ * its stageResult (e.g. researcher → researchContext, architect →
161
+ * architectContext, planner → plannedTask). Resume needs these to feed
162
+ * downstream stages without re-running the upstream one. The bundle is
163
+ * always shaped `{ stageResult, ...extras }` and `setStageBundle` mirrors
164
+ * the stageResult into `stage_results[name]` so legacy readers keep working.
165
+ */
166
+ export function setStageBundle(session, name, bundle) {
167
+ if (!session.stage_bundles) session.stage_bundles = {};
168
+ session.stage_bundles[name] = bundle;
169
+ setStageResult(session, name, bundle?.stageResult ?? bundle);
170
+ }
171
+
141
172
  // --- Alternative agent (rate-limit recovery) ------------------------------
142
173
 
143
174
  export function setAlternativeAgent(session, stage, provider) {