feat(skills): adopt adapted planning and delivery cores #21

Manually merged
bryan merged 18 commits from bryan/agent-skill-workflow-adoption into main 2026-08-21 15:59:03 +00:00
Owner

Summary

Adopts a smaller, curated planning and delivery workflow across Hermes, Claude, and OpenCode, based partly on Matt Pocock’s MIT-licensed agent skills.

Companion learning-zone PR: bryan/second-brain#1

What changed

  • Adds adapted grilling, wayfinder, tdd, diagnosing-bugs, code-review, codebase-architecture, and Hermes-only guided-learning skills.
  • Replaces the stale git-master and agent-workspace routes with focused owners and updates affected workflows.
  • Adds a private-Forgejo Wayfinder adapter with:
    • preview-first mutations;
    • exact map and ticket scoping;
    • server-ordered claim and release records;
    • native dependency tracking;
    • retry-safe map and ticket creation;
    • append-only resolution and decision-index comments, leaving human-edited map bodies untouched.
  • Consolidates PR review into Standards, Spec, and conditional Risk lanes.
  • Adds deterministic upstream provenance and a read-only weekly update watcher.
  • Preserves adr-and-spec-coach during the pilot for side-by-side evaluation.

Wayfinder state model

Forgejo does not provide compare-and-swap semantics for issue-body updates. To avoid overwriting concurrent human edits, Wayfinder never rewrites an existing map or ticket body.

Claims, resolutions, and decision-index entries are exact-schema append-only comments. The current decision index is derived deterministically from those records. Corrected decisions require reopening and re-claiming the ticket; the newer decision supersedes the prior entry while preserving history.

Verification

  • Five focused shipping-acceptance regression classes pass.
  • 575 Python tests pass for the candidate.
  • 79 shell integration tests pass.
  • Isolated Hermes installation and Qwen skill resolution pass.
  • Watcher output is deterministic across repeated runs.
  • All branch commits are signed.
  • The branch merges cleanly with current main.

Current main has one unrelated stale source-text assertion in the SGG morning-brief suite; the same failure reproduces on main without this PR.

Known boundary

No real Forgejo mutation smoke has run yet. The adapter is covered by the offline fake and read-only live checks, but the planned disposable-private-tracker smoke remains part of post-merge activation.

Activation

Merging this PR does not activate the new workflows. Skill reconciliation, cron creation, runtime reloads, and the first Wayfinder pilot remain separately gated.

## Summary Adopts a smaller, curated planning and delivery workflow across Hermes, Claude, and OpenCode, based partly on Matt Pocock’s MIT-licensed agent skills. Companion learning-zone PR: https://git.snowboardtechie.com/bryan/second-brain/pulls/1 ## What changed - Adds adapted `grilling`, `wayfinder`, `tdd`, `diagnosing-bugs`, `code-review`, `codebase-architecture`, and Hermes-only `guided-learning` skills. - Replaces the stale `git-master` and `agent-workspace` routes with focused owners and updates affected workflows. - Adds a private-Forgejo Wayfinder adapter with: - preview-first mutations; - exact map and ticket scoping; - server-ordered claim and release records; - native dependency tracking; - retry-safe map and ticket creation; - append-only resolution and decision-index comments, leaving human-edited map bodies untouched. - Consolidates PR review into Standards, Spec, and conditional Risk lanes. - Adds deterministic upstream provenance and a read-only weekly update watcher. - Preserves `adr-and-spec-coach` during the pilot for side-by-side evaluation. ## Wayfinder state model Forgejo does not provide compare-and-swap semantics for issue-body updates. To avoid overwriting concurrent human edits, Wayfinder never rewrites an existing map or ticket body. Claims, resolutions, and decision-index entries are exact-schema append-only comments. The current decision index is derived deterministically from those records. Corrected decisions require reopening and re-claiming the ticket; the newer decision supersedes the prior entry while preserving history. ## Verification - Five focused shipping-acceptance regression classes pass. - 575 Python tests pass for the candidate. - 79 shell integration tests pass. - Isolated Hermes installation and Qwen skill resolution pass. - Watcher output is deterministic across repeated runs. - All branch commits are signed. - The branch merges cleanly with current `main`. Current `main` has one unrelated stale source-text assertion in the SGG morning-brief suite; the same failure reproduces on `main` without this PR. ## Known boundary No real Forgejo mutation smoke has run yet. The adapter is covered by the offline fake and read-only live checks, but the planned disposable-private-tracker smoke remains part of post-merge activation. ## Activation Merging this PR does not activate the new workflows. Skill reconciliation, cron creation, runtime reloads, and the first Wayfinder pilot remain separately gated.
`git-master` carried no YAML frontmatter, so no runtime could ever
discover it; `agent-workspace` existed mainly to hold the
`resolve_trunk_root` pattern that every other skill cited by hand.

- Move canonical trunk/worktree resolution into `worktrunk` and point
  `issue-work`, `pr-self-review`, `issue-create`, and the OpenCode
  agents at it. `--path-format=absolute` is now explicit: without it
  `dirname` on a relative common-dir silently yields the wrong trunk.
- Replace `pr-self-review`'s `.notes`-era lookup with the three places
  prior context actually lives — project vault, repository decision
  sources, Hindsight — read-only, and never auto-created.
- Drop routes to bundled `test-driven-development`,
  `systematic-debugging`, and `requesting-code-review`; describe the
  capability instead, so the adapted canonical cores can claim it next.
- Uncurate and delete both retired pool skills.
- Fix the reconciler suite's `snapshot()`: GNU-only `find -printf`
  produced an empty listing on macOS, so the idempotency assertion was
  comparing two empty strings.

Verified: bash tests/test-reconcile-agent-skills.sh — 37 tests, 0 failures.
Adapt six skills from Matt Pocock's MIT-licensed suite (v1.2.3, pinned at
885e2ca) into the canonical pool, with a machine-readable provenance
ledger under dot-agents/upstreams/ recording every local divergence and
every upstream rule accepted or rejected. Adaptations, not vendored
copies: each one is reviewed local source from here on, and the pin only
advances by a human decision.

- `grilling` — single-session frontier-first deliberation. Requires
  explicit invocation, tracks verified/inferred/proposed/accepted status,
  and batches a round only when the answers genuinely cannot reshape each
  other. Upstream's `to-questionnaire` folds in as a disclosed reference
  rather than a second top-level skill.
- `tdd` — adds the pre-agreed-seam path an approved plan supplies, which
  is what lets `issue-work` implement unattended, and an explicit
  red-for-the-right-reason step.
- `diagnosing-bugs` — keeps the multi-component boundary tracing from the
  bundled workflow it replaces, and stops for an architecture discussion
  when repeated fixes reveal systemic coupling.
- `code-review` — the lane definitions (Standards, Spec, conditional
  Risk) as a primitive; `pr-self-review` will own the loop around them.
- `codebase-architecture` — upstream's vocabulary and survey skills
  merged into one owner of the terms. The generated HTML report is not
  adopted; the rejection is stated in the skill.
- `guided-learning` — Hermes-only, explicit invocation, refuses to write
  into its own installed directory, and records only demonstrated
  understanding with mandatory evidence.

Also lands the two writing-governance refinements on their existing
owners: sharpen a failed pointer's trigger before inlining its body
(dot-agents/README.md), and treat documentation that repeats a cheap
manifest/config/`--help` lookup as a stale cache (skill-retrospective).

Verified: bash tests/test-reconcile-agent-skills.sh — 37 tests, 0 failures.
Charts work too large for one session as a map issue plus decision
tickets, resolved one per session until the route is clear. Adapted from
Matt Pocock's `wayfinder`; provenance in dot-agents/upstreams.

The mechanics are a deterministic adapter rather than API prose in the
skill body, because prose drifts and this one mutates a real tracker:

- **Private trackers only.** `bryan/dotfiles` is public on the same
  Forgejo host, so this is enforced by an API check, not a convention —
  `create-map` and `create-ticket` refuse a public repository before
  issuing any write.
- **Preview by default.** Every mutation renders the exact content it
  would write and needs an explicit `--apply`.
- **Managed regions.** Writes replace text between stable markers; human
  prose outside them survives byte-for-byte, and an unbalanced region
  aborts the write.
- **Grounded in what Forgejo 16.0.1 actually offers.** Native issue
  dependencies carry blocking so the frontier renders in the tracker UI;
  there is no sub-issue endpoint at this version, so map parentage is a
  managed metadata block. Verified against the live API, not assumed.
- **Claims carry session identity**, not just assignment — Bryan is every
  session's assignee, so assignment alone cannot separate two of his
  sessions. Read, re-read, write under an `updated_at` precondition, read
  back. The reference states plainly which window Forgejo cannot close
  rather than implying a lock.
- **Stale claims are surfaced, never expired.** An assignment that looks
  abandoned may be a session that is merely slow.

Upstream's `prototype` folds in as a routing reference. It names
`dx-target` and `dx-preview`, which exist; the plan also named `spike`
and `sketch`, which are not in the pool, so those three general shapes
are written out inline instead of routing to skills that are not there.

Verified:
- python3 -m unittest .../tests/test_forgejo_wayfinder.py — 48 tests, OK
- bash tests/test-reconcile-agent-skills.sh — 37 tests, 0 failures
- live read-only smoke via `tea api` against the real host: private
  tracker accepted, `bryan/dotfiles` refused as public, create-map
  preview rendered with zero writes.

The preview-first mutation smoke against a disposable private repository
is NOT done: it needs a new external repository, which is outside what
was authorized. Ungated pending Bryan's approval.
Review ran six overlapping lenses (correctness / security / simplicity /
over-engineering / type-design / test-coverage) while `issue-work`
documented four — a drift that had been live for a while. Replaces both
with Standards, Spec, and a conditional Risk lane, and makes the choice
deterministic instead of a judgment call at review time.

- `scripts/select_review_lanes.py` selects the lanes. Standards and Spec
  cannot be suppressed by any input; Risk runs on a real signal in the
  changed paths, always for CairnOS, and always when a path looks
  security-adjacent but matches nothing known. A false Risk lane costs
  one child agent; a missed one ships the defect.
- The classifier reports *why* each lane ran, and `summary.md` now
  carries a Lane Selection section — a Risk lane silently skipped is the
  failure that section exists to expose.
- Lane briefs live in `code-review`; `pr-self-review` selects, dispatches,
  and dispositions. The Claude subagent becomes `lane-reviewer`, a thin
  shim reading the same definitions rather than a second copy of them.
- **Correction bound**: one normal pass, then one narrowly conditional
  final pass, and never a third. A plan defect, an architectural
  question, expanding scope, unavailable proof, or a repeated systemic
  failure stops immediately — those are not bounded implementation
  defects, and no number of edits makes them one.

Preserved deliberately: claim-not-just-observation validation,
convergence-is-not-corroboration, the independent acceptance-criteria
sweep (an obligation nobody implemented leaves no line for any lane to
object to), the source-issue rule, the material-conflict escalation gate,
and the branch-identity check before any commit.

`issue-work` now routes Phase 3 to `tdd`, Phase 3.5 to `diagnosing-bugs`,
and reads the three lane artifacts; its stale "four lenses" wording is
gone rather than corrected to a new hardcoded count.

Verified:
- python3 -m unittest .../test_select_review_lanes.py — 23 tests, OK
- python3 -m unittest .../test_forgejo_wayfinder.py — 48 tests, OK
- bash tests/test-reconcile-agent-skills.sh — 37 tests, 0 failures
A monitor job that reasons only when an upstream file this repository
actually adapted has changed, and stays silent otherwise.

`check-mattpocock-skill-updates.py` reads its watch list from the
adaptation ledger, so adding an adaptation widens the watch without
touching the script. It emits sorted JSON — the current upstream commit
plus a content hash per watched file, with the local skills each one
feeds — and deliberately carries no timestamp, no local path, and no
credential: the scheduler hashes these exact bytes, so anything that
varies run to run would make every weekly tick look like a change.

Two deliberate choices:

- **`git ls-remote`, not the GitHub REST API**, to resolve `main`. No rate
  limit, no credential, no local mutation — and `api.github.com` returned
  504 from this host, which a weekly job would have hit silently.
- **Non-zero exit on any source failure.** A monitor that returns success
  after failing to reach its source reports "nothing changed", which is
  the one lie that matters. The scheduler records it as an error instead.

`monitorScript` is a new declarative manifest key. `reconcile_cron.py`
validates it — bare filename, no path, never with `noAgent` — before
calling the API, because the scheduler's own rejection lands *after* the
reconciler would have reported the job synchronized. It is verified on
readback like every other field.

The prompt is read-only and mention-led: it reads only changed watched
paths, reads the ledger for what was deliberately rejected locally
(upstream restating a rejected rule is the same disagreement, not news),
separates what changed upstream from what it recommends here, caps an
alert at three items, and emits `[SILENT]` on the baseline run and
whenever nothing qualifies.

Verified:
- hermes/test_check_mattpocock_skill_updates.py + test_reconcile_cron.py
  — 34 tests, OK
- two consecutive real-upstream runs produced byte-identical output; the
  pin is still current at 885e2ca
- python3 hermes/install.py --force-host --skip-cron --skip-compile
  against a temporary home: monitor script linked, rc=0

The live cron job is NOT created. Manifest and script only; activation is
separately gated.
One suite asserting the accepted target rather than the prose that
describes it: frontmatter discoverable by every runtime, ledger
completeness with every watched file mapped to a declared upstream source
and every local path resolving, curation matching the intended per-runtime
roles (Pi receives none of this; `guided-learning` is Hermes-only),
retired routes absent from every active surface, the three review lanes
and their artifacts, the no-third-pass invariant, guided learning's
refusal to write into its own installed directory, and a monitor that
carries no timestamp.

Two checks are scoped deliberately rather than broadly:

- Link resolution covers only the skills this plan owns. Legacy skills
  carry illustrative markdown in their examples (`](Note.md)`,
  `](target)`) that is documentation, not a link to follow.
- The retired-route guard matches per paragraph, not per line, because
  the clause that makes a mention historical ("the workflow it replaces")
  routinely wraps onto the next line.

Failure messages carry the missing wording rather than dumping whole
skill bodies — a red run here should be readable.
The verification sweep found three live routes to bundled skills that the
first cleanup pass missed, because it only searched the canonical pool
and the OpenCode agents.

`qwen_worker.py` launches Hermes with `--skills
test-driven-development,systematic-debugging` — an executable route, not
documentation. Both implementation-loop skills also list the bundled
names, plus `requesting-code-review`, in their `related_skills` metadata.
All now select `tdd`, `diagnosing-bugs`, and `code-review`.

**Sequencing:** those three arrive in `~/.hermes/skills/personal/` only
when `scripts/reconcile-agent-skills.sh --apply` runs. Apply the
reconciler before the next Qwen worker run.

The contract guard is widened to the surfaces that hid these — the
Git-backed Hermes skill pool, its worker scripts, and the Claude agents
directory — and its historical-mention markers are stems, so
replace/replaces/replaced/replacing all read as history rather than as a
live route.

Verified: 168 python tests OK; reconciler suite 37 tests, 0 failures.
Correction pass 1. Six validated findings, all bounded implementation
defects — none touched the accepted goal, scope, or architecture.

**Risk — a truncated frontier hands out unsafe work.** Forgejo returns
*every* issue when a `labels=` filter names a label the repository does
not have yet, and before the first map exists `wayfinder:ticket` is
exactly that. Combined with an unpaginated single-page fetch, a busy
repository could hide a map's tickets, leaving the frontier blind to
blocked and claimed state. `list_tickets` now pages, and raises rather
than returning a truncated listing. The trap is written into the tracker
reference, because it is the kind of thing that is expensive to
rediscover.

**Risk — untrusted upstream content reaching a tooled agent.** The
watcher reads third-party skill files, which are full of imperative
language by their nature, and the job holds `terminal` and `file`. The
prompt now states that fetched content is the subject of the assessment,
never direction for the agent.

**Spec — the side-by-side pilot could produce no evidence.** `issue-plan`
routed decision coaching only to `adr-and-spec-coach`, so `grilling`
would never be exercised through the normal workflow and D2's comparison
would have nothing to compare. Both are now offered, chosen explicitly
once per effort and recorded in the plan, with `wayfinder` offered when
the effort plainly exceeds one session.

**Spec — `monitorScript` was not actually declarative.** Dropping the key
from a manifest entry left the live monitor running while the reconciler
reported the job synchronized. The field is now always sent (empty string
clears it) and always verified on readback.

**Standards** — extracted `_ticket_from_issue` where `list_tickets` and
`_read_ticket` had duplicated the construction, and folded two identical
`snapshot()` helpers in the monitor tests into one.

Verified: 172 python tests OK; reconciler suite 37 tests, 0 failures;
git diff --check clean.
bryan changed title from feat(skills): adopt adapted planning and delivery cores to WIP: feat(skills): adopt adapted planning and delivery cores 2026-08-19 17:28:56 +00:00
Repairs all 25 validated items from
`.hermes/review/independent-correction-pass-1.md`. Several were real
defects that would have failed on first live use, and one was a mistake I
made in the previous pass.

## Forgejo Wayfinder adapter

- **`CreateIssueOption.labels` is `array<int64>`, not names.** Confirmed
  in the live Swagger. Every issue this adapter created would have lost
  its labels — and a map without `wayfinder:map` is not a map. Labels are
  now resolved to numeric ids from deterministic definitions; the preview
  shows any label it would have to create, and apply reads each one back.
- **Every mutation now preflights**, immediately before the write:
  privacy, the issue number the API actually returned, the required
  label, the metadata version and type, and the map association. A body
  marker alone is not identity — anyone can paste one into an unrelated
  issue. Every mutation family has a public-repository refusal test.
- **Claims are arbitrated by comment id, not by body overwrite.**
  `updated_at` is *set by* the write on this API, so it cannot resolve a
  race; two contenders could both read back their own claim and both
  proceed. Claim and release are now append-only comments, replayed in
  Forgejo's monotonic id order, and the earliest unreleased claim wins. A
  deterministic interleaving test proves exactly one contender can win.
- **Only the current claimant may resolve.** `--session` is required.
- **Resolution is resumable.** A deterministic key lets a retry see which
  of comment / close / map-index already landed and finish only the rest;
  fault-injection tests after each step prove convergence on one comment,
  one closed ticket, one map entry. `append_decision` no longer glues the
  entry onto the heading — the old substring readback hid that.
- **Token transport is opt-in and host-pinned.** `auto` always uses Tea
  and never upgrades because an ambient `FORGEJO_TOKEN` exists; direct
  token mode requires an allowlisted host, refuses every redirect (urllib
  would replay the `Authorization` header), and validates the request
  path so it cannot be steered off the pin.

## Skill topology

- **`spike` and `sketch` do exist** — as builtin Hermes skills. My
  previous pass searched `~/.claude`, the pool, and OpenCode, but not the
  Hermes builtin pool, and wrongly declared them absent. The approved
  named routes are restored, with per-runtime availability stated and the
  inline procedure kept only as the Claude/OpenCode fallback.
- **Retired links now prune on every platform.** `Cwd::abs_path` returns
  *empty* for a dangling link whose parent is missing, which classified
  the old `agent-workspace`/`git-master` links as foreign and preserved
  them forever. An exact two-target allowlist keyed on the raw `readlink`
  output fixes it; every other broken or foreign link still survives.
- OpenCode agents write scratch state to `{TRUNK_ROOT}/.hermes/agents/…`
  instead of a relative `.notes/.agents/`, which forked per worktree.
- `find-skills` authors into the canonical pool with provenance and
  curation, and verifies through the reconciler — never an unmanaged
  `~/.claude/skills` copy.
- `issue-work`'s description no longer advertises the retired lens names;
  duplicate `worktrunk` rows merged; the morning-brief contract is scoped
  to the jobs it owns, with the watcher's model pin in the watcher tests.

## Review behavior

- The **AC sweep builds its checklist from every intent source** — plan,
  issue task list *and* prose criteria, spec, explicit PR criteria — and
  splits compound criteria. An issue with no `- [ ]` lines is not an
  issue with no acceptance criteria.
- **Correction passes count once per committed boundary.** A delegated
  batch previously spent a pass on dispatch, burning the conditional
  final pass before the first correction had been reviewed.
- **Risk selection reads the diff, not just paths.** Content signals
  cover every named risk area, so `src/parser.py` calling `json.loads`
  selects Risk; unrecognized security-adjacent content fails closed. A
  prose-only signal is marked as such, so "discusses authentication" and
  "changes authentication" stay distinguishable.
- The **Qwen worker's isolated home** (`~/.hermes/local-qwen-worker`) has
  no `skills/`, so reconciling the normal home could never supply
  `tdd`/`diagnosing-bugs` — Hermes does not error on an unresolvable
  `--skills` name, so it would have run without them and reported
  success. It now gets the repository pool via `skills.external_dirs`,
  verified to resolve in that exact home.

## Watcher

- **Identity is the per-file Git blob sha**, with no repository tip, so
  an unrelated upstream commit produces byte-identical output.
- **One recursive Trees request, not one Contents request per file.** The
  live run caught this: 17 watched files exhausted GitHub's 60/hour
  unauthenticated per-IP limit on the *second* invocation. A truncated
  tree is refused rather than read as "unchanged".
- **Installed as a copy.** The scheduler resolves a cron script path and
  requires containment in `HERMES_HOME/scripts`; `.resolve()` follows
  symlinks, so the symlinked install would have been blocked at fire
  time. Proven through the real installer and the real resolver.
- **Read-only tools**: `["web", "no_mcp"]`, not file/terminal. The
  bounded adaptation context the assessment needs — local paths, local
  changes, rejected rules — now travels in the monitor output.

Verified: 279 python tests OK; reconciler 53, hindsight 4, shell-additions
22, all 0 failures; isolated Hermes install rc=0 with the monitor as a
regular file; reconciler --check 7 creates / 0 prunes; git diff --check
clean. Live read-only: private accepted, public refused, preview with
zero writes.
The scheduler resolves a cron script path and then requires containment
in `HERMES_HOME/scripts`. `.resolve()` follows symlinks, so a symlink into
this repository resolves outside the sandbox and is rejected at fire time
— after the reconciler has already reported the job synchronized. And a
copied script cannot find repository files from `__file__`, so it needs
the job's `workdir` or an explicit variable.

Both facts cost a debugging pass to rediscover; they belong next to the
`monitorScript` contract rather than in a commit message.
Repairs all 12 validated blockers from
`.hermes/review/conditional-final-correction-pass-2.md`. This is the
plan's one conditional final correction pass; no third is authorized.

## Wayfinder

**Concurrent resolutions could lose a map decision.** Two tickets can be
legitimately claimed at once, so two resolutions can append to the map at
the same moment — and a post-write readback verifies what just landed
while saying nothing about the write arriving a second later with a
stale body. Map writes are now serialized behind append-only
`maplock`/`mapunlock` records ordered by comment id: acquire, preflight
under the lock, read the body fresh, merge, verify, release. A losing
lock request withdraws itself; the lock is handed back even when the
write fails; a crash-held lock is surfaced with the exact recovery
command rather than stolen.

**A losing claim became a zombie.** A contender that posted a claim and
then lost left the record queued, so the winner's release silently
transferred the ticket to a session that had already walked away. Losers
now withdraw their own claim and say so — including when the withdrawal
itself fails. The test that asserted the old transfer behaviour is gone,
replaced by one asserting the ticket ends up unclaimed.

**Preflight moved from entry to every write.** Privacy, the issue number
the API actually returned, labels, metadata version and type, map
association, and the active claim are revalidated immediately before each
comment, close, lock action, and map patch. Fault injection that flips a
repository public or strips a label between steps now stops before the
next write, and partial state stays retryable.

**Resolution markers are parsed structurally.** The old substring check
could be satisfied by answer prose containing `key: …`, and `search` read
only the first block in a comment. Now every block is iterated and a
marker counts only on exact kind, version, key, map, and session.

Also: the map index entry is validated as a single safe line before
preview or mutation (empty, multiline, heading, bare-bullet, and
marker-shaped inputs produce zero writes); `read_map` asserts the
returned issue number matches the requested one; and record fields are
refused if they contain a newline or `-->`.

## Review workflow

**Deletions selected no lane.** `git diff` writes `+++ /dev/null` for a
removed file, so deleting `src/auth/session.py` produced an empty path
list and no Risk lane. Both headers are parsed now, renames report both
sides, and a modified file is not double-counted.

**Reaching the correction bound is no longer an exit.** The second
correction's code had never been reviewed. A terminal `final_review_only`
pass now runs once — re-selecting lanes against the moved HEAD, applying
nothing — and only then does the run end clean or bound. Identical on
native and delegated paths.

**The AC checklist is an artifact.** `intent-checklist.json` is built
once from the plan, the issue, the spec, and explicit PR criteria, and
both the sweep and `summary.md` read that file rather than drifting back
to the `closes` task list. Prose criteria are captured at ingest while
the full body is in hand, never mined from the 400-char excerpt. An
unreadable authority is recorded `unswept`, not absent.

**Untracked files block the candidate.** A nonignored untracked file is
invisible to `{base}...HEAD`, so reviewing around it reports a clean
candidate over code nobody looked at.

## Qwen worker

**File presence is not resolution.** Hermes scans the worker home's own
`skills/` before the external pool and takes the first match, and it does
not error on a shadowed or unresolvable `--skills` name — so a stale
`worker-home/skills/tdd` would silently win and the worker would run with
the wrong skill, reporting success. Resolution is now verified through
Hermes's real discovery modules in the exact worker home, requiring
exactly one candidate per name equal to the canonical pool file, before
launch. Proven live, including a real synthetic shadow.

Verified: 337 + 6 Python tests OK; reconciler 53, hindsight 4,
shell-additions 22, all 0 failures; isolated Hermes install rc=0 with the
monitor a regular file; real Qwen isolated-home resolution to the
canonical pool; watcher byte-identical across runs; reconciler --check 19
creates / 0 prunes; git diff --check clean.
Applies `.hermes/review/additional-correction-cycle-1.md` — the first of
two additionally authorized correction cycles.

## Review lane selection reads Git name-status

`select_review_lanes.py` inferred every touched path from unified
`---`/`+++` headers. A content-identical rename emits `rename from` /
`rename to` and shows `--- /dev/null`, so moving `src/auth/session.py`
somewhere harmless looked like an add of the destination and nothing
else; a binary deletion emits no `---`/`+++` headers at all and was
invisible. Path identity now comes from
`git diff --name-status -z {base}...HEAD`, parsed NUL-separated with two
paths for `R`/`C`, and the unified diff is demoted to a content signal.
Both sides of a rename or copy are classified, a delete classifies the
deleted path, and neither is double-counted. `code-review` and
`pr-self-review` require callers to write both inputs to files; a
diff-only call still works but records the weaker path authority in
`selection.notes` rather than passing silently.

## A session is not a lock token

Claims and map locks arbitrated on session identity. One session
legitimately runs nested or concurrent operations, so a release matched
on session alone cleared whichever claim that session happened to hold —
including a sibling operation's. That is a lost lock inflicted from the
inside, which no amount of cross-session care prevents.

Every acquisition now mints a 128-bit `secrets.token_hex` **operation
id** and every `claim`, `release`, `maplock`, and `mapunlock` record
carries it (locks also carry their ticket). Arbitration replays the
comment log keyed on the operation, so:

- a release clears exactly the acquisition it names;
- a second same-session acquisition is a genuine contender, not a no-op,
  so one session cannot hold two overlapping critical sections;
- only an identical operation id folds into an existing hold, which is
  the one idempotent case;
- a loser withdraws *its own* operation and verifies that operation is
  inactive — not merely that someone else is winning, which is exactly
  the zombie the withdrawal exists to prevent;
- crash recovery names the exact held operation. `claim-status` and
  `map-lock` print it along with the verbatim recovery command.

`--operation` is required on `release` and `resolve`, optional on
`claim`. There is no session-only form.

## Exact managed-record schemas

Arbitration records went through a permissive `key: value` scrape, so a
truncated write or a hand-edited comment could take a lock. Each kind now
has an exact v1 schema, and a block failing it in any way — malformed
line, duplicate key, unknown key, missing key, empty value, wrong
version, out-of-pattern value — is discarded whole and can neither
arbitrate nor satisfy readback or idempotency. Rendering shares the same
schemas, so the adapter cannot emit a record its own reader would reject.

## One guarded write path

Preflights were spelled out around each call site, which is how one call
site quietly ends up missing one. Every external write now goes through
`guarded_write(WriteGuard(...), write, verify)`: re-read privacy, re-read
and verify the exact map/ticket issue number, require labels and exact
metadata, require the exact claim or lock operation where applicable,
write once, read the exact result back. This covers label creation, map
and ticket creation, dependency wiring, the claim comment, the assignee
patch, losing-claim withdrawal, release, the resolution comment, the
close, the lock request, losing-lock withdrawal, the body patch, both
unlocks, and explicit recovery — a write that cannot name its guard has
nowhere to go.

A 2xx is not proof of storage. The fake tracker gained a `swallow` mode
that acknowledges without persisting, and every family fails closed
under it rather than reporting success.

**Cleanup is not a licence to write.** If authority drifts mid-operation
the `finally` unlock fails closed and the outcome carries `held_lock`
plus the exact `release-map-lock` command, instead of writing to a
tracker that just went public or a map that lost its label. A resolution
reports `resolved` only when the decision is on the map *and* its own
lock operation has been released.

## Tests

183 Wayfinder tests (was 125), including the named adversarial
reproductions: nested and duplicate same-session claims, one session's
locks for two tickets, a release that must not clear a sibling, an
idempotent retry, a foreign-operation release, recovery by exact id,
concurrent different sessions, every schema rejection case, identity and
privacy drift immediately before each write family, seven swallowed-write
families, a swallowed unlock that must not read as success, a swallowed
losing-lock withdrawal that must not read as withdrawn, and cleanup
refusing to write after drift.

Five mutations of the new guarantees were each checked to fail the suite:
session-keyed arbitration (3), permissive parsing (7), skipped readback
(9), skipped privacy re-check (1), swallowed cleanup drift (3).

Verified: 398 Python tests OK, 0 failures — discovered across all
fourteen tracked suites, up from 319 at the candidate head 293b597
measured the same way; `test_sgg_pr_review_event.py` 6
OK; reconciler 53, hindsight 4, shell-additions 22, 0 failures; isolated
Hermes install rc=0 with the monitor a regular file; real Qwen
isolated-home resolution plus a live synthetic shadow; watcher
byte-identical across runs; reconciler --check 19 creates / 0 prunes;
`git diff --check origin/main...HEAD` clean.

Not proven: no Wayfinder command has been run against a real tracker.
That needs a disposable private repository and is outside this
authorization.
Reconciles the candidate with `cf0fd360` ("Improve personal brief capture").
Three files changed on both sides; both sides are preserved.

**`hermes/manifest.json`** — union. The Matt Pocock watcher keeps its entry in
`scripts`, `copiedScripts`, and `cronJobs`; upstream's Personal Weekly
Orientation keeps its script, copied-script ownership, and job; Personal
Morning Brief keeps upstream's migration to OpenAI Terra with
`carryPreviousOutput` and continuation metadata, and its now-absent `baseUrl`.

**`hermes/reconcile_cron.py`** — both features travel in the same payload. The
candidate's `monitorScript` validation, always-sent clear, and readback sit
alongside upstream's `continuity` propagation, `context_from` readback, and
explicit empty-string `base_url` clear. Terminal-job semantics are untouched:
a completed job still omits its unchanged schedule, and `repeat` still applies
only on creation.

**`hermes/test_morning_brief_split.py`** — the candidate's scoped owned-job
census survives, because exhaustive `set(jobs) == set(expected)` equality fails
every unrelated cron addition, including the watcher. Personal Morning Brief's
expected route moves to Terra, Personal Weekly Orientation joins the owned set,
and Workday Dependency Triage — which the candidate had dropped along with the
exhaustive check — is restored. Upstream's delta-only, privacy-filter,
carry-previous-output, and weekly-orientation tests all remain.

**`hermes/test_reconcile_cron.py`** — new `MergedFeatureIntegrationTest` drives
`main()` end to end against fake `cron.jobs` and `tools.cronjob_tools`, so the
asserted payloads are the exact dicts the scheduler would receive. It covers
the interaction the merge could silently break: both fields present on
creation, a brief clearing a stale `base_url` and a stale monitor in one update
while gaining continuity, a watcher keeping its monitor while a brief gains
continuity, a terminal job syncing both fields without being rescheduled into
life, and `repeat` applying only on creation.

Five mutations were each confirmed to fail this file: dropping the
`monitor_script` send (3 errors), dropping the `continuity` send (5), dropping
the stale `base_url` clear (2), sending `repeat` on update (1), and keeping the
terminal schedule (1).

Verified: test_reconcile_cron 21 OK, test_morning_brief_split 17 OK,
test_calendar_brief_contract 2 OK.
Applies `.hermes/review/additional-correction-cycle-2.md`, the final
authorized edit cycle. The merge of current `origin/main` is the parent
commit; this one carries the reviewed blockers.

## Privacy is the last read before every write

`enforce()` checked privacy first and then issued three more reads — map
identity, ticket identity, ownership. That leaves a window: the
repository can be made public while the adapter is still deciding, and
the mutation then lands on a tracker it approved a round trip ago.

The order is inverted. Every identity, metadata, ownership, and
dependency read completes first; `require_private()` is the final network
round trip before the single write. The write closure now reads nothing —
it works from the `GuardState` the guard captured — so it cannot reopen
the window it was just closed for. `_patch_issue`, which did
GET-then-PATCH inside the closure, is gone; read preparation and the
direct `PATCH`/`POST` are separate, and the staleness guard it provided
is subsumed by the guard's own strictly-fresher read.

The tests flip the repository public *during* the final read for every
write family, and assert no mutation follows. One of them states the
property structurally: for every POST and PATCH in the request log, the
immediately preceding request must be the privacy check.

## An operation id is a handle, not a credential

Ownership was keyed on the operation id alone. But every id this adapter
mints is written into a tracker comment, so anything that can read the
issue can quote it back. Ownership is now a full identity tuple: a claim
is `(operation, session)`, a map lock is `(operation, session, ticket)`.

The ticket matters because every lock record lives on the one map issue.
Without it, a request naming ticket 999 matches — and inherits — a lock
taken for ticket 4.

`acquisition_identity`, `active_holder`, `acquisition_is_active`, the
idempotent retries, the resolve guards, normal release, loser withdrawal,
and recovery all enforce the full tuple. `acquire_map_lock` validates the
whole request — map identity, ticket identity, and the claim that
authorizes locking on its behalf — *before* considering the idempotent
fast path: a retry is only a retry if it is a retry of this exact
critical section.

Reproduced as tests: a foreign session with the exposed operation cannot
release, resolve, or inherit a claim as a retry; a forged release or
mapunlock under the wrong session or ticket clears nothing; ticket 999
cannot ride ticket 4's lock; a nonexistent ticket cannot lock at all;
same-session siblings stay independently owned; and a lock retry refuses
once the ticket loses its label, moves map, loses its claim, or the
tracker goes public.

## Queued acquisitions are recoverable by name

A failed withdrawal does not leave a stuck lock — it leaves a **queued**
one, sitting behind the winner and becoming the holder the moment the
winner releases. Recovery that only reached the current holder could not
touch it, so the printed recovery command was advice the operator could
not follow.

`release` and `release-map-lock` now locate the exact schema-valid
acquisition whether it holds or is queued, verify its session, ticket,
and map scope, post the matching drop, and then verify both that this
acquisition is inactive and that the current holder is unchanged.
`claim-status` and `map-lock` list `queued` alongside `holder`, each with
its verbatim command, because recovery cannot target what inspection does
not show. A swallowed recovery write fails and leaves the state explicit.

## Exact identity, exact values, exact readback

**Maps and tickets.** A map needs exactly one balanced current-version
managed region: zero is not a map, and two is worse — a decision would be
merged into one while a reader sees the other. A ticket needs both
`wayfinder:ticket` and the matching `wayfinder:{type}` label agreeing with
exact `map`/`type` metadata; a label contradicting the body is two sources
of truth, and this adapter refuses to pick one.

**Values.** Ticket and map numbers are positive integers — Forgejo numbers
issues from 1, so zero was a missing value dressed as one, and the
synthetic `ticket: 0` fallback is gone. Timestamps are parsed to a real
UTC instant, not regex-matched: `2026-99-99T99:99:99Z` satisfied the old
check. Blank lines inside a managed block are rejected rather than
skipped, because padding a reader tolerates is padding a forger can hide
behind. Rendering and parsing share one validator set, and a test asserts
they agree value by value.

**Readback**, per family, measured against a pre-write snapshot: a created
issue must be a number that did not exist before, with byte-exact title,
body, and label set; a comment record must be a comment absent from the
snapshot, byte-exact, with the id the API returned; an assignment must
*equal* the requested set; a dependency must equal the snapshot plus the
one requested edge; a map patch must equal the full body intended; a close
must leave the ticket's identity intact. The snapshots are the point:
without them an echoed existing issue, or the record an earlier attempt
wrote, passes as proof of a write that never happened — and a swallowed
retry reports either success or a zombie that is not there.

## Name-status is one literal, strictly parsed

`git diff --name-status -z -M -C --find-copies-harder {base}...HEAD --`,
quoted identically in `code-review`, `pr-self-review`, the script's help
epilog, and the tests. `--find-copies-harder` is load-bearing: plain `-C`
inspects only modified files, so copying a risky *untouched* source emits
a bare `A` and the risky source is never classified. A test proves that
negative control against real Git.

Parsing rejects a suffixed status (`MM` is porcelain syntax), an
impossible similarity score, a misaligned empty status field, truncation,
an empty path, a control character, and bytes that are not valid UTF-8 —
a path decoded with substitutions names a different file than Git meant.
A guess here is a silently narrowed review.

New tests run the literal command in a throwaway repository and classify
what Git actually emits: a content-identical risky→neutral rename, a
binary deletion (with the unified diff asserted to expose nothing), a
copy from an untouched risky source, ordinary add/modify/delete, and a
filename containing a quote, semicolon, and tab.

## Tests

500 Python tests, up from 398. Wayfinder 258 (was 183); lanes 87 (was 70);
`test_reconcile_cron` 21 (was 16).

Every new guarantee was mutation-checked. Reverting each of these fails
the suite: identity back to operation-only (28 failures, 53 errors),
privacy checked first (2), the creation pre-existing-issue snapshot (2),
assignment readback as containment (1), the comment-snapshot filter (3),
the exact-body record check (1), the returned-comment-id check (1),
dependency readback as containment (1), blank lines tolerated (16),
regex-only timestamps (10), zero as an issue number (5), the map
managed-region check (8), the required type label (4), recovery reaching
only the holder (4 failures, 2 errors), map-body readback as
decision-presence (1), close readback without identity (1), and the lock
fast path before validation (5).

Preserved without regression: no lost concurrent decisions, safe index
line, structural resolution marker, resumable comment/close/index, and
success requiring both the exact decision body and the released lock
tuple.

Verified: 500 Python tests OK; `test_sgg_pr_review_event.py` 6 OK;
reconciler 53, hindsight 4, shell-additions 22, 0 failures; isolated
Hermes install rc=0 with both the monitor and the weekly-orientation
script regular files; real Qwen isolated-home resolution plus a live
synthetic shadow; watcher byte-identical across runs; reconciler --check
19 creates / 0 prunes; `git diff --check origin/main...HEAD` clean;
`git merge-base --is-ancestor origin/main HEAD` true; `git merge-tree
--write-tree origin/main HEAD` exits 0.

Not proven: no Wayfinder command has been run against a real tracker.
That needs a disposable private repository and is outside this
authorization.
Forgejo 16.0.1 has no compare-and-swap on issue update, so the map-body
PATCH that merged the decision index could always overwrite a human edit
landing between the last read and the write; no client-side lock closed
that window, and the maplock protocol only serialized lock-respecting
agents. Remove the body write instead of guarding it again:

- resolution posts one exact-schema 'index' comment on the map (key,
  map, ticket, gist, session, at); the decision index is the replay of
  those records in comment-id order, first record per key
- the map body is never written after creation; it belongs to Bryan
- delete maplock/mapunlock, lock recovery, and the guarded body-PATCH
  path (~500 adapter lines); claims are unchanged
- bind idempotency to the resolution key: a reworded retry converges on
  one entry, an identical gist for another ticket never satisfies it
- reject nonpositive server ids at every boundary: replay order,
  comment readback (positive returned id required), issue creation
- adversarial tests: human edit at every read interleaving is preserved
  while the resolution succeeds; concurrent resolutions both index;
  keyed convergence; forged/nonpositive records never arbitrate

Also from the final review: validate exact Git name-status status bytes
before any normalization and reject tab as a control character in paths;
distinguish disposable prototype shells from deliberately portable logic
modules in prototype-routing.md; move the review-selector provenance to
the code-review ledger entry with the selector's local paths; rewrite
the Wayfinder ledger bullets for the append-only model.
Independent adversarial review of the merged result confirmed three gaps:

- validate_index_line and render_record checked only CR/LF while
  parse_exact_block splits with str.splitlines(); a gist carrying U+2028,
  U+2029, NEL, VT, or FF rendered a record its own reader rejected whole,
  so the resolve reported indexed=True while the replay never showed the
  key and every retry posted another invalid record. 'Single line' now
  means the parser's notion, shared by both sides.
- resolution answers, ticket questions, and map content were unscreened
  for managed markers, so an answer quoting the holder's own release
  block genuinely released the claim mid-resolve and a quoted claim
  block queued a zombie acquisition. Free-form content containing
  '<!-- wayfinder' is now refused before preview or any write.
- _positive_comment_id used int(), which accepts '1_0', '  12  ', '007',
  and True; exact integer shapes only now.

Plus review nits: stale tab example in the name-status docstring, stale
adoption sentence on replace_managed_region, wrong --at wording, old-model
comment on MAX_INDEX_LINE, and the prototype rule-4/rule-5 tension.

Regressions committed for each finding (240 Wayfinder tests).
Repairs the merge blockers fixed by
.hermes/review/final-shipping-acceptance-contract.md:

1. Canonical decision state publishes before the close: resolve now
   orders comment -> index -> close, resolved requires the resolution
   comment + exact ticket-scoped current index + closed ticket, and a
   failed index append leaves the ticket open with its dependents out
   of the frontier.
2. Creation is retry-safe after ambiguous success: previews mint a
   caller-retained creation identity, apply requires --creation and
   embeds it in exact managed metadata, and a retry converges on the
   one exact match (zero matches creates; multiple or inexact matches
   fail closed for reconciliation).
3. One canonical current decision per ticket: index replay dedupes
   retries onto each key's first record, then supersedes by the latest
   key per ticket; a changed answer on a closed ticket is refused with
   the reopen/correction path, and history stays append-only.
4. Claim preview and apply are byte-identical: apply requires the
   previewed --operation and the preview prints the exact apply
   command; retries with the same operation stay idempotent.
5. wire_blocking's final guard revalidates exact map/ticket identity
   for both blocked and blocked_by immediately before the privacy
   check and the POST.

Each blocker carries committed regressions (wayfinder suite 240 -> 266
tests). Full gate: 575 Python tests across 14 suites and 79 shell tests,
all green (Qwen suites run with HERMES_CANONICAL_SKILL_POOL pointed at
this worktree's pool, per the contract's nonblocking note).
bryan changed title from WIP: feat(skills): adopt adapted planning and delivery cores to feat(skills): adopt adapted planning and delivery cores 2026-08-21 15:55:17 +00:00
bryan manually merged commit 07b8970baa into main 2026-08-21 15:59:03 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
bryan/dotfiles!21
No description provided.