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,356 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { emit, flagOn, type Parsed } from "./context.ts";
4
+ import { NovahizHome } from "../spec.ts";
5
+ import { ensureMemoryRoot, memoryRoot, writeEntry } from "../memory.ts";
6
+ import { ensureProjectAutoDocsConfig } from "./autodocs.ts";
7
+ import * as ui from "../render.ts";
8
+
9
+ type StepStatus = "created" | "skipped" | "dry-run" | "failed";
10
+
11
+ type InitStep = {
12
+ id: string;
13
+ label: string;
14
+ status: StepStatus;
15
+ detail: string;
16
+ };
17
+
18
+ type CleanupCandidate = {
19
+ path: string;
20
+ reason: string;
21
+ };
22
+
23
+ type ProjectInfo = {
24
+ kind: string;
25
+ name: string;
26
+ version: string;
27
+ markers: string[];
28
+ };
29
+
30
+ const DOC_FILES = ["ARCHITECTURE.md", "CONVENTIONS.md", "DECISIONS.md", "STANDARDS.md"] as const;
31
+
32
+ const CLEANUP_PATTERNS: { pattern: RegExp; reason: string }[] = [
33
+ { pattern: /^\.DS_Store$/, reason: "macOS folder metadata" },
34
+ { pattern: /^Thumbs\.db$/i, reason: "Windows thumbnail cache" },
35
+ { pattern: /\.orig$/i, reason: "merge leftover" },
36
+ { pattern: /\.rej$/i, reason: "patch reject leftover" },
37
+ { pattern: /^npm-debug\.log.*$/i, reason: "npm debug log" },
38
+ { pattern: /^yarn-error\.log.*$/i, reason: "yarn error log" },
39
+ { pattern: /^.*\.novahiz-bak$/i, reason: "Novahiz installer backup" }
40
+ ];
41
+
42
+ function detectProject(cwd: string): ProjectInfo {
43
+ const markers: string[] = [];
44
+ let name = "";
45
+ let version = "";
46
+ let kind = "unknown";
47
+
48
+ const pkgPath = join(cwd, "package.json");
49
+ if (existsSync(pkgPath)) {
50
+ markers.push("package.json");
51
+ kind = "node";
52
+ try {
53
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { name?: string; version?: string };
54
+ name = typeof pkg.name === "string" ? pkg.name : "";
55
+ version = typeof pkg.version === "string" ? pkg.version : "";
56
+ } catch {
57
+ // name falls back to the folder below
58
+ }
59
+ }
60
+ if (existsSync(join(cwd, "Cargo.toml"))) {
61
+ markers.push("Cargo.toml");
62
+ if (kind === "unknown") kind = "rust";
63
+ }
64
+ if (existsSync(join(cwd, "pyproject.toml")) || existsSync(join(cwd, "requirements.txt"))) {
65
+ markers.push("python-manifest");
66
+ if (kind === "unknown") kind = "python";
67
+ }
68
+ if (existsSync(join(cwd, "go.mod"))) {
69
+ markers.push("go.mod");
70
+ if (kind === "unknown") kind = "go";
71
+ }
72
+ if (existsSync(join(cwd, ".git"))) markers.push(".git");
73
+ if (existsSync(join(cwd, "README.md"))) markers.push("README.md");
74
+
75
+ if (!name) {
76
+ name = cwd.split(/[\\/]/).filter(Boolean).pop() ?? "project";
77
+ }
78
+ return { kind, name, version, markers };
79
+ }
80
+
81
+ function scanCleanup(cwd: string): CleanupCandidate[] {
82
+ const candidates: CleanupCandidate[] = [];
83
+ const skipDirs = new Set(["node_modules", ".git", "project-memory", "novahiz-docs", "dist", "build", ".next", "coverage"]);
84
+
85
+ const walk = (dir: string, depth: number): void => {
86
+ if (depth > 3) return;
87
+ let entries: string[];
88
+ try {
89
+ entries = readdirSync(dir);
90
+ } catch {
91
+ return;
92
+ }
93
+ for (const entry of entries) {
94
+ if (skipDirs.has(entry)) continue;
95
+ const full = join(dir, entry);
96
+ let isDir = false;
97
+ try {
98
+ isDir = statSync(full).isDirectory();
99
+ } catch {
100
+ continue;
101
+ }
102
+ if (isDir) {
103
+ walk(full, depth + 1);
104
+ continue;
105
+ }
106
+ for (const { pattern, reason } of CLEANUP_PATTERNS) {
107
+ if (pattern.test(entry)) {
108
+ candidates.push({ path: relative(cwd, full).split("\\").join("/"), reason });
109
+ break;
110
+ }
111
+ }
112
+ }
113
+ };
114
+
115
+ walk(cwd, 0);
116
+ return candidates.sort((a, b) => a.path.localeCompare(b.path));
117
+ }
118
+
119
+ function copyDocs(templatesDir: string, targetDir: string, dryRun: boolean): InitStep {
120
+ if (!existsSync(templatesDir)) {
121
+ return { id: "docs", label: "Documentation skeleton", status: "failed", detail: `templates missing: ${templatesDir}` };
122
+ }
123
+ if (existsSync(targetDir)) {
124
+ const existing = readdirSync(targetDir).filter((name) => name.endsWith(".md"));
125
+ return {
126
+ id: "docs",
127
+ label: "Documentation skeleton",
128
+ status: "skipped",
129
+ detail: `novahiz-docs/ already has ${existing.length} file(s)`
130
+ };
131
+ }
132
+ if (dryRun) {
133
+ return {
134
+ id: "docs",
135
+ label: "Documentation skeleton",
136
+ status: "dry-run",
137
+ detail: `would create ${DOC_FILES.length} files under novahiz-docs/`
138
+ };
139
+ }
140
+ try {
141
+ mkdirSync(targetDir, { recursive: true });
142
+ for (const file of DOC_FILES) {
143
+ const src = join(templatesDir, file);
144
+ if (existsSync(src)) copyFileSync(src, join(targetDir, file));
145
+ }
146
+ return {
147
+ id: "docs",
148
+ label: "Documentation skeleton",
149
+ status: "created",
150
+ detail: `${DOC_FILES.length} files in novahiz-docs/`
151
+ };
152
+ } catch (error) {
153
+ return {
154
+ id: "docs",
155
+ label: "Documentation skeleton",
156
+ status: "failed",
157
+ detail: (error as Error).message
158
+ };
159
+ }
160
+ }
161
+
162
+ function initMemory(cwd: string, dryRun: boolean, seed: boolean, project: ProjectInfo): InitStep {
163
+ const root = memoryRoot(cwd);
164
+ if (dryRun) {
165
+ const present = existsSync(root);
166
+ return {
167
+ id: "memory",
168
+ label: "Project memory",
169
+ status: "dry-run",
170
+ detail: present ? "project-memory/ already present, seed would append" : "would create project-memory/"
171
+ };
172
+ }
173
+ try {
174
+ const index = ensureMemoryRoot(root);
175
+ const seeded = index.slots.length === 0 && seed;
176
+ if (seeded) {
177
+ const stackLine = project.markers.length > 0 ? project.markers.join(", ") : "no manifest detected";
178
+ writeEntry({
179
+ title: "Project baseline",
180
+ description: `Initial memory for ${project.name}`,
181
+ tags: ["init", project.kind],
182
+ root,
183
+ content: [
184
+ `Name: ${project.name}`,
185
+ project.version ? `Version: ${project.version}` : null,
186
+ `Kind: ${project.kind}`,
187
+ `Markers: ${stackLine}`,
188
+ `Initialized: ${new Date().toISOString()}`,
189
+ "",
190
+ "Next: fill novahiz-docs/ from a deep read of the codebase (skill novahiz-init)."
191
+ ]
192
+ .filter((line): line is string => line !== null)
193
+ .join("\n")
194
+ });
195
+ }
196
+ return {
197
+ id: "memory",
198
+ label: "Project memory",
199
+ status: "created",
200
+ detail: seeded ? "project-memory/ created with baseline slot" : `project-memory/ ready (${index.slots.length} slot(s))`
201
+ };
202
+ } catch (error) {
203
+ return {
204
+ id: "memory",
205
+ label: "Project memory",
206
+ status: "failed",
207
+ detail: (error as Error).message
208
+ };
209
+ }
210
+ }
211
+
212
+ function projectDoctor(project: ProjectInfo): InitStep {
213
+ if (project.markers.length === 0) {
214
+ return {
215
+ id: "doctor",
216
+ label: "Project detection",
217
+ status: "failed",
218
+ detail: "no package.json, Cargo.toml, pyproject.toml, go.mod, or .git in cwd"
219
+ };
220
+ }
221
+ return {
222
+ id: "doctor",
223
+ label: "Project detection",
224
+ status: "created",
225
+ detail: `${project.kind}: ${project.name}${project.version ? `@${project.version}` : ""} (${project.markers.join(", ")})`
226
+ };
227
+ }
228
+
229
+ export function commandInit(parsed: Parsed): void {
230
+ const cwd = process.cwd();
231
+ const home = NovahizHome();
232
+ const dryRun = flagOn(parsed, "dry-run");
233
+ const docsOnly = flagOn(parsed, "docs-only");
234
+ const memoryOnly = flagOn(parsed, "memory-only");
235
+ const noSeed = flagOn(parsed, "no-seed");
236
+ const applyCleanup = flagOn(parsed, "apply") && !dryRun;
237
+
238
+ const project = detectProject(cwd);
239
+ const steps: InitStep[] = [];
240
+
241
+ steps.push(projectDoctor(project));
242
+
243
+ const runMemory = !docsOnly;
244
+ const runDocs = !memoryOnly;
245
+ const runCleanup = !docsOnly && !memoryOnly;
246
+
247
+ if (runMemory) steps.push(initMemory(cwd, dryRun, !noSeed, project));
248
+ if (runDocs) {
249
+ const templates = join(home, "skills", "novahiz-docs", "templates");
250
+ steps.push(copyDocs(templates, join(cwd, "novahiz-docs"), dryRun));
251
+ }
252
+
253
+ if (!docsOnly && !memoryOnly) {
254
+ if (dryRun) {
255
+ steps.push({
256
+ id: "autodocs",
257
+ label: "Auto docs config",
258
+ status: "dry-run",
259
+ detail: "would write .novahiz/config.json with autoDocs=true"
260
+ });
261
+ } else {
262
+ const status = ensureProjectAutoDocsConfig(cwd);
263
+ steps.push({
264
+ id: "autodocs",
265
+ label: "Auto docs config",
266
+ status: status === "failed" ? "failed" : status === "created" ? "created" : "skipped",
267
+ detail: status === "failed" ? "could not write .novahiz/config.json" : `.novahiz/config.json ${status}`
268
+ });
269
+ }
270
+ }
271
+
272
+ let cleanup: CleanupCandidate[] = [];
273
+ let cleanupApplied = 0;
274
+ if (runCleanup) {
275
+ cleanup = scanCleanup(cwd);
276
+ if (applyCleanup && cleanup.length > 0) {
277
+ const removed: string[] = [];
278
+ for (const candidate of cleanup) {
279
+ try {
280
+ rmSync(join(cwd, candidate.path), { force: true });
281
+ removed.push(candidate.path);
282
+ } catch {
283
+ // keep going; the count reflects what succeeded
284
+ }
285
+ }
286
+ cleanupApplied = removed.length;
287
+ cleanup = cleanup.filter((item) => !removed.includes(item.path));
288
+ }
289
+ steps.push({
290
+ id: "cleanup",
291
+ label: "Cleanup candidates",
292
+ status: applyCleanup ? "created" : dryRun || cleanup.length > 0 ? "dry-run" : "skipped",
293
+ detail: applyCleanup
294
+ ? `removed ${cleanupApplied} file(s)`
295
+ : cleanup.length > 0
296
+ ? `${cleanup.length} candidate(s), none deleted (use --apply after review)`
297
+ : "nothing safe to propose"
298
+ });
299
+ } else {
300
+ steps.push({ id: "cleanup", label: "Cleanup candidates", status: "skipped", detail: "skipped by scope flag" });
301
+ }
302
+
303
+ const failed = steps.filter((step) => step.status === "failed");
304
+ const value = {
305
+ cwd,
306
+ home,
307
+ dryRun,
308
+ project,
309
+ steps,
310
+ cleanup,
311
+ cleanupApplied,
312
+ next: failed.length === 0 ? "Run the novahiz-init skill in an agent to fill docs and deepen memory." : null,
313
+ failed: failed.map((step) => step.id)
314
+ };
315
+
316
+ emit(parsed, value, () => {
317
+ const lines = [
318
+ ui.heading(dryRun ? "novahiz init (dry-run)" : "novahiz init"),
319
+ ui.kv([
320
+ ["Project", `${project.name}${project.version ? `@${project.version}` : ""}`],
321
+ ["Cwd", cwd],
322
+ ["Home", home]
323
+ ]),
324
+ "",
325
+ ui.table(
326
+ ["step", "status", "detail"],
327
+ steps.map((step) => [
328
+ step.label,
329
+ step.status === "failed"
330
+ ? ui.status(false)
331
+ : step.status === "created"
332
+ ? ui.status(true)
333
+ : ui.style("dim", step.status),
334
+ step.detail
335
+ ])
336
+ ),
337
+ ""
338
+ ];
339
+ if (cleanup.length > 0 && !applyCleanup) {
340
+ lines.push(ui.heading("Cleanup (not applied)"));
341
+ for (const item of cleanup) lines.push(` ${item.path} ${ui.style("dim", item.reason)}`);
342
+ lines.push("");
343
+ }
344
+ lines.push(
345
+ failed.length > 0
346
+ ? ui.style("red", `${failed.length} step(s) failed.`)
347
+ : ui.style(
348
+ "green",
349
+ dryRun ? "Dry-run only. Re-run without --dry-run to write." : "Scaffold ready. Load the novahiz-init skill to finish."
350
+ )
351
+ );
352
+ return lines.join("\n");
353
+ });
354
+
355
+ if (failed.length > 0) process.exitCode = 1;
356
+ }
@@ -2,7 +2,7 @@ import { join } from "node:path";
2
2
  import { asString, dbPathFor, numberFlag, parse, print, readStdin, splitList, type Parsed } from "./context.ts";
3
3
  import { loadSpec, NovahizHome } from "../spec.ts";
4
4
  import { openDb } from "../db.ts";
5
- import { activeTask, addTodos, amendTodo, blockTodo, completeTodo, createTask, dropTodo, getTask, getTodo, insertTodo, ledgerSummary, recordTodoDone, reorderTodos, resume, reviewDue, reviewTask, revisionSignals, startTodo, type ReviewDiff, type TodoAmendment, type TodoInput, type TodoKind } from "../ledger.ts";
5
+ import { activeTask, addTodos, amendTodo, blockTodo, completeTodo, createTask, dropTask, dropTodo, getTask, getTodo, insertTodo, ledgerSummary, recordTodoDone, reorderTodos, resume, reviewDue, reviewTask, revisionSignals, startTodo, type ReviewDiff, type TodoAmendment, type TodoInput, type TodoKind } from "../ledger.ts";
6
6
 
7
7
  export function normalizeTodoInput(item: unknown): TodoInput {
8
8
  const record = (item ?? {}) as Record<string, unknown>;
@@ -106,15 +106,28 @@ function taskReorder(parsed: Parsed, db: ReturnType<typeof openDb>, session: str
106
106
  function taskDrop(parsed: Parsed, db: ReturnType<typeof openDb>, session: string, spec: ReturnType<typeof loadSpec>): void {
107
107
  const id = asString(parsed.flags.id) || parsed.positionals[2] || "";
108
108
  if (!id) {
109
- // NOTE: active tasks are never dropped — only completed/cancelled tasks
110
- // can be removed. This prevents accidental data loss on in-progress work.
111
- print({ error: "task drop requires --id" });
109
+ print({
110
+ error: "task drop requires --id",
111
+ usage: "novahiz task drop --id <task_id|todo_id> --reason \"why\""
112
+ });
112
113
  process.exitCode = 1;
113
114
  return;
114
115
  }
115
- print({ todo: dropTodo(db, id, asString(parsed.flags.reason)) });
116
- return;
117
- }
116
+ const reason = asString(parsed.flags.reason);
117
+ if (getTask(db, id)) {
118
+ print({ task: dropTask(db, id, reason) });
119
+ return;
120
+ }
121
+ if (getTodo(db, id)) {
122
+ print({ todo: dropTodo(db, id, reason) });
123
+ return;
124
+ }
125
+ print({
126
+ error: `unknown id: ${id}`,
127
+ hint: "expected a task id or a todo id (pass --reason to abandon an active task)"
128
+ });
129
+ process.exitCode = 1;
130
+ }
118
131
 
119
132
  function taskInsert(parsed: Parsed, db: ReturnType<typeof openDb>, session: string, spec: ReturnType<typeof loadSpec>): void {
120
133
  const taskId = asString(parsed.flags.task) || activeTask(db, session || undefined)?.id;
package/src/ledger.ts CHANGED
@@ -423,6 +423,29 @@ export function dropTodo(db: DatabaseSync, id: string, reason = ""): TodoRow {
423
423
  return getTodo(db, id) as TodoRow;
424
424
  }
425
425
 
426
+ export function dropTask(db: DatabaseSync, id: string, reason = ""): TaskRow {
427
+ const task = requireTask(db, id);
428
+ const trimmed = String(reason ?? "").trim();
429
+ // Active tasks may only be abandoned with an explicit reason (cleanup /
430
+ // superseded work). Finished tasks can be dropped without one.
431
+ if (task.status === "active" && trimmed.length === 0) {
432
+ throw new Error(`refusing to abandon active task ${id} without a reason; pass --reason (or finish the task first)`);
433
+ }
434
+ try {
435
+ db.exec("SAVEPOINT drop_task_sp");
436
+ db.prepare(
437
+ "UPDATE todos SET status = 'dropped', proof = ?, updated_at = ? WHERE task_id = ? AND status NOT IN ('done', 'dropped')"
438
+ ).run(trimmed || null, nowIso(), id);
439
+ db.prepare("UPDATE tasks SET status = 'abandoned', updated_at = ? WHERE id = ?").run(nowIso(), id);
440
+ db.exec("RELEASE SAVEPOINT drop_task_sp");
441
+ } catch (error) {
442
+ db.exec("ROLLBACK TO SAVEPOINT drop_task_sp");
443
+ throw error;
444
+ }
445
+ autoCommit("task-abandoned", trimmed ? `${id}: ${trimmed}` : id);
446
+ return requireTask(db, id);
447
+ }
448
+
426
449
  export function recordEdit(db: DatabaseSync, taskId: string): number {
427
450
  const result = db.prepare(
428
451
  "UPDATE tasks SET edits_since_review = edits_since_review + 1, updated_at = ? WHERE id = ? RETURNING edits_since_review"
package/src/targets.ts CHANGED
@@ -25,11 +25,18 @@ export function tokenizeShell(command: string): string[] {
25
25
  else current += char;
26
26
  continue;
27
27
  }
28
- // H5: outside quotes a backslash escapes the next character literally,
29
- // so `echo hello\ world` is one token, not two.
28
+ // H5: outside quotes a backslash escapes the next character only when it
29
+ // is a shell metacharacter (whitespace, quote, operator). Windows path
30
+ // separators (C:\Users\…) are not escapes and must stay intact so path
31
+ // extraction does not mangle or false-positive on drive letters.
30
32
  if (char === "\\" && index + 1 < command.length) {
31
- current += command[index + 1];
32
- index += 1;
33
+ const next = command[index + 1];
34
+ if (/[\s"'`$&|;<>()]/.test(next)) {
35
+ current += next;
36
+ index += 1;
37
+ continue;
38
+ }
39
+ current += char;
33
40
  continue;
34
41
  }
35
42
  if (char === '"' || char === "'") {
@@ -99,7 +106,16 @@ function isWindowsFlag(token: string): boolean {
99
106
  }
100
107
 
101
108
  function allPositionals(tokens: string[], windows: boolean): string[] {
102
- return tokens.filter((token) => !isDashFlag(token) && !(windows && isWindowsFlag(token)) && !isOperator(token));
109
+ const positionals: string[] = [];
110
+ for (let index = 0; index < tokens.length; index += 1) {
111
+ const token = tokens[index];
112
+ if (isDashFlag(token) || (windows && isWindowsFlag(token)) || isOperator(token)) continue;
113
+ // Skip the value that follows a value-taking flag (e.g. -ErrorAction Stop).
114
+ const previous = tokens[index - 1];
115
+ if (previous && VALUE_FLAGS.has(previous.toLowerCase())) continue;
116
+ positionals.push(token);
117
+ }
118
+ return positionals;
103
119
  }
104
120
 
105
121
  function firstPositional(tokens: string[], windows: boolean): string | null {
@@ -124,7 +140,24 @@ const VALUE_FLAGS = new Set([
124
140
  "-newname",
125
141
  "-property",
126
142
  "-inputobject",
127
- "-credential"
143
+ "-credential",
144
+ "-path",
145
+ "-literalpath",
146
+ "-filepath",
147
+ "-file",
148
+ "-erroraction",
149
+ "-warningaction",
150
+ "-informationaction",
151
+ "-errorvariable",
152
+ "-warningvariable",
153
+ "-informationvariable",
154
+ "-outvariable",
155
+ "-outbuffer",
156
+ "-context",
157
+ "-depth",
158
+ "-first",
159
+ "-last",
160
+ "-skip"
128
161
  ]);
129
162
 
130
163
  function firstPathLike(tokens: string[], windows: boolean): string | null {
@@ -255,7 +288,10 @@ export function extractShellPaths(command: string): string[] {
255
288
  } else if (DEST_CMDLETS.has(lower)) {
256
289
  add(flagValue(rest, ["-destination", "-newname"]) ?? lastPositional(rest, windows));
257
290
  } else if (DELETE_CMDLETS.has(lower)) {
258
- for (const candidate of allPositionals(rest, windows)) add(candidate);
291
+ // Prefer an explicit path flag so switch values never look like targets.
292
+ const explicit = flagValue(rest, ["-path", "-literalpath", "-filepath", "-file"]);
293
+ if (explicit) add(explicit);
294
+ else for (const candidate of allPositionals(rest, windows)) add(candidate);
259
295
  } else if (WRITE_CMDLETS.has(lower)) {
260
296
  add(flagValue(rest, ["-path", "-literalpath", "-filepath", "-destination", "-file"]) ?? firstPathLike(rest, windows));
261
297
  } else if (lower === "tee") {