novahiz 0.2.2 → 0.2.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,192 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export const NOVAHIZ_DIR = ".novahiz";
5
+ export const CONFIG_NAME = "config.json";
6
+ export const STATE_NAME = "state.json";
7
+
8
+ export type ProjectAutoDocsConfig = {
9
+ autoDocs?: boolean;
10
+ };
11
+
12
+ export type AutoDocsState = {
13
+ dirty: boolean;
14
+ pending: string[];
15
+ lastSync: string | null;
16
+ sessions: number;
17
+ };
18
+
19
+ export const EMPTY_STATE: AutoDocsState = {
20
+ dirty: false,
21
+ pending: [],
22
+ lastSync: null,
23
+ sessions: 0
24
+ };
25
+
26
+ const MAJOR_DIRS = /^(src|lib|app|routes|pages|api|server|internal|pkg|cmd)\//;
27
+ const MAJOR_FILES = new Set([
28
+ "package.json",
29
+ "pyproject.toml",
30
+ "Cargo.toml",
31
+ "go.mod",
32
+ "composer.json",
33
+ "Gemfile"
34
+ ]);
35
+ const MAJOR_EXT = new Set([
36
+ ".ts",
37
+ ".tsx",
38
+ ".js",
39
+ ".jsx",
40
+ ".mjs",
41
+ ".cjs",
42
+ ".py",
43
+ ".rs",
44
+ ".go",
45
+ ".php",
46
+ ".rb",
47
+ ".java",
48
+ ".kt",
49
+ ".swift",
50
+ ".dart",
51
+ ".sql"
52
+ ]);
53
+ const SKIP_DIRS = new Set([
54
+ "node_modules",
55
+ ".git",
56
+ "project-memory",
57
+ "novahiz-docs",
58
+ ".novahiz",
59
+ "dist",
60
+ "build",
61
+ "coverage",
62
+ ".next"
63
+ ]);
64
+
65
+ export function projectDir(cwd: string): string {
66
+ return join(cwd, NOVAHIZ_DIR);
67
+ }
68
+
69
+ export function configPath(cwd: string): string {
70
+ return join(projectDir(cwd), CONFIG_NAME);
71
+ }
72
+
73
+ export function statePath(cwd: string): string {
74
+ return join(projectDir(cwd), STATE_NAME);
75
+ }
76
+
77
+ function ensureDir(path: string): void {
78
+ if (!existsSync(path)) mkdirSync(path, { recursive: true });
79
+ }
80
+
81
+ export function autoDocsEnabled(cwd: string): boolean {
82
+ const escape = (process.env.NOVAHIZ_AUTODOCS ?? "").toLowerCase();
83
+ if (["off", "0", "false", "no", "disabled"].includes(escape)) return false;
84
+ const config = readProjectConfig(cwd);
85
+ return config.autoDocs === true;
86
+ }
87
+
88
+ export function readProjectConfig(cwd: string): ProjectAutoDocsConfig {
89
+ try {
90
+ const raw = readFileSync(configPath(cwd), "utf8");
91
+ const parsed = JSON.parse(raw) as Partial<ProjectAutoDocsConfig>;
92
+ if (!parsed || typeof parsed !== "object") return {};
93
+ return { autoDocs: parsed.autoDocs === true };
94
+ } catch {
95
+ return {};
96
+ }
97
+ }
98
+
99
+ export function writeProjectConfig(cwd: string, config: ProjectAutoDocsConfig): void {
100
+ ensureDir(projectDir(cwd));
101
+ writeFileSync(configPath(cwd), `${JSON.stringify(config, null, 2)}\n`, "utf8");
102
+ }
103
+
104
+ export function readState(cwd: string): AutoDocsState {
105
+ try {
106
+ const raw = readFileSync(statePath(cwd), "utf8");
107
+ const parsed = JSON.parse(raw) as Partial<AutoDocsState>;
108
+ if (!parsed || typeof parsed !== "object") return { ...EMPTY_STATE };
109
+ return {
110
+ dirty: parsed.dirty === true,
111
+ pending: Array.isArray(parsed.pending)
112
+ ? parsed.pending.filter((entry): entry is string => typeof entry === "string").slice(0, 64)
113
+ : [],
114
+ lastSync: typeof parsed.lastSync === "string" ? parsed.lastSync : null,
115
+ sessions: typeof parsed.sessions === "number" && Number.isFinite(parsed.sessions) ? parsed.sessions : 0
116
+ };
117
+ } catch {
118
+ return { ...EMPTY_STATE };
119
+ }
120
+ }
121
+
122
+ export function writeState(cwd: string, state: AutoDocsState): void {
123
+ ensureDir(projectDir(cwd));
124
+ writeFileSync(statePath(cwd), `${JSON.stringify(state, null, 2)}\n`, "utf8");
125
+ }
126
+
127
+ export function normalizePath(filePath: string): string {
128
+ return filePath.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, "");
129
+ }
130
+
131
+ export function isMajorPath(filePath: string): boolean {
132
+ const path = normalizePath(filePath);
133
+ const parts = path.split("/");
134
+ if (parts.some((part) => SKIP_DIRS.has(part))) return false;
135
+ const base = parts[parts.length - 1] ?? "";
136
+ if (MAJOR_FILES.has(base)) return true;
137
+ const dot = base.lastIndexOf(".");
138
+ const ext = dot >= 0 ? base.slice(dot).toLowerCase() : "";
139
+ if (MAJOR_EXT.has(ext)) return true;
140
+ return MAJOR_DIRS.test(path);
141
+ }
142
+
143
+ export function markDirty(cwd: string, filePath: string): AutoDocsState {
144
+ const path = normalizePath(filePath);
145
+ const state = readState(cwd);
146
+ const pending = state.pending.includes(path) ? state.pending : [...state.pending, path].slice(-64);
147
+ const next: AutoDocsState = {
148
+ ...state,
149
+ dirty: true,
150
+ pending,
151
+ sessions: state.sessions + 1
152
+ };
153
+ writeState(cwd, next);
154
+ return next;
155
+ }
156
+
157
+ export function clearDirty(cwd: string): AutoDocsState {
158
+ const state = readState(cwd);
159
+ const next: AutoDocsState = {
160
+ ...state,
161
+ dirty: false,
162
+ pending: [],
163
+ lastSync: new Date().toISOString()
164
+ };
165
+ writeState(cwd, next);
166
+ return next;
167
+ }
168
+
169
+ export function dateStamp(): string {
170
+ return new Date().toISOString().slice(0, 10);
171
+ }
172
+
173
+ export function upsertChangesSection(doc: string, lines: string[]): string {
174
+ const header = "## Recent changes";
175
+ const stamp = `*Last updated: ${dateStamp()}*`;
176
+ const bullet = lines.map((line) => `- ${line}`).join("\n");
177
+ const footerMatch = doc.match(/\n---\n\n\*Last updated:.*$/s);
178
+ const footer = footerMatch ? footerMatch[0] : `\n---\n\n${stamp}\n`;
179
+
180
+ if (!doc.includes(header)) {
181
+ const base = doc.replace(/\n---\n\n\*Last updated:.*$/s, "").replace(/\s+$/, "");
182
+ return `${base}\n\n${header}\n\n${bullet}\n${footer}`;
183
+ }
184
+
185
+ const start = doc.indexOf(header);
186
+ const after = doc.slice(start + header.length);
187
+ const nextHeader = after.search(/\n#{1,6} /);
188
+ const sectionEnd = nextHeader === -1 ? doc.length : start + header.length + nextHeader;
189
+ const before = doc.slice(0, start);
190
+ const rest = doc.slice(sectionEnd);
191
+ return `${before}${header}\n\n${bullet}\n${rest}`;
192
+ }
package/src/cli.ts CHANGED
@@ -13,6 +13,8 @@ import { commandClean } from "./commands/clean.ts";
13
13
  import { commandDoctor } from "./commands/doctor.ts";
14
14
  import { commandTokens } from "./commands/tokens.ts";
15
15
  import { graftCommand } from "./commands/graft.ts";
16
+ import { commandInit } from "./commands/init.ts";
17
+ import { commandAutodocs } from "./commands/autodocs.ts";
16
18
 
17
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
20
 
@@ -41,7 +43,9 @@ function usage(): void {
41
43
  print({
42
44
  name: "novahiz",
43
45
  commands: [
44
- "init Set up Novahiz (config, skills, catalog)",
46
+ "init Initialize Novahiz in the current project",
47
+ "setup Install Novahiz (config, skills, catalog)",
48
+ "autodocs [--flush] Sync docs and memory after major project changes",
45
49
  "doctor Check that everything works",
46
50
  "status Show current classification and gate state",
47
51
  "task new <title> Start a new tracked task",
@@ -131,8 +135,11 @@ function main(argv: string[]): void {
131
135
  }
132
136
  switch (command) {
133
137
  case "init":
138
+ return commandInit(parsed);
134
139
  case "setup":
135
140
  return runInit();
141
+ case "autodocs":
142
+ return commandAutodocs(parsed);
136
143
  case "doctor":
137
144
  return commandDoctor(parsed);
138
145
  case "status":
@@ -0,0 +1,250 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ autoDocsEnabled,
5
+ clearDirty,
6
+ dateStamp,
7
+ isMajorPath,
8
+ markDirty,
9
+ readProjectConfig,
10
+ readState,
11
+ upsertChangesSection,
12
+ writeProjectConfig
13
+ } from "../autodocs.ts";
14
+ import { emit, flagOn, type Parsed } from "./context.ts";
15
+ import { memoryRoot, writeEntry } from "../memory.ts";
16
+ import * as ui from "../render.ts";
17
+
18
+ const MAX_DOC_PATHS = 12;
19
+ const ARCHITECTURE = "novahiz-docs/ARCHITECTURE.md";
20
+
21
+ export type EnsureResult = "created" | "existing" | "failed";
22
+
23
+ export type FlushOutcome = {
24
+ status: "flushed" | "marked" | "skipped" | "failed";
25
+ reason: string;
26
+ docsPath: string | null;
27
+ docsUpdated: boolean;
28
+ memorySlot: string | null;
29
+ paths: string[];
30
+ pending: number;
31
+ lastSync: string | null;
32
+ };
33
+
34
+ export function ensureProjectAutoDocsConfig(cwd: string): EnsureResult {
35
+ try {
36
+ const config = readProjectConfig(cwd);
37
+ if (config.autoDocs === true) return "existing";
38
+ writeProjectConfig(cwd, { ...config, autoDocs: true });
39
+ return "created";
40
+ } catch {
41
+ return "failed";
42
+ }
43
+ }
44
+
45
+ function skipResult(reason: string, paths: string[] = []): FlushOutcome {
46
+ const state = readState(process.cwd());
47
+ return {
48
+ status: "skipped",
49
+ reason,
50
+ docsPath: null,
51
+ docsUpdated: false,
52
+ memorySlot: null,
53
+ paths,
54
+ pending: state.pending.length,
55
+ lastSync: state.lastSync
56
+ };
57
+ }
58
+
59
+ function flushDocs(cwd: string, paths: string[]): { path: string; updated: boolean } {
60
+ const docPath = join(cwd, "novahiz-docs", "ARCHITECTURE.md");
61
+ if (!existsSync(docPath)) return { path: ARCHITECTURE, updated: false };
62
+ const sample = paths.slice(0, MAX_DOC_PATHS);
63
+ if (sample.length === 0) return { path: ARCHITECTURE, updated: false };
64
+ const doc = readFileSync(docPath, "utf8");
65
+ const lines = sample.map((path) => `${path} (${dateStamp()})`);
66
+ const next = upsertChangesSection(doc, lines);
67
+ if (next === doc) return { path: ARCHITECTURE, updated: false };
68
+ writeFileSync(docPath, next, "utf8");
69
+ return { path: ARCHITECTURE, updated: true };
70
+ }
71
+
72
+ function flushMemory(cwd: string, paths: string[]): string | null {
73
+ const sample = paths.slice(0, MAX_DOC_PATHS);
74
+ if (sample.length === 0) return null;
75
+ try {
76
+ const result = writeEntry({
77
+ title: "Auto docs sync",
78
+ description: "Change log written by novahiz autodocs",
79
+ tags: ["autodocs", "sync"],
80
+ root: memoryRoot(cwd),
81
+ content: [`Sync: ${new Date().toISOString()}`, ...sample.map((path) => `- ${path}`)].join("\n")
82
+ });
83
+ return result.slot.id;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ export function flushAutoDocs(cwd: string): FlushOutcome {
90
+ if (!autoDocsEnabled(cwd)) {
91
+ return skipResult("autoDocs disabled (set autoDocs=true in .novahiz/config.json or unset NOVAHIZ_AUTODOCS)");
92
+ }
93
+ const state = readState(cwd);
94
+ if (!state.dirty && state.pending.length === 0) {
95
+ return skipResult("nothing dirty", state.pending);
96
+ }
97
+ try {
98
+ const paths = state.pending;
99
+ const docs = flushDocs(cwd, paths);
100
+ const memorySlot = flushMemory(cwd, paths);
101
+ const next = clearDirty(cwd);
102
+ return {
103
+ status: "flushed",
104
+ reason: `${paths.length} path(s) recorded`,
105
+ docsPath: docs.path,
106
+ docsUpdated: docs.updated,
107
+ memorySlot,
108
+ paths,
109
+ pending: next.pending.length,
110
+ lastSync: next.lastSync
111
+ };
112
+ } catch (error) {
113
+ return {
114
+ status: "failed",
115
+ reason: (error as Error).message,
116
+ docsPath: null,
117
+ docsUpdated: false,
118
+ memorySlot: null,
119
+ paths: state.pending,
120
+ pending: state.pending.length,
121
+ lastSync: state.lastSync
122
+ };
123
+ }
124
+ }
125
+
126
+ function markFromArgs(cwd: string, mark: string | boolean, positionals: string[]): FlushOutcome {
127
+ const raw =
128
+ typeof mark === "string" && mark.length > 0
129
+ ? mark
130
+ : positionals.join(",");
131
+ const list = raw
132
+ .split(",")
133
+ .map((entry) => entry.trim())
134
+ .filter(Boolean);
135
+ if (list.length === 0) {
136
+ return {
137
+ status: "failed",
138
+ reason: "--mark needs at least one path",
139
+ docsPath: null,
140
+ docsUpdated: false,
141
+ memorySlot: null,
142
+ paths: [],
143
+ pending: readState(cwd).pending.length,
144
+ lastSync: readState(cwd).lastSync
145
+ };
146
+ }
147
+ const accepted: string[] = [];
148
+ const rejected: string[] = [];
149
+ for (const path of list) {
150
+ if (isMajorPath(path)) {
151
+ markDirty(cwd, path);
152
+ accepted.push(path);
153
+ } else {
154
+ rejected.push(path);
155
+ }
156
+ }
157
+ const state = readState(cwd);
158
+ if (accepted.length === 0) {
159
+ return {
160
+ status: "skipped",
161
+ reason: `no major path among ${rejected.join(", ")}`,
162
+ docsPath: null,
163
+ docsUpdated: false,
164
+ memorySlot: null,
165
+ paths: rejected,
166
+ pending: state.pending.length,
167
+ lastSync: state.lastSync
168
+ };
169
+ }
170
+ return {
171
+ status: "marked",
172
+ reason:
173
+ rejected.length > 0
174
+ ? `marked ${accepted.length}, ignored ${rejected.length} non-major`
175
+ : `marked ${accepted.length}`,
176
+ docsPath: null,
177
+ docsUpdated: false,
178
+ memorySlot: null,
179
+ paths: accepted,
180
+ pending: state.pending.length,
181
+ lastSync: state.lastSync
182
+ };
183
+ }
184
+
185
+ function statusView(cwd: string): Record<string, unknown> {
186
+ const config = readProjectConfig(cwd);
187
+ const state = readState(cwd);
188
+ return {
189
+ cwd,
190
+ autoDocs: config.autoDocs === true,
191
+ enabled: autoDocsEnabled(cwd),
192
+ dirty: state.dirty,
193
+ pending: state.pending,
194
+ lastSync: state.lastSync,
195
+ sessions: state.sessions
196
+ };
197
+ }
198
+
199
+ export function commandAutodocs(parsed: Parsed): void {
200
+ const cwd = process.cwd();
201
+ const wantFlush = flagOn(parsed, "flush");
202
+ const markFlag = parsed.flags.mark;
203
+ const isMark = markFlag !== undefined && markFlag !== false;
204
+
205
+ let outcome: FlushOutcome | null = null;
206
+ if (isMark) {
207
+ outcome = markFromArgs(cwd, markFlag, parsed.positionals.slice(1));
208
+ } else if (wantFlush) {
209
+ outcome = flushAutoDocs(cwd);
210
+ }
211
+
212
+ const value = outcome
213
+ ? { ...statusView(cwd), action: isMark ? "mark" : "flush", ...outcome }
214
+ : { ...statusView(cwd), action: "status" };
215
+
216
+ emit(parsed, value, () => {
217
+ const view = statusView(cwd);
218
+ const lines = [
219
+ ui.heading("novahiz autodocs"),
220
+ ui.kv([
221
+ ["Cwd", String(view.cwd)],
222
+ ["autoDocs", String(view.autoDocs)],
223
+ ["Enabled", String(view.enabled)],
224
+ ["Dirty", String(view.dirty)],
225
+ ["Pending", String(view.pending)],
226
+ ["Last sync", String(view.lastSync)]
227
+ ])
228
+ ];
229
+ if (outcome) {
230
+ lines.push(
231
+ "",
232
+ ui.kv([
233
+ ["Action", outcome.status],
234
+ ["Reason", outcome.reason],
235
+ ["Docs", outcome.docsPath ? `${outcome.docsPath}${outcome.docsUpdated ? " updated" : " unchanged"}` : "n/a"],
236
+ ["Memory", outcome.memorySlot ?? "n/a"]
237
+ ])
238
+ );
239
+ lines.push(
240
+ "",
241
+ outcome.status === "failed" ? ui.style("red", outcome.reason) : ui.style("green", outcome.reason)
242
+ );
243
+ } else {
244
+ lines.push("", ui.style("dim", "Use --flush to sync, --mark <path> to record a change."));
245
+ }
246
+ return lines.join("\n");
247
+ });
248
+
249
+ if (outcome?.status === "failed") process.exitCode = 1;
250
+ }