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/dist/graft.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graft integration for Novahiz — version-controls the SQLite ledger.
|
|
3
|
+
*
|
|
4
|
+
* Provides: init, commit, log, diff, status, restore, isAvailable, isInitialized.
|
|
5
|
+
* Auto-commit is handled by calling `commitGraft()` from ledger operations.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { copyFileSync, existsSync } from "node:fs";
|
|
9
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
10
|
+
import { NovahizHome } from "./spec.js";
|
|
11
|
+
// ── Graft binary resolution ──────────────────────────────────────────────────
|
|
12
|
+
// Graft is expected on PATH. If not found, we degrade gracefully (no-op).
|
|
13
|
+
let _graftBinary = null; // null = not resolved yet, "" = resolved but absent
|
|
14
|
+
function graftBinary() {
|
|
15
|
+
if (_graftBinary !== null)
|
|
16
|
+
return _graftBinary || null;
|
|
17
|
+
const override = process.env.NOVAHIZ_GRAFT_BIN;
|
|
18
|
+
if (override) {
|
|
19
|
+
const abs = resolve(override);
|
|
20
|
+
if (existsSync(abs)) {
|
|
21
|
+
_graftBinary = abs;
|
|
22
|
+
return abs;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const isWin = process.platform === "win32";
|
|
26
|
+
const names = isWin ? ["graft.cmd", "graft.exe", "graft"] : ["graft"];
|
|
27
|
+
const delimiter = isWin ? ";" : ":";
|
|
28
|
+
for (const raw of (process.env.PATH ?? "").split(delimiter)) {
|
|
29
|
+
const dir = raw.trim();
|
|
30
|
+
if (dir === "" || !isAbsolute(dir))
|
|
31
|
+
continue;
|
|
32
|
+
for (const name of names) {
|
|
33
|
+
const candidate = resolve(dir, name);
|
|
34
|
+
if (existsSync(candidate)) {
|
|
35
|
+
_graftBinary = candidate;
|
|
36
|
+
return candidate;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
_graftBinary = "";
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
44
|
+
function graftDir() {
|
|
45
|
+
return resolve(NovahizHome(), ".graft");
|
|
46
|
+
}
|
|
47
|
+
function ledgerPath() {
|
|
48
|
+
const envDb = process.env.NOVAHIZ_DB;
|
|
49
|
+
if (envDb)
|
|
50
|
+
return resolve(envDb);
|
|
51
|
+
// Default: novahiz.sqlite in the Novahiz home directory
|
|
52
|
+
return resolve(NovahizHome(), "novahiz.sqlite");
|
|
53
|
+
}
|
|
54
|
+
function runGraft(args, opts) {
|
|
55
|
+
const bin = graftBinary();
|
|
56
|
+
if (!bin)
|
|
57
|
+
throw new Error("graft not found on PATH (install @eidos.space/graft or set NOVAHIZ_GRAFT_BIN)");
|
|
58
|
+
const db = ledgerPath();
|
|
59
|
+
const fullArgs = ["--db", db, ...args];
|
|
60
|
+
try {
|
|
61
|
+
return execFileSync(bin, fullArgs, {
|
|
62
|
+
stdio: "pipe",
|
|
63
|
+
timeout: opts?.timeout ?? 30_000,
|
|
64
|
+
cwd: NovahizHome(),
|
|
65
|
+
encoding: "utf-8",
|
|
66
|
+
}).trim();
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
const stderr = err instanceof Error && "stderr" in err ? String(err.stderr ?? "") : "";
|
|
70
|
+
const stdout = err instanceof Error && "stdout" in err ? String(err.stdout ?? "") : "";
|
|
71
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
72
|
+
throw new Error(`graft ${args[0]} failed: ${stderr || stdout || message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
76
|
+
/** Check if graft CLI is available on PATH. */
|
|
77
|
+
export function isGraftAvailable() {
|
|
78
|
+
const bin = graftBinary();
|
|
79
|
+
if (!bin)
|
|
80
|
+
return false;
|
|
81
|
+
try {
|
|
82
|
+
execFileSync(bin, ["--version"], { stdio: "pipe", timeout: 5000 });
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Check if .graft directory exists in the Novahiz home. */
|
|
90
|
+
export function isGraftInitialized() {
|
|
91
|
+
return existsSync(graftDir());
|
|
92
|
+
}
|
|
93
|
+
/** Initialize a .graft repository in the Novahiz home directory. */
|
|
94
|
+
export function initGraft() {
|
|
95
|
+
if (!isGraftAvailable()) {
|
|
96
|
+
return { success: false, message: "graft CLI not found on PATH" };
|
|
97
|
+
}
|
|
98
|
+
if (isGraftInitialized()) {
|
|
99
|
+
return { success: true, message: "graft already initialized" };
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
// graft init creates .graft/ in cwd
|
|
103
|
+
const bin = graftBinary();
|
|
104
|
+
if (!bin)
|
|
105
|
+
return { success: false, message: "graft CLI not found on PATH" };
|
|
106
|
+
execFileSync(bin, ["init"], {
|
|
107
|
+
stdio: "pipe",
|
|
108
|
+
timeout: 10_000,
|
|
109
|
+
cwd: NovahizHome(),
|
|
110
|
+
});
|
|
111
|
+
// Configure user identity
|
|
112
|
+
try {
|
|
113
|
+
runGraft(["config", "set", "user.name", "Novahiz"]);
|
|
114
|
+
runGraft(["config", "set", "user.email", "Novahiz@local"]);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// non-critical
|
|
118
|
+
}
|
|
119
|
+
return { success: true, message: "graft initialized" };
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
123
|
+
return { success: false, message: `init failed: ${message}` };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Stage and commit the ledger database.
|
|
128
|
+
* No-op if graft is not initialized or unavailable.
|
|
129
|
+
* Returns commit info or null if nothing to commit.
|
|
130
|
+
*/
|
|
131
|
+
export function commitGraft(message) {
|
|
132
|
+
if (!isGraftAvailable() || !isGraftInitialized())
|
|
133
|
+
return null;
|
|
134
|
+
const db = ledgerPath();
|
|
135
|
+
// Stage the database file (--force needed because .gitignore excludes *.sqlite)
|
|
136
|
+
try {
|
|
137
|
+
runGraft(["add", "--force", db]);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// may be no changes — graft add exits non-zero if nothing changed
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
// Commit
|
|
144
|
+
try {
|
|
145
|
+
const out = runGraft(["commit", "-m", message]);
|
|
146
|
+
// parse "[abc123] message"
|
|
147
|
+
const match = out.match(/^\[([a-f0-9]+)\]\s*(.*)/m);
|
|
148
|
+
if (match) {
|
|
149
|
+
return { hash: match[1], message: match[2] };
|
|
150
|
+
}
|
|
151
|
+
return { hash: "unknown", message };
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** Get commit log as an array of objects. */
|
|
158
|
+
export function getGraftLog(limit = 20) {
|
|
159
|
+
if (!isGraftAvailable() || !isGraftInitialized())
|
|
160
|
+
return [];
|
|
161
|
+
try {
|
|
162
|
+
const out = runGraft(["log", "--json", `--limit=${limit}`]);
|
|
163
|
+
const data = JSON.parse(out);
|
|
164
|
+
// graft JSON output has `commits` array
|
|
165
|
+
const commits = data.commits ?? [];
|
|
166
|
+
return commits.map((entry) => ({
|
|
167
|
+
hash: (entry.id ?? "unknown").slice(0, 8),
|
|
168
|
+
date: entry.timestamp_ms ? new Date(entry.timestamp_ms).toISOString() : "",
|
|
169
|
+
message: entry.message ?? "",
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** Get diff between current ledger and last committed version. */
|
|
177
|
+
export function getGraftDiff() {
|
|
178
|
+
if (!isGraftAvailable() || !isGraftInitialized())
|
|
179
|
+
return "(graft not initialized)";
|
|
180
|
+
try {
|
|
181
|
+
return runGraft(["diff"]);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return "(no diff available)";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** Get graft status output. */
|
|
188
|
+
export function getGraftStatus() {
|
|
189
|
+
if (!isGraftAvailable() || !isGraftInitialized())
|
|
190
|
+
return "graft not initialized";
|
|
191
|
+
try {
|
|
192
|
+
return runGraft(["status"]);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return "status unavailable";
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** Restore the ledger to a specific revision. */
|
|
199
|
+
export function restoreGraft(revision, opts) {
|
|
200
|
+
if (!isGraftAvailable() || !isGraftInitialized()) {
|
|
201
|
+
return { success: false, message: "graft not initialized" };
|
|
202
|
+
}
|
|
203
|
+
if (!opts?.force) {
|
|
204
|
+
return {
|
|
205
|
+
success: false,
|
|
206
|
+
message: "refuses to overwrite the ledger without --force; re-run as `novahiz graft restore <hash> --force` (a backup is written first)",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const db = ledgerPath();
|
|
211
|
+
if (existsSync(db))
|
|
212
|
+
copyFileSync(db, `${db}.backup-${Date.now()}`);
|
|
213
|
+
runGraft(["checkout", revision]);
|
|
214
|
+
return { success: true, message: `restored to ${revision} (backup kept next to the ledger)` };
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
218
|
+
return { success: false, message: `restore failed: ${message}` };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** Export a snapshot of the ledger as a physical .sqlite file. */
|
|
222
|
+
export function exportGraft(revision, outputPath, opts) {
|
|
223
|
+
if (!isGraftAvailable() || !isGraftInitialized()) {
|
|
224
|
+
return { success: false, message: "graft not initialized" };
|
|
225
|
+
}
|
|
226
|
+
const target = resolve(outputPath);
|
|
227
|
+
const allowed = [resolve(NovahizHome()), resolve(process.cwd())];
|
|
228
|
+
if (!allowed.some((r) => target === r || target.startsWith(r + sep))) {
|
|
229
|
+
return { success: false, message: `export path outside the workspace refused: ${outputPath}` };
|
|
230
|
+
}
|
|
231
|
+
if (existsSync(target) && !opts?.force) {
|
|
232
|
+
return { success: false, message: `export path already exists: ${target} (pass --force to overwrite)` };
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
runGraft(["export", revision, "--output", target]);
|
|
236
|
+
return { success: true, message: `exported to ${target}` };
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
240
|
+
return { success: false, message: `export failed: ${message}` };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// ── Auto-commit wrapper ──────────────────────────────────────────────────────
|
|
244
|
+
/**
|
|
245
|
+
* Wraps a ledger operation with auto-commit.
|
|
246
|
+
* Call this after any significant write to the database.
|
|
247
|
+
* The commit message is generated from the operation type.
|
|
248
|
+
*/
|
|
249
|
+
export function autoCommit(operation, detail) {
|
|
250
|
+
const msg = detail ? `novahiz: ${operation} — ${detail}` : `novahiz: ${operation}`;
|
|
251
|
+
try {
|
|
252
|
+
commitGraft(msg);
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// H12: auto-commit failures must never break the caller
|
|
256
|
+
}
|
|
257
|
+
}
|
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { globToRegExp, isSafeRegexPattern } from "./gate.js";
|
|
2
|
+
import { autoCommit } from "./graft.js";
|
|
3
|
+
export const DEFAULT_MAX_ITERATIONS = 12;
|
|
4
|
+
const DEFAULT_REVIEW_EDITS = 3;
|
|
5
|
+
const DEFAULT_REVIEW_TODOS = 2;
|
|
6
|
+
const SESSION_TTL_MS = 4 * 60 * 60 * 1000;
|
|
7
|
+
function nowIso() {
|
|
8
|
+
return new Date().toISOString();
|
|
9
|
+
}
|
|
10
|
+
// H4: SESSION_TTL_MS was defined but never enforced — sessions accumulated
|
|
11
|
+
// forever. pruneSessions deletes expired sessions and their orphaned rows.
|
|
12
|
+
// Called from openDb on every startup (best effort, never throws).
|
|
13
|
+
export function pruneSessions(db) {
|
|
14
|
+
const cutoff = new Date(Date.now() - SESSION_TTL_MS).toISOString();
|
|
15
|
+
db.exec("SAVEPOINT prune_sp");
|
|
16
|
+
try {
|
|
17
|
+
db.prepare("DELETE FROM skill_invocations WHERE session_id IN (SELECT id FROM sessions WHERE updated_at < ?)").run(cutoff);
|
|
18
|
+
db.prepare("DELETE FROM roadmap_progress WHERE session_id IN (SELECT id FROM sessions WHERE updated_at < ?)").run(cutoff);
|
|
19
|
+
db.prepare("DELETE FROM enforcement_log WHERE session_id IN (SELECT id FROM sessions WHERE updated_at < ?)").run(cutoff);
|
|
20
|
+
db.prepare("DELETE FROM sessions WHERE updated_at < ?").run(cutoff);
|
|
21
|
+
db.exec("RELEASE SAVEPOINT prune_sp");
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
db.exec("ROLLBACK TO SAVEPOINT prune_sp");
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function genId(prefix) {
|
|
29
|
+
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
30
|
+
}
|
|
31
|
+
function parseDeps(raw) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(raw || "[]");
|
|
34
|
+
return Array.isArray(parsed) ? parsed.map(String) : [];
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
console.warn(`[Novahiz] parseDeps: corrupted JSON ignored: ${error}`);
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function createTask(db, options) {
|
|
42
|
+
const taskId = options.id && options.id.trim().length > 0 ? options.id.trim() : genId("task");
|
|
43
|
+
const ts = nowIso();
|
|
44
|
+
// H5: ensure referenced session exists (auto-create if needed for FK compliance)
|
|
45
|
+
if (options.sessionId) {
|
|
46
|
+
db.prepare("INSERT OR IGNORE INTO sessions (id, categories, required_skills, updated_at) VALUES (?, '[]', '[]', ?)").run(options.sessionId, ts);
|
|
47
|
+
}
|
|
48
|
+
db.prepare("INSERT INTO tasks (id, title, status, session_id, created_at, updated_at) VALUES (?, ?, 'active', ?, ?, ?)").run(taskId, options.title, options.sessionId ?? null, ts, ts);
|
|
49
|
+
autoCommit("task-created", options.title);
|
|
50
|
+
return getTask(db, taskId);
|
|
51
|
+
}
|
|
52
|
+
export function getTask(db, id) {
|
|
53
|
+
const row = db.prepare("SELECT * FROM tasks WHERE id = ?").get(id);
|
|
54
|
+
return row ?? null;
|
|
55
|
+
}
|
|
56
|
+
// C6: Helper that throws a descriptive error instead of casting null as TaskRow.
|
|
57
|
+
// Prevents silent null-dereference when a task ID doesn't exist.
|
|
58
|
+
function requireTask(db, id) {
|
|
59
|
+
const task = getTask(db, id);
|
|
60
|
+
if (!task)
|
|
61
|
+
throw new Error(`task not found: ${id}`);
|
|
62
|
+
return task;
|
|
63
|
+
}
|
|
64
|
+
export function activeTask(db, sessionId) {
|
|
65
|
+
const row = sessionId
|
|
66
|
+
? db.prepare("SELECT * FROM tasks WHERE status = 'active' AND session_id = ? ORDER BY created_at DESC LIMIT 1").get(sessionId)
|
|
67
|
+
: db.prepare("SELECT * FROM tasks WHERE status = 'active' ORDER BY created_at DESC LIMIT 1").get();
|
|
68
|
+
return row ?? null;
|
|
69
|
+
}
|
|
70
|
+
export function getTodo(db, id) {
|
|
71
|
+
const row = db.prepare("SELECT * FROM todos WHERE id = ?").get(id);
|
|
72
|
+
return row ?? null;
|
|
73
|
+
}
|
|
74
|
+
export function listTodos(db, taskId) {
|
|
75
|
+
return db.prepare("SELECT * FROM todos WHERE task_id = ? ORDER BY seq").all(taskId);
|
|
76
|
+
}
|
|
77
|
+
export function addTodos(db, taskId, items) {
|
|
78
|
+
const task = getTask(db, taskId);
|
|
79
|
+
if (!task)
|
|
80
|
+
throw new Error(`unknown task: ${taskId}`);
|
|
81
|
+
const insert = db.prepare("INSERT INTO todos (id, task_id, seq, label, kind, status, acceptance, proof, owner, depends_on, iterations, max_iterations, updated_at) VALUES (?, ?, COALESCE((SELECT MAX(seq) FROM todos WHERE task_id = ?), 0) + 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
|
82
|
+
const ids = [];
|
|
83
|
+
db.exec("SAVEPOINT add_todos_sp");
|
|
84
|
+
try {
|
|
85
|
+
for (const item of items) {
|
|
86
|
+
const todoId = genId("todo");
|
|
87
|
+
insert.run(todoId, taskId, taskId, item.label, item.kind ?? "edit", item.status ?? "pending", item.acceptance ?? null, null, item.owner ?? null, JSON.stringify(item.dependsOn ?? []), 0, item.maxIterations ?? DEFAULT_MAX_ITERATIONS, nowIso());
|
|
88
|
+
ids.push(todoId);
|
|
89
|
+
}
|
|
90
|
+
reopenTask(db, taskId);
|
|
91
|
+
db.exec("RELEASE SAVEPOINT add_todos_sp");
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
db.exec("ROLLBACK TO SAVEPOINT add_todos_sp");
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
autoCommit("todos-added", `${items.length} item(s) to ${taskId}`);
|
|
98
|
+
return ids.map((id) => getTodo(db, id));
|
|
99
|
+
}
|
|
100
|
+
export function startTodo(db, id) {
|
|
101
|
+
const todo = getTodo(db, id);
|
|
102
|
+
if (!todo)
|
|
103
|
+
throw new Error(`unknown todo: ${id}`);
|
|
104
|
+
if (todo.status === "done")
|
|
105
|
+
throw new Error(`todo already done: ${id}`);
|
|
106
|
+
const unfinished = parseDeps(todo.depends_on).filter((dep) => {
|
|
107
|
+
const dependency = getTodo(db, dep);
|
|
108
|
+
return !dependency || (dependency.status !== "done" && dependency.status !== "dropped");
|
|
109
|
+
});
|
|
110
|
+
if (unfinished.length > 0) {
|
|
111
|
+
throw new Error(`todo ${id} is blocked by unfinished dependencies: ${unfinished.join(", ")}`);
|
|
112
|
+
}
|
|
113
|
+
const ts = nowIso();
|
|
114
|
+
const result = db.prepare("UPDATE todos SET iterations = iterations + 1, status = 'in_progress', updated_at = ? WHERE id = ? AND (max_iterations IS NULL OR iterations < max_iterations) RETURNING iterations, max_iterations").get(ts, id);
|
|
115
|
+
if (!result) {
|
|
116
|
+
throw new Error(`todo ${id} exceeded its iteration budget (${todo.max_iterations ?? DEFAULT_MAX_ITERATIONS})`);
|
|
117
|
+
}
|
|
118
|
+
return getTodo(db, id);
|
|
119
|
+
}
|
|
120
|
+
export function completeTodo(db, id, proof = "") {
|
|
121
|
+
const todo = getTodo(db, id);
|
|
122
|
+
if (!todo)
|
|
123
|
+
throw new Error(`unknown todo: ${id}`);
|
|
124
|
+
const trimmed = String(proof ?? "").trim();
|
|
125
|
+
if (todo.kind === "verify" && trimmed.length === 0) {
|
|
126
|
+
throw new Error(`todo ${id} is a verify step and needs proof (the command you ran and its result)`);
|
|
127
|
+
}
|
|
128
|
+
db.prepare("UPDATE todos SET status = 'done', proof = ?, updated_at = ? WHERE id = ?").run(trimmed.length > 0 ? trimmed : null, nowIso(), id);
|
|
129
|
+
maybeCompleteTask(db, todo.task_id);
|
|
130
|
+
autoCommit("todo-completed", id);
|
|
131
|
+
return getTodo(db, id);
|
|
132
|
+
}
|
|
133
|
+
export function blockTodo(db, id, reason = "") {
|
|
134
|
+
const todo = getTodo(db, id);
|
|
135
|
+
if (!todo)
|
|
136
|
+
throw new Error(`unknown todo: ${id}`);
|
|
137
|
+
// The todos table has no reason column, so the block reason travels in proof.
|
|
138
|
+
db.prepare("UPDATE todos SET status = 'blocked', proof = ?, updated_at = ? WHERE id = ?").run(String(reason ?? "").trim() || null, nowIso(), id);
|
|
139
|
+
return getTodo(db, id);
|
|
140
|
+
}
|
|
141
|
+
function maybeCompleteTask(db, taskId) {
|
|
142
|
+
const open = db.prepare("SELECT COUNT(*) AS count FROM todos WHERE task_id = ? AND status NOT IN ('done', 'dropped')").get(taskId);
|
|
143
|
+
if (open.count === 0) {
|
|
144
|
+
db.prepare("UPDATE tasks SET status = 'done', updated_at = ? WHERE id = ?").run(nowIso(), taskId);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function reopenTask(db, taskId) {
|
|
148
|
+
db.prepare("UPDATE tasks SET status = CASE WHEN status = 'done' THEN 'active' ELSE status END, updated_at = ? WHERE id = ?").run(nowIso(), taskId);
|
|
149
|
+
}
|
|
150
|
+
export function resume(db, sessionId) {
|
|
151
|
+
const task = activeTask(db, sessionId);
|
|
152
|
+
if (!task)
|
|
153
|
+
return { task: null, todos: [], current: null };
|
|
154
|
+
const todos = listTodos(db, task.id);
|
|
155
|
+
const current = todos.find((todo) => todo.status === "in_progress") ??
|
|
156
|
+
todos.find((todo) => todo.status === "pending") ??
|
|
157
|
+
null;
|
|
158
|
+
return { task, todos, current };
|
|
159
|
+
}
|
|
160
|
+
export function ownedBy(todo, filePath) {
|
|
161
|
+
const owner = (todo.owner ?? "").trim();
|
|
162
|
+
if (owner.length === 0)
|
|
163
|
+
return true;
|
|
164
|
+
const normalized = filePath.replace(/\\/g, "/");
|
|
165
|
+
return owner
|
|
166
|
+
.split(",")
|
|
167
|
+
.map((part) => part.trim())
|
|
168
|
+
.filter(Boolean)
|
|
169
|
+
// P2-C (LOW): owner globs come from todo labels; reject nested-quantifier
|
|
170
|
+
// shapes before compiling so a crafted owner cannot ReDoS every edit check.
|
|
171
|
+
.some((pattern) => isSafeRegexPattern(pattern) && globToRegExp(pattern).test(normalized));
|
|
172
|
+
}
|
|
173
|
+
export function traceCheck(db, options) {
|
|
174
|
+
if (!options.required)
|
|
175
|
+
return { required: false, ok: true, todo: null, reason: "" };
|
|
176
|
+
const state = resume(db, options.sessionId);
|
|
177
|
+
const inProgress = state.todos.filter((todo) => todo.status === "in_progress");
|
|
178
|
+
const match = inProgress.find((todo) => ownedBy(todo, options.filePath));
|
|
179
|
+
if (match)
|
|
180
|
+
return { required: true, ok: true, todo: match, reason: "" };
|
|
181
|
+
const reason = inProgress.length === 0
|
|
182
|
+
? "no todo is in progress. Start one with `novahiz task start --id <todo>` before editing."
|
|
183
|
+
: `no in-progress todo owns ${options.filePath}. Active todos own: ${inProgress.map((todo) => todo.owner || "(any file)").join("; ")}`;
|
|
184
|
+
return { required: true, ok: false, todo: null, reason };
|
|
185
|
+
}
|
|
186
|
+
export function buildWorkPackets(db, taskId) {
|
|
187
|
+
const task = getTask(db, taskId);
|
|
188
|
+
if (!task)
|
|
189
|
+
throw new Error(`unknown task: ${taskId}`);
|
|
190
|
+
return listTodos(db, taskId)
|
|
191
|
+
.filter((todo) => todo.status === "pending" || todo.status === "in_progress")
|
|
192
|
+
.map((todo) => ({
|
|
193
|
+
todo: todo.id,
|
|
194
|
+
label: todo.label,
|
|
195
|
+
objective: todo.acceptance ?? todo.label,
|
|
196
|
+
kind: todo.kind,
|
|
197
|
+
files: (todo.owner ?? "")
|
|
198
|
+
.split(",")
|
|
199
|
+
.map((part) => part.trim())
|
|
200
|
+
.filter(Boolean),
|
|
201
|
+
acceptance: todo.acceptance,
|
|
202
|
+
budget: todo.max_iterations,
|
|
203
|
+
dependsOn: parseDeps(todo.depends_on)
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
export function amendTodo(db, id, patch) {
|
|
207
|
+
const todo = getTodo(db, id);
|
|
208
|
+
if (!todo)
|
|
209
|
+
throw new Error(`unknown todo: ${id}`);
|
|
210
|
+
if (todo.status === "done" || todo.status === "dropped")
|
|
211
|
+
throw new Error(`cannot amend a ${todo.status} todo: ${id}`);
|
|
212
|
+
const label = patch.label ?? todo.label;
|
|
213
|
+
const kind = patch.kind ?? todo.kind;
|
|
214
|
+
const acceptance = patch.acceptance === undefined ? todo.acceptance : patch.acceptance;
|
|
215
|
+
const owner = patch.owner === undefined ? todo.owner : patch.owner;
|
|
216
|
+
const dependsOn = patch.dependsOn === undefined ? todo.depends_on : JSON.stringify(patch.dependsOn);
|
|
217
|
+
const maxIterations = patch.maxIterations === undefined ? todo.max_iterations : patch.maxIterations;
|
|
218
|
+
if (maxIterations !== null && maxIterations !== undefined && (!Number.isInteger(maxIterations) || maxIterations <= 0)) {
|
|
219
|
+
throw new Error(`maxIterations must be a positive integer, got: ${maxIterations}`);
|
|
220
|
+
}
|
|
221
|
+
db.prepare("UPDATE todos SET label = ?, kind = ?, acceptance = ?, owner = ?, depends_on = ?, max_iterations = ?, updated_at = ? WHERE id = ?").run(label, kind, acceptance, owner, dependsOn, maxIterations, nowIso(), id);
|
|
222
|
+
return getTodo(db, id);
|
|
223
|
+
}
|
|
224
|
+
export function reorderTodos(db, taskId, orderedIds) {
|
|
225
|
+
const todos = listTodos(db, taskId);
|
|
226
|
+
const existing = new Set(todos.map((todo) => todo.id));
|
|
227
|
+
if (orderedIds.length !== todos.length || new Set(orderedIds).size !== orderedIds.length || orderedIds.some((id) => !existing.has(id))) {
|
|
228
|
+
throw new Error(`reorder must list each todo of task ${taskId} exactly once`);
|
|
229
|
+
}
|
|
230
|
+
const update = db.prepare("UPDATE todos SET seq = ?, updated_at = ? WHERE id = ?");
|
|
231
|
+
const ts = nowIso();
|
|
232
|
+
orderedIds.forEach((id, index) => update.run(index + 1, ts, id));
|
|
233
|
+
db.prepare("UPDATE tasks SET updated_at = ? WHERE id = ?").run(ts, taskId);
|
|
234
|
+
return listTodos(db, taskId);
|
|
235
|
+
}
|
|
236
|
+
export function insertTodo(db, taskId, item, position) {
|
|
237
|
+
// M-Ledger: wrap insert + reorder in a savepoint so a reorder failure doesn't
|
|
238
|
+
// leave a half-inserted todo visible.
|
|
239
|
+
db.exec("SAVEPOINT insert_sp");
|
|
240
|
+
try {
|
|
241
|
+
const created = addTodos(db, taskId, [item])[0];
|
|
242
|
+
if (position === undefined || position === "end") {
|
|
243
|
+
db.exec("RELEASE SAVEPOINT insert_sp");
|
|
244
|
+
return created;
|
|
245
|
+
}
|
|
246
|
+
const others = listTodos(db, taskId).filter((todo) => todo.id !== created.id);
|
|
247
|
+
let orderedIds;
|
|
248
|
+
if (position === "start") {
|
|
249
|
+
orderedIds = [created.id, ...others.map((todo) => todo.id)];
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
orderedIds = [
|
|
253
|
+
...others.filter((todo) => todo.seq <= position).map((todo) => todo.id),
|
|
254
|
+
created.id,
|
|
255
|
+
...others.filter((todo) => todo.seq > position).map((todo) => todo.id)
|
|
256
|
+
];
|
|
257
|
+
}
|
|
258
|
+
reorderTodos(db, taskId, orderedIds);
|
|
259
|
+
db.exec("RELEASE SAVEPOINT insert_sp");
|
|
260
|
+
return getTodo(db, created.id);
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
db.exec("ROLLBACK TO SAVEPOINT insert_sp");
|
|
264
|
+
db.exec("RELEASE SAVEPOINT insert_sp");
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
export function dropTodo(db, id, reason = "") {
|
|
269
|
+
const todo = getTodo(db, id);
|
|
270
|
+
if (!todo)
|
|
271
|
+
throw new Error(`unknown todo: ${id}`);
|
|
272
|
+
db.prepare("UPDATE todos SET status = 'dropped', proof = ?, updated_at = ? WHERE id = ?").run(String(reason ?? "").trim() || null, nowIso(), id);
|
|
273
|
+
maybeCompleteTask(db, todo.task_id);
|
|
274
|
+
autoCommit("todo-dropped", id);
|
|
275
|
+
return getTodo(db, id);
|
|
276
|
+
}
|
|
277
|
+
export function dropTask(db, id, reason = "") {
|
|
278
|
+
const task = requireTask(db, id);
|
|
279
|
+
const trimmed = String(reason ?? "").trim();
|
|
280
|
+
// Active tasks may only be abandoned with an explicit reason (cleanup /
|
|
281
|
+
// superseded work). Finished tasks can be dropped without one.
|
|
282
|
+
if (task.status === "active" && trimmed.length === 0) {
|
|
283
|
+
throw new Error(`refusing to abandon active task ${id} without a reason; pass --reason (or finish the task first)`);
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
db.exec("SAVEPOINT drop_task_sp");
|
|
287
|
+
db.prepare("UPDATE todos SET status = 'dropped', proof = ?, updated_at = ? WHERE task_id = ? AND status NOT IN ('done', 'dropped')").run(trimmed || null, nowIso(), id);
|
|
288
|
+
db.prepare("UPDATE tasks SET status = 'abandoned', updated_at = ? WHERE id = ?").run(nowIso(), id);
|
|
289
|
+
db.exec("RELEASE SAVEPOINT drop_task_sp");
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
db.exec("ROLLBACK TO SAVEPOINT drop_task_sp");
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
autoCommit("task-abandoned", trimmed ? `${id}: ${trimmed}` : id);
|
|
296
|
+
return requireTask(db, id);
|
|
297
|
+
}
|
|
298
|
+
export function recordEdit(db, taskId) {
|
|
299
|
+
const result = db.prepare("UPDATE tasks SET edits_since_review = edits_since_review + 1, updated_at = ? WHERE id = ? RETURNING edits_since_review").get(nowIso(), taskId);
|
|
300
|
+
if (!result)
|
|
301
|
+
throw new Error(`task not found: ${taskId}`);
|
|
302
|
+
return result.edits_since_review;
|
|
303
|
+
}
|
|
304
|
+
export function recordTodoDone(db, taskId) {
|
|
305
|
+
db.prepare("UPDATE tasks SET todos_since_review = todos_since_review + 1, updated_at = ? WHERE id = ?").run(nowIso(), taskId);
|
|
306
|
+
return requireTask(db, taskId).todos_since_review;
|
|
307
|
+
}
|
|
308
|
+
export function reviewDue(db, taskId, policy = { edits: DEFAULT_REVIEW_EDITS, todos: DEFAULT_REVIEW_TODOS }) {
|
|
309
|
+
const task = getTask(db, taskId);
|
|
310
|
+
if (!task)
|
|
311
|
+
throw new Error(`unknown task: ${taskId}`);
|
|
312
|
+
const due = task.edits_since_review >= policy.edits || task.todos_since_review >= policy.todos;
|
|
313
|
+
const reason = due
|
|
314
|
+
? `plan review due (${task.edits_since_review} edits, ${task.todos_since_review} todos since last review; cadence ${policy.edits} edits / ${policy.todos} todos). Reconcile with \`novahiz task review\`.`
|
|
315
|
+
: "";
|
|
316
|
+
return { due, edits: task.edits_since_review, todos: task.todos_since_review, policy, reason };
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Which gate targets a due plan review should block.
|
|
320
|
+
* Only paths owned by an open todo with a non-empty owner pattern are blocked.
|
|
321
|
+
* Empty owners and tasks with no owned open todos never expand the block set,
|
|
322
|
+
* so a due review no longer freezes the whole gate.
|
|
323
|
+
*/
|
|
324
|
+
export function reviewBlockReason(db, taskId, filePath, policy) {
|
|
325
|
+
const due = reviewDue(db, taskId, policy);
|
|
326
|
+
if (!due.due)
|
|
327
|
+
return null;
|
|
328
|
+
const openOwned = listTodos(db, taskId).filter((todo) => (todo.status === "pending" || todo.status === "in_progress") && (todo.owner ?? "").trim().length > 0);
|
|
329
|
+
if (openOwned.length === 0)
|
|
330
|
+
return null;
|
|
331
|
+
return openOwned.some((todo) => ownedBy(todo, filePath)) ? due.reason : null;
|
|
332
|
+
}
|
|
333
|
+
export function revisionSignals(db, taskId) {
|
|
334
|
+
const todos = listTodos(db, taskId);
|
|
335
|
+
const signals = [];
|
|
336
|
+
const inProgress = todos.filter((todo) => todo.status === "in_progress");
|
|
337
|
+
if (inProgress.length > 1) {
|
|
338
|
+
signals.push({ type: "parallel", todo: null, detail: `${inProgress.length} todos are in progress at once; keep a single active step.` });
|
|
339
|
+
}
|
|
340
|
+
for (const todo of todos) {
|
|
341
|
+
if (todo.status === "blocked") {
|
|
342
|
+
signals.push({ type: "blocked", todo: todo.id, detail: `${todo.label} is blocked: ${todo.proof ?? "no reason given"}` });
|
|
343
|
+
}
|
|
344
|
+
if (todo.status === "in_progress" || todo.status === "pending") {
|
|
345
|
+
const max = todo.max_iterations ?? DEFAULT_MAX_ITERATIONS;
|
|
346
|
+
if (todo.iterations >= max) {
|
|
347
|
+
signals.push({ type: "budget", todo: todo.id, detail: `${todo.label} hit its iteration budget (${todo.iterations}/${max}); split, drop, or re-scope it.` });
|
|
348
|
+
}
|
|
349
|
+
if (todo.kind === "edit" && !todo.acceptance) {
|
|
350
|
+
signals.push({ type: "missing_acceptance", todo: todo.id, detail: `${todo.label} has no acceptance criterion.` });
|
|
351
|
+
}
|
|
352
|
+
if (!todo.owner) {
|
|
353
|
+
signals.push({ type: "unowned", todo: todo.id, detail: `${todo.label} owns no file; a parallel agent could collide.` });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const settled = new Set(todos.filter((todo) => todo.status === "done" || todo.status === "dropped").map((todo) => todo.id));
|
|
358
|
+
for (const todo of todos) {
|
|
359
|
+
if (todo.status !== "pending")
|
|
360
|
+
continue;
|
|
361
|
+
const deps = parseDeps(todo.depends_on);
|
|
362
|
+
if (deps.length > 0 && deps.every((dep) => settled.has(dep))) {
|
|
363
|
+
signals.push({ type: "ready", todo: todo.id, detail: `${todo.label} has no unfinished dependencies.` });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return signals;
|
|
367
|
+
}
|
|
368
|
+
export function reviewTask(db, options) {
|
|
369
|
+
const task = getTask(db, options.taskId);
|
|
370
|
+
if (!task)
|
|
371
|
+
throw new Error(`unknown task: ${options.taskId}`);
|
|
372
|
+
const additions = options.additions ?? [];
|
|
373
|
+
const amendments = options.amendments ?? [];
|
|
374
|
+
const removals = options.removals ?? [];
|
|
375
|
+
db.exec("BEGIN");
|
|
376
|
+
try {
|
|
377
|
+
for (const item of removals) {
|
|
378
|
+
const id = typeof item === "string" ? item : item.id;
|
|
379
|
+
const reason = typeof item === "string" ? "" : item.reason ?? "";
|
|
380
|
+
const target = getTodo(db, id);
|
|
381
|
+
if (!target || target.task_id !== options.taskId) {
|
|
382
|
+
throw new Error(`review removal targets a todo outside this task: ${id}`);
|
|
383
|
+
}
|
|
384
|
+
dropTodo(db, id, reason);
|
|
385
|
+
}
|
|
386
|
+
for (const change of amendments) {
|
|
387
|
+
const { id, ...patch } = change;
|
|
388
|
+
const target = getTodo(db, id);
|
|
389
|
+
if (!target || target.task_id !== options.taskId) {
|
|
390
|
+
throw new Error(`review amendment targets a todo outside this task: ${id}`);
|
|
391
|
+
}
|
|
392
|
+
amendTodo(db, id, patch);
|
|
393
|
+
}
|
|
394
|
+
for (const item of additions)
|
|
395
|
+
insertTodo(db, options.taskId, item, "end");
|
|
396
|
+
if (options.order)
|
|
397
|
+
reorderTodos(db, options.taskId, options.order);
|
|
398
|
+
const revision = task.revision + 1;
|
|
399
|
+
const ts = nowIso();
|
|
400
|
+
db.prepare("UPDATE tasks SET revision = ?, reviewed_at = ?, edits_since_review = 0, todos_since_review = 0, updated_at = ? WHERE id = ?").run(revision, ts, ts, options.taskId);
|
|
401
|
+
db.exec("COMMIT");
|
|
402
|
+
const result = {
|
|
403
|
+
task: requireTask(db, options.taskId),
|
|
404
|
+
revision,
|
|
405
|
+
applied: { additions: additions.length, amendments: amendments.length, removals: removals.length, reordered: Boolean(options.order) },
|
|
406
|
+
signals: revisionSignals(db, options.taskId)
|
|
407
|
+
};
|
|
408
|
+
autoCommit("task-reviewed", `revision ${revision}`);
|
|
409
|
+
return result;
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
db.exec("ROLLBACK");
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
export function ledgerSummary(state) {
|
|
417
|
+
if (!state.task)
|
|
418
|
+
return [];
|
|
419
|
+
const done = state.todos.filter((todo) => todo.status === "done").length;
|
|
420
|
+
const lines = [`Ledger ${state.task.id}: ${state.task.title} (${done}/${state.todos.length} done)`];
|
|
421
|
+
for (const todo of state.todos) {
|
|
422
|
+
const flag = todo.status === "in_progress" ? ">" : todo.status === "done" ? "x" : todo.status === "blocked" ? "!" : todo.status === "dropped" ? "-" : " ";
|
|
423
|
+
const proof = todo.proof ? ` | proof: ${todo.proof}` : "";
|
|
424
|
+
lines.push(` [${flag}] ${todo.seq}. ${todo.label}${proof}`);
|
|
425
|
+
}
|
|
426
|
+
if (state.current) {
|
|
427
|
+
const max = state.current.max_iterations ?? DEFAULT_MAX_ITERATIONS;
|
|
428
|
+
lines.push(` next: ${state.current.label} (budget ${state.current.iterations}/${max})`);
|
|
429
|
+
}
|
|
430
|
+
return lines;
|
|
431
|
+
}
|