@kal-elsam/kairo-runtime 0.12.0 → 0.13.1
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 +1179 -0
- package/LICENSE +21 -0
- package/README.md +63 -1193
- package/package.json +5 -3
- package/scripts/cockpit-smoke.mjs +2 -1
- package/src/cli.js +15 -139
- package/src/global/cli-help.js +180 -0
- package/src/global/ink/cockpit-control-center.js +4 -1
- package/src/global/ink/obsidian-vault-display.js +37 -0
- package/src/global/ink/ux/live-overview.js +72 -57
- package/src/global/ink/ux/overview-needs.js +160 -0
- package/src/global/observability/build-companion-snapshot.js +23 -2
- package/src/global/observability/index.js +39 -0
- package/src/global/observability/obsidian-knowledge-preview.js +214 -0
- package/src/global/observability/obsidian-knowledge-views.js +227 -0
- package/src/global/observability/obsidian-publisher.js +181 -0
- package/src/global/observability/obsidian-status.js +76 -0
- package/src/global/observability/obsidian-vault.js +259 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { resolveKairoNotePath } from "./obsidian-vault.js";
|
|
2
|
+
import { formatKnowledgeFrontmatter } from "./obsidian-knowledge-preview.js";
|
|
3
|
+
|
|
4
|
+
/** Canonical view folders / kairo_kind values under Kairo/. */
|
|
5
|
+
export const KAIRO_VIEW_KINDS = Object.freeze([
|
|
6
|
+
"projects", "decisions", "architecture", "sessions", "reviews"
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
const KIND_SET = new Set(KAIRO_VIEW_KINDS);
|
|
10
|
+
const MAX_INDEX_LINKS = 80;
|
|
11
|
+
|
|
12
|
+
function envelope(partial = {}) {
|
|
13
|
+
return { state: "error", views: emptyViews(), links: [], diagnostics: [], error: null, ...partial };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function emptyViews() {
|
|
17
|
+
return Object.fromEntries(KAIRO_VIEW_KINDS.map((k) => [k, []]));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Parse simple `key: "value"` frontmatter between leading --- fences. */
|
|
21
|
+
export function parseKnowledgeFrontmatter(markdown) {
|
|
22
|
+
const text = String(markdown ?? "");
|
|
23
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
24
|
+
if (!m) return { fields: {}, body: text, hasFrontmatter: false };
|
|
25
|
+
const fields = {};
|
|
26
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
27
|
+
const kv = line.match(/^([A-Za-z0-9_]+):\s*"(.*)"\s*$/);
|
|
28
|
+
if (kv) fields[kv[1]] = kv[2];
|
|
29
|
+
}
|
|
30
|
+
return { fields, body: text.slice(m[0].length), hasFrontmatter: true };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Extract `[[target]]` / `[[target|alias]]` wikilinks — display-only graph edges. */
|
|
34
|
+
export function extractWikilinks(markdown) {
|
|
35
|
+
const links = [];
|
|
36
|
+
const re = /\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|[^\]]+)?\]\]/g;
|
|
37
|
+
let match;
|
|
38
|
+
const text = String(markdown ?? "");
|
|
39
|
+
while ((match = re.exec(text)) != null) {
|
|
40
|
+
const target = match[1].trim().replace(/\\/g, "/");
|
|
41
|
+
if (target && !target.includes("..")) links.push(target);
|
|
42
|
+
}
|
|
43
|
+
return links;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function inferKind(relativePath, fields) {
|
|
47
|
+
const kind = fields.kairo_kind;
|
|
48
|
+
if (typeof kind === "string" && KIND_SET.has(kind)) return kind;
|
|
49
|
+
const head = String(relativePath ?? "").split(/[/\\]/)[0];
|
|
50
|
+
return KIND_SET.has(head) ? head : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeWikiTarget(target) {
|
|
54
|
+
const t = String(target).replace(/\.md$/i, "");
|
|
55
|
+
return t.startsWith("/") ? t.slice(1) : t;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read-only index of Kairo notes into view buckets + wikilink edges.
|
|
60
|
+
* `contentsByPath` is optional utf8 map; missing content → title-only entries.
|
|
61
|
+
*/
|
|
62
|
+
export function buildObsidianKnowledgeViews({
|
|
63
|
+
notes = [],
|
|
64
|
+
contentsByPath = {},
|
|
65
|
+
kairoRoot = "/virtual/Kairo"
|
|
66
|
+
} = {}) {
|
|
67
|
+
const views = emptyViews();
|
|
68
|
+
const links = [];
|
|
69
|
+
const diagnostics = [];
|
|
70
|
+
const byPath = new Set();
|
|
71
|
+
|
|
72
|
+
for (const note of notes ?? []) {
|
|
73
|
+
if (note == null || typeof note !== "object") {
|
|
74
|
+
diagnostics.push("skipped malformed note");
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const relativePath = String(note.relativePath ?? "");
|
|
78
|
+
const gate = resolveKairoNotePath(kairoRoot, relativePath);
|
|
79
|
+
if (!gate.ok) {
|
|
80
|
+
diagnostics.push(`skipped ${relativePath || "?"}: ${gate.reason}`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const markdown = contentsByPath[relativePath];
|
|
84
|
+
const parsed = typeof markdown === "string"
|
|
85
|
+
? parseKnowledgeFrontmatter(markdown)
|
|
86
|
+
: { fields: {}, body: "", hasFrontmatter: false };
|
|
87
|
+
const kind = inferKind(relativePath, parsed.fields);
|
|
88
|
+
if (!kind) {
|
|
89
|
+
diagnostics.push(`unclassified ${relativePath}`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const title = parsed.fields.title
|
|
93
|
+
|| note.title
|
|
94
|
+
|| relativePath.replace(/\.md$/i, "").split("/").pop();
|
|
95
|
+
const entry = {
|
|
96
|
+
relativePath,
|
|
97
|
+
title: String(title),
|
|
98
|
+
kairoId: parsed.fields.kairo_id ?? null,
|
|
99
|
+
kind,
|
|
100
|
+
wikiPath: relativePath.replace(/\.md$/i, "")
|
|
101
|
+
};
|
|
102
|
+
views[kind].push(entry);
|
|
103
|
+
byPath.add(entry.wikiPath);
|
|
104
|
+
|
|
105
|
+
if (typeof markdown === "string") {
|
|
106
|
+
for (const target of extractWikilinks(markdown)) {
|
|
107
|
+
links.push({
|
|
108
|
+
from: entry.wikiPath,
|
|
109
|
+
to: normalizeWikiTarget(target),
|
|
110
|
+
kind: entry.kind
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const kind of KAIRO_VIEW_KINDS) {
|
|
117
|
+
views[kind].sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
118
|
+
}
|
|
119
|
+
links.sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to));
|
|
120
|
+
|
|
121
|
+
const total = KAIRO_VIEW_KINDS.reduce((n, k) => n + views[k].length, 0);
|
|
122
|
+
return envelope({
|
|
123
|
+
state: total > 0 ? "available" : "empty",
|
|
124
|
+
views,
|
|
125
|
+
links,
|
|
126
|
+
diagnostics,
|
|
127
|
+
error: null,
|
|
128
|
+
resolvedTargets: [...byPath].sort()
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Build index-note *proposals* only — publish via Slice 03 with consent. */
|
|
133
|
+
export function buildKnowledgeIndexProposals(viewsResult, {
|
|
134
|
+
generatedAt = new Date().toISOString(),
|
|
135
|
+
kairoRoot = "/virtual/Kairo"
|
|
136
|
+
} = {}) {
|
|
137
|
+
const views = viewsResult?.views ?? emptyViews();
|
|
138
|
+
const proposals = [];
|
|
139
|
+
const diagnostics = [];
|
|
140
|
+
|
|
141
|
+
for (const kind of KAIRO_VIEW_KINDS) {
|
|
142
|
+
const entries = views[kind] ?? [];
|
|
143
|
+
const lines = entries.slice(0, MAX_INDEX_LINKS).map((e) => `- [[${e.wikiPath}|${e.title}]]`);
|
|
144
|
+
const fm = formatKnowledgeFrontmatter({
|
|
145
|
+
kairo_kind: kind,
|
|
146
|
+
kairo_id: `index-${kind}`,
|
|
147
|
+
source: "kairo-index",
|
|
148
|
+
generated_at: generatedAt,
|
|
149
|
+
title: `${kind} index`
|
|
150
|
+
});
|
|
151
|
+
const markdown = `${fm}# ${kind}\n\n${lines.length ? lines.join("\n") : "_No notes yet._"}\n`;
|
|
152
|
+
const relativePath = `${kind}/index.md`;
|
|
153
|
+
const gate = resolveKairoNotePath(kairoRoot, relativePath);
|
|
154
|
+
if (!gate.ok) {
|
|
155
|
+
diagnostics.push(`index ${kind}: ${gate.reason}`);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
proposals.push({
|
|
159
|
+
relativePath,
|
|
160
|
+
title: `${kind} index`,
|
|
161
|
+
markdown,
|
|
162
|
+
provenance: { system: "kairo", kind: "index", id: kind },
|
|
163
|
+
absolutePath: gate.path
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
state: proposals.length ? "available" : "empty",
|
|
169
|
+
proposals,
|
|
170
|
+
diagnostics,
|
|
171
|
+
generatedAt
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Convenience: inspect notes + optional content loader → views.
|
|
177
|
+
* Never writes; `readNote` must be injectable (tests / CLI).
|
|
178
|
+
*/
|
|
179
|
+
export async function loadObsidianKnowledgeViews({
|
|
180
|
+
inspectVault,
|
|
181
|
+
vaultPath,
|
|
182
|
+
readNote = null,
|
|
183
|
+
kairoRoot = null
|
|
184
|
+
} = {}) {
|
|
185
|
+
if (typeof inspectVault !== "function") {
|
|
186
|
+
return envelope({ error: "inspectVault required", diagnostics: ["inspectVault required"] });
|
|
187
|
+
}
|
|
188
|
+
let inspected;
|
|
189
|
+
try {
|
|
190
|
+
inspected = await inspectVault({ vaultPath });
|
|
191
|
+
} catch (err) {
|
|
192
|
+
return envelope({
|
|
193
|
+
error: String(err?.message ?? err),
|
|
194
|
+
diagnostics: [`inspect failed: ${String(err?.message ?? err)}`]
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const root = kairoRoot ?? inspected?.kairoRoot;
|
|
198
|
+
if (!root) {
|
|
199
|
+
return envelope({
|
|
200
|
+
state: inspected?.state ?? "error",
|
|
201
|
+
error: inspected?.error ?? "kairoRoot missing",
|
|
202
|
+
diagnostics: inspected?.diagnostics ?? ["kairoRoot missing"]
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const contentsByPath = {};
|
|
206
|
+
if (typeof readNote === "function") {
|
|
207
|
+
for (const note of inspected.notes ?? []) {
|
|
208
|
+
try {
|
|
209
|
+
const text = await readNote(note.relativePath, root);
|
|
210
|
+
if (typeof text === "string") contentsByPath[note.relativePath] = text;
|
|
211
|
+
} catch {
|
|
212
|
+
/* fail-soft: title-only entry */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const built = buildObsidianKnowledgeViews({
|
|
217
|
+
notes: inspected.notes ?? [],
|
|
218
|
+
contentsByPath,
|
|
219
|
+
kairoRoot: root
|
|
220
|
+
});
|
|
221
|
+
return {
|
|
222
|
+
...built,
|
|
223
|
+
diagnostics: [...(inspected.diagnostics ?? []), ...built.diagnostics],
|
|
224
|
+
vaultPath: inspected.vaultPath ?? vaultPath ?? null,
|
|
225
|
+
kairoRoot: root
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
copyFile, lstat, mkdir, readFile, realpath, rename, rm, writeFile
|
|
5
|
+
} from "node:fs/promises";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { hashBuffer } from "../../hash.js";
|
|
8
|
+
import {
|
|
9
|
+
assertInsideKairoRoot, isAllowedKairoNoteName, resolveKairoNotePath
|
|
10
|
+
} from "./obsidian-vault.js";
|
|
11
|
+
|
|
12
|
+
/** Slice 02 frontmatter marker — managed notes may be updated; manual notes refused. */
|
|
13
|
+
export const KAIRO_MANAGED_FRONTMATTER = /^kairo_kind:\s*"/m;
|
|
14
|
+
export const BACKUP_DIR_NAME = ".kairo-backups";
|
|
15
|
+
|
|
16
|
+
const envelope = (partial = {}) => ({
|
|
17
|
+
state: "error", dryRun: false, results: [], diagnostics: [], error: null, ...partial
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const hasConsent = ({ yes = false, confirm = false } = {}) =>
|
|
21
|
+
yes === true || confirm === true;
|
|
22
|
+
|
|
23
|
+
export function classifyNoteWrite(existingMarkdown, proposedMarkdown) {
|
|
24
|
+
if (existingMarkdown === proposedMarkdown) return { action: "skip", reason: "identical" };
|
|
25
|
+
if (KAIRO_MANAGED_FRONTMATTER.test(String(existingMarkdown ?? ""))) {
|
|
26
|
+
return { action: "update", reason: "managed" };
|
|
27
|
+
}
|
|
28
|
+
return { action: "refuse", reason: "manual content" };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function validateProposal(proposal, kairoRoot) {
|
|
32
|
+
if (proposal == null || typeof proposal !== "object") return { ok: false, reason: "malformed proposal" };
|
|
33
|
+
const relativePath = String(proposal.relativePath ?? "");
|
|
34
|
+
const markdown = proposal.markdown;
|
|
35
|
+
if (typeof markdown !== "string") return { ok: false, reason: "proposal markdown required" };
|
|
36
|
+
const base = relativePath.split(/[/\\]/).pop() ?? "";
|
|
37
|
+
if (!isAllowedKairoNoteName(base)) return { ok: false, reason: "proposal basename not allowed" };
|
|
38
|
+
const gate = resolveKairoNotePath(kairoRoot, relativePath);
|
|
39
|
+
if (!gate.ok) return { ok: false, reason: gate.reason };
|
|
40
|
+
return { ok: true, relativePath, markdown, absolutePath: gate.path };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Plan-only: `existingByPath` maps relativePath → utf8 (null = missing). No writes. */
|
|
44
|
+
export function planObsidianPublish(proposals = [], { kairoRoot, existingByPath = {} } = {}) {
|
|
45
|
+
if (typeof kairoRoot !== "string" || !kairoRoot) {
|
|
46
|
+
return envelope({ error: "kairoRoot required", diagnostics: ["kairoRoot required"] });
|
|
47
|
+
}
|
|
48
|
+
const results = [], diagnostics = [];
|
|
49
|
+
for (const proposal of proposals) {
|
|
50
|
+
const v = validateProposal(proposal, kairoRoot);
|
|
51
|
+
if (!v.ok) {
|
|
52
|
+
results.push({ relativePath: proposal?.relativePath ?? null, action: "refuse", reason: v.reason });
|
|
53
|
+
diagnostics.push(v.reason);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const existing = existingByPath[v.relativePath];
|
|
57
|
+
const cls = existing == null
|
|
58
|
+
? { action: "create", reason: "missing" }
|
|
59
|
+
: classifyNoteWrite(existing, v.markdown);
|
|
60
|
+
results.push({
|
|
61
|
+
relativePath: v.relativePath, absolutePath: v.absolutePath,
|
|
62
|
+
action: cls.action, reason: cls.reason,
|
|
63
|
+
hash: hashBuffer(Buffer.from(v.markdown, "utf8"))
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return envelope({ state: "planned", dryRun: true, results, diagnostics, error: null });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function resolveKairoRoot(kairoRoot, { realpathFn = realpath, existsFn = existsSync } = {}) {
|
|
70
|
+
if (typeof kairoRoot !== "string" || !kairoRoot) return { ok: false, reason: "kairoRoot required" };
|
|
71
|
+
if (!existsFn(kairoRoot)) return { ok: false, reason: "kairoRoot missing" };
|
|
72
|
+
try { return { ok: true, path: await realpathFn(kairoRoot) }; }
|
|
73
|
+
catch { return { ok: false, reason: "kairoRoot unreadable" }; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function readExistingMap(proposals, kairoRoot, { readFileFn = readFile, existsFn = existsSync } = {}) {
|
|
77
|
+
const map = {};
|
|
78
|
+
for (const proposal of proposals) {
|
|
79
|
+
const v = validateProposal(proposal, kairoRoot);
|
|
80
|
+
if (!v.ok) continue;
|
|
81
|
+
if (!existsFn(v.absolutePath)) { map[v.relativePath] = null; continue; }
|
|
82
|
+
try { map[v.relativePath] = await readFileFn(v.absolutePath, "utf8"); }
|
|
83
|
+
catch { map[v.relativePath] = null; }
|
|
84
|
+
}
|
|
85
|
+
return map;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function backupExisting(absolutePath, relativePath, kairoRoot, {
|
|
89
|
+
copyFileFn = copyFile, mkdirFn = mkdir, nowMs = Date.now()
|
|
90
|
+
} = {}) {
|
|
91
|
+
const backupGate = resolveKairoNotePath(kairoRoot, join(BACKUP_DIR_NAME, `${relativePath}.${nowMs}.bak`));
|
|
92
|
+
if (!backupGate.ok) return { ok: false, reason: backupGate.reason };
|
|
93
|
+
await mkdirFn(dirname(backupGate.path), { recursive: true });
|
|
94
|
+
await copyFileFn(absolutePath, backupGate.path);
|
|
95
|
+
return { ok: true, path: backupGate.path };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function atomicWrite(absolutePath, markdown, kairoRoot, {
|
|
99
|
+
writeFileFn = writeFile, renameFn = rename, rmFn = rm, mkdirFn = mkdir,
|
|
100
|
+
lstatFn = lstat, existsFn = existsSync
|
|
101
|
+
} = {}) {
|
|
102
|
+
const parent = dirname(absolutePath);
|
|
103
|
+
await mkdirFn(parent, { recursive: true });
|
|
104
|
+
const parentGate = await assertInsideKairoRoot(parent, kairoRoot, { lstatFn, existsFn });
|
|
105
|
+
if (!parentGate.ok && !parentGate.missing) throw new Error(parentGate.reason ?? "parent escapes Kairo/");
|
|
106
|
+
const destGate = await assertInsideKairoRoot(absolutePath, kairoRoot, { lstatFn, existsFn });
|
|
107
|
+
if (!destGate.ok) throw new Error(destGate.reason);
|
|
108
|
+
if (destGate.symlink) throw new Error("destination is a symlink; refusing");
|
|
109
|
+
const tmp = join(parent, `.kairo-write-${randomBytes(12).toString("hex")}.tmp`);
|
|
110
|
+
try {
|
|
111
|
+
await writeFileFn(tmp, markdown, { encoding: "utf8", flag: "wx" });
|
|
112
|
+
await renameFn(tmp, absolutePath);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
await rmFn(tmp, { force: true }).catch(() => {});
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Consent-gated publish. dryRun / no consent → plan only. Never deletes notes. */
|
|
120
|
+
export async function publishObsidianProposals({
|
|
121
|
+
kairoRoot, proposals = [], yes = false, confirm = false, dryRun = false,
|
|
122
|
+
readFileFn = readFile, writeFileFn = writeFile, renameFn = rename, rmFn = rm,
|
|
123
|
+
mkdirFn = mkdir, copyFileFn = copyFile, lstatFn = lstat, existsFn = existsSync,
|
|
124
|
+
realpathFn = realpath, nowMs = Date.now()
|
|
125
|
+
} = {}) {
|
|
126
|
+
const resolved = await resolveKairoRoot(kairoRoot, { realpathFn, existsFn });
|
|
127
|
+
if (!resolved.ok) return envelope({ error: resolved.reason, diagnostics: [resolved.reason] });
|
|
128
|
+
kairoRoot = resolved.path;
|
|
129
|
+
|
|
130
|
+
const plan = planObsidianPublish(proposals, {
|
|
131
|
+
kairoRoot,
|
|
132
|
+
existingByPath: await readExistingMap(proposals, kairoRoot, { readFileFn, existsFn })
|
|
133
|
+
});
|
|
134
|
+
if (dryRun === true || !hasConsent({ yes, confirm })) {
|
|
135
|
+
const reason = dryRun === true ? null : "consent required: pass yes or confirm";
|
|
136
|
+
return {
|
|
137
|
+
...plan, state: dryRun === true ? "planned" : "blocked", dryRun: true, error: reason,
|
|
138
|
+
diagnostics: reason ? [...plan.diagnostics, reason] : plan.diagnostics
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const results = [], diagnostics = [...plan.diagnostics];
|
|
143
|
+
const io = { writeFileFn, renameFn, rmFn, mkdirFn, copyFileFn, lstatFn, existsFn, nowMs };
|
|
144
|
+
for (const step of plan.results) {
|
|
145
|
+
if (step.action === "refuse" || step.action === "skip") { results.push({ ...step }); continue; }
|
|
146
|
+
const markdown = proposals.find((p) => p?.relativePath === step.relativePath)?.markdown;
|
|
147
|
+
if (typeof markdown !== "string") {
|
|
148
|
+
results.push({ ...step, action: "refuse", reason: "proposal markdown required" });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
let backupPath = null;
|
|
153
|
+
if (step.action === "update" && existsFn(step.absolutePath)) {
|
|
154
|
+
const bak = await backupExisting(step.absolutePath, step.relativePath, kairoRoot, io);
|
|
155
|
+
if (!bak.ok) {
|
|
156
|
+
results.push({ ...step, action: "refuse", reason: bak.reason });
|
|
157
|
+
diagnostics.push(bak.reason);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
backupPath = bak.path;
|
|
161
|
+
}
|
|
162
|
+
const gate = resolveKairoNotePath(kairoRoot, step.relativePath);
|
|
163
|
+
if (!gate.ok) { results.push({ ...step, action: "refuse", reason: gate.reason }); continue; }
|
|
164
|
+
await atomicWrite(gate.path, markdown, kairoRoot, io);
|
|
165
|
+
results.push({
|
|
166
|
+
...step, absolutePath: gate.path, backupPath,
|
|
167
|
+
action: step.action === "create" ? "created" : "updated"
|
|
168
|
+
});
|
|
169
|
+
} catch (err) {
|
|
170
|
+
const msg = String(err?.message ?? err);
|
|
171
|
+
results.push({ ...step, action: "error", reason: msg });
|
|
172
|
+
diagnostics.push(msg);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const wrote = results.some((r) => r.action === "created" || r.action === "updated");
|
|
176
|
+
const errored = results.some((r) => r.action === "error");
|
|
177
|
+
return envelope({
|
|
178
|
+
state: errored ? "partial" : wrote ? "applied" : "noop",
|
|
179
|
+
dryRun: false, results, diagnostics, error: null
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { inspectObsidianVault as defaultInspect } from "./obsidian-vault.js";
|
|
2
|
+
|
|
3
|
+
function envelope(partial = {}) {
|
|
4
|
+
return {
|
|
5
|
+
state: "unconfigured",
|
|
6
|
+
vaultPath: null,
|
|
7
|
+
kairoRoot: null,
|
|
8
|
+
noteCount: 0,
|
|
9
|
+
lastPublishAt: null,
|
|
10
|
+
pendingProposals: 0,
|
|
11
|
+
diagnostics: [],
|
|
12
|
+
error: null,
|
|
13
|
+
...partial
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function emptyObsidianVaultStatus() {
|
|
18
|
+
return envelope({ state: "error", error: "error" });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function summarizeObsidianVaultStatus(raw) {
|
|
22
|
+
if (raw == null || typeof raw !== "object") return emptyObsidianVaultStatus();
|
|
23
|
+
const noteCount = Array.isArray(raw.notes)
|
|
24
|
+
? raw.notes.length
|
|
25
|
+
: (typeof raw.noteCount === "number" && Number.isFinite(raw.noteCount) ? raw.noteCount : 0);
|
|
26
|
+
return envelope({
|
|
27
|
+
state: typeof raw.state === "string" && raw.state ? raw.state : "error",
|
|
28
|
+
vaultPath: raw.vaultPath ?? null,
|
|
29
|
+
kairoRoot: raw.kairoRoot ?? null,
|
|
30
|
+
noteCount,
|
|
31
|
+
lastPublishAt: typeof raw.lastPublishAt === "string" ? raw.lastPublishAt : null,
|
|
32
|
+
pendingProposals: typeof raw.pendingProposals === "number" && raw.pendingProposals > 0
|
|
33
|
+
? Math.floor(raw.pendingProposals)
|
|
34
|
+
: 0,
|
|
35
|
+
diagnostics: Array.isArray(raw.diagnostics) ? raw.diagnostics.map(String) : [],
|
|
36
|
+
error: raw.error == null ? null : String(raw.error)
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read-only vault status for Cockpit. No writes / no auto-sync.
|
|
42
|
+
* Without an absolute vaultPath → unconfigured (never guesses a home vault).
|
|
43
|
+
*/
|
|
44
|
+
export async function loadObsidianVaultStatus({
|
|
45
|
+
vaultPath = null,
|
|
46
|
+
lastPublishAt = null,
|
|
47
|
+
pendingProposals = 0,
|
|
48
|
+
inspectObsidianVault = defaultInspect
|
|
49
|
+
} = {}) {
|
|
50
|
+
if (vaultPath == null || vaultPath === "") {
|
|
51
|
+
return envelope({
|
|
52
|
+
state: "unconfigured",
|
|
53
|
+
diagnostics: ["vaultPath not configured"],
|
|
54
|
+
lastPublishAt,
|
|
55
|
+
pendingProposals: typeof pendingProposals === "number" ? Math.max(0, Math.floor(pendingProposals)) : 0
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const inspected = await inspectObsidianVault({ vaultPath });
|
|
60
|
+
return summarizeObsidianVaultStatus({
|
|
61
|
+
...inspected,
|
|
62
|
+
noteCount: inspected?.notes?.length ?? 0,
|
|
63
|
+
lastPublishAt,
|
|
64
|
+
pendingProposals
|
|
65
|
+
});
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return envelope({
|
|
68
|
+
state: "error",
|
|
69
|
+
vaultPath: String(vaultPath),
|
|
70
|
+
error: String(err?.message ?? err),
|
|
71
|
+
diagnostics: [String(err?.message ?? err)],
|
|
72
|
+
lastPublishAt,
|
|
73
|
+
pendingProposals: typeof pendingProposals === "number" ? Math.max(0, Math.floor(pendingProposals)) : 0
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|