@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.
- package/CHANGELOG.md +79 -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 +493 -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 +693 -0
- package/src/global/cockpit/rows.js +148 -0
- package/src/global/cockpit/theme.js +118 -0
- package/src/global/cockpit/view.js +1298 -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 +1090 -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 +466 -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,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PLAN_STATES, ARCHITECT_SCHEMA, createArchitectureRequestKey,
|
|
3
|
+
createTaskId, normalizeArchitectTask, sha256
|
|
4
|
+
} from "./architect-types.js";
|
|
5
|
+
import { runArchitectCodex } from "./architect-codex.js";
|
|
6
|
+
import {
|
|
7
|
+
acquireRequestLock, findReusableRequest, prepareTaskDirectory, resolveHead,
|
|
8
|
+
resolveProjectRoot, writeAtomicText, writeTaskArtifacts
|
|
9
|
+
} from "./architect-store.js";
|
|
10
|
+
import { writeAtomicJson } from "../runtime/write-atomic-json.js";
|
|
11
|
+
import { compileContextPack } from "../intelligence/context-compiler.js";
|
|
12
|
+
|
|
13
|
+
const REQUEST_DISCOVERY_ATTEMPTS = 40;
|
|
14
|
+
const REQUEST_DISCOVERY_DELAY_MS = 25;
|
|
15
|
+
|
|
16
|
+
async function waitForReusableRequest(projectRoot, requestKey) {
|
|
17
|
+
for (let attempt = 0; attempt < REQUEST_DISCOVERY_ATTEMPTS; attempt += 1) {
|
|
18
|
+
const active = await findReusableRequest(projectRoot, requestKey, {
|
|
19
|
+
states: [PLAN_STATES.DRAFT, PLAN_STATES.AWAITING_APPROVAL]
|
|
20
|
+
});
|
|
21
|
+
if (active) return active;
|
|
22
|
+
await new Promise((resolve) => setTimeout(resolve, REQUEST_DISCOVERY_DELAY_MS));
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function prepareUniqueTask(projectRoot, task, now) {
|
|
28
|
+
const baseId = createTaskId(task, now);
|
|
29
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
30
|
+
const suffix = attempt === 0 ? "" : `-${attempt + 1}`;
|
|
31
|
+
const taskId = `${baseId.slice(0, 96 - suffix.length)}${suffix}`;
|
|
32
|
+
try {
|
|
33
|
+
const paths = await prepareTaskDirectory(projectRoot, taskId, { createExclusive: true });
|
|
34
|
+
return { taskId, paths };
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error?.code !== "EEXIST") throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
throw new Error("Unable to allocate a unique architecture task id.");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function createArchitecturePlan({
|
|
43
|
+
task, cwd = process.cwd(), model = null, now = new Date(), runCodex = runArchitectCodex,
|
|
44
|
+
resolveRoot = resolveProjectRoot, resolveGitHead = resolveHead,
|
|
45
|
+
compileContext = compileContextPack, acquireLock = acquireRequestLock
|
|
46
|
+
} = {}) {
|
|
47
|
+
if (typeof task !== "string" || !task.trim()) throw new Error("Architect task is required.");
|
|
48
|
+
const projectRoot = await resolveRoot(cwd);
|
|
49
|
+
const baseHead = resolveGitHead(projectRoot);
|
|
50
|
+
const normalizedTask = normalizeArchitectTask(task);
|
|
51
|
+
const normalizedModel = model == null ? null : String(model).trim() || null;
|
|
52
|
+
const requestKey = createArchitectureRequestKey({
|
|
53
|
+
projectRoot, baseHead, task: normalizedTask, model: normalizedModel
|
|
54
|
+
});
|
|
55
|
+
const reusable = await findReusableRequest(projectRoot, requestKey);
|
|
56
|
+
if (reusable) return { status: reusable.status, paths: reusable.paths, reused: true };
|
|
57
|
+
|
|
58
|
+
const lock = await acquireLock(projectRoot, requestKey, { now });
|
|
59
|
+
if (!lock.acquired) {
|
|
60
|
+
const active = await waitForReusableRequest(projectRoot, requestKey);
|
|
61
|
+
if (active) return { status: active.status, paths: active.paths, reused: true };
|
|
62
|
+
throw new Error("An identical architecture request is already starting. Retry shortly.");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let paths;
|
|
66
|
+
try {
|
|
67
|
+
const afterLock = await findReusableRequest(projectRoot, requestKey);
|
|
68
|
+
if (afterLock) return { status: afterLock.status, paths: afterLock.paths, reused: true };
|
|
69
|
+
const orphan = await findReusableRequest(projectRoot, requestKey, { states: [PLAN_STATES.DRAFT] });
|
|
70
|
+
if (orphan) {
|
|
71
|
+
await writeAtomicJson(orphan.paths.statusPath, {
|
|
72
|
+
...orphan.status,
|
|
73
|
+
state: PLAN_STATES.FAILED,
|
|
74
|
+
updatedAt: now.toISOString(),
|
|
75
|
+
error: {
|
|
76
|
+
code: "stale_architect_request",
|
|
77
|
+
message: "Recovered an interrupted architecture request whose owner is no longer active."
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const prepared = await prepareUniqueTask(projectRoot, normalizedTask, now);
|
|
82
|
+
const taskId = prepared.taskId;
|
|
83
|
+
paths = prepared.paths;
|
|
84
|
+
const taskMarkdown = `# Task\n\n${normalizedTask}\n`;
|
|
85
|
+
const createdAt = now.toISOString();
|
|
86
|
+
const draft = {
|
|
87
|
+
schema: ARCHITECT_SCHEMA, taskId, state: PLAN_STATES.DRAFT,
|
|
88
|
+
requestKey, provider: "codex", model: normalizedModel, projectRoot, baseHead,
|
|
89
|
+
taskDigest: sha256(normalizedTask), taskArtifactDigest: sha256(taskMarkdown),
|
|
90
|
+
planArtifactDigest: null, usage: null,
|
|
91
|
+
createdAt, updatedAt: createdAt, decisionAt: null, decisionHead: null,
|
|
92
|
+
artifacts: { task: ".ai/tasks/" + taskId + "/task.md", plan: ".ai/tasks/" + taskId + "/plan.md" }
|
|
93
|
+
};
|
|
94
|
+
await writeAtomicText(paths.taskPath, taskMarkdown);
|
|
95
|
+
await writeAtomicJson(paths.statusPath, draft);
|
|
96
|
+
try {
|
|
97
|
+
const contextPack = await compileContext({
|
|
98
|
+
workspaceRoot: projectRoot, task: normalizedTask, relevantPaths: [], includePrivate: false
|
|
99
|
+
});
|
|
100
|
+
const result = await runCodex({
|
|
101
|
+
task: normalizedTask, cwd: projectRoot, model: normalizedModel, contextPack
|
|
102
|
+
});
|
|
103
|
+
const planMarkdown = `# Architecture Plan\n\n${result.plan.trim()}\n`;
|
|
104
|
+
const awaiting = {
|
|
105
|
+
...draft,
|
|
106
|
+
state: PLAN_STATES.AWAITING_APPROVAL,
|
|
107
|
+
planArtifactDigest: sha256(planMarkdown),
|
|
108
|
+
usage: result.usage ?? null,
|
|
109
|
+
updatedAt: new Date().toISOString()
|
|
110
|
+
};
|
|
111
|
+
await writeTaskArtifacts({
|
|
112
|
+
projectRoot, taskId, task: normalizedTask, plan: result.plan, status: awaiting
|
|
113
|
+
});
|
|
114
|
+
return { status: awaiting, paths, reused: false };
|
|
115
|
+
} catch (error) {
|
|
116
|
+
await writeAtomicJson(paths.statusPath, {
|
|
117
|
+
...draft, state: PLAN_STATES.FAILED, updatedAt: new Date().toISOString(),
|
|
118
|
+
error: { code: error?.code ?? "architect_failed", message: error?.message ?? String(error) }
|
|
119
|
+
});
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
await lock.release();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
lstat, mkdir, open, readdir, readFile, realpath, rename, rmdir, unlink
|
|
4
|
+
} from "node:fs/promises";
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import { lstatSync, readFileSync, readlinkSync } from "node:fs";
|
|
8
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
9
|
+
import { writeAtomicJson } from "../runtime/write-atomic-json.js";
|
|
10
|
+
import { ARCHITECT_SCHEMA, PLAN_STATES, assertTaskId, sha256 } from "./architect-types.js";
|
|
11
|
+
|
|
12
|
+
export const TASKS_RELATIVE_DIR = join(".ai", "tasks");
|
|
13
|
+
export const EXECUTION_SCHEMA = "kairo.architect-execution/v1";
|
|
14
|
+
const REQUEST_LOCKS_DIR = ".requests";
|
|
15
|
+
const DEFAULT_REQUEST_LOCK_STALE_MS = 15 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
function isInside(parent, candidate) {
|
|
18
|
+
const rel = relative(parent, candidate);
|
|
19
|
+
return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith("../"));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function statOrNull(path) {
|
|
23
|
+
try { return await lstat(path); }
|
|
24
|
+
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function assertSafeFile(path) {
|
|
28
|
+
const stat = await statOrNull(path);
|
|
29
|
+
if (!stat) return;
|
|
30
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`Unsafe artifact path: ${path}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function ensureSafeDirectory(root, segments) {
|
|
34
|
+
let current = root;
|
|
35
|
+
for (const segment of segments) {
|
|
36
|
+
current = join(current, segment);
|
|
37
|
+
const before = await statOrNull(current);
|
|
38
|
+
if (before?.isSymbolicLink() || (before && !before.isDirectory())) {
|
|
39
|
+
throw new Error(`Unsafe artifact directory: ${current}`);
|
|
40
|
+
}
|
|
41
|
+
if (!before) await mkdir(current);
|
|
42
|
+
const after = await lstat(current);
|
|
43
|
+
if (after.isSymbolicLink() || !after.isDirectory()) throw new Error(`Unsafe artifact directory: ${current}`);
|
|
44
|
+
const canonical = await realpath(current);
|
|
45
|
+
if (!isInside(root, canonical)) throw new Error(`Artifact directory escapes project: ${current}`);
|
|
46
|
+
}
|
|
47
|
+
return current;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function resolveProjectRoot(cwd, { exec = execFileSync } = {}) {
|
|
51
|
+
const requested = await realpath(resolve(cwd ?? process.cwd()));
|
|
52
|
+
let root;
|
|
53
|
+
try {
|
|
54
|
+
root = String(exec("git", ["rev-parse", "--show-toplevel"], {
|
|
55
|
+
cwd: requested, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"]
|
|
56
|
+
})).trim();
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error(`Architect requires a Git repository: ${requested}`);
|
|
59
|
+
}
|
|
60
|
+
const canonical = await realpath(root);
|
|
61
|
+
if (!isInside(canonical, requested)) throw new Error("Resolved cwd is outside the Git project root.");
|
|
62
|
+
return canonical;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function resolveHead(projectRoot, { exec = execFileSync } = {}) {
|
|
66
|
+
try {
|
|
67
|
+
return String(exec("git", ["rev-parse", "HEAD"], {
|
|
68
|
+
cwd: projectRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"]
|
|
69
|
+
})).trim();
|
|
70
|
+
} catch {
|
|
71
|
+
throw new Error("Architect requires a repository with a valid HEAD commit.");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function taskPaths(projectRoot, taskId) {
|
|
76
|
+
const safeId = assertTaskId(taskId);
|
|
77
|
+
const tasksRoot = join(projectRoot, TASKS_RELATIVE_DIR);
|
|
78
|
+
const taskDir = join(tasksRoot, safeId);
|
|
79
|
+
if (!isInside(tasksRoot, taskDir)) throw new Error("Task path escapes project artifacts.");
|
|
80
|
+
return {
|
|
81
|
+
tasksRoot, taskDir,
|
|
82
|
+
taskPath: join(taskDir, "task.md"),
|
|
83
|
+
planPath: join(taskDir, "plan.md"),
|
|
84
|
+
statusPath: join(taskDir, "status.json"),
|
|
85
|
+
executionPath: join(taskDir, "execution.json")
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function prepareTaskDirectory(projectRoot, taskId, { createExclusive = false } = {}) {
|
|
90
|
+
const paths = taskPaths(projectRoot, taskId);
|
|
91
|
+
if (createExclusive) {
|
|
92
|
+
await ensureSafeDirectory(projectRoot, [".ai", "tasks"]);
|
|
93
|
+
await mkdir(paths.taskDir);
|
|
94
|
+
const canonical = await realpath(paths.taskDir);
|
|
95
|
+
if (!isInside(projectRoot, canonical)) throw new Error(`Task directory escapes project: ${paths.taskDir}`);
|
|
96
|
+
} else {
|
|
97
|
+
await ensureSafeDirectory(projectRoot, [".ai", "tasks", taskId]);
|
|
98
|
+
}
|
|
99
|
+
for (const path of [paths.taskPath, paths.planPath, paths.statusPath, paths.executionPath]) await assertSafeFile(path);
|
|
100
|
+
return paths;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function writeAtomicText(targetPath, value) {
|
|
104
|
+
await assertSafeFile(targetPath);
|
|
105
|
+
const tempPath = join(dirname(targetPath), `.${basename(targetPath)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
106
|
+
let handle;
|
|
107
|
+
try {
|
|
108
|
+
handle = await open(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o644);
|
|
109
|
+
await handle.writeFile(String(value), "utf8");
|
|
110
|
+
await handle.sync();
|
|
111
|
+
await handle.close();
|
|
112
|
+
handle = null;
|
|
113
|
+
await rename(tempPath, targetPath);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (handle) await handle.close().catch(() => {});
|
|
116
|
+
await unlink(tempPath).catch(() => {});
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function writeTaskArtifacts({ projectRoot, taskId, task, plan, status }) {
|
|
122
|
+
const paths = await prepareTaskDirectory(projectRoot, taskId);
|
|
123
|
+
await writeAtomicText(paths.taskPath, `# Task\n\n${String(task).trim()}\n`);
|
|
124
|
+
await writeAtomicText(paths.planPath, `# Architecture Plan\n\n${String(plan).trim()}\n`);
|
|
125
|
+
await writeAtomicJson(paths.statusPath, status);
|
|
126
|
+
return paths;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function readTaskRecord(projectRoot, taskId) {
|
|
130
|
+
const paths = taskPaths(projectRoot, taskId);
|
|
131
|
+
const rootStat = await statOrNull(paths.taskDir);
|
|
132
|
+
if (!rootStat) return null;
|
|
133
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) throw new Error(`Unsafe task directory: ${paths.taskDir}`);
|
|
134
|
+
const [canonicalProject, canonicalTaskDir] = await Promise.all([realpath(projectRoot), realpath(paths.taskDir)]);
|
|
135
|
+
if (!isInside(canonicalProject, canonicalTaskDir)) throw new Error(`Task directory escapes project: ${paths.taskDir}`);
|
|
136
|
+
for (const path of [paths.taskPath, paths.planPath, paths.statusPath, paths.executionPath]) await assertSafeFile(path);
|
|
137
|
+
const [taskMarkdown, statusRaw] = await Promise.all([
|
|
138
|
+
readFile(paths.taskPath, "utf8"), readFile(paths.statusPath, "utf8")
|
|
139
|
+
]);
|
|
140
|
+
const status = JSON.parse(statusRaw);
|
|
141
|
+
const expectedArtifacts = {
|
|
142
|
+
task: `.ai/tasks/${taskId}/task.md`,
|
|
143
|
+
plan: `.ai/tasks/${taskId}/plan.md`
|
|
144
|
+
};
|
|
145
|
+
if (status.schema !== ARCHITECT_SCHEMA || status.taskId !== taskId
|
|
146
|
+
|| status.projectRoot !== canonicalProject
|
|
147
|
+
|| !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(status.baseHead ?? "")
|
|
148
|
+
|| status.artifacts?.task !== expectedArtifacts.task
|
|
149
|
+
|| status.artifacts?.plan !== expectedArtifacts.plan) {
|
|
150
|
+
throw new Error(`Invalid task status: ${taskId}`);
|
|
151
|
+
}
|
|
152
|
+
let planMarkdown = null;
|
|
153
|
+
const planStat = await statOrNull(paths.planPath);
|
|
154
|
+
if (planStat) planMarkdown = await readFile(paths.planPath, "utf8");
|
|
155
|
+
else if (status.state !== PLAN_STATES.FAILED && status.state !== PLAN_STATES.DRAFT) {
|
|
156
|
+
throw new Error(`Plan artifact missing: ${taskId}`);
|
|
157
|
+
}
|
|
158
|
+
return { status, taskMarkdown, planMarkdown, paths };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Pulls the real task description back out of `# Task\n\n<text>\n` so the
|
|
163
|
+
* cockpit can show what a task actually is instead of just its taskId (a
|
|
164
|
+
* timestamp+slug+hash string with no human meaning on its own).
|
|
165
|
+
* @param {string} taskMarkdown
|
|
166
|
+
* @returns {string}
|
|
167
|
+
*/
|
|
168
|
+
function extractTaskText(taskMarkdown) {
|
|
169
|
+
return String(taskMarkdown ?? "").replace(/^#\s*Task\s*\n+/, "").trim();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function listTaskRecords(projectRoot) {
|
|
173
|
+
const tasksRoot = join(projectRoot, TASKS_RELATIVE_DIR);
|
|
174
|
+
const stat = await statOrNull(tasksRoot);
|
|
175
|
+
if (!stat) return [];
|
|
176
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Unsafe tasks directory: ${tasksRoot}`);
|
|
177
|
+
const entries = await readdir(tasksRoot, { withFileTypes: true });
|
|
178
|
+
const rows = [];
|
|
179
|
+
for (const entry of entries) {
|
|
180
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || !/^[a-z0-9][a-z0-9-]{0,95}$/.test(entry.name)) continue;
|
|
181
|
+
try {
|
|
182
|
+
const record = await readTaskRecord(projectRoot, entry.name);
|
|
183
|
+
if (record) rows.push({ ...record.status, taskText: extractTaskText(record.taskMarkdown) });
|
|
184
|
+
} catch { /* malformed task is not advertised */ }
|
|
185
|
+
}
|
|
186
|
+
return rows.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function findReusableRequest(projectRoot, requestKey, {
|
|
190
|
+
states = [PLAN_STATES.AWAITING_APPROVAL]
|
|
191
|
+
} = {}) {
|
|
192
|
+
const records = await listTaskRecords(projectRoot);
|
|
193
|
+
const status = records.find((row) => row.requestKey === requestKey
|
|
194
|
+
&& states.includes(row.state));
|
|
195
|
+
return status ? readTaskRecord(projectRoot, status.taskId) : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function assertRequestKey(requestKey) {
|
|
199
|
+
if (!/^[0-9a-f]{64}$/.test(requestKey ?? "")) throw new Error("Invalid architecture request key.");
|
|
200
|
+
return requestKey;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function processIsAlive(pid) {
|
|
204
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
205
|
+
try { process.kill(pid, 0); return true; }
|
|
206
|
+
catch (error) { return error?.code === "EPERM"; }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function staleRequestLock(lockDir, leasePath, { nowMs, staleAfterMs }) {
|
|
210
|
+
const lockStat = await statOrNull(lockDir);
|
|
211
|
+
if (!lockStat) return true;
|
|
212
|
+
if (lockStat.isSymbolicLink() || !lockStat.isDirectory()) {
|
|
213
|
+
throw new Error(`Unsafe architecture request lock: ${lockDir}`);
|
|
214
|
+
}
|
|
215
|
+
let lease = null;
|
|
216
|
+
try {
|
|
217
|
+
await assertSafeFile(leasePath);
|
|
218
|
+
lease = JSON.parse(await readFile(leasePath, "utf8"));
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (error?.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error;
|
|
221
|
+
}
|
|
222
|
+
const createdMs = Date.parse(lease?.createdAt ?? "");
|
|
223
|
+
const ageMs = nowMs - (Number.isFinite(createdMs) ? createdMs : lockStat.mtimeMs);
|
|
224
|
+
if (Number.isSafeInteger(lease?.pid) && lease.pid > 0) return !processIsAlive(lease.pid);
|
|
225
|
+
return ageMs >= staleAfterMs;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function removeRequestLock(lockDir, leasePath) {
|
|
229
|
+
await assertSafeFile(leasePath);
|
|
230
|
+
await unlink(leasePath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
231
|
+
await rmdir(lockDir);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function acquireRequestLock(projectRoot, requestKey, {
|
|
235
|
+
now = new Date(), staleAfterMs = DEFAULT_REQUEST_LOCK_STALE_MS
|
|
236
|
+
} = {}) {
|
|
237
|
+
const key = assertRequestKey(requestKey);
|
|
238
|
+
const requestsRoot = await ensureSafeDirectory(projectRoot, [".ai", "tasks", REQUEST_LOCKS_DIR]);
|
|
239
|
+
const lockDir = join(requestsRoot, `${key}.lock`);
|
|
240
|
+
const leasePath = join(lockDir, "lease.json");
|
|
241
|
+
const token = randomBytes(16).toString("hex");
|
|
242
|
+
|
|
243
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
244
|
+
try {
|
|
245
|
+
await mkdir(lockDir);
|
|
246
|
+
await writeAtomicJson(leasePath, {
|
|
247
|
+
requestKey: key, token, pid: process.pid, createdAt: now.toISOString()
|
|
248
|
+
}, { createExclusive: true });
|
|
249
|
+
return {
|
|
250
|
+
acquired: true,
|
|
251
|
+
async release() {
|
|
252
|
+
let lease;
|
|
253
|
+
try { lease = JSON.parse(await readFile(leasePath, "utf8")); }
|
|
254
|
+
catch (error) { if (error.code === "ENOENT") return; throw error; }
|
|
255
|
+
if (lease.token !== token) return;
|
|
256
|
+
await removeRequestLock(lockDir, leasePath);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error.code !== "EEXIST") {
|
|
261
|
+
await removeRequestLock(lockDir, leasePath).catch(() => {});
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
if (attempt === 0 && await staleRequestLock(lockDir, leasePath, {
|
|
265
|
+
nowMs: now.getTime(), staleAfterMs
|
|
266
|
+
})) {
|
|
267
|
+
await removeRequestLock(lockDir, leasePath).catch((removeError) => {
|
|
268
|
+
if (removeError.code !== "ENOENT" && removeError.code !== "ENOTEMPTY") throw removeError;
|
|
269
|
+
});
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
return { acquired: false, release: async () => {} };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return { acquired: false, release: async () => {} };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function verifyArtifactDigests(record) {
|
|
279
|
+
if (sha256(record.taskMarkdown) !== record.status.taskArtifactDigest) throw new Error("Task artifact changed after planning.");
|
|
280
|
+
if (sha256(record.planMarkdown) !== record.status.planArtifactDigest) throw new Error("Plan artifact changed after planning.");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function resolveWorkingTreeFingerprint(projectRoot, { exec = execFileSync } = {}) {
|
|
284
|
+
const diff = exec("git", ["diff", "--binary", "HEAD", "--", ".", ":(exclude).ai/tasks/**"], {
|
|
285
|
+
cwd: projectRoot, encoding: null, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024
|
|
286
|
+
});
|
|
287
|
+
const untrackedRaw = exec("git", [
|
|
288
|
+
"ls-files", "--others", "--exclude-standard", "-z", "--", ".", ":(exclude).ai/tasks/**"
|
|
289
|
+
], { cwd: projectRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
290
|
+
const hash = createHash("sha256").update("kairo-working-tree/v1\0").update(diff);
|
|
291
|
+
for (const file of String(untrackedRaw).split("\0").filter(Boolean).sort()) {
|
|
292
|
+
const path = join(projectRoot, file);
|
|
293
|
+
const stat = lstatSync(path);
|
|
294
|
+
hash.update("\0").update(file).update("\0");
|
|
295
|
+
if (stat.isSymbolicLink()) hash.update("symlink\0").update(readlinkSync(path));
|
|
296
|
+
else if (stat.isFile()) hash.update("file\0").update(readFileSync(path));
|
|
297
|
+
else hash.update("other\0");
|
|
298
|
+
}
|
|
299
|
+
return hash.digest("hex");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export async function readExecutionLink(projectRoot, taskId) {
|
|
303
|
+
const record = await readTaskRecord(projectRoot, taskId);
|
|
304
|
+
if (!record) return null;
|
|
305
|
+
const paths = taskPaths(projectRoot, taskId);
|
|
306
|
+
await assertSafeFile(paths.executionPath);
|
|
307
|
+
const stat = await statOrNull(paths.executionPath);
|
|
308
|
+
if (!stat) return null;
|
|
309
|
+
const link = JSON.parse(await readFile(paths.executionPath, "utf8"));
|
|
310
|
+
if (link?.schema !== EXECUTION_SCHEMA || link?.taskId !== taskId
|
|
311
|
+
|| !/^run_[a-z0-9_]+$/.test(link?.runId ?? "") || link?.provider !== "claude") {
|
|
312
|
+
throw new Error(`Invalid execution artifact: ${taskId}`);
|
|
313
|
+
}
|
|
314
|
+
return link;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export async function writeExecutionLink(projectRoot, taskId, link) {
|
|
318
|
+
const paths = await prepareTaskDirectory(projectRoot, taskId);
|
|
319
|
+
const value = { ...link, schema: EXECUTION_SCHEMA, taskId, provider: "claude" };
|
|
320
|
+
await writeAtomicJson(paths.executionPath, value, { createExclusive: true });
|
|
321
|
+
return value;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function updateExecutionLink(projectRoot, taskId, link) {
|
|
325
|
+
const paths = await prepareTaskDirectory(projectRoot, taskId);
|
|
326
|
+
const value = { ...link, schema: EXECUTION_SCHEMA, taskId, provider: "claude" };
|
|
327
|
+
await writeAtomicJson(paths.executionPath, value);
|
|
328
|
+
return value;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export async function verifyPlanForExecution(projectRoot, taskId, {
|
|
332
|
+
exec, checkWorkingTree = true
|
|
333
|
+
} = {}) {
|
|
334
|
+
const record = await readTaskRecord(projectRoot, taskId);
|
|
335
|
+
if (!record) throw new Error(`Plan "${taskId}" not found.`);
|
|
336
|
+
if (record.status.state !== PLAN_STATES.APPROVED) {
|
|
337
|
+
throw new Error(`Plan "${taskId}" is ${record.status.state}; explicit approval is required.`);
|
|
338
|
+
}
|
|
339
|
+
verifyArtifactDigests(record);
|
|
340
|
+
const currentHead = resolveHead(projectRoot, { exec });
|
|
341
|
+
if (currentHead !== record.status.baseHead) throw new Error(`Plan "${taskId}" is stale: repository HEAD changed.`);
|
|
342
|
+
if (!checkWorkingTree) return record;
|
|
343
|
+
if (!record.status.workingTreeFingerprint) {
|
|
344
|
+
throw new Error(`Plan "${taskId}" predates working-tree fingerprints and cannot execute automatically.`);
|
|
345
|
+
}
|
|
346
|
+
const currentFingerprint = resolveWorkingTreeFingerprint(projectRoot, { exec });
|
|
347
|
+
if (currentFingerprint !== record.status.workingTreeFingerprint) {
|
|
348
|
+
throw new Error(`Plan "${taskId}" is stale: working tree changed after approval.`);
|
|
349
|
+
}
|
|
350
|
+
return record;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export async function transitionTask(projectRoot, taskId, nextState, { now = new Date(), exec } = {}) {
|
|
354
|
+
if (![PLAN_STATES.APPROVED, PLAN_STATES.REJECTED].includes(nextState)) throw new Error(`Invalid plan decision "${nextState}".`);
|
|
355
|
+
const record = await readTaskRecord(projectRoot, taskId);
|
|
356
|
+
if (!record) throw new Error(`Plan "${taskId}" not found.`);
|
|
357
|
+
if (record.status.state !== PLAN_STATES.AWAITING_APPROVAL) {
|
|
358
|
+
throw new Error(`Plan "${taskId}" is ${record.status.state}; expected awaiting_approval.`);
|
|
359
|
+
}
|
|
360
|
+
verifyArtifactDigests(record);
|
|
361
|
+
const currentHead = resolveHead(projectRoot, { exec });
|
|
362
|
+
if (currentHead !== record.status.baseHead) {
|
|
363
|
+
throw new Error(`Plan "${taskId}" is stale: repository HEAD changed.`);
|
|
364
|
+
}
|
|
365
|
+
const next = {
|
|
366
|
+
...record.status,
|
|
367
|
+
state: nextState,
|
|
368
|
+
updatedAt: now.toISOString(),
|
|
369
|
+
decisionAt: now.toISOString(),
|
|
370
|
+
decisionHead: currentHead,
|
|
371
|
+
...(nextState === PLAN_STATES.APPROVED
|
|
372
|
+
? { workingTreeFingerprint: resolveWorkingTreeFingerprint(projectRoot, { exec }) }
|
|
373
|
+
: {})
|
|
374
|
+
};
|
|
375
|
+
await writeAtomicJson(record.paths.statusPath, next);
|
|
376
|
+
return { ...record, status: next };
|
|
377
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const ARCHITECT_SCHEMA = "kairo.architect-task/v1";
|
|
4
|
+
|
|
5
|
+
export const PLAN_STATES = Object.freeze({
|
|
6
|
+
DRAFT: "draft",
|
|
7
|
+
AWAITING_APPROVAL: "awaiting_approval",
|
|
8
|
+
APPROVED: "approved",
|
|
9
|
+
REJECTED: "rejected",
|
|
10
|
+
FAILED: "failed"
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export function sha256(value) {
|
|
14
|
+
return createHash("sha256").update(String(value ?? "")).digest("hex");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizeArchitectTask(task) {
|
|
18
|
+
return String(task ?? "").normalize("NFKC").trim().replace(/\s+/g, " ");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createArchitectureRequestKey({ projectRoot, baseHead, task, model = null }) {
|
|
22
|
+
return sha256(JSON.stringify([
|
|
23
|
+
String(projectRoot),
|
|
24
|
+
String(baseHead),
|
|
25
|
+
normalizeArchitectTask(task),
|
|
26
|
+
model == null ? "" : String(model).trim()
|
|
27
|
+
]));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createTaskId(task, now = new Date()) {
|
|
31
|
+
const slug = String(task ?? "")
|
|
32
|
+
.normalize("NFKD")
|
|
33
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
36
|
+
.replace(/^-|-$/g, "")
|
|
37
|
+
.slice(0, 40) || "task";
|
|
38
|
+
const stamp = now.toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
|
|
39
|
+
return `${stamp}-${slug}-${sha256(task).slice(0, 8)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function assertTaskId(taskId) {
|
|
43
|
+
if (typeof taskId !== "string" || !/^[a-z0-9][a-z0-9-]{0,95}$/.test(taskId)) {
|
|
44
|
+
throw new Error(`Invalid task id "${taskId ?? ""}".`);
|
|
45
|
+
}
|
|
46
|
+
return taskId;
|
|
47
|
+
}
|
package/src/global/cli-help.js
CHANGED
|
@@ -70,6 +70,12 @@ Bootstrap: see README.md and docs/install.md (curl install.sh or npx ${PACKAGE_N
|
|
|
70
70
|
|
|
71
71
|
## Agents & runs
|
|
72
72
|
${cli} shell Operations cockpit (TTY)
|
|
73
|
+
${cli} architect --task "..." [--model <name>] [--cwd <dir>] [--json]
|
|
74
|
+
${cli} plans list|show|approve|reject [<taskId>] [--cwd <dir>] [--json]
|
|
75
|
+
${cli} start [--cwd <dir>] Interactive cockpit TUI (approve/execute/cancel plans)
|
|
76
|
+
${cli} ui [--cwd <dir>] [--port <n>] Local loopback conversation UI
|
|
77
|
+
${cli} conversation snapshot|architect|show|approve|reject|cancel ... [--json]
|
|
78
|
+
${cli} conversation execute <taskId> [--role <role>] [--confirm] [--model <name>] [--json] No --confirm: preview only. --confirm: revalidate and execute the shown target.
|
|
73
79
|
${cli} run --agent <id> --task "..." [--strategy direct|orchestrated] [--model <name>] [--cwd <dir>] [--permissions force|yolo|read-only] [--allow-unsafe-permissions] [--capture-transcript] [--follow] [--no-wait] [--json]
|
|
74
80
|
${cli} runs list [--json] [--limit <n>] [--active-only]
|
|
75
81
|
${cli} runs show <runId> [--json] [--limit <n>] [--follow]
|
|
@@ -121,6 +127,7 @@ Bootstrap: see README.md and docs/install.md (curl install.sh or npx ${PACKAGE_N
|
|
|
121
127
|
${cli} fleet models [--profile] [--json]
|
|
122
128
|
${cli} fleet configure [--platforms claude,opencode,cursor|codex] [--from profile|gentle] [--codex-model <id>] [--assignments a=b,...] [--yes] [--json]
|
|
123
129
|
${cli} fleet set --platform opencode|claude|codex --agent <id> --model <id> [--yes] [--json]
|
|
130
|
+
${cli} mcp --workspace-bound --cwd <abs>
|
|
124
131
|
${cli} mcp
|
|
125
132
|
${cli} mcp install [--yes] [--json] [--client cursor]
|
|
126
133
|
${cli} install --scope=workspace [--mode minimal|standard|enterprise] (opt-in/legacy)
|
|
@@ -133,6 +140,8 @@ Scopes:
|
|
|
133
140
|
Explicit --scope=workspace only.
|
|
134
141
|
|
|
135
142
|
Commands:
|
|
143
|
+
architect Run subscription-authenticated Codex in bounded read-only planning mode.
|
|
144
|
+
plans Inspect and explicitly approve or reject project-local architecture plans.
|
|
136
145
|
shell Operations cockpit (TTY). Bare ${cli} opens onboarding when ~/.harness/state.json
|
|
137
146
|
is missing, otherwise the cockpit. Explicit ${cli} shell always opens the cockpit.
|
|
138
147
|
Keys: ↑↓ · Enter · Esc back/exit · R refresh · C cancel · ? help.
|
|
@@ -175,7 +184,7 @@ Commands:
|
|
|
175
184
|
fleet configure one plan for Claude+OpenCode+Cursor (profile)
|
|
176
185
|
fleet configure --codex-model <id> Codex only (single-default)
|
|
177
186
|
fleet set --platform opencode|claude|codex --agent <id> --model <id> [--yes]
|
|
178
|
-
mcp Serve
|
|
187
|
+
mcp Serve MCP. \`mcp --workspace-bound --cwd <abs>\` is the write path; bare \`mcp\` is read-only.
|
|
179
188
|
uninstall Remove managed sections and global state. Backups are preserved.
|
|
180
189
|
init Alias for install --scope=workspace (legacy).
|
|
181
190
|
|