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 +217 -0
- package/bin/aval.js +3 -0
- package/bin/native.js +98 -0
- package/package.json +42 -0
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
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
|
+
}
|