@kal-elsam/kairo-runtime 0.13.1 → 0.15.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 (53) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +12 -10
  3. package/bin/kairo-runtime.js +0 -0
  4. package/bin/kairo.js +0 -0
  5. package/package.json +5 -1
  6. package/scripts/cockpit-smoke.mjs +14 -10
  7. package/src/cli.js +182 -11
  8. package/src/global/check-resolutions.js +31 -0
  9. package/src/global/cli-help.js +23 -4
  10. package/src/global/component-ecosystem-checks.js +2 -0
  11. package/src/global/component-integration-cli.js +29 -10
  12. package/src/global/components-resolve-cli.js +246 -0
  13. package/src/global/connection-actions.js +147 -0
  14. package/src/global/connections.js +269 -0
  15. package/src/global/fleet-configure-plan.js +123 -0
  16. package/src/global/fleet-configure.js +303 -0
  17. package/src/global/fleet-models.js +188 -0
  18. package/src/global/fleet-set.js +219 -0
  19. package/src/global/fleet-shared.js +38 -0
  20. package/src/global/ink/cockpit-controller.js +2 -4
  21. package/src/global/ink/cockpit-enter.js +3 -1
  22. package/src/global/ink/cockpit-focus.js +7 -1
  23. package/src/global/ink/cockpit-models.js +33 -21
  24. package/src/global/ink/cockpit-palette.js +8 -3
  25. package/src/global/ink/cockpit-views.js +9 -2
  26. package/src/global/ink/orchestrator-app.js +42 -4
  27. package/src/global/ink/ux/live-overview.js +53 -26
  28. package/src/global/ink/ux/overview-actions.js +80 -0
  29. package/src/global/ink/ux/overview-needs.js +1 -1
  30. package/src/global/integrations/engram-evidence.js +7 -2
  31. package/src/global/integrations/sdd-apply.js +17 -7
  32. package/src/global/integrations/sdd-evidence.js +22 -3
  33. package/src/global/integrations/sdd-plan.js +21 -3
  34. package/src/global/integrations/sdd-resolutions.js +73 -0
  35. package/src/global/integrations/sdd-state.js +69 -0
  36. package/src/global/integrations/sdd-verify.js +9 -4
  37. package/src/global/mcp/kairo-mcp.js +56 -5
  38. package/src/global/mcp/resolve-mcp-workspace.js +51 -0
  39. package/src/global/mcp/work-snapshot-rule.js +89 -0
  40. package/src/global/mcp/work-snapshot-tool.js +49 -0
  41. package/src/global/mcp-install.js +239 -0
  42. package/src/global/next/next-cli.js +35 -0
  43. package/src/global/next/next-report.js +145 -0
  44. package/src/global/next/project-key.js +36 -0
  45. package/src/global/next/publish-work-snapshot.js +116 -0
  46. package/src/global/next/work-enroll.js +91 -0
  47. package/src/global/next/work-snapshot.js +216 -0
  48. package/src/global/observability/fleet-activity.js +197 -0
  49. package/src/global/observability/fleet-models-catalog.js +137 -0
  50. package/src/global/observability/fleet-platforms.js +166 -0
  51. package/src/global/observability/fleet-probe.js +229 -0
  52. package/src/global/paths.js +2 -1
  53. package/src/global/self-update.js +216 -0
@@ -0,0 +1,35 @@
1
+ import { resolveHomeDir } from "../paths.js";
2
+ import { printJson } from "../json-output.js";
3
+ import { commandHeader } from "../brand/index.js";
4
+ import { buildNextReport } from "./next-report.js";
5
+
6
+ export async function runNextCli(options = {}) {
7
+ const report = await buildNextReport({
8
+ homeDir: options.homeDir ?? resolveHomeDir(),
9
+ cwd: options.cwd ?? process.cwd(),
10
+ provider: options.provider ?? "cursor",
11
+ client: options.mcpClient ?? options.client ?? "cursor"
12
+ });
13
+
14
+ if (options.json) {
15
+ printJson(report);
16
+ return report;
17
+ }
18
+
19
+ console.log(commandHeader("Next"));
20
+ console.log(`Integration · ${report.integration.state}`);
21
+ if (report.goal) console.log(`Goal · ${report.goal}`);
22
+ if (report.now) console.log(`Now · ${report.now}`);
23
+ if (report.next) console.log(`Next · ${report.next}`);
24
+ if (report.blockers?.length) {
25
+ console.log("Blockers");
26
+ for (const item of report.blockers) console.log(`- ${item}`);
27
+ }
28
+ if (!report.goal && !report.now && !report.next) {
29
+ console.log("No published work snapshot for this workspace.");
30
+ }
31
+ if (report.integration.showRepair) {
32
+ console.log("Repair · MCP configuration looks broken. Re-run: kairo mcp install --yes");
33
+ }
34
+ return report;
35
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * kairo.next/v1 — selected work snapshot + honest integration state for the panel.
3
+ * Never invents Goal/Progress/Now/Blockers/Next when data is absent or corrupt.
4
+ */
5
+ import { detectAgentMcpRegistration } from "../connections.js";
6
+ import { resolveHomeDir } from "../paths.js";
7
+ import { loadEnrollment } from "./work-enroll.js";
8
+ import {
9
+ listWorkSnapshots,
10
+ snapshotIsComplete
11
+ } from "./work-snapshot.js";
12
+
13
+ export const NEXT_SCHEMA = "kairo.next/v1";
14
+
15
+ export const INTEGRATION_STATE = Object.freeze({
16
+ MISSING: "missing",
17
+ READY: "ready",
18
+ ACTIVE: "active",
19
+ BROKEN: "broken"
20
+ });
21
+
22
+ function teamFromDelegations(delegations) {
23
+ if (!Array.isArray(delegations) || delegations.length === 0) return undefined;
24
+ const members = delegations
25
+ .map((row) => {
26
+ if (!row || typeof row !== "object") return null;
27
+ const title = typeof row.title === "string" ? row.title.slice(0, 160) : null;
28
+ const workId = typeof row.workId === "string" ? row.workId : null;
29
+ if (!title && !workId) return null;
30
+ return {
31
+ ...(workId ? { workId } : {}),
32
+ ...(title ? { title } : {}),
33
+ ...(row.role ? { role: row.role } : {}),
34
+ ...(row.state ? { state: row.state } : {})
35
+ };
36
+ })
37
+ .filter(Boolean);
38
+ return members.length > 0 ? { members } : undefined;
39
+ }
40
+
41
+ export function resolveIntegrationState({ mcp, hasUsableSnapshot }) {
42
+ if (mcp?.state === "error") {
43
+ return {
44
+ state: INTEGRATION_STATE.BROKEN,
45
+ mcpConnected: false,
46
+ showRepair: true,
47
+ detail: mcp.detail ?? "MCP configuration could not be read."
48
+ };
49
+ }
50
+ if (!mcp?.connected) {
51
+ return {
52
+ state: INTEGRATION_STATE.MISSING,
53
+ mcpConnected: false,
54
+ showRepair: false,
55
+ detail: mcp?.detail ?? "Kairo MCP is not registered."
56
+ };
57
+ }
58
+ if (hasUsableSnapshot) {
59
+ return {
60
+ state: INTEGRATION_STATE.ACTIVE,
61
+ mcpConnected: true,
62
+ showRepair: false,
63
+ detail: "MCP connected with a usable work snapshot."
64
+ };
65
+ }
66
+ return {
67
+ state: INTEGRATION_STATE.READY,
68
+ mcpConnected: true,
69
+ showRepair: false,
70
+ detail: "MCP connected; waiting for a published work snapshot."
71
+ };
72
+ }
73
+
74
+ function viewFromSnapshot(snapshot) {
75
+ if (!snapshot) {
76
+ return {
77
+ goal: null,
78
+ progress: [],
79
+ now: null,
80
+ blockers: [],
81
+ next: null,
82
+ conversationId: null,
83
+ updatedAt: null
84
+ };
85
+ }
86
+ const team = teamFromDelegations(snapshot.delegations);
87
+ return {
88
+ goal: snapshot.goal ?? null,
89
+ progress: Array.isArray(snapshot.progress) ? snapshot.progress : [],
90
+ now: snapshot.now ?? null,
91
+ blockers: Array.isArray(snapshot.blockers) ? snapshot.blockers : [],
92
+ next: snapshot.next ?? null,
93
+ conversationId: snapshot.conversationId ?? null,
94
+ updatedAt: snapshot.updatedAt ?? null,
95
+ ...(team ? { team } : {})
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Build the next report for the runtime workspace (deps.cwd / process.cwd()).
101
+ */
102
+ export async function buildNextReport({
103
+ homeDir = resolveHomeDir(),
104
+ cwd = process.cwd(),
105
+ provider = "cursor",
106
+ client = "cursor",
107
+ detectAgent = detectAgentMcpRegistration,
108
+ listSnapshots = listWorkSnapshots,
109
+ loadEnrollmentFn = loadEnrollment
110
+ } = {}) {
111
+ const mcp = await detectAgent({ client, homeDir });
112
+ const listed = await listSnapshots(homeDir, cwd);
113
+ // Prefer newest complete snapshot — incomplete records must not hide valid work.
114
+ const snapshot = listed.find((row) => snapshotIsComplete(row)) ?? null;
115
+ const complete = snapshotIsComplete(snapshot);
116
+ const integrationCore = resolveIntegrationState({
117
+ mcp,
118
+ hasUsableSnapshot: complete
119
+ });
120
+ const view = viewFromSnapshot(snapshot);
121
+
122
+ let enrolled = false;
123
+ if (view.conversationId) {
124
+ const enrollment = await loadEnrollmentFn(homeDir, cwd, view.conversationId);
125
+ enrolled = Boolean(enrollment);
126
+ }
127
+
128
+ return {
129
+ schema: NEXT_SCHEMA,
130
+ ok: integrationCore.state !== INTEGRATION_STATE.BROKEN,
131
+ ...view,
132
+ integration: {
133
+ state: integrationCore.state,
134
+ provider,
135
+ client,
136
+ mcpConnected: integrationCore.mcpConnected,
137
+ enrolled,
138
+ showRepair: integrationCore.showRepair === true,
139
+ detail: integrationCore.detail
140
+ },
141
+ diagnostics: integrationCore.state === INTEGRATION_STATE.BROKEN
142
+ ? ["integration_broken"]
143
+ : []
144
+ };
145
+ }
@@ -0,0 +1,36 @@
1
+ import { createHash } from "node:crypto";
2
+ import { realpathSync } from "node:fs";
3
+ import { resolve, sep } from "node:path";
4
+
5
+ /**
6
+ * Canonical absolute path for workspace identity.
7
+ * On macOS `/tmp` → `/private/tmp`; without realpath those alias to different keys.
8
+ * Walks up to an existing ancestor when the leaf path does not exist yet.
9
+ */
10
+ export function canonicalizeProjectPath(projectPath) {
11
+ const resolved = resolve(String(projectPath ?? ""));
12
+ try {
13
+ return realpathSync(resolved);
14
+ } catch {
15
+ const parts = resolved.split(sep);
16
+ for (let i = parts.length - 1; i > 0; i -= 1) {
17
+ const prefix = parts.slice(0, i).join(sep) || sep;
18
+ try {
19
+ const realPrefix = realpathSync(prefix);
20
+ return resolve(realPrefix, ...parts.slice(i));
21
+ } catch {
22
+ // keep walking up
23
+ }
24
+ }
25
+ return resolved;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Stable workspace key derived from an absolute project path.
31
+ * Agents must not supply this — callers compute it from runtime cwd/workspace.
32
+ */
33
+ export function projectKeyForPath(projectPath) {
34
+ const normalized = canonicalizeProjectPath(projectPath).toLowerCase();
35
+ return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
36
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Publish kairo.work-snapshot/v1 from runtime-derived workspace identity.
3
+ */
4
+ import { resolveHomeDir } from "../paths.js";
5
+ import { projectKeyForPath } from "./project-key.js";
6
+ import { enrollConversation } from "./work-enroll.js";
7
+ import {
8
+ assertNoWorkPrivatePayload,
9
+ createWorkSnapshot,
10
+ isIgnoredSmokeConversationId,
11
+ saveWorkSnapshot,
12
+ selectLatestWorkSnapshot,
13
+ snapshotIsComplete
14
+ } from "./work-snapshot.js";
15
+
16
+ const FORBIDDEN = Object.freeze([
17
+ "projectKey", "projectPath", "cwd", "homeDir", "workspaceRoot"
18
+ ]);
19
+
20
+ function fail(code) {
21
+ return { ok: false, code, data: null, diagnostics: [code] };
22
+ }
23
+
24
+ /**
25
+ * Validate → enroll → atomic snapshot write.
26
+ * Workspace comes only from deps.cwd / process.cwd().
27
+ */
28
+ export async function publishWorkSnapshot(input = {}, deps = {}) {
29
+ try {
30
+ assertNoWorkPrivatePayload(input);
31
+ } catch {
32
+ return fail("private_payload");
33
+ }
34
+ for (const key of FORBIDDEN) {
35
+ if (Object.prototype.hasOwnProperty.call(input, key)) {
36
+ return fail("forbidden_identity_fields");
37
+ }
38
+ }
39
+
40
+ const conversationId = typeof input.conversationId === "string"
41
+ ? input.conversationId.trim()
42
+ : "";
43
+ if (!conversationId) return fail("conversation_required");
44
+ if (isIgnoredSmokeConversationId(conversationId)) return fail("ignored_conversation");
45
+
46
+ const provider = typeof input.provider === "string" && input.provider.trim()
47
+ ? input.provider.trim().slice(0, 40)
48
+ : null;
49
+ if (!provider) return fail("provider_required");
50
+
51
+ const draft = createWorkSnapshot({
52
+ goal: input.goal,
53
+ progress: input.progress,
54
+ now: input.now,
55
+ blockers: input.blockers,
56
+ next: input.next,
57
+ delegations: input.delegations,
58
+ conversationId,
59
+ provider
60
+ });
61
+ if (!snapshotIsComplete(draft)) return fail("incomplete_snapshot");
62
+
63
+ const homeDir = deps.homeDir ?? resolveHomeDir();
64
+ const projectPath = deps.cwd ?? process.cwd();
65
+ const projectKey = projectKeyForPath(projectPath);
66
+ const io = { now: deps.now, writeAtomic: deps.writeAtomic };
67
+
68
+ let enrollmentResult;
69
+ try {
70
+ enrollmentResult = await (deps.enrollConversation ?? enrollConversation)(
71
+ homeDir, projectPath, { conversationId, provider }, io
72
+ );
73
+ } catch {
74
+ return fail("enroll_failed");
75
+ }
76
+
77
+ let saved;
78
+ try {
79
+ saved = await (deps.saveWorkSnapshot ?? saveWorkSnapshot)(
80
+ homeDir, projectPath, conversationId, draft, io
81
+ );
82
+ } catch {
83
+ return fail("snapshot_write_failed");
84
+ }
85
+
86
+ if (saved.projectKey !== projectKey || saved.conversationId !== conversationId) {
87
+ return fail("identity_mismatch");
88
+ }
89
+
90
+ const latest = await (deps.selectLatestWorkSnapshot ?? selectLatestWorkSnapshot)(
91
+ homeDir, projectPath
92
+ );
93
+
94
+ return {
95
+ ok: true,
96
+ code: enrollmentResult.created ? "enrolled" : "updated",
97
+ data: {
98
+ projectKey,
99
+ conversationId,
100
+ enrolled: true,
101
+ created: enrollmentResult.created === true,
102
+ snapshot: {
103
+ schema: saved.schema,
104
+ goal: saved.goal,
105
+ progress: saved.progress,
106
+ now: saved.now,
107
+ blockers: saved.blockers,
108
+ next: saved.next,
109
+ ...(saved.delegations ? { delegations: saved.delegations } : {}),
110
+ updatedAt: saved.updatedAt
111
+ },
112
+ selected: latest?.conversationId === conversationId
113
+ },
114
+ diagnostics: []
115
+ };
116
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Idempotent conversation enrollment scoped by runtime projectKey + conversationId.
3
+ */
4
+ import { mkdir, readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { harnessHomePaths } from "../paths.js";
7
+ import { writeAtomicJson } from "../runtime/write-atomic-json.js";
8
+ import { projectKeyForPath } from "./project-key.js";
9
+ import { snapshotFileId } from "./work-snapshot.js";
10
+
11
+ export const WORK_ENROLLMENT_SCHEMA = "kairo.work-enrollment/v1";
12
+
13
+ function enrollmentPath(homeDir, projectKey, conversationId) {
14
+ return join(
15
+ harnessHomePaths(homeDir).sessionsDir,
16
+ projectKey,
17
+ "enrollments",
18
+ `${snapshotFileId(conversationId)}.json`
19
+ );
20
+ }
21
+
22
+ /** Never trusts agent-supplied projectKey — derives it from projectPath. */
23
+ export async function enrollConversation(
24
+ homeDir,
25
+ projectPath,
26
+ { conversationId, provider = null } = {},
27
+ deps = {}
28
+ ) {
29
+ if (typeof conversationId !== "string" || !conversationId.trim()) {
30
+ throw new Error("conversationId is required to enroll.");
31
+ }
32
+ const id = conversationId.trim().slice(0, 160);
33
+ const projectKey = projectKeyForPath(projectPath);
34
+ const path = enrollmentPath(homeDir, projectKey, id);
35
+ await mkdir(join(path, ".."), { recursive: true });
36
+ const nowIso = deps.now ? deps.now() : new Date().toISOString();
37
+ const writeAtomic = deps.writeAtomic ?? writeAtomicJson;
38
+
39
+ let existing = null;
40
+ try {
41
+ existing = JSON.parse(await readFile(path, "utf8"));
42
+ } catch {
43
+ existing = null;
44
+ }
45
+
46
+ if (existing) {
47
+ if (
48
+ existing.schema !== WORK_ENROLLMENT_SCHEMA
49
+ || existing.projectKey !== projectKey
50
+ || existing.conversationId !== id
51
+ ) {
52
+ throw new Error("enrollment_identity_mismatch");
53
+ }
54
+ const refreshed = {
55
+ ...existing,
56
+ provider: provider ? String(provider).slice(0, 40) : existing.provider,
57
+ updatedAt: nowIso
58
+ };
59
+ await writeAtomic(path, refreshed);
60
+ return { created: false, enrollment: refreshed };
61
+ }
62
+
63
+ const enrollment = {
64
+ schema: WORK_ENROLLMENT_SCHEMA,
65
+ projectKey,
66
+ conversationId: id,
67
+ provider: provider ? String(provider).slice(0, 40) : null,
68
+ enrolledAt: nowIso,
69
+ updatedAt: nowIso
70
+ };
71
+ await writeAtomic(path, enrollment);
72
+ return { created: true, enrollment };
73
+ }
74
+
75
+ export async function loadEnrollment(homeDir, projectPath, conversationId) {
76
+ if (typeof conversationId !== "string" || !conversationId.trim()) return null;
77
+ const projectKey = projectKeyForPath(projectPath);
78
+ try {
79
+ const raw = JSON.parse(
80
+ await readFile(enrollmentPath(homeDir, projectKey, conversationId.trim()), "utf8")
81
+ );
82
+ if (
83
+ raw?.schema !== WORK_ENROLLMENT_SCHEMA
84
+ || raw.projectKey !== projectKey
85
+ || raw.conversationId !== conversationId.trim()
86
+ ) return null;
87
+ return raw;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * kairo.work-snapshot/v1 — semantic work state for observability.
3
+ * Never stores prompts, transcripts, or agent-supplied workspace identity.
4
+ */
5
+ import { createHash } from "node:crypto";
6
+ import { mkdir, readFile, readdir } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { harnessHomePaths } from "../paths.js";
9
+ import { writeAtomicJson } from "../runtime/write-atomic-json.js";
10
+ import { projectKeyForPath } from "./project-key.js";
11
+
12
+ export const WORK_SNAPSHOT_SCHEMA = "kairo.work-snapshot/v1";
13
+
14
+ /** Known smoke/fixture conversation ids — ignored on read, never auto-deleted. */
15
+ export const IGNORED_SMOKE_CONVERSATION_IDS = Object.freeze([
16
+ "echopilot-visual-smoke"
17
+ ]);
18
+
19
+ const PRIVATE_KEYS = Object.freeze([
20
+ "prompt", "prompts", "transcript", "transcripts", "response", "messages"
21
+ ]);
22
+
23
+ const TECH_LEAK_RE =
24
+ /\bks_[a-f0-9]{8,}\b|kairo\.(?:work|next|session)\b|call kairo_|engramRef|schema\s*[:=]/i;
25
+
26
+ export function isIgnoredSmokeConversationId(id) {
27
+ return Boolean(id) && IGNORED_SMOKE_CONVERSATION_IDS.includes(String(id));
28
+ }
29
+
30
+ export function looksLikeTechnicalLeak(text) {
31
+ return TECH_LEAK_RE.test(String(text ?? ""));
32
+ }
33
+
34
+ export function assertNoWorkPrivatePayload(input) {
35
+ if (!input || typeof input !== "object") return;
36
+ for (const key of PRIVATE_KEYS) {
37
+ if (Object.prototype.hasOwnProperty.call(input, key)) {
38
+ throw new Error(`Private field "${key}" is not allowed on work payloads.`);
39
+ }
40
+ }
41
+ }
42
+
43
+ function snapshotsDir(homeDir, projectKey) {
44
+ return join(harnessHomePaths(homeDir).sessionsDir, projectKey, "snapshots");
45
+ }
46
+
47
+ /** Stable file id — avoids collisions from sanitized path characters. */
48
+ export function snapshotFileId(conversationId) {
49
+ return createHash("sha256").update(String(conversationId)).digest("hex").slice(0, 32);
50
+ }
51
+
52
+ function snapshotFilePath(homeDir, projectKey, conversationId) {
53
+ return join(snapshotsDir(homeDir, projectKey), `${snapshotFileId(conversationId)}.json`);
54
+ }
55
+
56
+ function resolveConversationId(conversationId, snapshot) {
57
+ const raw = conversationId ?? snapshot?.conversationId ?? null;
58
+ if (typeof raw !== "string" || !raw.trim()) {
59
+ throw new Error("conversationId is required to save a work snapshot.");
60
+ }
61
+ return raw.trim().slice(0, 160);
62
+ }
63
+
64
+ function cleanText(value, max) {
65
+ if (typeof value !== "string") return null;
66
+ const trimmed = value.trim();
67
+ if (!trimmed || looksLikeTechnicalLeak(trimmed)) return null;
68
+ return trimmed.slice(0, max);
69
+ }
70
+
71
+ function cleanTextList(list, maxItem, maxItems) {
72
+ if (!Array.isArray(list)) return [];
73
+ return list.map((item) => cleanText(item, maxItem)).filter(Boolean).slice(0, maxItems);
74
+ }
75
+
76
+ function sanitizeDelegations(list) {
77
+ if (!Array.isArray(list)) return [];
78
+ return list
79
+ .map((row) => {
80
+ if (!row || typeof row !== "object") return null;
81
+ const title = cleanText(row.title ?? row.goal, 160);
82
+ const workId = typeof row.workId === "string" && /^kw_[a-f0-9]{32}$/i.test(row.workId)
83
+ ? row.workId
84
+ : null;
85
+ if (!title && !workId) return null;
86
+ const role = row.role === "orchestrator" || row.role === "worker" ? row.role : null;
87
+ const state = ["assigned", "working", "blocked", "completed", "failed"].includes(row.state)
88
+ ? row.state
89
+ : null;
90
+ return {
91
+ ...(workId ? { workId } : {}),
92
+ ...(title ? { title } : {}),
93
+ ...(role ? { role } : {}),
94
+ ...(state ? { state } : {})
95
+ };
96
+ })
97
+ .filter(Boolean)
98
+ .slice(0, 12);
99
+ }
100
+
101
+ export function isWorkSnapshotSchema(schema) {
102
+ return schema === WORK_SNAPSHOT_SCHEMA;
103
+ }
104
+
105
+ /** Build a sanitized v1 snapshot. Incomplete inputs keep nulls — never invent text. */
106
+ export function createWorkSnapshot(input = {}) {
107
+ assertNoWorkPrivatePayload(input);
108
+ const goal = cleanText(input.goal, 160);
109
+ const now = cleanText(input.now, 240);
110
+ const next = cleanText(input.next, 240);
111
+ const progress = cleanTextList(input.progress, 160, 3);
112
+ const blockers = cleanTextList(input.blockers, 200, 12);
113
+ const delegations = sanitizeDelegations(input.delegations);
114
+ const conversationId = input.conversationId
115
+ ? String(input.conversationId).slice(0, 160)
116
+ : null;
117
+ const provider = input.provider ? String(input.provider).slice(0, 40) : null;
118
+
119
+ return {
120
+ schema: WORK_SNAPSHOT_SCHEMA,
121
+ goal,
122
+ progress,
123
+ now,
124
+ blockers,
125
+ next,
126
+ ...(delegations.length > 0 ? { delegations } : {}),
127
+ conversationId,
128
+ provider,
129
+ updatedAt: input.updatedAt ?? new Date().toISOString()
130
+ };
131
+ }
132
+
133
+ export function snapshotIsComplete(snapshot) {
134
+ return Boolean(
135
+ snapshot
136
+ && isWorkSnapshotSchema(snapshot.schema)
137
+ && snapshot.goal
138
+ && snapshot.now
139
+ && snapshot.next
140
+ );
141
+ }
142
+
143
+ function acceptStoredSnapshot(raw) {
144
+ if (!isWorkSnapshotSchema(raw?.schema)) return null;
145
+ if (isIgnoredSmokeConversationId(raw.conversationId)) return null;
146
+ return raw;
147
+ }
148
+
149
+ export async function saveWorkSnapshot(
150
+ homeDir,
151
+ projectPath,
152
+ conversationId,
153
+ snapshot,
154
+ deps = {}
155
+ ) {
156
+ const resolvedId = resolveConversationId(conversationId, snapshot);
157
+ const projectKey = projectKeyForPath(projectPath);
158
+ await mkdir(snapshotsDir(homeDir, projectKey), { recursive: true });
159
+ const nowIso = deps.now ? deps.now() : new Date().toISOString();
160
+ const payload = {
161
+ ...createWorkSnapshot({
162
+ ...snapshot,
163
+ conversationId: resolvedId,
164
+ updatedAt: nowIso
165
+ }),
166
+ projectKey,
167
+ updatedAt: nowIso
168
+ };
169
+ await (deps.writeAtomic ?? writeAtomicJson)(
170
+ snapshotFilePath(homeDir, projectKey, resolvedId),
171
+ payload
172
+ );
173
+ return payload;
174
+ }
175
+
176
+ export async function loadWorkSnapshot(homeDir, projectPath, conversationId) {
177
+ if (typeof conversationId !== "string" || !conversationId.trim()) return null;
178
+ const projectKey = projectKeyForPath(projectPath);
179
+ try {
180
+ const raw = JSON.parse(
181
+ await readFile(snapshotFilePath(homeDir, projectKey, conversationId.trim()), "utf8")
182
+ );
183
+ return acceptStoredSnapshot(raw);
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ export async function listWorkSnapshots(homeDir, projectPath) {
190
+ const projectKey = projectKeyForPath(projectPath);
191
+ const dir = snapshotsDir(homeDir, projectKey);
192
+ let names = [];
193
+ try {
194
+ names = await readdir(dir);
195
+ } catch {
196
+ return [];
197
+ }
198
+ const out = [];
199
+ for (const name of names) {
200
+ if (!name.endsWith(".json")) continue;
201
+ try {
202
+ const raw = JSON.parse(await readFile(join(dir, name), "utf8"));
203
+ const accepted = acceptStoredSnapshot(raw);
204
+ if (accepted) out.push(accepted);
205
+ } catch {
206
+ // corrupt files are skipped — callers see absence, not invented content
207
+ }
208
+ }
209
+ return out.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
210
+ }
211
+
212
+ /** Most recently updated real snapshot for this workspace. */
213
+ export async function selectLatestWorkSnapshot(homeDir, projectPath) {
214
+ const [latest] = await listWorkSnapshots(homeDir, projectPath);
215
+ return latest ?? null;
216
+ }