karajan-code 2.17.1 โ 2.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/package.json +1 -1
- package/src/brain/agent-error-classifier.js +5 -1
- package/src/brain/standby-store.js +38 -1
- package/src/brain/with-brain-recovery.js +65 -8
- package/src/commands/plan/generate.js +22 -0
- package/src/commands/resume.js +48 -27
- package/src/commands/run.js +5 -0
- package/src/config/loader.js +27 -3
- package/src/hu/auto-generator.js +170 -39
- package/src/orchestrator/drivers/iteration-phases/coder-and-refactorer.js +21 -0
- package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +22 -2
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +56 -0
- package/src/orchestrator/flow-runner.js +5 -0
- package/src/orchestrator/stages/coder-stage.js +47 -4
- package/src/orchestrator/verification-gate.js +38 -9
- package/src/plan/plan-store.js +53 -9
- package/src/plan/tests-synthesizer.js +16 -6
- package/src/prompts/coder.js +27 -4
- package/src/roles/agent-role.js +24 -1
- package/src/roles/coder-role.js +14 -1
- package/src/roles/triage-role.js +28 -9
- package/src/session/store.js +2 -1
- package/src/utils/atomic-write.js +43 -0
- package/src/utils/display/resume-hint.js +41 -0
- package/src/utils/process.js +18 -0
- package/src/utils/rate-limit-detector.js +22 -0
- package/src/utils/run-registry.js +2 -1
package/README.md
CHANGED
|
@@ -23,7 +23,13 @@
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
> **v2.
|
|
26
|
+
> **v2.18.1 released** โ Patch. Six user-feedback follow-ups to v2.18.0: **`kj-tail` after `kj resume`** (#772, was silent because `resume.js` skipped `withCliRunLog`); **standby waits in-process instead of exiting on a short cooldown** (#773, kj stays alive for waits โค 12 h and retries on its own; Ctrl+C during the wait prints `kj standby resume <id>`); **closed KJC-BUG-0040 โ SEA linux binaries** (#774, was a race between `gh release create` and softprops, not `better-sqlite3` as the memory said โ 60 s poll fix + `make_latest:false`); **stack bias โ Python repos no longer get vitest** (#775 + #776 + #777, `detectProjectStack` finally cabled into the coder prompt, the HU auto-generator templates by language, and the synthesizer + `auto-hu-batch` take the stack from the filesystem). 4 971/4 971 tests passing across 416 files. Safe upgrade from 2.18.0.
|
|
27
|
+
>
|
|
28
|
+
> **v2.18.0** โ Minor. Closes the **resilience audit** triggered by the public launch: 15 PRs across 5 phases hardening Karajan against the silent-failure family of bugs โ *the problem is not that something fails, the problem is failing without telling the user why.* **Phase 1 โ hibernation end to end** (#756โ#759): a quota cap is classified as a recoverable quota class (incl. Claude Code's `"You've hit your session limit ยท resets 10:10pm"`); `withBrainRecovery` persists a standby JSON; the orchestrator consumes `action:"hibernate"`, seals the session `hibernated` (resumable) instead of `failed`; the last line printed is the exact resume command (`kj standby resume <id>`). **Phase 2 โ don't lie** (#761โ#763): `runCommand` surfaces ENOENT so a missing agent CLI gets an actionable error (not an empty one); `silenceTimeoutMs` is forwarded to every role so a hung agent is killed, not waited on forever; all 6 state-file writers go through `writeJsonAtomic` (write-temp + rename) โ interrupted writes can no longer truncate plans, sessions, standby snapshots. **Phase 3 โ don't lose or block** (#764โ#767): a corrupt plan JSON is renamed aside with a loud warn (was silently dropped from `kj plan list`); `kj.config.yml` parse errors throw `Invalid YAML in <path>` (used to brick every kj command including `kj doctor`); `injectLoadedPlan` reconciles HUs left in `coding`/`reviewing`/`running` by a killed `kj run` (cross-checked against the run-registry); board SQLite gets `busy_timeout`, a `user_version` schema gate and corruption recovery. **Phase 4 โ don't degrade silently** (#768โ#769): `TriageRole` warns loudly when LLM output is unparseable (used to skip researcher/architect/security/tester with a chirpy "fallback defaults" summary); `verifyCoderOutput` distinguishes a git failure from "the coder did nothing" (`gitError` + `retryStrategy: null`) โ no more iterations blaming the agent for infra. **Phase 5 โ safety net** (#770): a `tests/resilience/` suite indexes every silent-failure mode and pins each one with a test, plus an end-to-end tripwire for the whole quota โ hibernate โ resume flow. Plus CI runs the `packages/hu-board` suite on every PR (#755). 4 959/4 959 tests passing across 416 files. Safe upgrade from 2.17.2.
|
|
29
|
+
>
|
|
30
|
+
> **v2.17.2** โ Patch. Wires quota-exhaustion **hibernation end to end**. A `kj run` / `kj plan` that hits a provider session or usage cap used to abort the task with an opaque `UNKNOWN_FATAL`; now it suspends and tells you how to resume. **Session-limit classification (#756)**: `"You've hit your session limit ยท resets 10:10pm"` matched no rate-limit pattern โ `session limit`/`weekly limit` added, and `parseCooldown` learns the 12-hour `resets 10:10pm` clock. **Hibernation persists (#757)**: `withBrainRecovery` only wrote `~/.kj/standby/<id>.json` with a `sessionState`, which no caller passed โ new `buildStandbyState()` assembles it with an allowlisted env subset (never the full `process.env`). **Orchestrator consumes `action:"hibernate"` (#758)**: no code checked for it, so a hibernation sealed the HU `failed`; the coder/refactorer stages now stop cleanly and the session is sealed `hibernated` (resumable). **Resume hint (#759)**: a stopped run's last line is now the exact command โ `kj standby resume <id>` for a hibernation, `kj resume <id>` otherwise. Plus CI now runs the `packages/hu-board` suite (#755). 4 931/4 931 tests passing across 410 files. Safe upgrade from 2.17.1.
|
|
31
|
+
>
|
|
32
|
+
> **v2.17.1** โ Patch. Fixes **KJC-BUG-0055**: a project deleted from the HU Board (๐๏ธ) no longer resurrects on the next `kj plan` or board restart. Four independent leaks closed: (1) **`sync.js` temporal gate** โ the unconditional `removeTombstone` from KJC-BUG-0050 becomes a `plan.updatedAt > tombstone.deleted_at` comparison, so a tombstoned project revives only when the plan is genuinely newer than the delete; (2) **`ephemeral-cleaner.js`** now writes a tombstone and `rm -rf`'s `hu-stories/`, `sessions/` and `~/.kj/plans/` dirs when wiping ephemeral projects at boot โ previously the orphan directories revived the project on the next scan; (3) **`fullScan` boot GC** sweeps orphan tombstoned dirs (the manual DB-wipe case); (4) **`DELETE /api/projects/:id`** honours `KJ_PLANS_DIR` instead of the hardcoded path. New `getTombstone()` helper in `db.js`. **Also fixes a silent `kj board start` failure (#753)**: the daemon's entry-point guard compared `import.meta.url` to a hand-built `file://` path, which wrongly returned false on Windows, symlinked/global installs and paths with spaces โ the board exited 0 with an empty log. `server.js` now trusts a `KJ_BOARD_DAEMON` launcher flag, adds `uncaughtException`/`unhandledRejection` handlers and an actionable `better-sqlite3` load error; `board.js` detects an early daemon exit instead of reporting a phantom PID. 4 909/4 909 tests passing across 408 files. Safe upgrade from 2.17.0.
|
|
27
33
|
>
|
|
28
34
|
> **v2.17.0** โ Minor. `kj audit` gains two new deterministic structural collectors and the v2.16 Sonar false-positive filter is generalised to apply across every collector. **Knip dead-exports collector** (codeQuality dim): reports unused exports/types (MINOR) and unused files (MAJOR), stack-aware (JS/TS only, needs package.json), subprocess via `--reporter json`, 120s timeout. **Madge circular-import collector** (architecture dim): detects import cycles, severity by chain length (โฅ4 files = MAJOR), honours tsconfig.json path aliases, 60s timeout. **Generalised FP filter**: every collector (sonar, knip, madge, osv, semgrep) now uses the same `config.audit.false_positives` shape with `tool` field plus inline marker `// karajan-audit-ignore: <tool>:<ruleId>`; legacy `config.sonar.false_positives` and `// karajan-sonar-ignore:` keep working. **Built-in catalogue** ships 4 entries by default (knip:unused-files in tests/fixtures + examples, knip:unused-exports on barrel files, madge cycles in node_modules). **BREAKING(engines)**: Node `>=20.10` โ `>=20.19` (knip 6.x requirement; same flavour as the v2.8 18โ20.10 bump). 4 872/4 872 tests passing across 402 files. Safe upgrade from 2.16.0 if you're on Node โฅ 20.19.
|
|
29
35
|
>
|
package/package.json
CHANGED
|
@@ -33,7 +33,11 @@ const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
33
33
|
|
|
34
34
|
const AUTH_PATTERNS = /\b401\b|\b403\b|unauthorized|invalid\s+api\s+key|authentication\s+failed|expired\s+token/i;
|
|
35
35
|
const SILENCED_PATTERNS = /killed\s+after\s+\d+\s*ms|silence\s*timeout|no\s+output\s+for\s+\d+/i;
|
|
36
|
-
|
|
36
|
+
// `session\s+limit` / `weekly\s+limit` cover Claude Code's usage caps
|
|
37
|
+
// ("You've hit your session limit ยท resets 10:10pm"). Without them the
|
|
38
|
+
// message fell through to UNKNOWN_FATAL and the run aborted instead of
|
|
39
|
+
// hibernating until the reset.
|
|
40
|
+
const RATE_LIMIT_PATTERNS = /usage\s+limit|rate\s*limit|too\s+many\s+requests|\b429\b|throttl|exceeded\s+your\s+current\s+quota|resource\s+exhausted|quota\s+exceeded|token\s+limit\s+reached|monthly\s+limit|weekly\s+limit|daily\s+limit|session\s+limit/i;
|
|
37
41
|
const API_DOWN_PATTERNS = /\b50[0-4]\b|bad\s+gateway|service\s+unavailable|gateway\s+timeout|overloaded|internal\s+server\s+error/i;
|
|
38
42
|
const NETWORK_PATTERNS = /ECONNREFUSED|ECONNRESET|ETIMEDOUT|socket\s+hang\s+up|fetch\s+failed|network\s+error/i;
|
|
39
43
|
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import fs from "node:fs";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import os from "node:os";
|
|
18
|
+
import { writeJsonAtomicSync } from "../utils/atomic-write.js";
|
|
18
19
|
|
|
19
20
|
const FILENAME_RE = /^[a-zA-Z0-9._-]+\.json$/;
|
|
20
21
|
|
|
@@ -56,10 +57,46 @@ export function persistStandby(state) {
|
|
|
56
57
|
createdAt: state.createdAt || new Date().toISOString(),
|
|
57
58
|
persistedAt: new Date().toISOString(),
|
|
58
59
|
};
|
|
59
|
-
|
|
60
|
+
writeJsonAtomicSync(file, payload);
|
|
60
61
|
return file;
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
// Only these env vars are carried into the standby snapshot. NEVER
|
|
65
|
+
// persist the whole `process.env` โ that would leak API keys and other
|
|
66
|
+
// secrets into ~/.kj/standby/*.json. `kj standby resume` re-spawns with
|
|
67
|
+
// the live env merged on top, so the snapshot only needs what is needed
|
|
68
|
+
// to relaunch from the right place.
|
|
69
|
+
const STANDBY_ENV_ALLOWLIST = ["KJ_HOME", "HOME", "PATH"];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Builds the `sessionState` object that `withBrainRecovery` persists when
|
|
73
|
+
* a run hibernates. Pulls the session id, the relaunch metadata and an
|
|
74
|
+
* allowlisted env subset so `kj standby resume` can re-spawn the command.
|
|
75
|
+
* @param {object} args
|
|
76
|
+
* @param {object} args.session - the pipeline session ({ id, plan_id? })
|
|
77
|
+
* @param {object} [args.config] - resolved config ({ projectDir?, plan? })
|
|
78
|
+
* @param {string|null} [args.huId]
|
|
79
|
+
* @returns {object|null} sessionState, or null when there is no session id
|
|
80
|
+
*/
|
|
81
|
+
export function buildStandbyState({ session, config = {}, huId = null } = {}) {
|
|
82
|
+
if (!session?.id) return null;
|
|
83
|
+
const env = {};
|
|
84
|
+
for (const key of STANDBY_ENV_ALLOWLIST) {
|
|
85
|
+
if (process.env[key] != null) env[key] = process.env[key];
|
|
86
|
+
}
|
|
87
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
88
|
+
if (key.startsWith("KJ_") && value != null) env[key] = value;
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
sessionId: session.id,
|
|
92
|
+
planId: session.plan_id || config.plan || null,
|
|
93
|
+
huId: huId || null,
|
|
94
|
+
argv: process.argv.slice(2),
|
|
95
|
+
cwd: config.projectDir || process.cwd(),
|
|
96
|
+
env,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
63
100
|
/**
|
|
64
101
|
* @param {string} sessionId
|
|
65
102
|
* @returns {object|null}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// puede ignorarlo (fallback a long standby capped) hasta que llegue 0414.
|
|
8
8
|
|
|
9
9
|
import { classifyAgentError, ERROR_CLASS } from "./agent-error-classifier.js";
|
|
10
|
-
import { persistStandby } from "./standby-store.js";
|
|
10
|
+
import { persistStandby, markStandbyDone } from "./standby-store.js";
|
|
11
11
|
|
|
12
12
|
const ONE_MIN = 60 * 1000;
|
|
13
13
|
|
|
@@ -20,6 +20,13 @@ export const DEFAULT_RECOVERY_POLICY = Object.freeze({
|
|
|
20
20
|
// KJC-TSK-0415: threshold de fallback. Si retryAfter > esto Y el role
|
|
21
21
|
// tiene fallback configurado โ switch provider en vez de hibernar.
|
|
22
22
|
fallbackWaitHoursDefault: DEFAULT_FALLBACK_WAIT_HOURS,
|
|
23
|
+
// Standby-in-process: when the cooldown is short enough we keep the
|
|
24
|
+
// process alive and `await sleepFn(retryAfter)`, then retry the
|
|
25
|
+
// agent. The user sees nothing terminal โ kj just pauses. A SIGINT /
|
|
26
|
+
// SIGTERM during the wait prints `kj standby resume <id>` and exits
|
|
27
|
+
// cleanly. For waits longer than this the process exits straight
|
|
28
|
+
// away (a multi-day weekly cap is not worth keeping kj running).
|
|
29
|
+
standbyWaitHoursMax: DEFAULT_FALLBACK_WAIT_HOURS,
|
|
23
30
|
classes: {
|
|
24
31
|
[ERROR_CLASS.RATE_LIMIT_SHORT]: { mode: "standby", maxRetries: 3 },
|
|
25
32
|
[ERROR_CLASS.QUOTA_EXHAUSTED_DAILY]: { mode: "hibernate", maxRetries: 1, fallbackEligible: true },
|
|
@@ -132,10 +139,15 @@ export async function withBrainRecovery({
|
|
|
132
139
|
}
|
|
133
140
|
}
|
|
134
141
|
|
|
135
|
-
// HIBERNATE:
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
142
|
+
// HIBERNATE: the cooldown is too long for a short standby. Persist
|
|
143
|
+
// the snapshot first so a SIGINT / SIGTERM during the wait leaves a
|
|
144
|
+
// recoverable session on disk. Then either:
|
|
145
|
+
// (a) wait in-process until cooldownUntil and retry (the common
|
|
146
|
+
// case โ daily quotas reset in a few hours), or
|
|
147
|
+
// (b) when the wait would exceed `standbyWaitHoursMax`, return
|
|
148
|
+
// action="hibernate" so the caller exits and the user
|
|
149
|
+
// resumes later with `kj standby resume <id>` (weekly /
|
|
150
|
+
// monthly caps).
|
|
139
151
|
if (classPolicy.mode === "hibernate" && cls.retryAfter && cls.retryAfter > policy.hibernateThresholdMs) {
|
|
140
152
|
let standbyFile = null;
|
|
141
153
|
if (sessionState && sessionState.sessionId) {
|
|
@@ -147,13 +159,58 @@ export async function withBrainRecovery({
|
|
|
147
159
|
role, provider: effectiveProvider,
|
|
148
160
|
classifierMessage: cls.message,
|
|
149
161
|
});
|
|
150
|
-
logger?.info?.(`[brain] hibernated session ${sessionState.sessionId} โ ${standbyFile} (resume at ${cls.retryUntil})`);
|
|
151
162
|
} catch (err) {
|
|
152
163
|
logger?.warn?.(`[brain] failed to persist standby: ${err.message}`);
|
|
153
164
|
}
|
|
154
165
|
}
|
|
155
|
-
|
|
156
|
-
|
|
166
|
+
|
|
167
|
+
const maxWaitMs = (policy.standbyWaitHoursMax ?? DEFAULT_FALLBACK_WAIT_HOURS) * ONE_HOUR;
|
|
168
|
+
const tooLongToWait = cls.retryAfter > maxWaitMs;
|
|
169
|
+
|
|
170
|
+
if (tooLongToWait) {
|
|
171
|
+
logger?.info?.(`[brain] hibernated session ${sessionState?.sessionId ?? "(no id)"} โ ${standbyFile} (resume at ${cls.retryUntil}; wait > ${Math.round(maxWaitMs/ONE_HOUR)}h, exiting)`);
|
|
172
|
+
emit(emitter, "brain:hibernate-request", eventBase, { role, class: cls.class, retryUntil: cls.retryUntil, retryAfter: cls.retryAfter, standbyFile });
|
|
173
|
+
return { ok: false, action: "hibernate", recovery: cls, standbyFile };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Standby-in-process: keep kj alive, sleep until the cooldown,
|
|
177
|
+
// then retry. A SIGINT / SIGTERM during the wait prints the
|
|
178
|
+
// resume command and exits โ the standby JSON is already on
|
|
179
|
+
// disk so the user can pick it up later.
|
|
180
|
+
const sid = sessionState?.sessionId ?? null;
|
|
181
|
+
const onSignal = (signal) => {
|
|
182
|
+
// eslint-disable-next-line no-console
|
|
183
|
+
console.log(`\nโธ Standby interrupted (${signal}). Resume with:`);
|
|
184
|
+
// eslint-disable-next-line no-console
|
|
185
|
+
if (sid) console.log(` kj standby resume ${sid}`);
|
|
186
|
+
// eslint-disable-next-line no-console
|
|
187
|
+
else console.log(` kj standby list (no session id was assigned)`);
|
|
188
|
+
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
189
|
+
};
|
|
190
|
+
const detach = () => {
|
|
191
|
+
process.removeListener("SIGINT", onSignal);
|
|
192
|
+
process.removeListener("SIGTERM", onSignal);
|
|
193
|
+
};
|
|
194
|
+
process.once("SIGINT", onSignal);
|
|
195
|
+
process.once("SIGTERM", onSignal);
|
|
196
|
+
|
|
197
|
+
const hours = Math.round((cls.retryAfter / ONE_HOUR) * 10) / 10;
|
|
198
|
+
logger?.info?.(`[brain] ${role}: standby for ~${hours}h until ${cls.retryUntil} (Ctrl+C to detach; will resume with 'kj standby resume ${sid}').`);
|
|
199
|
+
emit(emitter, "brain:standby-wait", eventBase, { role, class: cls.class, waitMs: cls.retryAfter, retryUntil: cls.retryUntil, standbyFile });
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
await sleepFn(cls.retryAfter);
|
|
203
|
+
} finally {
|
|
204
|
+
detach();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Wait finished without an interrupt โ mark the standby JSON
|
|
208
|
+
// done so it does not auto-resume from disk later.
|
|
209
|
+
if (sid) {
|
|
210
|
+
try { markStandbyDone(sid); } catch { /* ignore */ }
|
|
211
|
+
}
|
|
212
|
+
emit(emitter, "brain:standby-resumed", eventBase, { role, class: cls.class });
|
|
213
|
+
continue;
|
|
157
214
|
}
|
|
158
215
|
|
|
159
216
|
// STANDBY: espera hasta cooldownUntil (capado por longStandbyCapMs).
|
|
@@ -11,6 +11,8 @@ import { applyReviewerFeedback, applyFixerPatch } from "../../plan/plan-fixer.js
|
|
|
11
11
|
import { runStructuralPass } from "../../plan/plan-structural-pass.js";
|
|
12
12
|
import { recommendModelsForHu, complexityFromTaskType } from "../../hu/model-router.js";
|
|
13
13
|
import { withBrainRecovery } from "../../brain/with-brain-recovery.js";
|
|
14
|
+
import { buildStandbyState } from "../../brain/standby-store.js";
|
|
15
|
+
import { printResumeHint } from "../../utils/display/resume-hint.js";
|
|
14
16
|
import { formatPlan, formatHuTable } from "./_shared.js";
|
|
15
17
|
|
|
16
18
|
/**
|
|
@@ -61,6 +63,13 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
|
|
|
61
63
|
// self-fix iter N por delante.
|
|
62
64
|
const progress = createCliProgressReporter({ role: "planner:initial", quiet: Boolean(json) });
|
|
63
65
|
let result;
|
|
66
|
+
// `kj plan` has no pipeline session, so synthesise a standby id
|
|
67
|
+
// (`plan_<stamp>`). If the planner hits a quota cap, withBrainRecovery
|
|
68
|
+
// persists it and `kj standby resume` re-spawns via the saved argv.
|
|
69
|
+
const plannerSessionState = buildStandbyState({
|
|
70
|
+
session: { id: `plan_${new Date().toISOString().replaceAll(/[:.]/g, "-")}` },
|
|
71
|
+
config,
|
|
72
|
+
});
|
|
64
73
|
try {
|
|
65
74
|
// KJC-TSK-0413: planner inicial pasa por Brain Recovery โ clasifica
|
|
66
75
|
// 401/429/5xx/SILENCED/etc y decide retry/standby/hibernate/abort.
|
|
@@ -69,6 +78,7 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
|
|
|
69
78
|
taskArgs: { prompt, role: "planner", silenceTimeoutMs, timeoutMs, onOutput: progress.onOutput },
|
|
70
79
|
role: "planner",
|
|
71
80
|
logger,
|
|
81
|
+
sessionState: plannerSessionState,
|
|
72
82
|
});
|
|
73
83
|
progress.finish(result.ok ? "done" : (result.action || "failed"));
|
|
74
84
|
} catch (err) {
|
|
@@ -76,6 +86,18 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
|
|
|
76
86
|
throw err;
|
|
77
87
|
}
|
|
78
88
|
|
|
89
|
+
if (!result.ok && result.action === "hibernate") {
|
|
90
|
+
// Quota cap: Brain Recovery persisted a standby snapshot. Stop
|
|
91
|
+
// cleanly with a resume hint instead of throwing a generic error.
|
|
92
|
+
const hibResult = {
|
|
93
|
+
ok: false, hibernated: true,
|
|
94
|
+
sessionId: plannerSessionState?.sessionId || null,
|
|
95
|
+
standbyFile: result.standbyFile || null,
|
|
96
|
+
};
|
|
97
|
+
if (!json) printResumeHint(hibResult);
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
return hibResult;
|
|
100
|
+
}
|
|
79
101
|
if (!result.ok) throw new Error(result.error || result.output || "Planner failed");
|
|
80
102
|
|
|
81
103
|
const parsed = parseMaybeJsonString(result.output);
|
package/src/commands/resume.js
CHANGED
|
@@ -3,6 +3,8 @@ import readline from "node:readline";
|
|
|
3
3
|
import { resumeFlow } from "../orchestrator.js";
|
|
4
4
|
import { createActivityLog } from "../activity-log.js";
|
|
5
5
|
import { printEvent } from "../utils/display/event-handlers.js";
|
|
6
|
+
import { withCliRunLog } from "../utils/cli-run-log.js";
|
|
7
|
+
import { registerRun, unregisterRun } from "../utils/run-registry.js";
|
|
6
8
|
|
|
7
9
|
function createCliAskQuestion(opts = {}) {
|
|
8
10
|
const { sessionId = null } = opts;
|
|
@@ -51,36 +53,55 @@ export async function resumeCommand({ sessionId, answer, config, logger, flags }
|
|
|
51
53
|
const jsonMode = flags?.json;
|
|
52
54
|
const quietMode = config.output?.quiet !== false;
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
// Same wrapper as every other CLI command โ without it `.kj/run.log`
|
|
57
|
+
// is never opened during the resume and `kj-tail` stays silent
|
|
58
|
+
// (every line of the resumed run goes only to the per-session
|
|
59
|
+
// activity log + the local console). Also registers the run so the
|
|
60
|
+
// HU Board shows it as live and can offer its Stop button.
|
|
61
|
+
return withCliRunLog("resume", { projectDir: config?.projectDir, logger }, async ({ forwardProgress }) => {
|
|
62
|
+
const runId = registerRun({
|
|
63
|
+
pid: process.pid,
|
|
64
|
+
planId: flags?.plan || null,
|
|
65
|
+
huIds: null,
|
|
66
|
+
projectDir: config?.projectDir || process.cwd(),
|
|
67
|
+
source: "cli-resume",
|
|
68
|
+
});
|
|
69
|
+
const cleanupRegistry = () => { if (runId) unregisterRun(runId); };
|
|
70
|
+
process.once("exit", cleanupRegistry);
|
|
71
|
+
process.once("SIGTERM", () => { cleanupRegistry(); process.exit(143); });
|
|
72
|
+
process.once("SIGINT", () => { cleanupRegistry(); process.exit(130); });
|
|
56
73
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
logger.onLog((entry) => activityLog.write(entry));
|
|
61
|
-
}
|
|
74
|
+
const emitter = new EventEmitter();
|
|
75
|
+
let activityLog = null;
|
|
76
|
+
forwardProgress(emitter);
|
|
62
77
|
|
|
63
|
-
|
|
64
|
-
activityLog.
|
|
65
|
-
|
|
78
|
+
emitter.on("progress", (event) => {
|
|
79
|
+
if (!activityLog && event.sessionId) {
|
|
80
|
+
activityLog = createActivityLog(event.sessionId);
|
|
81
|
+
logger.onLog((entry) => activityLog.write(entry));
|
|
82
|
+
}
|
|
83
|
+
if (activityLog) {
|
|
84
|
+
activityLog.writeEvent(event);
|
|
85
|
+
}
|
|
86
|
+
if (!jsonMode) {
|
|
87
|
+
printEvent(event, { quiet: quietMode });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
66
90
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
91
|
+
const askQuestion = createCliAskQuestion();
|
|
92
|
+
const result = await resumeFlow({
|
|
93
|
+
sessionId,
|
|
94
|
+
answer: answer || null,
|
|
95
|
+
config,
|
|
96
|
+
logger,
|
|
97
|
+
flags: flags || {},
|
|
98
|
+
emitter,
|
|
99
|
+
askQuestion
|
|
100
|
+
});
|
|
71
101
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
config,
|
|
77
|
-
logger,
|
|
78
|
-
flags: flags || {},
|
|
79
|
-
emitter,
|
|
80
|
-
askQuestion
|
|
102
|
+
if (jsonMode || !answer) {
|
|
103
|
+
console.log(JSON.stringify(result, null, 2));
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
81
106
|
});
|
|
82
|
-
|
|
83
|
-
if (jsonMode || !answer) {
|
|
84
|
-
console.log(JSON.stringify(result, null, 2));
|
|
85
|
-
}
|
|
86
107
|
}
|
package/src/commands/run.js
CHANGED
|
@@ -7,6 +7,7 @@ import { withCliRunLog } from "../utils/cli-run-log.js";
|
|
|
7
7
|
import { registerRun, unregisterRun } from "../utils/run-registry.js";
|
|
8
8
|
import { printHeader } from "../utils/display/header.js";
|
|
9
9
|
import { printEvent } from "../utils/display/event-handlers.js";
|
|
10
|
+
import { printResumeHint } from "../utils/display/resume-hint.js";
|
|
10
11
|
import { resolveRole } from "../config.js";
|
|
11
12
|
import { parseCardId } from "../planning-game/adapter.js";
|
|
12
13
|
import { confirmCwd } from "../utils/cwd-confirm.js";
|
|
@@ -175,6 +176,10 @@ export async function runCommandHandler({ task, config, logger, flags }) {
|
|
|
175
176
|
|
|
176
177
|
if (jsonMode) {
|
|
177
178
|
console.log(JSON.stringify(result, null, 2));
|
|
179
|
+
} else {
|
|
180
|
+
// Last line printed: if the run stopped (hibernated / paused /
|
|
181
|
+
// failed), tell the user the exact command to resume it.
|
|
182
|
+
printResumeHint(result);
|
|
178
183
|
}
|
|
179
184
|
return { ok: !result?.paused && result?.approved !== false };
|
|
180
185
|
});
|
package/src/config/loader.js
CHANGED
|
@@ -28,6 +28,30 @@ import { safeParseConfig, formatConfigIssues } from "./schema.js";
|
|
|
28
28
|
import { DEFAULTS } from "./defaults.js";
|
|
29
29
|
import { resolveTestHarness } from "./test-harness.js";
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Parse a YAML config file with an actionable error message.
|
|
33
|
+
* Plain `yaml.load` throws `YAMLException: bad indentation ...` with NO
|
|
34
|
+
* file path, so a single bad edit bricked the entire CLI (including
|
|
35
|
+
* `kj doctor`, the one command meant to diagnose it). Wrapping every
|
|
36
|
+
* read site in the same prefix means the user always knows which file
|
|
37
|
+
* they need to fix.
|
|
38
|
+
* @param {string} raw - file contents
|
|
39
|
+
* @param {string} filePath - absolute path (for the error message)
|
|
40
|
+
* @returns {object}
|
|
41
|
+
*/
|
|
42
|
+
function parseYamlOrThrow(raw, filePath) {
|
|
43
|
+
try {
|
|
44
|
+
return yaml.load(raw, { json: true }) || {};
|
|
45
|
+
} catch (err) {
|
|
46
|
+
const detail = err?.message || String(err);
|
|
47
|
+
const e = new Error(`Invalid YAML in ${filePath}:\n ${detail}`);
|
|
48
|
+
e.cause = err;
|
|
49
|
+
e.code = "INVALID_YAML";
|
|
50
|
+
e.path = filePath;
|
|
51
|
+
throw e;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
31
55
|
export function mergeDeep(base, override) {
|
|
32
56
|
const output = { ...base };
|
|
33
57
|
for (const [key, value] of Object.entries(override || {})) {
|
|
@@ -56,7 +80,7 @@ export async function loadProjectConfig(projectDir = process.cwd()) {
|
|
|
56
80
|
return null;
|
|
57
81
|
}
|
|
58
82
|
const raw = await fs.readFile(projectConfigPath, "utf8");
|
|
59
|
-
return
|
|
83
|
+
return parseYamlOrThrow(raw, projectConfigPath);
|
|
60
84
|
}
|
|
61
85
|
|
|
62
86
|
async function loadProjectPricingOverrides(projectDir = process.cwd()) {
|
|
@@ -66,7 +90,7 @@ async function loadProjectPricingOverrides(projectDir = process.cwd()) {
|
|
|
66
90
|
}
|
|
67
91
|
|
|
68
92
|
const raw = await fs.readFile(projectConfigPath, "utf8");
|
|
69
|
-
const parsed =
|
|
93
|
+
const parsed = parseYamlOrThrow(raw, projectConfigPath);
|
|
70
94
|
const pricing = parsed?.budget?.pricing;
|
|
71
95
|
if (!pricing || typeof pricing !== "object") {
|
|
72
96
|
return null;
|
|
@@ -84,7 +108,7 @@ export async function loadConfig(projectDir) {
|
|
|
84
108
|
const globalExists = await exists(configPath);
|
|
85
109
|
if (globalExists) {
|
|
86
110
|
const raw = await fs.readFile(configPath, "utf8");
|
|
87
|
-
globalConfig =
|
|
111
|
+
globalConfig = parseYamlOrThrow(raw, configPath);
|
|
88
112
|
}
|
|
89
113
|
|
|
90
114
|
// Load project config (.karajan/kj.config.yml)
|