karajan-code 2.19.2 → 2.19.4
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 +10 -0
- package/package.json +1 -1
- package/packages/hu-board/src/cleanup-zombies.js +7 -3
- package/packages/hu-board/src/db.js +45 -0
- package/packages/hu-board/src/plan-mutations.js +6 -3
- package/packages/hu-board/src/preflight.js +8 -7
- package/packages/hu-board/src/routes/api.js +42 -31
- package/src/orchestrator/config-init.js +19 -9
- package/src/orchestrator/drivers/init-context.js +7 -1
- package/src/orchestrator/drivers/pre-loop.js +88 -42
- package/src/session/mutators.js +31 -0
package/README.md
CHANGED
|
@@ -345,6 +345,16 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
|
|
|
345
345
|
|
|
346
346
|
## Recent releases
|
|
347
347
|
|
|
348
|
+
> **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
|
+
>
|
|
350
|
+
> 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.
|
|
351
|
+
>
|
|
352
|
+
> 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.
|
|
353
|
+
>
|
|
354
|
+
> **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.**
|
|
355
|
+
>
|
|
356
|
+
> **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.**
|
|
357
|
+
>
|
|
348
358
|
> **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).
|
|
349
359
|
>
|
|
350
360
|
> **v2.19.1 released** — Patch. **APPLICATION BLOCKER fix** for the HU Board: every fresh `npm install -g karajan-code` since the HU Board feature shipped was producing tarballs without the `packages/hu-board/` directory (the `files` array in `package.json` did not include it), and even when the user copied that directory manually, the board crashed at startup with `Cannot find package 'helmet' imported from .../packages/hu-board/src/server.js` because its five dependencies (`helmet`, `chokidar`, `better-sqlite3`, `express`, `express-rate-limit`) were declared in `packages/hu-board/package.json` but missing from the root `dependencies`. PR #791 adds `packages/hu-board/{src,public,package.json}` to `files`, adds the five deps at root at the exact same versions so `npm dedupe` collapses to one copy resolvable by upward traversal, and regenerates `package-lock.json`. Verified end-to-end: `npm pack --dry-run` now ships 12 board files; `node packages/hu-board/src/server.js` boots cleanly. Also internal: 38 direct `os.homedir()` callers routed through the unified resolver (PR #790, `KARAJAN_HOME` now redirects every component) and 5 inline constructions of `~/.karajan/hu-board-runs/` unified under one helper (PR #789). Reported by [@aitormf](https://github.com/aitormf). **If you tried v2.19.0 and `kj board start` failed, upgrade to v2.19.1.**
|
package/package.json
CHANGED
|
@@ -47,8 +47,12 @@ function isEphemeralId(id) {
|
|
|
47
47
|
return EPHEMERAL_PREFIXES.some((p) => id.startsWith(p));
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// KJC-BUG-0059 (v2.19.3): cleanup walks all known plan roots,
|
|
51
|
+
// canonical first then legacy. Pre-fix hard-coded `~/.kj/plans/`
|
|
52
|
+
// missed every plan post-v2.19.0 → zombies never garbage-collected.
|
|
53
|
+
import { getHuBoardPlansDirs } from './db.js';
|
|
54
|
+
function plansRoots() {
|
|
55
|
+
return getHuBoardPlansDirs();
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
function olderThanDays(isoStr, days) {
|
|
@@ -90,7 +94,7 @@ export function cleanupZombies(opts = {}) {
|
|
|
90
94
|
const fsPaths = [
|
|
91
95
|
...storyIds.map((sid) => path.join(getKjHome(), 'hu-stories', sid)),
|
|
92
96
|
...sessionIds.map((sid) => path.join(getKjHome(), 'sessions', sid)),
|
|
93
|
-
path.join(
|
|
97
|
+
...plansRoots().map((root) => path.join(root, proj.id)),
|
|
94
98
|
];
|
|
95
99
|
deleteProject(proj.id);
|
|
96
100
|
addTombstone('project', proj.id, { source: 'auto-cleanup', fsPaths });
|
|
@@ -56,6 +56,51 @@ export function getHuBoardRunsDir() {
|
|
|
56
56
|
return join(getKjHome(), 'hu-board-runs');
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Canonical path to the plans directory the board scans
|
|
61
|
+
* (`<karajan-home>/plans/`). Respects `KJ_PLANS_DIR` as an absolute
|
|
62
|
+
* override, mirroring sync.js. KJC-BUG-0059 (v2.19.3): preflight
|
|
63
|
+
* endpoint used a hard-coded `~/.kj/plans/` legacy fallback that
|
|
64
|
+
* survived the v2.19.0 consolidation, causing the board to report
|
|
65
|
+
* "directorio del proyecto: no detectado" even when the plan on disk
|
|
66
|
+
* had a valid `projectDir`.
|
|
67
|
+
*
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function getHuBoardPlansDir() {
|
|
71
|
+
return process.env.KJ_PLANS_DIR || join(getKjHome(), 'plans');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Legacy `~/.kj/plans/` directory — only returned when KJ_PLANS_DIR
|
|
76
|
+
* is unset (it would be the same path anyway when overridden).
|
|
77
|
+
* Callers iterate canonical + legacy so users who have not yet run
|
|
78
|
+
* the v2.19.0 auto-migrator still see their plans. KJC-BUG-0059.
|
|
79
|
+
*
|
|
80
|
+
* @returns {string|null}
|
|
81
|
+
*/
|
|
82
|
+
export function getHuBoardLegacyPlansDir() {
|
|
83
|
+
if (process.env.KJ_PLANS_DIR) return null;
|
|
84
|
+
return join(process.env.HOME || '/root', '.kj', 'plans');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Ordered list of every plans root the board should scan: canonical
|
|
89
|
+
* first (`~/.karajan/plans/`), legacy second when applicable
|
|
90
|
+
* (`~/.kj/plans/`). Honours `KJ_PLANS_DIR` as a single override,
|
|
91
|
+
* matching `sync.js`'s fullScan / startWatcher precedence so the
|
|
92
|
+
* preflight, plans-outcome, delete-project and delete-plan endpoints
|
|
93
|
+
* see the same set of files the watcher sees. KJC-BUG-0059.
|
|
94
|
+
*
|
|
95
|
+
* @returns {string[]}
|
|
96
|
+
*/
|
|
97
|
+
export function getHuBoardPlansDirs() {
|
|
98
|
+
const dirs = [getHuBoardPlansDir()];
|
|
99
|
+
const legacy = getHuBoardLegacyPlansDir();
|
|
100
|
+
if (legacy) dirs.push(legacy);
|
|
101
|
+
return dirs;
|
|
102
|
+
}
|
|
103
|
+
|
|
59
104
|
// Bump when the schema gains a column / index / table that older
|
|
60
105
|
// Karajan versions cannot understand. A DB written by a newer Karajan
|
|
61
106
|
// refuses to open here so it does not get silently downgraded.
|
|
@@ -40,10 +40,13 @@ const KJ_CLI = join(REPO_ROOT, 'src', 'cli.js');
|
|
|
40
40
|
* `KJ_PLANS_DIR` (tests) then `KJ_HOME/plans` (power users) then
|
|
41
41
|
* `~/.kj/plans/` (default) — same precedence as `sync.js::fullScan`.
|
|
42
42
|
*/
|
|
43
|
+
// KJC-BUG-0059: write operations go to the canonical plans dir.
|
|
44
|
+
// Read operations should iterate getHuBoardPlansDirs() instead, to
|
|
45
|
+
// pick up plans the user has under the legacy ~/.kj/plans/ tree
|
|
46
|
+
// while the auto-migrator has not yet fired.
|
|
47
|
+
import { getHuBoardPlansDir } from './db.js';
|
|
43
48
|
function plansRoot() {
|
|
44
|
-
|
|
45
|
-
if (process.env.KJ_HOME) return join(process.env.KJ_HOME, 'plans');
|
|
46
|
-
return join(homedir(), '.kj', 'plans');
|
|
49
|
+
return getHuBoardPlansDir();
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
/**
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
import { execSync } from 'node:child_process';
|
|
31
31
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
32
32
|
import { join } from 'node:path';
|
|
33
|
+
import { getHuBoardPlansDirs } from './db.js';
|
|
33
34
|
|
|
34
35
|
function runQuiet(cmd, opts = {}) {
|
|
35
36
|
try {
|
|
@@ -221,13 +222,13 @@ function checkConfigYml() {
|
|
|
221
222
|
}
|
|
222
223
|
|
|
223
224
|
function checkPlans(projectId) {
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (!
|
|
225
|
+
// KJC-BUG-0059 (v2.19.3): scan canonical + legacy roots, same as
|
|
226
|
+
// the preflight route. Pre-fix this hard-coded `~/.kj/plans/`
|
|
227
|
+
// missed every plan post-v2.19.0 → preflight reported "sin plans
|
|
228
|
+
// aún" even when the user had a valid plan on disk.
|
|
229
|
+
const dirs = getHuBoardPlansDirs().map((root) => join(root, projectId));
|
|
230
|
+
const dir = dirs.find((d) => existsSync(d));
|
|
231
|
+
if (!dir) {
|
|
231
232
|
return {
|
|
232
233
|
id: 'plans', label: 'Plans en disco', status: 'info',
|
|
233
234
|
detail: 'sin plans aún',
|
|
@@ -15,6 +15,9 @@ import {
|
|
|
15
15
|
deleteSession,
|
|
16
16
|
getKjHome,
|
|
17
17
|
getHuBoardRunsDir,
|
|
18
|
+
getHuBoardPlansDir,
|
|
19
|
+
getHuBoardLegacyPlansDir,
|
|
20
|
+
getHuBoardPlansDirs,
|
|
18
21
|
getStoryRow,
|
|
19
22
|
listPlanIdsForProject,
|
|
20
23
|
updateStoryStatus,
|
|
@@ -192,22 +195,23 @@ router.get('/projects/:id/preflight', async (req, res) => {
|
|
|
192
195
|
try {
|
|
193
196
|
const projectId = req.params.id;
|
|
194
197
|
let projectDir = null;
|
|
198
|
+
// KJC-BUG-0059 (v2.19.3): scan plans in BOTH the canonical
|
|
199
|
+
// ~/.karajan/plans/ AND the legacy ~/.kj/plans/ — the legacy
|
|
200
|
+
// fallback covers users who started the board before running any
|
|
201
|
+
// `kj` command on a fresh install (so the auto-migrator from
|
|
202
|
+
// v2.19.0 has not fired yet). Pre-fix, this endpoint hard-coded
|
|
203
|
+
// ~/.kj/plans/ as the default, missing every plan post-2.19.0
|
|
204
|
+
// → board card showed "directorio del proyecto: no detectado"
|
|
205
|
+
// even though the plan on disk had a valid projectDir.
|
|
195
206
|
try {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const plansBase = process.env.KJ_HOME
|
|
201
|
-
? path.join(process.env.KJ_HOME, 'plans')
|
|
202
|
-
: path.join(process.env.HOME || '', '.kj', 'plans');
|
|
203
|
-
const plansDir = path.join(plansBase, projectId);
|
|
204
|
-
if (fs.existsSync(plansDir)) {
|
|
205
|
-
const files = fs.readdirSync(plansDir).filter((f) => f.endsWith('.json'));
|
|
206
|
-
for (const f of files) {
|
|
207
|
+
outer: for (const root of getHuBoardPlansDirs()) {
|
|
208
|
+
const plansDir = path.join(root, projectId);
|
|
209
|
+
if (!fs.existsSync(plansDir)) continue;
|
|
210
|
+
for (const f of fs.readdirSync(plansDir).filter((x) => x.endsWith('.json'))) {
|
|
207
211
|
try {
|
|
208
212
|
const plan = JSON.parse(fs.readFileSync(path.join(plansDir, f), 'utf8'));
|
|
209
|
-
if (plan?.projectDir) { projectDir = plan.projectDir; break; }
|
|
210
|
-
} catch { /* try next */ }
|
|
213
|
+
if (plan?.projectDir) { projectDir = plan.projectDir; break outer; }
|
|
214
|
+
} catch { /* try next file */ }
|
|
211
215
|
}
|
|
212
216
|
}
|
|
213
217
|
} catch { /* projectDir stays null → preflight will report it */ }
|
|
@@ -229,17 +233,17 @@ router.get('/projects/:id/preflight', async (req, res) => {
|
|
|
229
233
|
router.get('/projects/:id/plans-outcome', (req, res) => {
|
|
230
234
|
try {
|
|
231
235
|
const projectId = req.params.id;
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
: path.join(process.env.HOME || '', '.kj', 'plans');
|
|
235
|
-
const plansDir = path.join(plansBase, projectId);
|
|
236
|
-
if (!fs.existsSync(plansDir)) return res.json({ projectId, plans: [] });
|
|
236
|
+
// KJC-BUG-0059 (v2.19.3): scan canonical + legacy roots so users
|
|
237
|
+
// who have not yet run the auto-migrator still see their plans.
|
|
237
238
|
const out = [];
|
|
238
|
-
for (const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
239
|
+
for (const root of getHuBoardPlansDirs()) {
|
|
240
|
+
const plansDir = path.join(root, projectId);
|
|
241
|
+
if (!fs.existsSync(plansDir)) continue;
|
|
242
|
+
for (const f of fs.readdirSync(plansDir).filter(n => n.endsWith('.json'))) {
|
|
243
|
+
try {
|
|
244
|
+
const plan = JSON.parse(fs.readFileSync(path.join(plansDir, f), 'utf8'));
|
|
245
|
+
if (plan?.version !== 2) continue;
|
|
246
|
+
out.push({
|
|
243
247
|
planId: plan.planId,
|
|
244
248
|
name: plan.name || (plan.task ? plan.task.slice(0, 80) : plan.planId),
|
|
245
249
|
status: plan.status || 'draft',
|
|
@@ -248,6 +252,7 @@ router.get('/projects/:id/plans-outcome', (req, res) => {
|
|
|
248
252
|
huCount: Array.isArray(plan.hus) ? plan.hus.length : 0,
|
|
249
253
|
});
|
|
250
254
|
} catch { /* skip unreadable plan */ }
|
|
255
|
+
}
|
|
251
256
|
}
|
|
252
257
|
res.json({ projectId, plans: out });
|
|
253
258
|
} catch (err) {
|
|
@@ -356,12 +361,13 @@ router.delete('/projects/:id', (req, res) => {
|
|
|
356
361
|
// ~/.kj/plans relative to KJ_HOME's parent, which broke when the
|
|
357
362
|
// two homes were configured separately and left orphan plan dirs
|
|
358
363
|
// on disk after a 🗑️ delete.
|
|
359
|
-
|
|
360
|
-
|
|
364
|
+
// KJC-BUG-0059 (v2.19.3): when removing a deleted project's
|
|
365
|
+
// residual plan dirs, sweep BOTH the canonical and legacy roots
|
|
366
|
+
// so the cleanup is consistent for users mid-migration.
|
|
361
367
|
const fsPaths = [
|
|
362
368
|
...storyIds.map((sid) => path.join(huStoriesDir(), sid)),
|
|
363
369
|
...sessionIds.map((sid) => path.join(getKjHome(), 'sessions', sid)),
|
|
364
|
-
path.join(
|
|
370
|
+
...getHuBoardPlansDirs().map((root) => path.join(root, id)),
|
|
365
371
|
];
|
|
366
372
|
|
|
367
373
|
const existed = deleteProject(id);
|
|
@@ -464,13 +470,18 @@ router.delete('/sessions/:id', (req, res) => {
|
|
|
464
470
|
router.delete('/plans/:planId', (req, res) => {
|
|
465
471
|
try {
|
|
466
472
|
const planId = req.params.planId;
|
|
467
|
-
// Locate the plan file by scanning
|
|
468
|
-
|
|
473
|
+
// Locate the plan file by scanning both canonical and legacy plans
|
|
474
|
+
// roots — KJC-BUG-0059 (v2.19.3). Pre-fix this hard-coded
|
|
475
|
+
// `getKjHome()/../.kj/plans` which silently misses plans saved by
|
|
476
|
+
// post-2.19.0 callers under `~/.karajan/plans/`.
|
|
469
477
|
let foundPath = null;
|
|
470
478
|
try {
|
|
471
|
-
for (const
|
|
472
|
-
|
|
473
|
-
|
|
479
|
+
outer: for (const plansRoot of getHuBoardPlansDirs()) {
|
|
480
|
+
if (!fs.existsSync(plansRoot)) continue;
|
|
481
|
+
for (const projSlug of fs.readdirSync(plansRoot)) {
|
|
482
|
+
const candidate = path.join(plansRoot, projSlug, `plan-${planId}.json`);
|
|
483
|
+
if (fs.existsSync(candidate)) { foundPath = candidate; break outer; }
|
|
484
|
+
}
|
|
474
485
|
}
|
|
475
486
|
} catch { /* plansRoot missing */ }
|
|
476
487
|
addTombstone('plan', planId, { source: 'api', fsPaths: foundPath ? [foundPath] : [] });
|
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
+
await persistStage("discover", discoverResult.stageResult);
|
|
88
113
|
}
|
|
89
114
|
|
|
90
115
|
// --- Triage (always on) — routed through StageRegistry (TSK-0336) ---
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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 };
|
package/src/session/mutators.js
CHANGED
|
@@ -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) {
|