Skip to content

feat(claude-ops): project --ids from a saved report, journal sync runs, split install/enable steps into a spoke - #3748

Merged
kyle-sexton merged 7 commits into
mainfrom
feat/3728-plugin-sync-cost-resilience
Sep 5, 2026
Merged

feat(claude-ops): project --ids from a saved report, journal sync runs, split install/enable steps into a spoke#3748
kyle-sexton merged 7 commits into
mainfrom
feat/3728-plugin-sync-cost-resilience

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Closes #3728

Summary

Three cost and resilience findings from a /claude-ops:plugins sync run against an already-current fleet. None were correctness bugs. The --ids selector rebuilt state the caller already held, the skill kept no durable record of a sweep it later has to report on, and about a hundred lines of install and enable policy loaded on every run while being unreachable in the common case.

Fix

  • Finding 1, --ids <selector> --from <report.json>. fleet-state.sh projects a selector from a report it already emitted instead of recomputing the fleet. The projection is now one jq program shared by the live path and --from, so the CR-free output contract (the reason the selector exists rather than hand-rolled jq at each call site) cannot drift between them. --from rejects combination with --all / --marketplaces, disagrees loudly if --marketplace names a different marketplace than the report, and fails exit 2 with empty stdout on a missing or malformed file. sync.md Steps 2 and 3 now project from the report they already read; Steps 4 and 5 keep their own live re-read (the concurrency rule requires it) and project from the saved copy.
  • Finding 2, run journal. sync writes ${CLAUDE_PLUGIN_DATA}/plugins-sync/runs/<UTC timestamp>/ with each fleet-state.sh report (pre, mid, post), and appends each mutating CLI command and its output to journal.log. Step 6 reads old/new pairs and the three divergences[] snapshots from the journal instead of conversation memory. audit writes no journal, so its "Mutates: No" row stays true. The journal_root assignment lives in SKILL.md because ${CLAUDE_PLUGIN_DATA} substitutes in skill content and not in a context/*.md spoke.
  • Finding 3, context/sync-install-enable.md. Steps 4 and 5 move into their own spoke, read only when missing_from_user_install or missing_from_enabled is non-empty or the Step 1 refresh failed. sync.md keeps a pointer paragraph with the read condition; the reference index in SKILL.md gains the row; cross-references in converge.md, gotchas.md, and scope-semantics.md are repointed. The Report template and install_new render stay in SKILL.md. Frontmatter is untouched.
  • claude-ops 0.42.4 -> 0.42.5 with CHANGELOG entry (main took 0.42.4 mid-flight).

Verification

  • fleet-state.test.sh: 86 cases, 0 failed, post-rebase on main. New cases: incomplete report missing a selector-required field exits 2 naming the field; --from projection equals live projection per selector; --from on a missing or malformed file exits 2 and names the file; --from combined with --all is rejected; --marketplace disagreement with the report is rejected.
  • shellcheck -S warning clean on both scripts.
  • markdownlint-cli2 over plugins/claude-ops/**/*.md: 0 issues in 54 files.
  • node scripts/validate-plugin-contracts.mjs: validated.
  • scripts/affected-tests.sh --run not run locally (it hangs on check-changed-skills.test.sh on this machine, see fix(claude-ops): resolve default marketplace for out-of-cache plugin roots #3744); CI is the statement of record for that suite.

Related

🤖 Generated with Claude Code

https://claude.ai/code/session_019gWgHogJFQeCne5U7vHKAE

@kyle-sexton
kyle-sexton marked this pull request as ready for review September 5, 2026 09:48
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-05T09:55:25.583263Z b3162a6 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 53s —— View job


Security review complete

  • Gather context (PR body, changed files, custom instructions)
  • Run /review:security-review per repo custom instructions
  • Post findings

Scope: this PR's diff only — fleet-state.sh's new --from projection mode, the PROJECTION_PROGRAM extraction, the run-journal additions in sync.md, the new sync-install-enable.md spoke, and doc cross-reference updates. Reviewed against origin/main...HEAD.

No security issues found.

What I checked specifically, since the diff's main new surface is a shell script reading a caller-supplied file path (--from <report.json>):

  • No shell injection via --from. $FROM_REPORT is only ever used in [[ -f ]] tests and as a jq positional filename argument (fleet-state.sh#L500-L526) — never concatenated into a shell command or eval'd.
  • No jq-program injection. PROJECTION_PROGRAM is a fixed literal (fleet-state.sh#L455-L465); the selector value flows in only via jq's --arg binding, never string-interpolated into the program text.
  • Shape validation before trust. --from rejects non-object JSON, an --all envelope, and anything missing .marketplace.name/.installed before projecting (fleet-state.sh#L496-L511), and every rejection path is verified (by the new test cases) to leave stdout empty — preserving the existing contract that a < <(… --ids …) consumer can never misread an error as a plugin id and hand it to claude plugin update.
  • Ids stay properly quoted downstream. Every consumer in sync.md/sync-install-enable.md that loops projected output (claude plugin update "$id" -s "$scope", etc.) quotes the variable, so no injection path opens up even for a hypothetical adversarial catalog/marketplace entry.
  • Doc-only changes (SKILL.md, converge.md, gotchas.md, scope-semantics.md, the new sync-install-enable.md spoke) are cross-reference repoints and a journal-path render; no new command construction or trust-boundary crossing introduced.

This lane doesn't re-report supply-chain/permissions/zizmor-covered findings, and none of those apply here anyway (no workflow or dependency-pin changes in this diff).

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Last security-reviewed head: b3162a6ae521dd308fadb59eebd5e1f09104f36d. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 7m 2s —— View job


Code review complete

  • Gather context (PR diff, changed files, CLAUDE.md/AGENTS.md conventions)
  • Review fleet-state.sh / fleet-state.test.sh (Finding 1: --from projection)
  • Review journal changes in SKILL.md / sync.md (Finding 2)
  • Review spoke split: sync-install-enable.md, cross-references (Finding 3)
  • Post findings

Scoped to files changed in this PR (git diff origin/main...HEAD), per /review:code-review's criteria (correctness/maintainability; security out of scope for this lane). I could not execute shellcheck, bash fleet-state.test.sh, or markdownlint in this sandbox (not in this lane's allowed tool patterns), so the PR body's verification claims (85/85 tests, shellcheck clean, markdownlint clean) are author-claimed and unverified by me — I read the test additions and traced the logic by hand instead.

Finding 1 (fleet-state.sh --from): the implementation is careful — single shared PROJECTION_PROGRAM for live and --from paths, exit-2/empty-stdout discipline on every rejection, shape validation that refuses an --all envelope by name instead of silently projecting it to nothing, and a --marketplace disagreement check. The new test cases (equality with live projection per selector, each rejection's exit code + empty stdout, projection with every CC state file absent) look like they'd catch a real regression here. No issues found.

Finding 3 (spoke split): sync-install-enable.md is a clean, verbatim move of the two steps; every cross-reference I checked (converge.md, gotchas.md, scope-semantics.md, SKILL.md's reference index) was repointed correctly, and I found no remaining stale sync.md Step 4/5 reference outside the CHANGELOG's historical prose (correctly left as-is).

Finding 2 (run journal) — two inline comments posted, one real gap:

sync.md's Step 2 and Step 3 now project their id lists via --from "$run_dir/pre.$mp.json" and --from "$run_dir/mid.$mp.json" respectively, but neither step (nor the "Run journal" section) ever shows the command that actually creates those two files. Contrast with the new sync-install-enable.md, whose Step 4/5 explicitly show both halves — the live read redirected to pre-install.$mp.json/pre-enable.$mp.json, then the --from projection off that same file
(sync-install-enable.md#L27-L32).

Followed literally, Step 2/3's --from calls would hit the script's own "report not found" rejection (exit 2, empty stdout by design), and because a while read loop over an empty process substitution just silently sees zero lines, both steps would do nothing — the exact "silently skipped update" failure class this file's own Step 3 section calls out as the reason for its fail-open pre-filter design. Detail and a suggested fix are in the inline comments on
sync.md:272 and
sync.md:347.

A smaller nit: sync.md:421 (Step 6) refers to the snapshots as bare pre.json/mid.json/post.json, while the "Run journal" table two sections up names them pre.<mp>.json etc. — worth aligning since all mode produces one set per marketplace.

Everything else — CHANGELOG.md, the 0.42.30.42.4 version bump, plugin.json — is consistent with the change.
· Branch

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b3162a6ae5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread plugins/claude-ops/skills/plugins/context/sync.md Outdated
Comment thread plugins/claude-ops/skills/plugins/context/sync.md
Comment thread plugins/claude-ops/skills/plugins/context/sync.md Outdated
Comment thread plugins/claude-ops/skills/plugins/context/sync.md Outdated
Comment thread plugins/claude-ops/skills/plugins/scripts/fleet-state.sh Outdated
Comment thread plugins/claude-ops/skills/plugins/context/sync.md Outdated
Comment thread plugins/claude-ops/skills/plugins/context/sync.md Outdated
Comment thread plugins/claude-ops/skills/plugins/context/sync.md Outdated
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

@kyle-sexton
kyle-sexton force-pushed the feat/3728-plugin-sync-cost-resilience branch from b3162a6 to 681ea5c Compare September 5, 2026 10:42
kyle-sexton and others added 7 commits September 5, 2026 07:51
The --ids selector rebuilt state the caller was already holding. `sync`
re-reads the full JSON report before each mutating step, and every selector
(update-candidates-user, missing-user-install, missing-enabled,
current-project, installed-user, user-scope-orphans) is derivable from that
report, so the separate live --ids process paid a second process creation to
re-parse installed_plugins.json, re-walk the catalog manifests, and re-run
realpath in order to recompute a block already in hand.

`--ids <selector> --from <report.json>` projects from a saved
single-marketplace report instead. The projection is lifted into one
PROJECTION_PROGRAM that both pass 3 and --from run, so the CR-free,
TAB-separated output contract cannot drift between the two modes, which is
the whole reason the selector exists rather than a hand-written jq at each
call site. A --from run reads no Claude Code state file at all.

Every rejection is exit 2 with stdout left EMPTY, because the documented
consumer is a process substitution that cannot see the exit status: --from
with --all, --from without --ids, a missing or malformed file, an --all
envelope (valid JSON every selector projects to nothing, refused by name
rather than silently returning an empty list), and a --marketplace that
disagrees with the report's own marketplace.name. Under --from that flag is
an optional consistency check, never a second read.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gWgHogJFQeCne5U7vHKAE
…3728)

Steps 4 and 5 are roughly a hundred of sync.md's lines: the install_new
policy branches, the --setting-sources caveat, the reinstall-recurrence
caveat, the normalize-enabled-plugins.sh contract, defaultEnabled precedence,
and the project-scope enable-gap suppression ordering. They loaded on every
run and are unreachable when missing_from_user_install and
missing_from_enabled are both empty, which is the common case on a current
fleet. The gating signal is already in the Step 1 report.

The text moves verbatim into context/sync-install-enable.md with a header
stating its read condition, which carries both gates: either array non-empty,
or a marketplace whose Step 1 refresh failed and whose report has to name
what these two steps deferred. Same progressive-disclosure pattern the hub
already uses for converge.md and scope-semantics.md.

References that named "sync.md Step 4" or "Step 5" in converge.md,
gotchas.md, and scope-semantics.md now point at the new spoke, and the
step-internal "see Step 3" references become explicit cross-file links.
sync.md's pointer paragraph and SKILL.md's spoke-table row land in the next
commit, which is where the rest of those two files' changes live.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gWgHogJFQeCne5U7vHKAE
A sweep of several dozen mutations was one context compaction away from being
unable to emit its own report. Version capture requires an <old> value that
exists nowhere on the machine once the sweep has run, and a <new> value only
the CLI's own output carries, and the skill's mitigation was to hold both in
context through Step 6.

Every run now creates ${CLAUDE_PLUGIN_DATA}/plugins-sync/runs/<UTC stamp>/,
saves each fleet-state.sh re-read there, and appends each mutating CLI call
and its output to journal.log. Step 6 reads the pairs and the three
divergences[] snapshots out of those files rather than out of conversation,
which also gives converge and a later audit a real before-state.

fleet-state.sh does not write it. This honors the reasoning of the deferred
--run-log finding rather than reversing it: the journal is agent-executed
shell around calls the algorithm already makes, and the script stays the
read-only inspector its header advertises. The saved reports are the re-reads
the concurrency rule already requires, so the journal costs a redirect. audit
mode writes no journal, keeping the action table's "Mutates: No" true.

Steps 2 and 3 now project their id lists with --from against the report each
step just read, replacing the second fleet-state.sh process per step, never
the re-read itself. The mandate to take ids from the script and never from a
hand-written jq is unchanged. SKILL.md carries the substituted journal_root
because ${CLAUDE_PLUGIN_DATA} resolves in skill content and not in a
context/*.md spoke, which is read raw. Also carries the Steps 4 and 5 pointer
paragraph and spoke-table row for the preceding commit's move.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gWgHogJFQeCne5U7vHKAE
Version bump and CHANGELOG entry for the --from projection, the sync run
journal, and the sync-install-enable spoke.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gWgHogJFQeCne5U7vHKAE
…s journal (#3728)

Addresses PR review findings on the sync cost/resilience branch.

fleet-state.sh: `--from` now validates the fields the CHOSEN selector
consumes, additively on top of the baseline `.marketplace.name` +
`.installed` shape check. A syntactically valid but incomplete report such
as `{"marketplace":{"name":"m"},"installed":[]}` used to evaluate the
absent array with `[]?`, emit nothing, and exit 0 -- a silently-empty id
list read as "nothing to do". It is now exit 2 with empty stdout and an
error naming the file and the field. A field present but empty still exits
0 with empty output, so the exit status discriminates.

sync.md: the run directory is created with `mktemp -d` so two sessions
starting in the same UTC second cannot share it; every tee-journaled
mutating call captures `rc=${PIPESTATUS[0]}` so a failed CLI call is not
read as success through tee's status; Steps 2-5 show the redirect that
creates their saved report and check the projection's exit status before
looping; Steps 4 and 5 gate on a fresh pre-Step-4 re-read rather than Step
1's older report; Step 6 uses the marketplace-suffixed snapshot names.

`audit` now runs the same algorithm against a throwaway `mktemp -d` scratch
directory it deletes, instead of being forbidden to save the reports its
own `--from` projections require.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gWgHogJFQeCne5U7vHKAE
#3728)

The Step 2, Step 3, and canonical projection snippets assigned `rc` and
then looped unconditionally, so the prose telling the reader to check it
sat next to code that did not. That is the same defect as journaling a
mutating call through `tee` without capturing `PIPESTATUS[0]`: the status
is available and discarded. Each snippet now branches, reporting the
failure under "Action needed" instead of falling through to the loop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019gWgHogJFQeCne5U7vHKAE
@kyle-sexton
kyle-sexton force-pushed the feat/3728-plugin-sync-cost-resilience branch from 2eb50a4 to f4bd1bb Compare September 5, 2026 11:56
@kyle-sexton
kyle-sexton merged commit d45b544 into main Sep 5, 2026
20 checks passed
@kyle-sexton
kyle-sexton deleted the feat/3728-plugin-sync-cost-resilience branch September 5, 2026 12:10
kyle-sexton pushed a commit that referenced this pull request Sep 5, 2026
Main took claude-ops 0.42.5 for the plugins skill (#3748) while this PR was
open, so this branch's release becomes 0.42.6: the manifest keeps this
branch's description at the new version, the changelog carries main's
0.42.5 entry under this branch's entry re-labeled 0.42.6, and the
retirement record and the hook-telemetry convention note follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme
kyle-sexton added a commit that referenced this pull request Sep 5, 2026
No related issue: the logging pipeline was designed and allocated in the
hook-logging-pipeline topic (operator brief 2026-09-04/05, relayed
through the prompt-hooks session) and tracked in the topic's contract
slice rather than an issue; #930 tracks the envelope follow-up it
leaves.

## Summary

Claude Code's hooks ran with no per-session record of what fired, what
was blocked, or what each hook cost, and the only telemetry store was
one shared `hook-events.jsonl` under `.claude/observability/` that no
session could be joined to. This PR is the third and last from the
hook-logging-pipeline topic (after #3747, the verifier-lane fix, and
#3749, the PostToolUse kill-switch hoist). It adds an opt-in,
default-off per-session hook event log to claude-ops, moves the
reference sink and the observability skill to one hook log root with
per-session files, adds `SessionEnd` retention that fits the 1.5 s
budget, gives `setup` an `apply` for the root's guard plus a retirement
record for the old path, and generates the event list from the hooks
reference rather than hardcoding it.

## Fix

1. **`hooks/session-log-lib.sh`** (sourced, no `hook-utils.sh`): root
resolution from `session_event_log_dir` (default
`.observability/claude`) with containment checked lexically and
physically (the nearest existing ancestor is resolved with `cd -P`, a
builtin, and must sit below the physical project; a symlinked component
out of the project, or back to the project root, is refused), the
self-ignoring `.gitignore` guard (healed on first write; an
operator-edited guard refuses the write; an empty file is healed, which
closed a race the 33-parallel-fires case caught), id validation,
process-free timestamps, the category table.
2. **`hooks/session-event-log.sh`**: kill switch first, bounded 4 KB
slice read to a 64 KB cap that stops early only when the buffer ends in
`}`, carries the event name and has balanced braces (the brace
characters come from variables, because bash ends a `${...}` expansion
at a literal `}` inside a bracket class; the Win32 late-EOF stall costs
one idle slice; a writer that pauses after a nested `}` is read to the
bound), bash-regex field extraction, one line `{ts, session_id,
hook_event_name, category, status, source: "event-log", duration_ms,
prompt_id?, tool_use_id?, agent_id?, tool_name?, file_path?
(repo-relative, or the last segment after either separator), reason?,
traceparent?}` to `<root>/sessions/<session_id>.jsonl`.
3. **`hooks/session-retention.sh`** (`SessionEnd`, no `timeout`, no
stdin): keep newest `session_log_keep_sessions` OR younger than
`session_log_keep_days`, four spawns; with
`session_log_pre_prune_command`, doomed files move to
`prune-pending/<epoch>-<pid>/` and the command runs detached (`nohup`,
stdin closed) with that directory; sets older than 24 h are deleted on
the next run.
4. **`scripts/gen-hook-event-registry.sh`**: `--fetch | --from <file> |
--check`; writes `hooks/hook-events.registry.json` (33 events, 30
observable; `WorktreeCreate`, `MessageDisplay`, `FileChanged` excluded
because each replaces or holds native behavior when registered; unknown
names excluded with a warning; under 25 rows refuses) with
upstream-drift stamps, and regenerates the producer rows and the
retention row in `hooks.json` preserving the nine audit handlers.
`--check` is offline.
5. **Reference sink** (and the repo-local copy): an envelope carrying a
well-formed `data.session_id` routes to `sessions/<id>.jsonl` in the
spine shape (`source: "envelope"`, `changed` when sent); any other
envelope to `<root>/hook-events.jsonl` in the legacy shape under its
lock. The nine audit hooks send `data.session_id`; seven data schemas
gain the optional key.
6. **`setup`**: `check | apply`. Probe 5 reports the root, containment,
root-equivalence refusal, and the guard; probe 6 is the fixed
retired-conventions line. `apply` writes exactly `<root>/.gitignore` and
reads back the tracked-versus-ignored pair. `retirements.yaml` gains
`claude-ops-r001` (`.claude/observability/hook-events.jsonl`,
`migrate`); the helper copy is enrolled in
`scripts/sync-check-retirements.sh`; one eval per record plus the
guard-only and root-refusal evals.
7. **`observability`**: every whole-root query reads `sessions/*.jsonl`
plus the shared file through one `HOOK_NORM` prelude; `session` (newest
by mtime) and `session:<id>` render a per-session report (hooks fired,
blocked, rewrote, per-hook duration, event timeline); every report ends
with the six lines of `probe-observability-state.sh --pipeline` (root,
guard, sessions, shared, prune-pending, toggles). The probe gains
`--root` and `--pipeline` (the rendered options arrive as flags because
a skill subprocess inherits no `CLAUDE_PLUGIN_OPTION_*`); `clean.sh`
gains `--hook-root`, prunes the root's shared file, removes session
files untouched for the window, and sweeps stale `prune-pending/` sets
whether or not the switch is on.
8. claude-ops to 0.42.6 with the CHANGELOG entry (main took 0.42.4 for
#3749 and 0.42.5 for #3748 while this PR was open; both are merged in);
six new `userConfig` keys and the regenerated README options table;
`.gitignore` gains `.observability/`; the hook-observability convention
gains the `# silent-skip-ok:` paragraph and the hook-telemetry
convention the sink-routing note with the #930 pointer;
`docs/CATALOG.md` and `docs/SKILL-CHEAT-SHEET.md` regenerated.

## Verification

Measured on the Linux CI host, N = 15 (raw captures in the topic's
memory slice; the distilled rows lived in the branch's FINDINGS.md until
the prune commit):

| Row | Result |
| --- | --- |
| `session-event-log` disabled (the default) | 2.42 ms against a 2.08 ms
bare spawn floor (1.16 S; acceptance bound 1.5 S) |
| enabled, 2 KB payload | 4.5 to 5.75 ms |
| enabled, 512 KB `tool_response` | 35.7 ms |
| sink, envelope with `session_id` | 26.7 ms, off the critical path |
| retention, 40 files nothing doomed / 100 files 70 pruned | 4.3 ms / 24
ms |
| held-open stdin (late-EOF shape) | producer returns in 262 ms
(asserted under 700; a broken early stop measured 1262); retention under
500 ms |
| toggle cycle (on 10, off 10, on 10, then `SessionEnd`) | root holds
only `.gitignore` and `sessions/cycle-1.jsonl` with 20 parsing lines;
`git status` clean |

Suites (all beside their scripts): `session-event-log.test.sh` 53,
`session-retention.test.sh` 20, `hook-telemetry-sink.test.sh` 38 plus
the repo-local drift check, `audit-session-id.test.sh` 27,
`gen-hook-event-registry.test.sh` 25,
`probe-observability-state.test.sh` 48, `claude-observability.test.sh`
57 (was 33), the guardrails `skill-reference-verify` suite 139 after the
merge from main. Four review findings were verified by reproduction and
fixed with repro-first cases (each fails on the previous script): the
early stop firing on a nested `}` during a mid-message pause (a
fresh-context review), a Windows path outside the project not reduced to
its last segment (same review), the brace-count class that bash could
not parse as written (the Claude review lane), and a configured root
escaping through a symlinked component (the Codex lane). The empty-guard
race case likewise fails before and passes after.
`scripts/affected-tests.sh --run`: 160 shell suites pass; the one
failure is `session-flow`'s `save_point.test.sh`
(`test_new_origin_falls_back_to_directory_name`, a directory-name
assertion in a suite this branch does not touch, failing identically on
`origin/main` in this container). `scripts/check-changelog-parity.sh
--check-bump origin/main`, `scripts/sync-check-retirements.sh --check`,
`scripts/gen-hook-event-registry.sh --check`,
`scripts/check-silent-skips.sh`, `scripts/sync-plugin-options-docs.py
--check`, `scripts/check-changed-skills.sh origin/main`, and
`scripts/validate-plugins.sh` all exit 0; shellcheck at info severity is
clean on every changed script. CI is green on the head, the Windows test
lane included.

Windows Git Bash is the binding host for the hook-budget parallel-wall
figure and unmeasured here; the README says so, the switch stays off by
default until it is taken, and the Codex thread asking for it is left
open for the operator, who has the host.

The topic's contract slice (`docs/topics/hook-logging-pipeline/`) rode
this branch for review and is pruned on this head, per the topic-docs
convention; the Brief and the phases this PR ships are summarized below.

<details>
<summary>Brief (TLDR, Goal, locked decisions) and Phases 3 to 8 of the
plan</summary>

### TLDR

Design the logging and telemetry pipeline for the marketplace's hooks,
and settle the upstream decisions that determine what it instruments.
Evidence base is FINDINGS.md, a measured read-only audit of the 26 wired
`PostToolUse` rows plus a doc-alignment pass. Interview complete: five
rounds, 21 questions, 19 answered and 2 deferred with named arbiters.

### Goal

Observability across every hook event, defaulting to off, costing
effectively nothing when off and as close to nothing as measurable when
on, with no surface left as a black box and every toggle reachable by
Claude on the operator's behalf rather than by hand.

### Decisions locked (the ones this PR implements)

Logging hooks stay in `claude-ops` (no plugin per hook); every
documented event, plugin default-OFF, from a generated registry with
upstream-drift stamps; storage at `.observability/claude/`,
configurable, one file per session; three toggle levels (sink
unconfigured, per-producer switch, category filter); five correlation
keys as a hierarchy (`session_id`, `prompt_id`, `tool_use_id`,
`agent_id`, `TRACEPARENT`); a fixed spine with payload only where a
decision was made; the producer sources no library; retention keeps the
newer of 30 sessions or 14 days at `SessionEnd`; the pre-prune command
runs detached; the guard heals on first write inside the plugin-owned
root and the project root is refused; only `hook-events.jsonl` migrates,
the skill-usage and OTEL stores stay.

### Phases

- **3, integration slice:** the library, the producer, the sink routes,
`data.session_id` on the nine audit hooks, the six options.
- **4, registry:** the generator, the fixture, `--check` in the suite,
the regenerated `hooks.json`.
- **5, retention:** the `SessionEnd` hook, the prune-pending move-aside,
`clean`'s sweep.
- **6, setup:** `apply` for the guard, the retirement record and its
helper copy, the evals.
- **7, reader:** the root-wide queries, the per-session report, the
pipeline probe, the path migration across the skill's docs, README and
manifest.
- **8, docs and PR:** the CHANGELOG entry and version, the two
convention notes, the toggle cycle, the Windows recheck table, the
affected suites, the code review, this PR, and the prune commit before
the ready flip.

### Follow-ups this PR leaves

- Windows Git Bash recheck: the hook-budget parallel-wall figure for the
always-on kill-switch read; same-second `>>` appends to one session file
at 4 KB lines; `ls -t` tie order on NTFS for retention's "newest N" and
the reader's `session` scope; the late-EOF slice cost against the 262 ms
Linux figure; the peer-reported 11 s cold `cli-flag-verify` run.
- #930: `data.session_id` on every producer and the envelope 1.1 spine
promotion; until then per-session hook duration covers the nine
claude-ops audit hooks.
- `changed` is a defined per-session key no formatter emits yet; the
"rewrote" block of the per-session report stays empty until one does.

</details>

## Related

- #930, the envelope follow-up: `data.session_id` on every producer and
the `schema_version` 1.1 spine promotion
- #3747, #3749 and #3748, the three PRs main took while this branch was
open, all merged back in
- `docs/conventions/hook-observability/README.md` and
`docs/conventions/hook-telemetry/README.md`, both amended here
- `docs/conventions/retired-conventions/README.md`, whose two fixed
setup lines and eval-per-record rule the setup change follows
- `lib/hook-utils.sh` and its 17 vendored copies are untouched

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_019DaWEB8Daq1xAXy2Xj1Pme

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant