@viloforge/vfkb 0.4.0 → 0.4.1

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.
@@ -32007,7 +32007,7 @@ function queryExplained(opts = {}) {
32007
32007
  }
32008
32008
 
32009
32009
  // src/version.ts
32010
- var ENGINE_VERSION = true ? "0.4.0" : ownPackageVersion();
32010
+ var ENGINE_VERSION = true ? "0.4.1" : ownPackageVersion();
32011
32011
 
32012
32012
  // src/mcp-server.ts
32013
32013
  var SEARCH_DEFAULT_LIMIT = 25;
@@ -16077,8 +16077,8 @@ import { dirname as dirname4, join as join7 } from "node:path";
16077
16077
 
16078
16078
  // src/version.ts
16079
16079
  var SCHEMA_VERSION = 1;
16080
- var ENGINE_VERSION = true ? "0.4.0" : ownPackageVersion();
16081
- var ENGINE_COMMIT = true ? "63659e5" : "dev";
16080
+ var ENGINE_VERSION = true ? "0.4.1" : ownPackageVersion();
16081
+ var ENGINE_COMMIT = true ? "66ee821" : "dev";
16082
16082
 
16083
16083
  // src/manifest.ts
16084
16084
  function manifestPath(brainDir2) {
@@ -16869,8 +16869,8 @@ function approvalNotice(project) {
16869
16869
 
16870
16870
  // src/doctor.ts
16871
16871
  import { execFileSync as execFileSync6 } from "node:child_process";
16872
- import { existsSync as existsSync9, readFileSync as readFileSync11, writeFileSync as writeFileSync6, mkdirSync as mkdirSync8 } from "node:fs";
16873
- import { join as join13, dirname as dirname5 } from "node:path";
16872
+ import { existsSync as existsSync9, readFileSync as readFileSync11, writeFileSync as writeFileSync6, mkdirSync as mkdirSync8, realpathSync } from "node:fs";
16873
+ import { join as join13, dirname as dirname5, relative as relative3, resolve as resolve4, isAbsolute as isAbsolute2 } from "node:path";
16874
16874
  function readJson2(path) {
16875
16875
  if (!existsSync9(path)) return void 0;
16876
16876
  try {
@@ -17065,11 +17065,45 @@ async function checkNpmCurrency(opts) {
17065
17065
  clearTimeout(timer);
17066
17066
  }
17067
17067
  }
17068
+ var HOOK_SUBCOMMANDS = ["session-start", "pre-tool-use", "post-tool-use", "stop", "session-end"];
17069
+ function isEngineWiring(hookJson) {
17070
+ const text = hookJson.replace(/\\[tnr]/g, " ").replace(/\\(.)/g, "$1");
17071
+ if (/bootstrap\.mjs/.test(text)) return true;
17072
+ return new RegExp(`\\bhook["'\\s]+(${HOOK_SUBCOMMANDS.join("|")})\\b`).test(text);
17073
+ }
17074
+ function engineDrift(manifestCommit, runningCommit) {
17075
+ if (!manifestCommit || manifestCommit === "dev") return false;
17076
+ if (!runningCommit || runningCommit === "dev") return false;
17077
+ return manifestCommit !== runningCommit;
17078
+ }
17079
+ function repoToplevel(root) {
17080
+ try {
17081
+ return execFileSync6("git", ["-C", root, "rev-parse", "--show-toplevel"], {
17082
+ encoding: "utf8",
17083
+ stdio: ["ignore", "pipe", "ignore"]
17084
+ }).trim();
17085
+ } catch {
17086
+ return void 0;
17087
+ }
17088
+ }
17089
+ function isUnder(parent, child) {
17090
+ if (!parent) return false;
17091
+ const real = (p) => {
17092
+ try {
17093
+ return realpathSync(p);
17094
+ } catch {
17095
+ return resolve4(p);
17096
+ }
17097
+ };
17098
+ const rel = relative3(real(parent), real(child));
17099
+ return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
17100
+ }
17068
17101
  function runDoctor(opts) {
17069
17102
  const { root, brainDir: brainDir2, env } = opts;
17103
+ const runningCommit = opts.engineCommit ?? ENGINE_COMMIT;
17070
17104
  const checks = [];
17071
17105
  const add = (name, status, detail) => checks.push({ name, status, detail });
17072
- add("engine", "ok", `version ${ENGINE_VERSION} \xB7 commit ${ENGINE_COMMIT} \xB7 schema v${SCHEMA_VERSION}`);
17106
+ add("engine", "ok", `version ${ENGINE_VERSION} \xB7 commit ${runningCommit} \xB7 schema v${SCHEMA_VERSION}`);
17073
17107
  const settings = readJson2(join13(root, ".claude", "settings.json"));
17074
17108
  const configDir = claudeConfigDir(env);
17075
17109
  const pluginsFile = opts.pluginsFile ?? (configDir ? join13(configDir, "plugins", "installed_plugins.json") : void 0);
@@ -17079,7 +17113,16 @@ function runDoctor(opts) {
17079
17113
  add(
17080
17114
  "brain manifest",
17081
17115
  "warn",
17082
- plugin ? `no manifest.json in ${brainDir2} \u2014 it will be stamped on the next write` : `no manifest.json in ${brainDir2} \u2014 run \`vfkb init\` (or it will be stamped on next write)`
17116
+ // #188 the ordinary write path (addEntry appendRecord backend.append)
17117
+ // never touches manifest.json. `writeManifest` has exactly two callers:
17118
+ // `vfkb init` (init.ts) and the broadcast heal, which fires only when the
17119
+ // manifest is ABSENT (broadcast.ts). Saying "on the next write" claimed
17120
+ // behaviour the code does not have.
17121
+ // On a PLUGIN-wired repo the message must not prescribe `vfkb init` — that
17122
+ // advice is what scaffolds double wiring (issue #77), and the invariant is
17123
+ // asserted in doctor.test.ts. A plugin-born brain simply has no manifest
17124
+ // until a cross-repo broadcast heals it (vfkb#193).
17125
+ plugin ? `no manifest.json in ${brainDir2} \u2014 plugin-born brains have none until a cross-repo broadcast heals it (vfkb#193); the ordinary write path never creates one` : `no manifest.json in ${brainDir2} \u2014 run \`vfkb init\` to stamp it (the ordinary write path never creates one)`
17083
17126
  );
17084
17127
  } else if (typeof mf.schema_version !== "number") {
17085
17128
  add("brain manifest", "warn", "manifest has no numeric schema_version");
@@ -17088,9 +17131,10 @@ function runDoctor(opts) {
17088
17131
  } else if (mf.schema_version < SCHEMA_VERSION) {
17089
17132
  add("brain\u2194engine compat", "warn", `brain schema v${mf.schema_version} is older than engine v${SCHEMA_VERSION} \u2014 migration may be needed`);
17090
17133
  } else {
17091
- add("brain\u2194engine compat", "ok", `schema v${mf.schema_version} matches`);
17092
- if (mf.engine_commit && ENGINE_COMMIT !== "dev" && mf.engine_commit !== ENGINE_COMMIT) {
17093
- add("engine drift", "warn", `brain last stamped by engine ${mf.engine_commit}, running ${ENGINE_COMMIT} \u2014 possible dual-clone drift`);
17134
+ const sentinel = mf.engine_commit === "dev" ? ` \xB7 stamped by a build that did not know its own commit ("dev") \u2014 provenance unknown (#212)` : "";
17135
+ add("brain\u2194engine compat", "ok", `schema v${mf.schema_version} matches${sentinel}`);
17136
+ if (engineDrift(mf.engine_commit, runningCommit)) {
17137
+ add("engine drift", "warn", `brain last stamped by engine ${mf.engine_commit}, running ${runningCommit} \u2014 possible dual-clone drift`);
17094
17138
  }
17095
17139
  }
17096
17140
  {
@@ -17110,7 +17154,7 @@ function runDoctor(opts) {
17110
17154
  } else {
17111
17155
  add("journal", "ok", `${js.walLines} line(s) in the uncommitted window, nothing to restore`);
17112
17156
  }
17113
- if (existsSync9(join13(brainDir2, ".journal"))) {
17157
+ if (existsSync9(join13(brainDir2, ".journal")) && isUnder(repoToplevel(root), join13(brainDir2, ".journal"))) {
17114
17158
  try {
17115
17159
  execFileSync6("git", ["-C", root, "check-ignore", "-q", join13(brainDir2, ".journal", "wal.jsonl")], {
17116
17160
  stdio: "ignore"
@@ -17160,7 +17204,7 @@ function runDoctor(opts) {
17160
17204
  }
17161
17205
  const hooks = settings?.hooks ?? {};
17162
17206
  const expected = ["SessionStart", "PreToolUse", "Stop", "SessionEnd"];
17163
- const have = expected.filter((e) => JSON.stringify(hooks[e] ?? "").includes("vfkb"));
17207
+ const have = expected.filter((e) => isEngineWiring(JSON.stringify(hooks[e] ?? "")));
17164
17208
  if (plugin && have.length > 0) {
17165
17209
  add(".claude/settings.json", "warn", `vfkb hooks present ALONGSIDE the plugin (double wiring) \u2014 remove them; the plugin's hooks are primary (ADR-0045)`);
17166
17210
  } else if (plugin) {
@@ -17380,13 +17424,13 @@ function packageVersion() {
17380
17424
  }
17381
17425
  }
17382
17426
  function readStdin() {
17383
- return new Promise((resolve4) => {
17427
+ return new Promise((resolve5) => {
17384
17428
  let data = "";
17385
- if (process.stdin.isTTY) return resolve4("");
17429
+ if (process.stdin.isTTY) return resolve5("");
17386
17430
  process.stdin.setEncoding("utf8");
17387
17431
  process.stdin.on("data", (c) => data += c);
17388
- process.stdin.on("end", () => resolve4(data));
17389
- setTimeout(() => resolve4(data), 2e3).unref?.();
17432
+ process.stdin.on("end", () => resolve5(data));
17433
+ setTimeout(() => resolve5(data), 2e3).unref?.();
17390
17434
  });
17391
17435
  }
17392
17436
  function flag(args, name) {
package/dist/doctor.js CHANGED
@@ -3,8 +3,8 @@
3
3
  // or inconsistent wiring, and the dual-clone drift signal (a brain last stamped by
4
4
  // a different engine build). Deterministic; unit-tested (the inner gate per ADR-0023).
5
5
  import { execFileSync } from 'node:child_process';
6
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
7
- import { join, dirname } from 'node:path';
6
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, realpathSync } from 'node:fs';
7
+ import { join, dirname, relative, resolve, isAbsolute } from 'node:path';
8
8
  import { SCHEMA_VERSION, ENGINE_VERSION, ENGINE_COMMIT } from './version.js';
9
9
  import { journalStatus } from './journal.js';
10
10
  import { readManifest } from './manifest.js';
@@ -313,12 +313,81 @@ export async function checkNpmCurrency(opts) {
313
313
  clearTimeout(timer);
314
314
  }
315
315
  }
316
+ // #186 — what OLD (pre-plugin) vfkb wiring actually is: a hook command that
317
+ // invokes the ENGINE's hook dispatcher, either through the committed bootstrap
318
+ // (ADR-0031) or by naming a hook subcommand directly.
319
+ //
320
+ // The predicate used to be `JSON.stringify(hook).includes('vfkb')`, which
321
+ // classified ANY hook mentioning the string as old wiring — including the
322
+ // ADR-0059 INACTIVE guard (`.claude/vfkb-guard.mjs`) that the plugin's own
323
+ // MIGRATION_GUIDE tells every consumer to commit. So a correctly migrated repo
324
+ // was told to delete its own INACTIVE detector, and real double wiring was
325
+ // buried in a warning consumers had learned to ignore.
326
+ const HOOK_SUBCOMMANDS = ['session-start', 'pre-tool-use', 'post-tool-use', 'stop', 'session-end'];
327
+ export function isEngineWiring(hookJson) {
328
+ // Compare against the LITERAL command text, not its JSON encoding: a tab in the
329
+ // command is the two characters \\t once stringified, and a quoted subcommand is
330
+ // \\". Un-escaping first means the separator rule below is about real whitespace
331
+ // and quoting rather than about JSON spelling. (Review of PR #216, minor 5: the
332
+ // previous `\\?` sat before the whitespace, where JSON never puts a backslash.)
333
+ const text = hookJson.replace(/\\[tnr]/g, ' ').replace(/\\(.)/g, '$1');
334
+ if (/bootstrap\.mjs/.test(text))
335
+ return true;
336
+ return new RegExp(`\\bhook["'\\s]+(${HOOK_SUBCOMMANDS.join('|')})\\b`).test(text);
337
+ }
338
+ // #212 — a sentinel commit means "this build does not know its own identity"
339
+ // (version.ts's honest `dev` fallback on the tsc/dist path), NOT "a different
340
+ // engine stamped this brain". Comparing a sentinel against a real sha is not a
341
+ // drift observation, it is an absence of one.
342
+ //
343
+ // The old predicate exempted a dev RUNNING engine but not a dev MANIFEST, so a
344
+ // brain stamped by a dist-path build (e.g. the documented `node dist/cli.js`
345
+ // fallback, which is how ViloGate's manifest got `"dev"`) reported drift forever
346
+ // against every real-sha engine.
347
+ export function engineDrift(manifestCommit, runningCommit) {
348
+ if (!manifestCommit || manifestCommit === 'dev')
349
+ return false;
350
+ if (!runningCommit || runningCommit === 'dev')
351
+ return false;
352
+ return manifestCommit !== runningCommit;
353
+ }
354
+ // #206 — the work tree that actually governs a path, or undefined outside a repo.
355
+ function repoToplevel(root) {
356
+ try {
357
+ return execFileSync('git', ['-C', root, 'rev-parse', '--show-toplevel'], {
358
+ encoding: 'utf8',
359
+ stdio: ['ignore', 'pipe', 'ignore'],
360
+ }).trim();
361
+ }
362
+ catch {
363
+ return undefined;
364
+ }
365
+ }
366
+ // Containment on path BOUNDARIES: `/repo` must not be judged to contain
367
+ // `/repo-other/...`. Both sides are realpath'd so a symlinked brain dir (or a
368
+ // /tmp that resolves to /private/tmp) compares honestly rather than by spelling.
369
+ function isUnder(parent, child) {
370
+ if (!parent)
371
+ return false;
372
+ const real = (p) => {
373
+ try {
374
+ return realpathSync(p);
375
+ }
376
+ catch {
377
+ return resolve(p);
378
+ }
379
+ };
380
+ const rel = relative(real(parent), real(child));
381
+ return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
382
+ }
316
383
  export function runDoctor(opts) {
317
384
  const { root, brainDir, env } = opts;
385
+ // The identity we compare the brain's stamp against (injectable — see DoctorOpts).
386
+ const runningCommit = opts.engineCommit ?? ENGINE_COMMIT;
318
387
  const checks = [];
319
388
  const add = (name, status, detail) => checks.push({ name, status, detail });
320
389
  // 1. Engine identity (info).
321
- add('engine', 'ok', `version ${ENGINE_VERSION} · commit ${ENGINE_COMMIT} · schema v${SCHEMA_VERSION}`);
390
+ add('engine', 'ok', `version ${ENGINE_VERSION} · commit ${runningCommit} · schema v${SCHEMA_VERSION}`);
322
391
  // Read the project settings once (used by plugin detection, hooks checks, and
323
392
  // project-consistency below), and detect ADR-0045 plugin wiring up front — it changes
324
393
  // what "healthy" means for the wiring checks AND which advice is safe to give
@@ -330,9 +399,19 @@ export function runDoctor(opts) {
330
399
  // 2. Brain ↔ engine compat (the load-bearing check).
331
400
  const mf = readManifest(brainDir);
332
401
  if (!mf) {
333
- add('brain manifest', 'warn', plugin
334
- ? `no manifest.json in ${brainDir} it will be stamped on the next write`
335
- : `no manifest.json in ${brainDir} run \`vfkb init\` (or it will be stamped on next write)`);
402
+ add('brain manifest', 'warn',
403
+ // #188 the ordinary write path (addEntry appendRecord backend.append)
404
+ // never touches manifest.json. `writeManifest` has exactly two callers:
405
+ // `vfkb init` (init.ts) and the broadcast heal, which fires only when the
406
+ // manifest is ABSENT (broadcast.ts). Saying "on the next write" claimed
407
+ // behaviour the code does not have.
408
+ // On a PLUGIN-wired repo the message must not prescribe `vfkb init` — that
409
+ // advice is what scaffolds double wiring (issue #77), and the invariant is
410
+ // asserted in doctor.test.ts. A plugin-born brain simply has no manifest
411
+ // until a cross-repo broadcast heals it (vfkb#193).
412
+ plugin
413
+ ? `no manifest.json in ${brainDir} — plugin-born brains have none until a cross-repo broadcast heals it (vfkb#193); the ordinary write path never creates one`
414
+ : `no manifest.json in ${brainDir} — run \`vfkb init\` to stamp it (the ordinary write path never creates one)`);
336
415
  }
337
416
  else if (typeof mf.schema_version !== 'number') {
338
417
  add('brain manifest', 'warn', 'manifest has no numeric schema_version');
@@ -344,10 +423,19 @@ export function runDoctor(opts) {
344
423
  add('brain↔engine compat', 'warn', `brain schema v${mf.schema_version} is older than engine v${SCHEMA_VERSION} — migration may be needed`);
345
424
  }
346
425
  else {
347
- add('brain↔engine compat', 'ok', `schema v${mf.schema_version} matches`);
426
+ // #212 — a sentinel stamp is suppressed as DRIFT (it is not drift), but it must
427
+ // not become invisible: the brain's provenance genuinely is unrecorded, and the
428
+ // stamping half of #212 (a build that does not know its commit should not write
429
+ // one) is still open. Reported in the detail rather than as a new warn — it is a
430
+ // true statement, but not one the consumer can act on, and this PR exists to stop
431
+ // doctor crying wolf.
432
+ const sentinel = mf.engine_commit === 'dev'
433
+ ? ` · stamped by a build that did not know its own commit ("dev") — provenance unknown (#212)`
434
+ : '';
435
+ add('brain↔engine compat', 'ok', `schema v${mf.schema_version} matches${sentinel}`);
348
436
  // Drift signal: same schema but a different engine build last stamped the brain.
349
- if (mf.engine_commit && ENGINE_COMMIT !== 'dev' && mf.engine_commit !== ENGINE_COMMIT) {
350
- add('engine drift', 'warn', `brain last stamped by engine ${mf.engine_commit}, running ${ENGINE_COMMIT} — possible dual-clone drift`);
437
+ if (engineDrift(mf.engine_commit, runningCommit)) {
438
+ add('engine drift', 'warn', `brain last stamped by engine ${mf.engine_commit}, running ${runningCommit} — possible dual-clone drift`);
351
439
  }
352
440
  }
353
441
  // 2b. Durable-capture journal (ADR-0064): what would recovery do, and is a
@@ -367,7 +455,12 @@ export function runDoctor(opts) {
367
455
  // has no .journal/ gitignore line — its next `git add .vfkb` would COMMIT
368
456
  // the wal (a tracked journal dies by the same reset --hard, RFC-034
369
457
  // Alternatives; and a committed wal defeats §4 redaction).
370
- if (existsSync(join(brainDir, '.journal'))) {
458
+ // #206 — judge only a journal the repo actually governs. With the brain on
459
+ // the default ~/.vfkb tier (VFKB_DATA_DIR outside the work tree),
460
+ // `check-ignore` exits 128 ("path outside work tree"), which the catch-all
461
+ // below read as plain "not ignored" — so doctor advised editing a .gitignore
462
+ // that does not govern that path at all.
463
+ if (existsSync(join(brainDir, '.journal')) && isUnder(repoToplevel(root), join(brainDir, '.journal'))) {
371
464
  try {
372
465
  execFileSync('git', ['-C', root, 'check-ignore', '-q', join(brainDir, '.journal', 'wal.jsonl')], {
373
466
  stdio: 'ignore',
@@ -430,7 +523,7 @@ export function runDoctor(opts) {
430
523
  // 5. Hooks wiring (same plugin logic as check 4).
431
524
  const hooks = settings?.hooks ?? {};
432
525
  const expected = ['SessionStart', 'PreToolUse', 'Stop', 'SessionEnd'];
433
- const have = expected.filter((e) => JSON.stringify(hooks[e] ?? '').includes('vfkb'));
526
+ const have = expected.filter((e) => isEngineWiring(JSON.stringify(hooks[e] ?? '')));
434
527
  if (plugin && have.length > 0) {
435
528
  add('.claude/settings.json', 'warn', `vfkb hooks present ALONGSIDE the plugin (double wiring) — remove them; the plugin's hooks are primary (ADR-0045)`);
436
529
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viloforge/vfkb",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "ViloForge KnowledgeBase (vfkb) — per-project knowledge substrate for the ViloForge software factory. Greenfield TypeScript.",
5
5
  "type": "module",
6
6
  "main": "dist/engine.js",