@veda-hq/dvara 0.0.0-stage → 0.5.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Veda Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,122 @@
1
- # Temporary Holding Version
1
+ # @veda-hq/dvara
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ द्वार, gate. Git hooks for repositories coding agents work in: cheap checks at
4
+ commit, expensive ones once at push.
5
+
6
+ This release holds the runner every hook goes through. Your hook stays a bash
7
+ file in your repository; dvara runs it.
8
+
9
+ ## What it does
10
+
11
+ - **One history row per run.** `dvara run <hook-file> [args…]` runs the hook
12
+ under bash in its own process group and appends a row to
13
+ `<historyDir>/history.jsonl`: kind, who started it (git, a person, a
14
+ background preflight, a benchmark), exit code and status, each stage's start,
15
+ duration and input, the base and tree it was judged against, the commits a
16
+ push updated, and the hook's peak memory.
17
+ - **Signals reach the hook.** Ctrl-C, SIGTERM, SIGHUP and SIGQUIT are forwarded
18
+ to the whole group. A run that was signalled is reported as cancelled, with
19
+ the signal's exit code, even when the hook trapped it and exited 0, so an
20
+ interrupted gate never lets a push through.
21
+ - **Quiet on success.** A hook listed in `quietHooks` (default `pre-commit`)
22
+ prints its warnings and one line when it passes, and everything when it
23
+ fails. The full output is kept in `<historyDir>/<hook>.log`.
24
+ `DVARA_VERBOSE=1` streams it.
25
+ - **Keep-going mode.** For a preflight, a run of the push hook ahead of the
26
+ push: each stage's `exit 1` is recorded and the hook carries on, so one run
27
+ lists every failure.
28
+ - **A watchdog, if you have one.** `watchdog` names a module the supervisor
29
+ attaches to each run; it may stop a hung hook and decide the verdict. Without
30
+ one, or when it fails to load, the hook runs exactly as it would anyway.
31
+
32
+ Every one of these is fail-open: telemetry, quiet output and the watchdog never
33
+ change a hook's verdict.
34
+
35
+ ## A hook under dvara
36
+
37
+ ```bash
38
+ #!/usr/bin/env bash
39
+ # .husky/pre-push, or wherever core.hooksPath points
40
+ if [ "${DVARA_MEASURED_HOOK:-}" != "pre-push" ]; then
41
+ exec node node_modules/@veda-hq/dvara/dist/cli.js run "$0" "$@"
42
+ fi
43
+ source node_modules/@veda-hq/dvara/shell/stages.sh
44
+ if [ "${DVARA_MODE:-}" = preflight ] && [ $# -eq 0 ]; then
45
+ source node_modules/@veda-hq/dvara/shell/keep-going.sh
46
+ fi
47
+
48
+ dvara_stage lint
49
+ pnpm lint || exit 1
50
+ dvara_passed && echo "✅ lint"
51
+
52
+ dvara_stage test
53
+ pnpm test || exit 1
54
+
55
+ if [ "${DVARA_MODE:-}" = preflight ] && [ $# -eq 0 ]; then dvara_keep_going_report; fi
56
+ ```
57
+
58
+ `stages.sh` gives the hook:
59
+
60
+ | Helper | Does |
61
+ | ------------------------------- | ----------------------------------------------------------------------------------------------------------- |
62
+ | `dvara_stage <name>` | marks the start of a stage |
63
+ | `dvara_record <type> <fields…>` | a fact for the row: `stage-input <stage> <kind> <digest>`, `proof <check> <outcome> <ms>`, `proof-reason …` |
64
+ | `dvara_record_refs "$stdin"` | the commits a push updates, from git's ref lines; never the ref names |
65
+ | `dvara_passed` | false once after keep-going mode carried on past a refusal, to guard a "✅" line |
66
+
67
+ `keep-going.sh` adds `dvara_keep_going_held`, `dvara_keep_going_skipped` and
68
+ `dvara_keep_going_report`. `DVARA_KEEP_GOING_NAME` and `DVARA_KEEP_GOING_TITLE`
69
+ rename it in its messages (default "preflight" and "Preflight").
70
+
71
+ ## Configuration
72
+
73
+ `.dvara.json` at the repository's root, or the file `DVARA_CONFIG` names. Every
74
+ field is optional; an unknown field is an error.
75
+
76
+ | Field | Default | Meaning |
77
+ | ------------ | ------------------------------------------------ | -------------------------------------------------------------- |
78
+ | `historyDir` | `.dvara` | where the history, the quiet log and scratch files go |
79
+ | `quietHooks` | `["pre-commit"]` | hooks whose passing output is one line |
80
+ | `watchdog` | `null` | a watchdog module, relative to the root |
81
+ | `baseRefs` | `origin/main`, `origin/develop`, `origin/master` | the refs tried for the base a run is judged against |
82
+ | `stages` | `{}` | each hook's stages in order; `dvara stages <hook>` prints them |
83
+
84
+ The environment it reads: `DVARA_MODE=preflight` (a preflight's rows get kind
85
+ `preflight`), `DVARA_BACKGROUND=1` (started detached), `DVARA_MEASUREMENT_CONTEXT=manual-benchmark`,
86
+ `DVARA_BOOTSTRAP_SECONDS` (seconds the hook spent selecting its runtime before
87
+ starting dvara, counted as a stage), `DVARA_VERBOSE=1`. It sets
88
+ `DVARA_MEASURED_HOOK`, `DVARA_STAGE_FILE` and `DVARA_RECORD_FILE` for the hook.
89
+
90
+ ## A watchdog module
91
+
92
+ ```ts
93
+ export function createWatchdog(options: {
94
+ rootPid;
95
+ stageFile;
96
+ hangsDir;
97
+ runId;
98
+ config;
99
+ }): {
100
+ disarm(): void;
101
+ stop(): void;
102
+ summary(): { mode: string };
103
+ enforced(): boolean;
104
+ };
105
+ export function resolveWatchdogConfig(
106
+ env,
107
+ platform,
108
+ perfLevels: string[],
109
+ ): unknown;
110
+ export function exitDisposition(
111
+ summary,
112
+ ): { code: number; status: string } | null;
113
+ ```
114
+
115
+ ## Commands
116
+
117
+ ```sh
118
+ dvara run <hook-file> [hook args…] # run a git hook under the supervisor
119
+ dvara stages <hook> # the stages .dvara.json lists for it
120
+ dvara shell <stages|keep-going> # the path of a shell helper
121
+ dvara --version
122
+ ```
package/dist/cli.js ADDED
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+ // dvara: git hooks with cheap checks at commit and expensive ones once at push.
3
+ import { readFileSync, realpathSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { loadConfig } from "./config.js";
7
+ import { supervise } from "./supervisor.js";
8
+ export const USAGE = [
9
+ "usage: dvara run <hook-file> [hook args…] run a git hook under the supervisor",
10
+ " dvara stages <hook> the hook stages the config lists, one per line",
11
+ " dvara shell <stages|keep-going> the path of a shell helper to source",
12
+ " dvara --version",
13
+ ].join("\n");
14
+ const SHELL_HELPERS = new Set(["stages", "keep-going"]);
15
+ export const ownVersion = () => {
16
+ const pkg = join(import.meta.dirname, "..", "package.json");
17
+ return JSON.parse(readFileSync(pkg, "utf8")).version;
18
+ };
19
+ /** The path of a shell helper this package ships. */
20
+ export const shellHelper = (name) => resolve(import.meta.dirname, "..", "shell", `${name}.sh`);
21
+ /** Runs one command; resolves with the process's exit code. */
22
+ export const run = async (argv, context) => {
23
+ const { io, cwd, env } = context;
24
+ const [command, ...rest] = argv;
25
+ if (command === "--version") {
26
+ io.out(ownVersion());
27
+ return 0;
28
+ }
29
+ if (command === "shell" &&
30
+ rest.length === 1 &&
31
+ SHELL_HELPERS.has(rest[0] ?? "")) {
32
+ io.out(shellHelper(rest[0] ?? ""));
33
+ return 0;
34
+ }
35
+ if ((command === "run" && rest.length > 0) ||
36
+ (command === "stages" && rest.length === 1)) {
37
+ // Git runs every hook at the top of the working tree.
38
+ let config;
39
+ try {
40
+ config = loadConfig(cwd, env);
41
+ }
42
+ catch (error) {
43
+ io.err(error.message);
44
+ return 2;
45
+ }
46
+ if (command === "stages") {
47
+ for (const name of config.stages[rest[0] ?? ""] ?? [])
48
+ io.out(name);
49
+ return 0;
50
+ }
51
+ return (context.supervise ?? supervise)(rest, {
52
+ ...context.options,
53
+ root: cwd,
54
+ config,
55
+ env,
56
+ });
57
+ }
58
+ io.err(USAGE);
59
+ return 2;
60
+ };
61
+ /** Whether this module is the script node was asked to run, through any symlink. */
62
+ export const isEntry = (entry, moduleUrl) => {
63
+ if (!entry)
64
+ return false;
65
+ try {
66
+ return (realpathSync(resolve(entry)) === realpathSync(fileURLToPath(moduleUrl)));
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ };
72
+ /** The process's own output, one line per call. */
73
+ export const processIo = {
74
+ out: (line) => process.stdout.write(`${line}\n`),
75
+ err: (line) => process.stderr.write(`${line}\n`),
76
+ };
77
+ if (isEntry(process.argv[1], import.meta.url)) {
78
+ process.exitCode = await run(process.argv.slice(2), {
79
+ io: processIo,
80
+ cwd: process.cwd(),
81
+ env: process.env,
82
+ });
83
+ }
package/dist/config.js ADDED
@@ -0,0 +1,76 @@
1
+ // A repository's dvara settings: `.dvara.json` at its root, or the file
2
+ // DVARA_CONFIG names (relative to the root). Every field is optional.
3
+ import { readFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ export const DEFAULTS = {
6
+ historyDir: ".dvara",
7
+ quietHooks: ["pre-commit"],
8
+ watchdog: null,
9
+ baseRefs: ["origin/main", "origin/develop", "origin/master"],
10
+ stages: {},
11
+ };
12
+ export const CONFIG_FILE = ".dvara.json";
13
+ const strings = (value, field) => {
14
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string"))
15
+ throw new Error(`${field} must be a list of strings`);
16
+ return value;
17
+ };
18
+ /** The settings in `text` over the defaults; a field of the wrong type is an error, an unknown one too. */
19
+ export const parseConfig = (text) => {
20
+ const raw = JSON.parse(text);
21
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
22
+ throw new Error("the config must be an object");
23
+ const entries = raw;
24
+ const known = new Set(Object.keys(DEFAULTS));
25
+ for (const key of Object.keys(entries))
26
+ if (!known.has(key))
27
+ throw new Error(`unknown field ${key}`);
28
+ const config = { ...DEFAULTS };
29
+ if ("historyDir" in entries) {
30
+ if (typeof entries.historyDir !== "string" || entries.historyDir === "")
31
+ throw new Error("historyDir must be a non-empty string");
32
+ config.historyDir = entries.historyDir;
33
+ }
34
+ if ("quietHooks" in entries)
35
+ config.quietHooks = strings(entries.quietHooks, "quietHooks");
36
+ if ("watchdog" in entries) {
37
+ if (entries.watchdog !== null && typeof entries.watchdog !== "string")
38
+ throw new Error("watchdog must be a path or null");
39
+ config.watchdog = entries.watchdog;
40
+ }
41
+ if ("baseRefs" in entries)
42
+ config.baseRefs = strings(entries.baseRefs, "baseRefs");
43
+ if ("stages" in entries) {
44
+ const stages = entries.stages;
45
+ if (typeof stages !== "object" || stages === null || Array.isArray(stages))
46
+ throw new Error("stages must map each hook to its stage names");
47
+ const out = {};
48
+ for (const [hook, names] of Object.entries(stages))
49
+ out[hook] = strings(names, `stages.${hook}`);
50
+ config.stages = out;
51
+ }
52
+ return config;
53
+ };
54
+ /**
55
+ * The settings for `root`. No file is the defaults. A file that cannot be
56
+ * parsed is an error: a hook that silently ran on the defaults would write its
57
+ * history somewhere nobody reads.
58
+ */
59
+ export const loadConfig = (root, env) => {
60
+ const path = resolve(root, env.DVARA_CONFIG || CONFIG_FILE);
61
+ let text;
62
+ try {
63
+ text = readFileSync(path, "utf8");
64
+ }
65
+ catch (error) {
66
+ if (error.code === "ENOENT" && !env.DVARA_CONFIG)
67
+ return DEFAULTS;
68
+ throw new Error(`dvara: cannot read ${path}: ${error.message}`);
69
+ }
70
+ try {
71
+ return parseConfig(text);
72
+ }
73
+ catch (error) {
74
+ throw new Error(`dvara: ${path}: ${error.message}`);
75
+ }
76
+ };
@@ -0,0 +1,67 @@
1
+ // Who started a run and on what machine: facts for the history row only.
2
+ import { execFileSync } from "node:child_process";
3
+ const run = (file, args) => execFileSync(file, args, { encoding: "utf8", timeout: 5000 });
4
+ /**
5
+ * A preflight runs the pre-push hook ahead of a push but is not one: its rows
6
+ * get their own kind, so a reader of push rows never counts it. Git always
7
+ * passes a real push its remote, so one that inherited the mode stays a push.
8
+ */
9
+ export const hookKind = (hookName, args, env) => hookName === "pre-push" && env.DVARA_MODE === "preflight" && args.length === 0
10
+ ? "preflight"
11
+ : hookName;
12
+ /** Parent of each pid in a `ps -axo pid=,ppid=,comm=` table. */
13
+ export const processTable = (text) => {
14
+ const parents = new Map();
15
+ for (const line of text.trim().split("\n")) {
16
+ const match = /^(\d+)\s+(\d+)\s+(.+)$/.exec(line.trim());
17
+ if (!match)
18
+ continue;
19
+ const pid = Number(match[1]);
20
+ const parent = Number(match[2]);
21
+ // A digit run too long for a number is no process.
22
+ if (!Number.isFinite(pid) || !Number.isFinite(parent))
23
+ continue;
24
+ parents.set(pid, { parent, command: match[3] ?? "" });
25
+ }
26
+ return parents;
27
+ };
28
+ /**
29
+ * Who started this run: a benchmark, a background preflight, git, or a person
30
+ * (a parent chain that ends at init without passing git).
31
+ */
32
+ export const invocationContext = (kind, env, ppid, exec = run) => {
33
+ if (env.DVARA_MEASUREMENT_CONTEXT === "manual-benchmark")
34
+ return "manual-benchmark";
35
+ // A detached background preflight's parent chain ends at init.
36
+ if (kind === "preflight" && env.DVARA_BACKGROUND === "1")
37
+ return "background";
38
+ try {
39
+ const parents = processTable(exec("ps", ["-axo", "pid=,ppid=,comm="]));
40
+ const seen = new Set();
41
+ let pid = ppid;
42
+ for (let entry = parents.get(pid); entry && !seen.has(pid); entry = parents.get(pid)) {
43
+ seen.add(pid);
44
+ if (/(?:^|\/)git$/.test(entry.command))
45
+ return "git-hook";
46
+ pid = entry.parent;
47
+ }
48
+ return pid <= 1 ? "manual" : "unknown";
49
+ }
50
+ catch {
51
+ return "unknown";
52
+ }
53
+ };
54
+ /** The CPU's performance-level names (macOS only), for a watchdog's mode. */
55
+ export const perfLevelNames = (platform, exec = run) => {
56
+ if (platform !== "darwin")
57
+ return [];
58
+ try {
59
+ const levels = Number(exec("sysctl", ["-n", "hw.nperflevels"]));
60
+ if (!Number.isFinite(levels))
61
+ return [];
62
+ return Array.from({ length: levels }, (_, i) => exec("sysctl", ["-n", `hw.perflevel${i}.name`]).trim());
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ };
package/dist/quiet.js ADDED
@@ -0,0 +1,31 @@
1
+ // Quiet on success. A passing commit hook that prints thirty lines of progress
2
+ // costs an agent session those lines on every later request, and says nothing
3
+ // one line cannot. The supervisor holds a quiet hook's output: on failure it is
4
+ // shown in full; on success it becomes the hook's warnings plus one summary
5
+ // line, and the full output is kept in a log. DVARA_VERBOSE=1 streams it.
6
+ /** Whether this run's output is held rather than streamed. */
7
+ export const isQuiet = (kind, quietHooks, env) => quietHooks.includes(kind) && env.DVARA_VERBOSE !== "1";
8
+ /**
9
+ * The warnings a passing run still shows: each line starting with ⚠ and the
10
+ * indented lines that continue it. A warning qualifies a pass; hiding it would
11
+ * turn the pass into an unqualified one.
12
+ */
13
+ export const successNotices = (text) => {
14
+ const kept = [];
15
+ let inNotice = false;
16
+ for (const line of text.split("\n")) {
17
+ if (line.trimStart().startsWith("⚠")) {
18
+ inNotice = true;
19
+ kept.push(line);
20
+ }
21
+ else if (inNotice && /^\s+\S/.test(line)) {
22
+ kept.push(line);
23
+ }
24
+ else {
25
+ inNotice = false;
26
+ }
27
+ }
28
+ return kept;
29
+ };
30
+ /** The one line a passing quiet run prints. */
31
+ export const successLine = (kind, durationMs, logPath) => `✅ ${kind} passed in ${(durationMs / 1000).toFixed(1)}s (full output: ${logPath})`;
@@ -0,0 +1,96 @@
1
+ // What a supervised hook tells its supervisor while it runs: stage markers
2
+ // (`dvara_stage`) and records (`dvara_record`), one line each in files the
3
+ // supervisor names. Parsed here into the history row's fields.
4
+ /**
5
+ * A duration field. Empty is unmeasured, not zero; text that is not a finite
6
+ * number is null too, as JSON would have written NaN and Infinity.
7
+ */
8
+ export const recordedMs = (text) => {
9
+ if (!text)
10
+ return null;
11
+ const ms = Number(text);
12
+ return Number.isFinite(ms) ? ms : null;
13
+ };
14
+ /**
15
+ * Records, one tab-separated line each: `refs-source <stdin|none>`,
16
+ * `ref <local> <remote>`, `stage-input <stage> <kind> <digest>`,
17
+ * `proof <check> <outcome> <ms>`, and
18
+ * `proof-reason <check> <reason> <waitMs> <executionMs> <previousExecutionMs>`.
19
+ * Anything else is ignored.
20
+ */
21
+ export const parseRecords = (text) => {
22
+ const declared = new Map();
23
+ const proof = [];
24
+ const proofDiagnostics = [];
25
+ if (text === null)
26
+ return { pushed: null, declared, proof, proofDiagnostics };
27
+ const pushed = { source: "none", refs: [] };
28
+ for (const line of text.split("\n").filter(Boolean)) {
29
+ const [type, ...f] = line.split("\t");
30
+ if (type === "refs-source")
31
+ pushed.source = f[0] ?? "";
32
+ else if (type === "ref")
33
+ pushed.refs.push({ local: f[0] ?? "", remote: f[1] ?? "" });
34
+ else if (type === "stage-input")
35
+ declared.set(f[0] ?? "", { kind: f[1] ?? "", digest: f[2] ?? "" });
36
+ else if (type === "proof") {
37
+ const ms = Number(f[2]);
38
+ proof.push({
39
+ check: f[0] ?? "",
40
+ outcome: f[1] ?? "",
41
+ ms: Number.isFinite(ms) ? ms : null,
42
+ });
43
+ }
44
+ else if (type === "proof-reason")
45
+ proofDiagnostics.push({
46
+ check: f[0] ?? "",
47
+ reason: f[1] ?? "",
48
+ waitMs: recordedMs(f[2]),
49
+ executionMs: recordedMs(f[3]),
50
+ previousExecutionMs: recordedMs(f[4]),
51
+ });
52
+ }
53
+ return { pushed, declared, proof, proofDiagnostics };
54
+ };
55
+ /**
56
+ * Stage markers, `<seconds>\t<name>` per line (bash's `$SECONDS`), offset by
57
+ * the time the hook spent choosing its runtime before the supervisor started,
58
+ * which becomes a stage of its own. A marker whose time is not a number stays
59
+ * NaN: JSON writes it as null, which voids its neighbours' durations rather
60
+ * than inventing them.
61
+ */
62
+ export const parseStages = (text, bootstrapMs) => {
63
+ const stages = (text ?? "")
64
+ .trim()
65
+ .split("\n")
66
+ .filter(Boolean)
67
+ .map((line) => {
68
+ const [seconds, name = ""] = line.split("\t");
69
+ const at = Number(seconds);
70
+ return {
71
+ name,
72
+ startMs: Number.isFinite(at) ? bootstrapMs + at * 1000 : Number.NaN,
73
+ };
74
+ });
75
+ if (bootstrapMs)
76
+ stages.unshift({ name: "runtime-selection", startMs: 0 });
77
+ return stages;
78
+ };
79
+ /** Resident memory (KiB) and process count of `rootPid`'s tree, from a `ps -axo pid=,ppid=,rss=` table. */
80
+ export const treeUsage = (table, rootPid) => {
81
+ const rows = table
82
+ .trim()
83
+ .split("\n")
84
+ .map((row) => row.trim().split(/\s+/).map(Number));
85
+ const ids = new Set([rootPid]);
86
+ // A Set visits what is added while iterating it: a breadth-first walk.
87
+ for (const id of ids)
88
+ for (const [pid, ppid] of rows)
89
+ if (ppid === id && pid !== undefined)
90
+ ids.add(pid);
91
+ let rssKiB = 0;
92
+ for (const [pid, , rss] of rows)
93
+ if (pid !== undefined && ids.has(pid))
94
+ rssKiB += rss ?? 0;
95
+ return { rssKiB, processes: ids.size };
96
+ };
@@ -0,0 +1,371 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ // The hook supervisor: runs a git hook under bash, forwards the terminal's
10
+ // signals to it, and writes one history row per run. It never changes the
11
+ // hook's verdict except to report a signalled run as cancelled; every failure
12
+ // of its own (telemetry, watchdog, quiet output) is fail-open.
13
+ import { execFileSync, spawn, spawnSync, } from "node:child_process";
14
+ import { createHash, randomUUID } from "node:crypto";
15
+ import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
16
+ import { cpus, totalmem } from "node:os";
17
+ import { relative, resolve } from "node:path";
18
+ import { pathToFileURL } from "node:url";
19
+ import { hookKind, invocationContext, perfLevelNames } from "./context.js";
20
+ import { isQuiet, successLine, successNotices } from "./quiet.js";
21
+ import { parseRecords, parseStages, treeUsage } from "./records.js";
22
+ /** Exit code per signal, shared by the pre-launch path and the exit handler. */
23
+ export const SIGNAL_EXIT_CODES = {
24
+ SIGINT: 130,
25
+ SIGTERM: 143,
26
+ SIGHUP: 129,
27
+ SIGQUIT: 131,
28
+ };
29
+ const FORWARDED = Object.keys(SIGNAL_EXIT_CODES);
30
+ // A hung `ps` must not hang the supervisor that exists to report hangs.
31
+ const psTable = () => execFileSync("ps", ["-axo", "pid=,ppid=,rss="], {
32
+ encoding: "utf8",
33
+ timeout: 10_000,
34
+ });
35
+ const readOrNull = (path) => {
36
+ try {
37
+ return readFileSync(path, "utf8");
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ };
43
+ const importWatchdog = async (path) => (await import(__rewriteRelativeImportExtension(pathToFileURL(path).href)));
44
+ /** What a run is judged against, read once before the hook starts. */
45
+ export const judgedAgainst = (root, env, baseRefs) => {
46
+ const git = (args) => {
47
+ try {
48
+ // A fact git cannot give is null; its complaint is not the hook's output.
49
+ return execFileSync("git", args, {
50
+ cwd: root,
51
+ env,
52
+ stdio: "pipe",
53
+ }).toString();
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ };
59
+ const revision = git(["rev-parse", "HEAD"]);
60
+ // A detached HEAD abbreviates to the literal "HEAD", which is no branch.
61
+ const branchName = git(["rev-parse", "--abbrev-ref", "HEAD"])?.trim();
62
+ const branch = !branchName || branchName === "HEAD" ? null : branchName;
63
+ const diff = revision === null ? null : git(["diff", "--binary", "HEAD"]);
64
+ // Diagnostic identity only: it never authorizes reusing a result.
65
+ const fingerprint = revision === null || diff === null
66
+ ? null
67
+ : createHash("sha256").update(revision).update(diff).digest("hex");
68
+ // The merge-base is taken with the captured commit, not the ref, so a peer's
69
+ // fetch between the two calls cannot pair two versions of the base. Exit 1
70
+ // is "no common ancestor", a real state; any other failure is unknown.
71
+ const mergeBaseOf = (commit) => {
72
+ const result = spawnSync("git", ["merge-base", commit, "HEAD"], {
73
+ cwd: root,
74
+ env,
75
+ encoding: "utf8",
76
+ });
77
+ if (result.status === 0)
78
+ return result.stdout.trim() || null;
79
+ return result.status === 1 ? "none" : null;
80
+ };
81
+ let base = null;
82
+ for (const ref of baseRefs) {
83
+ const commit = git([
84
+ "rev-parse",
85
+ "--verify",
86
+ "--quiet",
87
+ `${ref}^{commit}`,
88
+ ])?.trim();
89
+ if (commit) {
90
+ base = { ref, commit, mergeBase: mergeBaseOf(commit) };
91
+ break;
92
+ }
93
+ }
94
+ const tree = git(["rev-parse", "HEAD^{tree}"])?.trim() || null;
95
+ const untracked = git(["ls-files", "--others", "--exclude-standard"]);
96
+ const clean = diff === null || untracked === null
97
+ ? null
98
+ : diff === "" && untracked === "";
99
+ return { branch, fingerprint, base, tree, clean };
100
+ };
101
+ /**
102
+ * Run `hook` under supervision and resolve with the exit code git must see.
103
+ * One row per run goes to `<root>/<historyDir>/history.jsonl`.
104
+ */
105
+ export const supervise = async ([hook, ...args], options) => {
106
+ if (hook === undefined)
107
+ throw new Error("supervise: no hook given");
108
+ const { root, config, env = process.env, signals = process, exit = (code) => process.exit(code), platform = process.platform, sampleIntervalMs = 5000, drainLimitMs = 2000, ppid = process.ppid, loadWatchdog = importWatchdog, stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), processTable = psTable, } = options;
109
+ const dir = resolve(root, config.historyDir);
110
+ try {
111
+ mkdirSync(dir, { recursive: true });
112
+ }
113
+ catch {
114
+ /* telemetry cannot block the hook */
115
+ }
116
+ const id = randomUUID();
117
+ const events = resolve(dir, `${id}.stages`);
118
+ const records = resolve(dir, `${id}.records`);
119
+ // The seconds the hook spent selecting its runtime before it started this
120
+ // supervisor; unset, junk and negative count as none.
121
+ const bootstrapSeconds = Number(env.DVARA_BOOTSTRAP_SECONDS);
122
+ const bootstrapMs = Math.max(0, Number.isFinite(bootstrapSeconds) ? bootstrapSeconds : 0) *
123
+ 1000;
124
+ const started = Date.now() - bootstrapMs;
125
+ const hookName = hook.split("/").at(-1) ?? hook;
126
+ const kind = hookKind(hookName, args, env);
127
+ const invocation = invocationContext(kind, env, ppid);
128
+ // The handlers go in BEFORE the hook is spawned and before the first await.
129
+ // The child is detached, in its own process group, so it does not receive
130
+ // the terminal's signals: forwarding them is the only thing that stops it.
131
+ // Registered after the spawn, a signal in between kills this supervisor by
132
+ // default instead, writing no row and orphaning the hook. Under load that
133
+ // window opens wide.
134
+ let child;
135
+ let watchdog;
136
+ let signal = null;
137
+ const listeners = [];
138
+ for (const name of FORWARDED) {
139
+ const listener = () => {
140
+ signal = name;
141
+ // A person cancelled the run: the watchdog must neither act on nor relabel it.
142
+ watchdog?.disarm();
143
+ if (child?.pid === undefined) {
144
+ // Nothing has run, so there is no group to signal and no row to write.
145
+ exit(SIGNAL_EXIT_CODES[name] ?? 1);
146
+ return;
147
+ }
148
+ try {
149
+ process.kill(-child.pid, name);
150
+ }
151
+ catch (error) {
152
+ if (error.code !== "ESRCH") {
153
+ stderr(`Process-group signal forwarding failed: ${error.code}; forwarding to the hook directly.\n`);
154
+ child.kill(name);
155
+ }
156
+ }
157
+ };
158
+ listeners.push([name, listener]);
159
+ signals.on(name, listener);
160
+ }
161
+ // A settled run answers no more signals: each run removes only its own.
162
+ const release = () => {
163
+ for (const [name, listener] of listeners)
164
+ signals.off(name, listener);
165
+ };
166
+ const { branch, fingerprint, base, tree, clean } = judgedAgainst(root, env, config.baseRefs);
167
+ let watchdogModule = null;
168
+ if (config.watchdog) {
169
+ try {
170
+ watchdogModule = await loadWatchdog(resolve(root, config.watchdog));
171
+ }
172
+ catch {
173
+ /* the watchdog is a safety net, never a prerequisite for running the hook */
174
+ }
175
+ }
176
+ // Signalled while the watchdog loaded: `exit` has ended the CLI already.
177
+ if (signal) {
178
+ release();
179
+ return SIGNAL_EXIT_CODES[signal] ?? 1;
180
+ }
181
+ const quiet = isQuiet(kind, config.quietHooks, env);
182
+ const held = [];
183
+ const spawned = spawn("bash", [hook, ...args], {
184
+ cwd: root,
185
+ // stdin stays inherited: pre-push reads its refs from it.
186
+ stdio: quiet ? ["inherit", "pipe", "pipe"] : "inherit",
187
+ detached: true,
188
+ env: {
189
+ ...env,
190
+ DVARA_MEASURED_HOOK: hookName,
191
+ DVARA_STAGE_FILE: events,
192
+ DVARA_RECORD_FILE: records,
193
+ },
194
+ });
195
+ child = spawned;
196
+ if (quiet)
197
+ for (const stream of [spawned.stdout, spawned.stderr])
198
+ stream?.on("data", (chunk) => held.push(chunk));
199
+ if (watchdogModule && spawned.pid !== undefined) {
200
+ try {
201
+ watchdog = watchdogModule.createWatchdog({
202
+ rootPid: spawned.pid,
203
+ stageFile: events,
204
+ hangsDir: resolve(dir, "hangs"),
205
+ runId: id,
206
+ config: watchdogModule.resolveWatchdogConfig(env, platform, perfLevelNames(platform)),
207
+ });
208
+ }
209
+ catch {
210
+ watchdog = undefined;
211
+ }
212
+ }
213
+ let peakTreeRssKiB = 0;
214
+ let peakProcesses = 0;
215
+ let resourceSamples = 0;
216
+ const sample = () => {
217
+ try {
218
+ const table = processTable();
219
+ const usage = treeUsage(table, spawned.pid ?? -1);
220
+ peakTreeRssKiB = Math.max(peakTreeRssKiB, usage.rssKiB);
221
+ peakProcesses = Math.max(peakProcesses, usage.processes);
222
+ resourceSamples++;
223
+ }
224
+ catch {
225
+ /* an unavailable measurement is not a verdict */
226
+ }
227
+ };
228
+ const timer = setInterval(sample, sampleIntervalMs);
229
+ /** A quiet hook's held output: all of it when the run did not pass, else its warnings and one line. */
230
+ const report = (code, durationMs) => {
231
+ const output = Buffer.concat(held);
232
+ const log = resolve(dir, `${kind}.log`);
233
+ try {
234
+ writeFileSync(log, output);
235
+ }
236
+ catch {
237
+ /* the log is a convenience; the verdict is printed regardless */
238
+ }
239
+ if (code !== 0) {
240
+ stderr(output);
241
+ return;
242
+ }
243
+ const lines = [
244
+ ...successNotices(output.toString("utf8")),
245
+ successLine(kind, durationMs, relative(root, log)),
246
+ ];
247
+ stdout(`${lines.join("\n")}\n`);
248
+ };
249
+ /**
250
+ * Wait for a quiet hook's pipes to empty after it exits. A process the hook
251
+ * left running can hold them open forever, so the wait is bounded and the
252
+ * pipes are then closed.
253
+ */
254
+ const drained = async () => {
255
+ if (!quiet)
256
+ return;
257
+ const streams = [spawned.stdout, spawned.stderr].filter((s) => s !== null);
258
+ const closed = Promise.all(streams.map((stream) => stream.closed
259
+ ? null
260
+ : new Promise((done) => stream.once("close", done))));
261
+ const limit = new Promise((done) => setTimeout(() => done("limit"), drainLimitMs).unref());
262
+ const outcome = await Promise.race([closed, limit]);
263
+ if (outcome === "limit")
264
+ held.push(Buffer.from(`[output truncated: a process the hook started still held its output open ${drainLimitMs / 1000} s after it exited]\n`));
265
+ for (const stream of streams)
266
+ stream.destroy();
267
+ };
268
+ const finish = (exitCode, error) => {
269
+ let code = exitCode;
270
+ clearInterval(timer);
271
+ watchdog?.stop();
272
+ const watchdogSummary = watchdog?.summary() ?? {
273
+ mode: "unavailable",
274
+ };
275
+ // An enforced stop decides the outcome: the hook exited because the
276
+ // watchdog stopped it, so its own status is not the story.
277
+ const imposed = watchdogModule?.exitDisposition(watchdogSummary) ?? null;
278
+ if (imposed)
279
+ code = imposed.code;
280
+ const ended = Date.now();
281
+ if (quiet)
282
+ report(code, ended - started);
283
+ const marks = parseStages(readOrNull(events), bootstrapMs);
284
+ const { pushed, declared, proof, proofDiagnostics } = parseRecords(readOrNull(records));
285
+ const verdict = (last) => imposed
286
+ ? imposed.status
287
+ : signal
288
+ ? "cancelled"
289
+ : code === 0
290
+ ? "success"
291
+ : last
292
+ ? "failed"
293
+ : "failure";
294
+ // A stage's input is what it declared, else the whole tree when that tree
295
+ // is exactly a commit: coarse, but recomputable.
296
+ const treeInput = clean && tree ? { kind: "tree", digest: tree } : null;
297
+ const stages = marks.map((stage, index) => ({
298
+ ...stage,
299
+ input: declared.get(stage.name) ?? treeInput,
300
+ durationMs: Math.max(0, (marks[index + 1]?.startMs ?? ended - started) - stage.startMs),
301
+ status: index === marks.length - 1 ? verdict(true) : "completed",
302
+ }));
303
+ try {
304
+ appendFileSync(resolve(dir, "history.jsonl"), `${JSON.stringify({
305
+ schema: 1,
306
+ id,
307
+ kind,
308
+ invocation,
309
+ startedAt: new Date(started).toISOString(),
310
+ durationMs: ended - started,
311
+ exitCode: code,
312
+ signal,
313
+ error,
314
+ status: verdict(false),
315
+ branch,
316
+ fingerprint,
317
+ fingerprintPurpose: "diagnostic-head-and-tracked-diff-only",
318
+ base,
319
+ tree,
320
+ clean,
321
+ pushed,
322
+ stages,
323
+ proof,
324
+ proofDiagnostics,
325
+ node: process.version,
326
+ platform: process.platform,
327
+ arch: process.arch,
328
+ cpus: cpus().length,
329
+ totalMemoryBytes: totalmem(),
330
+ sampledPeakTreeRssKiB: peakTreeRssKiB || null,
331
+ peakProcesses: peakProcesses || null,
332
+ resourceSamples,
333
+ sampleIntervalMs,
334
+ watchdog: watchdogSummary,
335
+ })}\n`);
336
+ rmSync(events, { force: true });
337
+ rmSync(records, { force: true });
338
+ }
339
+ catch (failure) {
340
+ stderr(`History unavailable: ${failure.message}\n`);
341
+ }
342
+ return code;
343
+ };
344
+ // One row per run, whichever of `error` and `exit` arrives first.
345
+ let outcome;
346
+ return new Promise((done, fail) => {
347
+ const settle = (code, error) => {
348
+ try {
349
+ outcome ??= finish(code, error);
350
+ done(outcome);
351
+ }
352
+ catch (failure) {
353
+ fail(failure);
354
+ }
355
+ finally {
356
+ release();
357
+ }
358
+ };
359
+ spawned.on("error", (error) => settle(127, error.message));
360
+ spawned.on("exit", (code, childSignal) => {
361
+ // The watchdog's own stop is not a cancellation; `finish` reports its verdict instead.
362
+ if (!watchdog?.enforced())
363
+ signal ??= childSignal;
364
+ // A signal outranks the code the hook chose: a hook that traps its
365
+ // signal and exits 0 was still cancelled, and reporting 0 would let a
366
+ // push through a gate its operator had just interrupted.
367
+ const verdict = (signal ? SIGNAL_EXIT_CODES[signal] : undefined) ?? code ?? 1;
368
+ void drained().then(() => settle(verdict));
369
+ });
370
+ });
371
+ };
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@veda-hq/dvara",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.5.0",
4
+ "description": "Git hooks for repositories coding agents work in: runs each hook under a supervisor that times its stages, forwards signals and keeps one history row per run, with quiet output on success and a keep-going mode for preflights.",
5
+ "keywords": [
6
+ "git hooks",
7
+ "pre-push",
8
+ "pre-commit",
9
+ "coding agents",
10
+ "telemetry"
11
+ ],
12
+ "license": "MIT",
13
+ "type": "module",
14
+ "engines": {
15
+ "node": ">=24.15"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/veda-hq/veda.git",
20
+ "directory": "packages/dvara"
21
+ },
22
+ "homepage": "https://github.com/veda-hq/veda/tree/main/packages/dvara#readme",
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "bin": {
28
+ "dvara": "dist/cli.js"
29
+ },
30
+ "exports": {
31
+ ".": "./dist/supervisor.js",
32
+ "./config": "./dist/config.js",
33
+ "./shell/stages.sh": "./shell/stages.sh",
34
+ "./shell/keep-going.sh": "./shell/keep-going.sh",
35
+ "./package.json": "./package.json"
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "shell/*.sh"
40
+ ],
41
+ "devDependencies": {
42
+ "@types/node": "24.19.2",
43
+ "typescript": "7.0.2"
44
+ },
45
+ "scripts": {
46
+ "build": "tsc -p tsconfig.build.json",
47
+ "typecheck": "tsc -p tsconfig.json",
48
+ "test": "node --test --experimental-test-coverage --test-coverage-lines=95 --test-coverage-functions=95 --test-coverage-branches=90 --test-coverage-exclude=src/**/*.test.ts --test-coverage-include=src/**/*.ts src/**/*.test.ts",
49
+ "smoke": "node dist/cli.js --version && node dist/cli.js shell stages",
50
+ "check": "pnpm typecheck && pnpm test && pnpm build && pnpm smoke"
51
+ }
6
52
  }
@@ -0,0 +1,86 @@
1
+ # shellcheck shell=bash
2
+ # Keep-going mode, for a preflight: the push hook itself, run ahead of the
3
+ # push, so the preflight and the push cannot drift apart. Only what a refusal
4
+ # does differs. In a push, a stage's `exit 1` ends the hook at its first
5
+ # failure. Here the same `exit 1` is recorded against the stage that raised it
6
+ # (DVARA_CURRENT_STAGE, stages.sh) and the hook carries on, so one run lists
7
+ # every failure. dvara_keep_going_report ends the run.
8
+ #
9
+ # Source it only in keep-going mode, after the runtimes the hook needs are in
10
+ # place, so a missing runtime still ends the run at once.
11
+ #
12
+ # How: a function named `exit` runs in place of the builtin. It takes over one
13
+ # case only, status 1 in the hook's own shell. In a subshell or a command
14
+ # substitution (BASH_SUBSHELL > 0) `exit` must still end that subshell, and
15
+ # every other status (0, a trap's 130 or 143, a probe's 124) is a real end, so
16
+ # those go to the builtin. Whatever `set -e` aborts on never calls `exit` and
17
+ # still ends the run. A stage written as a function keeps this behaviour: the
18
+ # function runs in the hook's shell, and execution continues after the `exit`.
19
+ #
20
+ # Plain strings, not arrays: macOS runs hooks under /bin/bash 3.2, where an
21
+ # empty array is an unbound variable under `set -u`.
22
+
23
+ DVARA_KEEP_GOING_FAILED=""
24
+ DVARA_KEEP_GOING_HELD=""
25
+ DVARA_KEEP_GOING_SKIPPED=""
26
+
27
+ # shellcheck disable=SC2034 # DVARA_UNREPORTED_REFUSAL is read by stages.sh
28
+ exit() {
29
+ local status="${1:-$?}"
30
+ if [ "$status" = 1 ] && [ "${BASH_SUBSHELL:-0}" -eq 0 ]; then
31
+ local stage="${DVARA_CURRENT_STAGE:-unknown}"
32
+ DVARA_KEEP_GOING_FAILED="${DVARA_KEEP_GOING_FAILED}${stage}"$'\n'
33
+ DVARA_UNREPORTED_REFUSAL=1
34
+ echo "✗ ${DVARA_KEEP_GOING_NAME:-preflight}: ${stage} failed; carrying on to the next check"
35
+ return 0
36
+ fi
37
+ builtin exit "$status"
38
+ }
39
+
40
+ # A check not run because an earlier result makes its verdict meaningless.
41
+ dvara_keep_going_held() {
42
+ DVARA_KEEP_GOING_HELD="${DVARA_KEEP_GOING_HELD}$1: not run: $2"$'\n'
43
+ echo "→ $1: not run: $2"
44
+ }
45
+
46
+ # A check this run was asked not to run: not a failure, and not a pass.
47
+ dvara_keep_going_skipped() {
48
+ DVARA_KEEP_GOING_SKIPPED="${DVARA_KEEP_GOING_SKIPPED}$1: skipped: $2"$'\n'
49
+ echo "⏭ $1: skipped: $2"
50
+ }
51
+
52
+ # The end of the run: every check skipped, held and failed, and the verdict.
53
+ # Fails when any check failed or was held; passes, saying so, when the only
54
+ # gaps are skips.
55
+ dvara_keep_going_report() {
56
+ local line failed=0
57
+ echo ""
58
+ echo "━━━ ${DVARA_KEEP_GOING_TITLE:-Preflight} summary ━━━"
59
+ while IFS= read -r line; do
60
+ if [ -n "$line" ]; then echo " ⏭ ${line}"; fi
61
+ done <<< "$DVARA_KEEP_GOING_SKIPPED"
62
+ while IFS= read -r line; do
63
+ if [ -n "$line" ]; then echo " ⏸ ${line}"; fi
64
+ done <<< "$DVARA_KEEP_GOING_HELD"
65
+ while IFS= read -r line; do
66
+ if [ -n "$line" ]; then
67
+ echo " ❌ ${line}"
68
+ failed=$((failed + 1))
69
+ fi
70
+ done <<< "$DVARA_KEEP_GOING_FAILED"
71
+ if [ "$failed" -gt 0 ]; then
72
+ echo " ${failed} check(s) failed. Fix them all, commit, and run the preflight again."
73
+ echo " Nothing was pushed."
74
+ builtin exit 1
75
+ fi
76
+ if [ -n "$DVARA_KEEP_GOING_HELD" ]; then
77
+ echo " No check failed, but the checks above did not run. Nothing was pushed."
78
+ builtin exit 1
79
+ fi
80
+ if [ -n "$DVARA_KEEP_GOING_SKIPPED" ]; then
81
+ echo " ✅ Every check that ran passed; the push runs the skipped ones. Nothing was pushed."
82
+ builtin exit 0
83
+ fi
84
+ echo " ✅ Every check passed. Nothing was pushed and no pull request was opened."
85
+ builtin exit 0
86
+ }
@@ -0,0 +1,52 @@
1
+ # shellcheck shell=bash
2
+ # dvara's hook helpers, sourced by a hook that runs under `dvara run`.
3
+ # Builtins only: one-second resolution, no subprocesses, and a failed write
4
+ # never fails the hook. Outside the supervisor the files are unset and every
5
+ # helper is a no-op, so the hook runs the same either way.
6
+
7
+ # Mark the start of a stage. DVARA_CURRENT_STAGE is the stage a refusal is
8
+ # recorded against in keep-going mode (keep-going.sh).
9
+ # shellcheck disable=SC2034 # read by keep-going.sh
10
+ dvara_stage() {
11
+ DVARA_CURRENT_STAGE="$1"
12
+ if [ -n "${DVARA_STAGE_FILE:-}" ]; then
13
+ printf '%s\t%s\n' "$SECONDS" "$1" >> "$DVARA_STAGE_FILE" || true
14
+ fi
15
+ }
16
+
17
+ # A fact for the run's history row that only the hook knows: its fields,
18
+ # tab-separated, one record per call (records.ts lists the kinds).
19
+ dvara_record() {
20
+ if [ -n "${DVARA_RECORD_FILE:-}" ]; then
21
+ local IFS=$'\t'
22
+ printf '%s\n' "$*" >> "$DVARA_RECORD_FILE" || true
23
+ fi
24
+ }
25
+
26
+ # Record the commits a push updates, from the ref lines git passes pre-push
27
+ # on stdin ("<local_ref> <local_sha> <remote_ref> <remote_sha>"): each line's
28
+ # two commit ids, never its ref names. An empty argument means a manual run.
29
+ dvara_record_refs() {
30
+ local _local_ref local_sha _remote_ref remote_sha
31
+ if [ -z "${1:-}" ]; then
32
+ dvara_record refs-source none
33
+ return 0
34
+ fi
35
+ dvara_record refs-source stdin
36
+ while read -r _local_ref local_sha _remote_ref remote_sha; do
37
+ dvara_record ref "$local_sha" "$remote_sha"
38
+ done <<< "$1"
39
+ }
40
+
41
+ # Guards a "✅" line: false once after keep-going mode carried on past a
42
+ # refusal, so a failed check is never reported as passed. A run that stops at
43
+ # its first failure never carries on, so there it is always true.
44
+ dvara_passed() {
45
+ if [ "${DVARA_UNREPORTED_REFUSAL:-0}" = 1 ]; then
46
+ DVARA_UNREPORTED_REFUSAL=0
47
+ return 1
48
+ fi
49
+ return 0
50
+ }
51
+
52
+ dvara_stage startup