@veda-hq/dvara 0.0.0-stage → 0.5.4

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.
@@ -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,55 @@
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.4",
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
+ "dependencies": {
46
+ "mdast-util-from-markdown": "2.0.3"
47
+ },
48
+ "scripts": {
49
+ "build": "tsc -p tsconfig.build.json",
50
+ "typecheck": "tsc -p tsconfig.json",
51
+ "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'",
52
+ "smoke": "node dist/cli.js --version && node dist/cli.js shell stages",
53
+ "check": "pnpm typecheck && pnpm test && pnpm build && pnpm smoke"
54
+ }
6
55
  }
@@ -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