@kal-elsam/kairo-runtime 0.16.0 → 0.18.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/package.json +2 -1
  3. package/scripts/cockpit-smoke.mjs +1 -1
  4. package/scripts/ux-smoke-test.sh +3 -3
  5. package/src/cli.js +96 -11
  6. package/src/global/agent-capabilities/create-capability-adapter.js +2 -2
  7. package/src/global/architect/architect-cli.js +76 -0
  8. package/src/global/architect/architect-codex.js +146 -0
  9. package/src/global/architect/architect-manager.js +125 -0
  10. package/src/global/architect/architect-store.js +377 -0
  11. package/src/global/architect/architect-types.js +47 -0
  12. package/src/global/cli-help.js +10 -1
  13. package/src/global/cockpit/app.js +493 -0
  14. package/src/global/cockpit/card.js +111 -0
  15. package/src/global/cockpit/cli.js +33 -0
  16. package/src/global/cockpit/gauge.js +31 -0
  17. package/src/global/cockpit/project-overlay.js +693 -0
  18. package/src/global/cockpit/rows.js +148 -0
  19. package/src/global/cockpit/theme.js +118 -0
  20. package/src/global/cockpit/view.js +1298 -0
  21. package/src/global/conversation/bootstrap-analyzer-adapters.js +251 -0
  22. package/src/global/conversation/cli.js +53 -0
  23. package/src/global/conversation/codex-sandbox.js +230 -0
  24. package/src/global/conversation/cursor-sandbox.js +215 -0
  25. package/src/global/conversation/project-analysis.js +204 -0
  26. package/src/global/conversation/project-profile.js +178 -0
  27. package/src/global/conversation/project-router.js +149 -0
  28. package/src/global/conversation/project-strategy-store.js +64 -0
  29. package/src/global/conversation/project-strategy.js +514 -0
  30. package/src/global/conversation/sanitized-snapshot.js +169 -0
  31. package/src/global/conversation/secret-scanner.js +71 -0
  32. package/src/global/conversation/service.js +1090 -0
  33. package/src/global/conversation/session-store.js +75 -0
  34. package/src/global/conversation/transcript-store.js +79 -0
  35. package/src/global/conversation/ui.js +195 -0
  36. package/src/global/intelligence/capability-scoring.js +480 -0
  37. package/src/global/intelligence/execution-router.js +466 -0
  38. package/src/global/intelligence/kairo-telemetry-source.js +59 -0
  39. package/src/global/intelligence/kairobench-runner.js +85 -0
  40. package/src/global/intelligence/kairobench-source.js +34 -0
  41. package/src/global/intelligence/kairobench-tasks.js +47 -0
  42. package/src/global/intelligence/model-candidate-catalog.js +456 -0
  43. package/src/global/intelligence/model-capability-registry-sources.js +145 -0
  44. package/src/global/intelligence/model-capability-registry.js +125 -0
  45. package/src/global/intelligence/model-intelligence.js +1646 -0
  46. package/src/global/intelligence/official-benchmark-snapshots.js +162 -0
  47. package/src/global/intelligence/quick-ask.js +149 -0
  48. package/src/global/intelligence/role-profiles.js +251 -0
  49. package/src/global/intelligence/skill-catalog.js +67 -0
  50. package/src/global/intelligence/subscription-pressure-source.js +41 -0
  51. package/src/global/mcp/kairo-mcp.js +51 -18
  52. package/src/global/mcp/work-snapshot-rule.js +4 -2
  53. package/src/global/mcp/workspace-binding.js +88 -0
  54. package/src/global/mcp/workspace-mcp-entry.js +74 -0
  55. package/src/global/mcp-install.js +8 -1
  56. package/src/global/observability/artificial-analysis-models.js +118 -0
  57. package/src/global/observability/claude-models.js +31 -0
  58. package/src/global/observability/claude-usage.js +112 -0
  59. package/src/global/observability/codex-models.js +96 -0
  60. package/src/global/observability/codex-usage.js +160 -0
  61. package/src/global/observability/cursor-auth.js +88 -0
  62. package/src/global/observability/cursor-models.js +101 -0
  63. package/src/global/observability/huggingface-leaderboard.js +97 -0
  64. package/src/global/observability/opencode-models.js +101 -0
  65. package/src/global/observability/opencode-usage.js +162 -0
  66. package/src/global/paths.js +49 -2
  67. package/src/global/profile.js +23 -1
  68. package/src/global/runtime/execution-adapters/claude.js +63 -30
  69. package/src/global/runtime/execution-adapters/codex.js +9 -2
  70. package/src/global/runtime/execution-adapters/create-execution-adapter.js +6 -1
  71. package/src/global/runtime/execution-adapters/opencode.js +83 -18
  72. package/src/global/runtime/execution-worktree-manager.js +924 -0
  73. package/src/global/runtime/execution-worktree-orchestrator.js +194 -0
  74. package/src/global/runtime/execution-worktree-store.js +83 -0
  75. package/src/global/runtime/execution-worktree-types.js +45 -0
  76. package/src/global/runtime/run-events.js +38 -0
  77. package/src/global/runtime/run-manager.js +22 -6
  78. package/src/global/runtime/run-supervisor.js +41 -12
  79. package/src/global/runtime/usage-manager.js +96 -0
  80. package/src/global/runtime/usage-store.js +69 -0
  81. 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
+ }