karajan-code 2.10.1 → 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 +5 -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 +270 -61
- 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/sonar/token-bootstrap.js +182 -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/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
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sonar token bootstrap (KJC-TSK-0367).
|
|
3
|
+
*
|
|
4
|
+
* On `kj init`, after the SonarQube container is up, try to generate the
|
|
5
|
+
* analysis token via the REST API instead of asking the user to walk
|
|
6
|
+
* through the web UI manually. The bootstrapper:
|
|
7
|
+
*
|
|
8
|
+
* 1. Probes the host with admin/admin (the container default).
|
|
9
|
+
* 2. If login succeeds AND the password is still default, it rotates
|
|
10
|
+
* the password to a freshly generated value and persists that to
|
|
11
|
+
* `~/.karajan/sonar.admin-password` (mode 0600). This avoids
|
|
12
|
+
* leaving the deployment with the well-known default credentials.
|
|
13
|
+
* 3. Generates a Global Analysis Token named `karajan-cli` via
|
|
14
|
+
* `POST /api/user_tokens/generate` and returns it.
|
|
15
|
+
* 4. Persists the token to `~/.karajan/sonar.token` (mode 0600). The
|
|
16
|
+
* caller writes it into `config.sonarqube.token` separately.
|
|
17
|
+
*
|
|
18
|
+
* On any HTTP error, we return `{ ok: false, reason }` and let the
|
|
19
|
+
* caller fall back to the manual-flow instructions that existed before
|
|
20
|
+
* this card. We never throw — the wizard must keep going.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { writeFileSync, chmodSync, mkdirSync } from "node:fs";
|
|
24
|
+
import { dirname } from "node:path";
|
|
25
|
+
import { randomBytes } from "node:crypto";
|
|
26
|
+
import { getKarajanHome } from "../utils/paths.js";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
const DEFAULT_USER = "admin";
|
|
30
|
+
const DEFAULT_PASS = "admin";
|
|
31
|
+
const TOKEN_NAME = "karajan-cli";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} host e.g. http://localhost:9000
|
|
35
|
+
* @param {string} pathname "/api/..."
|
|
36
|
+
* @param {object} opts
|
|
37
|
+
* @param {"GET"|"POST"} [opts.method="GET"]
|
|
38
|
+
* @param {Record<string,string>} [opts.form] form-urlencoded body
|
|
39
|
+
* @param {string} opts.user
|
|
40
|
+
* @param {string} opts.pass
|
|
41
|
+
* @returns {Promise<{ status: number, body: any }>}
|
|
42
|
+
*/
|
|
43
|
+
async function call(host, pathname, opts) {
|
|
44
|
+
const url = host.replace(/\/$/, "") + pathname;
|
|
45
|
+
const headers = {
|
|
46
|
+
Authorization: "Basic " + Buffer.from(`${opts.user}:${opts.pass}`).toString("base64"),
|
|
47
|
+
};
|
|
48
|
+
let body;
|
|
49
|
+
if (opts.method === "POST" && opts.form) {
|
|
50
|
+
headers["Content-Type"] = "application/x-www-form-urlencoded";
|
|
51
|
+
body = new URLSearchParams(opts.form).toString();
|
|
52
|
+
}
|
|
53
|
+
const res = await fetch(url, { method: opts.method || "GET", headers, body });
|
|
54
|
+
let parsed = null;
|
|
55
|
+
const text = await res.text();
|
|
56
|
+
if (text) {
|
|
57
|
+
try { parsed = JSON.parse(text); } catch { parsed = text; }
|
|
58
|
+
}
|
|
59
|
+
return { status: res.status, body: parsed };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Generate a strong (32-byte hex) random password. We never re-use the
|
|
64
|
+
* default `admin` password and never persist `admin` to disk.
|
|
65
|
+
*/
|
|
66
|
+
function generatePassword() {
|
|
67
|
+
return randomBytes(24).toString("base64url");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Persist a value at `~/.karajan/<filename>` with mode 0600.
|
|
72
|
+
*/
|
|
73
|
+
function persistSecret(filename, value) {
|
|
74
|
+
const home = getKarajanHome();
|
|
75
|
+
const target = path.join(home, filename);
|
|
76
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
77
|
+
writeFileSync(target, value, { mode: 0o600 });
|
|
78
|
+
try { chmodSync(target, 0o600); } catch { /* best-effort on Windows */ }
|
|
79
|
+
return target;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Bootstrap the Sonar analysis token. Idempotent: if the
|
|
84
|
+
* `karajan-cli` token already exists Sonar will refuse to create it
|
|
85
|
+
* again (HTTP 400 with `"errors":[{"msg":"A user token...exists"}]`).
|
|
86
|
+
* In that case we delete-and-recreate so kj init always returns a
|
|
87
|
+
* usable token.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} opts
|
|
90
|
+
* @param {string} opts.host Default http://localhost:9000
|
|
91
|
+
* @returns {Promise<{
|
|
92
|
+
* ok: boolean,
|
|
93
|
+
* token?: string,
|
|
94
|
+
* savedTo?: string,
|
|
95
|
+
* passwordRotated?: boolean,
|
|
96
|
+
* passwordSavedTo?: string,
|
|
97
|
+
* reason?: string,
|
|
98
|
+
* }>}
|
|
99
|
+
*/
|
|
100
|
+
export async function bootstrapSonarToken({ host = "http://localhost:9000" } = {}) {
|
|
101
|
+
let user = DEFAULT_USER;
|
|
102
|
+
let pass = DEFAULT_PASS;
|
|
103
|
+
let passwordRotated = false;
|
|
104
|
+
let passwordSavedTo = null;
|
|
105
|
+
|
|
106
|
+
// 1) Probe: does admin/admin still work?
|
|
107
|
+
const probe = await call(host, "/api/authentication/validate", { user, pass }).catch((err) => ({ status: 0, body: err.message }));
|
|
108
|
+
if (probe.status === 0) {
|
|
109
|
+
return { ok: false, reason: `network error reaching ${host}: ${probe.body}` };
|
|
110
|
+
}
|
|
111
|
+
if (probe.status === 401 || (probe.body && probe.body.valid === false)) {
|
|
112
|
+
return { ok: false, reason: "admin/admin login failed (password may already be customized — set sonarqube.token manually)" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 2) Rotate the default password if it's still in place. This stops
|
|
116
|
+
// the well-known credentials from being a stable attack surface
|
|
117
|
+
// on this user's machine.
|
|
118
|
+
const newPass = generatePassword();
|
|
119
|
+
const change = await call(host, "/api/users/change_password", {
|
|
120
|
+
method: "POST",
|
|
121
|
+
user,
|
|
122
|
+
pass,
|
|
123
|
+
form: { login: user, previousPassword: pass, password: newPass },
|
|
124
|
+
}).catch((err) => ({ status: 0, body: err.message }));
|
|
125
|
+
|
|
126
|
+
if (change.status === 204 || change.status === 200) {
|
|
127
|
+
pass = newPass;
|
|
128
|
+
passwordRotated = true;
|
|
129
|
+
passwordSavedTo = persistSecret("sonar.admin-password", pass);
|
|
130
|
+
} else if (change.status === 400 && Array.isArray(change.body?.errors)
|
|
131
|
+
&& change.body.errors.some(e => /default/i.test(e.msg))) {
|
|
132
|
+
// Sonar complained the password matches a default — rare. Try with
|
|
133
|
+
// a longer one.
|
|
134
|
+
const longerPass = generatePassword() + generatePassword();
|
|
135
|
+
const retry = await call(host, "/api/users/change_password", {
|
|
136
|
+
method: "POST",
|
|
137
|
+
user,
|
|
138
|
+
pass,
|
|
139
|
+
form: { login: user, previousPassword: pass, password: longerPass },
|
|
140
|
+
}).catch(() => ({ status: 0 }));
|
|
141
|
+
if (retry.status === 204 || retry.status === 200) {
|
|
142
|
+
pass = longerPass;
|
|
143
|
+
passwordRotated = true;
|
|
144
|
+
passwordSavedTo = persistSecret("sonar.admin-password", pass);
|
|
145
|
+
}
|
|
146
|
+
} else if (change.status !== 0 && change.status !== 401) {
|
|
147
|
+
// Non-fatal: probably the admin already changed the password. We
|
|
148
|
+
// continue using whatever credentials worked at the probe step.
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 3) Token already exists? Revoke it so we can re-create cleanly.
|
|
152
|
+
await call(host, "/api/user_tokens/revoke", {
|
|
153
|
+
method: "POST",
|
|
154
|
+
user,
|
|
155
|
+
pass,
|
|
156
|
+
form: { name: TOKEN_NAME },
|
|
157
|
+
}).catch(() => { /* token may not exist — that's fine */ });
|
|
158
|
+
|
|
159
|
+
// 4) Generate the analysis token.
|
|
160
|
+
const gen = await call(host, "/api/user_tokens/generate", {
|
|
161
|
+
method: "POST",
|
|
162
|
+
user,
|
|
163
|
+
pass,
|
|
164
|
+
form: { name: TOKEN_NAME, type: "GLOBAL_ANALYSIS_TOKEN" },
|
|
165
|
+
}).catch((err) => ({ status: 0, body: err.message }));
|
|
166
|
+
|
|
167
|
+
if (gen.status !== 200 || !gen.body?.token) {
|
|
168
|
+
const detail = gen.body?.errors?.[0]?.msg || gen.body || `HTTP ${gen.status}`;
|
|
169
|
+
return { ok: false, reason: `token generation failed: ${detail}`, passwordRotated, passwordSavedTo };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const token = gen.body.token;
|
|
173
|
+
const savedTo = persistSecret("sonar.token", token);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
token,
|
|
178
|
+
savedTo,
|
|
179
|
+
passwordRotated,
|
|
180
|
+
passwordSavedTo: passwordRotated ? passwordSavedTo : null,
|
|
181
|
+
};
|
|
182
|
+
}
|
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]
|