@kal-elsam/kairo-runtime 0.15.0 → 0.17.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/CHANGELOG.md +76 -0
- package/package.json +2 -1
- package/scripts/cockpit-smoke.mjs +1 -1
- package/scripts/ux-smoke-test.sh +3 -3
- package/src/cli.js +106 -11
- package/src/global/agent-capabilities/create-capability-adapter.js +2 -2
- package/src/global/architect/architect-cli.js +76 -0
- package/src/global/architect/architect-codex.js +146 -0
- package/src/global/architect/architect-manager.js +125 -0
- package/src/global/architect/architect-store.js +377 -0
- package/src/global/architect/architect-types.js +47 -0
- package/src/global/cli-help.js +12 -1
- package/src/global/cockpit/app.js +475 -0
- package/src/global/cockpit/card.js +111 -0
- package/src/global/cockpit/cli.js +33 -0
- package/src/global/cockpit/gauge.js +31 -0
- package/src/global/cockpit/project-overlay.js +683 -0
- package/src/global/cockpit/rows.js +148 -0
- package/src/global/cockpit/theme.js +118 -0
- package/src/global/cockpit/view.js +1263 -0
- package/src/global/control-plane/attention.js +141 -0
- package/src/global/control-plane/build-report.js +146 -0
- package/src/global/control-plane/cli.js +36 -0
- package/src/global/control-plane/constants.js +38 -0
- package/src/global/control-plane/gentle-adapters.js +183 -0
- package/src/global/control-plane/provider.js +69 -0
- package/src/global/control-plane/review-status.js +115 -0
- package/src/global/control-plane/sdd-status.js +49 -0
- package/src/global/control-plane/team.js +63 -0
- package/src/global/conversation/bootstrap-analyzer-adapters.js +251 -0
- package/src/global/conversation/cli.js +53 -0
- package/src/global/conversation/codex-sandbox.js +230 -0
- package/src/global/conversation/cursor-sandbox.js +215 -0
- package/src/global/conversation/project-analysis.js +204 -0
- package/src/global/conversation/project-profile.js +178 -0
- package/src/global/conversation/project-router.js +149 -0
- package/src/global/conversation/project-strategy-store.js +64 -0
- package/src/global/conversation/project-strategy.js +514 -0
- package/src/global/conversation/sanitized-snapshot.js +169 -0
- package/src/global/conversation/secret-scanner.js +71 -0
- package/src/global/conversation/service.js +1063 -0
- package/src/global/conversation/session-store.js +75 -0
- package/src/global/conversation/transcript-store.js +79 -0
- package/src/global/conversation/ui.js +195 -0
- package/src/global/intelligence/capability-scoring.js +480 -0
- package/src/global/intelligence/execution-router.js +444 -0
- package/src/global/intelligence/kairo-telemetry-source.js +59 -0
- package/src/global/intelligence/kairobench-runner.js +85 -0
- package/src/global/intelligence/kairobench-source.js +34 -0
- package/src/global/intelligence/kairobench-tasks.js +47 -0
- package/src/global/intelligence/model-candidate-catalog.js +456 -0
- package/src/global/intelligence/model-capability-registry-sources.js +145 -0
- package/src/global/intelligence/model-capability-registry.js +125 -0
- package/src/global/intelligence/model-intelligence.js +1646 -0
- package/src/global/intelligence/official-benchmark-snapshots.js +162 -0
- package/src/global/intelligence/quick-ask.js +149 -0
- package/src/global/intelligence/role-profiles.js +251 -0
- package/src/global/intelligence/skill-catalog.js +67 -0
- package/src/global/intelligence/subscription-pressure-source.js +41 -0
- package/src/global/mcp/kairo-mcp.js +51 -18
- package/src/global/mcp/work-snapshot-rule.js +4 -2
- package/src/global/mcp/workspace-binding.js +88 -0
- package/src/global/mcp/workspace-mcp-entry.js +74 -0
- package/src/global/mcp-install.js +8 -1
- package/src/global/observability/artificial-analysis-models.js +118 -0
- package/src/global/observability/claude-models.js +31 -0
- package/src/global/observability/claude-usage.js +112 -0
- package/src/global/observability/codex-models.js +96 -0
- package/src/global/observability/codex-usage.js +160 -0
- package/src/global/observability/cursor-auth.js +88 -0
- package/src/global/observability/cursor-models.js +101 -0
- package/src/global/observability/gentle-probe.js +30 -2
- package/src/global/observability/huggingface-leaderboard.js +97 -0
- package/src/global/observability/index.js +2 -1
- package/src/global/observability/opencode-models.js +101 -0
- package/src/global/observability/opencode-usage.js +162 -0
- package/src/global/paths.js +49 -2
- package/src/global/profile.js +23 -1
- package/src/global/runtime/execution-adapters/claude.js +63 -30
- package/src/global/runtime/execution-adapters/codex.js +9 -2
- package/src/global/runtime/execution-adapters/create-execution-adapter.js +6 -1
- package/src/global/runtime/execution-adapters/opencode.js +83 -18
- package/src/global/runtime/execution-worktree-manager.js +924 -0
- package/src/global/runtime/execution-worktree-orchestrator.js +194 -0
- package/src/global/runtime/execution-worktree-store.js +83 -0
- package/src/global/runtime/execution-worktree-types.js +45 -0
- package/src/global/runtime/run-events.js +38 -0
- package/src/global/runtime/run-manager.js +22 -6
- package/src/global/runtime/run-supervisor.js +41 -12
- package/src/global/runtime/usage-manager.js +96 -0
- package/src/global/runtime/usage-store.js +69 -0
- package/src/global/runtime/usage-types.js +62 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Real, evidence-only ProjectProfile: what Kairo can actually detect about
|
|
2
|
+
// THIS project (stack, commands, git recency, Graphify/CodeGraph/Engram
|
|
3
|
+
// availability, docs) — never a value invented for a signal that couldn't
|
|
4
|
+
// be collected. Every heavy detector here is REUSED from where it already
|
|
5
|
+
// exists in this codebase (detectProject, resolveGitHeadSha, probeGraphify,
|
|
6
|
+
// inspectEngramIntegration) rather than reimplemented, so this module stays
|
|
7
|
+
// a thin composition layer, not a second copy of that logic.
|
|
8
|
+
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { resolve } from "node:path";
|
|
13
|
+
import { detectProject } from "../../project-detection.js";
|
|
14
|
+
import { resolveGitHeadSha, probeGraphify, scrubGitOverrideEnv } from "../observability/graphify-probe.js";
|
|
15
|
+
import { inspectEngramIntegration } from "../integrations/engram-evidence.js";
|
|
16
|
+
|
|
17
|
+
export const PROJECT_PROFILE_SCHEMA = "kairo.project-profile/v1";
|
|
18
|
+
|
|
19
|
+
const SDD_DOC = "docs/ai/spec-driven-development.md";
|
|
20
|
+
const TDD_DOC = "docs/ai/test-driven-development.md";
|
|
21
|
+
const AGENTS_DOC = "AGENTS.md";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Real recent-history hotspots: which files changed most often in the last
|
|
25
|
+
* 90 days, via `git log --name-only` — bounded, fail-soft (never throws;
|
|
26
|
+
* returns an empty list for a non-git or history-less project). This is
|
|
27
|
+
* the ONLY new detector this module adds rather than reusing — everything
|
|
28
|
+
* else composes an existing real function.
|
|
29
|
+
* @param {string} cwd
|
|
30
|
+
* @returns {Array<{path: string, changes: number}>}
|
|
31
|
+
*/
|
|
32
|
+
export function detectGitHotspots(cwd, { spawn = spawnSync, timeoutMs = 5000, env = process.env, limit = 5 } = {}) {
|
|
33
|
+
try {
|
|
34
|
+
const cleanEnv = scrubGitOverrideEnv(env);
|
|
35
|
+
const result = spawn("git", ["log", "--since=90.days", "--name-only", "--pretty=format:"], {
|
|
36
|
+
cwd, encoding: "utf8", timeout: timeoutMs, env: cleanEnv, maxBuffer: 10 * 1024 * 1024
|
|
37
|
+
});
|
|
38
|
+
if (result.status !== 0) return [];
|
|
39
|
+
const counts = new Map();
|
|
40
|
+
for (const line of String(result.stdout ?? "").split("\n")) {
|
|
41
|
+
const path = line.trim();
|
|
42
|
+
if (!path) continue;
|
|
43
|
+
counts.set(path, (counts.get(path) ?? 0) + 1);
|
|
44
|
+
}
|
|
45
|
+
return [...counts.entries()]
|
|
46
|
+
.sort((a, b) => b[1] - a[1])
|
|
47
|
+
.slice(0, limit)
|
|
48
|
+
.map(([path, changes]) => ({ path, changes }));
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Real risks Kairo can actually detect — never a guessed or generic risk.
|
|
56
|
+
* @param {object} project - detectProject() result
|
|
57
|
+
* @param {string} root
|
|
58
|
+
* @returns {Array<{kind: string, detail: string}>}
|
|
59
|
+
*/
|
|
60
|
+
function detectRisks(project, root) {
|
|
61
|
+
const risks = [];
|
|
62
|
+
if (project.commands.test === "Not configured") risks.push({ kind: "no-test-command", detail: "No real test script detected in package.json." });
|
|
63
|
+
if (project.commands.lint === "Not configured" && project.commands.typeCheck === "Not configured") {
|
|
64
|
+
risks.push({ kind: "no-static-checks", detail: "No real lint or typecheck script detected." });
|
|
65
|
+
}
|
|
66
|
+
if (existsSync(resolve(root, ".env"))) risks.push({ kind: "env-file-present", detail: ".env present at project root — never read into evidence without explicit consent." });
|
|
67
|
+
return risks;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Real, detected role requirements — derived strictly from what
|
|
72
|
+
* detectProject actually found (a real build/test/lint command), never
|
|
73
|
+
* from a guess about what a "typical" project needs. Explorer/Architect
|
|
74
|
+
* stay baseline (every real project needs investigation + planning);
|
|
75
|
+
* Builder/Tester/Reviewer only appear when their real command exists.
|
|
76
|
+
* @param {object} project - detectProject() result
|
|
77
|
+
* @returns {Array<{role: string, capabilities: string[], reason: string}>}
|
|
78
|
+
*/
|
|
79
|
+
function detectRoleRequirements(project) {
|
|
80
|
+
const requirements = [
|
|
81
|
+
{ role: "Explorer", capabilities: ["reasoning", "instructionFollowing"], reason: "Baseline investigation role for every real project." },
|
|
82
|
+
{ role: "Architect", capabilities: ["reasoning", "coding", "instructionFollowing"], reason: "Baseline planning role for every real project." }
|
|
83
|
+
];
|
|
84
|
+
if (project.commands.build !== "Not configured" || project.stack !== "Unknown") {
|
|
85
|
+
requirements.push({ role: "Builder", capabilities: ["coding", "softwareExecution", "terminalExecution", "instructionFollowing"], reason: "Real stack/build command detected." });
|
|
86
|
+
}
|
|
87
|
+
if (project.commands.test !== "Not configured") {
|
|
88
|
+
requirements.push({ role: "Tester", capabilities: ["coding", "terminalExecution"], reason: `Real test command detected: ${project.commands.test}` });
|
|
89
|
+
requirements.push({ role: "Debugger", capabilities: ["reasoning", "coding", "terminalExecution", "softwareExecution"], reason: "Real test command implies real failures to debug." });
|
|
90
|
+
}
|
|
91
|
+
if (project.commands.lint !== "Not configured" || project.commands.typeCheck !== "Not configured") {
|
|
92
|
+
requirements.push({ role: "Reviewer", capabilities: ["reasoning", "coding"], reason: "Real lint/typecheck command detected." });
|
|
93
|
+
}
|
|
94
|
+
return requirements;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A real, deterministic fingerprint of the profile's own inputs (git HEAD
|
|
99
|
+
* when available, plus the real detected commands/stack) — changes exactly
|
|
100
|
+
* when the real evidence behind the profile changes, which is what
|
|
101
|
+
* ProjectStrategy's STALE detection compares against. Never a random id.
|
|
102
|
+
*/
|
|
103
|
+
function computeFingerprint({ headSha, project }) {
|
|
104
|
+
const basis = JSON.stringify({ headSha: headSha ?? "no-git", stack: project.stack, commands: project.commands });
|
|
105
|
+
return createHash("sha256").update(basis).digest("hex").slice(0, 16);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Composes every real detector above into one ProjectProfile — strictly
|
|
110
|
+
* read-only, no file writes, no provider calls. `confidence` reflects how
|
|
111
|
+
* much REAL evidence was actually collected, never the profile's own
|
|
112
|
+
* apparent completeness — a project with no git history and a minimal
|
|
113
|
+
* package.json genuinely IS "low" confidence, not something to round up.
|
|
114
|
+
* @param {{cwd: string}} args
|
|
115
|
+
* @param {object} [deps] - injectable for tests
|
|
116
|
+
* @returns {Promise<object>} ProjectProfile
|
|
117
|
+
*/
|
|
118
|
+
export async function computeProjectProfile({ cwd }, deps = {}) {
|
|
119
|
+
const detectProjectImpl = deps.detectProject ?? detectProject;
|
|
120
|
+
const resolveGitHeadShaImpl = deps.resolveGitHeadSha ?? resolveGitHeadSha;
|
|
121
|
+
const probeGraphifyImpl = deps.probeGraphify ?? probeGraphify;
|
|
122
|
+
const inspectEngramImpl = deps.inspectEngramIntegration ?? inspectEngramIntegration;
|
|
123
|
+
const detectGitHotspotsImpl = deps.detectGitHotspots ?? detectGitHotspots;
|
|
124
|
+
|
|
125
|
+
const root = resolve(cwd);
|
|
126
|
+
const project = await detectProjectImpl(root);
|
|
127
|
+
const headSha = resolveGitHeadShaImpl(root);
|
|
128
|
+
const graphify = await probeGraphifyImpl({ cwd: root, headSha });
|
|
129
|
+
const engram = inspectEngramImpl();
|
|
130
|
+
const codegraphPresent = existsSync(resolve(root, ".codegraph"));
|
|
131
|
+
const hotspots = headSha ? detectGitHotspotsImpl(root) : [];
|
|
132
|
+
|
|
133
|
+
const sdd = existsSync(resolve(root, SDD_DOC));
|
|
134
|
+
const tdd = existsSync(resolve(root, TDD_DOC));
|
|
135
|
+
const agentsDoc = existsSync(resolve(root, AGENTS_DOC));
|
|
136
|
+
|
|
137
|
+
const workflowCapabilities = [];
|
|
138
|
+
if (sdd) workflowCapabilities.push("sdd");
|
|
139
|
+
if (tdd) workflowCapabilities.push("tdd");
|
|
140
|
+
|
|
141
|
+
const evidence = [
|
|
142
|
+
{ kind: "package-manifest", detail: `packageManager=${project.packageManager}` },
|
|
143
|
+
{ kind: "git-head", detail: headSha ? `HEAD=${headSha.slice(0, 12)}` : "not a git repository (or HEAD unresolved)" },
|
|
144
|
+
{ kind: "graphify", detail: `state=${graphify.state}` },
|
|
145
|
+
{ kind: "codegraph", detail: codegraphPresent ? ".codegraph/ present" : ".codegraph/ absent" },
|
|
146
|
+
{ kind: "engram", detail: `status=${engram.status}` },
|
|
147
|
+
{ kind: "agents-doc", detail: agentsDoc ? "AGENTS.md present" : "AGENTS.md absent" }
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
// Real, honest tiers — never rounded up because the profile LOOKS
|
|
151
|
+
// complete. High requires git history (real recency signal) AND at
|
|
152
|
+
// least one real code-intelligence integration actually available.
|
|
153
|
+
let confidence = "low";
|
|
154
|
+
const hasCodeIntelligence = graphify.state === "available" || codegraphPresent || engram.status === "configured";
|
|
155
|
+
if (headSha && project.stack !== "Unknown") {
|
|
156
|
+
confidence = hasCodeIntelligence ? "high" : "medium";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
schema: PROJECT_PROFILE_SCHEMA,
|
|
161
|
+
projectName: project.name,
|
|
162
|
+
fingerprint: computeFingerprint({ headSha, project }),
|
|
163
|
+
stack: [project.stack],
|
|
164
|
+
architecture: { pattern: project.architecturePattern },
|
|
165
|
+
quality: {
|
|
166
|
+
testCommand: project.commands.test !== "Not configured" ? project.commands.test : null,
|
|
167
|
+
lintCommand: project.commands.lint !== "Not configured" ? project.commands.lint : null,
|
|
168
|
+
typeCheckCommand: project.commands.typeCheck !== "Not configured" ? project.commands.typeCheck : null,
|
|
169
|
+
buildCommand: project.commands.build !== "Not configured" ? project.commands.build : null
|
|
170
|
+
},
|
|
171
|
+
risks: detectRisks(project, root),
|
|
172
|
+
hotspots,
|
|
173
|
+
workflowCapabilities,
|
|
174
|
+
roleRequirements: detectRoleRequirements(project),
|
|
175
|
+
evidence,
|
|
176
|
+
confidence
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Deterministic role -> real model resolver for Kairo's approved
|
|
2
|
+
// ProjectStrategy.projectTeam. This is pure routing logic over already-
|
|
3
|
+
// decided data (the human-approved team + current provider eligibility) —
|
|
4
|
+
// it never spends another model call, never ranks anything itself, and
|
|
5
|
+
// never reclassifies a task into a role from keywords: the caller (the
|
|
6
|
+
// future Builder/Debugger/Tester orchestrator) always hands in an explicit
|
|
7
|
+
// role. Execution itself — actually launching a run against the resolved
|
|
8
|
+
// model, worktrees, checkpoints — is a separate, later increment; this
|
|
9
|
+
// module only ever decides WHAT would run, never runs it.
|
|
10
|
+
//
|
|
11
|
+
// Alternative selection is DOMAIN policy, not UI policy: when a role's
|
|
12
|
+
// approved assignment becomes unroutable, this module is also the one
|
|
13
|
+
// place that decides what real alternative (if any) to suggest — never
|
|
14
|
+
// the cockpit, the CLI, or an execution layer independently re-deriving
|
|
15
|
+
// one, which would risk three different answers for the same real state.
|
|
16
|
+
// The alternative itself is never invented here either — it's whichever
|
|
17
|
+
// real fallback candidate buildEfficientTeam already found for this role
|
|
18
|
+
// at analysis time (see project-strategy.js's projectTeam.fallback,
|
|
19
|
+
// persisted, not recomputed) — this module only checks whether that
|
|
20
|
+
// persisted candidate is STILL real-eligible right now.
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether Kairo can launch an automatic run against this real candidate
|
|
24
|
+
* right now — reads model-candidate-catalog.js's own `accessMode`
|
|
25
|
+
* ("automatic"|"manual"), the canonical source, never a hardcoded
|
|
26
|
+
* adapterId list. Cursor/OpenCode Go are "manual" today because
|
|
27
|
+
* resolveAccessMode says so, not because this module knows their names —
|
|
28
|
+
* if OpenCode Go ever gets real, proven automatic execution, that change
|
|
29
|
+
* lands once in resolveAccessMode and this router picks it up for free,
|
|
30
|
+
* with no adapter-list edit needed here.
|
|
31
|
+
*/
|
|
32
|
+
function isAutomatic(model) {
|
|
33
|
+
return model?.accessMode === "automatic";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const PROJECT_ROUTE_DECISION = {
|
|
37
|
+
ROUTED: "ROUTED",
|
|
38
|
+
MANUAL_HANDOFF: "MANUAL_HANDOFF",
|
|
39
|
+
WAIT_FOR_PROJECT_TEAM: "WAIT_FOR_PROJECT_TEAM"
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function assignmentRef(model, assignmentSource) {
|
|
43
|
+
if (!model) return null;
|
|
44
|
+
return { provider: model.adapterId, model, assignmentSource };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A real candidate is only ever offered as `suggestedAlternative` when it's currently automatically-executable — the same bar ROUTED itself requires. Never suggests another manual-only or currently-ineligible provider; honestly null instead. */
|
|
48
|
+
function routableAlternative(model, eligibility) {
|
|
49
|
+
if (!model) return null;
|
|
50
|
+
if (!isAutomatic(model)) return null;
|
|
51
|
+
if (eligibility[model.adapterId]?.ok !== true) return null;
|
|
52
|
+
return { provider: model.adapterId, model };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function blocked(role, strategyFingerprint, why, { blockedAssignment = null, suggestedAlternative = null } = {}) {
|
|
56
|
+
return {
|
|
57
|
+
decision: PROJECT_ROUTE_DECISION.WAIT_FOR_PROJECT_TEAM, role, strategyFingerprint, why,
|
|
58
|
+
provider: null, model: null, assignmentSource: null,
|
|
59
|
+
blockedAssignment, suggestedAlternative
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolves one role to the real model Kairo would delegate to right now,
|
|
65
|
+
* strictly from the approved ProjectStrategy — never the global QUALITY/
|
|
66
|
+
* EFFICIENT team, and never a fabricated fallback.
|
|
67
|
+
*
|
|
68
|
+
* Blocking rules, checked in order:
|
|
69
|
+
* 1. No strategy, or not ACTIVE (suggested/stale), or missing `projectTeam`
|
|
70
|
+
* entirely (an old strategy built before projectTeam existed) ->
|
|
71
|
+
* WAIT_FOR_PROJECT_TEAM, no blockedAssignment/suggestedAlternative
|
|
72
|
+
* (there's no real known assignment to block or suggest around). No
|
|
73
|
+
* silent migration — a pre-projectTeam strategy must be re-analyzed
|
|
74
|
+
* and re-approved.
|
|
75
|
+
* 2. The role has no real projectTeam entry, or that entry's model is
|
|
76
|
+
* null (no real eligible candidate was ever found for it) ->
|
|
77
|
+
* WAIT_FOR_PROJECT_TEAM, same as above.
|
|
78
|
+
* 3. The assigned model's own real `accessMode` isn't "automatic" (Cursor/
|
|
79
|
+
* OpenCode Go today, per resolveAccessMode — see isAutomatic) ->
|
|
80
|
+
* MANUAL_HANDOFF, still naming the real model/provider so the caller
|
|
81
|
+
* can show a concrete handoff ("Continue in Cursor with <model>"),
|
|
82
|
+
* never a bare "not supported".
|
|
83
|
+
* 4. The assigned provider isn't currently eligible (quota/availability
|
|
84
|
+
* changed since the strategy was approved) -> WAIT_FOR_PROJECT_TEAM,
|
|
85
|
+
* with `blockedAssignment` naming the real unavailable model/reason and
|
|
86
|
+
* `suggestedAlternative` set to the strategy's own PERSISTED fallback
|
|
87
|
+
* for this role (see project-strategy.js) when that fallback is
|
|
88
|
+
* itself currently real-eligible and automatically executable —
|
|
89
|
+
* otherwise honestly null. Never a silent automatic substitution: the
|
|
90
|
+
* caller must still explicitly confirm before anything runs on the
|
|
91
|
+
* suggested alternative.
|
|
92
|
+
* 5. Otherwise -> ROUTED, with the real provider/model/assignmentSource
|
|
93
|
+
* this role delegates to.
|
|
94
|
+
* @param {object} args
|
|
95
|
+
* @param {string} args.role - given explicitly by the caller, never
|
|
96
|
+
* inferred from task text.
|
|
97
|
+
* @param {object|null} args.strategy - the persisted ProjectStrategy, or
|
|
98
|
+
* null when none exists yet.
|
|
99
|
+
* @param {Record<string, {ok: boolean, reason?: string}>} [args.eligibility] -
|
|
100
|
+
* CURRENT provider eligibility — may have changed since the strategy was
|
|
101
|
+
* built/approved.
|
|
102
|
+
* @returns {{decision: "ROUTED"|"MANUAL_HANDOFF"|"WAIT_FOR_PROJECT_TEAM", role: string, provider: string|null, model: object|null, assignmentSource: string|null, strategyFingerprint: string|null, blockedAssignment: {provider: string, model: object, assignmentSource: string}|null, suggestedAlternative: {provider: string, model: object}|null, why: string}}
|
|
103
|
+
*/
|
|
104
|
+
export function resolveProjectRoute({ role, strategy, eligibility = {} }) {
|
|
105
|
+
const strategyFingerprint = strategy?.profileFingerprint ?? null;
|
|
106
|
+
|
|
107
|
+
if (!strategy) {
|
|
108
|
+
return blocked(role, strategyFingerprint, "No project strategy exists yet — run /project to analyze and approve one.");
|
|
109
|
+
}
|
|
110
|
+
if (strategy.status !== "active") {
|
|
111
|
+
return blocked(role, strategyFingerprint, `Project strategy is ${strategy.status?.toUpperCase() ?? "UNKNOWN"}, not ACTIVE — approve it before Kairo can delegate automatically.`);
|
|
112
|
+
}
|
|
113
|
+
if (!Array.isArray(strategy.projectTeam)) {
|
|
114
|
+
return blocked(role, strategyFingerprint, "This project strategy was approved before projectTeam existed — re-analyze and approve to enable automatic delegation.");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const entry = strategy.projectTeam.find((e) => e.role === role);
|
|
118
|
+
if (!entry || !entry.model) {
|
|
119
|
+
return blocked(role, strategyFingerprint, `No real eligible model was assigned to ${role} in this project's team.`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const { model, assignmentSource, fallback = null } = entry;
|
|
123
|
+
|
|
124
|
+
if (!isAutomatic(model)) {
|
|
125
|
+
return {
|
|
126
|
+
decision: PROJECT_ROUTE_DECISION.MANUAL_HANDOFF, role, provider: model.adapterId, model, assignmentSource, strategyFingerprint,
|
|
127
|
+
blockedAssignment: null, suggestedAlternative: null,
|
|
128
|
+
why: `${model.adapterId} isn't executable by Kairo automatically — continue manually with ${model.displayName ?? model.modelId}.`
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (eligibility[model.adapterId]?.ok !== true) {
|
|
133
|
+
const reason = eligibility[model.adapterId]?.reason ?? "unknown reason";
|
|
134
|
+
const suggestedAlternative = routableAlternative(fallback, eligibility);
|
|
135
|
+
const why = suggestedAlternative
|
|
136
|
+
? `${model.adapterId} is not currently eligible (${reason}) — no automatic substitution; confirm the suggested alternative for ${role} before proceeding.`
|
|
137
|
+
: `${model.adapterId} is not currently eligible (${reason}) — no automatic alternative is available for ${role} right now.`;
|
|
138
|
+
return blocked(role, strategyFingerprint, why, {
|
|
139
|
+
blockedAssignment: assignmentRef(model, assignmentSource),
|
|
140
|
+
suggestedAlternative
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
decision: PROJECT_ROUTE_DECISION.ROUTED, role, provider: model.adapterId, model, assignmentSource, strategyFingerprint,
|
|
146
|
+
blockedAssignment: null, suggestedAlternative: null,
|
|
147
|
+
why: `${role} delegates to ${model.displayName ?? model.modelId} per the approved project team.`
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Persists ProjectStrategy — the approved (or suggested) real per-project
|
|
2
|
+
// role assignment — under the same global `~/.harness/sessions/<projectKey>/`
|
|
3
|
+
// tree session-store.js and transcript-store.js already use, but as its
|
|
4
|
+
// own separate file (project-strategy.json): a strategy's lifecycle
|
|
5
|
+
// (suggested -> approved -> stale) is independent of both the WorkMode and
|
|
6
|
+
// the chat transcript, and none of the three should need to read the
|
|
7
|
+
// others just to change.
|
|
8
|
+
|
|
9
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { harnessHomePaths } from "../paths.js";
|
|
12
|
+
import { projectKeyForPath } from "../next/project-key.js";
|
|
13
|
+
import { writeAtomicJson } from "../runtime/write-atomic-json.js";
|
|
14
|
+
|
|
15
|
+
export const PROJECT_STRATEGY_SCHEMA = "kairo.project-strategy/v1";
|
|
16
|
+
export const PROJECT_STRATEGY_STATUSES = ["suggested", "active", "stale"];
|
|
17
|
+
|
|
18
|
+
function strategyPath(homeDir, projectRoot) {
|
|
19
|
+
const { sessionsDir } = harnessHomePaths(homeDir);
|
|
20
|
+
return join(sessionsDir, projectKeyForPath(projectRoot), "project-strategy.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Reads the persisted ProjectStrategy for a project. Returns `null` on a
|
|
25
|
+
* missing or malformed file — unlike session-store's readSession, there is
|
|
26
|
+
* no safe default strategy to fall back to (a project genuinely has NOT
|
|
27
|
+
* been analyzed yet until one is computed), so `null` here is the honest
|
|
28
|
+
* "NOT_ANALYZED" signal the cockpit checks for.
|
|
29
|
+
* @param {string} homeDir
|
|
30
|
+
* @param {string} projectRoot
|
|
31
|
+
* @returns {Promise<object|null>}
|
|
32
|
+
*/
|
|
33
|
+
export async function readProjectStrategy(homeDir, projectRoot, deps = {}) {
|
|
34
|
+
const read = deps.readFile ?? readFile;
|
|
35
|
+
try {
|
|
36
|
+
const raw = await read(strategyPath(homeDir, projectRoot), "utf8");
|
|
37
|
+
const doc = JSON.parse(raw);
|
|
38
|
+
if (doc?.schema !== PROJECT_STRATEGY_SCHEMA || !PROJECT_STRATEGY_STATUSES.includes(doc.status)) return null;
|
|
39
|
+
return doc;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Persists a ProjectStrategy (create or replace — a strategy is always
|
|
47
|
+
* recomputed as a whole document, never patched field by field, so a
|
|
48
|
+
* stale nested field can never survive a refresh by accident).
|
|
49
|
+
* @param {string} homeDir
|
|
50
|
+
* @param {string} projectRoot
|
|
51
|
+
* @param {object} strategy - a ProjectStrategy document (see project-strategy.js)
|
|
52
|
+
*/
|
|
53
|
+
export async function writeProjectStrategy(homeDir, projectRoot, strategy, deps = {}) {
|
|
54
|
+
if (!PROJECT_STRATEGY_STATUSES.includes(strategy?.status)) {
|
|
55
|
+
throw new Error(`ProjectStrategy must have a real status (one of ${PROJECT_STRATEGY_STATUSES.join(", ")}).`);
|
|
56
|
+
}
|
|
57
|
+
const mkdirImpl = deps.mkdir ?? mkdir;
|
|
58
|
+
const writeJson = deps.writeAtomicJson ?? writeAtomicJson;
|
|
59
|
+
const path = strategyPath(homeDir, projectRoot);
|
|
60
|
+
const doc = { ...strategy, schema: PROJECT_STRATEGY_SCHEMA };
|
|
61
|
+
await mkdirImpl(dirname(path), { recursive: true });
|
|
62
|
+
await writeJson(path, doc);
|
|
63
|
+
return doc;
|
|
64
|
+
}
|