@kal-elsam/kairo-runtime 0.16.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 +50 -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 +96 -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 +10 -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/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/huggingface-leaderboard.js +97 -0
- 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,215 @@
|
|
|
1
|
+
// Real OS-level filesystem confinement for the Cursor CLI (cursor-agent),
|
|
2
|
+
// used ONLY for Bootstrap Analysis, mirroring codex-sandbox.js exactly —
|
|
3
|
+
// general ASK never routes Cursor through this module.
|
|
4
|
+
//
|
|
5
|
+
// Empirically proven necessary AND sufficient, not assumed: Cursor's own
|
|
6
|
+
// `--sandbox enabled` (documented in `cursor-agent --help` as "Explicitly
|
|
7
|
+
// enable or disable sandbox mode") does NOT confine file reads to
|
|
8
|
+
// --workspace — a real absolute-path read outside the workspace
|
|
9
|
+
// succeeded and disclosed real content under `--sandbox enabled` alone.
|
|
10
|
+
// The same external sandbox-exec wrapper approach that closed this gap
|
|
11
|
+
// for Codex (codex-sandbox.js) was then independently canary-tested
|
|
12
|
+
// against the real cursor-agent CLI and DOES hold: wrapping `cursor-agent`
|
|
13
|
+
// in an external macOS sandbox-exec profile, with Cursor's own internal
|
|
14
|
+
// sandbox disabled (`--sandbox disabled`, so only the external wrapper
|
|
15
|
+
// enforces anything — avoids any risk of the kind of nested-sandbox
|
|
16
|
+
// conflict that broke every Codex tool call when both layers tried to
|
|
17
|
+
// sandbox at once), produces a real, held boundary: an in-bounds read
|
|
18
|
+
// succeeds, an out-of-bounds absolute-path read is denied
|
|
19
|
+
// ("Permission denied", not a model claim in prose).
|
|
20
|
+
//
|
|
21
|
+
// Two real gotchas found only by testing the ACTUAL cursor-agent binary
|
|
22
|
+
// (not assumed from Codex's profile):
|
|
23
|
+
// 1. cursor-agent's real binary lives under `~/.local` (a wrapper script
|
|
24
|
+
// at ~/.local/bin/cursor-agent execs the real binary under
|
|
25
|
+
// ~/.local/share/cursor-agent/versions/...) — that whole tree must be
|
|
26
|
+
// readable+executable, or the CLI can't even launch.
|
|
27
|
+
// 2. cursor-agent's stored auth ("Authentication tokens stored
|
|
28
|
+
// securely") lives in the macOS Keychain, not a plain file under its
|
|
29
|
+
// config home — ~/Library/Keychains must be read+write accessible or
|
|
30
|
+
// every real invocation fails with "Authentication required" even
|
|
31
|
+
// though the session is genuinely logged in. The wrapper script also
|
|
32
|
+
// writes to /dev/null, which needs explicit file-write access (unlike
|
|
33
|
+
// Codex's profile, which never needed it).
|
|
34
|
+
//
|
|
35
|
+
// A workspace cursor-agent has never seen before triggers an interactive
|
|
36
|
+
// "Workspace Trust Required" prompt that blocks non-interactive use —
|
|
37
|
+
// `--trust` is required for automation, safe here because snapshotRoot is
|
|
38
|
+
// always Kairo's own freshly-generated temp directory, never an
|
|
39
|
+
// arbitrary user-chosen one (same category of bypass as Codex's
|
|
40
|
+
// --skip-git-repo-check for a snapshot that deliberately excludes .git).
|
|
41
|
+
|
|
42
|
+
import { spawn as defaultSpawn } from "node:child_process";
|
|
43
|
+
import { access, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
|
44
|
+
import { constants as fsConstants } from "node:fs";
|
|
45
|
+
import { tmpdir, homedir } from "node:os";
|
|
46
|
+
import { join } from "node:path";
|
|
47
|
+
|
|
48
|
+
const SANDBOX_EXEC_PATH = "/usr/bin/sandbox-exec";
|
|
49
|
+
const DEFAULT_TIMEOUT_MS = 180_000;
|
|
50
|
+
|
|
51
|
+
const SAFE_ENV_KEYS = Object.freeze([
|
|
52
|
+
"PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE",
|
|
53
|
+
"TMPDIR", "TERM", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
|
|
54
|
+
"http_proxy", "https_proxy", "no_proxy", "NODE_EXTRA_CA_CERTS"
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
function buildSandboxedCursorEnv(sourceEnv = process.env) {
|
|
58
|
+
const env = Object.create(null);
|
|
59
|
+
for (const key of SAFE_ENV_KEYS) {
|
|
60
|
+
if (sourceEnv[key] != null && sourceEnv[key] !== "") env[key] = sourceEnv[key];
|
|
61
|
+
}
|
|
62
|
+
return env;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function unknown(error) {
|
|
66
|
+
return { status: "error", answer: null, error: String(error) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function isCursorSandboxSupported(deps = {}) {
|
|
70
|
+
if ((deps.platform ?? process.platform) !== "darwin") return false;
|
|
71
|
+
try {
|
|
72
|
+
await (deps.access ?? access)(SANDBOX_EXEC_PATH, fsConstants.X_OK);
|
|
73
|
+
return true;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function getCursorIsolationStatus(deps = {}) {
|
|
80
|
+
const available = await isCursorSandboxSupported(deps);
|
|
81
|
+
return {
|
|
82
|
+
available,
|
|
83
|
+
platform: deps.platform ?? process.platform,
|
|
84
|
+
boundaryVerified: available,
|
|
85
|
+
reason: available
|
|
86
|
+
? null
|
|
87
|
+
: "OS-level read confinement for Cursor (sandbox-exec) is only implemented for macOS; Cursor is not eligible for isolated Bootstrap Analysis on this platform."
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function resolvedForms(path, deps) {
|
|
92
|
+
const forms = new Set([path]);
|
|
93
|
+
try {
|
|
94
|
+
forms.add(await (deps.realpath ?? realpath)(path));
|
|
95
|
+
} catch {
|
|
96
|
+
// fine — the literal form alone still covers it
|
|
97
|
+
}
|
|
98
|
+
return [...forms];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function subpathRules(paths) {
|
|
102
|
+
return paths.map((p) => ` (subpath "${p}")`).join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function buildCursorSandboxProfile({
|
|
106
|
+
snapshotRoot,
|
|
107
|
+
cursorHome = join(homedir(), ".cursor"),
|
|
108
|
+
cursorLocalHome = join(homedir(), ".local"),
|
|
109
|
+
keychainsHome = join(homedir(), "Library", "Keychains")
|
|
110
|
+
}, deps = {}) {
|
|
111
|
+
const snapshotForms = await resolvedForms(snapshotRoot, deps);
|
|
112
|
+
const cursorHomeForms = await resolvedForms(cursorHome, deps);
|
|
113
|
+
const cursorLocalForms = await resolvedForms(cursorLocalHome, deps);
|
|
114
|
+
const keychainsForms = await resolvedForms(keychainsHome, deps);
|
|
115
|
+
const readableExtra = [
|
|
116
|
+
"/usr", "/System", "/bin", "/sbin", "/private/var/db/dyld", "/Library", "/opt", "/private/etc"
|
|
117
|
+
];
|
|
118
|
+
return `(version 1)
|
|
119
|
+
(deny default)
|
|
120
|
+
(allow process-fork)
|
|
121
|
+
(allow process-exec)
|
|
122
|
+
(allow file-read-metadata (subpath "/"))
|
|
123
|
+
(allow file-read-data (literal "/"))
|
|
124
|
+
(allow file-read*
|
|
125
|
+
${subpathRules([...snapshotForms, ...cursorHomeForms, ...cursorLocalForms, ...keychainsForms, ...readableExtra])}
|
|
126
|
+
(literal "/dev/null")
|
|
127
|
+
(literal "/dev/urandom")
|
|
128
|
+
(literal "/dev/tty"))
|
|
129
|
+
(allow file-write*
|
|
130
|
+
(literal "/dev/null")
|
|
131
|
+
${subpathRules([...snapshotForms, ...cursorHomeForms, ...keychainsForms, "/private/var/folders", "/private/tmp"])})
|
|
132
|
+
(allow file-read-metadata (subpath "/private/var/folders"))
|
|
133
|
+
(allow sysctl-read)
|
|
134
|
+
(allow mach-lookup)
|
|
135
|
+
(allow signal (target self))
|
|
136
|
+
(allow network*)
|
|
137
|
+
(allow system-socket)
|
|
138
|
+
`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Runs a real, OS-sandboxed Cursor Bootstrap Analysis question. The ONLY
|
|
143
|
+
* intended caller is bootstrap-analyzer-adapters.js's Cursor adapter.
|
|
144
|
+
* Fails closed with `status: "error", error: "isolation_unavailable"`
|
|
145
|
+
* (never a silent fallback to Cursor's own non-confining --sandbox
|
|
146
|
+
* enabled) when this platform has no verified boundary.
|
|
147
|
+
* @param {object} args
|
|
148
|
+
* @param {string} args.question
|
|
149
|
+
* @param {string|null} [args.model] - omit (or pass null) for Cursor Auto
|
|
150
|
+
* @param {string} args.snapshotRoot
|
|
151
|
+
*/
|
|
152
|
+
export async function runCursorSandboxedBootstrap({
|
|
153
|
+
question, model = null, snapshotRoot,
|
|
154
|
+
cursorHome = join(homedir(), ".cursor"), cursorLocalHome = join(homedir(), ".local"),
|
|
155
|
+
keychainsHome = join(homedir(), "Library", "Keychains"),
|
|
156
|
+
spawn = defaultSpawn, timeoutMs = DEFAULT_TIMEOUT_MS, sourceEnv = process.env, deps = {}
|
|
157
|
+
}) {
|
|
158
|
+
const isolation = await getCursorIsolationStatus(deps);
|
|
159
|
+
if (!isolation.available) {
|
|
160
|
+
return { status: "error", answer: null, error: "isolation_unavailable", isolation };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let workDir;
|
|
164
|
+
try {
|
|
165
|
+
workDir = await (deps.mkdtemp ?? mkdtemp)(join(tmpdir(), "kairo-cursor-sandbox-"));
|
|
166
|
+
} catch (error) {
|
|
167
|
+
return { ...unknown(error?.message ?? error), isolation };
|
|
168
|
+
}
|
|
169
|
+
const profilePath = join(workDir, "bootstrap.sb");
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const profile = await buildCursorSandboxProfile({ snapshotRoot, cursorHome, cursorLocalHome, keychainsHome }, deps);
|
|
173
|
+
await (deps.writeFile ?? writeFile)(profilePath, profile, "utf8");
|
|
174
|
+
|
|
175
|
+
const args = [
|
|
176
|
+
"-f", profilePath, "cursor-agent", "-p", question,
|
|
177
|
+
"--output-format", "json", "--mode", "ask", "--sandbox", "disabled",
|
|
178
|
+
"--workspace", snapshotRoot, "--trust"
|
|
179
|
+
];
|
|
180
|
+
if (model) args.push("--model", model);
|
|
181
|
+
|
|
182
|
+
const env = buildSandboxedCursorEnv(sourceEnv);
|
|
183
|
+
const result = await new Promise((resolve) => {
|
|
184
|
+
let child;
|
|
185
|
+
try {
|
|
186
|
+
child = spawn("sandbox-exec", args, { cwd: snapshotRoot, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
187
|
+
} catch (error) {
|
|
188
|
+
resolve(unknown(error?.message ?? error));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
let stdout = "";
|
|
192
|
+
let finished = false;
|
|
193
|
+
const timer = setTimeout(() => finish(unknown("sandboxed cursor-agent -p timed out")), timeoutMs);
|
|
194
|
+
function finish(res) {
|
|
195
|
+
if (finished) return;
|
|
196
|
+
finished = true;
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
try { child.kill?.(); } catch { /* best effort */ }
|
|
199
|
+
resolve(res);
|
|
200
|
+
}
|
|
201
|
+
child.stdout?.on("data", (chunk) => { stdout += chunk; });
|
|
202
|
+
child.once?.("error", (error) => finish(unknown(error?.message ?? error)));
|
|
203
|
+
child.once?.("close", () => {
|
|
204
|
+
let parsed;
|
|
205
|
+
try { parsed = JSON.parse(stdout); } catch { return finish(unknown("malformed JSON from sandboxed cursor-agent -p")); }
|
|
206
|
+
const answer = parsed?.result ?? parsed?.text ?? parsed?.message ?? null;
|
|
207
|
+
if (typeof answer !== "string") return finish(unknown("no result text in sandboxed cursor-agent -p response"));
|
|
208
|
+
finish({ status: "answered", answer, error: null });
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
return { ...result, isolation };
|
|
212
|
+
} finally {
|
|
213
|
+
await (deps.rm ?? rm)(workDir, { recursive: true, force: true }).catch(() => {});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// The real Bootstrap Analyst step: a chosen real model actually reads the
|
|
2
|
+
// project (via askProvider — the same real, read-only, no-file-write path
|
|
3
|
+
// ASK mode already uses; never a new execution surface) and returns a
|
|
4
|
+
// structured ProjectAnalysis, validated against a real schema before
|
|
5
|
+
// anything downstream trusts it. An invalid or unparseable response is
|
|
6
|
+
// rejected outright — no ProjectStrategy is ever built from it.
|
|
7
|
+
//
|
|
8
|
+
// The deterministic step (deriveRoleRequirements) then turns that
|
|
9
|
+
// validated analysis into real RoleNeed[] — sanitized against the known
|
|
10
|
+
// capability vocabulary, unioned with the project's own mechanical floor
|
|
11
|
+
// (real test/lint/build commands, already computed by project-profile.js)
|
|
12
|
+
// so a thin or low-confidence analysis can never leave the project with
|
|
13
|
+
// literally zero real role requirements.
|
|
14
|
+
|
|
15
|
+
export const PROJECT_ANALYSIS_SCHEMA = "kairo.project-analysis/v1";
|
|
16
|
+
|
|
17
|
+
// The only real capabilities the scoring engine (capability-scoring.js)
|
|
18
|
+
// actually understands — any other token in the analyst's own output is
|
|
19
|
+
// dropped rather than trusted, so a hallucinated capability name can never
|
|
20
|
+
// corrupt scoring (worst case: a role with zero recognized capabilities
|
|
21
|
+
// simply never activates — fails closed, not open).
|
|
22
|
+
const KNOWN_CAPABILITIES = new Set(["reasoning", "coding", "terminalExecution", "softwareExecution", "instructionFollowing"]);
|
|
23
|
+
const KNOWN_ROLES = new Set(["Explorer", "Architect", "Builder", "Debugger", "Tester", "Reviewer"]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The real, limited context package the Bootstrap Analyst receives — never
|
|
27
|
+
* the whole repo dumped in, and never anything the analyst could mistake
|
|
28
|
+
* for permission to write: just what project-profile.js already collected
|
|
29
|
+
* read-only (stack, real build/test/lint commands, real git hotspots,
|
|
30
|
+
* real workflow docs present). The analyst can still read further real
|
|
31
|
+
* files on its own (it runs inside `cwd`), but this is its starting brief.
|
|
32
|
+
* @param {object} profile - computeProjectProfile() result
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function buildAnalystPrompt(profile) {
|
|
36
|
+
const lines = [
|
|
37
|
+
"You are Kairo's Bootstrap Analyst. Investigate this real project, READ-ONLY — never propose or make any file change.",
|
|
38
|
+
"You may read real files in this working directory to inform your answer, but do not modify anything.",
|
|
39
|
+
"",
|
|
40
|
+
"## Known real evidence",
|
|
41
|
+
`Project: ${profile.projectName}`,
|
|
42
|
+
`Stack: ${profile.stack.join(", ") || "unknown"}`,
|
|
43
|
+
`Architecture pattern: ${profile.architecture?.pattern ?? "unknown"}`,
|
|
44
|
+
`Build command: ${profile.quality.buildCommand ?? "none detected"}`,
|
|
45
|
+
`Test command: ${profile.quality.testCommand ?? "none detected"}`,
|
|
46
|
+
`Lint/typecheck: ${profile.quality.lintCommand ?? profile.quality.typeCheckCommand ?? "none detected"}`,
|
|
47
|
+
`Real git hotspots (most-changed files, last 90 days): ${profile.hotspots.map((h) => h.path).join(", ") || "none"}`,
|
|
48
|
+
`Workflow docs present: ${profile.workflowCapabilities.join(", ") || "none"}`,
|
|
49
|
+
`Known risks: ${profile.risks.map((r) => r.detail).join("; ") || "none"}`,
|
|
50
|
+
"",
|
|
51
|
+
"## Task",
|
|
52
|
+
"Respond with ONLY one JSON object (no prose, no markdown fences) matching exactly this shape:",
|
|
53
|
+
JSON.stringify({
|
|
54
|
+
architectureTraits: ["string"], complexitySignals: ["string"], criticalAreas: ["string"],
|
|
55
|
+
contextNeeds: ["string"], workflowNeeds: ["string"],
|
|
56
|
+
recommendedRoleNeeds: [{
|
|
57
|
+
role: "Explorer|Architect|Builder|Debugger|Tester|Reviewer",
|
|
58
|
+
capabilities: ["reasoning|coding|terminalExecution|softwareExecution|instructionFollowing"],
|
|
59
|
+
reason: "string", evidence: ["real file path you actually read that supports THIS role need"]
|
|
60
|
+
}],
|
|
61
|
+
uncertainties: ["string"], evidenceReferences: ["string"]
|
|
62
|
+
}, null, 2),
|
|
63
|
+
"",
|
|
64
|
+
"Every field must reflect something you actually observed in this project — never invent a trait, risk, or role need you have no real evidence for. If you're not sure about something, put it in `uncertainties` instead of guessing.",
|
|
65
|
+
"Each recommendedRoleNeeds entry's own `evidence` must list the real file path(s) you actually read that support THAT SPECIFIC role need — a role need with no real evidence of its own will be discarded, even if other fields in this response are well-supported."
|
|
66
|
+
];
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Extracts and validates a ProjectAnalysis from the analyst's raw text
|
|
72
|
+
* response. Fails closed: any parse failure or shape mismatch returns
|
|
73
|
+
* `{valid: false}`, never a partially-trusted guess.
|
|
74
|
+
* @param {string} rawText
|
|
75
|
+
* @returns {{valid: true, analysis: object}|{valid: false, error: string}}
|
|
76
|
+
*/
|
|
77
|
+
export function parseProjectAnalysis(rawText) {
|
|
78
|
+
const match = String(rawText ?? "").match(/\{[\s\S]*\}/);
|
|
79
|
+
if (!match) return { valid: false, error: "No JSON object found in the analyst's response." };
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(match[0]);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
return { valid: false, error: `Analyst response is not valid JSON: ${error.message}` };
|
|
85
|
+
}
|
|
86
|
+
const arrayFields = ["architectureTraits", "complexitySignals", "criticalAreas", "contextNeeds", "workflowNeeds", "uncertainties", "evidenceReferences"];
|
|
87
|
+
for (const field of arrayFields) {
|
|
88
|
+
if (!Array.isArray(parsed[field])) return { valid: false, error: `Missing or invalid real array field "${field}".` };
|
|
89
|
+
}
|
|
90
|
+
if (!Array.isArray(parsed.recommendedRoleNeeds)) return { valid: false, error: 'Missing or invalid real array field "recommendedRoleNeeds".' };
|
|
91
|
+
for (const need of parsed.recommendedRoleNeeds) {
|
|
92
|
+
if (typeof need?.role !== "string" || !Array.isArray(need.capabilities)) {
|
|
93
|
+
return { valid: false, error: "Each recommendedRoleNeeds entry needs a real role (string) and capabilities (array)." };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
valid: true,
|
|
98
|
+
analysis: {
|
|
99
|
+
schema: PROJECT_ANALYSIS_SCHEMA,
|
|
100
|
+
architectureTraits: parsed.architectureTraits.map(String),
|
|
101
|
+
complexitySignals: parsed.complexitySignals.map(String),
|
|
102
|
+
criticalAreas: parsed.criticalAreas.map(String),
|
|
103
|
+
contextNeeds: parsed.contextNeeds.map(String),
|
|
104
|
+
workflowNeeds: parsed.workflowNeeds.map(String),
|
|
105
|
+
// `evidence` defaults to an empty array when the analyst omits it —
|
|
106
|
+
// never invented, and a role need with no evidence of its own
|
|
107
|
+
// fails the real per-entry gate in deriveRoleRequirements below,
|
|
108
|
+
// exactly as if it had cited nothing real.
|
|
109
|
+
recommendedRoleNeeds: parsed.recommendedRoleNeeds.map((need) => ({
|
|
110
|
+
role: String(need.role), capabilities: need.capabilities.map(String),
|
|
111
|
+
reason: typeof need.reason === "string" ? need.reason : null,
|
|
112
|
+
evidence: Array.isArray(need.evidence) ? need.evidence.map(String) : []
|
|
113
|
+
})),
|
|
114
|
+
uncertainties: parsed.uncertainties.map(String),
|
|
115
|
+
evidenceReferences: parsed.evidenceReferences.map(String)
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizePath(path) {
|
|
121
|
+
return String(path ?? "").trim().replace(/^\.\//, "").replace(/\/+$/, "");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether a single cited path corresponds to a real file the analyst
|
|
126
|
+
* actually had access to (the sanitized snapshot's real copied-file
|
|
127
|
+
* list) — a citation to a path that was never even in the snapshot is a
|
|
128
|
+
* real, checkable signal the analyst may be describing exploration it
|
|
129
|
+
* didn't actually do, not evidence it observed. Matching is real-path-
|
|
130
|
+
* based but tolerant of how a model might phrase a reference (a leading
|
|
131
|
+
* "./", or citing just the tail of a longer real path) — an exact
|
|
132
|
+
* string mismatch alone never disqualifies a real match.
|
|
133
|
+
* @param {string} reference
|
|
134
|
+
* @param {string[]} realFilePaths
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
function referenceMatchesRealFile(reference, realFilePaths) {
|
|
138
|
+
const ref = normalizePath(reference);
|
|
139
|
+
if (!ref) return false;
|
|
140
|
+
return realFilePaths.some((path) => path === ref || path.endsWith(`/${ref}`) || ref.endsWith(`/${path}`));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Checks which of a list of citations correspond to a real file — used
|
|
145
|
+
* both for the analysis's own top-level evidenceReferences (informational)
|
|
146
|
+
* and, per-entry, for each recommendedRoleNeeds' own `evidence` (see
|
|
147
|
+
* deriveRoleRequirements, which is the one that actually gates on this).
|
|
148
|
+
* @param {string[]} references
|
|
149
|
+
* @param {string[]} realFilePaths - the sanitized snapshot's real copiedFiles
|
|
150
|
+
* @returns {{verified: string[], unverified: string[]}}
|
|
151
|
+
*/
|
|
152
|
+
export function validateReferences(references, realFilePaths) {
|
|
153
|
+
const real = realFilePaths.map(normalizePath);
|
|
154
|
+
const verified = [];
|
|
155
|
+
const unverified = [];
|
|
156
|
+
for (const raw of references) {
|
|
157
|
+
(referenceMatchesRealFile(raw, real) ? verified : unverified).push(raw);
|
|
158
|
+
}
|
|
159
|
+
return { verified, unverified };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Back-compat alias — validates the analysis's own top-level evidenceReferences. @deprecated prefer validateReferences for the per-RoleNeed gate in deriveRoleRequirements. */
|
|
163
|
+
export function validateEvidenceReferences(analysis, realFilePaths) {
|
|
164
|
+
return validateReferences(analysis.evidenceReferences, realFilePaths);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Deterministically derives real roleRequirements from a validated
|
|
169
|
+
* ProjectAnalysis, unioned with the project's own mechanical floor (real
|
|
170
|
+
* build/test/lint commands — see project-profile.js's detectRoleRequirements)
|
|
171
|
+
* so a thin or low-confidence analysis can never leave a real project with
|
|
172
|
+
* zero role requirements. The analyst's own role/capability tokens are
|
|
173
|
+
* sanitized against the known vocabulary first — an unrecognized one is
|
|
174
|
+
* dropped, never trusted as-is.
|
|
175
|
+
*
|
|
176
|
+
* Evidence is checked PER role need, not once for the whole analysis: a
|
|
177
|
+
* recommendedRoleNeeds entry is only trusted when at least one of ITS OWN
|
|
178
|
+
* `evidence` citations verifies against a real file the analyst actually
|
|
179
|
+
* had access to — a single well-evidenced role need can no longer
|
|
180
|
+
* "vouch for" every other, unrelated role need in the same response.
|
|
181
|
+
* @param {object} analysis - parseProjectAnalysis().analysis
|
|
182
|
+
* @param {Array<{role: string, capabilities: string[], reason: string}>} mechanicalFloor - profile.roleRequirements (the pre-existing command-based detection)
|
|
183
|
+
* @param {string[]} [realFilePaths] - the sanitized snapshot's real copiedFiles; omit only when no real file list is available (falls back to trusting each role need's vocabulary alone, matching this function's pre-sanitized-snapshot behavior)
|
|
184
|
+
* @returns {Array<{role: string, capabilities: string[], reason: string}>}
|
|
185
|
+
*/
|
|
186
|
+
export function deriveRoleRequirements(analysis, mechanicalFloor, realFilePaths = null) {
|
|
187
|
+
const byRole = new Map(mechanicalFloor.map((requirement) => [requirement.role, { ...requirement }]));
|
|
188
|
+
for (const need of analysis.recommendedRoleNeeds) {
|
|
189
|
+
if (!KNOWN_ROLES.has(need.role)) continue;
|
|
190
|
+
const capabilities = need.capabilities.filter((c) => KNOWN_CAPABILITIES.has(c));
|
|
191
|
+
if (!capabilities.length) continue;
|
|
192
|
+
// Real-evidence-per-recommendation gate: this specific role need is
|
|
193
|
+
// only trusted when it cites at least one real file of its own.
|
|
194
|
+
if (realFilePaths && !(need.evidence ?? []).some((ref) => referenceMatchesRealFile(ref, realFilePaths))) continue;
|
|
195
|
+
const existing = byRole.get(need.role);
|
|
196
|
+
if (existing) {
|
|
197
|
+
existing.capabilities = [...new Set([...existing.capabilities, ...capabilities])];
|
|
198
|
+
existing.reason = `${existing.reason} Bootstrap Analyst: ${need.reason ?? "real project analysis"}.`;
|
|
199
|
+
} else {
|
|
200
|
+
byRole.set(need.role, { role: need.role, capabilities, reason: `Bootstrap Analyst: ${need.reason ?? "real project analysis"}.` });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return [...byRole.values()];
|
|
204
|
+
}
|
|
@@ -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
|
+
}
|