@r1z4x/tezgah 1.2.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.
- package/CHANGELOG.md +44 -1
- package/MANIFEST +3 -0
- package/bin/tezgah-doctor +75 -18
- package/bin/tezgah-setup +6 -17
- package/bin/tezgah-taste +38 -32
- package/docs/architecture.md +4 -4
- package/docs/clarity-baseline.json +4 -4
- package/docs/contract.md +1 -1
- package/docs/environment.md +1 -0
- package/docs/evidence.md +67 -31
- package/docs/gate.md +21 -21
- package/docs/glossary.md +4 -4
- package/docs/hosts.md +8 -8
- package/docs/judge.md +10 -5
- package/docs/operations.md +53 -17
- package/docs/skills.md +1 -1
- package/docs/status-line.md +7 -7
- package/hooks/tezgah_attest.py +15 -22
- package/hooks/tezgah_context.py +52 -118
- package/hooks/tezgah_gate.py +26 -23
- package/hooks/tezgah_guard.py +9 -11
- package/hooks/tezgah_integrity.py +77 -178
- package/hooks/tezgah_judge.py +25 -43
- package/hooks/tezgah_lessons.py +18 -63
- package/hooks/tezgah_paths.py +18 -28
- package/hooks/tezgah_replay.py +31 -18
- package/hooks/tezgah_shapes.py +4 -4
- package/hooks/tezgah_skill_pick.py +6 -35
- package/hooks/tezgah_snapshot.py +25 -46
- package/hooks/tezgah_store.py +1268 -0
- package/hooks/tezgah_taste.py +59 -6
- package/hooks/tezgah_taste_ledger.py +52 -86
- package/hooks/tezgah_update.py +6 -26
- package/hosts/cursor/hook.py +19 -31
- package/hosts/opencode/plugins/tezgah.js +309 -108
- package/package.json +1 -1
|
@@ -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,
|
|
59
|
+
readFileSync, readdirSync, realpathSync, rmSync, statSync,
|
|
60
60
|
writeFileSync } from "node:fs"
|
|
61
|
-
import { appendFile, mkdir,
|
|
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
|
|
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
|
-
//
|
|
1400
|
-
// Stop hook read (hooks/
|
|
1401
|
-
//
|
|
1402
|
-
//
|
|
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
|
-
//
|
|
1405
|
-
//
|
|
1406
|
-
// (
|
|
1407
|
-
//
|
|
1408
|
-
//
|
|
1409
|
-
//
|
|
1410
|
-
//
|
|
1411
|
-
//
|
|
1412
|
-
|
|
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
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
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
|
|
1426
|
-
//
|
|
1427
|
-
//
|
|
1428
|
-
//
|
|
1429
|
-
|
|
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
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
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
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
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
|
-
|
|
2062
|
-
|
|
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
|
|
2072
|
-
|
|
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
|
|
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
|
|
2103
|
-
// (hooks/tezgah_integrity._slug):
|
|
2104
|
-
// 40 characters, then the first 12
|
|
2105
|
-
//
|
|
2106
|
-
// hash suffix this writer would append one
|
|
2107
|
-
// ledger - the
|
|
2108
|
-
// `slug()` above still names the used-marks and
|
|
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
|
|
2177
|
-
// nudged on either side is spent on both
|
|
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
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
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
|
|
2196
|
-
|
|
2197
|
-
|
|
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/
|
|
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)
|
|
2379
|
-
//
|
|
2380
|
-
function
|
|
2381
|
-
|
|
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
|
-
|
|
2384
|
-
|
|
2385
|
-
} catch {
|
|
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