@r1z4x/tezgah 1.3.0 → 1.4.0

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.
@@ -56,13 +56,14 @@
56
56
  // (Plugin.trigger does `if (!hook) continue`), so returning a hook that build
57
57
  // lacks is safe and must never be a load-time error.
58
58
  import { closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync,
59
- readFileSync, readSync, readdirSync, realpathSync, rmSync, statSync,
59
+ readFileSync, readdirSync, realpathSync, rmSync, statSync,
60
60
  writeFileSync } from "node:fs"
61
- import { appendFile, mkdir, open, readFile, writeFile } from "node:fs/promises"
61
+ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"
62
62
  import { spawn } from "node:child_process"
63
63
  import { createHash } from "node:crypto"
64
64
  import { homedir, tmpdir } from "node:os"
65
65
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"
66
+ import { fileURLToPath } from "node:url"
66
67
 
67
68
  const HOME = homedir()
68
69
  const CONFIG = join(process.env.XDG_CONFIG_HOME || join(HOME, ".config"), "tezgah")
@@ -1172,7 +1173,7 @@ const DETAIL_MAX = 200
1172
1173
  // agree on the credential itself; they differ only for an astral character
1173
1174
  // inside a quoted named value.
1174
1175
 
1175
- // Evidence ledger, the same JSONL the Python gate and Stop hook read. Written
1176
+ // Evidence ledger, the store the Python gate and Stop hook read. Written
1176
1177
  // per tool call so a "done/tested" claim can be checked against what ran.
1177
1178
  // The bash tool returns `metadata.exit` (the process exit code), so a check's
1178
1179
  // real outcome is available: exit 0 -> verify_ok, non-zero -> verify_fail, and
@@ -1268,7 +1269,7 @@ async function recordEvidence(sessionID, tool, args, result, workspace, cwd,
1268
1269
  // lives where the kill switches do (tezgah_paths.armed), so off it costs two
1269
1270
  // stats and spawns nothing. The process prints the taste note for the first
1270
1271
  // write to this file type (tezgah_taste.write_note), returned to the caller.
1271
- if (kind === "edit" && row.exit !== 1 && off("taste-on")) {
1272
+ if (kind === "edit" && row.exit !== 1 && await off("taste-on")) {
1272
1273
  return await noteTaste(sessionID, row.id, args, cwd)
1273
1274
  }
1274
1275
  }
@@ -1396,67 +1397,257 @@ async function postWrite(sessionID, args, cwd) {
1396
1397
  return { hash: after, changed: before !== after }
1397
1398
  }
1398
1399
 
1399
- // One row appended to a session's ledger, the same JSONL the Python gate and the
1400
- // Stop hook read (hooks/tezgah_integrity.note). Best effort: a write failure is
1401
- // not fatal, and a reader that needs the row to exist simply reads the tail
1402
- // without it rather than letting the action through.
1400
+ // The cache database: `<cache>/tezgah.db`, the SQLite store the Python gate and
1401
+ // the Stop hook write and read (hooks/tezgah_store.py, "the cache database").
1402
+ // A session's evidence rows are the `evidence` rows whose `session` is its
1403
+ // ledger stem, each one the JSON text this file writes, in append order (`n`).
1404
+ // The plugin also writes two of the cache stores beside it: the used-tool marks
1405
+ // (`used`, record) and the once-per-session nudge marks (`nudged`,
1406
+ // oncePerSession). It opens the file itself through node:sqlite with
1407
+ // tezgah_store's settings - the busy timeout, WAL with its switch retried
1408
+ // (`_wal`), CACHE_SCHEMA at CACHE_VERSION (`connect`) - so either side may be
1409
+ // the one that creates it; tests/test_opencode_plugin.py holds the two schemas
1410
+ // equal. A runtime without node:sqlite (node before 22.5, a build that does not
1411
+ // expose it) asks tezgah_store's `evidence`, `mark` and `use` CLI instead, one
1412
+ // process per read or write, and TEZGAH_OPENCODE_NO_SQLITE=1 forces that path.
1403
1413
  //
1404
- // The line is written by one write(2) of the whole row on an O_APPEND handle,
1405
- // which is not the flock the Python writer takes: node core exposes no flock(2)
1406
- // (typeof fs.flock is undefined, and fcntl is not bound either), so the same
1407
- // lock would need a native addon or a helper process, neither of which belongs
1408
- // in a plugin that has to load on a bare host. Same file, one line at a time,
1409
- // not the same lock: the kernel's atomic append for a single write is what
1410
- // serializes this writer against Python's, and on a filesystem where that
1411
- // atomicity is not guaranteed (NFS) a torn line is possible here where the
1412
- // flock would prevent it.
1414
+ // A session may still have the JSONL file this plugin wrote before the store
1415
+ // (`<cache>/evidence/<stem>.jsonl`), and a row the store refuses lands there
1416
+ // (legacyAppend, tezgah_integrity._append). Its import is tezgah_store's
1417
+ // (`import_session`), asked before a read or write whenever the file changed
1418
+ // since the last ask, so its rows stay ahead of the new ones. The CLI path
1419
+ // imports it on every call, as every Python reader does. The other stores'
1420
+ // old files are imported by the first Python process that opens the database
1421
+ // (tezgah_store._import_cache).
1422
+ const CACHE_DB = "tezgah.db"
1423
+ const CACHE_VERSION = 2
1424
+ const DOC_STORES = ["turns", "switches", "harness_drift", "import_crash", "judge_last",
1425
+ "skill_pick", "section_hint", "update_check", "cursor_answer", "taste_learn"]
1426
+ const MARK_STORES = ["gate_inactive", "nudged", "cursor_reinforced"]
1427
+ const CACHE_SCHEMA = `
1428
+ CREATE TABLE IF NOT EXISTS imported (name TEXT NOT NULL, dev INTEGER NOT NULL,
1429
+ ino INTEGER NOT NULL, bytes INTEGER NOT NULL,
1430
+ PRIMARY KEY (name, dev, ino));
1431
+ CREATE TABLE IF NOT EXISTS import_failed (name TEXT PRIMARY KEY, mtime_ns INTEGER NOT NULL,
1432
+ size INTEGER NOT NULL);
1433
+
1434
+ CREATE TABLE IF NOT EXISTS evidence (n INTEGER PRIMARY KEY, session TEXT NOT NULL,
1435
+ kind TEXT, ts INTEGER, row TEXT NOT NULL);
1436
+ CREATE INDEX IF NOT EXISTS evidence_session ON evidence (session, n);
1437
+ CREATE INDEX IF NOT EXISTS evidence_ts ON evidence (ts);
1438
+ ` + DOC_STORES.map((table) => `CREATE TABLE IF NOT EXISTS ${table} ` +
1439
+ "(key TEXT PRIMARY KEY, at REAL NOT NULL, doc TEXT NOT NULL);\n").join("") +
1440
+ MARK_STORES.map((table) => `CREATE TABLE IF NOT EXISTS ${table} ` +
1441
+ "(key TEXT PRIMARY KEY, at REAL NOT NULL);\n").join("") + `
1442
+ CREATE TABLE IF NOT EXISTS used (session TEXT NOT NULL, kind TEXT NOT NULL, at REAL NOT NULL,
1443
+ PRIMARY KEY (session, kind));
1444
+ CREATE TABLE IF NOT EXISTS judge_down (key TEXT PRIMARY KEY, until REAL NOT NULL);
1445
+ CREATE TABLE IF NOT EXISTS lesson_taint (root TEXT NOT NULL, key TEXT NOT NULL,
1446
+ source TEXT, ts INTEGER, PRIMARY KEY (root, key));
1447
+ CREATE TABLE IF NOT EXISTS snapshots (n INTEGER PRIMARY KEY, id TEXT NOT NULL UNIQUE,
1448
+ meta TEXT NOT NULL);
1449
+ CREATE TABLE IF NOT EXISTS replay_runs (n INTEGER PRIMARY KEY, run TEXT NOT NULL UNIQUE,
1450
+ summary TEXT NOT NULL);
1451
+ `
1452
+ const BUSY_MS = 5000
1453
+ const LOCK_POLL_MS = 20
1454
+ const READS = {
1455
+ tail: "SELECT row FROM (SELECT n, row FROM evidence WHERE session = ? " +
1456
+ "ORDER BY n DESC LIMIT ?) ORDER BY n",
1457
+ first: "SELECT row FROM evidence WHERE session = ? ORDER BY n LIMIT 1",
1458
+ kind: "SELECT row FROM evidence WHERE session = ? AND kind = ? ORDER BY n",
1459
+ }
1460
+ let sqliteMemo = null
1461
+ // db path -> { db, id }: one connection per database, reopened when the file
1462
+ // was removed or replaced under this process (doctor, uninstall), as
1463
+ // tezgah_store._cache_db does
1464
+ const cacheDbs = new Map()
1465
+ // stem -> { sig: the legacy file's size and mtime when last asked, done: that
1466
+ // import } (importLegacy)
1467
+ const legacyAsked = new Map()
1468
+
1469
+ function sqlite() {
1470
+ return (sqliteMemo ??= process.env.TEZGAH_OPENCODE_NO_SQLITE === "1"
1471
+ ? Promise.resolve(null) : import("node:sqlite").catch(() => null))
1472
+ }
1473
+
1474
+ function ledgerPath(sessionID) {
1475
+ return join(cacheDir(), "evidence", ledgerStem(sessionID) + ".jsonl")
1476
+ }
1477
+
1478
+ function fileId(path) {
1479
+ try {
1480
+ const st = statSync(path)
1481
+ return st.dev + ":" + st.ino
1482
+ } catch { return null }
1483
+ }
1484
+
1485
+ // The open database, or null when it does not exist and `create` is false (a
1486
+ // read creates nothing). Throws what SQLite throws.
1487
+ function cacheDb(mod, create) {
1488
+ const path = join(cacheDir(), CACHE_DB)
1489
+ const id = fileId(path)
1490
+ const held = cacheDbs.get(path)
1491
+ if (held && held.id === id) return held.db
1492
+ if (held) {
1493
+ cacheDbs.delete(path)
1494
+ try { held.db.close() } catch {}
1495
+ }
1496
+ if (id === null && !create) return null
1497
+ // owner-only before SQLite creates it: a row carries commands and paths
1498
+ // (audit SEC-05); SQLite gives its -wal and -shm files the same mode
1499
+ closeSync(openSync(path, "a", 0o600))
1500
+ const db = new mod.DatabaseSync(path)
1501
+ try {
1502
+ db.exec("PRAGMA busy_timeout = " + BUSY_MS)
1503
+ walMode(db)
1504
+ if (db.prepare("PRAGMA user_version").get().user_version < CACHE_VERSION) {
1505
+ // the write lock first, so a second opener waits on the busy timeout
1506
+ db.exec("BEGIN IMMEDIATE")
1507
+ try {
1508
+ db.exec(CACHE_SCHEMA + "PRAGMA user_version = " + CACHE_VERSION + ";")
1509
+ db.exec("COMMIT")
1510
+ } catch (err) {
1511
+ try { db.exec("ROLLBACK") } catch {}
1512
+ throw err
1513
+ }
1514
+ }
1515
+ db.exec("PRAGMA synchronous = NORMAL")
1516
+ } catch (err) {
1517
+ db.close()
1518
+ throw err
1519
+ }
1520
+ cacheDbs.set(path, { db, id: fileId(path) })
1521
+ return db
1522
+ }
1523
+
1524
+ // The switch to WAL needs the file to itself and SQLite answers "locked" at
1525
+ // once instead of waiting, so it is retried for as long as the busy timeout
1526
+ // would have waited (tezgah_store._wal).
1527
+ function walMode(db) {
1528
+ const deadline = Date.now() + BUSY_MS
1529
+ for (;;) {
1530
+ try {
1531
+ db.exec("PRAGMA journal_mode = WAL")
1532
+ return
1533
+ } catch (err) {
1534
+ if (Date.now() >= deadline) throw err
1535
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_POLL_MS)
1536
+ }
1537
+ }
1538
+ }
1539
+
1540
+ // hooks/tezgah_store.py's CLI (`evidence` for one ledger, `mark` and `use` for
1541
+ // the cache stores), settled by `done` like every collect; a missing script is
1542
+ // a spawn failure.
1543
+ function storeCli(args, done) {
1544
+ const script = hookScript("tezgah_store.py")
1545
+ if (!script) return Promise.resolve(done(null, "", "spawn"))
1546
+ return collect([script, ...args],
1547
+ { stdio: ["ignore", "pipe", "ignore"] }, null, done)
1548
+ }
1549
+
1550
+ // The legacy JSONL of a session into the store (see above), asked again only
1551
+ // when the file's size or mtime changed since the last ask - a Python process
1552
+ // whose row the store refused appends to it (tezgah_integrity._append), and so
1553
+ // does legacyAppend. Every caller that sees one state awaits the same import,
1554
+ // so none reads or writes before it has landed.
1555
+ function importLegacy(sessionID) {
1556
+ const stem = ledgerStem(sessionID)
1557
+ const path = ledgerPath(sessionID)
1558
+ let sig = null
1559
+ try {
1560
+ const st = statSync(path)
1561
+ sig = st.size + ":" + st.mtimeMs
1562
+ } catch {}
1563
+ const held = legacyAsked.get(stem)
1564
+ if (held && held.sig === sig) return held.done
1565
+ const done = sig === null ? Promise.resolve()
1566
+ : storeCli(["evidence", "import", path], () => undefined)
1567
+ legacyAsked.set(stem, { sig, done })
1568
+ return done
1569
+ }
1570
+
1571
+ function parseRows(texts) {
1572
+ const rows = []
1573
+ for (const text of texts) {
1574
+ try {
1575
+ const row = JSON.parse(text)
1576
+ if (row && typeof row === "object" && !Array.isArray(row)) rows.push(row)
1577
+ } catch {}
1578
+ }
1579
+ return rows
1580
+ }
1581
+
1582
+ // One row appended to a session's ledger (hooks/tezgah_integrity._append): one
1583
+ // INSERT in autocommit, so a row is whole or absent, and two writers queue on
1584
+ // the database's lock. A row the store cannot take (busy past its timeout, a
1585
+ // damaged file, a CLI that did not answer) goes to the legacy file instead
1586
+ // (legacyAppend). Best effort: a write failure is not fatal, and a reader that
1587
+ // needs the row to exist simply reads the tail without it rather than letting
1588
+ // the action through.
1413
1589
  async function appendRow(sessionID, row) {
1590
+ let stored = false
1414
1591
  try {
1415
- const dir = join(cacheDir(), "evidence")
1416
- // owner-only, as the Python writer creates them: a ledger row carries
1417
- // commands and paths (audit SEC-05 measured 0644 under the default umask);
1418
- // the modes apply when the dir and file are created
1419
- await mkdir(dir, { recursive: true, mode: 0o700 })
1420
- await appendFile(join(dir, ledgerStem(sessionID) + ".jsonl"),
1421
- JSON.stringify(row) + "\n", { mode: 0o600 })
1592
+ const text = JSON.stringify(row)
1593
+ const mod = await sqlite()
1594
+ if (!mod) {
1595
+ stored = await storeCli(["evidence", "append", ledgerPath(sessionID), text],
1596
+ (code) => code === 0)
1597
+ } else {
1598
+ await importLegacy(sessionID)
1599
+ // the two columns beside the text, as tezgah_store._insert fills them
1600
+ const kind = typeof row.kind === "string" ? row.kind : null
1601
+ const ts = Number.isFinite(row.ts) ? Math.trunc(row.ts) : null
1602
+ cacheDb(mod, true).prepare(
1603
+ "INSERT INTO evidence (session, kind, ts, row) VALUES (?, ?, ?, ?)")
1604
+ .run(ledgerStem(sessionID), kind, ts, text)
1605
+ stored = true
1606
+ }
1422
1607
  } catch {}
1608
+ if (!stored) await legacyAppend(sessionID, row)
1423
1609
  }
1424
1610
 
1425
- // The ledger tail, oldest first, as the Python guard reads it
1426
- // (hooks/tezgah_integrity.events(session_id, tail)). It is read only after a
1427
- // rule has matched, so a normal call pays nothing - but it runs on every gated
1428
- // bash call of a matched one, and the file grows with the session, so the read
1429
- // is bounded like the Python one: backwards in chunks until the last `tail`
1430
- // lines are in hand, never a parse of the whole ledger. A chunk boundary can
1431
- // split the first line; it fails to parse and is dropped, which is the same
1432
- // partial-line tolerance `_tail_lines` has.
1433
- const TAIL_CHUNK = 8192
1434
-
1435
- async function ledgerTail(sessionID, tail) {
1436
- let text = ""
1611
+ // The row the store refused, appended to the session's legacy JSONL file marked
1612
+ // `unlocked`, as hooks/tezgah_integrity._append falls back: a lost `began` row
1613
+ // would turn an honest pass into an orphan, and the mark is what exempts the
1614
+ // pairing. The next import takes it (importLegacy sees the file change).
1615
+ async function legacyAppend(sessionID, row) {
1437
1616
  try {
1438
- const fh = await open(join(cacheDir(), "evidence",
1439
- ledgerStem(sessionID) + ".jsonl"), "r")
1440
- try {
1441
- let pos = (await fh.stat()).size
1442
- while (pos > 0 && (text.match(/\n/g) || []).length <= tail) {
1443
- const step = Math.min(TAIL_CHUNK, pos)
1444
- pos -= step
1445
- const buf = Buffer.alloc(step)
1446
- await fh.read(buf, 0, step, pos)
1447
- text = buf.toString("utf8") + text
1448
- }
1449
- } finally {
1450
- await fh.close()
1617
+ const path = ledgerPath(sessionID)
1618
+ // owner-only, as the Python writer creates them (audit SEC-05)
1619
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 })
1620
+ await appendFile(path, JSON.stringify({ ...row, unlocked: 1 }) + "\n",
1621
+ { mode: 0o600 })
1622
+ } catch {}
1623
+ }
1624
+
1625
+ // A session's rows, oldest first, by one of READS: `tail` with the row count,
1626
+ // `first`, or `kind` with the kind. A row that does not parse is dropped, the
1627
+ // tolerance the old file reader had; nothing found, or a failed read, is [].
1628
+ async function ledgerRead(sessionID, verb, arg) {
1629
+ const more = arg === undefined ? [] : [arg]
1630
+ try {
1631
+ const mod = await sqlite()
1632
+ if (!mod) {
1633
+ return await storeCli(["evidence", verb, ledgerPath(sessionID), ...more.map(String)],
1634
+ (code, out) => (code === 0 ? parseRows(out.split("\n")) : []))
1451
1635
  }
1636
+ await importLegacy(sessionID)
1637
+ const db = cacheDb(mod, false)
1638
+ if (!db) return []
1639
+ return parseRows(db.prepare(READS[verb]).all(ledgerStem(sessionID), ...more)
1640
+ .map((found) => found.row))
1452
1641
  } catch {
1453
1642
  return []
1454
1643
  }
1455
- const rows = []
1456
- for (const line of text.split("\n").filter((l) => l.trim()).slice(-tail)) {
1457
- try { rows.push(JSON.parse(line)) } catch {}
1458
- }
1459
- return rows
1644
+ }
1645
+
1646
+ // The ledger tail, oldest first, as the Python guard reads it
1647
+ // (hooks/tezgah_integrity.events(session_id, tail)). It is read only after a
1648
+ // rule has matched, so a normal call pays nothing.
1649
+ function ledgerTail(sessionID, tail) {
1650
+ return ledgerRead(sessionID, "tail", tail)
1460
1651
  }
1461
1652
 
1462
1653
  // (attempts in the current user turn, attempts over the whole tail, the newest
@@ -2057,55 +2248,43 @@ const SWITCHES = new Set(["adhd-off", "consult-off", "docs-judge-off",
2057
2248
  // serves many sessions, so the id is an argument, not process state.
2058
2249
  const STAND_DOWN = "tezgah-setup --uninstall --full\n"
2059
2250
 
2060
- function off(name, sessionID) {
2061
- return SWITCHES.has(name) && [CONFIG, join(HOME, ".claude")].some(
2062
- (d) => honored(join(d, name), name, sessionID))
2251
+ async function off(name, sessionID) {
2252
+ if (!SWITCHES.has(name)) return false
2253
+ for (const d of [CONFIG, join(HOME, ".claude")]) {
2254
+ if (await honored(join(d, name), name, sessionID)) return true
2255
+ }
2256
+ return false
2063
2257
  }
2064
2258
 
2065
- function honored(path, name, sessionID) {
2259
+ async function honored(path, name, sessionID) {
2066
2260
  // the link's own times too: statSync follows a link, and a link made now to
2067
2261
  // an old file would read as old
2068
2262
  let stats
2069
2263
  try { stats = [statSync(path), lstatSync(path)] } catch { return false }
2070
2264
  if (!sessionID) return true
2071
- const ledger = join(cacheDir(), "evidence", ledgerStem(sessionID) + ".jsonl")
2072
- let since = null
2073
- try {
2074
- const fd = openSync(ledger, "r")
2075
- try {
2076
- const buf = Buffer.alloc(65536)
2077
- const n = readSync(fd, buf, 0, buf.length, 0)
2078
- const ts = JSON.parse(buf.toString("utf8", 0, n).split("\n")[0]).ts
2079
- if (typeof ts === "number") since = ts
2080
- } finally { closeSync(fd) }
2081
- } catch {}
2265
+ const [first] = await ledgerRead(sessionID, "first")
2266
+ const since = typeof first?.ts === "number" ? first.ts : null
2082
2267
  const newest = Math.max(...stats.flatMap((s) => [s.mtimeMs, s.ctimeMs])) / 1000
2083
2268
  if (since === null || newest < since + 1) return true
2084
2269
  try {
2085
2270
  if (name === "pretooluse-off" && readFileSync(path, "utf8") === STAND_DOWN) return true
2086
- for (const line of readFileSync(ledger, "utf8").split("\n")) {
2087
- if (!line.includes('"authorized"')) continue
2088
- try {
2089
- const row = JSON.parse(line)
2090
- if (row.kind === "authorized" && Array.isArray(row.authorized) &&
2091
- row.authorized.includes(name)) return true
2092
- } catch {}
2093
- }
2094
2271
  } catch {}
2095
- return false
2272
+ return (await ledgerRead(sessionID, "kind", "authorized")).some(
2273
+ (row) => Array.isArray(row.authorized) && row.authorized.includes(name))
2096
2274
  }
2097
2275
 
2098
2276
  function slug(p) {
2099
2277
  return p.replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
2100
2278
  }
2101
2279
 
2102
- // The evidence ledger's filename stem, frozen with the Python reader
2103
- // (hooks/tezgah_integrity._slug): the punctuation-collapsed session id cut to
2104
- // 40 characters, then the first 12 hex of sha1 over the raw id. Two ids
2105
- // differing only in punctuation collapse to the same prefix, and without the
2106
- // hash suffix this writer would append one session's rows to another session's
2107
- // ledger - the file the loop guard and the counters then read. The plain
2108
- // `slug()` above still names the used-marks and index files, as before.
2280
+ // The evidence ledger's session key (its `session` column, and the stem of its
2281
+ // legacy file), frozen with the Python reader (hooks/tezgah_integrity._slug):
2282
+ // the punctuation-collapsed session id cut to 40 characters, then the first 12
2283
+ // hex of sha1 over the raw id. Two ids differing only in punctuation collapse
2284
+ // to the same prefix, and without the hash suffix this writer would append one
2285
+ // session's rows to another session's ledger - the rows the loop guard and the
2286
+ // counters then read. The plain `slug()` above still names the used-marks and
2287
+ // index files, as before.
2109
2288
  function ledgerStem(sessionID) {
2110
2289
  const raw = String(sessionID == null ? "" : sessionID) || "nosession"
2111
2290
  return slug(raw).slice(0, 40) + "-" +
@@ -2173,28 +2352,40 @@ function permissionToolArgs(input) {
2173
2352
  return { tool, args }
2174
2353
  }
2175
2354
 
2176
- // The mark's name is hooks/tezgah_gate.nudge_mark's: sha1(id)[:16], so a session
2177
- // nudged on either side is spent on both (test_the_nudge_mark_is_one_key_on_both_sides).
2355
+ // The mark's key is hooks/tezgah_gate.nudge_key's: sha1(id)[:16] in the
2356
+ // `nudged` store, so a session nudged on either side is spent on both
2357
+ // (test_the_nudge_mark_is_one_key_on_both_sides). One INSERT OR IGNORE, so of
2358
+ // two calls exactly one is first; spent when it cannot be written.
2178
2359
  async function oncePerSession(sessionID) {
2179
2360
  const key = createHash("sha1").update(String(sessionID || "nosession")).digest("hex").slice(0, 16)
2180
- const dir = join(cacheDir(), "nudged")
2181
- const mark = join(dir, key)
2182
- if (existsSync(mark)) return false
2183
2361
  try {
2184
- await mkdir(dir, { recursive: true })
2185
- await writeFile(mark, "")
2186
- return true // consume before denying: later greps pass
2362
+ const mod = await sqlite()
2363
+ if (!mod) {
2364
+ return await storeCli(["mark", cacheDir(), "nudged", key],
2365
+ (code, out) => code === 0 && out.trim() === "1")
2366
+ }
2367
+ // consume before denying: later greps pass
2368
+ return cacheDb(mod, true).prepare(
2369
+ "INSERT OR IGNORE INTO nudged (key, at) VALUES (?, ?)").run(key, Date.now() / 1000)
2370
+ .changes === 1
2187
2371
  } catch {
2188
2372
  return false
2189
2373
  }
2190
2374
  }
2191
2375
 
2376
+ // One used-tool kind for the status line, the row hooks/tezgah_context.record
2377
+ // writes: `used` keyed by the plain slug of the session id. Best effort.
2192
2378
  async function record(sessionID, kind) {
2193
2379
  if (!sessionID || !kind) return
2194
2380
  try {
2195
- const dir = join(cacheDir(), "sessions")
2196
- await mkdir(dir, { recursive: true })
2197
- await appendFile(join(dir, slug(sessionID) + ".jsonl"), JSON.stringify({ kind }) + "\n")
2381
+ const mod = await sqlite()
2382
+ if (!mod) {
2383
+ await storeCli(["use", cacheDir(), slug(sessionID), String(kind)], () => undefined)
2384
+ return
2385
+ }
2386
+ cacheDb(mod, true).prepare(
2387
+ "INSERT OR REPLACE INTO used (session, kind, at) VALUES (?, ?, ?)")
2388
+ .run(slug(sessionID), String(kind), Date.now() / 1000)
2198
2389
  } catch {}
2199
2390
  }
2200
2391
 
@@ -2373,16 +2564,26 @@ function captureSnapshot(tool, args, dir, sessionID) {
2373
2564
  { stdio: "ignore" }, null, () => undefined)
2374
2565
  }
2375
2566
 
2376
- // hooks/tezgah_taste.py's own entry point, found beside the bin the snapshot
2567
+ // A hooks/ module's own entry point, or null: beside the bin the snapshot
2377
2568
  // capture runs (the installed bins are links into the checkout, which holds no
2378
- // copy of hooks/ under CONFIG). Best effort, like captureSnapshot: a failure is
2379
- // ignored. It resolves to the taste note the process printed, else undefined.
2380
- function noteTaste(sessionID, id, args, cwd) {
2381
- let script
2569
+ // copy of hooks/ under CONFIG), else beside this file itself, which the install
2570
+ // links from the checkout's hosts/opencode/plugins/.
2571
+ function hookScript(name) {
2572
+ const roots = []
2573
+ try { roots.push(dirname(dirname(realpathSync(CAPTURE_BIN)))) } catch {}
2382
2574
  try {
2383
- script = join(dirname(dirname(realpathSync(CAPTURE_BIN))), "hooks",
2384
- "tezgah_taste.py")
2385
- } catch { return undefined }
2575
+ roots.push(dirname(dirname(dirname(dirname(
2576
+ realpathSync(fileURLToPath(import.meta.url)))))))
2577
+ } catch {}
2578
+ return roots.map((root) => join(root, "hooks", name)).find(existsSync) ?? null
2579
+ }
2580
+
2581
+ // hooks/tezgah_taste.py's own entry point. Best effort, like captureSnapshot: a
2582
+ // failure is ignored. It resolves to the taste note the process printed, else
2583
+ // undefined.
2584
+ function noteTaste(sessionID, id, args, cwd) {
2585
+ const script = hookScript("tezgah_taste.py")
2586
+ if (!script) return undefined
2386
2587
  return collect([script, JSON.stringify({
2387
2588
  session_id: sessionID, id, input: args, cwd, host: "opencode" })],
2388
2589
  { stdio: ["ignore", "pipe", "ignore"] }, null,
@@ -2403,7 +2604,7 @@ export const Tezgah = async ({ directory }) => {
2403
2604
  config: async (cfg) => {
2404
2605
  try {
2405
2606
  if (!cfg || typeof cfg !== "object") return
2406
- if (off("agents-off")) return
2607
+ if (await off("agents-off")) return
2407
2608
  if (!(await rootFor(dir))) return
2408
2609
  const extra = await opencodeAgents(dir)
2409
2610
  const agents = (extra && extra.agent) || {}
@@ -2422,7 +2623,7 @@ export const Tezgah = async ({ directory }) => {
2422
2623
  try {
2423
2624
  if (!output || typeof output !== "object") return
2424
2625
  const sessionID = input?.sessionID || input?.sessionId
2425
- if (off("pretooluse-off", sessionID)) return
2626
+ if (await off("pretooluse-off", sessionID)) return
2426
2627
  if (!(await rootFor(dir))) return
2427
2628
  const { tool, args } = permissionToolArgs(input)
2428
2629
  const sub = String(args.subagent_type || args.agent || "")
@@ -2449,7 +2650,7 @@ export const Tezgah = async ({ directory }) => {
2449
2650
  const core = {}
2450
2651
  try {
2451
2652
  const sessionID = input?.sessionID || input?.sessionId
2452
- if (off("pretooluse-off", sessionID)) return
2653
+ if (await off("pretooluse-off", sessionID)) return
2453
2654
  const base = await rootFor(dir)
2454
2655
  if (!base) return
2455
2656
  const tool = String(input?.tool || "").toLowerCase()
@@ -2458,7 +2659,7 @@ export const Tezgah = async ({ directory }) => {
2458
2659
  const sub = String(args.subagent_type || args.agent || "")
2459
2660
  // the shortcut denials are the gate half of the integrity rule, which
2460
2661
  // `verify-off` removes; attribution and explore are other rules and stay
2461
- const shortcuts = !off("verify-off", sessionID)
2662
+ const shortcuts = !(await off("verify-off", sessionID))
2462
2663
  // The control rule is the Python gate's first (hooks/tezgah_gate.decision),
2463
2664
  // so a shell line CONTROL_CMD says could touch the control plane is put to
2464
2665
  // the core before any rule here: its answer is the whole gate's, in the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r1z4x/tezgah",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "One shared working contract for every AI coding assistant you run: Claude Code, Codex, Cursor, opencode, dsh and oh-my-pi (omp).",
5
5
  "license": "MIT",
6
6
  "repository": {