novahiz 0.3.7 → 0.3.8
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/README.md +211 -209
- package/adapters/opencode/agent/novahiz.md +1 -1
- package/adapters/opencode/novahiz.ts +7 -7
- package/dist/commands/graft.js +7 -3
- package/dist/commands/hook.js +10 -3
- package/dist/commands/inspect.js +44 -7
- package/dist/commands/task.js +15 -2
- package/dist/db.js +5 -1
- package/dist/gate-repair.js +4 -4
- package/dist/gate.js +21 -2
- package/dist/ledger.js +25 -6
- package/docs/PROVIDERS.md +6 -4
- package/install/bootstrap.mjs +21 -5
- package/install/install.mjs +1 -1
- package/install/lib.mjs +37 -5
- package/mcp/novahiz-tools/index.mjs +37 -28
- package/package.json +1 -1
- package/skills/gate/SKILL.md +4 -4
- package/src/commands/graft.ts +7 -3
- package/src/commands/hook.ts +10 -3
- package/src/commands/inspect.ts +46 -7
- package/src/commands/task.ts +13 -2
- package/src/db.ts +5 -1
- package/src/gate-repair.ts +4 -4
- package/src/gate.ts +21 -2
- package/src/ledger.ts +31 -9
|
@@ -29,4 +29,4 @@ Rules:
|
|
|
29
29
|
- Load the Supabase skills for any Supabase work.
|
|
30
30
|
- Be honest. Avoid false good ideas. Keep a critical stance. Zero simulation: never pretend to have run, tested, or verified something you did not.
|
|
31
31
|
- Criticize the request when it is inconsistent, ambiguous, risky, or suboptimal, and propose an alternative.
|
|
32
|
-
- Gate
|
|
32
|
+
- Gate reload: when a call fails with "Novahiz gate blocked", do not stop and do not ask the user. Execute the GATE RELOAD block from the error verbatim: load every skill it names with `skill({ name: "..." })`, then retry the exact same call once and continue the user's task where it left off. If the identical skills are reported missing again, the loads did not register — run `novahiz doctor`, report honestly to the user, and stop. Never bypass the gate with a shell write, an alternate tool, or NOVAHIZ_GATE.
|
|
@@ -506,7 +506,7 @@ function buildRepairDirective(failure: GateFailure, attempt: number): string {
|
|
|
506
506
|
const steps = loads.map((skill, index) => ` ${index + 1}. skill({name:"${skill}"})`).join("\n");
|
|
507
507
|
return [
|
|
508
508
|
`${head} Missing skills: ${missing.join(", ")}.`,
|
|
509
|
-
"
|
|
509
|
+
"GATE RELOAD — execute now, do not ask the user, do not stop:",
|
|
510
510
|
steps,
|
|
511
511
|
` ${loads.length + 1}. Retry this exact ${failure.tool} call once, then continue the user's task where it left off.`,
|
|
512
512
|
"Never bypass the gate: no NOVAHIZ_GATE, no alternate tool, no shell write, no editing around the block."
|
|
@@ -514,7 +514,7 @@ function buildRepairDirective(failure: GateFailure, attempt: number): string {
|
|
|
514
514
|
}
|
|
515
515
|
|
|
516
516
|
return [
|
|
517
|
-
`${head}
|
|
517
|
+
`${head} GATE RELOAD FAILED on attempt ${attempt}: still missing ${missing.join(", ")} after skill() loads.`,
|
|
518
518
|
"The loads did not register — diagnose instead of retrying:",
|
|
519
519
|
" 1. Confirm the skill is installed and the index matches (`novahiz doctor`).",
|
|
520
520
|
" 2. Realign the index (`novahiz sync`), then load the named skills again.",
|
|
@@ -530,8 +530,8 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
|
|
|
530
530
|
// P0-B: reason of the last failed classify per session — gate tool calls are
|
|
531
531
|
// refused while set, instead of running with empty categories (fail-open).
|
|
532
532
|
const classifyFailedBySession = new Map<string, string>();
|
|
533
|
-
//
|
|
534
|
-
// carries the
|
|
533
|
+
// Gate reload: denial count per `session|tool|missing set`. A first denial
|
|
534
|
+
// carries the reload protocol; an identical repeat escalates to diagnosis
|
|
535
535
|
// instead of looping. Cleared on a successful call of the same tool.
|
|
536
536
|
const repairAttemptsBySession = new Map<string, number>();
|
|
537
537
|
const SESSION_TTL_MS = 4 * 60 * 60 * 1000;
|
|
@@ -550,7 +550,7 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
|
|
|
550
550
|
enforcementBySession.delete(sessionID);
|
|
551
551
|
lastSeenBySession.delete(sessionID);
|
|
552
552
|
classifyFailedBySession.delete(sessionID);
|
|
553
|
-
//
|
|
553
|
+
// Gate reload keys are prefixed with the session ID — drop them too.
|
|
554
554
|
for (const key of repairAttemptsBySession.keys()) {
|
|
555
555
|
if (key.startsWith(`${sessionID}|`)) repairAttemptsBySession.delete(key);
|
|
556
556
|
}
|
|
@@ -836,9 +836,9 @@ export const NovahizPlugin: Plugin = async ({ client }) => {
|
|
|
836
836
|
);
|
|
837
837
|
}
|
|
838
838
|
if (result.status === 2) {
|
|
839
|
-
//
|
|
839
|
+
// Gate reload: a structured denial becomes an executable directive
|
|
840
840
|
// (load the named skills, retry the same call, resume the task).
|
|
841
|
-
// Counting identical denials turns a failed
|
|
841
|
+
// Counting identical denials turns a failed reload into a diagnosis
|
|
842
842
|
// instead of an infinite retry loop. The denial itself still stands
|
|
843
843
|
// until the gate CLI sees the skills — nothing is granted here.
|
|
844
844
|
const failure = parseGateFailure(result.stdout);
|
package/dist/commands/graft.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Subcommands: init, log, diff, status, restore, export, commit
|
|
5
5
|
*/
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
|
-
import { existsSync } from "node:fs";
|
|
7
|
+
import { existsSync, writeSync } from "node:fs";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
9
|
import { NovahizHome } from "../spec.js";
|
|
10
10
|
import { isGraftAvailable, isGraftInitialized, initGraft, commitGraft, getGraftLog, getGraftDiff, getGraftStatus, restoreGraft, exportGraft, } from "../graft.js";
|
|
@@ -21,8 +21,12 @@ Usage:
|
|
|
21
21
|
novahiz graft help Show this help
|
|
22
22
|
`;
|
|
23
23
|
function fail(msg) {
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// WS3: exit immediately — a fail() that only sets exitCode let the switch
|
|
25
|
+
// keep running (usage-fail then commitGraft(""), "graft not found" then a
|
|
26
|
+
// spawn anyway). writeSync(2, ...) so the message survives the exit on a
|
|
27
|
+
// piped stderr (console.error is async on pipes).
|
|
28
|
+
writeSync(2, `error: ${msg}\n`);
|
|
29
|
+
process.exit(1);
|
|
26
30
|
}
|
|
27
31
|
export function graftCommand(argv) {
|
|
28
32
|
const sub = argv[0] ?? "help";
|
package/dist/commands/hook.js
CHANGED
|
@@ -18,8 +18,8 @@ function emitDeny(harness, event, reason) {
|
|
|
18
18
|
}
|
|
19
19
|
process.stdout.write(`Novahiz advisory: ${reason}\n`);
|
|
20
20
|
}
|
|
21
|
-
//
|
|
22
|
-
// session+tool since the last allow. attempt 1 = first block (
|
|
21
|
+
// GATE RELOAD attempt tracking: how many consecutive denials for this
|
|
22
|
+
// session+tool since the last allow. attempt 1 = first block (reload
|
|
23
23
|
// directive), attempt 2+ = loads did not register (escalation). Computed
|
|
24
24
|
// BEFORE enforceLedgerChecks logs the current row.
|
|
25
25
|
function repairAttempt(db, session, tool) {
|
|
@@ -62,7 +62,14 @@ function recordSkillLoad(spec, db, session, skill) {
|
|
|
62
62
|
db.prepare("INSERT OR IGNORE INTO skill_invocations (session_id, skill, invoked_at) VALUES (?, ?, ?)").run(session, skill, new Date().toISOString());
|
|
63
63
|
}
|
|
64
64
|
export function commandHook(parsed) {
|
|
65
|
-
const
|
|
65
|
+
const harnessFlag = asString(parsed.flags.harness) || "claude";
|
|
66
|
+
const harness = harnessFlag === "claude" || harnessFlag === "codex" ? harnessFlag : "claude";
|
|
67
|
+
if (harness !== harnessFlag) {
|
|
68
|
+
// WS3: an unvalidated cast let a typo ("claudee") silently downgrade the
|
|
69
|
+
// PreToolUse deny to an advisory line = fail-open. Unknown values now
|
|
70
|
+
// fail toward the strictest format (claude deny) with a warning.
|
|
71
|
+
process.stderr.write(`novahiz: unknown harness "${harnessFlag.slice(0, 32)}" — treating as "claude".\n`);
|
|
72
|
+
}
|
|
66
73
|
const event = asString(parsed.flags.event) || "PreToolUse";
|
|
67
74
|
const root = NovahizHome();
|
|
68
75
|
const raw = readStdin().trim();
|
package/dist/commands/inspect.js
CHANGED
|
@@ -7,7 +7,7 @@ import { rankSkills } from "../relevance.js";
|
|
|
7
7
|
import { buildMcpEntries, enabledProviders, installCommands } from "../providers.js";
|
|
8
8
|
import { bootstrapFor, checkDependencies, missingPrerequisites } from "../deps.js";
|
|
9
9
|
import { classify } from "../classify.js";
|
|
10
|
-
import {
|
|
10
|
+
import { runScript } from "../exec.js";
|
|
11
11
|
import { activeTask, buildWorkPackets, getTask } from "../ledger.js";
|
|
12
12
|
export function commandCheck() {
|
|
13
13
|
const root = NovahizHome();
|
|
@@ -187,6 +187,13 @@ export function commandStep(parsed) {
|
|
|
187
187
|
process.exitCode = 1;
|
|
188
188
|
return;
|
|
189
189
|
}
|
|
190
|
+
// WS3: the step id follows the roadmap naming pattern (plan, write,
|
|
191
|
+
// impeccable-critique) — reject anything else before it reaches the database.
|
|
192
|
+
if (done.length > 0 && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(done)) {
|
|
193
|
+
print({ error: `invalid step id format: "${done}" (expected alphanumeric, hyphens, underscores, dots)` });
|
|
194
|
+
process.exitCode = 1;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
190
197
|
const db = openDb(dbPathFor(root, spec));
|
|
191
198
|
if (done.length > 0) {
|
|
192
199
|
const ts = new Date().toISOString();
|
|
@@ -206,13 +213,26 @@ export function commandProviders(parsed) {
|
|
|
206
213
|
return;
|
|
207
214
|
}
|
|
208
215
|
if (parsed.flags.install) {
|
|
216
|
+
const entries = installCommands(spec);
|
|
217
|
+
if (!flagOn(parsed, "yes")) {
|
|
218
|
+
// Dry run by default: the catalog is not signed, so the plan is shown
|
|
219
|
+
// and nothing executes until --yes is passed explicitly.
|
|
220
|
+
print({
|
|
221
|
+
plan: entries.map((entry) => ({ id: entry.id, kind: entry.kind, command: entry.command.join(" "), source: entry.source })),
|
|
222
|
+
note: "dry run: pass --yes to execute"
|
|
223
|
+
});
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
209
226
|
const results = [];
|
|
210
|
-
for (const entry of
|
|
211
|
-
|
|
212
|
-
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
// runScript applies the binary allowlist and the code-runner guard:
|
|
229
|
+
// catalog argv may only start a known packager/interpreter.
|
|
230
|
+
const result = runScript(entry.command);
|
|
213
231
|
process.stdout.write(`${result.ok ? "ok " : "fail"} ${entry.id} (${entry.kind}) ${entry.command.join(" ")}\n`);
|
|
214
232
|
if (!result.ok && result.stderr)
|
|
215
233
|
process.stderr.write(result.stderr);
|
|
234
|
+
if (result.error)
|
|
235
|
+
process.stderr.write(`${result.error}\n`);
|
|
216
236
|
results.push({ id: entry.id, kind: entry.kind, source: entry.source, command: entry.command.join(" "), ok: result.ok, error: result.error ?? null });
|
|
217
237
|
}
|
|
218
238
|
print(results);
|
|
@@ -247,9 +267,26 @@ export function commandDeps(parsed) {
|
|
|
247
267
|
print({ node: process.version, platform: process.platform, dependencies: status });
|
|
248
268
|
return;
|
|
249
269
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
270
|
+
if (!flagOn(parsed, "yes")) {
|
|
271
|
+
// Dry run by default: show what would run (bootstrap + install) without
|
|
272
|
+
// touching the machine; --yes opts into execution explicitly.
|
|
273
|
+
const plan = [];
|
|
274
|
+
for (const entry of missingPrerequisites(spec)) {
|
|
275
|
+
const bootstrap = bootstrapFor(entry.provider);
|
|
276
|
+
plan.push(bootstrap && bootstrap.length > 0
|
|
277
|
+
? { provider: entry.provider.id, step: "bootstrap", command: bootstrap.join(" ") }
|
|
278
|
+
: { provider: entry.provider.id, step: "bootstrap", note: `missing ${entry.missing.join(", ")}` });
|
|
279
|
+
}
|
|
280
|
+
for (const entry of installCommands(spec)) {
|
|
281
|
+
plan.push({ provider: entry.id, step: "install", command: entry.command.join(" ") });
|
|
282
|
+
}
|
|
283
|
+
print({ plan, note: "dry run: pass --yes to execute" });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
const run = (argv, label) => {
|
|
287
|
+
// runScript applies the binary allowlist and the code-runner guard:
|
|
288
|
+
// catalog argv may only start a known packager/interpreter.
|
|
289
|
+
const result = runScript(argv);
|
|
253
290
|
process.stdout.write(`${result.ok ? "ok " : "fail"} ${label}\n`);
|
|
254
291
|
if (!result.ok && result.stderr)
|
|
255
292
|
process.stderr.write(result.stderr);
|
package/dist/commands/task.js
CHANGED
|
@@ -171,7 +171,18 @@ function taskInsert(parsed, db, session, spec) {
|
|
|
171
171
|
maxIterations: numberFlag(parsed, "max-iterations", { min: 1, integer: true }) || undefined
|
|
172
172
|
});
|
|
173
173
|
const positionRaw = asString(parsed.flags.position);
|
|
174
|
-
|
|
174
|
+
let position = "end";
|
|
175
|
+
if (positionRaw !== "") {
|
|
176
|
+
if (/^\d+$/.test(positionRaw))
|
|
177
|
+
position = Number(positionRaw);
|
|
178
|
+
else if (positionRaw === "start" || positionRaw === "end")
|
|
179
|
+
position = positionRaw;
|
|
180
|
+
else {
|
|
181
|
+
print({ error: `invalid position: ${positionRaw} (expected a number, "start" or "end")` });
|
|
182
|
+
process.exitCode = 1;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
175
186
|
print({ todo: insertTodo(db, taskId, item, position), task: getTask(db, taskId) });
|
|
176
187
|
return;
|
|
177
188
|
}
|
|
@@ -348,7 +359,9 @@ function taskNew(parsed, db, session, spec) {
|
|
|
348
359
|
return;
|
|
349
360
|
}
|
|
350
361
|
const id = asString(parsed.flags.id) || undefined;
|
|
351
|
-
|
|
362
|
+
// F: the shell's cwd is the project this plan belongs to — record it so the
|
|
363
|
+
// ledger only counts and blocks edits inside that project.
|
|
364
|
+
const task = createTask(db, { title, id, sessionId: session || undefined, projectRoot: process.cwd() });
|
|
352
365
|
print({ task, todos: [] });
|
|
353
366
|
return;
|
|
354
367
|
}
|
package/dist/db.js
CHANGED
|
@@ -159,12 +159,16 @@ function ensureColumn(db, table, column, definition) {
|
|
|
159
159
|
return;
|
|
160
160
|
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`);
|
|
161
161
|
}
|
|
162
|
-
export const SCHEMA_VERSION =
|
|
162
|
+
export const SCHEMA_VERSION = 2;
|
|
163
163
|
function migrate(db) {
|
|
164
164
|
ensureColumn(db, "tasks", "revision", "INTEGER NOT NULL DEFAULT 0");
|
|
165
165
|
ensureColumn(db, "tasks", "reviewed_at", "TEXT");
|
|
166
166
|
ensureColumn(db, "tasks", "edits_since_review", "INTEGER NOT NULL DEFAULT 0");
|
|
167
167
|
ensureColumn(db, "tasks", "todos_since_review", "INTEGER NOT NULL DEFAULT 0");
|
|
168
|
+
// F: the project a task belongs to — ledger edits are filtered against it so
|
|
169
|
+
// one project's plan never counts or blocks another project's files. NULL on
|
|
170
|
+
// legacy tasks keeps the previous unscoped owner behaviour.
|
|
171
|
+
ensureColumn(db, "tasks", "project_root", "TEXT");
|
|
168
172
|
db.exec(`PRAGMA user_version = ${SCHEMA_VERSION};`);
|
|
169
173
|
}
|
|
170
174
|
export function setMeta(db, key, value) {
|
package/dist/gate-repair.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Gate reload for gate FAIL results.
|
|
2
2
|
//
|
|
3
3
|
// When the gate denies a tool call because required skills are not loaded,
|
|
4
4
|
// the denial must be actionable: parse the structured FAIL payload and build
|
|
@@ -43,7 +43,7 @@ export function parseGateFailure(stdout) {
|
|
|
43
43
|
/**
|
|
44
44
|
* Build the actionable denial for a gate failure.
|
|
45
45
|
*
|
|
46
|
-
* attempt 1 — missing skills: the
|
|
46
|
+
* attempt 1 — missing skills: the gate reload protocol (load, retry, resume).
|
|
47
47
|
* attempt 2+ — the same skills are still missing: the loads did not take
|
|
48
48
|
* effect, so the directive escalates to diagnosis and stops
|
|
49
49
|
* the loop instead of repeating itself.
|
|
@@ -63,14 +63,14 @@ export function buildRepairDirective(failure, attempt) {
|
|
|
63
63
|
const steps = loads.map((skill, index) => ` ${index + 1}. skill({name:"${skill}"})`).join("\n");
|
|
64
64
|
return [
|
|
65
65
|
`${head} Missing skills: ${missing.join(", ")}.`,
|
|
66
|
-
"
|
|
66
|
+
"GATE RELOAD — execute now, do not ask the user, do not stop:",
|
|
67
67
|
steps,
|
|
68
68
|
` ${loads.length + 1}. Retry this exact ${failure.tool} call once, then continue the user's task where it left off.`,
|
|
69
69
|
"Never bypass the gate: no NOVAHIZ_GATE, no alternate tool, no shell write, no editing around the block."
|
|
70
70
|
].join("\n");
|
|
71
71
|
}
|
|
72
72
|
return [
|
|
73
|
-
`${head}
|
|
73
|
+
`${head} GATE RELOAD FAILED on attempt ${attempt}: still missing ${missing.join(", ")} after skill() loads.`,
|
|
74
74
|
"The loads did not register — diagnose instead of retrying:",
|
|
75
75
|
" 1. Confirm the skill is installed and the index matches (`novahiz doctor`).",
|
|
76
76
|
" 2. Realign the index (`novahiz sync`), then load the named skills again.",
|
package/dist/gate.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
1
2
|
import { hasPlaceholder, hasProse, hasStyle, isTrivial } from "./content.js";
|
|
2
3
|
import { determineTier } from "./complexity.js";
|
|
3
4
|
// Shared ledger enforcement (audit P1-D/M1): the CLI command and the MCP
|
|
4
5
|
// novahiz_gate tool both run these checks so their verdicts cannot diverge.
|
|
5
|
-
import { activeTask, recordEdit, reviewBlockReason, reviewDue, traceCheck } from "./ledger.js";
|
|
6
|
+
import { activeTask, ownedByOpenTodo, recordEdit, reviewBlockReason, reviewDue, traceCheck } from "./ledger.js";
|
|
6
7
|
import { autoCommit } from "./graft.js";
|
|
7
8
|
const CLASS_BY_EXTENSION = {
|
|
8
9
|
css: "design",
|
|
@@ -455,7 +456,22 @@ export function enforceLedgerChecks(db, input) {
|
|
|
455
456
|
if (ledgerConfig?.enabled !== false) {
|
|
456
457
|
const task = activeTask(db, session || undefined);
|
|
457
458
|
if (task) {
|
|
458
|
-
|
|
459
|
+
// F: a task bound to a project only ever owns paths inside that project —
|
|
460
|
+
// another project's edits never advance or block it. Legacy tasks without
|
|
461
|
+
// project_root keep the owner-only behaviour. Relative paths resolve
|
|
462
|
+
// against the caller's cwd; absolute paths (plugin flow) stand as-is.
|
|
463
|
+
const inProject = (filePath) => {
|
|
464
|
+
if (!task.project_root)
|
|
465
|
+
return true;
|
|
466
|
+
const abs = isAbsolute(filePath) ? resolve(filePath) : resolve(process.cwd(), filePath);
|
|
467
|
+
const rel = relative(task.project_root, abs);
|
|
468
|
+
return rel.length === 0 || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
469
|
+
};
|
|
470
|
+
// Cadence counts only edits an open todo actually owns (owner-scoped
|
|
471
|
+
// review): a session or project touching unrelated paths must never
|
|
472
|
+
// advance this plan toward its review block.
|
|
473
|
+
const ownedTarget = results.some((entry) => entry.path.length > 0 && inProject(entry.path) && ownedByOpenTodo(db, task.id, entry.path));
|
|
474
|
+
if (["edit", "write", "patch", "apply_patch"].includes(tool) && ownedTarget)
|
|
459
475
|
recordEdit(db, task.id);
|
|
460
476
|
// Targeted review: block only paths owned by an open todo with an owner
|
|
461
477
|
// pattern. A due review no longer freezes every target.
|
|
@@ -464,6 +480,9 @@ export function enforceLedgerChecks(db, input) {
|
|
|
464
480
|
// pathless command must not be blocked by a file-ownership check.
|
|
465
481
|
if (entry.path.length === 0)
|
|
466
482
|
continue;
|
|
483
|
+
// F: outside the task's project nothing in this ledger applies to it.
|
|
484
|
+
if (!inProject(entry.path))
|
|
485
|
+
continue;
|
|
467
486
|
const reason = reviewBlockReason(db, task.id, entry.path, ledgerConfig.review);
|
|
468
487
|
if (reason && !entry.ignored) {
|
|
469
488
|
entry.allow = false;
|
package/dist/ledger.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
1
2
|
import { globToRegExp, isSafeRegexPattern } from "./gate.js";
|
|
2
3
|
import { autoCommit } from "./graft.js";
|
|
3
4
|
export const DEFAULT_MAX_ITERATIONS = 12;
|
|
@@ -45,7 +46,11 @@ export function createTask(db, options) {
|
|
|
45
46
|
if (options.sessionId) {
|
|
46
47
|
db.prepare("INSERT OR IGNORE INTO sessions (id, categories, required_skills, updated_at) VALUES (?, '[]', '[]', ?)").run(options.sessionId, ts);
|
|
47
48
|
}
|
|
48
|
-
|
|
49
|
+
// F: remember which project the plan belongs to so the ledger can filter its
|
|
50
|
+
// edits to that project. Callers that cannot know it (legacy MCP flows) pass
|
|
51
|
+
// nothing and get NULL = unscoped, the behaviour before this column existed.
|
|
52
|
+
const projectRoot = options.projectRoot && options.projectRoot.trim().length > 0 ? resolve(options.projectRoot) : null;
|
|
53
|
+
db.prepare("INSERT INTO tasks (id, title, status, session_id, created_at, updated_at, project_root) VALUES (?, ?, 'active', ?, ?, ?, ?)").run(taskId, options.title, options.sessionId ?? null, ts, ts, projectRoot);
|
|
49
54
|
autoCommit("task-created", options.title);
|
|
50
55
|
return getTask(db, taskId);
|
|
51
56
|
}
|
|
@@ -62,9 +67,13 @@ function requireTask(db, id) {
|
|
|
62
67
|
return task;
|
|
63
68
|
}
|
|
64
69
|
export function activeTask(db, sessionId) {
|
|
70
|
+
// G2: two worlds, never mixed. A caller with a session only ever sees its own
|
|
71
|
+
// tasks; a sessionless caller only sees unbound tasks (session_id NULL) — a
|
|
72
|
+
// session-bound plan can no longer be grabbed (counted, blocked) by a foreign
|
|
73
|
+
// sessionless gate run, and the newest active task is no longer a free-for-all.
|
|
65
74
|
const row = sessionId
|
|
66
75
|
? 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();
|
|
76
|
+
: db.prepare("SELECT * FROM tasks WHERE status = 'active' AND session_id IS NULL ORDER BY created_at DESC LIMIT 1").get();
|
|
68
77
|
return row ?? null;
|
|
69
78
|
}
|
|
70
79
|
export function getTodo(db, id) {
|
|
@@ -315,6 +324,19 @@ export function reviewDue(db, taskId, policy = { edits: DEFAULT_REVIEW_EDITS, to
|
|
|
315
324
|
: "";
|
|
316
325
|
return { due, edits: task.edits_since_review, todos: task.todos_since_review, policy, reason };
|
|
317
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Does an open todo of this task own filePath through a non-empty owner
|
|
329
|
+
* pattern? Blank owners own everything (ownedBy semantics) and are excluded
|
|
330
|
+
* on purpose: review mechanics — cadence and block — stay tied to explicit
|
|
331
|
+
* ownership, so edits from another session or project never advance this plan.
|
|
332
|
+
*/
|
|
333
|
+
export function ownedByOpenTodo(db, taskId, filePath) {
|
|
334
|
+
if (filePath.length === 0)
|
|
335
|
+
return false;
|
|
336
|
+
return listTodos(db, taskId).some((todo) => (todo.status === "pending" || todo.status === "in_progress") &&
|
|
337
|
+
(todo.owner ?? "").trim().length > 0 &&
|
|
338
|
+
ownedBy(todo, filePath));
|
|
339
|
+
}
|
|
318
340
|
/**
|
|
319
341
|
* Which gate targets a due plan review should block.
|
|
320
342
|
* Only paths owned by an open todo with a non-empty owner pattern are blocked.
|
|
@@ -325,10 +347,7 @@ export function reviewBlockReason(db, taskId, filePath, policy) {
|
|
|
325
347
|
const due = reviewDue(db, taskId, policy);
|
|
326
348
|
if (!due.due)
|
|
327
349
|
return null;
|
|
328
|
-
|
|
329
|
-
if (openOwned.length === 0)
|
|
330
|
-
return null;
|
|
331
|
-
return openOwned.some((todo) => ownedBy(todo, filePath)) ? due.reason : null;
|
|
350
|
+
return ownedByOpenTodo(db, taskId, filePath) ? due.reason : null;
|
|
332
351
|
}
|
|
333
352
|
export function revisionSignals(db, taskId) {
|
|
334
353
|
const todos = listTodos(db, taskId);
|
package/docs/PROVIDERS.md
CHANGED
|
@@ -98,12 +98,14 @@ Control it in `novahiz.config.json`:
|
|
|
98
98
|
Run the install commands on demand:
|
|
99
99
|
|
|
100
100
|
```
|
|
101
|
-
node src/cli.ts providers --install
|
|
101
|
+
node src/cli.ts providers --install --yes
|
|
102
102
|
node install/install.mjs --install-providers
|
|
103
103
|
```
|
|
104
104
|
|
|
105
105
|
Installation is opt-in on purpose. The commands download third-party packages, including a large Rust binary for `narsil`, so `autoInstall` defaults to `false`. Enabling it means you trust each upstream listed in `source`.
|
|
106
106
|
|
|
107
|
+
`providers --install` and `deps --install` print the plan and execute nothing until `--yes` is passed. Whatever runs then goes through a binary allowlist (`node`, `npm`, `npx`, `uv`, `uvx`, `python`, `py`) — the same one bootstrap uses — so a tampered `providers.json` cannot execute an arbitrary program.
|
|
108
|
+
|
|
107
109
|
## Dependencies
|
|
108
110
|
|
|
109
111
|
Each provider declares its prerequisites in `requires` (the executable it needs) and, when it can be bootstrapped, a per-platform `bootstrap` command. No provider currently declares a `bootstrap`; `Novahiz deps --install` supports the field for future entries.
|
|
@@ -111,7 +113,7 @@ Each provider declares its prerequisites in `requires` (the executable it needs)
|
|
|
111
113
|
- `npx` based providers need `npx`, which ships with Node.
|
|
112
114
|
- `dart` needs the Dart SDK on `PATH` (`dart --version`). Flutter installs ship it.
|
|
113
115
|
|
|
114
|
-
`Novahiz deps` checks every prerequisite and reports what is missing. `Novahiz deps --install` first bootstraps a missing prerequisite through its official installer, then runs each provider's install command. The installer runs the check on every install and, when `providers.autoInstall` is true or `--install-providers` is passed, runs the installs too.
|
|
116
|
+
`Novahiz deps` checks every prerequisite and reports what is missing. `Novahiz deps --install` prints the plan (bootstrap and install steps) and runs nothing; with `--yes` it first bootstraps a missing prerequisite through its official installer, then runs each provider's install command. The installer runs the check on every install and, when `providers.autoInstall` is true or `--install-providers` is passed, runs the installs with `--yes` too.
|
|
115
117
|
|
|
116
118
|
## Troubleshooting
|
|
117
119
|
|
|
@@ -137,7 +139,7 @@ Confirm the tool count with `narsil-mcp tools list` afterwards, then rerun `Nova
|
|
|
137
139
|
|
|
138
140
|
- `Novahiz providers` lists providers, optionally by `--category` or a query.
|
|
139
141
|
- `Novahiz providers --mcp-json` prints the MCP entry map.
|
|
140
|
-
- `Novahiz providers --install`
|
|
141
|
-
- `Novahiz deps [--install]` checks prerequisites and
|
|
142
|
+
- `Novahiz providers --install [--yes]` plans the official install commands; `--yes` executes them.
|
|
143
|
+
- `Novahiz deps [--install] [--yes]` checks prerequisites and plans or runs the bootstrap/install commands.
|
|
142
144
|
- MCP `novahiz_providers` and `novahiz_deps` expose the list and the dependency status over stdio.
|
|
143
145
|
- `Novahiz report` lists the provider ids.
|
package/install/bootstrap.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { homedir } from "node:os";
|
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
|
-
import { nodeVersionOk, opencodeConfigDir, NovahizHome } from "./lib.mjs";
|
|
8
|
+
import { nodeVersionOk, opencodeConfigDir, NovahizHome, unsafeHostToken } from "./lib.mjs";
|
|
9
9
|
|
|
10
10
|
const REPO_URL = "https://github.com/novahiz/novahiz.git";
|
|
11
11
|
// Was hardcoded to ~/.config/novahiz, ignoring NOVAHIZ_HOME/NOVAHIZ_HOME.
|
|
@@ -21,19 +21,34 @@ function error(msg) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// npm/npx are .cmd shims on Windows: spawning them without a shell throws
|
|
24
|
-
// ENOENT (post-CVE-2024-* Node refuses to run .cmd via CreateProcess).
|
|
25
|
-
//
|
|
26
|
-
//
|
|
24
|
+
// ENOENT (post-CVE-2024-* Node refuses to run .cmd via CreateProcess). The
|
|
25
|
+
// line handed to cmd.exe only contains tokens accepted by unsafeHostToken
|
|
26
|
+
// (lib.mjs — the install-side mirror of src/exec.ts SAFE_TOKEN, widened by
|
|
27
|
+
// "*" for the literal flutter-skills glob); a refused token fails the run
|
|
28
|
+
// loudly instead of being reinterpreted by cmd.exe. Same explicit cmd.exe
|
|
29
|
+
// spelling as src/exec.ts resolveSpawn — argv form, no deprecated shell flag
|
|
30
|
+
// (DEP0190).
|
|
27
31
|
const HOST_CMDS = new Set(["npm", "npx"]);
|
|
28
32
|
function hostInvocation(cmd, args) {
|
|
29
33
|
if (process.platform === "win32" && HOST_CMDS.has(cmd)) {
|
|
30
|
-
|
|
34
|
+
const bad = unsafeHostToken([cmd, ...args]);
|
|
35
|
+
if (bad !== null) return { refused: bad };
|
|
36
|
+
const shell = process.env.ComSpec ?? "cmd.exe";
|
|
37
|
+
return { command: shell, spawnArgs: ["/d", "/s", "/c", [cmd, ...args].join(" ")], opts: {} };
|
|
31
38
|
}
|
|
32
39
|
return { command: cmd, spawnArgs: args, opts: {} };
|
|
33
40
|
}
|
|
34
41
|
|
|
42
|
+
function refusedResult(token) {
|
|
43
|
+
return { ok: false, status: 1, stdout: "", stderr: `refused unsafe token: ${token}\n` };
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
function run(cmd, args, opts = {}) {
|
|
36
47
|
const inv = hostInvocation(cmd, args);
|
|
48
|
+
if (inv.refused !== undefined) {
|
|
49
|
+
error(`refused unsafe token in spawn: ${inv.refused}`);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
37
52
|
const result = spawnSync(inv.command, inv.spawnArgs, {
|
|
38
53
|
encoding: "utf8",
|
|
39
54
|
stdio: "inherit",
|
|
@@ -45,6 +60,7 @@ function run(cmd, args, opts = {}) {
|
|
|
45
60
|
|
|
46
61
|
function runCapture(cmd, args, opts = {}) {
|
|
47
62
|
const inv = hostInvocation(cmd, args);
|
|
63
|
+
if (inv.refused !== undefined) return refusedResult(inv.refused);
|
|
48
64
|
const result = spawnSync(inv.command, inv.spawnArgs, {
|
|
49
65
|
encoding: "utf8",
|
|
50
66
|
stdio: ["pipe", "pipe", "pipe"],
|
package/install/install.mjs
CHANGED
|
@@ -423,7 +423,7 @@ async function main() {
|
|
|
423
423
|
if (check.stdout) process.stdout.write(check.stdout);
|
|
424
424
|
if (autoInstall) {
|
|
425
425
|
note("Installing dependencies and providers (MCP, skills, commands)");
|
|
426
|
-
const result = spawnSync(process.execPath, [cli, "deps", "--install"], {
|
|
426
|
+
const result = spawnSync(process.execPath, [cli, "deps", "--install", "--yes"], {
|
|
427
427
|
encoding: "utf8",
|
|
428
428
|
env: { ...process.env, NOVAHIZ_HOME: home }
|
|
429
429
|
});
|
package/install/lib.mjs
CHANGED
|
@@ -190,13 +190,45 @@ export function loadManifest(home) {
|
|
|
190
190
|
return readJson(join(home, ".novahiz-install.json"), { created: [], backups: [] });
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
// npm/npx are .cmd shims on Windows: direct spawnSync throws ENOENT
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
193
|
+
// npm/npx are .cmd shims on Windows: direct spawnSync throws ENOENT
|
|
194
|
+
// (post-CVE-2024-* Node refuses to run .cmd via CreateProcess), so they need
|
|
195
|
+
// cmd.exe. The line handed to cmd.exe is built only from tokens that pass
|
|
196
|
+
// SAFE_HOST_TOKEN — the install-side mirror of src/exec.ts SAFE_TOKEN, widened
|
|
197
|
+
// by a single character: "*" (cmd.exe performs no glob expansion, and the
|
|
198
|
+
// flutter-skills `--skill *` argument must reach npx literally). Everything a
|
|
199
|
+
// shell can reinterpret — & | < > ^ % ! ( ) " ' ` ; $ ? whitespace, newlines —
|
|
200
|
+
// stays banned, so the joined line can never grow a second command. Tokens are
|
|
201
|
+
// static today (catalog package names, fixed flags); this check keeps that
|
|
202
|
+
// true for any future argument instead of trusting it. Same cmd.exe spelling
|
|
203
|
+
// as src/exec.ts resolveSpawn — cmd.exe is spawned explicitly with argv, no
|
|
204
|
+
// deprecated shell flag (DEP0190), so the joined line is the only string cmd
|
|
205
|
+
// ever parses.
|
|
206
|
+
const SAFE_HOST_TOKEN = /^[A-Za-z0-9@._+*,/:=~-]+$/;
|
|
207
|
+
|
|
208
|
+
export function unsafeHostToken(tokens) {
|
|
209
|
+
for (const token of tokens) {
|
|
210
|
+
if (token.length === 0) return "";
|
|
211
|
+
if (!SAFE_HOST_TOKEN.test(token)) return token;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function refusedHost(token) {
|
|
217
|
+
return {
|
|
218
|
+
status: 1,
|
|
219
|
+
stdout: "",
|
|
220
|
+
stderr: `refused unsafe token: ${token}\n`,
|
|
221
|
+
error: new Error(`refused unsafe token: ${token}`),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
197
225
|
export function spawnHost(cmd, args, opts = {}) {
|
|
198
226
|
if (process.platform === "win32") {
|
|
199
|
-
|
|
227
|
+
const bad = unsafeHostToken([cmd, ...args]);
|
|
228
|
+
if (bad !== null) return refusedHost(bad);
|
|
229
|
+
const shell = process.env.ComSpec ?? "cmd.exe";
|
|
230
|
+
const line = [cmd, ...args].join(" ");
|
|
231
|
+
return spawnSync(shell, ["/d", "/s", "/c", line], { ...opts, encoding: "utf8" });
|
|
200
232
|
}
|
|
201
233
|
return spawnSync(cmd, args, { ...opts, encoding: "utf8" });
|
|
202
234
|
}
|
|
@@ -134,6 +134,7 @@ const TOOLS = [
|
|
|
134
134
|
id: { type: "string", description: "Task id (new) or todo/task id (start, done, block, drop)." },
|
|
135
135
|
task: { type: "string", description: "Task id. Defaults to the active task." },
|
|
136
136
|
session: { type: "string", description: "Session id used to scope the active task." },
|
|
137
|
+
projectRoot: { type: "string", description: "Project directory a new task belongs to (action new). Omitted = unscoped legacy task." },
|
|
137
138
|
label: { type: "string", description: "Todo label for action todo." },
|
|
138
139
|
kind: { type: "string", enum: ["read", "edit", "verify", "delegate"], description: "Todo kind." },
|
|
139
140
|
acceptance: { type: "string", description: "Acceptance criterion for the todo." },
|
|
@@ -393,36 +394,37 @@ function callTool(name, args) {
|
|
|
393
394
|
// Same shape as the CLI's results array: path alongside the GateResult,
|
|
394
395
|
// on the same object so enforceLedgerChecks mutates this verdict in place.
|
|
395
396
|
result.path = file;
|
|
396
|
-
|
|
397
|
-
|
|
397
|
+
// Parity with the CLI: an empty session no longer skips this block. The
|
|
398
|
+
// sessionless call still enforces unbound tasks (activeTask) and fails
|
|
399
|
+
// closed on DB errors; it simply writes no enforcement_log row.
|
|
400
|
+
let mdb = null;
|
|
401
|
+
try {
|
|
402
|
+
mdb = openDb(resolve(spec.root, spec.config.dbPath));
|
|
403
|
+
} catch {
|
|
404
|
+
mdb = null;
|
|
405
|
+
}
|
|
406
|
+
if (mdb) {
|
|
398
407
|
try {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
spec,
|
|
412
|
-
gateConfig: spec.config.gate
|
|
413
|
-
});
|
|
414
|
-
if (enforced.reasons.length > 0) {
|
|
415
|
-
result.reasons.push(...enforced.reasons);
|
|
416
|
-
result.allow = false;
|
|
417
|
-
}
|
|
418
|
-
if (enforced.reviewWarning) result.reasons.push(enforced.reviewWarning);
|
|
419
|
-
} finally {
|
|
420
|
-
mdb.close();
|
|
408
|
+
const enforced = enforceLedgerChecks(mdb, {
|
|
409
|
+
session,
|
|
410
|
+
tool: String(args?.tool ?? "edit"),
|
|
411
|
+
paths: [file],
|
|
412
|
+
categories,
|
|
413
|
+
results: [result],
|
|
414
|
+
spec,
|
|
415
|
+
gateConfig: spec.config.gate
|
|
416
|
+
});
|
|
417
|
+
if (enforced.reasons.length > 0) {
|
|
418
|
+
result.reasons.push(...enforced.reasons);
|
|
419
|
+
result.allow = false;
|
|
421
420
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
421
|
+
if (enforced.reviewWarning) result.reasons.push(enforced.reviewWarning);
|
|
422
|
+
} finally {
|
|
423
|
+
mdb.close();
|
|
425
424
|
}
|
|
425
|
+
} else {
|
|
426
|
+
result.allow = false;
|
|
427
|
+
result.reasons.push("DB open failed: ledger enforcement unavailable");
|
|
426
428
|
}
|
|
427
429
|
// A gate refusal is a normal verdict, not an execution error.
|
|
428
430
|
return toolResult(result, false);
|
|
@@ -466,6 +468,11 @@ function callTool(name, args) {
|
|
|
466
468
|
if (name === "novahiz_step") {
|
|
467
469
|
const session = String(args?.session ?? "default");
|
|
468
470
|
const done = args?.done ? String(args.done) : "";
|
|
471
|
+
// WS3: the step id follows the roadmap naming pattern (plan, write,
|
|
472
|
+
// impeccable-critique) — reject anything else before it reaches the database.
|
|
473
|
+
if (done.length > 0 && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(done)) {
|
|
474
|
+
throw new Error(`Invalid params: step id must match [A-Za-z0-9][A-Za-z0-9._-]{0,127} (got "${done}")`);
|
|
475
|
+
}
|
|
469
476
|
const db = openDb(resolve(spec.root, spec.config.dbPath));
|
|
470
477
|
try {
|
|
471
478
|
if (done.length > 0) {
|
|
@@ -497,7 +504,9 @@ function callTool(name, args) {
|
|
|
497
504
|
const db = openDb(resolve(spec.root, spec.config.dbPath));
|
|
498
505
|
try {
|
|
499
506
|
if (action === "new") {
|
|
500
|
-
|
|
507
|
+
// F: projectRoot scopes the new task to a project when the caller knows
|
|
508
|
+
// it; omitted = unscoped legacy task (no path filtering).
|
|
509
|
+
return toolResult(createTask(db, { title: String(args?.title ?? ""), id: args?.id ? String(args.id) : undefined, sessionId: session, projectRoot: args?.projectRoot ? String(args.projectRoot) : undefined }));
|
|
501
510
|
}
|
|
502
511
|
if (action === "plan") {
|
|
503
512
|
const taskId = args?.task ? String(args.task) : activeTask(db, session)?.id;
|