bantamkit-mcp 0.33.0 → 0.34.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.
package/dist/cli.js CHANGED
@@ -81,7 +81,7 @@ import { buildServer } from './mcp/server.js';
81
81
  import { RawStdioTransport } from './mcp/transport.js';
82
82
  import { ArgumentTypeError, ArgvError, formatHelp, formatUsageBlock, HelpRequested, helpWidth, parseArgs as parseWithSpec, pyIntStrict, } from './pyargparse.js';
83
83
  import { pyJoin, PyOSError, pyRepr, pyStatIsDir } from './memory/pyfs.js';
84
- import { HOSTS, install as installHost, InstallError, thisCommand } from './hostinstall.js';
84
+ import { HOSTS, InstallError, installSelf } from './hostinstall.js';
85
85
  import { runUpdate } from './selfupdate.js';
86
86
  import { statusLine } from './statusline.js';
87
87
  /** `SystemExit("...")`: the message on stderr, exit 1. */
@@ -471,10 +471,11 @@ async function main(argv) {
471
471
  // earlier here made the same argv write a file on one runtime and not the other: one
472
472
  // command line, two different states on the user's disk. Reviewed and moved.
473
473
  if (options.install !== null && options.install !== undefined) {
474
- // Before any store or transport exists, the shape `--assets-root` established.
475
- const { command, args } = thisCommand();
474
+ // Before any store or transport exists, the shape `--assets-root` established. The command
475
+ // is resolved INSIDE the try: on an `npx` cache it makes the kept install first, and npm
476
+ // failing there is an `InstallError` like any other refusal — `error:` and exit 1.
476
477
  try {
477
- process.stdout.write(`${installHost(options.install, command, args, options.force)}\n`);
478
+ process.stdout.write(`${installSelf(options.install, options.force, { version: version() })}\n`);
478
479
  }
479
480
  catch (e) {
480
481
  if (!(e instanceof InstallError))
package/dist/eventlog.js CHANGED
@@ -55,7 +55,8 @@
55
55
  * notice — the failure mode a diagnostic can least afford.
56
56
  */
57
57
  import { appendFileSync, mkdirSync, renameSync, statSync } from 'node:fs';
58
- import { cmpCodepoint, osErrorClassName, PyOSError, pyJoin, pyParent } from './memory/pyfs.js';
58
+ import { cmpCodepoint, osErrorClassName, pyExists, PyOSError, pyJoin, pyName, pyParent, pyParents } from './memory/pyfs.js';
59
+ import { ensureBantamkitGitignore } from './memory/store.js';
59
60
  /**
60
61
  * Environment switch. Unset or `off`/`0`/`false`/`no`/empty -> disabled. `on`/`1`/`true`/
61
62
  * `yes` -> the default file inside the memory store. Anything else is taken as the literal
@@ -242,11 +243,33 @@ export class EventLog {
242
243
  * by construction, so that branch is a statement of intent, not a live path.
243
244
  */
244
245
  append(path, payload) {
246
+ // AUDIT FINDING (J51-1, mirrored from `runtime-py/src/bantamkit/eventlog.py`): the
247
+ // `mkdirSync` below can bring a whole `.bantamkit` directory into existence on its own
248
+ // -- `BANTAMKIT_EVENT_LOG=on` with a project store that was never saved to reaches here
249
+ // first -- entirely bypassing `MemoryStore.ensureDirs`, which is the only other place a
250
+ // `.bantamkit` directory gets created. Without this call a store built that way would
251
+ // never get its self-ignoring `.gitignore`. Cheap and idempotent: a no-op unless one of
252
+ // `path`'s ancestors is literally named `.bantamkit`.
253
+ //
254
+ // USER RULING #2 (J51-8b): the ignore file is written only when THIS call is what
255
+ // creates `.bantamkit`, so existence has to be checked BEFORE the mkdir below -- after
256
+ // it, the directory unconditionally exists and the question is unanswerable.
257
+ let bantamkitDir = null;
258
+ for (const parent of pyParents(path)) {
259
+ if (pyName(parent) === '.bantamkit') {
260
+ bantamkitDir = parent;
261
+ break;
262
+ }
263
+ }
264
+ const bantamkitDirExistedBefore = bantamkitDir !== null && pyExists(bantamkitDir);
245
265
  // `pyParent` and not `node:path`'s `dirname`: this module is reached from a Windows
246
266
  // host through the same code, and the reference's `Path(...).parent` is the spelling
247
267
  // that understands a drive letter. `.`'s parent is `.` in both, so a bare filename
248
268
  // makes the directory that already exists rather than reaching for the root.
249
269
  mkdirSync(pyParent(path), { recursive: true });
270
+ if (bantamkitDir !== null) {
271
+ ensureBantamkitGitignore(bantamkitDir, !bantamkitDirExistedBefore);
272
+ }
250
273
  let size = 0;
251
274
  try {
252
275
  size = statSync(path).size;
@@ -1,3 +1,5 @@
1
+ import { keptCli, keptPrefix } from './npminstall.js';
2
+ export { keptCli, keptPrefix };
1
3
  /** The four hosts, in the order they print in `--help`. */
2
4
  export declare const HOSTS: readonly ["claude", "claude-desktop", "copilot", "cursor"];
3
5
  export type Host = (typeof HOSTS)[number];
@@ -21,16 +23,46 @@ export declare function configKey(host: Host): string;
21
23
  export declare function entryFor(host: Host, command: string, args: string[]): Record<string, unknown>;
22
24
  /** Register this server with `host`. Returns the report to print; throws `InstallError`. */
23
25
  export declare function install(host: Host, command: string, args: string[], force?: boolean): string;
26
+ /** The seams `thisCommand` takes, so no test ever reaches npm or the network. */
27
+ export interface CommandOptions {
28
+ /** The install shape. Default: `currentInstall().shape` — never a second detector. */
29
+ readonly shape?: () => string;
30
+ /** Runs an argv and returns `[exit code, output]`. Default: `npminstall.runInstaller`. */
31
+ readonly installer?: (command: string[]) => [number, string];
32
+ /** This process's version. Default: the `package.json` this build ships with. */
33
+ readonly version?: string;
34
+ }
24
35
  /**
25
- * What a host should run to get THIS server.
36
+ * What a host should run to get THIS server: `<absolute node> <absolute dist/cli.js>`.
37
+ *
38
+ * NOT `npx -y bantamkit-mcp` ANY MORE — see the module header for the measurement: with a warm
39
+ * cache and no network that command hangs silently, so a host that launches it offline sees a
40
+ * handshake timeout. Not a bare `node` either, because a GUI host's PATH need not have one, and
41
+ * not a `.bin` shim, whose `#!/usr/bin/env node` fails the same way (measured: exit 127).
42
+ * `process.execPath` is the node binary running THIS process, which is known to work.
43
+ *
44
+ * WHICH `cli.js` DEPENDS ON THE INSTALL SHAPE, and the shape is `currentInstall()`'s answer:
26
45
  *
27
- * `npx -y bantamkit-mcp` and not this process's own path, and the two are not the same
28
- * thing: a global install, an `npx` cache entry and a checkout all have different paths,
29
- * while the published name resolves the same everywhere. `-y` because `npx` historically
30
- * prompts before installing a package it has not seen, and stdin here is the JSON-RPC
31
- * channel — a prompt that ate one frame would look like a server that lost a request.
46
+ * - `ephemeral` (an `npx` cache): this process's own `cli.js` lives in a cache npm may discard,
47
+ * so the recorded one is the KEPT install at `keptPrefix()`. If its manifest already reports
48
+ * this version OR A NEWER ONE and its `cli.js` is there, nothing runs — a second `--install`
49
+ * for another host works offline, and a stale `npx` cache never moves a kept install that
50
+ * `--update` took further back to its own version (J51-9a). Otherwise `npm install --prefix <kept> bantamkit-mcp@<this version>`
51
+ * runs once, through the same spawner `--update` uses, and the `cli.js` is confirmed after.
52
+ * npm creates a missing prefix directory itself (measured, npm 11.6.2), so nothing is made
53
+ * here first and a failed install leaves no empty directory of this module's making.
54
+ * - every other shape (`registry`, `local-file`, `linked`, `checkout`) is already kept, so it
55
+ * is this process's own `dist/cli.js`.
56
+ *
57
+ * Every failure is an `InstallError` thrown BEFORE any host configuration is read or written.
32
58
  */
33
- export declare function thisCommand(): {
59
+ export declare function thisCommand(options?: CommandOptions): {
34
60
  command: string;
35
61
  args: string[];
36
62
  };
63
+ /**
64
+ * `--install <host>` whole: resolve the command (making the kept install if this is an `npx`
65
+ * cache), THEN touch the host. The order is the property: a refusal from npm leaves the host's
66
+ * file byte-unchanged with no backup, because `install` has not been reached.
67
+ */
68
+ export declare function installSelf(host: Host, force?: boolean, options?: CommandOptions): string;
@@ -17,15 +17,29 @@
17
17
  * runtimes write different bytes for the same install.
18
18
  *
19
19
  * WHAT IS RULED DIFFERENT, and it is one field: the COMMAND. This side records
20
- * `npx -y bantamkit-mcp`; the reference records its own console script. The thing installed
21
- * should be the thing that answers, and neither side should send a host looking for the
22
- * other's runtime. `docs/porting.md` carries the row.
20
+ * `<absolute node> <absolute .../dist/cli.js>`; the reference records its own console script.
21
+ * The thing installed should be the thing that answers, and neither side should send a host
22
+ * looking for the other's runtime. `docs/porting.md` carries the row.
23
+ *
24
+ * JOB51 CHANGED THIS SIDE'S COMMAND, AND THE REASON IS A MEASUREMENT. It used to be
25
+ * `npx -y bantamkit-mcp`. On npm 11.6.2, with a WARM cache and the network cut (proxies pointed
26
+ * at a closed port, which leaves the cache key alone), that command — pinned, unpinned or
27
+ * `@latest` — hung silently past 45 s with zero bytes on stdout, so a host reports a handshake
28
+ * timeout. A kept `npm install --prefix` launched as `<abs node> <abs dist/cli.js>` answered in
29
+ * 0.11 s under a GUI-shaped PATH (`/usr/bin:/bin:/usr/sbin:/sbin`), where that install's own
30
+ * `.bin/bantamkit-mcp` shim exits 127 (`env: node: No such file or directory`). The user ruled
31
+ * (2026-09-15): `--install` makes a one-time kept install under `~/.bantamkit`, and the host
32
+ * launches it without the network. See `thisCommand`.
23
33
  */
24
34
  import { spawnSync } from 'node:child_process';
25
35
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
26
36
  import { homedir } from 'node:os';
27
37
  import { dirname, join } from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
39
+ import { currentInstall, Undetermined } from './mcp/identity.js';
40
+ import { compareVersions, keptCli, keptManifest, keptPrefix, PACKAGE, runInstaller, shlexJoin } from './npminstall.js';
28
41
  import { dumpJson, fromJs } from './pyjson.js';
42
+ export { keptCli, keptPrefix };
29
43
  /** The four hosts, in the order they print in `--help`. */
30
44
  export const HOSTS = ['claude', 'claude-desktop', 'copilot', 'cursor'];
31
45
  /** The entry name written into every host. */
@@ -233,15 +247,95 @@ export function install(host, command, args, force = false) {
233
247
  lines.push(` backup : ${copied}`);
234
248
  return lines.join('\n');
235
249
  }
250
+ function ownVersion() {
251
+ return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
252
+ .version;
253
+ }
254
+ /** The kept install's version, or `null` when there is no readable manifest with one. */
255
+ function keptVersion(prefix) {
256
+ try {
257
+ const manifest = JSON.parse(readFileSync(keptManifest(prefix), 'utf8'));
258
+ return typeof manifest.version === 'string' ? manifest.version : null;
259
+ }
260
+ catch {
261
+ return null;
262
+ }
263
+ }
264
+ /** The installer's last non-blank line — stderr's, when npm wrote any, since it is appended last. */
265
+ function lastLine(output) {
266
+ const lines = output.split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== '');
267
+ return lines.length === 0 ? 'it printed nothing' : (lines[lines.length - 1] ?? '');
268
+ }
236
269
  /**
237
- * What a host should run to get THIS server.
270
+ * What a host should run to get THIS server: `<absolute node> <absolute dist/cli.js>`.
271
+ *
272
+ * NOT `npx -y bantamkit-mcp` ANY MORE — see the module header for the measurement: with a warm
273
+ * cache and no network that command hangs silently, so a host that launches it offline sees a
274
+ * handshake timeout. Not a bare `node` either, because a GUI host's PATH need not have one, and
275
+ * not a `.bin` shim, whose `#!/usr/bin/env node` fails the same way (measured: exit 127).
276
+ * `process.execPath` is the node binary running THIS process, which is known to work.
238
277
  *
239
- * `npx -y bantamkit-mcp` and not this process's own path, and the two are not the same
240
- * thing: a global install, an `npx` cache entry and a checkout all have different paths,
241
- * while the published name resolves the same everywhere. `-y` because `npx` historically
242
- * prompts before installing a package it has not seen, and stdin here is the JSON-RPC
243
- * channel — a prompt that ate one frame would look like a server that lost a request.
278
+ * WHICH `cli.js` DEPENDS ON THE INSTALL SHAPE, and the shape is `currentInstall()`'s answer:
279
+ *
280
+ * - `ephemeral` (an `npx` cache): this process's own `cli.js` lives in a cache npm may discard,
281
+ * so the recorded one is the KEPT install at `keptPrefix()`. If its manifest already reports
282
+ * this version OR A NEWER ONE and its `cli.js` is there, nothing runs — a second `--install`
283
+ * for another host works offline, and a stale `npx` cache never moves a kept install that
284
+ * `--update` took further back to its own version (J51-9a). Otherwise `npm install --prefix <kept> bantamkit-mcp@<this version>`
285
+ * runs once, through the same spawner `--update` uses, and the `cli.js` is confirmed after.
286
+ * npm creates a missing prefix directory itself (measured, npm 11.6.2), so nothing is made
287
+ * here first and a failed install leaves no empty directory of this module's making.
288
+ * - every other shape (`registry`, `local-file`, `linked`, `checkout`) is already kept, so it
289
+ * is this process's own `dist/cli.js`.
290
+ *
291
+ * Every failure is an `InstallError` thrown BEFORE any host configuration is read or written.
244
292
  */
245
- export function thisCommand() {
246
- return { command: 'npx', args: ['-y', 'bantamkit-mcp'] };
293
+ export function thisCommand(options = {}) {
294
+ let shape;
295
+ try {
296
+ shape = (options.shape ?? (() => currentInstall().shape))();
297
+ }
298
+ catch (e) {
299
+ if (!(e instanceof Undetermined))
300
+ throw e;
301
+ throw new InstallError(`--install could not tell how this install was made, so it will not guess which copy a host should launch: ${e.message}`);
302
+ }
303
+ const node = process.execPath;
304
+ if (shape !== 'ephemeral') {
305
+ return { command: node, args: [fileURLToPath(new URL('./cli.js', import.meta.url))] };
306
+ }
307
+ const version = options.version ?? ownVersion();
308
+ const prefix = keptPrefix();
309
+ const cli = keptCli(prefix);
310
+ const kept = keptVersion(prefix);
311
+ // NEVER LOWER THE KEPT INSTALL (J51-9a). An `npx` cache can be older than the kept install —
312
+ // `--update` moved the kept install on, and this cache was filled weeks ago — and every host
313
+ // already launches the kept one, so installing THIS version over it would move them all back.
314
+ // A kept version at or above this one is recorded as it is. `compareVersions` is total: a
315
+ // component that is not all digits sorts after every number, so a version it cannot read as
316
+ // numbers counts as newer and is left alone rather than overwritten. A blank one is no version.
317
+ if (kept !== null && kept.trim() !== '' && compareVersions(kept, version) >= 0 && existsSync(cli)) {
318
+ return { command: node, args: [cli] };
319
+ }
320
+ const command = ['npm', 'install', '--prefix', prefix, `${PACKAGE}@${version}`];
321
+ const rendered = shlexJoin(command);
322
+ const [code, output] = (options.installer ?? runInstaller)(command);
323
+ const refused = `could not make the kept install at ${prefix}, so no host configuration was changed: ${rendered} exited ${code}`;
324
+ if (code !== 0)
325
+ throw new InstallError(`${refused}: ${lastLine(output)}`);
326
+ if (!existsSync(cli))
327
+ throw new InstallError(`${refused} but left no ${cli}`);
328
+ return { command: node, args: [cli] };
329
+ }
330
+ /**
331
+ * `--install <host>` whole: resolve the command (making the kept install if this is an `npx`
332
+ * cache), THEN touch the host. The order is the property: a refusal from npm leaves the host's
333
+ * file byte-unchanged with no backup, because `install` has not been reached.
334
+ */
335
+ export function installSelf(host, force = false, options = {}) {
336
+ if (!HOSTS.includes(host)) {
337
+ throw new InstallError(`unknown host '${host}'; choose one of: ${HOSTS.join(', ')}`);
338
+ }
339
+ const { command, args } = thisCommand(options);
340
+ return install(host, command, args, force);
247
341
  }
package/dist/index.d.ts CHANGED
@@ -18,7 +18,7 @@ export { absolutise, blockKey, claimSlot, collapse, dream, formatFixed3, mergeBo
18
18
  export type { DateHit, DreamMerge, DreamResult, SimilarPair, Superseded } from './memory/dream.js';
19
19
  export { CAP_BYTES, DEFAULT_RELATIVE_PATH, defaultPath, encodeRecord, EVENT_LOG_ENV, EventLog, formatTimestamp, resolvePath, SCHEMA_VERSION, } from './eventlog.js';
20
20
  export type { DetailValue } from './eventlog.js';
21
- export { DEFAULT_INDEX_BUDGET, DUPLICATE_JACCARD, jaccard, MemoryBudgetExceeded, MemoryStore, MemoryValidationError, pyCompareLt, pyEqualValue, pyHashKey, pyText, RECALL_MIN_SCORE_RATIO, sortScored, tokens, VALID_TYPES, } from './memory/store.js';
21
+ export { BANTAMKIT_GITIGNORE_TEXT, DEFAULT_INDEX_BUDGET, DUPLICATE_JACCARD, ensureBantamkitGitignore, jaccard, MemoryBudgetExceeded, MemoryStore, MemoryValidationError, pyCompareLt, pyEqualValue, pyHashKey, pyText, RECALL_MIN_SCORE_RATIO, sortScored, tokens, VALID_TYPES, } from './memory/store.js';
22
22
  export type { MemoryStoreOptions, SaveResult, StoreInternals } from './memory/store.js';
23
23
  export { asPyOSError, cmpCodepoint, matchesMd, normcase, pyDecodeUtf8, pyCwd, pyExists, pyExpanduser, pyHome, pyIsAbsolute, pyIsDir, pyIsFile, pyJoin, pyLexists, pyMkdirParents, pyMtimeDate, pyName, PyOSError, pyParent, pyParents, pyReadText, pyReplace, pyRepr, pyResolve, PyRuntimeError, pyScandirNames, PyUnicodeDecodeError, pyAppendText, pyStatIsDir, pySuffix, pyUnlink, pyWithSuffix, pyWriteText, sortedPathNames, sortedPathParts, STRERROR_NAMES, } from './memory/pyfs.js';
24
24
  export { clockIn, clockOut, HISTORY_RING_SIZE, SCHEMA_NAME, status, TERMINAL_UNIT_STATUS, timestamp, } from './shiftwork.js';
package/dist/index.js CHANGED
@@ -9,6 +9,6 @@ export { countFacts, discoverProjectStore, loadGrants, MEMORY_DIR_ENV, PROJECT_S
9
9
  export { layerLabel, Memory, normalizeName, profileStore } from './memory/component.js';
10
10
  export { absolutise, blockKey, claimSlot, collapse, dream, formatFixed3, mergeBodies, mergeDescriptions, mergeLinks, PROFILE_LAYER, PROJECT_LAYER, pyIntDigits, sortedNames, splitBlocks, SUPERSEDED_HEADING, } from './memory/dream.js';
11
11
  export { CAP_BYTES, DEFAULT_RELATIVE_PATH, defaultPath, encodeRecord, EVENT_LOG_ENV, EventLog, formatTimestamp, resolvePath, SCHEMA_VERSION, } from './eventlog.js';
12
- export { DEFAULT_INDEX_BUDGET, DUPLICATE_JACCARD, jaccard, MemoryBudgetExceeded, MemoryStore, MemoryValidationError, pyCompareLt, pyEqualValue, pyHashKey, pyText, RECALL_MIN_SCORE_RATIO, sortScored, tokens, VALID_TYPES, } from './memory/store.js';
12
+ export { BANTAMKIT_GITIGNORE_TEXT, DEFAULT_INDEX_BUDGET, DUPLICATE_JACCARD, ensureBantamkitGitignore, jaccard, MemoryBudgetExceeded, MemoryStore, MemoryValidationError, pyCompareLt, pyEqualValue, pyHashKey, pyText, RECALL_MIN_SCORE_RATIO, sortScored, tokens, VALID_TYPES, } from './memory/store.js';
13
13
  export { asPyOSError, cmpCodepoint, matchesMd, normcase, pyDecodeUtf8, pyCwd, pyExists, pyExpanduser, pyHome, pyIsAbsolute, pyIsDir, pyIsFile, pyJoin, pyLexists, pyMkdirParents, pyMtimeDate, pyName, PyOSError, pyParent, pyParents, pyReadText, pyReplace, pyRepr, pyResolve, PyRuntimeError, pyScandirNames, PyUnicodeDecodeError, pyAppendText, pyStatIsDir, pySuffix, pyUnlink, pyWithSuffix, pyWriteText, sortedPathNames, sortedPathParts, STRERROR_NAMES, } from './memory/pyfs.js';
14
14
  export { clockIn, clockOut, HISTORY_RING_SIZE, SCHEMA_NAME, status, TERMINAL_UNIT_STATUS, timestamp, } from './shiftwork.js';
@@ -1,6 +1,53 @@
1
1
  import { BantamError } from '../errors.js';
2
2
  import type { Fact, FactValue } from './factfile.js';
3
3
  export type { Fact } from './factfile.js';
4
+ /**
5
+ * The exact bytes written into a `.bantamkit/.gitignore` the first time such a directory is
6
+ * brought into existence (user ruling 2026-09-15, `.shiftwork/notes-job51/P0-probes.md`).
7
+ * Both runtimes hold this text byte-for-byte -- a conformance case pins it against the
8
+ * Python literal -- so it is spelled once, here, rather than re-typed at each call site.
9
+ * `runtime-py/src/bantamkit/memory/store.py`'s `BANTAMKIT_GITIGNORE_TEXT` is the reference.
10
+ */
11
+ export declare const BANTAMKIT_GITIGNORE_TEXT: string;
12
+ /**
13
+ * Best-effort, idempotent: give `directory` a self-ignoring `.gitignore` if it is literally
14
+ * named `.bantamkit` AND `created` says THIS CALLER'S OWN mkdir is what just brought it into
15
+ * existence.
16
+ *
17
+ * USER RULING #2 (2026-09-15, after J51-8 measured the fallout of the first rule): the
18
+ * ignore file is written only when bantamkit itself creates the `.bantamkit` directory. An
19
+ * existing `.bantamkit` -- made by an earlier bantamkit, by hand, or checked out from git;
20
+ * with or without a `.gitignore` already in it -- is never given one. That is why `created`
21
+ * is the caller's job, not this function's: only the caller knows whether `.bantamkit`
22
+ * existed immediately before ITS mkdir, because by the time this function runs the directory
23
+ * unconditionally exists either way. (Was: written whenever the ignore file itself was
24
+ * absent, regardless of whether `.bantamkit` predated the call -- which meant deleting the
25
+ * file never stuck, and a store a team already commits would silently start ignoring new
26
+ * fact files after an upgrade. `P0-probes.md` ruling 4.)
27
+ *
28
+ * THE PROPERTY, not the mechanism: whenever a `.bantamkit` directory is brought into
29
+ * existence -- by `MemoryStore.ensureDirs`, or by anything else in this runtime that creates
30
+ * one, such as `EventLog`'s own append-time `mkdirSync` when the event log is on and the
31
+ * project store was never saved to -- this is the one place that decides whether it gets
32
+ * ignored. A second creator that skipped this call, or that got `created` wrong, would leave
33
+ * a `.bantamkit` that git can see (or ignore one it should not), which is the whole bug this
34
+ * closes.
35
+ *
36
+ * NEVER REWRITES. A `.gitignore` that already exists -- whatever its bytes -- is left
37
+ * exactly as it is, kept as a second guard even when `created` is `true`: an operator who
38
+ * deleted it to commit the directory keeps that decision, and this never diffs its own
39
+ * output against what is on disk.
40
+ *
41
+ * NEVER RAISES. Failing to write the ignore file must never be why a fact does not get
42
+ * saved -- the parent write still has to succeed exactly as it does today. An unwritable
43
+ * filesystem, a permissions error, or the directory disappearing under this call are all
44
+ * swallowed, mirroring the reference's `except OSError: pass`.
45
+ *
46
+ * NEVER CREATES `directory` ITSELF. A write into a missing parent fails like any other
47
+ * filesystem error here, so calling this before `directory` exists is a no-op, not a way to
48
+ * bring `.bantamkit` into existence early.
49
+ */
50
+ export declare function ensureBantamkitGitignore(directory: string, created: boolean): void;
4
51
  /** `sorted(VALID_TYPES)` is what the error message interpolates, so the order is load-bearing. */
5
52
  export declare const VALID_TYPES: readonly ["feedback", "project", "reference", "user"];
6
53
  /**
@@ -42,7 +42,67 @@ import { basename } from 'node:path';
42
42
  import { BantamError } from '../errors.js';
43
43
  import { formatFact, parseFrontmatter, pySplit, pyStrip, todayLocal } from './factfile.js';
44
44
  import { PyScalar } from './pyyaml.js';
45
- import { asPyOSError, cmpCodepoint, matchesMd, pyExists, pyJoin, pyLexists, pyMkdirParents, pyMtimeDate, pyName, pyReadText, pyReplace, pyScandirNames, pySuffix, pyUnlink, pyWithSuffix, pyWriteText, PyOSError, sortedPathNames, } from './pyfs.js';
45
+ import { asPyOSError, cmpCodepoint, matchesMd, pyExists, pyJoin, pyLexists, pyMkdirParents, pyMtimeDate, pyName, pyParent, pyReadText, pyReplace, pyScandirNames, pySuffix, pyUnlink, pyWithSuffix, pyWriteText, PyOSError, sortedPathNames, } from './pyfs.js';
46
+ /**
47
+ * The exact bytes written into a `.bantamkit/.gitignore` the first time such a directory is
48
+ * brought into existence (user ruling 2026-09-15, `.shiftwork/notes-job51/P0-probes.md`).
49
+ * Both runtimes hold this text byte-for-byte -- a conformance case pins it against the
50
+ * Python literal -- so it is spelled once, here, rather than re-typed at each call site.
51
+ * `runtime-py/src/bantamkit/memory/store.py`'s `BANTAMKIT_GITIGNORE_TEXT` is the reference.
52
+ */
53
+ export const BANTAMKIT_GITIGNORE_TEXT = '# Created by bantamkit: this directory is local state. Delete this file to commit it.\n' +
54
+ '*\n';
55
+ /**
56
+ * Best-effort, idempotent: give `directory` a self-ignoring `.gitignore` if it is literally
57
+ * named `.bantamkit` AND `created` says THIS CALLER'S OWN mkdir is what just brought it into
58
+ * existence.
59
+ *
60
+ * USER RULING #2 (2026-09-15, after J51-8 measured the fallout of the first rule): the
61
+ * ignore file is written only when bantamkit itself creates the `.bantamkit` directory. An
62
+ * existing `.bantamkit` -- made by an earlier bantamkit, by hand, or checked out from git;
63
+ * with or without a `.gitignore` already in it -- is never given one. That is why `created`
64
+ * is the caller's job, not this function's: only the caller knows whether `.bantamkit`
65
+ * existed immediately before ITS mkdir, because by the time this function runs the directory
66
+ * unconditionally exists either way. (Was: written whenever the ignore file itself was
67
+ * absent, regardless of whether `.bantamkit` predated the call -- which meant deleting the
68
+ * file never stuck, and a store a team already commits would silently start ignoring new
69
+ * fact files after an upgrade. `P0-probes.md` ruling 4.)
70
+ *
71
+ * THE PROPERTY, not the mechanism: whenever a `.bantamkit` directory is brought into
72
+ * existence -- by `MemoryStore.ensureDirs`, or by anything else in this runtime that creates
73
+ * one, such as `EventLog`'s own append-time `mkdirSync` when the event log is on and the
74
+ * project store was never saved to -- this is the one place that decides whether it gets
75
+ * ignored. A second creator that skipped this call, or that got `created` wrong, would leave
76
+ * a `.bantamkit` that git can see (or ignore one it should not), which is the whole bug this
77
+ * closes.
78
+ *
79
+ * NEVER REWRITES. A `.gitignore` that already exists -- whatever its bytes -- is left
80
+ * exactly as it is, kept as a second guard even when `created` is `true`: an operator who
81
+ * deleted it to commit the directory keeps that decision, and this never diffs its own
82
+ * output against what is on disk.
83
+ *
84
+ * NEVER RAISES. Failing to write the ignore file must never be why a fact does not get
85
+ * saved -- the parent write still has to succeed exactly as it does today. An unwritable
86
+ * filesystem, a permissions error, or the directory disappearing under this call are all
87
+ * swallowed, mirroring the reference's `except OSError: pass`.
88
+ *
89
+ * NEVER CREATES `directory` ITSELF. A write into a missing parent fails like any other
90
+ * filesystem error here, so calling this before `directory` exists is a no-op, not a way to
91
+ * bring `.bantamkit` into existence early.
92
+ */
93
+ export function ensureBantamkitGitignore(directory, created) {
94
+ if (pyName(directory) !== '.bantamkit' || !created)
95
+ return;
96
+ const gitignore = pyJoin(directory, '.gitignore');
97
+ if (pyExists(gitignore))
98
+ return;
99
+ try {
100
+ pyWriteText(gitignore, BANTAMKIT_GITIGNORE_TEXT);
101
+ }
102
+ catch {
103
+ /* best-effort: a missing ignore file is not worth a lost fact */
104
+ }
105
+ }
46
106
  /** `sorted(VALID_TYPES)` is what the error message interpolates, so the order is load-bearing. */
47
107
  export const VALID_TYPES = ['feedback', 'project', 'reference', 'user'];
48
108
  /**
@@ -535,6 +595,11 @@ export class MemoryStore {
535
595
  * what lets one conformance case pin this line byte for byte.
536
596
  */
537
597
  ensureDirs() {
598
+ // Checked BEFORE the mkdir below, per user ruling #2: this is the one moment that can
599
+ // tell whether `.bantamkit` already existed. After the mkdir it unconditionally exists
600
+ // either way, so the answer has to be captured now or not at all.
601
+ const bantamkitDir = pyParent(this.root);
602
+ const bantamkitDirExistedBefore = pyExists(bantamkitDir);
538
603
  try {
539
604
  pyMkdirParents(pyJoin(this.root, 'facts'));
540
605
  pyMkdirParents(pyJoin(this.root, 'archive'));
@@ -545,6 +610,7 @@ export class MemoryStore {
545
610
  throw new MemoryValidationError(`memory store could not be created: ${this.root}; the directory is not` +
546
611
  ' there and this filesystem would not make it, so nothing was written');
547
612
  }
613
+ ensureBantamkitGitignore(bantamkitDir, !bantamkitDirExistedBefore);
548
614
  }
549
615
  // ---- ops ----
550
616
  /**
@@ -0,0 +1,54 @@
1
+ /** The npm package this install would upgrade. Divergent by construction (`DISTRIBUTION`). */
2
+ export declare const PACKAGE = "bantamkit-mcp";
3
+ /**
4
+ * Where `--install` keeps the one-time install a host launches: `<homedir>/.bantamkit/mcp`.
5
+ *
6
+ * DEFINED ONCE, HERE, because two flags must agree on it byte for byte: `--install` makes it and
7
+ * records its `cli.js`, and `--update` from an `npx` cache patches it in place. A second spelling
8
+ * of this path would be a second place for the two to disagree about a user's install.
9
+ *
10
+ * `homedir()` and nothing else — `HOME` on POSIX, `USERPROFILE` on Windows — so pointing those at
11
+ * a scratch directory moves it, which is what makes the tests possible.
12
+ */
13
+ export declare function keptPrefix(home?: string): string;
14
+ /** The `dist/cli.js` inside the kept install — what a host is told to run with node. */
15
+ export declare function keptCli(prefix?: string): string;
16
+ /** The `package.json` inside the kept install, whose `version` says whether it is current. */
17
+ export declare function keptManifest(prefix?: string): string;
18
+ /**
19
+ * `shlex.join`: `shlex.quote` each word and space them.
20
+ *
21
+ * Hand-rolled from CPython's own rule — `_find_unsafe = re.compile(r'[^\w@%+=:,./-]', re.ASCII)`
22
+ * — because the rendered command is printed to the operator by `UPDATING`, by `COMMAND_FAILED`
23
+ * and by the `local-file` route, and a prefix path with a space in it is the normal case on
24
+ * macOS and on Windows. Single quotes, with an embedded `'` closed and reopened the way
25
+ * `shlex.quote` does it, so the line can be pasted back into a shell unchanged.
26
+ */
27
+ export declare function shlexJoin(command: readonly string[]): string;
28
+ /**
29
+ * Run the installer, capture what it said, return both.
30
+ *
31
+ * CAPTURED RATHER THAN INHERITED so the report has one shape whether or not anybody is
32
+ * watching, and so a test can inject a substitute and assert on the command WITHOUT a real
33
+ * install ever running. On the `--install` path this also keeps npm's progress off stdout,
34
+ * which is the operator's report and nothing else.
35
+ *
36
+ * THE ONE PLACE THIS IS NOT THE REFERENCE'S BEHAVIOUR: `stderr=STDOUT` genuinely interleaves
37
+ * the two streams in the order they happened, and `spawnSync` has no fd-dup, so the two pipes
38
+ * are concatenated instead — stdout, then stderr. npm writes its progress to stderr and its
39
+ * result to stdout, so an operator reading a failure still gets both, in two blocks rather than
40
+ * one. The installer's own words are never compared across runtimes; the sentences around them
41
+ * are.
42
+ *
43
+ * `shell: true` ON WINDOWS ONLY, the idiom `hostinstall.installViaClaudeCli` established for
44
+ * the same reason: npm is a `npm.cmd` batch shim there and `spawnSync` returns ENOENT for a
45
+ * batch file without a shell. Arguments are quoted because `cmd.exe` gets a string.
46
+ */
47
+ export declare function runInstaller(command: readonly string[]): [number, string];
48
+ /**
49
+ * -1 when the index is ahead, 0 when they agree, 1 when the installed version is ahead.
50
+ *
51
+ * The shorter of the two is padded with numeric zeros, so `0.30` and `0.30.0` agree — which is
52
+ * what a person means by them and what both registries would print for one release.
53
+ */
54
+ export declare function compareVersions(installed: string, latest: string): number;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The npm spawner and the kept-install location, shared by `--install` and `--update`.
3
+ *
4
+ * WHY THIS IS ITS OWN MODULE. `selfupdate.ts` owns the one network request in this runtime, and
5
+ * `test/selfupdate.test.mjs` pins that `cli.ts` is the ONLY module in `src/` importing it. Job51
6
+ * made `--install` spawn npm too (the kept install below), and the brief was to SHARE the spawner
7
+ * rather than write a second one. Moving it here lets `hostinstall.ts` use it without reaching
8
+ * `selfupdate.ts`; `selfupdate.ts` re-exports all three names, so its surface is unchanged.
9
+ *
10
+ * Nothing here touches the network by itself. `runInstaller` spawns whatever argv it is handed,
11
+ * and only the two operator-typed flags hand it one.
12
+ */
13
+ import { spawnSync } from 'node:child_process';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ /** The npm package this install would upgrade. Divergent by construction (`DISTRIBUTION`). */
17
+ export const PACKAGE = 'bantamkit-mcp';
18
+ /**
19
+ * Where `--install` keeps the one-time install a host launches: `<homedir>/.bantamkit/mcp`.
20
+ *
21
+ * DEFINED ONCE, HERE, because two flags must agree on it byte for byte: `--install` makes it and
22
+ * records its `cli.js`, and `--update` from an `npx` cache patches it in place. A second spelling
23
+ * of this path would be a second place for the two to disagree about a user's install.
24
+ *
25
+ * `homedir()` and nothing else — `HOME` on POSIX, `USERPROFILE` on Windows — so pointing those at
26
+ * a scratch directory moves it, which is what makes the tests possible.
27
+ */
28
+ export function keptPrefix(home = homedir()) {
29
+ return join(home, '.bantamkit', 'mcp');
30
+ }
31
+ /** The `dist/cli.js` inside the kept install — what a host is told to run with node. */
32
+ export function keptCli(prefix = keptPrefix()) {
33
+ return join(prefix, 'node_modules', PACKAGE, 'dist', 'cli.js');
34
+ }
35
+ /** The `package.json` inside the kept install, whose `version` says whether it is current. */
36
+ export function keptManifest(prefix = keptPrefix()) {
37
+ return join(prefix, 'node_modules', PACKAGE, 'package.json');
38
+ }
39
+ /**
40
+ * `shlex.join`: `shlex.quote` each word and space them.
41
+ *
42
+ * Hand-rolled from CPython's own rule — `_find_unsafe = re.compile(r'[^\w@%+=:,./-]', re.ASCII)`
43
+ * — because the rendered command is printed to the operator by `UPDATING`, by `COMMAND_FAILED`
44
+ * and by the `local-file` route, and a prefix path with a space in it is the normal case on
45
+ * macOS and on Windows. Single quotes, with an embedded `'` closed and reopened the way
46
+ * `shlex.quote` does it, so the line can be pasted back into a shell unchanged.
47
+ */
48
+ export function shlexJoin(command) {
49
+ return command
50
+ .map((word) => {
51
+ if (word === '')
52
+ return "''";
53
+ if (/^[\w@%+=:,./-]+$/.test(word))
54
+ return word;
55
+ return `'${word.replace(/'/g, `'"'"'`)}'`;
56
+ })
57
+ .join(' ');
58
+ }
59
+ /**
60
+ * Run the installer, capture what it said, return both.
61
+ *
62
+ * CAPTURED RATHER THAN INHERITED so the report has one shape whether or not anybody is
63
+ * watching, and so a test can inject a substitute and assert on the command WITHOUT a real
64
+ * install ever running. On the `--install` path this also keeps npm's progress off stdout,
65
+ * which is the operator's report and nothing else.
66
+ *
67
+ * THE ONE PLACE THIS IS NOT THE REFERENCE'S BEHAVIOUR: `stderr=STDOUT` genuinely interleaves
68
+ * the two streams in the order they happened, and `spawnSync` has no fd-dup, so the two pipes
69
+ * are concatenated instead — stdout, then stderr. npm writes its progress to stderr and its
70
+ * result to stdout, so an operator reading a failure still gets both, in two blocks rather than
71
+ * one. The installer's own words are never compared across runtimes; the sentences around them
72
+ * are.
73
+ *
74
+ * `shell: true` ON WINDOWS ONLY, the idiom `hostinstall.installViaClaudeCli` established for
75
+ * the same reason: npm is a `npm.cmd` batch shim there and `spawnSync` returns ENOENT for a
76
+ * batch file without a shell. Arguments are quoted because `cmd.exe` gets a string.
77
+ */
78
+ export function runInstaller(command) {
79
+ const [program, ...args] = command;
80
+ const win = process.platform === 'win32';
81
+ const quoted = win ? args.map((a) => (/[\s"^&|<>]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a)) : args;
82
+ const done = spawnSync(program ?? '', quoted, { encoding: 'utf8', shell: win });
83
+ if (done.error) {
84
+ // A missing `npm` is not a stack trace: it is a command that failed, and `COMMAND_FAILED`
85
+ // is the sentence that says so and hands back what went wrong. 127 is the shell's own
86
+ // code for "command not found", so the number means something to the person reading it.
87
+ return [127, done.error.message];
88
+ }
89
+ return [done.status ?? 1, `${done.stdout ?? ''}${done.stderr ?? ''}`];
90
+ }
91
+ /**
92
+ * A dotted version as something orderable, deterministically, without claiming PEP 440.
93
+ *
94
+ * THE REFERENCE'S RULE, REPRODUCED EXACTLY, INCLUDING THE PART THAT IS WRONG. Split on `.`; a
95
+ * component that is all digits sorts as a NUMBER, anything else sorts as a STRING after every
96
+ * number in that position. So `0.9.0 < 0.10.0` — the thing a plain string compare gets wrong,
97
+ * and the reason this exists — and `0.31.0 < 0.31.0rc1`, which is WRONG BY SEMVER AND BY PEP
98
+ * 440 and is kept anyway.
99
+ *
100
+ * A CORRECT COMPARISON HERE WOULD BE A DIVERGENCE, NOT AN IMPROVEMENT. `_version_key`'s
101
+ * docstring in the reference gives the reason: bantamkit has never published a prerelease to
102
+ * either registry, and implementing PEP 440 there in order to reproduce it here would be a
103
+ * second, larger thing to keep byte-identical in service of a case neither index can currently
104
+ * return. What matters is that both runtimes are wrong in the SAME direction, which a
105
+ * conformance case can pin and a reader can check. If a prerelease is ever published, this is
106
+ * the function to fix — in both runtimes, in one job.
107
+ *
108
+ * `/^\d+$/` is ASCII-only, where CPython's `str.isdigit()` is not. That narrowing is the safe
109
+ * direction: the strings `str.isdigit()` accepts and `int()` then REFUSES (superscripts, for
110
+ * one) raise in the reference and sort as strings here, and no registry can answer with one.
111
+ */
112
+ function versionKey(version) {
113
+ return version.split('.').map((part) => (/^\d+$/.test(part) ? [0, Number(part), ''] : [1, 0, part]));
114
+ }
115
+ const PAD = [0, 0, ''];
116
+ function comparePart(left, right) {
117
+ if (left[0] !== right[0])
118
+ return left[0] < right[0] ? -1 : 1;
119
+ if (left[1] !== right[1])
120
+ return left[1] < right[1] ? -1 : 1;
121
+ if (left[2] === right[2])
122
+ return 0;
123
+ return left[2] < right[2] ? -1 : 1;
124
+ }
125
+ /**
126
+ * -1 when the index is ahead, 0 when they agree, 1 when the installed version is ahead.
127
+ *
128
+ * The shorter of the two is padded with numeric zeros, so `0.30` and `0.30.0` agree — which is
129
+ * what a person means by them and what both registries would print for one release.
130
+ */
131
+ export function compareVersions(installed, latest) {
132
+ const left = versionKey(installed);
133
+ const right = versionKey(latest);
134
+ const width = Math.max(left.length, right.length);
135
+ for (let i = 0; i < width; i += 1) {
136
+ const order = comparePart(left[i] ?? PAD, right[i] ?? PAD);
137
+ if (order !== 0)
138
+ return order;
139
+ }
140
+ return 0;
141
+ }