karajan-code 2.10.2 → 2.11.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 +3 -1
- package/package.json +1 -1
- package/src/audit/sonar-findings.js +12 -0
- package/src/cli/register-meta.js +4 -4
- package/src/cli/register-pipeline.js +3 -3
- package/src/cli/register-plan.js +1 -1
- package/src/commands/board.js +28 -4
- package/src/commands/init.js +24 -5
- package/src/config/loader.js +32 -1
- package/src/git/automation.js +94 -4
- package/src/git/hu-automation.js +35 -3
- package/src/orchestrator/config-init.js +17 -0
- package/src/orchestrator/drivers/post-loop.js +31 -2
- package/src/orchestrator/flow-runner.js +41 -0
- package/src/orchestrator/stages/sonar-stage.js +19 -0
- package/src/skills/addyosmani-catalog.js +29 -7
- package/src/sonar/project-key.js +37 -0
- package/src/types/session.js +2 -1
- package/src/utils/display/session-summary.js +12 -2
- package/src/utils/git.js +105 -1
package/README.md
CHANGED
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
> **v2.
|
|
26
|
+
> **v2.11.0 released** — Dogfooding pass release. Two-day pass through a 10-level test plan surfaced and fixed a long tail of UX papercuts and three latent bugs that only show up on fresh `/tmp` repos: the `SonarStage` no longer loops on remoteless projects (was burning iterations until `max_iterations`-fallback-approval), the post-loop `commitAll` now tolerates the locale-specific "nothing to commit" race, the HU sub-pipeline branches off `master`/`HEAD` when the configured `main` doesn't exist, and `runFlow` now seals `session.status` at the boundary so `kj status` never shows zombi `running` runs again. Plus `hu-board` gains automatic ephemeral-project cleanup and an in-UI help modal for the five views. 14 PRs (#624–#637), 4452/4452 tests passing. Safe upgrade from 2.10.2.
|
|
27
|
+
>
|
|
28
|
+
> **v2.10.2** — Patch release. `kj init` wizard expanded from 9 prompts to a full setup: per-role provider selection (10 roles, "inherit / pick CLI / disable"), automatic SonarQube token generation via REST API (no more web UI walkthrough), git automation flags (`auto_commit/push/pr`) and HU Board security (bind host + port). +16 new tests. Safe upgrade from 2.10.1.
|
|
27
29
|
>
|
|
28
30
|
> **v2.10.1** — Patch release. One-line fix for a stdout contamination bug in `kj audit --agent-readiness --json` (the `[info]` banner was breaking downstream `jq` pipes), plus polish in the asciinema demo scripts. Safe upgrade from 2.10.0.
|
|
29
31
|
>
|
package/package.json
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { isSonarReachable } from "../sonar/manager.js";
|
|
24
24
|
import { getOpenIssues, getQualityGateStatus } from "../sonar/api.js";
|
|
25
|
+
import { canResolveSonarProjectKey } from "../sonar/project-key.js";
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Collect deterministic Sonar findings for the audit prompt.
|
|
@@ -43,6 +44,17 @@ export async function collectSonarFindings(config, logger = null) {
|
|
|
43
44
|
return { available: false, reason: `sonar host ${host} not reachable` };
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
// No git remote and no explicit project_key → silent skip. This is the
|
|
48
|
+
// typical `kj run` in a fresh /tmp/... folder. Reporting it as a
|
|
49
|
+
// warning treats it like an error (which it isn't — sonar simply
|
|
50
|
+
// doesn't apply to a remoteless repo).
|
|
51
|
+
if (!(await canResolveSonarProjectKey(config))) {
|
|
52
|
+
return {
|
|
53
|
+
available: false,
|
|
54
|
+
reason: "not applicable: no git remote and no sonarqube.project_key configured",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
let issuesResult;
|
|
47
59
|
try {
|
|
48
60
|
issuesResult = await getOpenIssues(config);
|
package/src/cli/register-meta.js
CHANGED
|
@@ -24,7 +24,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
24
24
|
program
|
|
25
25
|
.command("discover")
|
|
26
26
|
.description("Analyze task for gaps, ambiguities and missing info")
|
|
27
|
-
.argument("[task]", "Task description (or
|
|
27
|
+
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
28
28
|
.option("--task-file <path>", "Read the task from a file (e.g. .md)")
|
|
29
29
|
.option("--mode <name>", "Discovery mode: gaps|momtest|wendel|classify|jtbd", "gaps")
|
|
30
30
|
.option("--discover <name>", "Override discover agent")
|
|
@@ -41,7 +41,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
41
41
|
program
|
|
42
42
|
.command("triage")
|
|
43
43
|
.description("Classify task complexity and recommend pipeline roles")
|
|
44
|
-
.argument("[task]", "Task description (or
|
|
44
|
+
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
45
45
|
.option("--task-file <path>", "Read the task from a file (e.g. .md)")
|
|
46
46
|
.option("--triage <name>", "Override triage agent")
|
|
47
47
|
.option("--triage-model <name>", "Override triage model")
|
|
@@ -57,7 +57,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
57
57
|
program
|
|
58
58
|
.command("researcher")
|
|
59
59
|
.description("Research codebase for a task (files, patterns, constraints)")
|
|
60
|
-
.argument("[task]", "Task description (or
|
|
60
|
+
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
61
61
|
.option("--task-file <path>", "Read the task from a file (e.g. .md)")
|
|
62
62
|
.option("--researcher <name>", "Override researcher agent")
|
|
63
63
|
.option("--researcher-model <name>", "Override researcher model")
|
|
@@ -72,7 +72,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
72
72
|
program
|
|
73
73
|
.command("architect")
|
|
74
74
|
.description("Design solution architecture (layers, patterns, contracts)")
|
|
75
|
-
.argument("[task]", "Task description (or
|
|
75
|
+
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
76
76
|
.option("--task-file <path>", "Read the task from a file (e.g. .md)")
|
|
77
77
|
.option("--architect <name>", "Override architect agent")
|
|
78
78
|
.option("--architect-model <name>", "Override architect model")
|
|
@@ -40,7 +40,7 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
40
40
|
program
|
|
41
41
|
.command("run")
|
|
42
42
|
.description("Run coder+sonar+reviewer loop")
|
|
43
|
-
.argument("[task]", "Task description (
|
|
43
|
+
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
44
44
|
.option("--task-file <path>", "Read the task from a file (e.g. .md). Alternative to the positional task argument.")
|
|
45
45
|
.option("-y, --yes", "Skip the cwd confirmation prompt (CI / scripted runs).")
|
|
46
46
|
.option("--planner <name>")
|
|
@@ -135,7 +135,7 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
135
135
|
program
|
|
136
136
|
.command("code")
|
|
137
137
|
.description("Run only coder")
|
|
138
|
-
.argument("[task]", "Task description (or
|
|
138
|
+
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
139
139
|
.option("--task-file <path>", "Read the task from a file (e.g. .md)")
|
|
140
140
|
.option("--coder <name>")
|
|
141
141
|
.option("--coder-model <name>")
|
|
@@ -150,7 +150,7 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
150
150
|
program
|
|
151
151
|
.command("review")
|
|
152
152
|
.description("Run only reviewer")
|
|
153
|
-
.argument("[task]", "Task description (or
|
|
153
|
+
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
154
154
|
.option("--task-file <path>", "Read the task from a file (e.g. .md)")
|
|
155
155
|
.option("--reviewer <name>")
|
|
156
156
|
.option("--reviewer-model <name>")
|
package/src/cli/register-plan.js
CHANGED
|
@@ -26,7 +26,7 @@ export function registerPlan(program, { pkgVersion }) {
|
|
|
26
26
|
plan
|
|
27
27
|
.command("generate", { isDefault: true })
|
|
28
28
|
.description("Generate implementation plan with HUs")
|
|
29
|
-
.argument("[task]", "Task description (or
|
|
29
|
+
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
30
30
|
.option("--task-file <path>", "Read the task from a file (e.g. .md)")
|
|
31
31
|
.option("--planner <name>")
|
|
32
32
|
.option("--planner-model <name>")
|
package/src/commands/board.js
CHANGED
|
@@ -126,14 +126,29 @@ export async function startBoard(desiredPort = 4000, opts = {}) {
|
|
|
126
126
|
const karajanHome = getKarajanHome();
|
|
127
127
|
fs.mkdirSync(karajanHome, { recursive: true });
|
|
128
128
|
|
|
129
|
+
// Open a log file for the daemon. Pre-fix the daemon was spawned
|
|
130
|
+
// with `stdio: "ignore"`, so every `console.log` from server.js
|
|
131
|
+
// (including the new [zombie-reaper] messages) went straight to
|
|
132
|
+
// /dev/null and the user had no way to see what was happening.
|
|
133
|
+
// The file is opened in append-only mode so successive starts
|
|
134
|
+
// don't truncate the history.
|
|
135
|
+
const logPath = path.join(karajanHome, "hu-board.log");
|
|
136
|
+
const logFd = fs.openSync(logPath, "a");
|
|
137
|
+
fs.writeSync(
|
|
138
|
+
logFd,
|
|
139
|
+
`\n--- kj board start at ${new Date().toISOString()} (port ${port}, bind ${bind}) ---\n`,
|
|
140
|
+
);
|
|
141
|
+
|
|
129
142
|
// Use process.execPath (absolute path to current node binary) instead of "node".
|
|
130
143
|
// Fixes ENOENT on nvm setups where `node` is not in the spawned process's PATH.
|
|
131
144
|
const child = spawn(process.execPath, [serverPath], {
|
|
132
145
|
env: { ...process.env, PORT: String(port), BIND_HOST: bind },
|
|
133
146
|
detached: true,
|
|
134
|
-
stdio: "ignore",
|
|
147
|
+
stdio: ["ignore", logFd, logFd],
|
|
135
148
|
cwd: BOARD_DIR
|
|
136
149
|
});
|
|
150
|
+
// The parent doesn't need the fd anymore — the child inherited it.
|
|
151
|
+
fs.closeSync(logFd);
|
|
137
152
|
// Swallow spawn errors so an HU Board failure never crashes the pipeline.
|
|
138
153
|
child.on("error", () => { /* non-blocking — caller logs via tryAutoStartBoard catch */ });
|
|
139
154
|
if (!child.pid) {
|
|
@@ -143,10 +158,16 @@ export async function startBoard(desiredPort = 4000, opts = {}) {
|
|
|
143
158
|
// Detach for real: `detached: true` only sets up the new session;
|
|
144
159
|
// without `unref()` the parent's event loop still holds a reference
|
|
145
160
|
// to the child handle and `kj board start` hangs at the prompt
|
|
146
|
-
// instead of returning.
|
|
147
|
-
// to ensure the parent isn't waiting on any of the child's pipes.
|
|
161
|
+
// instead of returning.
|
|
148
162
|
child.unref();
|
|
149
|
-
return {
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
alreadyRunning: false,
|
|
166
|
+
pid: child.pid,
|
|
167
|
+
url: buildBoardUrl(port, projectSlug),
|
|
168
|
+
port,
|
|
169
|
+
logPath,
|
|
170
|
+
};
|
|
150
171
|
}
|
|
151
172
|
|
|
152
173
|
export async function stopBoard(opts = {}) {
|
|
@@ -273,6 +294,9 @@ export async function boardCommand({ action = "start", port = 4000, bind = "127.
|
|
|
273
294
|
logger.info(`HU Board already running (PID ${result.pid}) at ${result.url}`);
|
|
274
295
|
} else {
|
|
275
296
|
logger.info(`HU Board started (PID ${result.pid}) at ${result.url}`);
|
|
297
|
+
if (result.logPath) {
|
|
298
|
+
logger.info(`Daemon logs: tail -f ${result.logPath}`);
|
|
299
|
+
}
|
|
276
300
|
if (bind !== "127.0.0.1" && bind !== "localhost") {
|
|
277
301
|
logger.warn(`HU Board bound to ${bind} — token auth enforced for non-loopback peers. Token: ~/.karajan/hu-board/token`);
|
|
278
302
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -277,6 +277,22 @@ async function runWizard(config, logger) {
|
|
|
277
277
|
return config;
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Backwards-compatible alias for `writeConfig`. Pre-KJC-BUG-0036 this
|
|
282
|
+
* wrapper was the *only* writer that stripped `sonarqube.enabled`
|
|
283
|
+
* before serialising — we needed it because the global `writeConfig`
|
|
284
|
+
* would happily persist the wizard's transient hint. KJC-BUG-0036
|
|
285
|
+
* moved that strip (and the equivalent `_deprecated` strip) into
|
|
286
|
+
* `writeConfig` itself, so this is now a thin alias kept around to
|
|
287
|
+
* keep callers and tests stable. Prefer `writeConfig` in new code.
|
|
288
|
+
*
|
|
289
|
+
* @param {string} configPath
|
|
290
|
+
* @param {object} config
|
|
291
|
+
*/
|
|
292
|
+
export async function writeInitConfig(configPath, config) {
|
|
293
|
+
await writeConfig(configPath, config);
|
|
294
|
+
}
|
|
295
|
+
|
|
280
296
|
async function handleConfigSetup({ config, configExists, interactive, configPath, logger }) {
|
|
281
297
|
if (configExists && interactive) {
|
|
282
298
|
const wizard = createWizard();
|
|
@@ -284,7 +300,7 @@ async function handleConfigSetup({ config, configExists, interactive, configPath
|
|
|
284
300
|
const reconfigure = await wizard.confirm("Config already exists. Reconfigure?", false);
|
|
285
301
|
if (reconfigure) {
|
|
286
302
|
const updated = await runWizard(config, logger);
|
|
287
|
-
await
|
|
303
|
+
await writeInitConfig(configPath, updated);
|
|
288
304
|
logger.info(`Updated ${configPath}`);
|
|
289
305
|
} else {
|
|
290
306
|
logger.info("Keeping existing config.");
|
|
@@ -294,10 +310,10 @@ async function handleConfigSetup({ config, configExists, interactive, configPath
|
|
|
294
310
|
}
|
|
295
311
|
} else if (!configExists && interactive) {
|
|
296
312
|
const updated = await runWizard(config, logger);
|
|
297
|
-
await
|
|
313
|
+
await writeInitConfig(configPath, updated);
|
|
298
314
|
logger.info(`Created ${configPath}`);
|
|
299
315
|
} else if (!configExists) {
|
|
300
|
-
await
|
|
316
|
+
await writeInitConfig(configPath, config);
|
|
301
317
|
logger.info(`Created ${configPath}`);
|
|
302
318
|
}
|
|
303
319
|
}
|
|
@@ -561,7 +577,7 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
561
577
|
config.development.methodology = config.development.methodology || "tdd";
|
|
562
578
|
}
|
|
563
579
|
|
|
564
|
-
await
|
|
580
|
+
await writeInitConfig(configPath, config);
|
|
565
581
|
} else {
|
|
566
582
|
logger.info("No project stack detected — using default configuration.");
|
|
567
583
|
}
|
|
@@ -582,7 +598,10 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
582
598
|
await scaffoldCiGateway(config, flags, logger);
|
|
583
599
|
|
|
584
600
|
// Persist any changes setupSonarQube made (token, etc.) back to disk.
|
|
585
|
-
|
|
601
|
+
// Use writeInitConfig so the deprecated `sonarqube.enabled` key —
|
|
602
|
+
// which setupSonarQube still mutates as an in-memory hint — never
|
|
603
|
+
// reaches the YAML file.
|
|
604
|
+
await writeInitConfig(configPath, config);
|
|
586
605
|
|
|
587
606
|
// Telemetry: anonymous install event (non-blocking)
|
|
588
607
|
const { sendTelemetryEvent } = await import("../utils/telemetry.js");
|
package/src/config/loader.js
CHANGED
|
@@ -128,9 +128,40 @@ export async function loadConfig(projectDir) {
|
|
|
128
128
|
return { config: merged, path: configPath, exists: globalExists, hasProjectConfig: !!projectConfig };
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Runtime-only keys that the loader synthesises on every load (or that
|
|
133
|
+
* a wizard uses as transient in-memory hints). They MUST NOT survive
|
|
134
|
+
* to the on-disk YAML — otherwise the `kj run` deprecation warning
|
|
135
|
+
* fossilises (KJC-BUG-0036, observed in dogfooding 2026-05-07: the
|
|
136
|
+
* `_deprecated.sonarqubeEnabledKey: true` block ended up baked into
|
|
137
|
+
* `~/.karajan/kj.config.yml` and re-fired the warning on every run
|
|
138
|
+
* even after the user cleaned `sonarqube.enabled`).
|
|
139
|
+
*
|
|
140
|
+
* Pure: returns a shallow-copied config without the runtime keys —
|
|
141
|
+
* the caller's object is left untouched.
|
|
142
|
+
*/
|
|
143
|
+
function stripRuntimeOnlyKeys(config) {
|
|
144
|
+
if (!config || typeof config !== "object") return config;
|
|
145
|
+
const out = { ...config };
|
|
146
|
+
// _deprecated is loader-synthesised — never written intentionally.
|
|
147
|
+
if ("_deprecated" in out) delete out._deprecated;
|
|
148
|
+
// sonarqube.enabled was deprecated in v2.7.4. The init wizard still
|
|
149
|
+
// uses it as a hint to drive setupSonarQube during install, but the
|
|
150
|
+
// runtime ignores it. Persisting it would re-trigger the deprecation
|
|
151
|
+
// warning on every subsequent kj run.
|
|
152
|
+
if (out.sonarqube && typeof out.sonarqube === "object"
|
|
153
|
+
&& Object.prototype.hasOwnProperty.call(out.sonarqube, "enabled")) {
|
|
154
|
+
const { enabled: _drop, ...rest } = out.sonarqube;
|
|
155
|
+
void _drop;
|
|
156
|
+
out.sonarqube = rest;
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
131
161
|
export async function writeConfig(configPath, config) {
|
|
132
162
|
await ensureDir(path.dirname(configPath));
|
|
133
|
-
|
|
163
|
+
const sanitized = stripRuntimeOnlyKeys(config);
|
|
164
|
+
await fs.writeFile(configPath, yaml.dump(sanitized, { lineWidth: 120 }), "utf8");
|
|
134
165
|
}
|
|
135
166
|
|
|
136
167
|
// Declarative mappings for applyRunOverrides to reduce cognitive complexity.
|
package/src/git/automation.js
CHANGED
|
@@ -15,14 +15,88 @@ import {
|
|
|
15
15
|
buildBranchName,
|
|
16
16
|
commitAll,
|
|
17
17
|
pushBranch,
|
|
18
|
-
createPullRequest
|
|
18
|
+
createPullRequest,
|
|
19
|
+
listPendingPaths
|
|
19
20
|
} from "../utils/git.js";
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Karajan writes / updates several internal directories during a pipeline
|
|
24
|
+
* run (the workspace scaffold + the session journal). When they're the
|
|
25
|
+
* ONLY thing pending at finalization, the post-loop commit shouldn't
|
|
26
|
+
* inherit the user task's `feat:` message — it should be a clear
|
|
27
|
+
* `chore: scaffold karajan workspace` so `git log --oneline` reads:
|
|
28
|
+
*
|
|
29
|
+
* 2d770aa docs: add JSDoc comment to index.js
|
|
30
|
+
* abc1234 chore: scaffold karajan workspace
|
|
31
|
+
* 8a95ef0 initial
|
|
32
|
+
*
|
|
33
|
+
* A path counts as scaffolding when it lives at one of these prefixes
|
|
34
|
+
* OR is the project's root `.gitignore` (we extend it to include
|
|
35
|
+
* stack-specific entries during triage).
|
|
36
|
+
*/
|
|
37
|
+
const KARAJAN_SCAFFOLD_PREFIXES = [
|
|
38
|
+
".karajan/",
|
|
39
|
+
".reviews/",
|
|
40
|
+
".kj/",
|
|
41
|
+
".agent/",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function isKarajanScaffoldPath(p) {
|
|
45
|
+
if (p === ".gitignore") return true;
|
|
46
|
+
return KARAJAN_SCAFFOLD_PREFIXES.some((prefix) => p.startsWith(prefix));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string[]} paths
|
|
51
|
+
* @returns {boolean} true when every pending path is a Karajan internal
|
|
52
|
+
*/
|
|
53
|
+
export function pendingPathsAreOnlyKarajanScaffold(paths) {
|
|
54
|
+
if (!Array.isArray(paths) || paths.length === 0) return false;
|
|
55
|
+
return paths.every(isKarajanScaffoldPath);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Map the taskType (as classified by triage) to the matching
|
|
60
|
+
* Conventional Commits prefix. When triage hasn't run or the type is
|
|
61
|
+
* unknown we default to `feat:` (the pre-fix behaviour).
|
|
62
|
+
*/
|
|
63
|
+
const TASK_TYPE_TO_PREFIX = {
|
|
64
|
+
doc: "docs",
|
|
65
|
+
"add-tests": "test",
|
|
66
|
+
refactor: "refactor",
|
|
67
|
+
infra: "chore",
|
|
68
|
+
sw: "feat",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function prefixForTaskType(taskType) {
|
|
72
|
+
return TASK_TYPE_TO_PREFIX[taskType] || "feat";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function commitMessageFromTask(task, taskType = null) {
|
|
22
76
|
const clean = String(task || "")
|
|
23
77
|
.replaceAll(/\s+/g, " ")
|
|
24
78
|
.trim();
|
|
25
|
-
|
|
79
|
+
const prefix = prefixForTaskType(taskType);
|
|
80
|
+
return `${prefix}: ${clean.slice(0, 72) || "karajan update"}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the message used by `finalizeGitAutomation` for the post-loop
|
|
85
|
+
* commit. Two paths:
|
|
86
|
+
* - When the only pending changes are Karajan scaffold files
|
|
87
|
+
* (.gitignore, .karajan/, .reviews/, ...), use a fixed
|
|
88
|
+
* `chore: scaffold karajan workspace` message. This avoids the
|
|
89
|
+
* "duplicate feat:" git log seen when triage extends .gitignore
|
|
90
|
+
* for a freshly detected stack and the coder already committed
|
|
91
|
+
* the user-visible change.
|
|
92
|
+
* - Otherwise, fall back to `commitMessageFromTask(task, taskType)`,
|
|
93
|
+
* respecting the type for the prefix.
|
|
94
|
+
*/
|
|
95
|
+
export function buildFinalizeCommitMessage({ task, taskType, pendingPaths }) {
|
|
96
|
+
if (pendingPathsAreOnlyKarajanScaffold(pendingPaths)) {
|
|
97
|
+
return "chore: scaffold karajan workspace";
|
|
98
|
+
}
|
|
99
|
+
return commitMessageFromTask(task, taskType);
|
|
26
100
|
}
|
|
27
101
|
|
|
28
102
|
export async function prepareGitAutomation({ config, task, logger, session }) {
|
|
@@ -173,7 +247,23 @@ export async function incrementalPush({ gitCtx, task, logger, session }) {
|
|
|
173
247
|
export async function finalizeGitAutomation({ config, gitCtx, task, logger, session, stageResults = null }) {
|
|
174
248
|
if (!gitCtx?.enabled) return { git: "disabled", commits: [] };
|
|
175
249
|
|
|
176
|
-
|
|
250
|
+
// Take a snapshot of pending paths BEFORE staging so we can decide
|
|
251
|
+
// whether what's about to be committed is just Karajan scaffolding
|
|
252
|
+
// (in which case we use `chore: scaffold karajan workspace`) or a
|
|
253
|
+
// real, user-visible change (in which case we honour the task's
|
|
254
|
+
// taskType for the Conventional Commits prefix).
|
|
255
|
+
const taskType = stageResults?.triage?.taskType ?? null;
|
|
256
|
+
let pendingPaths = [];
|
|
257
|
+
if (config.git.auto_commit) {
|
|
258
|
+
try {
|
|
259
|
+
pendingPaths = await listPendingPaths();
|
|
260
|
+
} catch { /* best-effort — fall back to default message */ }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const commitMsg =
|
|
264
|
+
config.git.commit_message
|
|
265
|
+
|| buildFinalizeCommitMessage({ task, taskType, pendingPaths });
|
|
266
|
+
|
|
177
267
|
let committed = false;
|
|
178
268
|
const commits = [];
|
|
179
269
|
if (config.git.auto_commit) {
|
package/src/git/hu-automation.js
CHANGED
|
@@ -41,6 +41,34 @@ export function resolveHuBase(story, huBranches, baseBranch) {
|
|
|
41
41
|
return baseBranch;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Resolve to an actually-existing local ref. Tries the preferred branch
|
|
46
|
+
* first, then `main` and `master`, then plain `HEAD` as last resort.
|
|
47
|
+
*
|
|
48
|
+
* Why: N6 plan-flow dogfooding (2026-05-07) on a `git init -q` repo
|
|
49
|
+
* with `init.defaultBranch=master` produced 7 identical warnings:
|
|
50
|
+
* `failed to create branch feat/...-001 from main:
|
|
51
|
+
* fatal: 'main' is not a commit and a branch can't be created`.
|
|
52
|
+
* Every HU then fell back to running on the original branch, which
|
|
53
|
+
* defeats the per-HU isolation the sub-pipeline was supposed to give.
|
|
54
|
+
*
|
|
55
|
+
* The configured base_branch is honoured when it exists; otherwise we
|
|
56
|
+
* pick the most likely default. HEAD always exists once the repo has
|
|
57
|
+
* any commit, so the fallback never throws past the gate.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} preferred
|
|
60
|
+
* @returns {Promise<string>}
|
|
61
|
+
*/
|
|
62
|
+
async function resolveExistingBranchRef(preferred) {
|
|
63
|
+
const candidates = [preferred, "main", "master", "HEAD"]
|
|
64
|
+
.filter((c, i, arr) => c && arr.indexOf(c) === i); // unique, truthy
|
|
65
|
+
for (const ref of candidates) {
|
|
66
|
+
const probe = await runCommand("git", ["rev-parse", "--verify", "--quiet", ref], {});
|
|
67
|
+
if (probe.exitCode === 0) return ref;
|
|
68
|
+
}
|
|
69
|
+
return preferred; // give up; caller will surface the original error
|
|
70
|
+
}
|
|
71
|
+
|
|
44
72
|
/**
|
|
45
73
|
* Create a branch for an HU starting from its resolved base.
|
|
46
74
|
* Returns the branch name created (or null if git automation is disabled).
|
|
@@ -57,17 +85,21 @@ export async function prepareHuBranch({ story, huBranches, config, logger }) {
|
|
|
57
85
|
return null;
|
|
58
86
|
}
|
|
59
87
|
const baseBranch = resolveHuBase(story, huBranches, config.base_branch || "main");
|
|
88
|
+
const effectiveBase = await resolveExistingBranchRef(baseBranch);
|
|
89
|
+
if (effectiveBase !== baseBranch) {
|
|
90
|
+
logger.info(`HU git: configured base '${baseBranch}' not found locally — using '${effectiveBase}' instead`);
|
|
91
|
+
}
|
|
60
92
|
const prefix = config.git?.branch_prefix || "feat/";
|
|
61
93
|
const branchName = buildHuBranchName(prefix, story);
|
|
62
94
|
|
|
63
95
|
// Checkout from the resolved base. Use `git checkout -B` to overwrite if rerun.
|
|
64
|
-
const res = await runCommand("git", ["checkout", "-B", branchName,
|
|
96
|
+
const res = await runCommand("git", ["checkout", "-B", branchName, effectiveBase], {});
|
|
65
97
|
if (res.exitCode !== 0) {
|
|
66
|
-
logger.warn(`HU git: failed to create branch ${branchName} from ${
|
|
98
|
+
logger.warn(`HU git: failed to create branch ${branchName} from ${effectiveBase}: ${res.stderr}`);
|
|
67
99
|
return null;
|
|
68
100
|
}
|
|
69
101
|
huBranches.set(story.id, branchName);
|
|
70
|
-
logger.info(`HU ${story.id}: branch '${branchName}' from '${
|
|
102
|
+
logger.info(`HU ${story.id}: branch '${branchName}' from '${effectiveBase}'`);
|
|
71
103
|
return branchName;
|
|
72
104
|
}
|
|
73
105
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import fs from "node:fs/promises";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { computeBaseRef, setSnapshot } from "../review/diff-generator.js";
|
|
8
|
+
import { revParse } from "../utils/git.js";
|
|
8
9
|
import { buildCoderPrompt } from "../prompts/coder.js";
|
|
9
10
|
import { buildReviewerPrompt } from "../prompts/reviewer.js";
|
|
10
11
|
import { resolveRole } from "../config.js";
|
|
@@ -367,12 +368,28 @@ export async function initializeSession({ task, config, flags, pgTaskId, pgProje
|
|
|
367
368
|
const projectDir = config.projectDir || process.cwd();
|
|
368
369
|
const project_id = projectSlug(projectDir);
|
|
369
370
|
|
|
371
|
+
// Capture HEAD at the very start of the run, separately from base_ref.
|
|
372
|
+
// session_start_sha was being conflated with base_ref (used for `git diff`)
|
|
373
|
+
// — when the user's repo had a single commit and no remote main, base_ref
|
|
374
|
+
// fell through to git's empty-tree SHA. The post-loop journal then read
|
|
375
|
+
// session_start_sha to enumerate "commits made by this run" and got back
|
|
376
|
+
// the entire history, including pre-existing commits. Capturing actual
|
|
377
|
+
// HEAD here makes "what did the run produce?" deterministic and correct.
|
|
378
|
+
// Falls back to baseRef when HEAD doesn't exist (zero-commits repo); in
|
|
379
|
+
// that case "everything since baseRef" still equals "everything the run
|
|
380
|
+
// produced" because there was no prior history.
|
|
381
|
+
let headAtStart;
|
|
382
|
+
try {
|
|
383
|
+
headAtStart = (await revParse("HEAD")) || baseRef;
|
|
384
|
+
} catch { headAtStart = baseRef; }
|
|
385
|
+
|
|
370
386
|
const sessionInit = {
|
|
371
387
|
task,
|
|
372
388
|
project_id,
|
|
373
389
|
config_snapshot: config,
|
|
374
390
|
base_ref: baseRef,
|
|
375
391
|
session_start_sha: baseRef,
|
|
392
|
+
head_at_start: headAtStart,
|
|
376
393
|
last_reviewer_feedback: null,
|
|
377
394
|
repeated_issue_count: 0,
|
|
378
395
|
sonar_retry_count: 0,
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { generateDiff } from "../../review/diff-generator.js";
|
|
25
25
|
import { finalizeGitAutomation } from "../../git/automation.js";
|
|
26
|
+
import { listCommitsBetween } from "../../utils/git.js";
|
|
26
27
|
import { saveSession, markSessionStatus } from "../../session/store.js";
|
|
27
28
|
import {
|
|
28
29
|
setReviewerFeedback, setBudget, setRtkSavings, resetRetryCount,
|
|
@@ -151,6 +152,20 @@ export async function finalizeApprovedSession({ config, gitCtx, task, logger, se
|
|
|
151
152
|
const { writeSummaryJournal: writeSummaryJournalV2 } =
|
|
152
153
|
await import("../../session/journal/summary-writer.js");
|
|
153
154
|
const startedAt = session._startedAt ? new Date(session._startedAt).toISOString() : undefined;
|
|
155
|
+
// 2026-05-07 dogfooding: gitResult.commits only carries the post-loop
|
|
156
|
+
// commit (scaffold), not the coder's own commit. The summary's
|
|
157
|
+
// "Commits" section was therefore misleading — it claimed the run
|
|
158
|
+
// produced one chore commit when in fact the coder had also written
|
|
159
|
+
// a docs/feat commit. Compute the full range from session_start_sha
|
|
160
|
+
// so every commit between then and HEAD shows up in execution order.
|
|
161
|
+
// try/catch (instead of `.catch(...)`) so we tolerate both promise
|
|
162
|
+
// rejection AND a sync return of undefined — the latter happens
|
|
163
|
+
// when a test mocks listCommitsBetween with a plain vi.fn() that
|
|
164
|
+
// resetAllMocks has stripped of its mockResolvedValue.
|
|
165
|
+
let allCommits = [];
|
|
166
|
+
try { allCommits = (await listCommitsBetween(session.head_at_start || session.session_start_sha)) || []; }
|
|
167
|
+
catch { allCommits = gitResult?.commits || []; }
|
|
168
|
+
const summaryCommits = allCommits.length > 0 ? allCommits : (gitResult?.commits || []);
|
|
154
169
|
await writeSummaryJournalV2(journalDir, {
|
|
155
170
|
task: session.task,
|
|
156
171
|
result: "APPROVED",
|
|
@@ -159,7 +174,7 @@ export async function finalizeApprovedSession({ config, gitCtx, task, logger, se
|
|
|
159
174
|
durationMs: Date.now() - (session._startedAt || Date.now()),
|
|
160
175
|
budget: budgetSummary(),
|
|
161
176
|
stages: stageResults,
|
|
162
|
-
commits:
|
|
177
|
+
commits: summaryCommits,
|
|
163
178
|
files: journalFiles,
|
|
164
179
|
startedAt,
|
|
165
180
|
finishedAt: new Date().toISOString(),
|
|
@@ -180,7 +195,21 @@ export async function finalizeApprovedSession({ config, gitCtx, task, logger, se
|
|
|
180
195
|
}
|
|
181
196
|
}
|
|
182
197
|
|
|
183
|
-
|
|
198
|
+
// Mirror the same enriched commit list into the session:end event so the
|
|
199
|
+
// terminal `🏁 Result` banner (printSessionGit) shows every commit the
|
|
200
|
+
// run produced, not just the post-loop scaffold one. We compute again
|
|
201
|
+
// (cheap: one git log call) rather than threading a variable through
|
|
202
|
+
// because the journal block is wrapped in try/catch and may have set
|
|
203
|
+
// `summaryCommits` to a fallback we don't want for the event detail.
|
|
204
|
+
// try/catch tolerates both promise rejection and sync undefined returns
|
|
205
|
+
// (see the journal block above for the detailed rationale).
|
|
206
|
+
let eventCommits = null;
|
|
207
|
+
try { eventCommits = await listCommitsBetween(session.head_at_start || session.session_start_sha); }
|
|
208
|
+
catch { /* leave null — we'll use gitResult unchanged */ }
|
|
209
|
+
const gitForEvent = (Array.isArray(eventCommits) && eventCommits.length > 0)
|
|
210
|
+
? { ...gitResult, commits: eventCommits }
|
|
211
|
+
: gitResult;
|
|
212
|
+
const endDetail = { approved: true, iterations: i, stages: stageResults, git: gitForEvent, budget: budgetSummary(), deferredIssues };
|
|
184
213
|
if (rtkSavings) endDetail.rtk_savings = rtkSavings;
|
|
185
214
|
emitProgress(
|
|
186
215
|
emitter,
|
|
@@ -60,6 +60,45 @@ import { runSecurityStage, runFinalAuditStage } from "./post-loop-stages.js";
|
|
|
60
60
|
// PG card "In Progress" logic moved to src/planning-game/pipeline-adapter.js → initPgAdapter()
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Boundary guard: make sure the session's terminal status reflects what
|
|
65
|
+
* actually happened, even if some inner exit path forgot to call
|
|
66
|
+
* `markSessionStatus`. KJC-BUG-0037 (N1 dogfooding 2026-05-07): runs
|
|
67
|
+
* that took the Brain "max_iterations approved" / "Solomon-after-error
|
|
68
|
+
* approved" / "Brain solomon-approved" exits returned `{approved:true}`
|
|
69
|
+
* upstream but left `session.status = "running"` on disk indefinitely.
|
|
70
|
+
* `kj status` then showed "Pipeline RUNNING" forever and the HU Board
|
|
71
|
+
* carried a perma-zombie.
|
|
72
|
+
*
|
|
73
|
+
* Maps the result shape to a terminal status:
|
|
74
|
+
* approved → "approved"
|
|
75
|
+
* paused → "paused"
|
|
76
|
+
* cancelled → "cancelled"
|
|
77
|
+
* anything else → "failed"
|
|
78
|
+
*
|
|
79
|
+
* Idempotent: only writes when the current status is still "running"
|
|
80
|
+
* (or unset). If an inner path already sealed it correctly, this is a
|
|
81
|
+
* no-op.
|
|
82
|
+
*
|
|
83
|
+
* Safe-by-default: never throws. A failure here must not block the
|
|
84
|
+
* top-level result from being returned to the caller.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} session
|
|
87
|
+
* @param {object} [result]
|
|
88
|
+
*/
|
|
89
|
+
export async function sealSessionStatusIfStillRunning(session, result) {
|
|
90
|
+
try {
|
|
91
|
+
if (!session) return;
|
|
92
|
+
const current = session.status;
|
|
93
|
+
if (current && current !== "running") return;
|
|
94
|
+
let target = "failed";
|
|
95
|
+
if (result?.approved === true) target = "approved";
|
|
96
|
+
else if (result?.paused === true) target = "paused";
|
|
97
|
+
else if (result?.cancelled === true) target = "cancelled";
|
|
98
|
+
await markSessionStatus(session, target);
|
|
99
|
+
} catch { /* non-blocking */ }
|
|
100
|
+
}
|
|
101
|
+
|
|
63
102
|
export async function runFlow(opts) {
|
|
64
103
|
// TSK-0338: isolate per-run state (git/diff runner, projectDir, snapshot)
|
|
65
104
|
// inside an AsyncLocalStorage scope. Two concurrent `runFlow` invocations
|
|
@@ -193,12 +232,14 @@ async function _runFlowInner({ task, config, logger, flags = {}, emitter = null,
|
|
|
193
232
|
// policies + acceptance-tests gate + Sonar + tester translation.
|
|
194
233
|
const huBatch = await runHuBatch({ ctx, task, askQuestion, emitter, logger });
|
|
195
234
|
if (huBatch.handled) {
|
|
235
|
+
await sealSessionStatusIfStillRunning(ctx.session, huBatch.result);
|
|
196
236
|
return huBatch.result;
|
|
197
237
|
}
|
|
198
238
|
|
|
199
239
|
// --- Standard single-task pipeline (1 HU or no HU reviewer) ---
|
|
200
240
|
const result = await runIterationLoop(ctx, { task: ctx.plannedTask, askQuestion, emitter, logger });
|
|
201
241
|
await writeHistoryRecord({ sessionId: ctx.session.id, task, result, logger });
|
|
242
|
+
await sealSessionStatusIfStillRunning(ctx.session, result);
|
|
202
243
|
return result;
|
|
203
244
|
} finally {
|
|
204
245
|
// --- Cleanup auto-installed skills ---
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { emitProgress, makeEvent } from "../../utils/events.js";
|
|
12
12
|
import { invokeSolomon } from "../solomon-escalation.js";
|
|
13
13
|
import { sonarUp, isSonarReachable } from "../../sonar/manager.js";
|
|
14
|
+
import { canResolveSonarProjectKey } from "../../sonar/project-key.js";
|
|
14
15
|
import { addEntries } from "../feedback-queue.js";
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -222,6 +223,24 @@ export async function runSonarStage({ config, logger, emitter, eventBase, sessio
|
|
|
222
223
|
logger.info("SonarQube is ready");
|
|
223
224
|
}
|
|
224
225
|
|
|
226
|
+
// 2026-05-07 (N4 dogfooding): when the working tree has neither a git
|
|
227
|
+
// remote nor an explicit `sonarqube.project_key`, the scanner inside
|
|
228
|
+
// SonarRole would throw "Missing git remote.origin.url" — the Brain
|
|
229
|
+
// logs that as a sonar error, the iteration cap is reached, and the
|
|
230
|
+
// run finalises as "approved" by exhaustion fallback even though no
|
|
231
|
+
// sonar gate ever ran. KJC-TSK-0373 silenced this for the audit
|
|
232
|
+
// collector but the run loop hit it on the parallel code path.
|
|
233
|
+
// Skipping cleanly here means the gate reports "SKIPPED" once and
|
|
234
|
+
// the iteration loop progresses on the next stage.
|
|
235
|
+
if (!await canResolveSonarProjectKey(config)) {
|
|
236
|
+
logger.info("Sonar gate skipped: no git remote and no sonarqube.project_key configured");
|
|
237
|
+
emitProgress(emitter, makeEvent("sonar:end", { ...eventBase, stage: "sonar" }, {
|
|
238
|
+
status: "skip",
|
|
239
|
+
message: "Sonar skipped — no git remote and no sonarqube.project_key configured (run `git remote add origin …` or set `sonarqube.project_key` to enable)"
|
|
240
|
+
}));
|
|
241
|
+
return { action: "ok", stageResult: { gateStatus: "SKIPPED", reason: "no git remote and no sonarqube.project_key configured" } };
|
|
242
|
+
}
|
|
243
|
+
|
|
225
244
|
const sonarRole = new SonarRole({ config, logger, emitter });
|
|
226
245
|
await sonarRole.init({ iteration });
|
|
227
246
|
const sonarStart = Date.now();
|
|
@@ -146,7 +146,7 @@ export async function ensureCatalog(opts = {}) {
|
|
|
146
146
|
* ensureCatalog().
|
|
147
147
|
*
|
|
148
148
|
* @param {{ refreshMs?: number, repoUrl?: string, force?: boolean, logger?: object }} [opts]
|
|
149
|
-
* @returns {Promise<{ok: boolean, action: "cloned"|"pulled"|"fresh"|"skipped", error?: string}>}
|
|
149
|
+
* @returns {Promise<{ok: boolean, action: "cloned"|"pulled"|"reset"|"fresh"|"skipped", error?: string}>}
|
|
150
150
|
*/
|
|
151
151
|
export async function refreshIfStale(opts = {}) {
|
|
152
152
|
const { refreshMs = DEFAULT_REFRESH_MS, repoUrl = REPO_URL, force = false, logger } = opts;
|
|
@@ -171,16 +171,38 @@ export async function refreshIfStale(opts = {}) {
|
|
|
171
171
|
return { ok: true, action: "skipped" };
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
const
|
|
174
|
+
const pullResult = await runCommand(
|
|
175
175
|
"git",
|
|
176
176
|
["-C", root, "pull", "--ff-only", "--depth", "1"],
|
|
177
177
|
{ timeout: 60_000 }
|
|
178
178
|
);
|
|
179
179
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
180
|
+
let action = "pulled";
|
|
181
|
+
if (pullResult.exitCode !== 0) {
|
|
182
|
+
// The cached catalog is read-only (we never commit to it), so a
|
|
183
|
+
// non-fast-forward result is virtually always upstream rewriting
|
|
184
|
+
// history with `git push --force` — see KJC-BUG-0033 / N3-2 (the
|
|
185
|
+
// user hit this on 2026-05-07 when addyosmani force-pushed his
|
|
186
|
+
// skills repo). Recover by fetching shallow and hard-resetting to
|
|
187
|
+
// FETCH_HEAD: there's nothing local to lose.
|
|
188
|
+
const fetchResult = await runCommand(
|
|
189
|
+
"git",
|
|
190
|
+
["-C", root, "fetch", "--depth", "1", "origin", "HEAD"],
|
|
191
|
+
{ timeout: 60_000 }
|
|
192
|
+
);
|
|
193
|
+
const resetResult = fetchResult.exitCode === 0
|
|
194
|
+
? await runCommand("git", ["-C", root, "reset", "--hard", "FETCH_HEAD"], { timeout: 30_000 })
|
|
195
|
+
: { exitCode: fetchResult.exitCode, stderr: fetchResult.stderr };
|
|
196
|
+
|
|
197
|
+
if (resetResult.exitCode !== 0) {
|
|
198
|
+
const pullErr = (pullResult.stderr || "").trim();
|
|
199
|
+
const recoveryErr = (resetResult.stderr || "").trim();
|
|
200
|
+
const combined = recoveryErr ? `${pullErr} | recovery: ${recoveryErr}` : pullErr;
|
|
201
|
+
logger?.warn?.(`addyosmani-catalog: git pull failed — ${combined} (keeping stale cache)`);
|
|
202
|
+
return { ok: false, action: "skipped", error: combined };
|
|
203
|
+
}
|
|
204
|
+
action = "reset";
|
|
205
|
+
logger?.info?.("addyosmani-catalog: ff-only failed, recovered via fetch+reset (upstream force-pushed)");
|
|
184
206
|
}
|
|
185
207
|
|
|
186
208
|
await writeMeta({
|
|
@@ -189,7 +211,7 @@ export async function refreshIfStale(opts = {}) {
|
|
|
189
211
|
lastPullAt: new Date().toISOString(),
|
|
190
212
|
});
|
|
191
213
|
|
|
192
|
-
return { ok: true, action
|
|
214
|
+
return { ok: true, action };
|
|
193
215
|
}
|
|
194
216
|
|
|
195
217
|
/**
|
package/src/sonar/project-key.js
CHANGED
|
@@ -81,3 +81,40 @@ export async function resolveSonarProjectKey(config, options = {}) {
|
|
|
81
81
|
const repo = slug(repoId.split("/").pop());
|
|
82
82
|
return normalizeProjectKey(`kj-${repo}-${digest(repoId)}`);
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Non-throwing predicate: can a Sonar project key be resolved for `config`?
|
|
87
|
+
*
|
|
88
|
+
* Used by callers (audit collector, run-loop SonarStage) that want to skip
|
|
89
|
+
* the Sonar work cleanly instead of letting `resolveSonarProjectKey` throw
|
|
90
|
+
* "Missing git remote.origin.url" deep inside the scanner. The scanner's
|
|
91
|
+
* thrown error is fine for an explicit `kj sonar` call (the user asked
|
|
92
|
+
* for it), but for implicit pipeline gates it produces a noisy "Sonar
|
|
93
|
+
* scan failed" loop in fresh /tmp/... repos.
|
|
94
|
+
*
|
|
95
|
+
* Returns true when EITHER:
|
|
96
|
+
* - an explicit `project_key` is set (env var, config, or per-call option),
|
|
97
|
+
* - OR `git config --get remote.origin.url` succeeds with a non-empty value.
|
|
98
|
+
*
|
|
99
|
+
* Side-effect-free apart from the git probe.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} config
|
|
102
|
+
* @param {object} [options]
|
|
103
|
+
* @param {string} [options.projectKey]
|
|
104
|
+
* @returns {Promise<boolean>}
|
|
105
|
+
*/
|
|
106
|
+
export async function canResolveSonarProjectKey(config, options = {}) {
|
|
107
|
+
const explicit = String(
|
|
108
|
+
options.projectKey || process.env.KJ_SONAR_PROJECT_KEY || config?.sonarqube?.project_key || ""
|
|
109
|
+
).trim();
|
|
110
|
+
if (explicit) return true;
|
|
111
|
+
// try/catch (instead of `.catch(...)`) tolerates two legitimate test
|
|
112
|
+
// realities: a runCommand mock that returns undefined, and a real
|
|
113
|
+
// promise rejection from a missing git binary. Either way we treat
|
|
114
|
+
// it as "no remote available" and let the caller skip Sonar cleanly.
|
|
115
|
+
let remote;
|
|
116
|
+
try { remote = await runCommand("git", ["config", "--get", "remote.origin.url"]); }
|
|
117
|
+
catch { return false; }
|
|
118
|
+
if (!remote) return false;
|
|
119
|
+
return remote.exitCode === 0 && String(remote.stdout || "").trim().length > 0;
|
|
120
|
+
}
|
package/src/types/session.js
CHANGED
|
@@ -64,7 +64,8 @@
|
|
|
64
64
|
* @property {string[]} [skillsRecommended] - wouldHaveUsed when openskills missing
|
|
65
65
|
* @property {string[]} [autoInstalledSkills]
|
|
66
66
|
* @property {RtkSavings} [rtk_savings]
|
|
67
|
-
* @property {string} [session_start_sha]
|
|
67
|
+
* @property {string} [session_start_sha] - baseline for `git diff`; may be the empty-tree SHA when no prior commits exist
|
|
68
|
+
* @property {string} [head_at_start] - actual HEAD when the run started; used by the post-loop journal to enumerate commits produced BY the run. Falls back to session_start_sha in zero-commits repos. (2026-05-07 dogfooding fix.)
|
|
68
69
|
* @property {string} [_journalDir] - runtime journal path
|
|
69
70
|
* @property {string[]} [_journalIterations] - rendered iteration markdown
|
|
70
71
|
* @property {string[]} [_journalFiles]
|
|
@@ -26,9 +26,19 @@ function printSessionPlanner(planner) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
function printSessionSonar(sonar) {
|
|
29
|
+
export function printSessionSonar(sonar) {
|
|
30
30
|
if (!sonar) return;
|
|
31
|
-
|
|
31
|
+
// Three buckets so the banner reads correctly:
|
|
32
|
+
// - OK \u2192 green (real pass).
|
|
33
|
+
// - SKIPPED / PENDING \u2192 gray (informational; sonar
|
|
34
|
+
// wasn't gated against, this is NOT a fail). Pre-2026-05-07 N4
|
|
35
|
+
// dogfooding these were rendered red, which made a clean run
|
|
36
|
+
// look like a sonar failure during the live demo.
|
|
37
|
+
// - everything else (ERROR, WARN, ...) \u2192 red (real fail).
|
|
38
|
+
let gateLabel;
|
|
39
|
+
if (sonar.gateStatus === "OK") gateLabel = ANSI.green;
|
|
40
|
+
else if (sonar.gateStatus === "SKIPPED" || sonar.gateStatus === "PENDING") gateLabel = ANSI.gray;
|
|
41
|
+
else gateLabel = ANSI.red;
|
|
32
42
|
console.log(` ${ANSI.dim}\ud83d\udd0d Sonar: ${gateLabel}${sonar.gateStatus}${ANSI.reset}${ANSI.dim} (${sonar.openIssues ?? 0} issues)${ANSI.reset}`);
|
|
33
43
|
if (typeof sonar.issuesInitial === "number" || typeof sonar.issuesResolved === "number") {
|
|
34
44
|
const issuesInitial = sonar.issuesInitial ?? sonar.openIssues ?? 0;
|
package/src/utils/git.js
CHANGED
|
@@ -65,6 +65,42 @@ export async function revParse(ref) {
|
|
|
65
65
|
return runGit(["rev-parse", ref]);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* List commits in the range `fromSha..toRef`. Used by the post-loop
|
|
70
|
+
* journal builder to enumerate every commit produced during a `kj run`
|
|
71
|
+
* — both the coder's own commits AND the post-loop scaffold commit —
|
|
72
|
+
* so the user-visible summary doesn't lose track of them. Pre-fix the
|
|
73
|
+
* summary only listed `gitResult.commits` (post-loop only), which made
|
|
74
|
+
* `kj run "..."` runs look like the coder hadn't committed.
|
|
75
|
+
*
|
|
76
|
+
* Returns oldest-first so the list reads in execution order.
|
|
77
|
+
*
|
|
78
|
+
* @param {string|null|undefined} fromSha The SHA right BEFORE the run started
|
|
79
|
+
* (typically `session.session_start_sha`). Empty/missing → returns [].
|
|
80
|
+
* @param {string} [toRef="HEAD"]
|
|
81
|
+
* @returns {Promise<Array<{hash: string, message: string}>>}
|
|
82
|
+
*/
|
|
83
|
+
export async function listCommitsBetween(fromSha, toRef = "HEAD") {
|
|
84
|
+
if (!fromSha) return [];
|
|
85
|
+
const result = await run(
|
|
86
|
+
"git",
|
|
87
|
+
["log", "--reverse", "--format=%H%x09%s", `${fromSha}..${toRef}`],
|
|
88
|
+
).catch(() => null);
|
|
89
|
+
if (!result || result.exitCode !== 0) return [];
|
|
90
|
+
return String(result.stdout || "")
|
|
91
|
+
.split("\n")
|
|
92
|
+
.map((line) => line.trimEnd())
|
|
93
|
+
.filter(Boolean)
|
|
94
|
+
.map((line) => {
|
|
95
|
+
const tabIdx = line.indexOf("\t");
|
|
96
|
+
// Defensive: if git emitted no tab (shouldn't happen with our format)
|
|
97
|
+
// treat the whole line as the hash and leave the message empty rather
|
|
98
|
+
// than throw — caller can still render the row.
|
|
99
|
+
if (tabIdx === -1) return { hash: line, message: "" };
|
|
100
|
+
return { hash: line.slice(0, tabIdx), message: line.slice(tabIdx + 1) };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
68
104
|
export async function syncBaseBranch({ baseBranch, autoRebase }) {
|
|
69
105
|
const local = await revParse(baseBranch);
|
|
70
106
|
const remote = await revParse(`origin/${baseBranch}`);
|
|
@@ -109,11 +145,79 @@ export async function hasChanges() {
|
|
|
109
145
|
return status.length > 0;
|
|
110
146
|
}
|
|
111
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Return the relative paths of every uncommitted change (modified,
|
|
150
|
+
* untracked, staged, etc.) in the working tree. Each path is the
|
|
151
|
+
* filename as `git status --porcelain` reports it (after the 2-char
|
|
152
|
+
* status code + space). Returns an empty array when the tree is clean.
|
|
153
|
+
*
|
|
154
|
+
* Used by `finalizeGitAutomation` to decide whether the changes
|
|
155
|
+
* pending at the end of a pipeline are exclusively Karajan
|
|
156
|
+
* scaffolding (and thus deserve a `chore:` commit message instead
|
|
157
|
+
* of inheriting the user task's `feat:`).
|
|
158
|
+
*
|
|
159
|
+
* NOTE: bypasses `runGit()` because that helper trims stdout, which
|
|
160
|
+
* eats the leading space of porcelain lines whose first status char
|
|
161
|
+
* is " " (e.g. " M .gitignore" → "M .gitignore"). The leading space
|
|
162
|
+
* is structural here — without it the slice(3) below would crop one
|
|
163
|
+
* character off the first path.
|
|
164
|
+
*/
|
|
165
|
+
export async function listPendingPaths() {
|
|
166
|
+
const res = await run("git", ["status", "--porcelain"]);
|
|
167
|
+
if (res.exitCode !== 0) {
|
|
168
|
+
throw new Error(`git status --porcelain failed: ${res.stderr || res.stdout}`);
|
|
169
|
+
}
|
|
170
|
+
const stdout = res.stdout;
|
|
171
|
+
if (!stdout) return [];
|
|
172
|
+
const lines = stdout.split("\n").map((l) => l.replace(/\r$/, "")).filter(Boolean);
|
|
173
|
+
const out = [];
|
|
174
|
+
for (const line of lines) {
|
|
175
|
+
// Each line is "XY <path>" (XY = 2-char status, space, path).
|
|
176
|
+
// Renames look like "R old -> new"; we want the new path.
|
|
177
|
+
const after = line.slice(3);
|
|
178
|
+
const arrow = after.indexOf(" -> ");
|
|
179
|
+
out.push(arrow !== -1 ? after.slice(arrow + 4) : after);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Locale-tolerant detection of "git commit refused because the working
|
|
185
|
+
// tree was already clean by the time we ran it". Observed in N4
|
|
186
|
+
// dogfooding (2026-05-07) — `hasChanges()` reported true after `add -A`
|
|
187
|
+
// but `git commit` then failed with the Spanish message "nada para hacer
|
|
188
|
+
// commit, el árbol de trabajo está limpio". The race window is real:
|
|
189
|
+
// post-loop journal writes can finish between hasChanges and commit, and
|
|
190
|
+
// some files come back from `add -A` with no diff vs HEAD even though
|
|
191
|
+
// they show up in `git status --porcelain`. We treat any of those as
|
|
192
|
+
// "no commit needed" rather than escalating to Solomon.
|
|
193
|
+
const NOTHING_TO_COMMIT_PATTERNS = [
|
|
194
|
+
/nothing to commit/i,
|
|
195
|
+
/nada para hacer commit/i, // git in es_ES locale
|
|
196
|
+
/no hay nada para confirmar/i, // git in es_ES alt phrasing
|
|
197
|
+
/aucune modification ajout[ée]e au commit/i, // fr_FR
|
|
198
|
+
/nichts zu committen/i, // de_DE
|
|
199
|
+
];
|
|
200
|
+
function isNothingToCommit(message) {
|
|
201
|
+
const m = String(message || "");
|
|
202
|
+
return NOTHING_TO_COMMIT_PATTERNS.some((re) => re.test(m));
|
|
203
|
+
}
|
|
204
|
+
|
|
112
205
|
export async function commitAll(message) {
|
|
113
206
|
await runGit(["add", "-A"]);
|
|
114
207
|
const changed = await hasChanges();
|
|
115
208
|
if (!changed) return { committed: false };
|
|
116
|
-
|
|
209
|
+
try {
|
|
210
|
+
await runGit(["commit", "-m", message]);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
// `git status --porcelain` and `git commit` disagreed about whether
|
|
213
|
+
// there was anything to commit. Don't escalate — the only outcome
|
|
214
|
+
// either way is "no new commit was created", which the caller
|
|
215
|
+
// already handles via committed=false.
|
|
216
|
+
if (isNothingToCommit(err?.message)) {
|
|
217
|
+
return { committed: false };
|
|
218
|
+
}
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
117
221
|
const raw = await runGit(["log", "-1", "--pretty=format:%H%x1f%s"]);
|
|
118
222
|
const [hash, commitMessage] = raw.split("\x1f");
|
|
119
223
|
return { committed: true, commit: { hash, message: commitMessage } };
|