@delorenj/pjangler 1.4.2 → 1.4.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.
Files changed (39) hide show
  1. package/README.md +528 -0
  2. package/contracts/fleet-contract.yaml +513 -0
  3. package/dist/index.js +9333 -1509
  4. package/dist/mcp-server.js +6634 -1096
  5. package/dist/prompt.js +2 -1
  6. package/package.json +10 -4
  7. package/templates/hermes-agent/copier.yml +16 -3
  8. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
  9. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
  10. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
  11. package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
  12. package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
  13. package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
  14. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
  15. package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
  16. package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
  17. package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
  18. package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
  19. package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
  20. package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
  21. package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
  22. package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
  23. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
  24. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
  25. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +93 -4
  26. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
  27. package/templates/hermes-agent/template/.scripts/providers/linear.sh +138 -25
  28. package/templates/hermes-agent/template/.scripts/providers/plane.sh +408 -51
  29. package/templates/hermes-agent/template/.scripts/providers/trello.sh +54 -6
  30. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +257 -43
  31. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +142 -25
  32. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
  33. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
  34. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +3 -1
  35. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
  36. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
  37. package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
  38. package/templates/hermes-agent/template/hermes.jinja +1 -1
  39. package/templates/hermes-agent/template/role.yaml.jinja +15 -4
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`