novahiz 0.3.2 → 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.
- package/bin/novahiz.mjs +30 -2
- package/dist/autodocs.js +170 -0
- package/dist/catalog.js +245 -0
- package/dist/classify.js +181 -0
- package/dist/cli.js +218 -0
- package/dist/commands/autodocs.js +218 -0
- package/dist/commands/clean.js +106 -0
- package/dist/commands/context.js +167 -0
- package/dist/commands/doctor.js +221 -0
- package/dist/commands/gate.js +218 -0
- package/dist/commands/graft.js +146 -0
- package/dist/commands/init.js +325 -0
- package/dist/commands/inspect.js +314 -0
- package/dist/commands/report.js +87 -0
- package/dist/commands/task.js +354 -0
- package/dist/commands/tokens.js +69 -0
- package/dist/complexity.js +265 -0
- package/dist/content.js +99 -0
- package/dist/db.js +176 -0
- package/dist/deps.js +24 -0
- package/dist/exec.js +108 -0
- package/dist/gate-repair.js +76 -0
- package/dist/gate.js +493 -0
- package/dist/graft.js +257 -0
- package/dist/ledger.js +431 -0
- package/dist/memory.js +441 -0
- package/dist/prompt-rewriter.js +481 -0
- package/dist/providers.js +47 -0
- package/dist/relevance.js +58 -0
- package/dist/render.js +90 -0
- package/dist/spec.js +208 -0
- package/dist/targets.js +375 -0
- package/install/bootstrap.mjs +25 -6
- package/install/install.mjs +32 -44
- package/install/lib.mjs +41 -0
- package/package.json +4 -3
- package/src/exec.ts +6 -1
- package/src/spec.ts +4 -1
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
|
|
7
|
-
|
|
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);
|
package/dist/autodocs.js
ADDED
|
@@ -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
|
+
}
|
package/dist/catalog.js
ADDED
|
@@ -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
|
+
}
|
package/dist/classify.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { providersForCategories } from "./providers.js";
|
|
2
|
+
import { determineTier } from "./complexity.js";
|
|
3
|
+
export function fold(value) {
|
|
4
|
+
return value
|
|
5
|
+
.toLowerCase()
|
|
6
|
+
.normalize("NFD")
|
|
7
|
+
.replace(/[\u0300-\u036f]/g, "");
|
|
8
|
+
}
|
|
9
|
+
function escapeRegex(value) {
|
|
10
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
11
|
+
}
|
|
12
|
+
function termMatches(text, keyword) {
|
|
13
|
+
const needle = fold(keyword).trim();
|
|
14
|
+
if (needle.length === 0)
|
|
15
|
+
return false;
|
|
16
|
+
const pattern = new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegex(needle)}([^\\p{L}\\p{N}]|$)`, "u");
|
|
17
|
+
return pattern.test(text);
|
|
18
|
+
}
|
|
19
|
+
function keywordTerm(keyword) {
|
|
20
|
+
return typeof keyword === "string" ? keyword : keyword.term;
|
|
21
|
+
}
|
|
22
|
+
function keywordWeight(keyword) {
|
|
23
|
+
if (typeof keyword === "string")
|
|
24
|
+
return 1;
|
|
25
|
+
return typeof keyword.weight === "number" ? keyword.weight : 1;
|
|
26
|
+
}
|
|
27
|
+
function matchedAlternative(text, keyword) {
|
|
28
|
+
const raw = keywordTerm(keyword);
|
|
29
|
+
const alternatives = raw.includes("|") ? raw.split("|") : [raw];
|
|
30
|
+
for (const alternative of alternatives) {
|
|
31
|
+
const term = alternative.trim();
|
|
32
|
+
if (term.length > 0 && termMatches(text, term))
|
|
33
|
+
return term;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
function byCodeUnit(a, b) {
|
|
38
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
39
|
+
}
|
|
40
|
+
// Confidence maps a raw keyword score to [0, 1) using a half-saturation curve.
|
|
41
|
+
// At score=2 the confidence is ~0.5; at score=5 it's ~0.71; at score=10 it's ~0.83.
|
|
42
|
+
// The "+2" constant prevents low-score categories from having disproportionate confidence.
|
|
43
|
+
function confidence(score) {
|
|
44
|
+
if (score <= 0)
|
|
45
|
+
return 0;
|
|
46
|
+
return Math.round((score / (score + 2)) * 100) / 100;
|
|
47
|
+
}
|
|
48
|
+
function skillsOfCategory(category) {
|
|
49
|
+
const skills = [...category.defaultSkills];
|
|
50
|
+
for (const step of category.roadmap?.steps ?? []) {
|
|
51
|
+
for (const skill of step.requireSkills ?? []) {
|
|
52
|
+
if (!skills.includes(skill))
|
|
53
|
+
skills.push(skill);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return skills;
|
|
57
|
+
}
|
|
58
|
+
function enforcedOfCategory(category) {
|
|
59
|
+
const skills = [];
|
|
60
|
+
for (const step of category.roadmap?.steps ?? []) {
|
|
61
|
+
if (step.kind !== "skill" || step.optional)
|
|
62
|
+
continue;
|
|
63
|
+
for (const skill of step.requireSkills ?? []) {
|
|
64
|
+
if (!skills.includes(skill))
|
|
65
|
+
skills.push(skill);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return skills;
|
|
69
|
+
}
|
|
70
|
+
export function classify(spec, prompt, options = {}) {
|
|
71
|
+
const minScore = options.minScore ?? spec.config.classify.minScore;
|
|
72
|
+
const maxCategories = options.maxCategories ?? spec.config.classify.maxCategories;
|
|
73
|
+
const fallbackCategory = options.fallbackCategory ?? spec.config.classify.fallbackCategory;
|
|
74
|
+
const text = fold(prompt);
|
|
75
|
+
// Determine complexity tier
|
|
76
|
+
const tier = determineTier(prompt);
|
|
77
|
+
const scored = [];
|
|
78
|
+
for (const category of spec.categories) {
|
|
79
|
+
let score = 0;
|
|
80
|
+
const terms = [];
|
|
81
|
+
const negatives = [];
|
|
82
|
+
for (const keyword of category.keywords) {
|
|
83
|
+
const matched = matchedAlternative(text, keyword);
|
|
84
|
+
if (!matched)
|
|
85
|
+
continue;
|
|
86
|
+
let weight = keywordWeight(keyword);
|
|
87
|
+
if (matched.includes(" "))
|
|
88
|
+
weight += 0.5;
|
|
89
|
+
score += weight;
|
|
90
|
+
terms.push(matched);
|
|
91
|
+
}
|
|
92
|
+
for (const negative of category.negativeKeywords ?? []) {
|
|
93
|
+
const matched = matchedAlternative(text, negative);
|
|
94
|
+
if (!matched)
|
|
95
|
+
continue;
|
|
96
|
+
score -= keywordWeight(negative);
|
|
97
|
+
negatives.push(matched);
|
|
98
|
+
}
|
|
99
|
+
if (score >= minScore) {
|
|
100
|
+
scored.push({ id: category.id, score, confidence: confidence(score), margin: 0, terms, negatives });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const priorityOf = (id) => spec.categories.find((category) => category.id === id)?.priority ?? 0;
|
|
104
|
+
scored.sort((a, b) => b.score - a.score || priorityOf(b.id) - priorityOf(a.id) || byCodeUnit(a.id, b.id));
|
|
105
|
+
for (let index = 0; index < scored.length; index += 1) {
|
|
106
|
+
const next = scored[index + 1]?.score ?? 0;
|
|
107
|
+
scored[index].margin = Math.round((scored[index].score - next) * 1000) / 1000;
|
|
108
|
+
}
|
|
109
|
+
const selected = scored.slice(0, Math.max(0, maxCategories));
|
|
110
|
+
// H8: when nothing matched and fallback is misconfigured, force "general" so
|
|
111
|
+
// the gate still applies R6 rules instead of silently allowing everything.
|
|
112
|
+
if (selected.length === 0) {
|
|
113
|
+
if (fallbackCategory.length > 0 && spec.categories.some((entry) => entry.id === fallbackCategory)) {
|
|
114
|
+
selected.push({ id: fallbackCategory, score: 0, confidence: 0, margin: 0, terms: [], negatives: [] });
|
|
115
|
+
}
|
|
116
|
+
else if (spec.categories.some((entry) => entry.id === "general")) {
|
|
117
|
+
selected.push({ id: "general", score: 0, confidence: 0, margin: 0, terms: [], negatives: [] });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const requiredSkills = [];
|
|
121
|
+
const invocations = [];
|
|
122
|
+
const roadmaps = [];
|
|
123
|
+
for (const item of selected) {
|
|
124
|
+
const category = spec.categories.find((entry) => entry.id === item.id);
|
|
125
|
+
if (!category)
|
|
126
|
+
continue;
|
|
127
|
+
// Tier-based skill filtering
|
|
128
|
+
if (tier === "trivial") {
|
|
129
|
+
// Trivial: no skills required from roadmap
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (tier === "lite") {
|
|
133
|
+
// Lite: only implement + converge skills (skip plan, clarify, etc.)
|
|
134
|
+
for (const step of category.roadmap?.steps ?? []) {
|
|
135
|
+
const allowed = (step.requireSkills ?? []).filter(s => s === "novahiz-implement" || s === "novahiz-converge");
|
|
136
|
+
for (const skill of allowed) {
|
|
137
|
+
if (!requiredSkills.includes(skill))
|
|
138
|
+
requiredSkills.push(skill);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
// Full: all skills
|
|
144
|
+
for (const skill of skillsOfCategory(category)) {
|
|
145
|
+
if (!requiredSkills.includes(skill))
|
|
146
|
+
requiredSkills.push(skill);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const step of category.roadmap?.steps ?? []) {
|
|
150
|
+
if (step.kind !== "skill")
|
|
151
|
+
continue;
|
|
152
|
+
invocations.push({
|
|
153
|
+
category: category.id,
|
|
154
|
+
step: step.id,
|
|
155
|
+
label: step.label,
|
|
156
|
+
kind: step.kind,
|
|
157
|
+
skills: step.requireSkills ?? [],
|
|
158
|
+
optional: step.optional === true
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (category.roadmap) {
|
|
162
|
+
roadmaps.push({ category: category.id, id: category.roadmap.id, steps: category.roadmap.steps });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const primary = selected[0]?.id ?? null;
|
|
166
|
+
const primaryCategory = primary ? spec.categories.find((entry) => entry.id === primary) : undefined;
|
|
167
|
+
// C1: enforcedSkills must respect tier gating — trivial prompts should not
|
|
168
|
+
// expose enforced skills, otherwise downstream consumers load skills unnecessarily.
|
|
169
|
+
const enforcedSkills = (tier !== "trivial" && primaryCategory) ? enforcedOfCategory(primaryCategory) : [];
|
|
170
|
+
const providers = providersForCategories(spec, selected.map((entry) => entry.id)).map((provider) => provider.id);
|
|
171
|
+
return {
|
|
172
|
+
categories: selected,
|
|
173
|
+
primary,
|
|
174
|
+
tier,
|
|
175
|
+
requiredSkills,
|
|
176
|
+
enforcedSkills,
|
|
177
|
+
invocations,
|
|
178
|
+
providers,
|
|
179
|
+
roadmaps
|
|
180
|
+
};
|
|
181
|
+
}
|