aval-adr 0.6.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/README.md ADDED
@@ -0,0 +1,217 @@
1
+ # aval
2
+
3
+ **Git is the history. ADRs are the evidence. `aval resolve` tells you what is
4
+ true now.**
5
+
6
+ Most ADR tooling helps you write and browse decision records. `aval` answers a
7
+ different question, the one a coding agent actually needs before it writes a
8
+ line of code:
9
+
10
+ ```console
11
+ $ aval resolve storage.object-store --scope homelab
12
+ active ADR-0021 Ceph RGW
13
+ $ echo $?
14
+ 0
15
+ ```
16
+
17
+ and, just as importantly, refuses to guess when it should not:
18
+
19
+ ```console
20
+ $ aval resolve data.realtime-processing
21
+ undecided no accepted decision for this key
22
+ $ echo $?
23
+ 4
24
+
25
+ $ aval resolve api.gateway
26
+ contradiction two heads for api.gateway
27
+ ADR-0062 introduced in 4f2ac1d
28
+ ADR-0071 introduced in 9bd3e08
29
+ $ echo $?
30
+ 5
31
+ ```
32
+
33
+ An agent branches on a number. It never reads three documents and reasons its
34
+ way to a plausible wrong answer.
35
+
36
+ ## The idea
37
+
38
+ ADRs are not the current architecture. **ADRs are the evidence from which the
39
+ current architecture is computed.**
40
+
41
+ - A **decision key** names one architectural question, from a controlled
42
+ vocabulary. `storage.object-store`, not "the storage doc".
43
+ - An **ADR** is an event that changes one or more keys.
44
+ - **Superseded is never written down.** It is derived from supersession edges.
45
+ A hand-written status is copied state, and copied state drifts.
46
+ - **`HEADS.md` is a projection**, regenerated and byte-compared in CI. Nobody
47
+ edits it.
48
+
49
+ ## Typed non-answers
50
+
51
+ The design is mostly about what happens when there is no clean answer, because
52
+ that is where an agent left to its own judgment does damage.
53
+
54
+ | Exit | Verdict | Means |
55
+ |---|---|---|
56
+ | 0 | `active` | here is the decision |
57
+ | 4 | `undecided` | the key exists, nothing decided it |
58
+ | 5 | `contradiction` | competing heads. Stop, do not pick one. |
59
+ | 6 | `retired` | an ADR deliberately retired this key |
60
+ | 7 | `unknown` | no such key or scope, or a key not decided on that axis |
61
+
62
+ ## In front of an agent
63
+
64
+ A corpus that resolves is half the point. The other half is that whatever is
65
+ about to write code starts from what was decided:
66
+
67
+ ```console
68
+ $ aval hook install
69
+ wrote .claude/hooks/aval-heads.sh (created)
70
+ wrote .claude/settings.json (merged)
71
+ ```
72
+
73
+ It writes a session-start hook that prints the current heads, and merges one
74
+ entry into `.claude/settings.json` without disturbing what else is there. The
75
+ hook is **silent** when `aval` is not installed, so committing it cannot fail a
76
+ colleague's session, and silent when there is no corpus to report.
77
+
78
+ Repo-specific caveats go in `.claude/aval-hook.local.md`. The hook appends that
79
+ file; installing again never touches it. `--check` is the drift detector for
80
+ CI: exit 0 wired, exit 1 stale.
81
+
82
+ The hook pushes; **`aval mcp` lets an agent pull** — the same answers as native
83
+ tools, asked at the moment the question comes up rather than only at the top of
84
+ a session:
85
+
86
+ ```console
87
+ $ claude mcp add aval -- aval mcp
88
+ ```
89
+
90
+ Five read-only tools: `aval_resolve`, `aval_keys`, `aval_heads`, `aval_show`,
91
+ `aval_history`. They resolve through the same graph the CLI does and return the
92
+ same bytes `--json` would, so the two surfaces cannot drift apart.
93
+
94
+ The distinction that matters: **a verdict is not an error**. `undecided`,
95
+ `retired`, `unknown` and `contradiction` all come back as ordinary results with
96
+ `isError: false`, because each is an answer. Reporting `contradiction` as a tool
97
+ failure would teach an agent to retry or work around the one verdict that means
98
+ *stop and ask a human* — so the flag is reserved for a corpus that would not
99
+ load, or a name it does not carry.
100
+
101
+ Nothing on that surface writes. Resolving answers a question; deciding is not
102
+ something to do on an agent's behalf.
103
+
104
+ Codes `1`, `2` and `3` mean the tool failed, was misused, or could not read the
105
+ corpus. They never overlap a verdict, so "I could not look" is never mistaken
106
+ for "I looked and found nothing".
107
+
108
+ ## Scopes
109
+
110
+ A decision can be global or partitioned. Choosing VXLAN for one cluster must not
111
+ silently change another, so scoped divergence and in-place replacement are
112
+ different edges:
113
+
114
+ ```yaml
115
+ - key: cni.routing-mode
116
+ scope: cloud
117
+ choice: VXLAN tunnel + WireGuard
118
+ first: true
119
+ overrides: ADR-0006 # the global native-routing head STAYS a head
120
+ ```
121
+
122
+ ## Commands
123
+
124
+ | | |
125
+ |---|---|
126
+ | `aval resolve <key> [--scope S]` | the authoritative lookup |
127
+ | `aval check` | every invariant; what the git hook and CI run |
128
+ | `aval heads [--write\|--check]` | the projection |
129
+ | `aval show ADR-0015` | derived status, including partial supersession |
130
+ | `aval history <key>` | the chain, labelled as history |
131
+ | `aval hook install [--check]` | put the heads in front of an agent at session start |
132
+ | `aval pack [--write\|--check]` | publish this corpus's declarations for others to read |
133
+ | `aval add <source>… [--dry-run]` | vendor another repository's declarations |
134
+ | `aval add --check` | are the vendored packs still what their revisions name |
135
+
136
+ ## Sharing one decision across repositories
137
+
138
+ A decision made once should be readable everywhere it applies. `aval pack`
139
+ publishes a corpus's declarations; `aval add` vendors them into another
140
+ repository, which then resolves them as if they were its own.
141
+
142
+ ```console
143
+ $ aval add github:acme/decisions
144
+ $ aval resolve stack.sql-layer --scope effect-stack
145
+ active decisions:ADR-0002 @effect/sql
146
+ vendored: from the `decisions` pack; change it there, not here
147
+ ```
148
+
149
+ A consumer needs no corpus of its own — a registry with `packs:` and no `dir:`
150
+ is enough. Transport is git and only git, so a private repository and a forge
151
+ behind a client certificate both work with your own credentials and no token
152
+ issued to this tool.
153
+
154
+ What a consumer cannot do is quietly disagree. A local record deciding a slot a
155
+ pack already decides is two heads for one slot, which is exit 5 — the invariant
156
+ the model already had, and the reason vendoring is worth anything. What it
157
+ *can* do is answer the same key at a scope of its own: one repository may hold
158
+ a SQL layer in the browser and another in the app server without either being
159
+ a disagreement with the fleet's answer at the fleet's scope.
160
+
161
+ Nothing in a pack is ever executed, so there is no trust prompt to match
162
+ `amont trust`. The review gate is the pull request that adds the file.
163
+
164
+ ## Status
165
+
166
+ Published, and in use: the resolver, the invariants, the projection, the CLI,
167
+ vendoring and the tool surface all work, against real corpora rather than
168
+ fixtures. [`SEMANTICS.md`](SEMANTICS.md) is normative and is the place to
169
+ start if you intend to write records against this.
170
+
171
+ Still to come: a 1.0.
172
+
173
+ ## Install
174
+
175
+ curl -fsSL https://raw.githubusercontent.com/fredericrous/aval/main/install/install.sh | sh
176
+
177
+ Pin a version or move the destination with `AVAL_VERSION` and `AVAL_BIN_DIR`.
178
+ Windows: `irm https://raw.githubusercontent.com/fredericrous/aval/main/install/install.ps1 | iex`.
179
+
180
+ Also `cargo install aval`, and `npx aval-adr` — the npm package carries the
181
+ suffix because plain `aval` was taken in 2016 by an unrelated property
182
+ validator; the binary it installs is still `aval`.
183
+
184
+ Nothing is gated by installing. To gate a repository, one committed line in its
185
+ [`amont.conf`](https://github.com/fredericrous/amont):
186
+
187
+ pre-commit adr *+.adr.yaml block aval check
188
+
189
+ Records live under `dir`, and a specification that carries decisions can be
190
+ named where it is rather than moved:
191
+
192
+ ```yaml
193
+ dir: docs/adr
194
+ sources:
195
+ - docs/spec-change-proposals.md
196
+ ```
197
+
198
+ Literal paths, not patterns: a listed file that goes missing is an error, where
199
+ a pattern that stops matching would drop the record and let a superseded
200
+ decision come back as the current one.
201
+
202
+ The `+` keeps it inert in any repository without a `.adr.yaml`, and a missing
203
+ binary is reported as a gap rather than blocking a commit.
204
+
205
+ ## Building
206
+
207
+ make check # what CI runs: no-deps, fmt, clippy, tests
208
+ cargo build --release
209
+
210
+ No external dependencies, by design and enforced in CI. `aval` runs on the
211
+ pre-commit path, so it pulls in nothing. `make check` uses rustup's shim when
212
+ one is present, because a Homebrew cargo earlier on `PATH` ignores the
213
+ toolchain pin and would lint with a different clippy than CI.
214
+
215
+ ## License
216
+
217
+ MIT
package/bin/aval.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ // The resolver. See ./native.js for how the platform binary is found.
3
+ require("./native.js").become("aval");
package/bin/native.js ADDED
@@ -0,0 +1,98 @@
1
+ // Find the native binary npm installed for this platform, and become it.
2
+ //
3
+ // This wrapper exists only because a package manager can link a `bin` that
4
+ // lives inside the package, and the native executable lives in a DIFFERENT
5
+ // package — one of six, selected by npm from `os`/`cpu`/`libc`.
6
+ //
7
+ // It is not on the hook path. `aval init` bakes `current_exe()` — the native
8
+ // binary this spawns, not this file — into `.git/hooks`, so the ~30ms of node
9
+ // start-up below is paid once during `prepare` and never again on a commit.
10
+ // See npm/README.md.
11
+ //
12
+ // Parameterised by binary name rather than copied per binary. Everything below
13
+ // — pnpm's non-hoisting, the libc fallback, spawn-versus-exists, signal exit
14
+ // codes — was learned once and is worth exactly one copy; a second wrapper that
15
+ // duplicated it would be a second place for those lessons to rot.
16
+
17
+ const { spawnSync } = require("node:child_process");
18
+ const { existsSync } = require("node:fs");
19
+
20
+ // The same six targets `release.yaml` builds, spelled the way npm spells them.
21
+ // `libc` is why linux-x64 appears twice: a musl host cannot run the glibc build,
22
+ // and npm will install only the package whose `libc` matches.
23
+ const PACKAGES = {
24
+ "darwin arm64": "@aval-adr/darwin-arm64",
25
+ "darwin x64": "@aval-adr/darwin-x64",
26
+ "linux arm64": "@aval-adr/linux-arm64-gnu",
27
+ "linux x64": ["@aval-adr/linux-x64-gnu", "@aval-adr/linux-x64-musl"],
28
+ "win32 x64": "@aval-adr/win32-x64",
29
+ };
30
+
31
+ function candidates() {
32
+ const found = PACKAGES[`${process.platform} ${process.arch}`];
33
+ if (!found) return [];
34
+ return Array.isArray(found) ? found : [found];
35
+ }
36
+
37
+ // `require.resolve` rather than a hand-built `../aval-<target>/bin/…` path.
38
+ // pnpm does not hoist — the real package sits under `node_modules/.pnpm/` — so a
39
+ // path assembled from `__dirname` is correct under npm and wrong under pnpm and
40
+ // yarn. Node's own resolver knows where the dependency actually is.
41
+ function binaryOf(pkg, name) {
42
+ const exe = process.platform === "win32" ? `${name}.exe` : name;
43
+ try {
44
+ const p = require.resolve(`${pkg}/bin/${exe}`);
45
+ if (existsSync(p)) return p;
46
+ } catch {
47
+ // Not installed: either the wrong libc for this host, or an
48
+ // `--ignore-scripts`-style install that skipped optional deps.
49
+ }
50
+ return null;
51
+ }
52
+
53
+ // Existence is not runnability, so the loop is over SPAWNS, not paths. With a
54
+ // package manager that ignores the `libc` field — yarn classic does, and
55
+ // `npm install --force` is this file's own suggested remedy — BOTH linux-x64
56
+ // builds get installed, gnu listed first, and on a musl host the gnu binary
57
+ // fails at exec on the missing glibc loader while the right one sits a
58
+ // candidate later. Only a spawn-level failure (`result.error`) falls through:
59
+ // a binary that ran and exited non-zero has ANSWERED, and its exit code is the
60
+ // whole product of a hook runner.
61
+ function become(name) {
62
+ const failures = [];
63
+ for (const pkg of candidates()) {
64
+ const binary = binaryOf(pkg, name);
65
+ if (!binary) continue;
66
+ // `spawnSync` with inherited stdio rather than `execFileSync`: this forwards
67
+ // the child's exit CODE. It also keeps the child's stdin, which `aval run`
68
+ // reads.
69
+ const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit" });
70
+ if (result.error) {
71
+ failures.push(` ${binary}: ${result.error.message}`);
72
+ continue;
73
+ }
74
+ // A signalled child has a null status. Report it the way a shell does, so
75
+ // "killed by SIGINT" does not read as a clean exit 0.
76
+ if (result.status === null && result.signal) {
77
+ process.exit(128 + (require("node:os").constants.signals[result.signal] ?? 0));
78
+ }
79
+ process.exit(result.status ?? 1);
80
+ }
81
+
82
+ // Name the platform. "aval binary not found" sends people to reinstall;
83
+ // "no build for linux/ppc64" tells them the actual answer, which is that they
84
+ // want `cargo install aval` or the shell installer.
85
+ const target = `${process.platform}/${process.arch}`;
86
+ process.stderr.write(
87
+ `${name}: no runnable native binary for ${target}.\n` +
88
+ (failures.length ? ` Installed but could not run:\n${failures.join("\n")}\n` : "") +
89
+ ` npm installs one of: ${Object.values(PACKAGES).flat().join(", ")}\n` +
90
+ ` If your platform is not among them, build from source:\n` +
91
+ ` cargo install aval\n` +
92
+ ` If it is, the optional dependency did not install — try:\n` +
93
+ ` npm install --force aval\n`,
94
+ );
95
+ process.exit(1);
96
+ }
97
+
98
+ module.exports = { become };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "aval-adr",
3
+ "version": "0.6.0",
4
+ "description": "Ask what the current architecture decision is, and get a typed answer",
5
+ "keywords": [
6
+ "adr",
7
+ "architecture",
8
+ "architecture-decision-record",
9
+ "decision-record",
10
+ "governance",
11
+ "documentation"
12
+ ],
13
+ "homepage": "https://github.com/fredericrous/aval#readme",
14
+ "bugs": "https://github.com/fredericrous/aval/issues",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/fredericrous/aval.git"
18
+ },
19
+ "license": "MIT",
20
+ "author": "Frederic Rousseau",
21
+ "type": "commonjs",
22
+ "_comment_name": "The package is `aval-adr` and the binary it installs is `aval`. npm's `aval` was taken in 2016 by an unrelated property validator that has not moved since 0.1.3. crates.io and Homebrew both carry the plain name; only npm needs the suffix.",
23
+ "bin": {
24
+ "aval": "bin/aval.js"
25
+ },
26
+ "files": [
27
+ "bin/aval.js",
28
+ "bin/native.js",
29
+ "README.md"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "optionalDependencies": {
35
+ "@aval-adr/darwin-arm64": "0.6.0",
36
+ "@aval-adr/darwin-x64": "0.6.0",
37
+ "@aval-adr/linux-arm64-gnu": "0.6.0",
38
+ "@aval-adr/linux-x64-gnu": "0.6.0",
39
+ "@aval-adr/linux-x64-musl": "0.6.0",
40
+ "@aval-adr/win32-x64": "0.6.0"
41
+ }
42
+ }