@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.
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,202 @@
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
+ `records`, an object, holds the settings of `dvara records` and `dvara frozen`
85
+ (below). Each field is optional; an unknown one is an error.
86
+
87
+ | Field | Default | Meaning |
88
+ | ---------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
89
+ | `baseline` | `.dvara/records-baseline.json` | the per-file baseline, relative to the root; read from git, so it must be tracked (un-ignore it if `.dvara/` is ignored) |
90
+ | `skip` | `[]` | path prefixes no rule reads, such as a checker's own fixtures |
91
+ | `frozen` | `{"paths": [], "except": []}` | `paths`: prefixes `dvara frozen` refuses changing and no rule reads; `except`: exact paths under them that stay editable |
92
+ | `identityPaths` | `[]` | regular expressions for the paths where a commit must be cited by its full id (`EVIDENCE-01`); none by default |
93
+ | `repository` | `null` | `owner/name` on GitHub: its commit URLs are this repository's, every other repository's are not checked |
94
+ | `allowPaths` | `[]` | regular expressions for machine paths that are durable by agreement, matched from where the path starts: anchor with `^` |
95
+ | `packageManager` | `null` | `pnpm`, `npm` or `yarn`: the other managers' install commands are refused; `null` checks none |
96
+
97
+ The environment it reads: `DVARA_MODE=preflight` (a preflight's rows get kind
98
+ `preflight`), `DVARA_BACKGROUND=1` (started detached), `DVARA_MEASUREMENT_CONTEXT=manual-benchmark`,
99
+ `DVARA_BOOTSTRAP_SECONDS` (seconds the hook spent selecting its runtime before
100
+ starting dvara, counted as a stage), `DVARA_VERBOSE=1`. It sets
101
+ `DVARA_MEASURED_HOOK`, `DVARA_STAGE_FILE` and `DVARA_RECORD_FILE` for the hook.
102
+
103
+ ## A watchdog module
104
+
105
+ ```ts
106
+ export function createWatchdog(options: {
107
+ rootPid;
108
+ stageFile;
109
+ hangsDir;
110
+ runId;
111
+ config;
112
+ }): {
113
+ disarm(): void;
114
+ stop(): void;
115
+ summary(): { mode: string };
116
+ enforced(): boolean;
117
+ };
118
+ export function resolveWatchdogConfig(
119
+ env,
120
+ platform,
121
+ perfLevels: string[],
122
+ ): unknown;
123
+ export function exitDisposition(
124
+ summary,
125
+ ): { code: number; status: string } | null;
126
+ ```
127
+
128
+ ## The record gate: `dvara records`
129
+
130
+ Committed text should not cite what stops meaning anything once the session
131
+ that wrote it ends. The gate reads every text file that is not source code, a
132
+ binary, a lockfile (`*.lock`, `pnpm-lock.yaml`, `package-lock.json`), a
133
+ symlink or a submodule, a skipped or frozen path, the baseline or the config,
134
+ and refuses (rule `EVIDENCE-02`, one finding per line):
135
+
136
+ - a `file://` URL, a `/Users/<name>` or `/home/<name>` home path, a sandbox temp
137
+ root (`/private/tmp/`, `/private/var/`, `/var/folders/`), a literal `.git/`
138
+ path, or an agent-process marker ("I am Agent 3");
139
+ - another package manager's install command, when `packageManager` names one.
140
+ A clause that says not to ("never run `npm install`") is no finding.
141
+
142
+ Where `identityPaths` match, it also refuses (rule `EVIDENCE-01`) a commit cited
143
+ by less than its full 40-hex id, since git lengthens abbreviations as history
144
+ grows and clones disagree on their length:
145
+
146
+ - `abbreviated-id`: a 7–39 hex token the line makes a commit (after a commit
147
+ word, backticked on its own, in an `a..b` range, cut off with an ellipsis, or
148
+ in a short commit URL of `repository`), unless the line names it another kind
149
+ of hash (digest, sha256, checksum, …);
150
+ - `abbreviating-command`: a git command that prints abbreviations
151
+ (`--oneline`, `%h`, `--abbrev-commit`, `rev-parse --short`);
152
+ - `unresolved-id`: a full id the change adds that is not a commit in this
153
+ repository, compared with the ids the file cited at the merge base. No
154
+ baseline holds these: they are new by construction.
155
+
156
+ In Markdown, a fence's captured output is not read for tokens; its command
157
+ lines are (`$ ` lines of a transcript, every line of an `sh` fence). Fences are
158
+ found by CommonMark's rules (mdast-util-from-markdown), so a fence in a quote or
159
+ a list item counts.
160
+
161
+ Files written before the gate existed are held by the baseline: each file's
162
+ count per rule, which may only fall. A new finding in a legacy file, a fix that
163
+ leaves its allowance in place, an entry for a file that is gone or not read,
164
+ and a baseline edit that raises a count or adds a file are all refused; a
165
+ rename git detects carries its entry forward.
166
+
167
+ ```sh
168
+ dvara records --staged # pre-commit: the index, against the first baseRefs that resolves
169
+ dvara records --base origin/main # CI: the files the branch changed, at HEAD
170
+ dvara records --all [--base REF] # every tracked file at HEAD
171
+ dvara records --all --write-baseline # record today's counts
172
+ dvara records --all --survey # counts per rule and check
173
+ ```
174
+
175
+ Exit 0: clean or within the baseline; 1: refused; 2: could not run. Inside the
176
+ package the gate takes any list of rules, `(path, text, tree) => Finding[]`;
177
+ the command runs the list `rulesFor` in `src/records/rules.ts` returns.
178
+
179
+ ## Frozen paths: `dvara frozen`
180
+
181
+ A path under one of `records.frozen.paths` is history: no commit adds, changes
182
+ or deletes a file there, except the exact `records.frozen.except` paths.
183
+
184
+ ```sh
185
+ dvara frozen --staged # pre-commit, merge commits included
186
+ dvara frozen --base origin/main [--head REF] # CI: what the branch changes since its merge base
187
+ ```
188
+
189
+ In a merge, a path staged exactly as a merged-in parent has it is that
190
+ parent's change and passes; a hand resolution counts. With no frozen paths
191
+ configured it passes without asking git. Exit 0, 1 or 2 as above.
192
+
193
+ ## Commands
194
+
195
+ ```sh
196
+ dvara run <hook-file> [hook args…] # run a git hook under the supervisor
197
+ dvara stages <hook> # the stages .dvara.json lists for it
198
+ dvara shell <stages|keep-going> # the path of a shell helper
199
+ dvara records <--staged | --all [--base REF] [--write-baseline | --survey] | --base REF>
200
+ dvara frozen <--staged | --base REF [--head REF]>
201
+ dvara --version
202
+ ```
package/dist/cli.js ADDED
@@ -0,0 +1,124 @@
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 { configName, loadConfig } from "./config.js";
7
+ import { frozen, parseArgs as parseFrozenArgs, USAGE as FROZEN_USAGE, } from "./records/frozen.js";
8
+ import { parseArgs as parseRecordsArgs, records, USAGE as RECORDS_USAGE, } from "./records/gate.js";
9
+ import { runner } from "./records/git.js";
10
+ import { supervise } from "./supervisor.js";
11
+ export const USAGE = [
12
+ "usage: dvara run <hook-file> [hook args…] run a git hook under the supervisor",
13
+ " dvara stages <hook> the hook stages the config lists, one per line",
14
+ " dvara shell <stages|keep-going> the path of a shell helper to source",
15
+ " dvara records <--staged | --all [--base REF] [--write-baseline | --survey] | --base REF>",
16
+ " the record gate: no new non-durable paths, a baseline that only falls",
17
+ " dvara frozen <--staged | --base REF [--head REF]>",
18
+ " refuse changes to the frozen paths",
19
+ " dvara --version",
20
+ ].join("\n");
21
+ const SHELL_HELPERS = new Set(["stages", "keep-going"]);
22
+ export const ownVersion = () => {
23
+ const pkg = join(import.meta.dirname, "..", "package.json");
24
+ return JSON.parse(readFileSync(pkg, "utf8")).version;
25
+ };
26
+ /** The path of a shell helper this package ships. */
27
+ export const shellHelper = (name) => resolve(import.meta.dirname, "..", "shell", `${name}.sh`);
28
+ /**
29
+ * `dvara records` or `dvara frozen` at the root `cwd`: 2 on bad arguments, a
30
+ * config it cannot read, or git failing.
31
+ */
32
+ const checkCommand = (command, args, { io, cwd, env }) => {
33
+ const parsed = command === "records" ? parseRecordsArgs(args) : parseFrozenArgs(args);
34
+ if ("error" in parsed) {
35
+ io.err(`dvara ${command}: ${parsed.error}`);
36
+ io.err(command === "records" ? RECORDS_USAGE : FROZEN_USAGE);
37
+ return 2;
38
+ }
39
+ try {
40
+ const config = loadConfig(cwd, env);
41
+ const repo = { cwd, env };
42
+ if ("mode" in parsed)
43
+ return records(parsed, repo, io.out, {
44
+ records: config.records,
45
+ baseRefs: config.baseRefs,
46
+ own: [configName(env)],
47
+ });
48
+ const result = frozen(parsed, runner(repo), config.records.frozen, env);
49
+ for (const line of result.stdout)
50
+ io.out(line);
51
+ for (const line of result.stderr)
52
+ io.err(line);
53
+ return result.code;
54
+ }
55
+ catch (error) {
56
+ io.err(`dvara ${command}: ${error.message}`);
57
+ return 2;
58
+ }
59
+ };
60
+ /** Runs one command; resolves with the process's exit code. */
61
+ export const run = async (argv, context) => {
62
+ const { io, cwd, env } = context;
63
+ const [command, ...rest] = argv;
64
+ if (command === "--version") {
65
+ io.out(ownVersion());
66
+ return 0;
67
+ }
68
+ if (command === "shell" &&
69
+ rest.length === 1 &&
70
+ SHELL_HELPERS.has(rest[0] ?? "")) {
71
+ io.out(shellHelper(rest[0] ?? ""));
72
+ return 0;
73
+ }
74
+ if (command === "records" || command === "frozen")
75
+ return checkCommand(command, rest, context);
76
+ if ((command === "run" && rest.length > 0) ||
77
+ (command === "stages" && rest.length === 1)) {
78
+ // Git runs every hook at the top of the working tree.
79
+ let config;
80
+ try {
81
+ config = loadConfig(cwd, env);
82
+ }
83
+ catch (error) {
84
+ io.err(error.message);
85
+ return 2;
86
+ }
87
+ if (command === "stages") {
88
+ for (const name of config.stages[rest[0] ?? ""] ?? [])
89
+ io.out(name);
90
+ return 0;
91
+ }
92
+ return (context.supervise ?? supervise)(rest, {
93
+ ...context.options,
94
+ root: cwd,
95
+ config,
96
+ env,
97
+ });
98
+ }
99
+ io.err(USAGE);
100
+ return 2;
101
+ };
102
+ /** Whether this module is the script node was asked to run, through any symlink. */
103
+ export const isEntry = (entry, moduleUrl) => {
104
+ if (!entry)
105
+ return false;
106
+ try {
107
+ return (realpathSync(resolve(entry)) === realpathSync(fileURLToPath(moduleUrl)));
108
+ }
109
+ catch {
110
+ return false;
111
+ }
112
+ };
113
+ /** The process's own output, one line per call. */
114
+ export const processIo = {
115
+ out: (line) => process.stdout.write(`${line}\n`),
116
+ err: (line) => process.stderr.write(`${line}\n`),
117
+ };
118
+ if (isEntry(process.argv[1], import.meta.url)) {
119
+ process.exitCode = await run(process.argv.slice(2), {
120
+ io: processIo,
121
+ cwd: process.cwd(),
122
+ env: process.env,
123
+ });
124
+ }
package/dist/config.js ADDED
@@ -0,0 +1,175 @@
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 { isAbsolute, posix, resolve } from "node:path";
5
+ export const PACKAGE_MANAGERS = ["pnpm", "npm", "yarn"];
6
+ export const DEFAULTS = {
7
+ historyDir: ".dvara",
8
+ quietHooks: ["pre-commit"],
9
+ watchdog: null,
10
+ baseRefs: ["origin/main", "origin/develop", "origin/master"],
11
+ stages: {},
12
+ records: {
13
+ baseline: ".dvara/records-baseline.json",
14
+ skip: [],
15
+ frozen: { paths: [], except: [] },
16
+ identityPaths: [],
17
+ repository: null,
18
+ allowPaths: [],
19
+ packageManager: null,
20
+ },
21
+ };
22
+ export const CONFIG_FILE = ".dvara.json";
23
+ /** The config file's name relative to the root, as `loadConfig` reads it. */
24
+ export const configName = (env) => posix.normalize(env.DVARA_CONFIG || CONFIG_FILE);
25
+ const strings = (value, field) => {
26
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string"))
27
+ throw new Error(`${field} must be a list of strings`);
28
+ return value;
29
+ };
30
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
31
+ const onlyKnown = (entries, known, field) => {
32
+ for (const key of Object.keys(entries))
33
+ if (!known.includes(key))
34
+ throw new Error(`unknown field ${field}${key}`);
35
+ };
36
+ /** Whether `path`, normalized, leaves the root: `..`, `../x`, `a/../../x`. */
37
+ const escapes = (path) => {
38
+ const normal = posix.normalize(path);
39
+ return normal === ".." || normal.startsWith("../");
40
+ };
41
+ /**
42
+ * Repository-relative path prefixes or paths: none empty, none absolute, none
43
+ * leaving the root (git prints no path there, so it would match nothing).
44
+ */
45
+ const repoPaths = (value, field) => {
46
+ const list = strings(value, field);
47
+ for (const path of list)
48
+ if (path === "" || isAbsolute(path) || escapes(path))
49
+ throw new Error(`${field} must hold repository-relative paths`);
50
+ return list;
51
+ };
52
+ const patterns = (value, field) => strings(value, field).map((source, i) => {
53
+ try {
54
+ return new RegExp(source);
55
+ }
56
+ catch (error) {
57
+ throw new Error(`${field}[${i}] is not a regular expression: ${error.message}`);
58
+ }
59
+ });
60
+ const parseRecordsConfig = (value) => {
61
+ if (!isObject(value))
62
+ throw new Error("records must be an object");
63
+ onlyKnown(value, Object.keys(DEFAULTS.records), "records.");
64
+ const records = { ...DEFAULTS.records };
65
+ if ("baseline" in value) {
66
+ const path = value.baseline;
67
+ // Compared with the paths git prints: `./a/../b.json` is `b.json`. A
68
+ // folder (`.`, `archive/`) names no tracked file, so it is no baseline.
69
+ const normal = typeof path === "string" ? posix.normalize(path) : "";
70
+ if (typeof path !== "string" ||
71
+ path === "" ||
72
+ isAbsolute(path) ||
73
+ escapes(path) ||
74
+ normal === "." ||
75
+ normal.endsWith("/"))
76
+ throw new Error("records.baseline must be a file path inside the repository");
77
+ records.baseline = normal;
78
+ }
79
+ if ("skip" in value)
80
+ records.skip = repoPaths(value.skip, "records.skip");
81
+ if ("frozen" in value) {
82
+ const frozen = value.frozen;
83
+ if (!isObject(frozen))
84
+ throw new Error("records.frozen must be an object");
85
+ onlyKnown(frozen, ["paths", "except"], "records.frozen.");
86
+ records.frozen = {
87
+ paths: "paths" in frozen
88
+ ? repoPaths(frozen.paths, "records.frozen.paths")
89
+ : [],
90
+ except: "except" in frozen
91
+ ? repoPaths(frozen.except, "records.frozen.except")
92
+ : [],
93
+ };
94
+ }
95
+ if ("identityPaths" in value)
96
+ records.identityPaths = patterns(value.identityPaths, "records.identityPaths");
97
+ if ("repository" in value) {
98
+ const repository = value.repository;
99
+ if (repository !== null &&
100
+ (typeof repository !== "string" || !/^[\w.-]+\/[\w.-]+$/.test(repository)))
101
+ throw new Error('records.repository must be "owner/name" or null');
102
+ records.repository = repository;
103
+ }
104
+ if ("allowPaths" in value)
105
+ records.allowPaths = patterns(value.allowPaths, "records.allowPaths");
106
+ if ("packageManager" in value) {
107
+ const manager = value.packageManager;
108
+ if (manager !== null &&
109
+ !PACKAGE_MANAGERS.includes(manager))
110
+ throw new Error(`records.packageManager must be one of ${PACKAGE_MANAGERS.join(", ")}, or null`);
111
+ records.packageManager = manager;
112
+ }
113
+ return records;
114
+ };
115
+ /** The settings in `text` over the defaults; a field of the wrong type is an error, an unknown one too. */
116
+ export const parseConfig = (text) => {
117
+ const raw = JSON.parse(text);
118
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
119
+ throw new Error("the config must be an object");
120
+ const entries = raw;
121
+ const known = new Set(Object.keys(DEFAULTS));
122
+ for (const key of Object.keys(entries))
123
+ if (!known.has(key))
124
+ throw new Error(`unknown field ${key}`);
125
+ const config = { ...DEFAULTS };
126
+ if ("historyDir" in entries) {
127
+ if (typeof entries.historyDir !== "string" || entries.historyDir === "")
128
+ throw new Error("historyDir must be a non-empty string");
129
+ config.historyDir = entries.historyDir;
130
+ }
131
+ if ("quietHooks" in entries)
132
+ config.quietHooks = strings(entries.quietHooks, "quietHooks");
133
+ if ("watchdog" in entries) {
134
+ if (entries.watchdog !== null && typeof entries.watchdog !== "string")
135
+ throw new Error("watchdog must be a path or null");
136
+ config.watchdog = entries.watchdog;
137
+ }
138
+ if ("baseRefs" in entries)
139
+ config.baseRefs = strings(entries.baseRefs, "baseRefs");
140
+ if ("stages" in entries) {
141
+ const stages = entries.stages;
142
+ if (typeof stages !== "object" || stages === null || Array.isArray(stages))
143
+ throw new Error("stages must map each hook to its stage names");
144
+ const out = {};
145
+ for (const [hook, names] of Object.entries(stages))
146
+ out[hook] = strings(names, `stages.${hook}`);
147
+ config.stages = out;
148
+ }
149
+ if ("records" in entries)
150
+ config.records = parseRecordsConfig(entries.records);
151
+ return config;
152
+ };
153
+ /**
154
+ * The settings for `root`. No file is the defaults. A file that cannot be
155
+ * parsed is an error: a hook that silently ran on the defaults would write its
156
+ * history somewhere nobody reads.
157
+ */
158
+ export const loadConfig = (root, env) => {
159
+ const path = resolve(root, env.DVARA_CONFIG || CONFIG_FILE);
160
+ let text;
161
+ try {
162
+ text = readFileSync(path, "utf8");
163
+ }
164
+ catch (error) {
165
+ if (error.code === "ENOENT" && !env.DVARA_CONFIG)
166
+ return DEFAULTS;
167
+ throw new Error(`dvara: cannot read ${path}: ${error.message}`);
168
+ }
169
+ try {
170
+ return parseConfig(text);
171
+ }
172
+ catch (error) {
173
+ throw new Error(`dvara: ${path}: ${error.message}`);
174
+ }
175
+ };
@@ -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})`;