novahiz 0.3.2 → 0.3.4
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 +5 -3
- package/skills/novahiz-gate/SKILL.md +3 -3
- package/src/exec.ts +6 -1
- package/src/spec.ts +4 -1
package/dist/cli.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join, dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { parse, print, flagOn } from "./commands/context.js";
|
|
6
|
+
import { expandHome } from "./spec.js";
|
|
7
|
+
import { commandCheck, commandSync, commandClassify, commandSkills, commandCategories, commandRules, commandSessionLoad, commandSessionState, commandCatalog, commandRoadmap, commandStep, commandProviders, commandDeps, commandDispatch } from "./commands/inspect.js";
|
|
8
|
+
import { commandGate } from "./commands/gate.js";
|
|
9
|
+
import { commandReport } from "./commands/report.js";
|
|
10
|
+
import { commandTask } from "./commands/task.js";
|
|
11
|
+
import { commandClean } from "./commands/clean.js";
|
|
12
|
+
import { commandDoctor } from "./commands/doctor.js";
|
|
13
|
+
import { commandTokens } from "./commands/tokens.js";
|
|
14
|
+
import { graftCommand } from "./commands/graft.js";
|
|
15
|
+
import { commandInit } from "./commands/init.js";
|
|
16
|
+
import { commandAutodocs } from "./commands/autodocs.js";
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
// M6: HOME was a module-level const, so `--home` (which sets the env var in
|
|
19
|
+
// main()) was silently ignored by the current execution. Read it lazily.
|
|
20
|
+
function homeDir() {
|
|
21
|
+
return (process.env.NOVAHIZ_HOME ||
|
|
22
|
+
join(process.env.HOME || process.env.USERPROFILE || "", ".config", "novahiz"));
|
|
23
|
+
}
|
|
24
|
+
function runSync() {
|
|
25
|
+
const home = homeDir();
|
|
26
|
+
const cli = join(home, "src", "cli.ts");
|
|
27
|
+
if (existsSync(cli)) {
|
|
28
|
+
spawnSync(process.execPath, [cli, "sync"], {
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
stdio: "inherit",
|
|
31
|
+
env: { ...process.env, NOVAHIZ_HOME: home }
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function usage() {
|
|
36
|
+
print({
|
|
37
|
+
name: "novahiz",
|
|
38
|
+
commands: [
|
|
39
|
+
"init Initialize Novahiz in the current project",
|
|
40
|
+
"setup Install Novahiz (config, skills, catalog)",
|
|
41
|
+
"autodocs [--flush] Sync docs and memory after major project changes",
|
|
42
|
+
"doctor Check that everything works",
|
|
43
|
+
"status Show current classification and gate state",
|
|
44
|
+
"task new <title> Start a new tracked task",
|
|
45
|
+
"task status Show task progress",
|
|
46
|
+
"task done <id> Mark a todo as complete",
|
|
47
|
+
"report Session report",
|
|
48
|
+
"clean Remove old logs and sessions",
|
|
49
|
+
"graft Version-control the ledger (graft init/log/diff/status/restore)",
|
|
50
|
+
"upgrade Pull latest and rebuild catalog",
|
|
51
|
+
"version Show version",
|
|
52
|
+
"",
|
|
53
|
+
"",
|
|
54
|
+
"Options:",
|
|
55
|
+
" --home <path> Override Novahiz home directory",
|
|
56
|
+
" --version, -v Show version",
|
|
57
|
+
" --help, -h Show this help",
|
|
58
|
+
"",
|
|
59
|
+
"Advanced (for power users and adapters):",
|
|
60
|
+
" classify <text> Classify a prompt",
|
|
61
|
+
" gate --tool <t> --file <f> Check if an edit is allowed",
|
|
62
|
+
" skills [--category c] List loaded skills",
|
|
63
|
+
" catalog <query> Search the skill catalog",
|
|
64
|
+
" roadmap --category c Show execution roadmap",
|
|
65
|
+
" dispatch [--task id] Generate work packets",
|
|
66
|
+
" tokens Token diagnostics",
|
|
67
|
+
]
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function printVersion() {
|
|
71
|
+
try {
|
|
72
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8"));
|
|
73
|
+
process.stdout.write(`Novahiz ${pkg.version}\n`);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
process.stdout.write("Novahiz (unknown version)\n");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function runInit() {
|
|
80
|
+
const home = homeDir();
|
|
81
|
+
const installScript = join(home, "install", "install.mjs");
|
|
82
|
+
if (existsSync(installScript)) {
|
|
83
|
+
process.stdout.write("Installing Novahiz...\n");
|
|
84
|
+
const result = spawnSync(process.execPath, [installScript, "--yes"], {
|
|
85
|
+
encoding: "utf8",
|
|
86
|
+
stdio: "inherit",
|
|
87
|
+
env: { ...process.env, NOVAHIZ_HOME: home }
|
|
88
|
+
});
|
|
89
|
+
if (result.status !== 0) {
|
|
90
|
+
process.stderr.write("Installation failed. Run `novahiz doctor` for details.\n");
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
process.stdout.write("\nBuilding skill catalog...\n");
|
|
96
|
+
runSync();
|
|
97
|
+
process.stdout.write("\nDone! Restart opencode to activate Novahiz.\n");
|
|
98
|
+
}
|
|
99
|
+
function runUpgrade(parsed) {
|
|
100
|
+
const home = homeDir();
|
|
101
|
+
if (!existsSync(join(home, ".git"))) {
|
|
102
|
+
process.stderr.write("Not a git repository. Install from source first.\n");
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// P2-C (LOW): `git pull` rewrote the running code (hooks included) with no
|
|
107
|
+
// preview. Default to a dry run; `--apply` is required to actually pull.
|
|
108
|
+
if (!flagOn(parsed, "apply")) {
|
|
109
|
+
const dry = spawnSync("git", ["pull", "--dry-run"], { cwd: home, encoding: "utf8" });
|
|
110
|
+
const preview = `${dry.stdout ?? ""}${dry.stderr ?? ""}`.trim();
|
|
111
|
+
if (preview)
|
|
112
|
+
process.stdout.write(preview + "\n");
|
|
113
|
+
if (dry.status !== 0) {
|
|
114
|
+
process.stderr.write("Dry run failed; fix git first.\n");
|
|
115
|
+
process.exitCode = 1;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
process.stdout.write("Dry run only. Re-run `novahiz upgrade --apply` to pull and rebuild.\n");
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
process.stdout.write("Pulling latest changes...\n");
|
|
123
|
+
const pull = spawnSync("git", ["pull", "--ff-only"], { cwd: home, stdio: "inherit" });
|
|
124
|
+
if (pull.status !== 0) {
|
|
125
|
+
process.stderr.write("Pull failed; nothing rebuilt.\n");
|
|
126
|
+
process.exitCode = 1;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
process.stdout.write("\nRebuilding skill catalog...\n");
|
|
130
|
+
runSync();
|
|
131
|
+
process.stdout.write("\nUpgraded! Restart opencode to apply changes.\n");
|
|
132
|
+
}
|
|
133
|
+
function main(argv) {
|
|
134
|
+
const parsed = parse(argv);
|
|
135
|
+
if (typeof parsed.flags.home === "string" && parsed.flags.home.length > 0) {
|
|
136
|
+
process.env.NOVAHIZ_HOME = resolve(expandHome(parsed.flags.home));
|
|
137
|
+
}
|
|
138
|
+
const command = parsed.positionals[0];
|
|
139
|
+
if (command === undefined || command === "help" || command === "--help" || command === "-h") {
|
|
140
|
+
usage();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (parsed.flags.version || parsed.flags.v) {
|
|
144
|
+
printVersion();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
switch (command) {
|
|
148
|
+
case "init":
|
|
149
|
+
return commandInit(parsed);
|
|
150
|
+
case "setup":
|
|
151
|
+
return runInit();
|
|
152
|
+
case "autodocs":
|
|
153
|
+
return commandAutodocs(parsed);
|
|
154
|
+
case "doctor":
|
|
155
|
+
return commandDoctor(parsed);
|
|
156
|
+
case "status":
|
|
157
|
+
case "check":
|
|
158
|
+
return commandCheck();
|
|
159
|
+
case "report":
|
|
160
|
+
return commandReport(parsed);
|
|
161
|
+
case "clean":
|
|
162
|
+
return commandClean(parsed);
|
|
163
|
+
case "upgrade":
|
|
164
|
+
case "update":
|
|
165
|
+
return runUpgrade(parsed);
|
|
166
|
+
case "version":
|
|
167
|
+
case "-v":
|
|
168
|
+
case "--version":
|
|
169
|
+
return printVersion();
|
|
170
|
+
case "task":
|
|
171
|
+
return commandTask(parsed);
|
|
172
|
+
case "sync":
|
|
173
|
+
return commandSync();
|
|
174
|
+
case "classify":
|
|
175
|
+
return commandClassify(parsed);
|
|
176
|
+
case "gate":
|
|
177
|
+
return commandGate(parsed);
|
|
178
|
+
case "skills":
|
|
179
|
+
return commandSkills(parsed);
|
|
180
|
+
case "categories":
|
|
181
|
+
return commandCategories();
|
|
182
|
+
case "rules":
|
|
183
|
+
return commandRules();
|
|
184
|
+
case "session-load":
|
|
185
|
+
return commandSessionLoad(parsed);
|
|
186
|
+
case "session-state":
|
|
187
|
+
return commandSessionState(parsed);
|
|
188
|
+
case "catalog":
|
|
189
|
+
return commandCatalog(parsed);
|
|
190
|
+
case "roadmap":
|
|
191
|
+
return commandRoadmap(parsed);
|
|
192
|
+
case "step":
|
|
193
|
+
return commandStep(parsed);
|
|
194
|
+
case "providers":
|
|
195
|
+
return commandProviders(parsed);
|
|
196
|
+
case "deps":
|
|
197
|
+
return commandDeps(parsed);
|
|
198
|
+
case "dispatch":
|
|
199
|
+
return commandDispatch(parsed);
|
|
200
|
+
case "tokens":
|
|
201
|
+
return commandTokens(parsed);
|
|
202
|
+
case "graft":
|
|
203
|
+
return graftCommand(parsed.positionals.slice(1));
|
|
204
|
+
default:
|
|
205
|
+
process.stderr.write(`novahiz: unknown command "${command}"\n\n`);
|
|
206
|
+
usage();
|
|
207
|
+
process.exitCode = 1;
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
main(process.argv.slice(2));
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
216
|
+
process.stderr.write(`novahiz: ${message}\n`);
|
|
217
|
+
process.exitCode = 1;
|
|
218
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { autoDocsEnabled, clearDirty, dateStamp, isMajorPath, markDirty, readProjectConfig, readState, upsertChangesSection, writeProjectConfig } from "../autodocs.js";
|
|
4
|
+
import { emit, flagOn } from "./context.js";
|
|
5
|
+
import { memoryRoot, writeEntry } from "../memory.js";
|
|
6
|
+
import * as ui from "../render.js";
|
|
7
|
+
const MAX_DOC_PATHS = 12;
|
|
8
|
+
const ARCHITECTURE = "novahiz-docs/ARCHITECTURE.md";
|
|
9
|
+
export function ensureProjectAutoDocsConfig(cwd) {
|
|
10
|
+
try {
|
|
11
|
+
const config = readProjectConfig(cwd);
|
|
12
|
+
if (config.autoDocs === true)
|
|
13
|
+
return "existing";
|
|
14
|
+
writeProjectConfig(cwd, { ...config, autoDocs: true });
|
|
15
|
+
return "created";
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return "failed";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function skipResult(reason, paths = []) {
|
|
22
|
+
const state = readState(process.cwd());
|
|
23
|
+
return {
|
|
24
|
+
status: "skipped",
|
|
25
|
+
reason,
|
|
26
|
+
docsPath: null,
|
|
27
|
+
docsUpdated: false,
|
|
28
|
+
memorySlot: null,
|
|
29
|
+
paths,
|
|
30
|
+
pending: state.pending.length,
|
|
31
|
+
lastSync: state.lastSync
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function flushDocs(cwd, paths) {
|
|
35
|
+
const docPath = join(cwd, "novahiz-docs", "ARCHITECTURE.md");
|
|
36
|
+
if (!existsSync(docPath))
|
|
37
|
+
return { path: ARCHITECTURE, updated: false };
|
|
38
|
+
const sample = paths.slice(0, MAX_DOC_PATHS);
|
|
39
|
+
if (sample.length === 0)
|
|
40
|
+
return { path: ARCHITECTURE, updated: false };
|
|
41
|
+
const doc = readFileSync(docPath, "utf8");
|
|
42
|
+
const lines = sample.map((path) => `${path} (${dateStamp()})`);
|
|
43
|
+
const next = upsertChangesSection(doc, lines);
|
|
44
|
+
if (next === doc)
|
|
45
|
+
return { path: ARCHITECTURE, updated: false };
|
|
46
|
+
writeFileSync(docPath, next, "utf8");
|
|
47
|
+
return { path: ARCHITECTURE, updated: true };
|
|
48
|
+
}
|
|
49
|
+
function flushMemory(cwd, paths) {
|
|
50
|
+
const sample = paths.slice(0, MAX_DOC_PATHS);
|
|
51
|
+
if (sample.length === 0)
|
|
52
|
+
return null;
|
|
53
|
+
try {
|
|
54
|
+
const result = writeEntry({
|
|
55
|
+
title: "Auto docs sync",
|
|
56
|
+
description: "Change log written by novahiz autodocs",
|
|
57
|
+
tags: ["autodocs", "sync"],
|
|
58
|
+
root: memoryRoot(cwd),
|
|
59
|
+
content: [`Sync: ${new Date().toISOString()}`, ...sample.map((path) => `- ${path}`)].join("\n")
|
|
60
|
+
});
|
|
61
|
+
return result.slot.id;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function flushAutoDocs(cwd) {
|
|
68
|
+
if (!autoDocsEnabled(cwd)) {
|
|
69
|
+
return skipResult("autoDocs disabled (set autoDocs=true in .novahiz/config.json or unset NOVAHIZ_AUTODOCS)");
|
|
70
|
+
}
|
|
71
|
+
const state = readState(cwd);
|
|
72
|
+
if (!state.dirty && state.pending.length === 0) {
|
|
73
|
+
return skipResult("nothing dirty", state.pending);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const paths = state.pending;
|
|
77
|
+
const docs = flushDocs(cwd, paths);
|
|
78
|
+
const memorySlot = flushMemory(cwd, paths);
|
|
79
|
+
const next = clearDirty(cwd);
|
|
80
|
+
return {
|
|
81
|
+
status: "flushed",
|
|
82
|
+
reason: `${paths.length} path(s) recorded`,
|
|
83
|
+
docsPath: docs.path,
|
|
84
|
+
docsUpdated: docs.updated,
|
|
85
|
+
memorySlot,
|
|
86
|
+
paths,
|
|
87
|
+
pending: next.pending.length,
|
|
88
|
+
lastSync: next.lastSync
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
return {
|
|
93
|
+
status: "failed",
|
|
94
|
+
reason: error.message,
|
|
95
|
+
docsPath: null,
|
|
96
|
+
docsUpdated: false,
|
|
97
|
+
memorySlot: null,
|
|
98
|
+
paths: state.pending,
|
|
99
|
+
pending: state.pending.length,
|
|
100
|
+
lastSync: state.lastSync
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function markFromArgs(cwd, mark, positionals) {
|
|
105
|
+
const raw = typeof mark === "string" && mark.length > 0
|
|
106
|
+
? mark
|
|
107
|
+
: positionals.join(",");
|
|
108
|
+
const list = raw
|
|
109
|
+
.split(",")
|
|
110
|
+
.map((entry) => entry.trim())
|
|
111
|
+
.filter(Boolean);
|
|
112
|
+
if (list.length === 0) {
|
|
113
|
+
return {
|
|
114
|
+
status: "failed",
|
|
115
|
+
reason: "--mark needs at least one path",
|
|
116
|
+
docsPath: null,
|
|
117
|
+
docsUpdated: false,
|
|
118
|
+
memorySlot: null,
|
|
119
|
+
paths: [],
|
|
120
|
+
pending: readState(cwd).pending.length,
|
|
121
|
+
lastSync: readState(cwd).lastSync
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const accepted = [];
|
|
125
|
+
const rejected = [];
|
|
126
|
+
for (const path of list) {
|
|
127
|
+
if (isMajorPath(path)) {
|
|
128
|
+
markDirty(cwd, path);
|
|
129
|
+
accepted.push(path);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
rejected.push(path);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const state = readState(cwd);
|
|
136
|
+
if (accepted.length === 0) {
|
|
137
|
+
return {
|
|
138
|
+
status: "skipped",
|
|
139
|
+
reason: `no major path among ${rejected.join(", ")}`,
|
|
140
|
+
docsPath: null,
|
|
141
|
+
docsUpdated: false,
|
|
142
|
+
memorySlot: null,
|
|
143
|
+
paths: rejected,
|
|
144
|
+
pending: state.pending.length,
|
|
145
|
+
lastSync: state.lastSync
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
status: "marked",
|
|
150
|
+
reason: rejected.length > 0
|
|
151
|
+
? `marked ${accepted.length}, ignored ${rejected.length} non-major`
|
|
152
|
+
: `marked ${accepted.length}`,
|
|
153
|
+
docsPath: null,
|
|
154
|
+
docsUpdated: false,
|
|
155
|
+
memorySlot: null,
|
|
156
|
+
paths: accepted,
|
|
157
|
+
pending: state.pending.length,
|
|
158
|
+
lastSync: state.lastSync
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function statusView(cwd) {
|
|
162
|
+
const config = readProjectConfig(cwd);
|
|
163
|
+
const state = readState(cwd);
|
|
164
|
+
return {
|
|
165
|
+
cwd,
|
|
166
|
+
autoDocs: config.autoDocs === true,
|
|
167
|
+
enabled: autoDocsEnabled(cwd),
|
|
168
|
+
dirty: state.dirty,
|
|
169
|
+
pending: state.pending,
|
|
170
|
+
lastSync: state.lastSync,
|
|
171
|
+
sessions: state.sessions
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
export function commandAutodocs(parsed) {
|
|
175
|
+
const cwd = process.cwd();
|
|
176
|
+
const wantFlush = flagOn(parsed, "flush");
|
|
177
|
+
const markFlag = parsed.flags.mark;
|
|
178
|
+
const isMark = markFlag !== undefined && markFlag !== false;
|
|
179
|
+
let outcome = null;
|
|
180
|
+
if (isMark) {
|
|
181
|
+
outcome = markFromArgs(cwd, markFlag, parsed.positionals.slice(1));
|
|
182
|
+
}
|
|
183
|
+
else if (wantFlush) {
|
|
184
|
+
outcome = flushAutoDocs(cwd);
|
|
185
|
+
}
|
|
186
|
+
const value = outcome
|
|
187
|
+
? { ...statusView(cwd), action: isMark ? "mark" : "flush", ...outcome }
|
|
188
|
+
: { ...statusView(cwd), action: "status" };
|
|
189
|
+
emit(parsed, value, () => {
|
|
190
|
+
const view = statusView(cwd);
|
|
191
|
+
const lines = [
|
|
192
|
+
ui.heading("novahiz autodocs"),
|
|
193
|
+
ui.kv([
|
|
194
|
+
["Cwd", String(view.cwd)],
|
|
195
|
+
["autoDocs", String(view.autoDocs)],
|
|
196
|
+
["Enabled", String(view.enabled)],
|
|
197
|
+
["Dirty", String(view.dirty)],
|
|
198
|
+
["Pending", String(view.pending)],
|
|
199
|
+
["Last sync", String(view.lastSync)]
|
|
200
|
+
])
|
|
201
|
+
];
|
|
202
|
+
if (outcome) {
|
|
203
|
+
lines.push("", ui.kv([
|
|
204
|
+
["Action", outcome.status],
|
|
205
|
+
["Reason", outcome.reason],
|
|
206
|
+
["Docs", outcome.docsPath ? `${outcome.docsPath}${outcome.docsUpdated ? " updated" : " unchanged"}` : "n/a"],
|
|
207
|
+
["Memory", outcome.memorySlot ?? "n/a"]
|
|
208
|
+
]));
|
|
209
|
+
lines.push("", outcome.status === "failed" ? ui.style("red", outcome.reason) : ui.style("green", outcome.reason));
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
lines.push("", ui.style("dim", "Use --flush to sync, --mark <path> to record a change."));
|
|
213
|
+
}
|
|
214
|
+
return lines.join("\n");
|
|
215
|
+
});
|
|
216
|
+
if (outcome?.status === "failed")
|
|
217
|
+
process.exitCode = 1;
|
|
218
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { asString, confirm, dbPathFor, emit, flagOn, numberFlag, print } from "./context.js";
|
|
4
|
+
import { loadSpec, NovahizHome } from "../spec.js";
|
|
5
|
+
import { openDb } from "../db.js";
|
|
6
|
+
import * as ui from "../render.js";
|
|
7
|
+
const CLEAN_SCOPES = {
|
|
8
|
+
logs: [
|
|
9
|
+
{ table: "enforcement_log", column: "logged_at", label: "enforcement log" },
|
|
10
|
+
{ table: "skill_invocations", column: "invoked_at", label: "skill invocations" }
|
|
11
|
+
],
|
|
12
|
+
roadmap: [{ table: "roadmap_progress", column: "updated_at", label: "roadmap progress" }],
|
|
13
|
+
sessions: [{ table: "sessions", column: "updated_at", label: "sessions" }],
|
|
14
|
+
tasks: [{ table: "tasks", column: "created_at", where: "status <> 'active'", label: "completed tasks" }]
|
|
15
|
+
};
|
|
16
|
+
const CLEAN_CHOICES = ["logs", "roadmap", "sessions", "tasks", "all"];
|
|
17
|
+
export function commandClean(parsed) {
|
|
18
|
+
const root = NovahizHome();
|
|
19
|
+
const spec = loadSpec(root);
|
|
20
|
+
const path = dbPathFor(root, spec);
|
|
21
|
+
const daysRaw = numberFlag(parsed, "days", { min: 1, integer: true });
|
|
22
|
+
const days = daysRaw !== undefined && daysRaw > 0 ? Math.floor(daysRaw) : 30;
|
|
23
|
+
const targetRaw = (asString(parsed.flags.target) || "logs").toLowerCase();
|
|
24
|
+
if (!CLEAN_CHOICES.includes(targetRaw)) {
|
|
25
|
+
print({ error: `unknown target: ${targetRaw}`, choices: CLEAN_CHOICES });
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const scopes = targetRaw === "all" ? Object.values(CLEAN_SCOPES).flat() : CLEAN_SCOPES[targetRaw];
|
|
30
|
+
const vacuum = flagOn(parsed, "vacuum");
|
|
31
|
+
const apply = flagOn(parsed, "apply");
|
|
32
|
+
const cutoff = new Date(Date.now() - days * 86_400_000).toISOString();
|
|
33
|
+
// M13: only allow known tables from CLEAN_SCOPES to prevent SQL injection
|
|
34
|
+
// via --target values. The CLEAN_CHOICES check above already gates this,
|
|
35
|
+
// but we validate at the SQL layer too for defense-in-depth.
|
|
36
|
+
const SAFE_TABLES = new Set(Object.values(CLEAN_SCOPES).flat().map((s) => s.table));
|
|
37
|
+
const db = openDb(path);
|
|
38
|
+
const before = statSync(path).size;
|
|
39
|
+
const plan = scopes.map((scope) => {
|
|
40
|
+
if (!SAFE_TABLES.has(scope.table))
|
|
41
|
+
throw new Error(`unsafe table: ${scope.table}`);
|
|
42
|
+
const where = `${scope.column} < ?${scope.where ? ` AND ${scope.where}` : ""}`;
|
|
43
|
+
const rows = db.prepare(`SELECT COUNT(*) AS n FROM ${scope.table}`).get().n;
|
|
44
|
+
const deletable = db.prepare(`SELECT COUNT(*) AS n FROM ${scope.table} WHERE ${where}`).get(cutoff).n;
|
|
45
|
+
return { table: scope.table, label: scope.label, rows, deletable };
|
|
46
|
+
});
|
|
47
|
+
const total = plan.reduce((sum, entry) => sum + entry.deletable, 0);
|
|
48
|
+
const renderPlan = () => {
|
|
49
|
+
const lines = [
|
|
50
|
+
ui.heading("novahiz clean"),
|
|
51
|
+
ui.kv([
|
|
52
|
+
["Base", `${path} (${ui.bytes(before)})`],
|
|
53
|
+
["Target", targetRaw],
|
|
54
|
+
["Age", `older than ${days} days, before ${cutoff}`],
|
|
55
|
+
["To delete", `${total} line(s)`]
|
|
56
|
+
]),
|
|
57
|
+
"",
|
|
58
|
+
ui.table(["table", "rows", "to delete"], plan.map((entry) => [entry.table, String(entry.rows), String(entry.deletable)])),
|
|
59
|
+
""
|
|
60
|
+
];
|
|
61
|
+
if (targetRaw === "tasks" || targetRaw === "all") {
|
|
62
|
+
lines.push(ui.style("dim", "Orphan todos of deleted tasks are removed with them."));
|
|
63
|
+
}
|
|
64
|
+
return lines.join("\n");
|
|
65
|
+
};
|
|
66
|
+
if (!apply) {
|
|
67
|
+
const interactive = process.stdin.isTTY === true;
|
|
68
|
+
const explicitDryRun = flagOn(parsed, "dry-run");
|
|
69
|
+
if (!interactive) {
|
|
70
|
+
db.close();
|
|
71
|
+
emit(parsed, { path, days, cutoff, target: targetRaw, vacuum, applied: false, bytesBefore: before, bytesAfter: before, deleted: 0, tables: plan }, () => [renderPlan(), ui.style("dim", "Plan mode. Restart with --apply to delete.")].join("\n"));
|
|
72
|
+
if (!explicitDryRun) {
|
|
73
|
+
process.stderr.write("Non-interactive input: add --apply to delete, or --dry-run to keep only the preview.\n");
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
process.stdout.write(`${renderPlan()}\n`);
|
|
79
|
+
if (!confirm("Delete these rows?")) {
|
|
80
|
+
db.close();
|
|
81
|
+
process.stdout.write(`${ui.style("yellow", "Canceled.")}\n`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
let deleted = 0;
|
|
86
|
+
for (const scope of scopes) {
|
|
87
|
+
const where = `${scope.column} < ?${scope.where ? ` AND ${scope.where}` : ""}`;
|
|
88
|
+
deleted += Number(db.prepare(`DELETE FROM ${scope.table} WHERE ${where}`).run(cutoff).changes);
|
|
89
|
+
}
|
|
90
|
+
if (targetRaw === "tasks" || targetRaw === "all") {
|
|
91
|
+
deleted += Number(db.prepare("DELETE FROM todos WHERE task_id NOT IN (SELECT id FROM tasks)").run().changes);
|
|
92
|
+
}
|
|
93
|
+
if (vacuum)
|
|
94
|
+
db.exec("VACUUM");
|
|
95
|
+
db.close();
|
|
96
|
+
const after = statSync(path).size;
|
|
97
|
+
const value = { path, days, cutoff, target: targetRaw, vacuum, applied: true, bytesBefore: before, bytesAfter: after, deleted, tables: plan };
|
|
98
|
+
emit(parsed, value, () => [
|
|
99
|
+
renderPlan(),
|
|
100
|
+
ui.kv([
|
|
101
|
+
["Deleted", `${deleted} line(s)`],
|
|
102
|
+
["Size", `${ui.bytes(before)} to ${ui.bytes(after)}`],
|
|
103
|
+
["Vacuum", vacuum ? "yes" : "no"]
|
|
104
|
+
])
|
|
105
|
+
].join("\n"));
|
|
106
|
+
}
|