novahiz 0.3.1 → 0.3.3

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 (46) hide show
  1. package/NOTICE.md +1 -0
  2. package/README.md +1 -1
  3. package/bin/novahiz.mjs +30 -2
  4. package/catalog/providers.json +15 -4
  5. package/dist/autodocs.js +170 -0
  6. package/dist/catalog.js +245 -0
  7. package/dist/classify.js +181 -0
  8. package/dist/cli.js +218 -0
  9. package/dist/commands/autodocs.js +218 -0
  10. package/dist/commands/clean.js +106 -0
  11. package/dist/commands/context.js +167 -0
  12. package/dist/commands/doctor.js +221 -0
  13. package/dist/commands/gate.js +218 -0
  14. package/dist/commands/graft.js +146 -0
  15. package/dist/commands/init.js +325 -0
  16. package/dist/commands/inspect.js +314 -0
  17. package/dist/commands/report.js +87 -0
  18. package/dist/commands/task.js +354 -0
  19. package/dist/commands/tokens.js +69 -0
  20. package/dist/complexity.js +265 -0
  21. package/dist/content.js +99 -0
  22. package/dist/db.js +176 -0
  23. package/dist/deps.js +24 -0
  24. package/dist/exec.js +108 -0
  25. package/dist/gate-repair.js +76 -0
  26. package/dist/gate.js +493 -0
  27. package/dist/graft.js +257 -0
  28. package/dist/ledger.js +431 -0
  29. package/dist/memory.js +441 -0
  30. package/dist/prompt-rewriter.js +481 -0
  31. package/dist/providers.js +47 -0
  32. package/dist/relevance.js +58 -0
  33. package/dist/render.js +90 -0
  34. package/dist/spec.js +208 -0
  35. package/dist/targets.js +375 -0
  36. package/docs/CATALOG.md +1 -1
  37. package/docs/CONFIGURATION.md +2 -2
  38. package/docs/PROVIDERS.md +4 -3
  39. package/install/bootstrap.mjs +25 -6
  40. package/install/install.mjs +36 -48
  41. package/install/lib.mjs +41 -0
  42. package/novahiz.config.example.json +1 -1
  43. package/package.json +5 -3
  44. package/src/exec.ts +6 -1
  45. package/src/spec.ts +5 -2
  46. package/docs/audit-2026-09-25.md +0 -275
package/NOTICE.md CHANGED
@@ -43,6 +43,7 @@ Installed on demand with `npx skills add` (or an upstream tarball when git trans
43
43
  | `flutter-skills` | https://github.com/flutter/agent-plugins | BSD-3-Clause |
44
44
  | `dart-skills` | https://github.com/dart-lang/skills | BSD-3-Clause |
45
45
  | `expo-skills` | https://github.com/expo/skills | MIT |
46
+ | `impeccable` | https://github.com/pbakaus/impeccable | Apache-2.0 |
46
47
 
47
48
  ## opencode plugins (third-party)
48
49
 
package/README.md CHANGED
@@ -274,7 +274,7 @@ Novahiz auto-registers external MCP servers based on the prompt category:
274
274
  | cron | `scheduler-mcp` (local venv clone) | MIT | devops |
275
275
  | dart | `dart mcp-server` (Dart SDK) | BSD-3-Clause | code, debug, design-ui, flutter |
276
276
 
277
- Skill packs (installed from official repos, never vendored): `flutter/agent-plugins` (25 skills), `dart-lang/skills` (15 skills), `expo/skills` (17 skills, the `expo-*` group only; `eas-*` paid services excluded). See [docs/PROVIDERS.md](docs/PROVIDERS.md).
277
+ Skill packs (installed from official repos, never vendored): `flutter/agent-plugins` (25 skills), `dart-lang/skills` (15 skills), `expo/skills` (17 skills, the `expo-*` group only; `eas-*` paid services excluded), `pbakaus/impeccable` (1 skill, the upstream `impeccable` design skill). See [docs/PROVIDERS.md](docs/PROVIDERS.md).
278
278
 
279
279
  Upstream repositories and full provenance for MCP providers and opencode plugins: [docs/PROVIDERS.md](docs/PROVIDERS.md), [docs/HARNESSES.md](docs/HARNESSES.md), [NOTICE.md](NOTICE.md).
280
280
 
package/bin/novahiz.mjs CHANGED
@@ -1,7 +1,35 @@
1
1
  #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
2
3
  import { dirname, join } from "node:path";
3
4
  import { fileURLToPath, pathToFileURL } from "node:url";
4
5
 
6
+ // engines: node >=22.18. Node strips types only OUTSIDE node_modules
7
+ // (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so:
8
+ // - in the repo (or any checkout), run src/cli.ts directly;
9
+ // - under node_modules, run the compiled dist/cli.js shipped by `prepare`.
10
+ const [major, minor] = process.versions.node.split(".").map((value) => Number.parseInt(value, 10));
11
+ if (major < 22 || (major === 22 && minor < 18)) {
12
+ process.stderr.write(
13
+ `novahiz requires Node 22.18 or later (found ${process.versions.node}). Upgrade Node: https://nodejs.org/\n`
14
+ );
15
+ process.exit(1);
16
+ }
17
+
5
18
  const here = dirname(fileURLToPath(import.meta.url));
6
- const cli = join(here, "..", "src", "cli.ts");
7
- await import(pathToFileURL(cli).href);
19
+ const root = join(here, "..");
20
+ const underNodeModules = root.replace(/\\/g, "/").toLowerCase().includes("/node_modules/");
21
+ const dist = join(root, "dist", "cli.js");
22
+ const src = join(root, "src", "cli.ts");
23
+
24
+ let target;
25
+ if (underNodeModules) {
26
+ if (!existsSync(dist)) {
27
+ process.stderr.write("novahiz: dist/cli.js is missing from the installed package. Reinstall: npm install novahiz\n");
28
+ process.exit(1);
29
+ }
30
+ target = dist;
31
+ } else {
32
+ target = src;
33
+ }
34
+
35
+ await import(pathToFileURL(target).href);
@@ -39,20 +39,21 @@
39
39
  "id": "cron",
40
40
  "label": "Cron Scheduler",
41
41
  "kind": "mcp",
42
- "command": ["C:/Users/hiz/.local/share/mcp-scheduler/.venv/Scripts/python.exe", "C:/Users/hiz/.local/share/mcp-scheduler/main.py"],
42
+ "command": ["python", "C:/path/to/scheduler-mcp/main.py"],
43
43
  "source": "https://github.com/PhialsBasement/scheduler-mcp",
44
44
  "license": "MIT",
45
45
  "categories": ["devops"],
46
- "purpose": "Local MIT scheduler clone at C:/Users/hiz/.local/share/mcp-scheduler (requirements pin mcp<2)."
46
+ "purpose": "MIT scheduler clone (requirements pin mcp<2); no npm package exists. Clone PhialsBasement/scheduler-mcp, install its requirements into a venv, then point `command` at that venv's python and your clone's main.py (setup in docs/PROVIDERS.md). Shipped disabled: enable it after the local setup."
47
47
  },
48
48
  {
49
49
  "id": "novahiz",
50
50
  "label": "novahiz",
51
51
  "kind": "mcp",
52
- "command": ["node", "C:/Users/hiz/.config/novahiz/mcp/novahiz-tools/index.mjs"],
52
+ "command": ["node", "~/.config/novahiz/mcp/novahiz-tools/index.mjs"],
53
53
  "source": "https://github.com/novahiz/novahiz",
54
54
  "license": "Apache-2.0",
55
- "categories": ["code", "planning"]
55
+ "categories": ["code", "planning"],
56
+ "purpose": "Novahiz stdio MCP (novahiz_providers, novahiz_deps, memory tools). The opencode plugin resolves this path from the home directory itself; edit the command for other harnesses."
56
57
  },
57
58
  {
58
59
  "id": "dart",
@@ -94,5 +95,15 @@
94
95
  "license": "MIT",
95
96
  "categories": ["code", "expo"],
96
97
  "purpose": "17 official Expo framework skills (expo-* group); the 7 eas-* paid-service skills are excluded. Installed to ~/.agents/skills; when git clone fails, fetch the tarball from codeload.github.com and copy the skill folders."
98
+ },
99
+ {
100
+ "id": "impeccable",
101
+ "label": "Impeccable design skill",
102
+ "kind": "skill",
103
+ "install": ["npx", "-y", "skills@1.7.0", "add", "pbakaus/impeccable", "--skill", "impeccable", "-g", "-a", "opencode", "-y"],
104
+ "source": "https://github.com/pbakaus/impeccable",
105
+ "license": "Apache-2.0",
106
+ "categories": ["design-ui"],
107
+ "purpose": "Upstream Impeccable skill (critique, audit and polish playbooks) required by gate rule R14. Installed to ~/.agents/skills; rerun the command to update."
97
108
  }
98
109
  ]
@@ -0,0 +1,170 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export const NOVAHIZ_DIR = ".novahiz";
4
+ export const CONFIG_NAME = "config.json";
5
+ export const STATE_NAME = "state.json";
6
+ export const EMPTY_STATE = {
7
+ dirty: false,
8
+ pending: [],
9
+ lastSync: null,
10
+ sessions: 0
11
+ };
12
+ const MAJOR_DIRS = /^(src|lib|app|routes|pages|api|server|internal|pkg|cmd)\//;
13
+ const MAJOR_FILES = new Set([
14
+ "package.json",
15
+ "pyproject.toml",
16
+ "Cargo.toml",
17
+ "go.mod",
18
+ "composer.json",
19
+ "Gemfile"
20
+ ]);
21
+ const MAJOR_EXT = new Set([
22
+ ".ts",
23
+ ".tsx",
24
+ ".js",
25
+ ".jsx",
26
+ ".mjs",
27
+ ".cjs",
28
+ ".py",
29
+ ".rs",
30
+ ".go",
31
+ ".php",
32
+ ".rb",
33
+ ".java",
34
+ ".kt",
35
+ ".swift",
36
+ ".dart",
37
+ ".sql"
38
+ ]);
39
+ const SKIP_DIRS = new Set([
40
+ "node_modules",
41
+ ".git",
42
+ "project-memory",
43
+ "novahiz-docs",
44
+ ".novahiz",
45
+ "dist",
46
+ "build",
47
+ "coverage",
48
+ ".next"
49
+ ]);
50
+ export function projectDir(cwd) {
51
+ return join(cwd, NOVAHIZ_DIR);
52
+ }
53
+ export function configPath(cwd) {
54
+ return join(projectDir(cwd), CONFIG_NAME);
55
+ }
56
+ export function statePath(cwd) {
57
+ return join(projectDir(cwd), STATE_NAME);
58
+ }
59
+ function ensureDir(path) {
60
+ if (!existsSync(path))
61
+ mkdirSync(path, { recursive: true });
62
+ }
63
+ export function autoDocsEnabled(cwd) {
64
+ const escape = (process.env.NOVAHIZ_AUTODOCS ?? "").toLowerCase();
65
+ if (["off", "0", "false", "no", "disabled"].includes(escape))
66
+ return false;
67
+ const config = readProjectConfig(cwd);
68
+ return config.autoDocs === true;
69
+ }
70
+ export function readProjectConfig(cwd) {
71
+ try {
72
+ const raw = readFileSync(configPath(cwd), "utf8");
73
+ const parsed = JSON.parse(raw);
74
+ if (!parsed || typeof parsed !== "object")
75
+ return {};
76
+ return { autoDocs: parsed.autoDocs === true };
77
+ }
78
+ catch {
79
+ return {};
80
+ }
81
+ }
82
+ export function writeProjectConfig(cwd, config) {
83
+ ensureDir(projectDir(cwd));
84
+ writeFileSync(configPath(cwd), `${JSON.stringify(config, null, 2)}\n`, "utf8");
85
+ }
86
+ export function readState(cwd) {
87
+ try {
88
+ const raw = readFileSync(statePath(cwd), "utf8");
89
+ const parsed = JSON.parse(raw);
90
+ if (!parsed || typeof parsed !== "object")
91
+ return { ...EMPTY_STATE };
92
+ return {
93
+ dirty: parsed.dirty === true,
94
+ pending: Array.isArray(parsed.pending)
95
+ ? parsed.pending.filter((entry) => typeof entry === "string").slice(0, 64)
96
+ : [],
97
+ lastSync: typeof parsed.lastSync === "string" ? parsed.lastSync : null,
98
+ sessions: typeof parsed.sessions === "number" && Number.isFinite(parsed.sessions) ? parsed.sessions : 0
99
+ };
100
+ }
101
+ catch {
102
+ return { ...EMPTY_STATE };
103
+ }
104
+ }
105
+ export function writeState(cwd, state) {
106
+ ensureDir(projectDir(cwd));
107
+ writeFileSync(statePath(cwd), `${JSON.stringify(state, null, 2)}\n`, "utf8");
108
+ }
109
+ export function normalizePath(filePath) {
110
+ return filePath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
111
+ }
112
+ export function isMajorPath(filePath) {
113
+ const path = normalizePath(filePath);
114
+ const parts = path.split("/");
115
+ if (parts.some((part) => SKIP_DIRS.has(part)))
116
+ return false;
117
+ const base = parts[parts.length - 1] ?? "";
118
+ if (MAJOR_FILES.has(base))
119
+ return true;
120
+ const dot = base.lastIndexOf(".");
121
+ const ext = dot >= 0 ? base.slice(dot).toLowerCase() : "";
122
+ if (MAJOR_EXT.has(ext))
123
+ return true;
124
+ return MAJOR_DIRS.test(path);
125
+ }
126
+ export function markDirty(cwd, filePath) {
127
+ const path = normalizePath(filePath);
128
+ const state = readState(cwd);
129
+ const pending = state.pending.includes(path) ? state.pending : [...state.pending, path].slice(-64);
130
+ const next = {
131
+ ...state,
132
+ dirty: true,
133
+ pending,
134
+ sessions: state.sessions + 1
135
+ };
136
+ writeState(cwd, next);
137
+ return next;
138
+ }
139
+ export function clearDirty(cwd) {
140
+ const state = readState(cwd);
141
+ const next = {
142
+ ...state,
143
+ dirty: false,
144
+ pending: [],
145
+ lastSync: new Date().toISOString()
146
+ };
147
+ writeState(cwd, next);
148
+ return next;
149
+ }
150
+ export function dateStamp() {
151
+ return new Date().toISOString().slice(0, 10);
152
+ }
153
+ export function upsertChangesSection(doc, lines) {
154
+ const header = "## Recent changes";
155
+ const stamp = `*Last updated: ${dateStamp()}*`;
156
+ const bullet = lines.map((line) => `- ${line}`).join("\n");
157
+ const footerMatch = doc.match(/\n---\n\n\*Last updated:.*$/s);
158
+ const footer = footerMatch ? footerMatch[0] : `\n---\n\n${stamp}\n`;
159
+ if (!doc.includes(header)) {
160
+ const base = doc.replace(/\n---\n\n\*Last updated:.*$/s, "").replace(/\s+$/, "");
161
+ return `${base}\n\n${header}\n\n${bullet}\n${footer}`;
162
+ }
163
+ const start = doc.indexOf(header);
164
+ const after = doc.slice(start + header.length);
165
+ const nextHeader = after.search(/\n#{1,6} /);
166
+ const sectionEnd = nextHeader === -1 ? doc.length : start + header.length + nextHeader;
167
+ const before = doc.slice(0, start);
168
+ const rest = doc.slice(sectionEnd);
169
+ return `${before}${header}\n\n${bullet}\n${rest}`;
170
+ }
@@ -0,0 +1,245 @@
1
+ import { mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
2
+ import { basename, isAbsolute, join } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { expandHome } from "./spec.js";
5
+ const IGNORED_DIRECTORIES = new Set(["node_modules", ".git", "dist", "build", ".next", ".cache"]);
6
+ function stripQuotes(value) {
7
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
8
+ return value.slice(1, -1);
9
+ }
10
+ return value;
11
+ }
12
+ export function parseFrontmatter(content) {
13
+ const result = {};
14
+ const text = content.replace(/^\uFEFF/, "");
15
+ const lines = text.split(/\r?\n/);
16
+ if (lines[0]?.trim() !== "---")
17
+ return result;
18
+ let end = -1;
19
+ for (let index = 1; index < lines.length; index += 1) {
20
+ if (lines[index].trim() === "---") {
21
+ end = index;
22
+ break;
23
+ }
24
+ }
25
+ if (end === -1)
26
+ return result;
27
+ const block = lines.slice(1, end);
28
+ for (let index = 0; index < block.length; index += 1) {
29
+ const line = block[index];
30
+ const separator = line.indexOf(":");
31
+ if (separator === -1)
32
+ continue;
33
+ const key = line.slice(0, separator).trim();
34
+ if (key.length === 0)
35
+ continue;
36
+ const raw = line.slice(separator + 1).trim();
37
+ const isBlock = ["|", "|-", "|+", ">", ">-", ">+"].includes(raw);
38
+ if (isBlock) {
39
+ const folded = raw.startsWith(">");
40
+ const parts = [];
41
+ while (index + 1 < block.length) {
42
+ const next = block[index + 1];
43
+ if (next.trim() === "" || /^\s/.test(next)) {
44
+ index += 1;
45
+ parts.push(next.replace(/^\s+/, ""));
46
+ }
47
+ else {
48
+ break;
49
+ }
50
+ }
51
+ result[key] = folded ? parts.join(" ").replace(/\s+/g, " ").trim() : parts.join("\n").trim();
52
+ continue;
53
+ }
54
+ result[key] = stripQuotes(raw);
55
+ }
56
+ return result;
57
+ }
58
+ function walkForSkillFiles(root, found, visited, errors) {
59
+ let real;
60
+ try {
61
+ real = realpathSync(root);
62
+ }
63
+ catch (error) {
64
+ errors.push(`scan could not resolve ${root}: ${String(error)}`);
65
+ return;
66
+ }
67
+ if (visited.has(real))
68
+ return;
69
+ visited.add(real);
70
+ let entries;
71
+ try {
72
+ entries = readdirSync(root).sort();
73
+ }
74
+ catch (error) {
75
+ errors.push(`scan could not list ${root}: ${String(error)}`);
76
+ return;
77
+ }
78
+ for (const entry of entries) {
79
+ if (IGNORED_DIRECTORIES.has(entry))
80
+ continue;
81
+ const full = join(root, entry);
82
+ let stats;
83
+ try {
84
+ stats = statSync(full);
85
+ }
86
+ catch (error) {
87
+ errors.push(`scan could not stat ${full}: ${String(error)}`);
88
+ continue;
89
+ }
90
+ if (stats.isDirectory()) {
91
+ walkForSkillFiles(full, found, visited, errors);
92
+ }
93
+ else if (entry === "SKILL.md") {
94
+ found.push(full);
95
+ }
96
+ }
97
+ }
98
+ export function scanSkills(spec, errors = []) {
99
+ const files = [];
100
+ const visited = new Set();
101
+ for (const root of spec.config.skillRoots) {
102
+ const expanded = expandHome(root);
103
+ walkForSkillFiles(isAbsolute(expanded) ? expanded : join(spec.root, expanded), files, visited, errors);
104
+ }
105
+ files.sort();
106
+ const byId = new Map();
107
+ for (const file of files) {
108
+ let content;
109
+ try {
110
+ content = readFileSync(file, "utf8");
111
+ }
112
+ catch (error) {
113
+ errors.push(`scan could not read ${file}: ${String(error)}`);
114
+ continue;
115
+ }
116
+ const frontmatter = parseFrontmatter(content);
117
+ const folder = basename(join(file, ".."));
118
+ const id = (frontmatter.name || folder).trim();
119
+ if (id.length === 0)
120
+ continue;
121
+ const override = spec.overrides.skills?.[id];
122
+ // M4: power was uncapped — a rogue override (power: 100) always won the
123
+ // "better" comparison and could force an attacker-controlled skill.
124
+ // Clamp to the documented 0..5 range, non-numeric falls back to 3.
125
+ const rawPower = override?.power;
126
+ const power = typeof rawPower === "number" && Number.isFinite(rawPower) ? Math.min(Math.max(Math.trunc(rawPower), 0), 5) : 3;
127
+ const record = {
128
+ id,
129
+ name: frontmatter.name || folder,
130
+ description: frontmatter.description || "",
131
+ sourcePath: file,
132
+ power,
133
+ stars: override?.stars ?? null,
134
+ tags: override?.tags ?? [],
135
+ categories: override?.categories ?? []
136
+ };
137
+ const existing = byId.get(id);
138
+ const better = !existing ||
139
+ record.power > existing.power ||
140
+ (record.power === existing.power && record.sourcePath < existing.sourcePath);
141
+ if (better)
142
+ byId.set(id, record);
143
+ }
144
+ return [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
145
+ }
146
+ export function writeSkillIndex(spec, skills) {
147
+ const directory = join(spec.root, "build");
148
+ mkdirSync(directory, { recursive: true });
149
+ const target = join(directory, "installed-skills.json");
150
+ const ids = skills.map((skill) => skill.id).sort();
151
+ writeFileSync(target, `${JSON.stringify(ids, null, 2)}\n`, "utf8");
152
+ return target;
153
+ }
154
+ export function writeCatalog(spec, skills) {
155
+ const directory = join(spec.root, "build");
156
+ mkdirSync(directory, { recursive: true });
157
+ const target = join(directory, "catalog.json");
158
+ const records = skills.map((skill) => ({
159
+ id: skill.id,
160
+ name: skill.name,
161
+ description: skill.description,
162
+ power: skill.power,
163
+ stars: skill.stars,
164
+ tags: skill.tags,
165
+ categories: skill.categories
166
+ }));
167
+ writeFileSync(target, `${JSON.stringify(records, null, 2)}\n`, "utf8");
168
+ return target;
169
+ }
170
+ export function loadCatalog(spec) {
171
+ try {
172
+ const raw = readFileSync(join(spec.root, "build", "catalog.json"), "utf8");
173
+ const parsed = JSON.parse(raw);
174
+ return Array.isArray(parsed) ? parsed : [];
175
+ }
176
+ catch (err) {
177
+ console.error(`[Novahiz] loadCatalog failed: ${err instanceof Error ? err.message : err}`);
178
+ return [];
179
+ }
180
+ }
181
+ export function loadInstalledSkills(spec) {
182
+ let raw;
183
+ try {
184
+ raw = readFileSync(join(spec.root, "build", "installed-skills.json"), "utf8");
185
+ }
186
+ catch {
187
+ return { available: false, skills: new Set() };
188
+ }
189
+ try {
190
+ const parsed = JSON.parse(raw);
191
+ if (!Array.isArray(parsed))
192
+ return { available: false, skills: new Set() };
193
+ return { available: true, skills: new Set(parsed.map((value) => String(value))) };
194
+ }
195
+ catch {
196
+ return { available: false, skills: new Set() };
197
+ }
198
+ }
199
+ export function persistCatalog(db, spec, skills) {
200
+ const now = new Date().toISOString();
201
+ // M3: the destructive DELETE used to run outside the try — a failure between
202
+ // DELETE and COMMIT left an empty catalog with no rollback. Everything is now
203
+ // inside the transaction, and ROLLBACK itself is guarded.
204
+ try {
205
+ db.exec("BEGIN; DELETE FROM skills; DELETE FROM categories; DELETE FROM rules;");
206
+ const upsertSkill = db.prepare(`INSERT INTO skills (id, name, description, source_path, power, stars, tags, categories, scanned_at)
207
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
208
+ ON CONFLICT(id) DO UPDATE SET
209
+ name = excluded.name,
210
+ description = excluded.description,
211
+ source_path = excluded.source_path,
212
+ power = excluded.power,
213
+ stars = excluded.stars,
214
+ tags = excluded.tags,
215
+ categories = excluded.categories,
216
+ scanned_at = excluded.scanned_at`);
217
+ for (const skill of skills) {
218
+ upsertSkill.run(skill.id, skill.name, skill.description, skill.sourcePath, skill.power, skill.stars, JSON.stringify(skill.tags), JSON.stringify(skill.categories), now);
219
+ }
220
+ const upsertCategory = db.prepare(`INSERT INTO categories (id, label, priority, keywords, default_skills)
221
+ VALUES (?, ?, ?, ?, ?)
222
+ ON CONFLICT(id) DO UPDATE SET
223
+ label = excluded.label,
224
+ priority = excluded.priority,
225
+ keywords = excluded.keywords,
226
+ default_skills = excluded.default_skills`);
227
+ for (const category of spec.categories) {
228
+ upsertCategory.run(category.id, category.label, category.priority, JSON.stringify(category.keywords), JSON.stringify(category.defaultSkills));
229
+ }
230
+ const insertRule = db.prepare("INSERT INTO rules (id, json) VALUES (?, ?)");
231
+ for (const rule of spec.rules) {
232
+ insertRule.run(rule.id, JSON.stringify(rule));
233
+ }
234
+ db.exec("COMMIT");
235
+ }
236
+ catch (error) {
237
+ try {
238
+ db.exec("ROLLBACK");
239
+ }
240
+ catch {
241
+ // no transaction was open — nothing to roll back
242
+ }
243
+ throw error;
244
+ }
245
+ }