@delorenj/pjangler 1.4.5 → 1.4.6

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 CHANGED
@@ -275,7 +275,7 @@ the same envelope, including `baseline` and `exitCode`.
275
275
  | --- | --- | --- |
276
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
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 |
278
+ | `template_scaffold` | the tracked template's gitlink, remote and cleanliness (fleet-wide), and **every managed role directory compared asset by asset against the template at the committed gitlink** — eight group observations per agent, typed per-asset `items`, `data.scaffold` and `agents[].scaffold` summaries (see *Scaffold parity* below) | every tracked-asset parity rule, plus a `scaffold-rule-disagreement` finding where `hermes.pm-scaffold` and the observer disagree |
279
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
280
  | `runtime` | the role-local runtime directory derived from `role_dir` | `hermes.untracked-runtimes` |
281
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 |
@@ -304,6 +304,82 @@ Story 1.8 owns the systemd observer, 1.9 the live-process observer, and 1.10
304
304
  Bloodbank routing readiness. Until then those domains say so, by name, rather
305
305
  than disappearing.
306
306
 
307
+ ### Scaffold parity
308
+
309
+ `template_scaffold` compares every managed role directory against the tracked
310
+ template **at the gitlink this repository has committed** for
311
+ `templates/hermes-agent` — git objects, never the submodule worktree, never a
312
+ sibling clone, never a branch tip. The contract's `scaffold_manifest` block
313
+ (schema 3) is the policy that says how to read that tree: which subdirectory
314
+ holds the rendered files, how a rendered file is named, which registry fields
315
+ feed each simple `{{ name }}` placeholder (`render_inputs`), which `scaffold.*`
316
+ writable leaf owns each role-relative path (`groups`), which assets are
317
+ compared for presence only and why (`presence_only`: `role.yaml`, `SOUL.md`),
318
+ what may never appear in a template tree (`excluded_patterns`), and which
319
+ role-local directory is ignored runtime nobody owns (`runtime_dir`).
320
+
321
+ Each agent gets **eight observations, one per declared leaf** — `scaffold.role.yaml`,
322
+ `scaffold.SOUL.md`, `scaffold.hermes`, `scaffold.momo`, `scaffold.sentinel.prompt.md`,
323
+ `scaffold.gitignore`, `scaffold.scripts`, `scaffold.runtime-scaffold` — each
324
+ `pass`, `fail` or `error`, with `observed: "<matching>/<owned> assets match"`
325
+ and, on anything but a clean pass, a typed `items[]` list (capped at 100 per
326
+ observation, the clip recorded in `truncated`). Every item carries a
327
+ **role-relative path**, a kind, and a `desired`/`observed` pair that is a 12-hex
328
+ git blob-id prefix or a type/mode word — never a file body, never an absolute
329
+ path:
330
+
331
+ | kind | meaning |
332
+ | --- | --- |
333
+ | `missing` | the template renders it; the role does not have it |
334
+ | `stale-content` | different bytes, and the observed blob exists in the template's lineage — an older release |
335
+ | `locally-modified` | different bytes, and no template version ever shipped them — somebody edited it |
336
+ | `wrong-mode` | same bytes, executable bit differs |
337
+ | `wrong-type` | a directory or symlink where a file was rendered, or the reverse |
338
+ | `unsafe-symlink` | a symlink whose target is absolute or leaves the repository |
339
+ | `unexpected-owned` | a tracked file inside an owned group the template did not render (a committed `.done-*`, an unrendered `.jinja`) — named, never proposed for deletion |
340
+ | `incomplete` | this build could not decide: a render input is missing (`input-missing: display_name`), the template needs control flow (`render-unsupported`), or the bytes were unreadable |
341
+
342
+ `stale-content` versus `locally-modified` is **lineage, not commit state**: a
343
+ verbatim asset is stale when its observed blob exists in the template's object
344
+ database, a rendered asset when it equals the render of one of the last twelve
345
+ versions of its Jinja source. An uncommitted edit is the orthogonal `wip: true`
346
+ flag on the item, and a drifted path that also carries one appears in
347
+ `agents[].scaffold.wip_overlap`. Ignored runtime bytes, git-ignored entries and
348
+ tracked files outside every owned group are **counted, never named**
349
+ (`ignored_entries`, `wip_preserved`, `foreign_tracked`).
350
+
351
+ **Source integrity is a host finding.** `scaffold.source` in `data.host` reads
352
+ `pass` when the committed gitlink is stable (`ls-tree HEAD` and the index
353
+ agree), its object exists, the submodule worktree is at it with no modified
354
+ tracked file, the tree carries no excluded pattern, and every rendered path
355
+ resolves to one declared group. Any other reading — `gitlink-missing`,
356
+ `gitlink-unstable`, `source-uninitialized`, `source-missing-object`,
357
+ `source-mismatched`, `source-dirty`, `source-contaminated`, `source-empty`,
358
+ `manifest-uncovered:<path>` — is `error`, every selected agent's eight groups
359
+ are `error`, and **desired bytes are never taken from the worktree as a
360
+ fallback**. The same code is carried in `data.scaffold.source.integrity`.
361
+
362
+ `data.scaffold.agents` counts every selected agent before any cap
363
+ (`total_registered`, `selected`, `applicable`, `passing`, `drifted`,
364
+ `incomplete`, `exception_authorized`, `unobserved`), and
365
+ `data.scaffold.rule_agreement` says, under `--live`, how the observer and the
366
+ `hermes.pm-scaffold` rule agreed over the subset both compare — a disagreement
367
+ is a `scaffold-rule-disagreement` finding and both readings stand. An operator
368
+ ruling on one agent's scaffold drift lives in
369
+ `health_policy.agent_exceptions[]` (`domain`, `agent_id`, `reason`, `owner`):
370
+ the drifted groups keep their `fail`, carry `justification.kind: "exception"`
371
+ with that entry's own path, and the agent is counted `exception` rather than
372
+ `unhealthy` — `health.healthy` is unaffected, exactly as for a permitted
373
+ identity conflict. A contract with no `scaffold_manifest` still loads; the
374
+ domain then reads `unsupported` under capability `scaffold.manifest`.
375
+
376
+ `--domain template_scaffold` is the surface. A run scoped to any other domain
377
+ spawns zero scaffold probes; `--agent <id>` reads only that agent's role
378
+ directory while `data.scaffold.agents` keeps the fleet-wide totals. The role
379
+ directory is the registry row's `role_dir`, defaulting to
380
+ `<project_path>/agents/hermes/<role>` only when the row is silent
381
+ (`agents[].scaffold.role_dir_source`), and must sit inside `project_path`.
382
+
307
383
  ### Seven states, one precedence
308
384
 
309
385
  | state | meaning |
@@ -12,7 +12,11 @@
12
12
  # agents.* Hermes agent registry, per-agent rows
13
13
  # gateways.* Hermes agent registry, fleet-shared gateway block
14
14
  # profiles.* generated Hermes profile tree
15
- # scaffold.* tracked per-agent role scaffold
15
+ # scaffold.* tracked per-agent role scaffold. Each leaf is mapped to the
16
+ # role-relative path it owns by `scaffold_manifest.groups`, so
17
+ # `scaffold.gitignore` means `.gitignore` and
18
+ # `scaffold.sentinel.prompt.md` means `.scripts/sentinel.prompt.md`
19
+ # -- the leaves name owned asset GROUPS, not store keys.
16
20
  # units.* systemd user units
17
21
  # processes.* observed Hermes processes (read-only)
18
22
  #
@@ -24,12 +28,12 @@
24
28
  # rather than be rejected as an unknown key, because rejecting it would hide the
25
29
  # very drift this contract exists to name.
26
30
 
27
- schema_version: 2
28
- contract_version: 1.1.0
31
+ schema_version: 3
32
+ contract_version: 1.2.0
29
33
 
30
34
  compatibility:
31
35
  min_schema_version: 1
32
- max_schema_version: 2
36
+ max_schema_version: 3
33
37
 
34
38
  x-delonet/provenance:
35
39
  assessment: fleet-convergence-live-assessment-2026-08-31
@@ -393,13 +397,6 @@ health_policy:
393
397
  can dispatch to it is not. Routing readiness is a separate authority
394
398
  from the record and must never be inferred from it.
395
399
  owner_story: "1.10"
396
- - domain: template_scaffold
397
- capability: scaffold.template_ref
398
- reason: >-
399
- A deployed role scaffold records no template ref, so there is no
400
- recorded value on this host to compare against the pinned gitlink and
401
- none may be invented.
402
- owner_story: "1.6"
403
400
  - domain: release_provenance
404
401
  capability: profile.render_generation
405
402
  reason: >-
@@ -459,6 +456,80 @@ health_policy:
459
456
  max_age_days: 365
460
457
  applies_to: registry
461
458
 
459
+ # An operator's ruling on ONE agent's drift in ONE domain. The drifted
460
+ # observation is still reported and still `fail`; what changes is that the
461
+ # agent is counted `exception` rather than `unhealthy`, exactly as a permitted
462
+ # identity conflict is. Ships empty on purpose: a ruling is written here when
463
+ # it is made, never inferred.
464
+ agent_exceptions: []
465
+
466
+ # What a deployed role directory SHOULD contain, as POLICY about the tracked
467
+ # template rather than a copy of it.
468
+ #
469
+ # The bytes come from git at the gitlink the parent repository has COMMITTED for
470
+ # `template_submodule` -- never from the submodule worktree, never from a
471
+ # sibling clone, never from a branch tip. This block says how to read that
472
+ # tree: which subdirectory holds the rendered files, how a rendered file is
473
+ # named, which registry fields feed each simple `{{ name }}` placeholder, which
474
+ # `scaffold.*` writable leaf owns each role-relative path, which assets are
475
+ # compared for presence only (and why), what must never appear in the tree,
476
+ # and which role-local directory is ignored runtime state nobody owns.
477
+ scaffold_manifest:
478
+ template_submodule: templates/hermes-agent
479
+ template_subdirectory: template
480
+ render_suffix: .jinja
481
+ # Placeholder name -> the field that feeds it. Only simple substitution is
482
+ # rendered; a template that needs control flow or a filter in a
483
+ # content-compared asset is `incomplete`, never rendered "as best it can".
484
+ render_inputs:
485
+ agent_id: agents.{agent_id}
486
+ role: agents.{agent_id}.role
487
+ target_repo: agents.{agent_id}.repo
488
+ display_name: agents.{agent_id}.display_name
489
+ ticket_provider: projects.{slug}.ticket_provider.type
490
+ # Declared leaf -> the role-relative path it owns. A trailing `/` owns a
491
+ # directory; longest prefix wins, so the rendered sentinel prompt inside the
492
+ # verbatim `.scripts/` tree has its own leaf. Every key is a writable field of
493
+ # `authorities.tracked_role_scaffold`, which is what makes those eight leaves
494
+ # real paths rather than invented ones.
495
+ groups:
496
+ scaffold.role.yaml: role.yaml
497
+ scaffold.SOUL.md: SOUL.md
498
+ scaffold.hermes: hermes
499
+ scaffold.momo: momo
500
+ scaffold.sentinel.prompt.md: .scripts/sentinel.prompt.md
501
+ scaffold.gitignore: .gitignore
502
+ scaffold.scripts: .scripts/
503
+ scaffold.runtime-scaffold: .runtime-scaffold/
504
+ # Compared for type and mode only. Policy, with a reason each, never a
505
+ # shortcut: any OTHER control-flow template is reported incomplete.
506
+ presence_only:
507
+ - path: role.yaml
508
+ reason: >-
509
+ Rendered with strftime and rewritten by deployment state (verified
510
+ channel identities, operator choices); Copier itself skips it when it
511
+ exists.
512
+ - path: SOUL.md
513
+ reason: >-
514
+ The operator's persona for the agent. The fanout writes it only when
515
+ absent, so its content is the operator's, not the template's.
516
+ # Never allowed in the tree at the gitlink. Matching a tracked file in a role
517
+ # directory makes it `unexpected-owned`; matching a template path makes the
518
+ # source `source-contaminated`.
519
+ excluded_patterns:
520
+ - "__pycache__/"
521
+ - "*.pyc"
522
+ - "*.pyo"
523
+ - "*.log"
524
+ - ".done-*"
525
+ - ".last-run"
526
+ - ".provision.log"
527
+ - ".plane-project-id"
528
+ - ".DS_Store"
529
+ # Ignored role-local runtime. Never compared, never named, never proposed
530
+ # for deletion; owned by nobody.
531
+ runtime_dir: runtime
532
+
462
533
  activation:
463
534
  states:
464
535
  - discovered