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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * `novahiz graft` — version-control the SQLite ledger via graft.
3
+ *
4
+ * Subcommands: init, log, diff, status, restore, export, commit
5
+ */
6
+ import { execFileSync } from "node:child_process";
7
+ import { existsSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { NovahizHome } from "../spec.js";
10
+ import { isGraftAvailable, isGraftInitialized, initGraft, commitGraft, getGraftLog, getGraftDiff, getGraftStatus, restoreGraft, exportGraft, } from "../graft.js";
11
+ const HELP = `novahiz graft — version-control the SQLite ledger
12
+
13
+ Usage:
14
+ novahiz graft init Initialize graft repository
15
+ novahiz graft log [N] Show last N commits (default: 20)
16
+ novahiz graft diff Diff current ledger vs last commit
17
+ novahiz graft status Show graft status
18
+ novahiz graft restore <hash> [--force] Restore ledger to a revision (--force required; a backup is written first)
19
+ novahiz graft export <hash> <path> [--force] Export snapshot as .sqlite (path must stay in the workspace; --force to overwrite)
20
+ novahiz graft commit -m MSG Manual commit with message
21
+ novahiz graft help Show this help
22
+ `;
23
+ function fail(msg) {
24
+ console.error(`error: ${msg}`);
25
+ process.exitCode = 1;
26
+ }
27
+ export function graftCommand(argv) {
28
+ const sub = argv[0] ?? "help";
29
+ if (sub === "help" || sub === "--help" || sub === "-h") {
30
+ console.log(HELP);
31
+ return;
32
+ }
33
+ if (!isGraftAvailable()) {
34
+ fail("graft CLI not found on PATH. Install: npm install -g @eidos.space/graft");
35
+ }
36
+ switch (sub) {
37
+ case "init": {
38
+ const result = initGraft();
39
+ if (result.success) {
40
+ console.log(`ok: ${result.message}`);
41
+ }
42
+ else {
43
+ fail(result.message);
44
+ }
45
+ break;
46
+ }
47
+ case "log": {
48
+ if (!isGraftInitialized()) {
49
+ fail("graft not initialized. Run: novahiz graft init");
50
+ }
51
+ const limit = parseInt(argv[1] ?? "20", 10) || 20;
52
+ const entries = getGraftLog(limit);
53
+ if (entries.length === 0) {
54
+ console.log("no commits yet");
55
+ return;
56
+ }
57
+ for (const e of entries) {
58
+ console.log(`\x1b[33m${e.hash}\x1b[0m ${e.date}`);
59
+ console.log(` ${e.message}`);
60
+ }
61
+ break;
62
+ }
63
+ case "diff": {
64
+ if (!isGraftInitialized()) {
65
+ fail("graft not initialized. Run: novahiz graft init");
66
+ }
67
+ const diff = getGraftDiff();
68
+ console.log(diff);
69
+ break;
70
+ }
71
+ case "status": {
72
+ if (!isGraftInitialized()) {
73
+ fail("graft not initialized. Run: novahiz graft init");
74
+ }
75
+ const status = getGraftStatus();
76
+ console.log(status);
77
+ break;
78
+ }
79
+ case "restore": {
80
+ if (!isGraftInitialized()) {
81
+ fail("graft not initialized. Run: novahiz graft init");
82
+ }
83
+ const force = argv.slice(1).includes("--force");
84
+ const rev = argv.slice(1).find((a) => a !== "--force");
85
+ if (!rev) {
86
+ fail("usage: novahiz graft restore <revision> [--force]");
87
+ return;
88
+ }
89
+ const result = restoreGraft(rev, { force });
90
+ if (result.success) {
91
+ console.log(`ok: ${result.message}`);
92
+ }
93
+ else {
94
+ fail(result.message);
95
+ }
96
+ break;
97
+ }
98
+ case "export": {
99
+ if (!isGraftInitialized()) {
100
+ fail("graft not initialized. Run: novahiz graft init");
101
+ }
102
+ const rest = argv.slice(1).filter((a) => a !== "--force");
103
+ const force = argv.slice(1).includes("--force");
104
+ const rev = rest[0];
105
+ const outPath = rest[1];
106
+ if (!rev || !outPath) {
107
+ fail("usage: novahiz graft export <revision> <output-path> [--force]");
108
+ return;
109
+ }
110
+ const result = exportGraft(rev, outPath, { force });
111
+ if (result.success) {
112
+ console.log(`ok: ${result.message}`);
113
+ }
114
+ else {
115
+ fail(result.message);
116
+ }
117
+ break;
118
+ }
119
+ case "commit": {
120
+ if (!isGraftInitialized()) {
121
+ fail("graft not initialized. Run: novahiz graft init");
122
+ }
123
+ // Parse -m flag
124
+ let msg = "";
125
+ for (let i = 1; i < argv.length; i++) {
126
+ if (argv[i] === "-m" && argv[i + 1]) {
127
+ msg = argv[i + 1];
128
+ break;
129
+ }
130
+ }
131
+ if (!msg) {
132
+ fail("usage: novahiz graft commit -m <message>");
133
+ }
134
+ const result = commitGraft(msg);
135
+ if (result) {
136
+ console.log(`[${result.hash}] ${result.message}`);
137
+ }
138
+ else {
139
+ console.log("nothing to commit");
140
+ }
141
+ break;
142
+ }
143
+ default:
144
+ fail(`unknown subcommand: ${sub}. Use 'novahiz graft help'`);
145
+ }
146
+ }
@@ -0,0 +1,325 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { emit, flagOn } from "./context.js";
4
+ import { NovahizHome } from "../spec.js";
5
+ import { ensureMemoryRoot, memoryRoot, writeEntry } from "../memory.js";
6
+ import { ensureProjectAutoDocsConfig } from "./autodocs.js";
7
+ import * as ui from "../render.js";
8
+ const DOC_FILES = ["ARCHITECTURE.md", "CONVENTIONS.md", "DECISIONS.md", "STANDARDS.md"];
9
+ const CLEANUP_PATTERNS = [
10
+ { pattern: /^\.DS_Store$/, reason: "macOS folder metadata" },
11
+ { pattern: /^Thumbs\.db$/i, reason: "Windows thumbnail cache" },
12
+ { pattern: /\.orig$/i, reason: "merge leftover" },
13
+ { pattern: /\.rej$/i, reason: "patch reject leftover" },
14
+ { pattern: /^npm-debug\.log.*$/i, reason: "npm debug log" },
15
+ { pattern: /^yarn-error\.log.*$/i, reason: "yarn error log" },
16
+ { pattern: /^.*\.novahiz-bak$/i, reason: "Novahiz installer backup" }
17
+ ];
18
+ function detectProject(cwd) {
19
+ const markers = [];
20
+ let name = "";
21
+ let version = "";
22
+ let kind = "unknown";
23
+ const pkgPath = join(cwd, "package.json");
24
+ if (existsSync(pkgPath)) {
25
+ markers.push("package.json");
26
+ kind = "node";
27
+ try {
28
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
29
+ name = typeof pkg.name === "string" ? pkg.name : "";
30
+ version = typeof pkg.version === "string" ? pkg.version : "";
31
+ }
32
+ catch {
33
+ // name falls back to the folder below
34
+ }
35
+ }
36
+ if (existsSync(join(cwd, "Cargo.toml"))) {
37
+ markers.push("Cargo.toml");
38
+ if (kind === "unknown")
39
+ kind = "rust";
40
+ }
41
+ if (existsSync(join(cwd, "pyproject.toml")) || existsSync(join(cwd, "requirements.txt"))) {
42
+ markers.push("python-manifest");
43
+ if (kind === "unknown")
44
+ kind = "python";
45
+ }
46
+ if (existsSync(join(cwd, "go.mod"))) {
47
+ markers.push("go.mod");
48
+ if (kind === "unknown")
49
+ kind = "go";
50
+ }
51
+ if (existsSync(join(cwd, ".git")))
52
+ markers.push(".git");
53
+ if (existsSync(join(cwd, "README.md")))
54
+ markers.push("README.md");
55
+ if (!name) {
56
+ name = cwd.split(/[\\/]/).filter(Boolean).pop() ?? "project";
57
+ }
58
+ return { kind, name, version, markers };
59
+ }
60
+ function scanCleanup(cwd) {
61
+ const candidates = [];
62
+ const skipDirs = new Set(["node_modules", ".git", "project-memory", "novahiz-docs", "dist", "build", ".next", "coverage"]);
63
+ const walk = (dir, depth) => {
64
+ if (depth > 3)
65
+ return;
66
+ let entries;
67
+ try {
68
+ entries = readdirSync(dir);
69
+ }
70
+ catch {
71
+ return;
72
+ }
73
+ for (const entry of entries) {
74
+ if (skipDirs.has(entry))
75
+ continue;
76
+ const full = join(dir, entry);
77
+ let isDir = false;
78
+ try {
79
+ isDir = statSync(full).isDirectory();
80
+ }
81
+ catch {
82
+ continue;
83
+ }
84
+ if (isDir) {
85
+ walk(full, depth + 1);
86
+ continue;
87
+ }
88
+ for (const { pattern, reason } of CLEANUP_PATTERNS) {
89
+ if (pattern.test(entry)) {
90
+ candidates.push({ path: relative(cwd, full).split("\\").join("/"), reason });
91
+ break;
92
+ }
93
+ }
94
+ }
95
+ };
96
+ walk(cwd, 0);
97
+ return candidates.sort((a, b) => a.path.localeCompare(b.path));
98
+ }
99
+ function copyDocs(templatesDir, targetDir, dryRun) {
100
+ if (!existsSync(templatesDir)) {
101
+ return { id: "docs", label: "Documentation skeleton", status: "failed", detail: `templates missing: ${templatesDir}` };
102
+ }
103
+ if (existsSync(targetDir)) {
104
+ const existing = readdirSync(targetDir).filter((name) => name.endsWith(".md"));
105
+ return {
106
+ id: "docs",
107
+ label: "Documentation skeleton",
108
+ status: "skipped",
109
+ detail: `novahiz-docs/ already has ${existing.length} file(s)`
110
+ };
111
+ }
112
+ if (dryRun) {
113
+ return {
114
+ id: "docs",
115
+ label: "Documentation skeleton",
116
+ status: "dry-run",
117
+ detail: `would create ${DOC_FILES.length} files under novahiz-docs/`
118
+ };
119
+ }
120
+ try {
121
+ mkdirSync(targetDir, { recursive: true });
122
+ for (const file of DOC_FILES) {
123
+ const src = join(templatesDir, file);
124
+ if (existsSync(src))
125
+ copyFileSync(src, join(targetDir, file));
126
+ }
127
+ return {
128
+ id: "docs",
129
+ label: "Documentation skeleton",
130
+ status: "created",
131
+ detail: `${DOC_FILES.length} files in novahiz-docs/`
132
+ };
133
+ }
134
+ catch (error) {
135
+ return {
136
+ id: "docs",
137
+ label: "Documentation skeleton",
138
+ status: "failed",
139
+ detail: error.message
140
+ };
141
+ }
142
+ }
143
+ function initMemory(cwd, dryRun, seed, project) {
144
+ const root = memoryRoot(cwd);
145
+ if (dryRun) {
146
+ const present = existsSync(root);
147
+ return {
148
+ id: "memory",
149
+ label: "Project memory",
150
+ status: "dry-run",
151
+ detail: present ? "project-memory/ already present, seed would append" : "would create project-memory/"
152
+ };
153
+ }
154
+ try {
155
+ const index = ensureMemoryRoot(root);
156
+ const seeded = index.slots.length === 0 && seed;
157
+ if (seeded) {
158
+ const stackLine = project.markers.length > 0 ? project.markers.join(", ") : "no manifest detected";
159
+ writeEntry({
160
+ title: "Project baseline",
161
+ description: `Initial memory for ${project.name}`,
162
+ tags: ["init", project.kind],
163
+ root,
164
+ content: [
165
+ `Name: ${project.name}`,
166
+ project.version ? `Version: ${project.version}` : null,
167
+ `Kind: ${project.kind}`,
168
+ `Markers: ${stackLine}`,
169
+ `Initialized: ${new Date().toISOString()}`,
170
+ "",
171
+ "Next: fill novahiz-docs/ from a deep read of the codebase (skill novahiz-init)."
172
+ ]
173
+ .filter((line) => line !== null)
174
+ .join("\n")
175
+ });
176
+ }
177
+ return {
178
+ id: "memory",
179
+ label: "Project memory",
180
+ status: "created",
181
+ detail: seeded ? "project-memory/ created with baseline slot" : `project-memory/ ready (${index.slots.length} slot(s))`
182
+ };
183
+ }
184
+ catch (error) {
185
+ return {
186
+ id: "memory",
187
+ label: "Project memory",
188
+ status: "failed",
189
+ detail: error.message
190
+ };
191
+ }
192
+ }
193
+ function projectDoctor(project) {
194
+ if (project.markers.length === 0) {
195
+ return {
196
+ id: "doctor",
197
+ label: "Project detection",
198
+ status: "failed",
199
+ detail: "no package.json, Cargo.toml, pyproject.toml, go.mod, or .git in cwd"
200
+ };
201
+ }
202
+ return {
203
+ id: "doctor",
204
+ label: "Project detection",
205
+ status: "created",
206
+ detail: `${project.kind}: ${project.name}${project.version ? `@${project.version}` : ""} (${project.markers.join(", ")})`
207
+ };
208
+ }
209
+ export function commandInit(parsed) {
210
+ const cwd = process.cwd();
211
+ const home = NovahizHome();
212
+ const dryRun = flagOn(parsed, "dry-run");
213
+ const docsOnly = flagOn(parsed, "docs-only");
214
+ const memoryOnly = flagOn(parsed, "memory-only");
215
+ const noSeed = flagOn(parsed, "no-seed");
216
+ const applyCleanup = flagOn(parsed, "apply") && !dryRun;
217
+ const project = detectProject(cwd);
218
+ const steps = [];
219
+ steps.push(projectDoctor(project));
220
+ const runMemory = !docsOnly;
221
+ const runDocs = !memoryOnly;
222
+ const runCleanup = !docsOnly && !memoryOnly;
223
+ if (runMemory)
224
+ steps.push(initMemory(cwd, dryRun, !noSeed, project));
225
+ if (runDocs) {
226
+ const templates = join(home, "skills", "novahiz-docs", "templates");
227
+ steps.push(copyDocs(templates, join(cwd, "novahiz-docs"), dryRun));
228
+ }
229
+ if (!docsOnly && !memoryOnly) {
230
+ if (dryRun) {
231
+ steps.push({
232
+ id: "autodocs",
233
+ label: "Auto docs config",
234
+ status: "dry-run",
235
+ detail: "would write .novahiz/config.json with autoDocs=true"
236
+ });
237
+ }
238
+ else {
239
+ const status = ensureProjectAutoDocsConfig(cwd);
240
+ steps.push({
241
+ id: "autodocs",
242
+ label: "Auto docs config",
243
+ status: status === "failed" ? "failed" : status === "created" ? "created" : "skipped",
244
+ detail: status === "failed" ? "could not write .novahiz/config.json" : `.novahiz/config.json ${status}`
245
+ });
246
+ }
247
+ }
248
+ let cleanup = [];
249
+ let cleanupApplied = 0;
250
+ if (runCleanup) {
251
+ cleanup = scanCleanup(cwd);
252
+ if (applyCleanup && cleanup.length > 0) {
253
+ const removed = [];
254
+ for (const candidate of cleanup) {
255
+ try {
256
+ rmSync(join(cwd, candidate.path), { force: true });
257
+ removed.push(candidate.path);
258
+ }
259
+ catch {
260
+ // keep going; the count reflects what succeeded
261
+ }
262
+ }
263
+ cleanupApplied = removed.length;
264
+ cleanup = cleanup.filter((item) => !removed.includes(item.path));
265
+ }
266
+ steps.push({
267
+ id: "cleanup",
268
+ label: "Cleanup candidates",
269
+ status: applyCleanup ? "created" : dryRun || cleanup.length > 0 ? "dry-run" : "skipped",
270
+ detail: applyCleanup
271
+ ? `removed ${cleanupApplied} file(s)`
272
+ : cleanup.length > 0
273
+ ? `${cleanup.length} candidate(s), none deleted (use --apply after review)`
274
+ : "nothing safe to propose"
275
+ });
276
+ }
277
+ else {
278
+ steps.push({ id: "cleanup", label: "Cleanup candidates", status: "skipped", detail: "skipped by scope flag" });
279
+ }
280
+ const failed = steps.filter((step) => step.status === "failed");
281
+ const value = {
282
+ cwd,
283
+ home,
284
+ dryRun,
285
+ project,
286
+ steps,
287
+ cleanup,
288
+ cleanupApplied,
289
+ next: failed.length === 0 ? "Run the novahiz-init skill in an agent to fill docs and deepen memory." : null,
290
+ failed: failed.map((step) => step.id)
291
+ };
292
+ emit(parsed, value, () => {
293
+ const lines = [
294
+ ui.heading(dryRun ? "novahiz init (dry-run)" : "novahiz init"),
295
+ ui.kv([
296
+ ["Project", `${project.name}${project.version ? `@${project.version}` : ""}`],
297
+ ["Cwd", cwd],
298
+ ["Home", home]
299
+ ]),
300
+ "",
301
+ ui.table(["step", "status", "detail"], steps.map((step) => [
302
+ step.label,
303
+ step.status === "failed"
304
+ ? ui.status(false)
305
+ : step.status === "created"
306
+ ? ui.status(true)
307
+ : ui.style("dim", step.status),
308
+ step.detail
309
+ ])),
310
+ ""
311
+ ];
312
+ if (cleanup.length > 0 && !applyCleanup) {
313
+ lines.push(ui.heading("Cleanup (not applied)"));
314
+ for (const item of cleanup)
315
+ lines.push(` ${item.path} ${ui.style("dim", item.reason)}`);
316
+ lines.push("");
317
+ }
318
+ lines.push(failed.length > 0
319
+ ? ui.style("red", `${failed.length} step(s) failed.`)
320
+ : ui.style("green", dryRun ? "Dry-run only. Re-run without --dry-run to write." : "Scaffold ready. Load the novahiz-init skill to finish."));
321
+ return lines.join("\n");
322
+ });
323
+ if (failed.length > 0)
324
+ process.exitCode = 1;
325
+ }