Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d686834
refactor(claude-ops): resolve the skill-usage store through one polic…
claude Sep 9, 2026
28db30f
feat(ci): prove every continue-on-error gate step is fed to the ci-st…
claude Sep 9, 2026
b3eb2aa
refactor(claude-ops): dispatch seven audit rows from one event emitter
claude Sep 9, 2026
0f3c73b
refactor(disk-hygiene): declare the engine grammar once for the parse…
claude Sep 9, 2026
fdbc319
refactor(repo-fleet-hygiene): register finding kinds once and classif…
claude Sep 9, 2026
663467e
fix(claude-ops): clear the inherited git environment in the co-occurr…
claude Sep 10, 2026
3947bee
refactor(scripts): read every list through read-list.sh and every dif…
claude Sep 10, 2026
94f1df8
refactor(claude-ops): build every JSONL record through one formatter
claude Sep 10, 2026
63b21db
refactor(hook-utils): return a parsed git invocation instead of five …
claude Sep 10, 2026
2cbaf29
refactor(hook-utils): own the file-edit prologue and carry redirects …
claude Sep 10, 2026
52e1cf4
refactor(scripts): build every check-script fixture through one builder
claude Sep 10, 2026
6be3873
test(guardrails): drive guards to a verdict through one driver, direc…
claude Sep 10, 2026
f4883bc
refactor(scripts): give the check family one exit-code and stream con…
claude Sep 10, 2026
65689b9
refactor(scripts): move the shell-portability scanner out of the quot…
claude Sep 10, 2026
d829e02
refactor(hook-utils): own the exit arm with hook::finish and the pare…
claude Sep 10, 2026
7920053
refactor(guardrails): make the dispatcher and guard contract one decl…
claude Sep 11, 2026
9072a42
refactor(hook-utils): one calling convention, and one engine behind t…
claude Sep 11, 2026
4ae6675
Merge origin/main into claude/architecture-improvements-hqfc0i
claude Sep 11, 2026
0e00183
chore(plugins): bump twenty plugin versions and record the deepening …
claude Sep 11, 2026
19fdb46
fix(ci): mark the two new test suites executable
claude Sep 11, 2026
12a69b2
Merge remote-tracking branch 'origin/main' into claude/architecture-i…
claude Sep 11, 2026
cbfdfd4
Merge remote-tracking branch 'origin/main' into claude/architecture-i…
claude Sep 11, 2026
601d96d
test(hook-utils): compare the begin and finish cases in forms Windows…
claude Sep 11, 2026
c94be90
Merge remote-tracking branch 'origin/main' into claude/architecture-i…
claude Sep 11, 2026
c7c6f7a
fix(hook-utils,claude-ops): match the pre-filter on unescaped separat…
claude Sep 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 30 additions & 48 deletions .claude/hooks/hook-telemetry-sink.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,23 @@
# the log root (.observability/claude by default, project-relative; the
# session_event_log_dir option moves it).
#
# Two routes, decided by the envelope's session id, read from the spine
# (`session_id`, which a contract-1.1 producer carries when its payload held a
# well-formed one and omits otherwise) and falling back to `data.session_id`,
# which the claude-ops audit hooks still send:
# * present and well-formed: one spine-shaped line appended to
# sessions/<session_id>.jsonl, beside the per-session event log
# (session-event-log.sh); `source: "envelope"` tells the reader which
# producer wrote it. No lock: one file per session removes the shared
# write.
# * absent: the legacy shape ({ts, event, hook, tool, duration_ms, exit_code,
# subject, status}) appended to hook-events.jsonl under the same root, the
# shared file the observability skill has always read, under its lock.
# Both routes write ONE record shape — the hook event record session-log-lib.sh
# documents and formats (slog_event_record_to), `source: "envelope"`. The
# envelope's session id, read from the spine (`session_id`, which a
# contract-1.1 producer carries when its payload held a well-formed one and
# omits otherwise) and falling back to `data.session_id`, which the claude-ops
# audit hooks still send, decides the DESTINATION and nothing else:
# * present and well-formed: appended to sessions/<session_id>.jsonl, beside
# the per-session event log (session-event-log.sh). No lock: one file per
# session removes the shared write.
# * absent: appended to the shared hook-events.jsonl under the same root, the
# file the observability skill has always read, under its lock. The record
# is the same minus `session_id`, which these rows do not have.
#
# Field mapping: ts<-timestamp, event<-hook_event, hook<-hook, tool<-data.tool,
# subject<-data.subject, changed<-data.changed (when a producer sends one);
# status translates (ok->success) and exit_code derives from status
# (error/blocked->2, else 0), since the skill keys errors on it.
# Field mapping: ts<-timestamp, hook_event_name<-hook_event, hook<-hook,
# tool<-data.tool, subject<-data.subject, changed<-data.changed (when a
# producer sends one); status translates (ok->success) and exit_code derives
# from status (error/blocked->2, else 0), since the skill keys errors on it.
#
# The root carries a self-ignoring .gitignore inside a checkout, healed on the
# first write when absent (session-log-lib.sh); a guard an operator changed is
Expand Down Expand Up @@ -115,50 +115,32 @@ blocked)
;;
esac

project_dir=$(hook::repo_root "${CLAUDE_PROJECT_DIR:-.}")
project_dir=""
hook::repo_root_to project_dir "${CLAUDE_PROJECT_DIR:-.}" || :
root=""
slog_root_to root "$project_dir"
[[ -n "$root" ]] || exit 0
slog_guard_ok "$root" "$project_dir" || exit 0

# One record, one formatter, both routes: the line differs only by the session
# id the spine carries, so the route decides the destination and nothing else.
# No second jq here — session-log-lib.sh builds and escapes the line from
# builtins, which is one process fewer per event on both routes.
RUN_KEYS=(hook s "$HOOK" exit_code n "${EXIT_CODE:-0}" subject s "$SUBJECT" tool s "$TOOL")
[[ -n "$CHANGED" ]] && RUN_KEYS+=(changed n "$CHANGED")

LINE=""
if [[ -n "$SESSION_ID" ]] && slog_valid_id "$SESSION_ID"; then
[[ -d "$root/sessions" ]] || mkdir -p "$root/sessions" 2>/dev/null || exit 0
LINE=$(MSYS_NO_PATHCONV=1 jq -nc \
--arg ts "$TS" \
--arg session_id "$SESSION_ID" \
--arg event "$EVENT" \
--arg hook "$HOOK" \
--arg tool "$TOOL" \
--argjson duration_ms "${DURATION_MS:-0}" \
--argjson exit_code "${EXIT_CODE:-0}" \
--arg subject "$SUBJECT" \
--arg status "$STATUS_OUT" \
--arg changed "$CHANGED" \
'{ts: $ts, session_id: $session_id, hook_event_name: $event, status: $status,
duration_ms: $duration_ms, source: "envelope", hook: $hook,
exit_code: $exit_code, subject: $subject, tool: $tool}
+ (if $changed == "" then {} else {changed: ($changed == "true")} end)' 2>/dev/null) || exit 0
[[ -n "$LINE" ]] || exit 0
slog_event_record_to LINE envelope "$TS" "$SESSION_ID" "$EVENT" "$STATUS_OUT" \
"${DURATION_MS:-0}" "${RUN_KEYS[@]}"
printf '%s\n' "$LINE" >>"$root/sessions/$SESSION_ID.jsonl" 2>/dev/null
exit 0
fi

mkdir -p "$root" 2>/dev/null || exit 0

LINE=$(MSYS_NO_PATHCONV=1 jq -nc \
--arg ts "$TS" \
--arg event "$EVENT" \
--arg hook "$HOOK" \
--arg tool "$TOOL" \
--argjson duration_ms "${DURATION_MS:-0}" \
--argjson exit_code "${EXIT_CODE:-0}" \
--arg subject "$SUBJECT" \
--arg status "$STATUS_OUT" \
'{ts: $ts, event: $event, hook: $hook, tool: $tool,
duration_ms: $duration_ms, exit_code: $exit_code,
subject: $subject, status: $status}' 2>/dev/null) || exit 0
[[ -n "$LINE" ]] || exit 0

slog_event_record_to LINE envelope "$TS" "" "$EVENT" "$STATUS_OUT" \
"${DURATION_MS:-0}" "${RUN_KEYS[@]}"
hook::append_jsonl "${root}/hook-events.jsonl" "$LINE"

exit 0
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1027,7 +1027,7 @@ jobs:
- name: Test the lane-coverage gate
if: needs.changes.outputs.run_shell == 'true'
run: bash scripts/check-lane-coverage.test.sh
- name: Check every ci.yml job is reachable from the ci-status aggregate
- name: Check every ci.yml job and gate step is fed to the ci-status aggregate
id: lane_coverage
continue-on-error: true
run: scripts/check-lane-coverage.sh --check
Expand Down
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,61 @@ ceilings that fail spuriously under concurrency. Selection is the lever.

CI is unaffected, it still runs everything.

### The check-script contract

`scripts/check-*.sh` is one family with one caller-visible interface, so a CI
lane, a wrapper, or an agent reads a run's outcome without knowing which member
produced it.

| Exit | Meaning |
|---|---|
| `0` | Clean. The check ran over its whole corpus and found nothing. |
| `1` | Findings. The check ran and something in the tree is wrong. |
| `2` | Environment or usage. The check could not run: a missing tool, a bad argument, a repo root or shared library that did not resolve, a git query that failed. Nothing was inspected, so this is never a pass. |

Findings and diagnostics go to **stderr**; **stdout** carries the clean-run
statement and, for the discovery or list modes some members offer, the report
that mode exists to print.

Keeping `1` and `2` apart is the whole point: "your tree is wrong" and "I could
not look" are different answers, and a lane that reads only success or failure
collapses them into one. This is the mechanical form, for this family, of the
fail-loud rule in
[`docs/conventions/liveness-assertion/`](docs/conventions/liveness-assertion/README.md).
An environment failure is spelled out rather than left to `set -e`: resolving the
repo root and sourcing a shared library each end in `|| exit 2`, because `set -e`
would exit with the failing command's own status and hand the caller a `1` that
reads as findings.

[`scripts/check-script-contract.test.sh`](scripts/check-script-contract.test.sh)
holds the contract. Every member is registered there and a new one fails as
unregistered. Each member that declares a prerequisite is run with that
prerequisite taken away and must exit `2` on stderr; each member with a fixture
recipe is also run clean (exit `0`, statement on stdout) and against a seeded
violation (exit `1`, finding on stderr and not on stdout). A member with no
recipe yet is held to those last two halves by its own co-located suite.

Three readings diverge on purpose and are recorded here rather than forced into
line:

- `scripts/check-hook-exec-form.sh` treats a hook declaration it cannot parse as
a finding (`1`), not an environment problem. The input is what is wrong, and
clearing a file the gate never read is the silent no-op it exists to catch.
- `scripts/check-killswitch-hoist.sh` stops at `1`, not `2`, when the hook corpus
scans to empty or the inlined kill-switch predicate no longer agrees with the
`hook::is_enabled` it duplicates. Both are statements about the tree, not about
the host.
- `scripts/check-changelog-parity.sh` discusses exit `128` and `141` at length
and emits neither. Those are git's "no merge base" and a reader killed by
SIGPIPE, each converted to an `exit 2` or read correctly. Grepping the file for
those numbers finds the guards, not divergence.

The contract governs the observable interface, not the option set: `set -euo`
and `set -uo` both appear in the family and neither is required, which is why
the prologue states its own `|| exit 2` instead of depending on which one is in
force. Members still resolving the repo root under `set -e` alone are aligned on
touch.

## Official documentation

This repo tracks policy and wiring only; authoritative behavior lives in the official docs, which must
Expand Down
14 changes: 11 additions & 3 deletions docs/conventions/hook-telemetry/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,17 @@ The claude-ops reference sink reads the spine key first and falls back to `data.
key the nine claude-ops audit hooks added under the additive rule before 1.1 and still send: an
envelope carrying a well-formed id is appended to `<root>/sessions/<session_id>.jsonl` beside the
per-session event log, and an envelope without one (a producer on 1.0, or a hook whose payload
carries no session) goes to the shared `<root>/hook-events.jsonl` in the legacy shape. The
per-session report therefore covers every fleet producer from their 1.1 versions on (#3758, which
finishes the thread #930 opened).
carries no session) goes to the shared `<root>/hook-events.jsonl`. The per-session report therefore
covers every fleet producer from their 1.1 versions on (#3758, which finishes the thread #930
opened).

A sink's own store shape is the sink's business, not this contract's — but a store a reader has to
reconcile per query is the sink's own defect. The claude-ops reference sink writes ONE record shape
on both routes, alongside the per-session event log's rows: the routes differ by destination and by
whether the record carries `session_id`, and `hook_event_name` names the event on every row, so no
reader normalizes an event key. The key set and the group each route contributes are stated once,
where the formatter every writer calls lives:
`plugins/claude-ops/hooks/session-log-lib.sh` (`slog_event_record_to`).

## Implementers

Expand Down
4 changes: 2 additions & 2 deletions docs/conventions/windows-path-emit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,10 @@ creates nothing. The fix is a per-command `MSYS_NO_PATHCONV=1` prefix, or `git s
and is where this repo's `cygpath` dependency was first established, but neither of its path helpers
is an emit helper:

- `hook::normalize_path` folds a leading drive prefix for a **comparison**, using no `cygpath` at
- `hook::normalize_path_to` folds a leading drive prefix for a **comparison**, using no `cygpath` at
all. Its own comment is explicit that "the emitted path is always the caller's original." Emitting
its return value is a misuse of it.
- `hook::expand_8dot3` does call `cygpath -m` / `cygpath -l -m`, but to expand **8.3 short names**,
- `hook::expand_8dot3_to` does call `cygpath -m` / `cygpath -l -m`, but to expand **8.3 short names**,
and only for a path containing `~`.

Both fail **open** — degrading to the caller's original path — which is right for a comparison and
Expand Down
Loading
Loading