@delorenj/pjangler 1.4.3 → 1.4.5
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 +528 -0
- package/contracts/fleet-contract.yaml +513 -0
- package/dist/index.js +6135 -288
- package/dist/mcp-server.js +5335 -479
- package/package.json +4 -2
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +84 -4
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +114 -24
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +354 -43
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +29 -4
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +253 -28
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +142 -11
- package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +3 -1
- package/templates/hermes-agent/template/role.yaml.jinja +4 -0
- package/dist/index.js.map +0 -7
- package/dist/mcp-server.js.map +0 -7
- package/dist/prompt.js.map +0 -7
package/README.md
CHANGED
|
@@ -38,6 +38,531 @@ True `SessionStart` and `SessionEnd` hooks are projected separately from the
|
|
|
38
38
|
project-scoped hook masters. Their per-repository policy stays fail-open and
|
|
39
39
|
disabled until explicitly enabled in `.project.json`.
|
|
40
40
|
|
|
41
|
+
## Fleet contract
|
|
42
|
+
|
|
43
|
+
`pjangler fleet` inspects the 33GOD fleet authority and managed-state contract
|
|
44
|
+
at `contracts/fleet-contract.yaml`. The contract is a declaration, never an
|
|
45
|
+
observation: it records who owns which field, which projections flow in which
|
|
46
|
+
direction, the lifecycle class every managed thing lands in, the canonical
|
|
47
|
+
systemd service model, the activation gate, and the modes that are retired
|
|
48
|
+
drift rather than alternate healthy states.
|
|
49
|
+
|
|
50
|
+
`validate` is strictly read-only — it opens no registry, profile, service, or
|
|
51
|
+
process, and writes nothing anywhere.
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pjangler fleet contract validate # human report
|
|
55
|
+
pjangler fleet contract validate --json # fleet JSON v1 envelope
|
|
56
|
+
pjangler fleet contract validate --contract ./candidate.yaml --json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Exit codes are categorized: `0` valid, `2` malformed contract, `3` contract not
|
|
60
|
+
found, `4` a contract that states something forbidden (dual field ownership, an
|
|
61
|
+
incomplete lifecycle entry, a retired mode declared healthy), `5` a schema
|
|
62
|
+
version this build cannot read, `6` internal.
|
|
63
|
+
|
|
64
|
+
Two things about the tracked contract that are easy to trip over:
|
|
65
|
+
|
|
66
|
+
- **It must be its own canonical serialization.** `contracts/fleet-contract.yaml`
|
|
67
|
+
is re-serialized through `yaml` and compared byte-for-byte, so a hand edit
|
|
68
|
+
with different indentation or a trailing blank line fails with exit `2` even
|
|
69
|
+
though the contract is perfectly valid. Re-save it through the round trip
|
|
70
|
+
(`node -e 'const Y=require("yaml"),f="contracts/fleet-contract.yaml",fs=require("fs");fs.writeFileSync(f,String(Y.parseDocument(fs.readFileSync(f,"utf8"))))'`)
|
|
71
|
+
and the diff disappears. A file passed with `--contract` owes nobody canonical
|
|
72
|
+
formatting: there the round trip is reported as `byte_stable`, not enforced.
|
|
73
|
+
- **`x-`-prefixed keys are yours.** At any depth they round-trip verbatim, are
|
|
74
|
+
reported separately under `data.extensions`, and are never read as policy — so
|
|
75
|
+
provenance, ticket references and local annotations have somewhere to live.
|
|
76
|
+
They are still scanned for credentials and host paths, because a secret in an
|
|
77
|
+
extension is still a secret in a tracked file.
|
|
78
|
+
|
|
79
|
+
## Fleet inventory
|
|
80
|
+
|
|
81
|
+
`pjangler fleet inventory` reads the two canonical registries — the Hermes agent
|
|
82
|
+
registry and the PJangler project registry — plus each repository's
|
|
83
|
+
`.project.json`, and answers the question neither store answers alone: what is
|
|
84
|
+
the whole fleet, and where does it disagree with itself?
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pjangler fleet inventory # human report
|
|
88
|
+
pjangler fleet inventory --json # fleet JSON v1 envelope
|
|
89
|
+
pjangler fleet inventory --agent pjangler-pm --json # one row, full-fleet totals
|
|
90
|
+
pjangler fleet inventory --agent-registry ./copy.yaml # inspect a copy
|
|
91
|
+
pjangler fleet inventory --project-registry ./copy.yaml # inspect a copy
|
|
92
|
+
pjangler fleet inventory --contract ./candidate.yaml # read a candidate contract
|
|
93
|
+
pjangler fleet inventory --deadline-ms 30000 # bound the whole run
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`--contract` and `--deadline-ms` are shared with `pjangler fleet provenance`, and
|
|
97
|
+
mean the same thing on both: the same options, the same defaults, the same
|
|
98
|
+
envelope. `SIGINT` and `SIGTERM` cancel either command (exit `8`).
|
|
99
|
+
|
|
100
|
+
It is strictly read-only. It opens no service, no process, and no network, and
|
|
101
|
+
it creates no directory, project, role, profile, or registry row. Every declared
|
|
102
|
+
path is classified with `lstat`, so a link is *seen* as a link: a symlinked
|
|
103
|
+
profile directory is reported as a symlink with its target as evidence, and the
|
|
104
|
+
target is never substituted for the declared value or used to derive one. (One
|
|
105
|
+
read does traverse the filesystem's own links: the `.project.json` under an
|
|
106
|
+
agent's `project_path` is opened by path. It is confirming evidence only — it can
|
|
107
|
+
never become a field's `source` or its value — but a symlinked `project_path`
|
|
108
|
+
does redirect which manifest is read. Nothing else is opened: a `project_path`
|
|
109
|
+
the classifier calls `relative`, `absent`, `outside-root`, or `not-a-directory`
|
|
110
|
+
is reported as `manifest-not-consulted` and no file is read for it, so the
|
|
111
|
+
evidence an agent is judged against never depends on the directory you ran the
|
|
112
|
+
command from.)
|
|
113
|
+
|
|
114
|
+
Every emitted value carries `{value, source, state}`, where `source` is the
|
|
115
|
+
authority owner `contracts/fleet-contract.yaml` declares for that field path and
|
|
116
|
+
`state` is one of `resolved`, `unresolved`, `conflicted`, or `unobserved`. An
|
|
117
|
+
unknown is an explicit `null` at `unresolved`, never a guess from a convenient
|
|
118
|
+
basename. `.project.json` is read as confirming evidence only: it is never the
|
|
119
|
+
`source` of a field and never a tiebreaker when the two registries disagree.
|
|
120
|
+
|
|
121
|
+
**An unhealthy fleet is data, not a failure.** A fleet with identity conflicts
|
|
122
|
+
exits `0` with `ok: true` and `data.health.healthy: false` — the envelope nulls
|
|
123
|
+
`data` on `ok: false`, so reporting drift as a failure would blank the inventory
|
|
124
|
+
on exactly the runs that matter. Only a *command* failure is nonzero:
|
|
125
|
+
|
|
126
|
+
| exit | meaning |
|
|
127
|
+
| --- | --- |
|
|
128
|
+
| `0` | the command ran — read `data.health.healthy` for the verdict |
|
|
129
|
+
| `2` | a malformed flag value, or a registry that could not be parsed |
|
|
130
|
+
| `3` | a registry that is not there, or an `--agent` id that is not registered |
|
|
131
|
+
| `4` | the fleet contract declares a conflicting authority, an invalid class, or a live retired mode |
|
|
132
|
+
| `5` | the fleet contract declares a schema version this build does not support |
|
|
133
|
+
| `6` | internal |
|
|
134
|
+
| `7` | the run did not finish inside `--deadline-ms` |
|
|
135
|
+
| `8` | the run was cancelled (`SIGINT`/`SIGTERM`, or an aborted MCP request) |
|
|
136
|
+
|
|
137
|
+
Exit `4` and `5` come from the contract, not from a registry: the inventory
|
|
138
|
+
validates `contracts/fleet-contract.yaml` before it reads anything, and refuses
|
|
139
|
+
to attribute provenance against a contract it cannot trust. Run
|
|
140
|
+
`pjangler fleet contract validate` for the diagnostic.
|
|
141
|
+
|
|
142
|
+
Two more things worth knowing:
|
|
143
|
+
|
|
144
|
+
- **`--agent` scopes the rows, never the totals or the verdict.** `data.rows`
|
|
145
|
+
carries the one agent and `data.scope` says the result is scoped, but
|
|
146
|
+
`data.totals`, `data.health` and `data.conflicts` still describe the whole
|
|
147
|
+
registered fleet. A scoped run therefore reports `healthy: false` for a fleet
|
|
148
|
+
that is unhealthy elsewhere — deliberately, because a slice that could report
|
|
149
|
+
"healthy" while the fleet is broken is the one thing an aggregate must never
|
|
150
|
+
do.
|
|
151
|
+
- **`--agent-registry` / `--project-registry` say which bytes to read, not which
|
|
152
|
+
file is canonical.** `data.stores[].configured_path` keeps naming the
|
|
153
|
+
configured store and `inspected_path` names the override.
|
|
154
|
+
|
|
155
|
+
An identity conflict is grouped under a stable id —
|
|
156
|
+
`conflict:{field-path}:{12 hex}` — identical for every participant, on every
|
|
157
|
+
machine, run after run. A group can be declared permitted by adding an entry to
|
|
158
|
+
`classifications.intentionally_unmanaged.entries` in the contract whose `source`
|
|
159
|
+
equals the group's field path and whose `participants` match the group's set
|
|
160
|
+
exactly; a superset never absorbs a claimant nobody ruled on.
|
|
161
|
+
|
|
162
|
+
## Fleet provenance
|
|
163
|
+
|
|
164
|
+
`pjangler fleet provenance` answers the question the inventory does not: *which
|
|
165
|
+
build is each agent actually running?* It pairs every **recorded, pinned, or
|
|
166
|
+
declared** value with its **live** counterpart, each side naming its own source.
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
pjangler fleet provenance # human report
|
|
170
|
+
pjangler fleet provenance --json # fleet JSON v1 envelope
|
|
171
|
+
pjangler fleet provenance --agent pjangler-pm --json # one agent, full-fleet totals
|
|
172
|
+
pjangler fleet provenance --agent-registry ./copy.yaml # inspect a copy
|
|
173
|
+
pjangler fleet provenance --project-registry ./copy.yaml # inspect a copy
|
|
174
|
+
pjangler fleet provenance --contract ./candidate.yaml # read a candidate contract
|
|
175
|
+
pjangler fleet provenance --deadline-ms 30000 # bound the whole run
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`--agent`, `--project-registry`, `--agent-registry`, `--contract` and
|
|
179
|
+
`--deadline-ms` are also accepted by `pjangler fleet inventory`, and both
|
|
180
|
+
commands are exposed as MCP tools with the same options and the same envelope.
|
|
181
|
+
|
|
182
|
+
**One global rule: `desired` is the recorded side, `observed` is the live side.**
|
|
183
|
+
That is what makes the template gitlink structural rather than defensive — the
|
|
184
|
+
recorded gitlink is read from `git ls-files --stage` on the *parent*, so no
|
|
185
|
+
worktree move can make it report the worktree's SHA. `observed` is the
|
|
186
|
+
submodule's own `HEAD`. A reader never has to ask which side is authoritative.
|
|
187
|
+
|
|
188
|
+
Every fact lands in exactly one of six statuses, and **absence is never a
|
|
189
|
+
match**:
|
|
190
|
+
|
|
191
|
+
| status | meaning |
|
|
192
|
+
| --- | --- |
|
|
193
|
+
| `match` | both sides are present and equal |
|
|
194
|
+
| `mismatch` | both sides are present and differ |
|
|
195
|
+
| `dirty` | a cleanliness fact whose observed side is not clean — always its own fact, never a modifier on the value beside it |
|
|
196
|
+
| `missing` | a side that should carry a value carries none |
|
|
197
|
+
| `unsupported` | no comparable value exists without inventing one: nothing records the desired value, or it is spelled as an unexpanded `$VAR` |
|
|
198
|
+
| `unobserved` | the probe did not run, or ran and failed — nothing may be claimed |
|
|
199
|
+
|
|
200
|
+
Within one fact the precedence is
|
|
201
|
+
`unobserved` > `unsupported` > `missing` > `dirty` > `mismatch` > `match`.
|
|
202
|
+
`data.totals.by_status` counts all six, and `data.health` reports `healthy`
|
|
203
|
+
(drift-free) and `complete` (everything that should have been observed was) as
|
|
204
|
+
two separate verdicts — a run that could not reach half the fleet must never
|
|
205
|
+
read as a clean bill.
|
|
206
|
+
|
|
207
|
+
It is strictly read-only, and provably so. Every git probe passes
|
|
208
|
+
`--no-optional-locks`, because a plain `git status` refreshes `.git/index` and a
|
|
209
|
+
command that rewrites an index on 28 repositories is not read-only. The observed
|
|
210
|
+
`hermes` binary is classified by **path** — against the configured release root
|
|
211
|
+
first, then the contract's retired `detect` patterns — and is **never executed**.
|
|
212
|
+
Nothing fetches, pulls, clones, or reaches the network. `~/.hermes/fleet.env` is
|
|
213
|
+
read through a key allowlist, so the Plane API keys beside the fleet paths never
|
|
214
|
+
enter memory at all.
|
|
215
|
+
|
|
216
|
+
Two failure modes are deliberately different. A **per-probe** timeout downgrades
|
|
217
|
+
one fact to `unobserved`, records the probe, sets `health.complete: false`, and
|
|
218
|
+
the run still succeeds. A **whole-run** deadline is a command failure, because a
|
|
219
|
+
truncated provenance report is exactly the kind of partial that must never be
|
|
220
|
+
mistaken for a complete one.
|
|
221
|
+
|
|
222
|
+
**A drifted fleet is data, not a failure.** It exits `0` with `ok: true` and
|
|
223
|
+
`data.health.healthy: false`. Only a *command* failure is nonzero:
|
|
224
|
+
|
|
225
|
+
| exit | meaning |
|
|
226
|
+
| --- | --- |
|
|
227
|
+
| `0` | the command ran — read `data.health.healthy` and `data.health.complete` for the verdicts |
|
|
228
|
+
| `2` | a malformed flag value |
|
|
229
|
+
| `3` | a **registry** that is not there, or an `--agent` id that is not registered — a missing template config or fleet env is a finding, not an exit |
|
|
230
|
+
| `4` | the fleet contract declares a conflicting authority, an invalid class, or a live retired mode |
|
|
231
|
+
| `5` | the fleet contract declares a schema version this build does not support |
|
|
232
|
+
| `6` | internal |
|
|
233
|
+
| `7` | the whole-run `--deadline-ms` budget expired; no partial result is reported |
|
|
234
|
+
| `8` | the run was cancelled (`SIGINT`/`SIGTERM`, or an aborted MCP request); no probe child survives |
|
|
235
|
+
|
|
236
|
+
`data` is deterministic: no timestamp, duration, hostname, or ordering by
|
|
237
|
+
completion. Two runs over unchanged state produce byte-identical `data`, which is
|
|
238
|
+
what lets the MCP tool result be compared to the CLI `--json` envelope by
|
|
239
|
+
equality rather than by resemblance.
|
|
240
|
+
|
|
241
|
+
Two provenance questions this host records nothing to answer, and which are
|
|
242
|
+
therefore reported as `unsupported` with their observed evidence rather than
|
|
243
|
+
guessed: a deployed role scaffold carries no template ref (it renders no
|
|
244
|
+
`.copier-answers.yml`), and a generated profile config carries only the
|
|
245
|
+
`GENERATED FILE -- DO NOT EDIT` marker — no generation counter, digest, or
|
|
246
|
+
sidecar — so a sha256 of its bytes is the only stable evidence.
|
|
247
|
+
|
|
248
|
+
## Fleet status
|
|
249
|
+
|
|
250
|
+
`pjangler fleet status` answers the question the inventory and provenance
|
|
251
|
+
commands do not: *is the fleet correct?* One read-only traversal of the registry
|
|
252
|
+
reports every registered agent across **all nine observation domains**, plus one
|
|
253
|
+
aggregate — in a single invocation.
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
pjangler fleet status # human report
|
|
257
|
+
pjangler fleet status --json # fleet JSON v1 envelope
|
|
258
|
+
pjangler fleet status --live --json # authorize the recipe-owned audit rules
|
|
259
|
+
pjangler fleet status --agent pjangler-pm --json # one agent, full-fleet totals
|
|
260
|
+
pjangler fleet status --domain profile --json # one domain, nothing else collected
|
|
261
|
+
pjangler fleet status --agent-registry ./copy.yaml # inspect a copy
|
|
262
|
+
pjangler fleet status --project-registry ./copy.yaml # inspect a copy
|
|
263
|
+
pjangler fleet status --contract ./candidate.yaml # read a candidate contract
|
|
264
|
+
pjangler fleet status --deadline-ms 60000 # bound the whole run
|
|
265
|
+
pjangler fleet status --baseline ./base.json --json # correlate against a prior run
|
|
266
|
+
pjangler fleet status --exit-code # project the verdict onto the exit status
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
It is exposed as the `pjangler_fleet_status` MCP tool with the same options and
|
|
270
|
+
the same envelope, including `baseline` and `exitCode`.
|
|
271
|
+
|
|
272
|
+
### The nine domains, and what each observes today
|
|
273
|
+
|
|
274
|
+
| domain | observed today | what `--live` adds |
|
|
275
|
+
| --- | --- | --- |
|
|
276
|
+
| `registry` | the agent row itself: well-formedness, identity conflicts, correlation to a project record | `hermes.registry-parity` (**host-scoped** → `data.host`, unfiltered runs only†) |
|
|
277
|
+
| `project_binding` | the row's board binding and whether the repository's `.project.json` agrees | the notebook and `sot.project-json` rules |
|
|
278
|
+
| `template_scaffold` | the tracked template's gitlink, remote and cleanliness (fleet-wide); `scaffold.template_ref` is `unsupported` — a deployed role scaffold records none | every tracked-asset parity rule |
|
|
279
|
+
| `profile` | the generated profile directory, `lstat`ed and never followed; a symlink is a `fail`, because the contract declares `symlink_allowed: false` | `hermes.runtime-singleton`; `hermes.profile-wiring` (**host-scoped**) |
|
|
280
|
+
| `runtime` | the role-local runtime directory derived from `role_dir` | `hermes.untracked-runtimes` |
|
|
281
|
+
| `systemd` | `unsupported` — no systemd observer exists in this release; the unit names are the contract's expectations, carried as evidence | `systemd.sentinel` (**host-scoped**, unfiltered runs only†), never promoted to an agent |
|
|
282
|
+
| `live_process` | `unsupported` — there is no `ps`, `pgrep`, or `/proc` read anywhere in this build | nothing |
|
|
283
|
+
| `bloodbank` | the stored routing record and the strict activation flag; liveness is `unsupported` | `hermes.fleet-config` (**host-scoped**, unfiltered runs only†) |
|
|
284
|
+
| `release_provenance` | every provenance fact for the agent: executable, checkout, remote, HEAD, cleanliness | nothing |
|
|
285
|
+
|
|
286
|
+
† `registry`, `systemd` and `bloodbank` are each observed live by exactly one
|
|
287
|
+
rule, and that rule is **host-scoped** — it can add nothing to any agent's
|
|
288
|
+
record, only to `data.host`. Filters constrain collection, so
|
|
289
|
+
`--domain systemd --live` spawns no audit child at all and `data.host` comes back
|
|
290
|
+
empty; the run says so with an `audit-host-rules-not-collected` finding naming
|
|
291
|
+
the rule it did not collect. Run without `--domain` to get them.
|
|
292
|
+
|
|
293
|
+
A `--domain` run whose selected domain *is* audit-fed does spawn children, and
|
|
294
|
+
those children report every rule — including host-scoped rules for domains you
|
|
295
|
+
did not select. Those results are not carried in `data.host` (only the selected
|
|
296
|
+
domain is emitted), and the run says so too, with one
|
|
297
|
+
`audit-host-rules-not-reported` finding per rule. **An empty `data.host` never
|
|
298
|
+
means "this machine is clean"** on a filtered run; the findings say which reading
|
|
299
|
+
you are not being shown. For the same reason a host finding's `retrieval` is the
|
|
300
|
+
unfiltered `--live` invocation whenever its domain is one of the three above —
|
|
301
|
+
the narrowed command could not return it.
|
|
302
|
+
|
|
303
|
+
Story 1.8 owns the systemd observer, 1.9 the live-process observer, and 1.10
|
|
304
|
+
Bloodbank routing readiness. Until then those domains say so, by name, rather
|
|
305
|
+
than disappearing.
|
|
306
|
+
|
|
307
|
+
### Seven states, one precedence
|
|
308
|
+
|
|
309
|
+
| state | meaning |
|
|
310
|
+
| --- | --- |
|
|
311
|
+
| `pass` | observed, and in the state it should be in |
|
|
312
|
+
| `warn` | observed, imperfect, and not a gate |
|
|
313
|
+
| `skip` | declared not applicable; does **not** reduce completeness |
|
|
314
|
+
| `fail` | observed, and wrong |
|
|
315
|
+
| `unsupported` | no adapter exists in this release; counted and visible, but it does **not** reduce completeness |
|
|
316
|
+
| `unobserved` | applicable, and not read; **does** reduce completeness |
|
|
317
|
+
| `error` | collection itself failed; never silently a `pass`, never a dropped agent |
|
|
318
|
+
|
|
319
|
+
Within a domain and then across domains the precedence is
|
|
320
|
+
|
|
321
|
+
1. `unsupported` **yields** whenever the domain produced any other state. It is a
|
|
322
|
+
statement about this build, not about the fleet, so it is the strongest answer
|
|
323
|
+
for a domain with nothing else (`live_process`) and the weakest thing to report
|
|
324
|
+
for a domain that also has real findings — without this, a
|
|
325
|
+
`template_scaffold` domain carrying one permanent "no template ref is
|
|
326
|
+
recorded" reported `unsupported` while 135 tracked assets were failing.
|
|
327
|
+
2. Then, over whatever is left:
|
|
328
|
+
`error` > `unobserved` > `unsupported` > `fail` > `warn` > `skip` > `pass`.
|
|
329
|
+
|
|
330
|
+
Both halves are the rule; the ordered list alone is not. `rollUp` in
|
|
331
|
+
`src/fleet/status.ts` applies them in that order.
|
|
332
|
+
|
|
333
|
+
### What `--live` does and does not authorize
|
|
334
|
+
|
|
335
|
+
`--live` authorizes **bounded, read-only host and network observation**, and
|
|
336
|
+
nothing else: it runs the recipe-owned audit rules per repository as bounded
|
|
337
|
+
child processes, because one of them (`bmad.version`) makes a real `npm view`
|
|
338
|
+
call. It never authorizes mutation, process control, service changes, board
|
|
339
|
+
changes, or Bloodbank activation, and it does not conjure a systemd,
|
|
340
|
+
live-process, or Bloodbank-liveness observer.
|
|
341
|
+
|
|
342
|
+
Each repository is audited as a child of this build with a **narrow, allowlisted
|
|
343
|
+
environment**, so no credential in your shell or in `~/.hermes/fleet.env` ever
|
|
344
|
+
reaches it. Each child is time-boxed: one hung `systemctl` downgrades that
|
|
345
|
+
repository's audit-fed domains to `unobserved` and leaves every other agent
|
|
346
|
+
fully reported.
|
|
347
|
+
|
|
348
|
+
Filters constrain **collection**, not just emission: `--domain registry` spawns
|
|
349
|
+
no audit child and no provenance probe, and `--agent <id>` spawns neither for any
|
|
350
|
+
other agent. It holds per probe FAMILY too — `--domain template_scaffold` runs
|
|
351
|
+
the gitlink and submodule probes and no checkout probe, and
|
|
352
|
+
`--domain release_provenance` the reverse — so a filtered run never pays for
|
|
353
|
+
facts it would discard.
|
|
354
|
+
|
|
355
|
+
### Four axes, because one word cannot carry four questions
|
|
356
|
+
|
|
357
|
+
Every observation carries four *separate* axes, and collapsing any two of them is
|
|
358
|
+
how "we did not look" becomes "it is fine".
|
|
359
|
+
|
|
360
|
+
| axis | values | what it answers |
|
|
361
|
+
| --- | --- | --- |
|
|
362
|
+
| `state` | `pass` `warn` `skip` `fail` `unsupported` `unobserved` `error` | what was concluded |
|
|
363
|
+
| `applicability` | `required` `optional` `not_applicable` `deferred` `exception` | whether it was required, and if not, on whose authority |
|
|
364
|
+
| `evidence` | `direct` `declared` `derived` `absent` | how strongly it is supported |
|
|
365
|
+
| `freshness` | `current` `stale` `unknown` `not_applicable` | whether the evidence is still current |
|
|
366
|
+
|
|
367
|
+
`evidence: "declared"` is the load-bearing one. A registry field that *asserts*
|
|
368
|
+
something with nothing verifying it — a stored routing target, an activation
|
|
369
|
+
flag, a recorded unit name — is `declared`, never `direct`. A `declared`
|
|
370
|
+
observation may be `pass` on its own record, but it can never set
|
|
371
|
+
`lifecycle.capability_readiness: "ready"` and never contributes to `proven`.
|
|
372
|
+
`derived` is a reading computed across other rows, such as an identity conflict.
|
|
373
|
+
|
|
374
|
+
**Freshness is a bucket, never an age.** `data` is byte-identical across two runs
|
|
375
|
+
over unchanged state, and an age in seconds is not. The reference instant is
|
|
376
|
+
captured once per run and never serialized; each `health_policy.freshness` entry
|
|
377
|
+
declares a `max_age_days`, and only the bucket is emitted.
|
|
378
|
+
|
|
379
|
+
### `health_policy`: the only thing that can authorize a gap
|
|
380
|
+
|
|
381
|
+
`contracts/fleet-contract.yaml` carries an optional `health_policy` root block.
|
|
382
|
+
It is the **only** place a skip, a warning, a deferred capability, or a managed
|
|
383
|
+
exception can be justified — nothing is inferred from a summary, a severity, or
|
|
384
|
+
the absence of other findings.
|
|
385
|
+
|
|
386
|
+
| key | authorizes |
|
|
387
|
+
| --- | --- |
|
|
388
|
+
| `required_domains` | which domains must be observed before proof can be claimed |
|
|
389
|
+
| `deferred_capabilities[]` | one `unsupported` answer, with a `reason` and the `owner_story` that will implement the observer |
|
|
390
|
+
| `allowed_warnings[]` | one rule whose `warn` is upstream cadence rather than fleet drift |
|
|
391
|
+
| `allowed_skips[]` | one rule or domain whose `skip` is a declared property of a read-only run |
|
|
392
|
+
| `freshness[]` | how long one recorded timestamp counts as current evidence |
|
|
393
|
+
|
|
394
|
+
An authorized gap is still **reported**, with its own state; what changes is
|
|
395
|
+
whether the aggregate may claim it was proven. Every justified observation names
|
|
396
|
+
the entry that authorized it in `justification.policy`, so an operator can open
|
|
397
|
+
the contract at that path.
|
|
398
|
+
|
|
399
|
+
`health.unjustified` counts the three states the contract can actually
|
|
400
|
+
authorize — `warn`, `skip` and `unsupported` — and no others. A `fail` or an
|
|
401
|
+
`error` is not something a policy entry may excuse; those are what
|
|
402
|
+
`health.healthy` is for, and an `unobserved` is a coverage question `complete`
|
|
403
|
+
already answers. A contract with **no** `health_policy` block still loads — it
|
|
404
|
+
is a schema-1 contract — and then authorizes nothing: every `warn`, `skip` and
|
|
405
|
+
`unsupported` is unjustified, `proven` is false, and one
|
|
406
|
+
`health-policy-undeclared` finding names the missing block rather than the run
|
|
407
|
+
failing.
|
|
408
|
+
|
|
409
|
+
Adding the block was a grammar change, so the tracked contract is
|
|
410
|
+
`schema_version: 2` at `contract_version: 1.1.0`. This build reads schema 1 and 2.
|
|
411
|
+
|
|
412
|
+
### Three verdicts, and which one to read
|
|
413
|
+
|
|
414
|
+
```
|
|
415
|
+
verdict = !healthy -> "unhealthy" drift is PROVEN
|
|
416
|
+
: !complete || stale || unknown || unjustified -> "unproven" nothing is proven either way
|
|
417
|
+
: "healthy"
|
|
418
|
+
proven = verdict === "healthy" && fleet_complete
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
`health.healthy` and `health.complete` keep exactly the meanings they had:
|
|
422
|
+
`healthy` is "no `fail`, no `error`" (the fleet is not *wrong*), `complete` is
|
|
423
|
+
"nothing unobserved, no collection error, no truncation, no contradiction" (this
|
|
424
|
+
run *read* all of it). `health.verdict` is the aggregate built on top of both,
|
|
425
|
+
and it is what the report headline and `data.health.exit_category` lead with — so
|
|
426
|
+
`healthy` can no longer be claimed over a fleet whose audit-fed half was never
|
|
427
|
+
opened, while `healthy` itself still means what story 1.4 pinned it to mean.
|
|
428
|
+
|
|
429
|
+
`health.proven` is the only field that means *we read all of it and it was right*.
|
|
430
|
+
|
|
431
|
+
`health.freshness_unknown` sits beside `health.stale` and blocks `proven` just
|
|
432
|
+
as hard: a policy entry that applies to a field no row populates buckets every
|
|
433
|
+
reading `unknown`, and if that gated nothing the entry would validate, change
|
|
434
|
+
nothing, and read as though the fleet had been checked.
|
|
435
|
+
|
|
436
|
+
Beside them: `health.stale`, `health.freshness_unknown`, `health.unjustified`, `health.contradictions`, and
|
|
437
|
+
`health.members` — every **selected** agent in exactly one of `healthy`,
|
|
438
|
+
`unhealthy`, `incomplete`, `deferred`, `exception`, `unclassified`. The six counts
|
|
439
|
+
sum to `scope.selected_agents`, not to the records the envelope's cap let
|
|
440
|
+
through.
|
|
441
|
+
|
|
442
|
+
### Severity, repair class, and one exact next action
|
|
443
|
+
|
|
444
|
+
Every non-pass observation and every host finding carries an `owner`, an
|
|
445
|
+
`observed`/`desired` pair, a `severity`, a `repair` class, and one `next_action`.
|
|
446
|
+
Each is derived from a real field — the audit rule's own `fixable`, its
|
|
447
|
+
`rule_scope`, and the contract's `activation.execution_authority` — never from
|
|
448
|
+
prose.
|
|
449
|
+
|
|
450
|
+
| `repair` | condition | next action |
|
|
451
|
+
| --- | --- | --- |
|
|
452
|
+
| `automatic` | an audit rule, project-scoped, reporting `fixable` | the exact `pjangler migrate <rule_id> <repo> --dry-run` |
|
|
453
|
+
| `approval-gated` | the observation's field **is** `activation.execution_authority` (`strict: true`, `default: deny`) | the activation route, and it names the authority |
|
|
454
|
+
| `blocked` | a contract-declared deferred capability | nothing to run in this release; the action names the owning story |
|
|
455
|
+
| `other-owner` | a host-scoped rule | the host route — no work in any repository changes it |
|
|
456
|
+
| `manual` | everything else that needs a decision | the retrieval that returns the observation alone |
|
|
457
|
+
| `none` | a pass, or a declared-not-applicable skip | the retrieval |
|
|
458
|
+
|
|
459
|
+
Severity is `state` × `applicability`: an `error` is `critical`; a `fail` on a
|
|
460
|
+
required domain is `critical` and elsewhere `high`; an `unobserved` required
|
|
461
|
+
domain is `high`; an unjustified `warn` or a `stale` reading is `medium`; a
|
|
462
|
+
**justified** `warn`, `unsupported` or `stale` is `low`; a `pass` and a justified
|
|
463
|
+
`skip` are `info`. An *unjustified* `unsupported` outranks a justified one —
|
|
464
|
+
same observation, same build, and the only difference is whether anyone wrote
|
|
465
|
+
down that it was expected.
|
|
466
|
+
|
|
467
|
+
**A recommended command is read-only unless it is labelled.**
|
|
468
|
+
`next_action_class` is `"read-only"` or `"requires-authorization"`, and a
|
|
469
|
+
`requires-authorization` action names the authorization in the string itself.
|
|
470
|
+
|
|
471
|
+
`data.findings` is stable-sorted by gating impact, then severity, then scope,
|
|
472
|
+
then agent, then domain, then `finding_id` — **before** any cap, on both the
|
|
473
|
+
machine and the human path. A gating finding at position 26 of an unsorted list
|
|
474
|
+
is silently dropped by the report's cap of 25, which is exactly the failure the
|
|
475
|
+
sort exists to prevent.
|
|
476
|
+
|
|
477
|
+
### Lifecycle: four values, never one boolean
|
|
478
|
+
|
|
479
|
+
Each agent record carries `lifecycle` with four separate fields.
|
|
480
|
+
`desired_state` is what the registry declares as the target for that row — a
|
|
481
|
+
statement of intent, never a claim about the agent. `observed_state` is the
|
|
482
|
+
furthest state this run actually proved, and it can never read `routing_ready` or
|
|
483
|
+
`activated` in this release because no observer for either exists.
|
|
484
|
+
`capability_readiness` is never `ready` for the same reason: a `declared`
|
|
485
|
+
registry field is not a direct observation of the shared gateway. `activation`
|
|
486
|
+
reports the strict flag verbatim, and the contract's default is deny.
|
|
487
|
+
|
|
488
|
+
### `--baseline`: two runs, correlated read-only
|
|
489
|
+
|
|
490
|
+
```bash
|
|
491
|
+
pjangler fleet status --json > base.json
|
|
492
|
+
pjangler fleet status --baseline base.json --json
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
`--baseline` opens a prior status document **for reading and nothing else**, and
|
|
496
|
+
no state is ever written to disk to compute a transition. Findings are joined on
|
|
497
|
+
`finding_id`, a sha256 prefix that is stable across runs and identical on the CLI
|
|
498
|
+
and MCP adapters, and `data.transitions[]` reports every `appeared`, `resolved`,
|
|
499
|
+
`state_changed`, `severity_changed` and `evidence_changed`. An **unchanged**
|
|
500
|
+
finding emits nothing, so a byte-identical baseline produces an empty array. An
|
|
501
|
+
baseline is refused as `INVALID_INPUT` at exit 2, naming the path, before a
|
|
502
|
+
single probe or audit child spawns, when it is unreadable, unparseable, not a
|
|
503
|
+
`fleet.status` document, **taken under a different `--agent`/`--domain` scope**,
|
|
504
|
+
or **written by a run whose own output was clipped**. The scope check is the
|
|
505
|
+
load-bearing one: a document taken over the whole fleet and diffed by a
|
|
506
|
+
`--agent alpha` run would otherwise report `resolved` for every other agent —
|
|
507
|
+
"it got fixed" about observations the run never collected. `--live` is
|
|
508
|
+
deliberately *not* part of the scope, because reading more than the baseline did
|
|
509
|
+
is a real transition.
|
|
510
|
+
|
|
511
|
+
### The exit taxonomy, and why the projection is opt-in
|
|
512
|
+
|
|
513
|
+
`data.health.exit_category` is `ok`, `unhealthy`, or `incomplete`, and **both
|
|
514
|
+
adapters carry it** — it is the discriminant an MCP client had no way to read
|
|
515
|
+
before, because `isError` is `false` for a fully unhealthy fleet.
|
|
516
|
+
|
|
517
|
+
| category | verdict | `--exit-code` exits |
|
|
518
|
+
| --- | --- | --- |
|
|
519
|
+
| `ok` | `healthy` | 0 |
|
|
520
|
+
| `unhealthy` | `unhealthy` | **10** |
|
|
521
|
+
| `incomplete` | `unproven` | **11** |
|
|
522
|
+
|
|
523
|
+
A contradiction is reported only where one source **proved a failure** and
|
|
524
|
+
another reported a **pass** for the same `(agent, domain, field)` — never on a
|
|
525
|
+
`warn` against a `pass`, and never on two differing non-pass states. The narrow
|
|
526
|
+
rule is deliberate: `DOMAIN_FIELD` gives every rule in a domain one contract
|
|
527
|
+
field path, so even this fires on readings that are both true, and widening it
|
|
528
|
+
to "any two states that differ" would make `complete` meaningless.
|
|
529
|
+
|
|
530
|
+
`unhealthy` and `incomplete` are `ok: true` states — the command succeeded, the
|
|
531
|
+
fleet did not — so they are not error codes and never null out `data`. The
|
|
532
|
+
default exit stays **0**: `fleet status` is an observation command, gating CI is a
|
|
533
|
+
later story's job, and a `mise run fleet:status` that is permanently red on a real
|
|
534
|
+
fleet teaches an operator to ignore it. A *command* failure still wins: an
|
|
535
|
+
unknown `--agent` is exit 3 whether or not `--exit-code` was given.
|
|
536
|
+
|
|
537
|
+
**Host-scoped findings are reported once**, deduped by rule id, in `data.host`.
|
|
538
|
+
They never reach a per-agent record and never make an agent or the fleet
|
|
539
|
+
unhealthy: no amount of work in a repository can change a condition about this
|
|
540
|
+
machine, so failing the repository for it is a category error.
|
|
541
|
+
|
|
542
|
+
**An unhealthy fleet is data, not a failure.** By default it exits `0` with
|
|
543
|
+
`ok: true` and `data.health.verdict: "unhealthy"`. Only a *command* failure is
|
|
544
|
+
nonzero without `--exit-code`:
|
|
545
|
+
|
|
546
|
+
| exit | meaning |
|
|
547
|
+
| --- | --- |
|
|
548
|
+
| `0` | the command ran — read `data.health.verdict` for the answer |
|
|
549
|
+
| `2` | a malformed flag value, a `--domain` that is not one of the nine, or a `--baseline` that could not be read or parsed |
|
|
550
|
+
| `3` | an `--agent` id that is not registered, or a registry that is not there |
|
|
551
|
+
| `4` | the fleet contract declares a conflicting authority, an invalid class, or a live retired mode |
|
|
552
|
+
| `5` | the fleet contract declares a schema version this build does not support |
|
|
553
|
+
| `6` | internal |
|
|
554
|
+
| `7` | the whole-run `--deadline-ms` budget expired; no partial result is reported |
|
|
555
|
+
| `8` | the run was cancelled (`SIGINT`/`SIGTERM`, or an aborted MCP request); no audit child survives |
|
|
556
|
+
| `10` | **`--exit-code` only** — `data.health.verdict` is `unhealthy` |
|
|
557
|
+
| `11` | **`--exit-code` only** — `data.health.verdict` is `unproven` |
|
|
558
|
+
|
|
559
|
+
`data` is deterministic: no timestamp, duration, pid, hostname, or ordering by
|
|
560
|
+
completion — the audit child's `auditedAt` is dropped at the boundary and every
|
|
561
|
+
path is home-redacted. Two runs over unchanged state produce byte-identical
|
|
562
|
+
`data`, and every `--json` document is written through an **awaited stdout
|
|
563
|
+
drain**, so it survives a file, a pty, a shell pipe, and a `spawn` capture
|
|
564
|
+
identically at any size.
|
|
565
|
+
|
|
41
566
|
## Orienting in a repo
|
|
42
567
|
|
|
43
568
|
`describe` reads a repo and reports what it actually is — detected type,
|
|
@@ -133,3 +658,6 @@ Exposed tools:
|
|
|
133
658
|
- `pjangler_describe_recipe`
|
|
134
659
|
- `pjangler_run_recipe`
|
|
135
660
|
- `pjangler_deploy_hermes_agent`
|
|
661
|
+
- `pjangler_fleet_inventory`
|
|
662
|
+
- `pjangler_fleet_provenance`
|
|
663
|
+
- `pjangler_fleet_status`
|