karajan-code 2.7.3 → 2.8.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 +4 -4
- package/package.json +24 -4
- package/scripts/diet-baseline.mjs +170 -0
- package/scripts/regen-arch-stats.sh +71 -0
- package/src/agents/availability.js +15 -9
- package/src/agents/claude-agent.js +143 -45
- package/src/agents/model-registry.js +1 -1
- package/src/audit/agent-readiness.js +240 -0
- package/src/bootstrap.js +26 -1
- package/src/brain/decision-tracker.js +3 -1
- package/src/brain/decisor.js +1 -1
- package/src/canvas/parse-md.js +238 -0
- package/src/canvas/schema.js +101 -0
- package/src/canvas/to-flat.js +85 -0
- package/src/canvas/validate.js +163 -0
- package/src/checks/binaries.js +1 -1
- package/src/checks/ports.js +97 -27
- package/src/checks/runner.js +26 -10
- package/src/checks/sonar.js +1 -1
- package/src/checks/system.js +1 -1
- package/src/checks/tokens.js +77 -24
- package/src/cli/_shared.js +30 -0
- package/src/cli/register-meta.js +204 -0
- package/src/cli/register-pipeline.js +212 -0
- package/src/cli/register-plan.js +152 -0
- package/src/cli/register-roles-skills.js +38 -0
- package/src/cli/register-sonar.js +28 -0
- package/src/cli.js +83 -468
- package/src/commands/agents.js +18 -6
- package/src/commands/architect.js +29 -2
- package/src/commands/audit.js +27 -3
- package/src/commands/board.js +160 -30
- package/src/commands/clean.js +83 -0
- package/src/commands/code.js +7 -2
- package/src/commands/discover.js +7 -2
- package/src/commands/init.js +2 -2
- package/src/commands/plan/_shared.js +40 -0
- package/src/commands/plan/add-hu.js +21 -0
- package/src/commands/plan/delete.js +9 -0
- package/src/commands/plan/generate.js +284 -0
- package/src/commands/plan/index.js +14 -0
- package/src/commands/plan/list.js +21 -0
- package/src/commands/plan/ready.js +46 -0
- package/src/commands/plan/remove-hu.js +17 -0
- package/src/commands/plan/show.js +81 -0
- package/src/commands/plan/validate.js +20 -0
- package/src/commands/plan.js +14 -291
- package/src/commands/researcher.js +7 -2
- package/src/commands/resume.js +24 -1
- package/src/commands/review.js +7 -2
- package/src/commands/run.js +52 -1
- package/src/commands/status.js +1 -1
- package/src/commands/sync.js +75 -0
- package/src/commands/triage.js +7 -2
- package/src/commands/undo.js +1 -1
- package/src/config/defaults.js +213 -0
- package/src/config/loader.js +137 -0
- package/src/config/overrides.js +199 -0
- package/src/config/role-resolver.js +159 -0
- package/src/config/test-harness.js +79 -0
- package/src/config.js +30 -609
- package/src/git/automation.js +2 -2
- package/src/guards/output-guard.js +0 -2
- package/src/hu/acceptance-runner.js +52 -10
- package/src/hu/store.js +11 -0
- package/src/mcp/handlers/direct-handlers.js +2 -5
- package/src/mcp/handlers/management-handlers.js +48 -4
- package/src/mcp/handlers/run-handler.js +9 -8
- package/src/mcp/preflight.js +32 -13
- package/src/mcp/server-handlers.js +4 -3
- package/src/mcp/suggest-handler.js +3 -2
- package/src/mcp/tools.js +15 -0
- package/src/orchestrator/agent-fallback.js +1 -1
- package/src/orchestrator/ci-integration.js +10 -10
- package/src/orchestrator/config-init.js +25 -7
- package/src/orchestrator/direct-actions.js +6 -2
- package/src/orchestrator/drivers/error-recovery.js +287 -0
- package/src/orchestrator/drivers/init-context.js +222 -0
- package/src/orchestrator/drivers/iteration-loop.js +311 -0
- package/src/orchestrator/drivers/iteration-phases/coder-and-refactorer.js +48 -0
- package/src/orchestrator/drivers/iteration-phases/guards.js +84 -0
- package/src/orchestrator/drivers/iteration-phases/handle-approved.js +29 -0
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +76 -0
- package/src/orchestrator/drivers/iteration-phases/reviewer-gate.js +60 -0
- package/src/orchestrator/drivers/post-loop.js +370 -0
- package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +116 -0
- package/src/orchestrator/drivers/pre-loop-phases/config-deprecations.js +47 -0
- package/src/orchestrator/drivers/pre-loop-phases/ensure-addyosmani-skills.js +84 -0
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +268 -0
- package/src/orchestrator/drivers/pre-loop.js +434 -0
- package/src/orchestrator/drivers/run-hu-batch.js +367 -0
- package/src/orchestrator/flow-control.js +4 -3
- package/src/orchestrator/flow-runner.js +121 -2019
- package/src/orchestrator/fs-leak-detector.js +156 -0
- package/src/orchestrator/hu-sub-pipeline.js +197 -9
- package/src/orchestrator/integrations.js +88 -0
- package/src/orchestrator/post-loop-stages.js +35 -8
- package/src/orchestrator/preflight-checks.js +30 -3
- package/src/orchestrator/repair/repair-detector.js +117 -0
- package/src/orchestrator/repair/repair-runner.js +142 -0
- package/src/orchestrator/reviewer-fallback.js +1 -1
- package/src/orchestrator/solomon-escalation.js +3 -2
- package/src/orchestrator/solomon-rules.js +7 -3
- package/src/orchestrator/stages/architect-stage.js +12 -11
- package/src/orchestrator/stages/coder-stage.js +56 -7
- package/src/orchestrator/stages/hu-reviewer-phases/detect-and-apply-splits.js +127 -0
- package/src/orchestrator/stages/hu-reviewer-stage.js +6 -79
- package/src/orchestrator/stages/planner-stage.js +1 -1
- package/src/orchestrator/stages/research-stage.js +1 -1
- package/src/orchestrator/stages/reviewer-stage.js +7 -7
- package/src/orchestrator/stages/sonar-stage.js +94 -10
- package/src/orchestrator/stages/stage-classes.js +86 -0
- package/src/orchestrator/stages/stage-executor.js +1 -1
- package/src/orchestrator/stages/triage-stage.js +1 -1
- package/src/orchestrator/standby.js +3 -2
- package/src/orchestrator/verification-gate.js +20 -12
- package/src/plan/adr-generator.js +170 -0
- package/src/plan/adr-loader.js +60 -0
- package/src/plan/plan-executor.js +19 -1
- package/src/plan/plan-hu-ops.js +170 -1
- package/src/plan/plan-reviewer.js +199 -0
- package/src/plan/plan-schema.js +69 -1
- package/src/plan/plan-store.js +40 -3
- package/src/plan/spec-detector.js +61 -0
- package/src/plan/tests-synthesizer.js +116 -0
- package/src/planning-game/client.js +3 -14
- package/src/planning-game/decomposition.js +1 -1
- package/src/planning-game/pipeline-adapter.js +36 -5
- package/src/prompts/coder.js +172 -1
- package/src/prompts/hu-reviewer.js +0 -1
- package/src/prompts/planner.js +81 -4
- package/src/review/diff-generator.js +35 -22
- package/src/review/snapshot-diff.js +1 -1
- package/src/roles/coder-role.js +25 -3
- package/src/roles/planner-role.js +42 -1
- package/src/roles/repairer-role.js +181 -0
- package/src/roles/tester-role.js +70 -4
- package/src/session/mutators.js +304 -0
- package/src/session/runtime-overrides.js +59 -0
- package/src/{session-store.js → session/store.js} +8 -2
- package/src/skills/semantic-detector.js +10 -5
- package/src/skills/skill-detector.js +1 -1
- package/src/sonar/cloud-scanner.js +0 -1
- package/src/sonar/credentials.js +14 -6
- package/src/sonar/discovery.js +123 -0
- package/src/sonar/manager.js +18 -0
- package/src/sync/detect-drift.js +113 -0
- package/src/tracker-bootstrap.js +44 -0
- package/src/utils/agent-detect.js +15 -11
- package/src/utils/board-prompt-bridge.js +109 -0
- package/src/utils/cli-progress.js +161 -0
- package/src/utils/cwd-confirm.js +39 -0
- package/src/utils/derive-project-name-from-cwd.js +119 -0
- package/src/utils/garbage-collector.js +396 -0
- package/src/utils/git.js +16 -4
- package/src/utils/locale.js +1 -1
- package/src/utils/paths.js +21 -1
- package/src/utils/process.js +1 -1
- package/src/utils/prompt-project-name.js +35 -0
- package/src/utils/run-context.js +75 -0
- package/src/utils/run-log.js +12 -0
- package/src/utils/task-file.js +1 -1
- package/src/utils/telemetry.js +13 -1
- package/src/webperf/devtools-detect.js +1 -1
- package/templates/coder-rules.md +13 -0
- package/templates/roles/coder.md +34 -0
- package/templates/roles/repairer.md +94 -0
|
@@ -129,18 +129,23 @@ export async function refineSkillsSemantically({ task, alreadyDetected = [], con
|
|
|
129
129
|
|
|
130
130
|
/**
|
|
131
131
|
* Resolve the effective skills mode from config + flags.
|
|
132
|
-
* Precedence: flags.skillsMode > config.skills.mode >
|
|
132
|
+
* Precedence: flags.skillsMode > config.skills.mode > config.testHarness.defaultSkillsMode > "auto".
|
|
133
133
|
*
|
|
134
|
-
* The
|
|
135
|
-
* without each orchestrator test having to mock this module
|
|
136
|
-
*
|
|
134
|
+
* The testHarness default lets the test harness opt out of semantic
|
|
135
|
+
* detection without each orchestrator test having to mock this module.
|
|
136
|
+
* Production code reads `config.testHarness.defaultSkillsMode` only;
|
|
137
|
+
* the legacy global override is documented and exclusively read in
|
|
138
|
+
* src/config/test-harness.js (ESLint rule #557 blocks any reintroduction
|
|
139
|
+
* of `globalThis.__KJ_*` outside that one file). Legacy callers without
|
|
140
|
+
* a fully-loaded config still work because `config.testHarness` is
|
|
141
|
+
* undefined → falls through to `"auto"`.
|
|
137
142
|
*
|
|
138
143
|
* @param {Object} config
|
|
139
144
|
* @param {Object} [flags]
|
|
140
145
|
* @returns {"auto"|"regex"|"semantic"|"none"}
|
|
141
146
|
*/
|
|
142
147
|
export function resolveSkillsMode(config, flags = {}) {
|
|
143
|
-
const defaultMode =
|
|
148
|
+
const defaultMode = config?.testHarness?.defaultSkillsMode || "auto";
|
|
144
149
|
const candidate = flags.skillsMode ?? config?.skills?.mode ?? defaultMode;
|
|
145
150
|
const valid = new Set(["auto", "regex", "semantic", "none"]);
|
|
146
151
|
return valid.has(candidate) ? candidate : "auto";
|
|
@@ -226,7 +226,7 @@ async function collectPythonDeps(projectDir, needed) {
|
|
|
226
226
|
for (const line of raw.split("\n")) {
|
|
227
227
|
const trimmed = line.trim().split("#")[0].trim();
|
|
228
228
|
if (!trimmed) continue;
|
|
229
|
-
const name = trimmed.split(/[
|
|
229
|
+
const name = trimmed.split(/[<>=!~[\s]/)[0].toLowerCase();
|
|
230
230
|
const skill = PYTHON_DEPS[name];
|
|
231
231
|
if (skill) needed.add(skill);
|
|
232
232
|
}
|
|
@@ -5,7 +5,6 @@ function buildCloudScannerArgs(projectKey, config) {
|
|
|
5
5
|
const sc = config.sonarcloud || {};
|
|
6
6
|
const scanner = sc.scanner || {};
|
|
7
7
|
const host = sc.host || "https://sonarcloud.io";
|
|
8
|
-
const token = process.env.KJ_SONARCLOUD_TOKEN || sc.token;
|
|
9
8
|
const organization = process.env.KJ_SONARCLOUD_ORG || sc.organization;
|
|
10
9
|
|
|
11
10
|
const args = [
|
package/src/sonar/credentials.js
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Load SonarQube
|
|
2
|
+
* Load SonarQube credentials from ~/.karajan/sonar-credentials.json.
|
|
3
3
|
*
|
|
4
|
-
* File format:
|
|
4
|
+
* File format (any subset):
|
|
5
5
|
* {
|
|
6
6
|
* "user": "admin",
|
|
7
|
-
* "password": "your-password"
|
|
7
|
+
* "password": "your-password",
|
|
8
|
+
* "token": "squ_..."
|
|
8
9
|
* }
|
|
9
10
|
*
|
|
10
|
-
* Returns { user, password }
|
|
11
|
+
* Returns { user, password, token } with each field null when missing.
|
|
12
|
+
*
|
|
13
|
+
* The `token` field is consumed by resolveSonarTokenAsync as the
|
|
14
|
+
* lowest-priority fallback. Until v2.7.5 it was not extracted here, which
|
|
15
|
+
* meant a token persisted to the credentials file by `kj init` was never
|
|
16
|
+
* actually used at scan time — Karajan would silently fall back to the
|
|
17
|
+
* stale token in kj.config.yml or fail with a 401.
|
|
11
18
|
*/
|
|
12
19
|
|
|
13
20
|
import fs from "node:fs/promises";
|
|
@@ -23,10 +30,11 @@ export async function loadSonarCredentials() {
|
|
|
23
30
|
const data = JSON.parse(raw);
|
|
24
31
|
return {
|
|
25
32
|
user: data.user || null,
|
|
26
|
-
password: data.password || null
|
|
33
|
+
password: data.password || null,
|
|
34
|
+
token: data.token || null
|
|
27
35
|
};
|
|
28
36
|
} catch { /* credentials file may not exist */
|
|
29
|
-
return { user: null, password: null };
|
|
37
|
+
return { user: null, password: null, token: null };
|
|
30
38
|
}
|
|
31
39
|
}
|
|
32
40
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discover running SonarQube containers regardless of the user's
|
|
3
|
+
* config. The point: never bother the user about port 9000 being
|
|
4
|
+
* "occupied by an unknown process" — if there's a Sonar already up
|
|
5
|
+
* somewhere, just point at it and move on.
|
|
6
|
+
*
|
|
7
|
+
* The previous preflight check insisted on owning port 9000 by name
|
|
8
|
+
* (`karajan-sonarqube`). If you happened to have a `sonarqube` from
|
|
9
|
+
* another project running on 9000 (or had pulled a different image
|
|
10
|
+
* tag), we'd error out asking the user to "stop the conflicting
|
|
11
|
+
* process". That's busywork — Sonar is Sonar; reuse what's there.
|
|
12
|
+
*
|
|
13
|
+
* The discovery strategy:
|
|
14
|
+
* 1. List Docker containers whose image looks like sonarqube
|
|
15
|
+
* (image-name regex / ancestor filter — covers `sonarqube`,
|
|
16
|
+
* `sonarqube:community`, `sonarqube:lts`, `sonarqube:9.9`, …).
|
|
17
|
+
* 2. Parse the published host port (`0.0.0.0:9001->9000/tcp`).
|
|
18
|
+
* 3. Optionally HTTP-probe to confirm it's actually serving
|
|
19
|
+
* `/api/system/status` so a misnamed image can't hijack us.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { runCommand } from "../utils/process.js";
|
|
23
|
+
import { isSonarReachable } from "./manager.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} DiscoveredSonar
|
|
27
|
+
* @property {boolean} found
|
|
28
|
+
* @property {string} [containerName]
|
|
29
|
+
* @property {string} [image]
|
|
30
|
+
* @property {number} [port] - host port (what we connect to)
|
|
31
|
+
* @property {string} [host] - http://localhost:<port>
|
|
32
|
+
* @property {boolean} [reachable] - true after a successful HTTP probe
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse the Ports column from `docker ps`. Looks for any host binding
|
|
37
|
+
* that maps to the container's `internalPort` (default 9000).
|
|
38
|
+
* Examples:
|
|
39
|
+
* "0.0.0.0:9000->9000/tcp" → 9000
|
|
40
|
+
* "127.0.0.1:9001->9000/tcp" → 9001
|
|
41
|
+
* "9000/tcp" → null (no host binding)
|
|
42
|
+
* "0.0.0.0:9000->9000/tcp, 9092/tcp" → 9000
|
|
43
|
+
*
|
|
44
|
+
* @param {string} portsStr
|
|
45
|
+
* @param {number} [internalPort=9000]
|
|
46
|
+
* @returns {number|null}
|
|
47
|
+
*/
|
|
48
|
+
export function extractHostPort(portsStr, internalPort = 9000) {
|
|
49
|
+
if (!portsStr) return null;
|
|
50
|
+
const re = new RegExp(
|
|
51
|
+
`(?:0\\.0\\.0\\.0|127\\.0\\.0\\.1|::|\\[::\\]):(\\d+)\\s*->\\s*${internalPort}\\/tcp`,
|
|
52
|
+
"g"
|
|
53
|
+
);
|
|
54
|
+
const matches = [...portsStr.matchAll(re)];
|
|
55
|
+
if (matches.length === 0) return null;
|
|
56
|
+
// Prefer the smallest port number (likeliest the "main" binding);
|
|
57
|
+
// arbitrary but deterministic.
|
|
58
|
+
return Math.min(...matches.map((m) => Number(m[1])));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run `docker ps` with a list-of-images filter and parse the result.
|
|
63
|
+
* Returns the FIRST container with a usable host port, or null.
|
|
64
|
+
*
|
|
65
|
+
* @returns {Promise<DiscoveredSonar|null>}
|
|
66
|
+
*/
|
|
67
|
+
async function dockerPsForSonar() {
|
|
68
|
+
// List all running containers with their image so we can image-match
|
|
69
|
+
// ourselves. Cheap (single docker call) and tolerant of image tags.
|
|
70
|
+
const res = await runCommand("docker", [
|
|
71
|
+
"ps",
|
|
72
|
+
"--format", "{{.ID}}|{{.Names}}|{{.Image}}|{{.Ports}}",
|
|
73
|
+
]);
|
|
74
|
+
if (res.exitCode !== 0 || !res.stdout?.trim()) return null;
|
|
75
|
+
|
|
76
|
+
const lines = res.stdout.trim().split("\n");
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
const [, name, image, ports] = line.split("|");
|
|
79
|
+
if (!image) continue;
|
|
80
|
+
if (!/sonarqube/i.test(image)) continue;
|
|
81
|
+
const port = extractHostPort(ports, 9000);
|
|
82
|
+
if (!port) continue;
|
|
83
|
+
return {
|
|
84
|
+
found: true,
|
|
85
|
+
containerName: name,
|
|
86
|
+
image,
|
|
87
|
+
port,
|
|
88
|
+
host: `http://localhost:${port}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Public entry point: find a usable, reachable Sonar.
|
|
96
|
+
*
|
|
97
|
+
* `probe`: when true (default) HTTP-pings `/api/system/status` to
|
|
98
|
+
* confirm the container is actually serving Sonar. Tests can disable
|
|
99
|
+
* the probe for hermetic mocking.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} [opts]
|
|
102
|
+
* @param {boolean} [opts.probe=true]
|
|
103
|
+
* @param {number} [opts.healthcheckSeconds=5]
|
|
104
|
+
* @returns {Promise<DiscoveredSonar>}
|
|
105
|
+
*/
|
|
106
|
+
export async function discoverRunningSonar(opts = {}) {
|
|
107
|
+
const { probe = true, healthcheckSeconds = 5 } = opts;
|
|
108
|
+
let candidate;
|
|
109
|
+
try {
|
|
110
|
+
candidate = await dockerPsForSonar();
|
|
111
|
+
} catch {
|
|
112
|
+
candidate = null;
|
|
113
|
+
}
|
|
114
|
+
if (!candidate) return { found: false };
|
|
115
|
+
if (!probe) return { ...candidate, reachable: true };
|
|
116
|
+
let reachable;
|
|
117
|
+
try {
|
|
118
|
+
reachable = await isSonarReachable(candidate.host, healthcheckSeconds);
|
|
119
|
+
} catch {
|
|
120
|
+
reachable = false;
|
|
121
|
+
}
|
|
122
|
+
return { ...candidate, reachable };
|
|
123
|
+
}
|
package/src/sonar/manager.js
CHANGED
|
@@ -87,6 +87,24 @@ export async function sonarUp(hostOverride = null) {
|
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
// Discover-before-spawn (v2.7.5): if there's already a SonarQube
|
|
91
|
+
// container running anywhere — even under a different name on a
|
|
92
|
+
// different port — reuse it instead of spinning up a second one.
|
|
93
|
+
// Avoids the user accidentally accumulating Sonar containers across
|
|
94
|
+
// projects, and matches what the port preflight check already does.
|
|
95
|
+
const { discoverRunningSonar } = await import("./discovery.js");
|
|
96
|
+
const discovered = await discoverRunningSonar({
|
|
97
|
+
healthcheckSeconds: sonarConfig.timeouts.healthcheckSeconds,
|
|
98
|
+
});
|
|
99
|
+
if (discovered.found && discovered.reachable) {
|
|
100
|
+
return {
|
|
101
|
+
exitCode: 0,
|
|
102
|
+
stdout: `Reusing existing SonarQube container "${discovered.containerName}" at ${discovered.host} (skipping container start).`,
|
|
103
|
+
stderr: "",
|
|
104
|
+
reusedHost: discovered.host,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
90
108
|
const compose = await ensureComposeFile(config.sonarqube);
|
|
91
109
|
return runCommand("docker", ["compose", "-f", compose, "up", "-d"], { timeout: sonarConfig.timeouts.composeUpMs });
|
|
92
110
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kj sync — drift detection between code and the Canvas / plan
|
|
3
|
+
* (issue #540, MVP).
|
|
4
|
+
*
|
|
5
|
+
* Scope of THIS module: pure data transformation. Given a plan
|
|
6
|
+
* (with Operations and their file_hints) plus a list of currently
|
|
7
|
+
* tracked source files (mtime + path), classify each file:
|
|
8
|
+
*
|
|
9
|
+
* - covered — file IS referenced by some Operation's file_hint
|
|
10
|
+
* AND was modified since the plan was created.
|
|
11
|
+
* (Expected drift; plan still authoritative.)
|
|
12
|
+
* - untracked — file exists, modified, but no Operation references
|
|
13
|
+
* it. The user added it manually after the run; the
|
|
14
|
+
* Canvas needs a new Operation to reflect it.
|
|
15
|
+
* - missing — Operation references a file that no longer exists
|
|
16
|
+
* on disk. Either the file was deleted or the plan
|
|
17
|
+
* is wrong about its location.
|
|
18
|
+
* - clean — file is referenced by an Operation AND has not
|
|
19
|
+
* been modified since the plan. No action needed.
|
|
20
|
+
*
|
|
21
|
+
* The classifier is pure: caller does the I/O (walking the tree,
|
|
22
|
+
* stating files, parsing plan JSON). Easy to unit-test.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} plan — plan JSON with `operations` (or `hus`) carrying
|
|
27
|
+
* optional `file_hint` strings, and `createdAt` ISO
|
|
28
|
+
* @param {Array<{path: string, mtime: Date}>} files — files in the project
|
|
29
|
+
* @returns {{ covered: object[], untracked: object[], missing: object[], clean: object[] }}
|
|
30
|
+
*/
|
|
31
|
+
export function classifyDrift(plan, files) {
|
|
32
|
+
const planMtime = new Date(plan?.createdAt || plan?.updatedAt || 0);
|
|
33
|
+
|
|
34
|
+
// Collect every file_hint declared by any operation/HU. Both keys
|
|
35
|
+
// are accepted for back-compat with v2 plans (HUs) and Canvas
|
|
36
|
+
// (Operations).
|
|
37
|
+
const ops = Array.isArray(plan?.operations) ? plan.operations
|
|
38
|
+
: Array.isArray(plan?.hus) ? plan.hus
|
|
39
|
+
: [];
|
|
40
|
+
const hinted = new Map(); // file → operation index
|
|
41
|
+
ops.forEach((op, idx) => {
|
|
42
|
+
const hint = op.file_hint || op.fileHint;
|
|
43
|
+
if (typeof hint === "string" && hint.length > 0) {
|
|
44
|
+
hinted.set(hint, idx);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const out = { covered: [], untracked: [], missing: [], clean: [] };
|
|
49
|
+
const seenInFs = new Set();
|
|
50
|
+
|
|
51
|
+
for (const f of files) {
|
|
52
|
+
if (!f?.path) continue;
|
|
53
|
+
seenInFs.add(f.path);
|
|
54
|
+
const opIdx = hinted.get(f.path);
|
|
55
|
+
const modified = f.mtime instanceof Date && f.mtime > planMtime;
|
|
56
|
+
|
|
57
|
+
if (opIdx === undefined) {
|
|
58
|
+
// Not referenced by any operation. Two sub-cases:
|
|
59
|
+
// - modified: real drift, propose new operation.
|
|
60
|
+
// - not modified: probably a pre-existing file from before the
|
|
61
|
+
// plan; ignore (no action).
|
|
62
|
+
if (modified) {
|
|
63
|
+
out.untracked.push({ path: f.path, mtime: f.mtime, suggestion: "new operation" });
|
|
64
|
+
}
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (modified) {
|
|
68
|
+
out.covered.push({ path: f.path, mtime: f.mtime, operationIndex: opIdx });
|
|
69
|
+
} else {
|
|
70
|
+
out.clean.push({ path: f.path, operationIndex: opIdx });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Missing: operation says a file should exist, but we never saw it.
|
|
75
|
+
for (const [hint, opIdx] of hinted.entries()) {
|
|
76
|
+
if (!seenInFs.has(hint)) {
|
|
77
|
+
out.missing.push({ path: hint, operationIndex: opIdx, suggestion: "remove operation OR fix the file_hint" });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Render a drift report as plain-text for the terminal.
|
|
86
|
+
*/
|
|
87
|
+
export function formatDriftReport(report, plan) {
|
|
88
|
+
const lines = [`Drift report — plan ${plan?.planId || "(unknown)"}`, ""];
|
|
89
|
+
if (report.untracked.length === 0 && report.missing.length === 0 && report.covered.length === 0) {
|
|
90
|
+
lines.push(" No drift detected.");
|
|
91
|
+
return lines.join("\n");
|
|
92
|
+
}
|
|
93
|
+
if (report.covered.length > 0) {
|
|
94
|
+
lines.push(`Covered (${report.covered.length}) — modified, an Operation references it; consider extending its Approach:`);
|
|
95
|
+
for (const c of report.covered) lines.push(` ~ ${c.path} [op #${c.operationIndex + 1}]`);
|
|
96
|
+
lines.push("");
|
|
97
|
+
}
|
|
98
|
+
if (report.untracked.length > 0) {
|
|
99
|
+
lines.push(`Untracked (${report.untracked.length}) — modified, NO Operation references it; propose new Operation:`);
|
|
100
|
+
for (const u of report.untracked) lines.push(` + ${u.path}`);
|
|
101
|
+
lines.push("");
|
|
102
|
+
}
|
|
103
|
+
if (report.missing.length > 0) {
|
|
104
|
+
lines.push(`Missing (${report.missing.length}) — Operation file_hint points at a file that doesn't exist:`);
|
|
105
|
+
for (const m of report.missing) lines.push(` ! ${m.path} [op #${m.operationIndex + 1}] — ${m.suggestion}`);
|
|
106
|
+
lines.push("");
|
|
107
|
+
}
|
|
108
|
+
if (report.clean.length > 0) {
|
|
109
|
+
lines.push(`Clean (${report.clean.length}) — covered, unchanged.`);
|
|
110
|
+
}
|
|
111
|
+
lines.push("", "Run with --json for machine-readable output. Future: --apply to propose Canvas patches (issue #540 follow-ups).");
|
|
112
|
+
return lines.join("\n");
|
|
113
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracker bootstrap — registers the configured task-tracker adapter into
|
|
3
|
+
* the orchestrator's integrations registry at runtime.
|
|
4
|
+
*
|
|
5
|
+
* This module exists so that `src/orchestrator/` never imports
|
|
6
|
+
* `src/planning-game/` (or any other tracker) directly — the architecture
|
|
7
|
+
* invariant `tests/architecture/tracker-coupling.test.js` enforces that.
|
|
8
|
+
* `runFlow` calls `ensureTrackerRegistered(config)` from here; this
|
|
9
|
+
* module owns the conditional import of each adapter.
|
|
10
|
+
*
|
|
11
|
+
* Adding a new tracker:
|
|
12
|
+
* 1. Write `src/adapters/<name>/adapter.js` with the hook surface
|
|
13
|
+
* documented in `src/orchestrator/integrations.js`.
|
|
14
|
+
* 2. Add a branch below that imports and registers it when the relevant
|
|
15
|
+
* config flag is true.
|
|
16
|
+
*
|
|
17
|
+
* The function is idempotent: once registered, subsequent calls are
|
|
18
|
+
* no-ops (the registry already has an entry for the slot).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { getIntegration, registerIntegration } from "./orchestrator/integrations.js";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Ensure the tracker adapter configured by `config` is registered under
|
|
25
|
+
* the `"tracker"` slot. Safe to call on every `runFlow` invocation.
|
|
26
|
+
*
|
|
27
|
+
* @param {object|null} config
|
|
28
|
+
* @returns {Promise<void>}
|
|
29
|
+
*/
|
|
30
|
+
export async function ensureTrackerRegistered(config) {
|
|
31
|
+
if (getIntegration("tracker")) return; // Already registered.
|
|
32
|
+
|
|
33
|
+
if (config?.planning_game?.enabled) {
|
|
34
|
+
try {
|
|
35
|
+
const { planningGameAdapter } = await import("./planning-game/pipeline-adapter.js");
|
|
36
|
+
registerIntegration("tracker", planningGameAdapter);
|
|
37
|
+
} catch { /* registration failed — non-blocking, adapter stays absent */ }
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// No tracker enabled — the orchestrator's `getIntegration("tracker")`
|
|
42
|
+
// calls will return undefined and every hook becomes an optional-chain
|
|
43
|
+
// no-op. That's the intended behavior for "tracker disabled" runs.
|
|
44
|
+
}
|
|
@@ -18,17 +18,21 @@ export async function checkBinary(name, versionArg = "--version") {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export async function detectAvailableAgents() {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
21
|
+
// PR-J (audit quick win): probe the 5 agent binaries in parallel
|
|
22
|
+
// via Promise.all instead of awaiting in a for-of. checkBinary is
|
|
23
|
+
// a pure shell-out (no shared state), so serialising them was
|
|
24
|
+
// wasted latency. With 5 agents at ~80ms each: O(n) → O(1).
|
|
25
|
+
return Promise.all(
|
|
26
|
+
KNOWN_AGENTS.map(async (agent) => {
|
|
27
|
+
const check = await checkBinary(agent.name);
|
|
28
|
+
return {
|
|
29
|
+
name: agent.name,
|
|
30
|
+
available: check.ok,
|
|
31
|
+
version: check.ok ? check.version : null,
|
|
32
|
+
install: agent.install
|
|
33
|
+
};
|
|
34
|
+
})
|
|
35
|
+
);
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
/**
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based RPC between a non-interactive Karajan run and the HU
|
|
3
|
+
* Board. When the runner needs an answer to a question (Solomon
|
|
4
|
+
* escalation, max-iterations resolution, anything wired to
|
|
5
|
+
* askQuestion) and stdin is unavailable (the board's ▶ Run plan
|
|
6
|
+
* spawns with stdio=ignore), it writes a prompt JSON to a known
|
|
7
|
+
* directory and polls for an "answer" sibling file. The board's
|
|
8
|
+
* chokidar watcher sees the prompt and surfaces it as a modal; the
|
|
9
|
+
* user's answer is POSTed back, written as the answer file, and the
|
|
10
|
+
* runner picks it up and resolves.
|
|
11
|
+
*
|
|
12
|
+
* Why files instead of a socket / SSE-back-channel:
|
|
13
|
+
* - Zero new server in the runner. A detached child has no listening
|
|
14
|
+
* port; opening one would mean discovery + auth + cleanup.
|
|
15
|
+
* - Survives a runner restart: if the user kills + resumes, the
|
|
16
|
+
* pending prompt is still on disk and the board can re-show it.
|
|
17
|
+
* - The board already watches `~/.karajan/`; one more glob is cheap.
|
|
18
|
+
*
|
|
19
|
+
* Layout:
|
|
20
|
+
* $KJ_HOME/prompts/<promptId>.json - { sessionId, question, context, createdAt }
|
|
21
|
+
* $KJ_HOME/prompts/<promptId>.answer.json - { answer, answeredAt } (written by board)
|
|
22
|
+
*
|
|
23
|
+
* The bridge owns the lifecycle: write the prompt, poll until the
|
|
24
|
+
* answer arrives, delete BOTH files, return the answer string (or
|
|
25
|
+
* null when the user picked "stop"). Aborts cleanly on signal or on
|
|
26
|
+
* a configurable timeout (default 30 min — long enough for a coffee).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { mkdir, writeFile, readFile, unlink, stat } from "node:fs/promises";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { homedir } from "node:os";
|
|
32
|
+
import { randomUUID } from "node:crypto";
|
|
33
|
+
|
|
34
|
+
const POLL_INTERVAL_MS = 500;
|
|
35
|
+
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000; // 30 min
|
|
36
|
+
|
|
37
|
+
function getPromptsDir() {
|
|
38
|
+
const home = process.env.KJ_HOME || join(homedir(), ".karajan");
|
|
39
|
+
return join(home, "prompts");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function exists(path) {
|
|
43
|
+
return stat(path).then(() => true, () => false);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Publish a prompt + wait for the answer. Returns the answer string,
|
|
48
|
+
* `null` when the user explicitly picked "stop", or rejects on
|
|
49
|
+
* timeout / abort.
|
|
50
|
+
*
|
|
51
|
+
* @param {object} args
|
|
52
|
+
* @param {string} args.sessionId
|
|
53
|
+
* @param {string} args.question
|
|
54
|
+
* @param {object} [args.context] - serialised verbatim into the prompt JSON
|
|
55
|
+
* @param {number} [args.timeoutMs]
|
|
56
|
+
* @param {AbortSignal} [args.signal]
|
|
57
|
+
* @param {object} [args.logger] - { info, warn } — optional, for breadcrumbs
|
|
58
|
+
* @returns {Promise<string|null>}
|
|
59
|
+
*/
|
|
60
|
+
export async function askThroughBoard({ sessionId, question, context = null, timeoutMs = DEFAULT_TIMEOUT_MS, signal = null, logger = null } = {}) {
|
|
61
|
+
if (!question) throw new Error("askThroughBoard: question is required");
|
|
62
|
+
|
|
63
|
+
const dir = getPromptsDir();
|
|
64
|
+
await mkdir(dir, { recursive: true });
|
|
65
|
+
|
|
66
|
+
const promptId = `prompt-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
67
|
+
const promptPath = join(dir, `${promptId}.json`);
|
|
68
|
+
const answerPath = join(dir, `${promptId}.answer.json`);
|
|
69
|
+
|
|
70
|
+
const payload = {
|
|
71
|
+
promptId,
|
|
72
|
+
sessionId: sessionId || null,
|
|
73
|
+
question,
|
|
74
|
+
context: context || null,
|
|
75
|
+
createdAt: new Date().toISOString(),
|
|
76
|
+
};
|
|
77
|
+
await writeFile(promptPath, JSON.stringify(payload, null, 2), "utf-8");
|
|
78
|
+
logger?.info?.(`[prompt-bridge] published ${promptId} — waiting for answer via the board`);
|
|
79
|
+
|
|
80
|
+
const startedAt = Date.now();
|
|
81
|
+
try {
|
|
82
|
+
while (true) {
|
|
83
|
+
if (signal?.aborted) throw new Error("askThroughBoard: aborted");
|
|
84
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
85
|
+
throw new Error(`askThroughBoard: timed out after ${Math.round(timeoutMs / 1000)}s waiting for answer to ${promptId}`);
|
|
86
|
+
}
|
|
87
|
+
if (await exists(answerPath)) {
|
|
88
|
+
const raw = await readFile(answerPath, "utf-8");
|
|
89
|
+
let answer;
|
|
90
|
+
try { ({ answer } = JSON.parse(raw)); } catch { answer = null; }
|
|
91
|
+
await unlink(answerPath).catch(() => {});
|
|
92
|
+
await unlink(promptPath).catch(() => {});
|
|
93
|
+
if (typeof answer === "string" && answer.trim().toLowerCase() === "stop") return null;
|
|
94
|
+
return typeof answer === "string" ? answer.trim() : null;
|
|
95
|
+
}
|
|
96
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
97
|
+
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
// Best-effort cleanup of the prompt file on error so it doesn't
|
|
100
|
+
// get stuck pending on the board forever.
|
|
101
|
+
await unlink(promptPath).catch(() => {});
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Test seam — exposes the resolved prompts dir for assertions. */
|
|
107
|
+
export function _getPromptsDirForTests() {
|
|
108
|
+
return getPromptsDir();
|
|
109
|
+
}
|