@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.
Files changed (92) hide show
  1. package/CHANGELOG.md +76 -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 +106 -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 +12 -1
  13. package/src/global/cockpit/app.js +475 -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 +683 -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 +1263 -0
  21. package/src/global/control-plane/attention.js +141 -0
  22. package/src/global/control-plane/build-report.js +146 -0
  23. package/src/global/control-plane/cli.js +36 -0
  24. package/src/global/control-plane/constants.js +38 -0
  25. package/src/global/control-plane/gentle-adapters.js +183 -0
  26. package/src/global/control-plane/provider.js +69 -0
  27. package/src/global/control-plane/review-status.js +115 -0
  28. package/src/global/control-plane/sdd-status.js +49 -0
  29. package/src/global/control-plane/team.js +63 -0
  30. package/src/global/conversation/bootstrap-analyzer-adapters.js +251 -0
  31. package/src/global/conversation/cli.js +53 -0
  32. package/src/global/conversation/codex-sandbox.js +230 -0
  33. package/src/global/conversation/cursor-sandbox.js +215 -0
  34. package/src/global/conversation/project-analysis.js +204 -0
  35. package/src/global/conversation/project-profile.js +178 -0
  36. package/src/global/conversation/project-router.js +149 -0
  37. package/src/global/conversation/project-strategy-store.js +64 -0
  38. package/src/global/conversation/project-strategy.js +514 -0
  39. package/src/global/conversation/sanitized-snapshot.js +169 -0
  40. package/src/global/conversation/secret-scanner.js +71 -0
  41. package/src/global/conversation/service.js +1063 -0
  42. package/src/global/conversation/session-store.js +75 -0
  43. package/src/global/conversation/transcript-store.js +79 -0
  44. package/src/global/conversation/ui.js +195 -0
  45. package/src/global/intelligence/capability-scoring.js +480 -0
  46. package/src/global/intelligence/execution-router.js +444 -0
  47. package/src/global/intelligence/kairo-telemetry-source.js +59 -0
  48. package/src/global/intelligence/kairobench-runner.js +85 -0
  49. package/src/global/intelligence/kairobench-source.js +34 -0
  50. package/src/global/intelligence/kairobench-tasks.js +47 -0
  51. package/src/global/intelligence/model-candidate-catalog.js +456 -0
  52. package/src/global/intelligence/model-capability-registry-sources.js +145 -0
  53. package/src/global/intelligence/model-capability-registry.js +125 -0
  54. package/src/global/intelligence/model-intelligence.js +1646 -0
  55. package/src/global/intelligence/official-benchmark-snapshots.js +162 -0
  56. package/src/global/intelligence/quick-ask.js +149 -0
  57. package/src/global/intelligence/role-profiles.js +251 -0
  58. package/src/global/intelligence/skill-catalog.js +67 -0
  59. package/src/global/intelligence/subscription-pressure-source.js +41 -0
  60. package/src/global/mcp/kairo-mcp.js +51 -18
  61. package/src/global/mcp/work-snapshot-rule.js +4 -2
  62. package/src/global/mcp/workspace-binding.js +88 -0
  63. package/src/global/mcp/workspace-mcp-entry.js +74 -0
  64. package/src/global/mcp-install.js +8 -1
  65. package/src/global/observability/artificial-analysis-models.js +118 -0
  66. package/src/global/observability/claude-models.js +31 -0
  67. package/src/global/observability/claude-usage.js +112 -0
  68. package/src/global/observability/codex-models.js +96 -0
  69. package/src/global/observability/codex-usage.js +160 -0
  70. package/src/global/observability/cursor-auth.js +88 -0
  71. package/src/global/observability/cursor-models.js +101 -0
  72. package/src/global/observability/gentle-probe.js +30 -2
  73. package/src/global/observability/huggingface-leaderboard.js +97 -0
  74. package/src/global/observability/index.js +2 -1
  75. package/src/global/observability/opencode-models.js +101 -0
  76. package/src/global/observability/opencode-usage.js +162 -0
  77. package/src/global/paths.js +49 -2
  78. package/src/global/profile.js +23 -1
  79. package/src/global/runtime/execution-adapters/claude.js +63 -30
  80. package/src/global/runtime/execution-adapters/codex.js +9 -2
  81. package/src/global/runtime/execution-adapters/create-execution-adapter.js +6 -1
  82. package/src/global/runtime/execution-adapters/opencode.js +83 -18
  83. package/src/global/runtime/execution-worktree-manager.js +924 -0
  84. package/src/global/runtime/execution-worktree-orchestrator.js +194 -0
  85. package/src/global/runtime/execution-worktree-store.js +83 -0
  86. package/src/global/runtime/execution-worktree-types.js +45 -0
  87. package/src/global/runtime/run-events.js +38 -0
  88. package/src/global/runtime/run-manager.js +22 -6
  89. package/src/global/runtime/run-supervisor.js +41 -12
  90. package/src/global/runtime/usage-manager.js +96 -0
  91. package/src/global/runtime/usage-store.js +69 -0
  92. 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
+ }