Skip to content

Commit 3c96e77

Browse files
feat(plugins/git): operator Co-authored-by trailer (#158)
Add a small `git` plugin as a configuration point for non-observability git concerns — currently just operator attribution; intended to grow into a home for other git-related hooks, config, and tooling. Why a separate plugin: the observability plugin owns git event-emission hooks (post-commit, pre-push, …) and that's its job. This plugin holds non-observability git concerns so the two stay focused and can evolve independently. Changes: - Add plugins/git/.claude-plugin/plugin.json declaring SYN_OPERATOR_NAME and SYN_OPERATOR_EMAIL via requires_env so the agentic-isolation orchestrator auto-forwards them when set in the host env. - Add plugins/git/hooks/prepare-commit-msg — appends 'Co-authored-by: NAME <EMAIL>' when both operator env vars are set. Skips merge/squash/template/commit message sources. Idempotent. Strips CR/LF from operator inputs to prevent newline-injection of extra trailers. No-op when env vars unset (backward compatible). - Add plugins/git/README.md framing the plugin's scope. - Update providers/workspaces/claude-cli/manifest.yaml to bake the new plugin into the image. - Update entrypoint.sh: compose git hooks from BOTH observability (event emission) and the new git plugin (attribution) into a single runtime directory at $HOME/.git-hooks, then point core.hooksPath there. Multiple plugins can now contribute git hooks. Observability is otherwise untouched. Resolves #158. Verification (rebuilt 2.1.126 image): - 27/27 just test-workspace passes. - Hook composition: 6 symlinks under $HOME/.git-hooks/ — 5 to observability/hooks/git/, 1 to git/hooks/prepare-commit-msg. - prepare-commit-msg matrix in container: - merge|squash|template|commit source -> no trailer - message source, no env -> no trailer - message source, with env -> trailer appended - trailer already present -> idempotent - newline-injected env var -> sanitized, only 1 trailer line - Observability git_commit events still emit on stderr as before.
1 parent a15a3ee commit 3c96e77

5 files changed

Lines changed: 125 additions & 12 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "git",
3+
"version": "0.1.0",
4+
"description": "Git configuration point for the workspace — non-observability git concerns. Currently owns operator Co-authored-by attribution; intended to grow into a home for other git-related hooks, config, and tooling.",
5+
"author": {"name": "NeuralEmpowerment"},
6+
"repository": "https://github.com/AgentParadise/agentic-primitives",
7+
"requires_env": {
8+
"SYN_OPERATOR_NAME": {
9+
"description": "Operator display name. When set together with SYN_OPERATOR_EMAIL, every workspace commit gets a 'Co-authored-by' trailer attributing the operator who sponsored the workspace.",
10+
"required": false,
11+
"secret": false
12+
},
13+
"SYN_OPERATOR_EMAIL": {
14+
"description": "Operator email — must match a verified GitHub account so the co-author renders with the operator's avatar on PRs.",
15+
"required": false,
16+
"secret": false
17+
}
18+
}
19+
}

plugins/git/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# git
2+
3+
Git configuration point for the workspace — a home for **non-observability** git concerns: hooks, config, and tooling that affect how the agent interacts with git, but aren't about emitting events.
4+
5+
> **Why a separate plugin?** The `observability` plugin owns git hooks that emit events (`post-commit`, `pre-push`, etc.) — that's its job. This plugin holds everything else git-related so observability stays focused. Both plugins can ship git hooks; the workspace entrypoint composes them into a single `core.hooksPath` at startup.
6+
7+
## What's here today
8+
9+
- **`hooks/prepare-commit-msg`** — operator `Co-authored-by:` attribution. When `SYN_OPERATOR_NAME` and `SYN_OPERATOR_EMAIL` are both set, the hook appends a co-author trailer to every commit message so PRs the workspace opens are attributed to the operator who sponsored it.
10+
11+
## Operator attribution
12+
13+
Two env vars enable the trailer:
14+
15+
- `SYN_OPERATOR_NAME` — display name (e.g. `"NeuralEmpowerment"`)
16+
- `SYN_OPERATOR_EMAIL` — must match a verified GitHub account so the co-author renders with the operator's avatar
17+
18+
Both are declared in `requires_env` of `.claude-plugin/plugin.json`, so an `agentic-isolation` orchestrator that has them in the host env will auto-forward them into the container.
19+
20+
When unset, the hook is a no-op — backward-compatible.
21+
22+
The hook skips auto-generated message sources (`merge`, `squash`, `template`, `commit`) to avoid polluting them, and is idempotent (won't double-append the same trailer).
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
#!/bin/sh
2+
# prepare-commit-msg — append operator Co-authored-by trailer.
3+
# Plugin marker for install.py uninstall detection: agentic_events
4+
#
5+
# When the workspace is run by an operator (e.g. via Syntropic137 selfhost),
6+
# SYN_OPERATOR_NAME and SYN_OPERATOR_EMAIL identify the human who sponsored
7+
# the workspace. This hook adds them as a co-author on every agent commit so
8+
# the operator gets attribution on PRs the workspace opens.
9+
#
10+
# Behavior:
11+
# - No-op when either env var is unset (backward compatible).
12+
# - Skipped on auto-generated message sources (merge/squash/template/commit)
13+
# so we don't pollute them.
14+
# - Idempotent — won't append if the same trailer already exists.
15+
# - Always exits 0 — never blocks a commit on attribution failure.
16+
17+
COMMIT_MSG_FILE="$1"
18+
COMMIT_SOURCE="$2"
19+
20+
# Skip auto-generated message sources so we don't pollute them.
21+
# merge — message produced by `git merge`
22+
# squash — message produced by `git merge --squash`
23+
# template — user has a commit.template configured
24+
# commit — reusing an existing commit (e.g. `git commit --amend`,
25+
# `git cherry-pick`). The original message already has any
26+
# trailer it should have; idempotency below handles re-adds.
27+
case "$COMMIT_SOURCE" in
28+
merge|squash|template|commit) exit 0 ;;
29+
esac
30+
31+
if [ -z "$SYN_OPERATOR_NAME" ] || [ -z "$SYN_OPERATOR_EMAIL" ]; then
32+
exit 0
33+
fi
34+
35+
# Strip CR/LF from operator inputs — env vars are normally well-formed but we
36+
# don't want a stray newline in either to inject additional trailers.
37+
NAME=$(printf '%s' "$SYN_OPERATOR_NAME" | tr -d '\r\n')
38+
EMAIL=$(printf '%s' "$SYN_OPERATOR_EMAIL" | tr -d '\r\n')
39+
40+
if [ -z "$NAME" ] || [ -z "$EMAIL" ]; then
41+
exit 0
42+
fi
43+
44+
TRAILER="Co-authored-by: ${NAME} <${EMAIL}>"
45+
46+
if grep -qF "$TRAILER" "$COMMIT_MSG_FILE" 2>/dev/null; then
47+
exit 0
48+
fi
49+
50+
printf '\n%s\n' "$TRAILER" >> "$COMMIT_MSG_FILE"
51+
exit 0

providers/workspaces/claude-cli/manifest.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ plugins:
3636
- sdlc
3737
- workspace
3838
- observability
39+
- git
3940
# Install location in the image
4041
install_path: /opt/agentic/plugins
4142

providers/workspaces/claude-cli/scripts/entrypoint.sh

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -100,23 +100,43 @@ if [ -n "${GIT_AUTHOR_NAME}" ]; then
100100
git config --global init.defaultBranch main
101101
fi
102102

103-
# Install observability git hooks globally (ADR-043).
103+
# Install workspace git hooks globally (ADR-043).
104104
#
105105
# core.hooksPath is a git global config that overrides the per-repo .git/hooks/
106-
# directory. Setting it here (at container startup) means post-commit, pre-push,
107-
# post-merge, and post-rewrite hooks fire for EVERY repo cloned or initialized
108-
# inside this container — including repos cloned by the agent mid-task.
106+
# directory. Setting it here (at container startup) means our hooks fire for
107+
# EVERY repo cloned or initialized inside this container — including repos
108+
# cloned by the agent mid-task.
109109
#
110-
# The hooks emit JSONL observability events to stderr. The docker exec stream in
111-
# AgenticEventStreamAdapter uses stderr=STDOUT to capture them alongside Claude's
112-
# stdout, and WorkflowExecutionEngine stores them in TimescaleDB.
110+
# Multiple plugins can contribute git hooks. We compose them into a single
111+
# runtime directory ($GIT_HOOKS_DIR) and point core.hooksPath at it:
112+
# - observability/hooks/git/ → event-emission (post-commit, pre-push, …)
113+
# - git/hooks/ → other git concerns (prepare-commit-msg, …)
113114
#
114-
# Without this line, git hooks only fire in repos that have their own .git/hooks/
115-
# scripts, which is almost never the case for freshly cloned repos.
116-
GIT_HOOKS_DIR="${AGENTIC_PLUGINS_DIR:-/opt/agentic/plugins}/observability/hooks/git"
117-
if [ -d "${GIT_HOOKS_DIR}" ]; then
115+
# Each plugin owns hook filenames it ships; collisions across plugins are not
116+
# expected today. If two plugins ever ship the same hook name, the second
117+
# symlink wins.
118+
#
119+
# Observability hooks emit JSONL events to stderr; the docker exec stream in
120+
# AgenticEventStreamAdapter uses stderr=STDOUT to capture them alongside
121+
# Claude's stdout, and WorkflowExecutionEngine stores them in TimescaleDB.
122+
GIT_HOOKS_DIR="${HOME}/.git-hooks"
123+
mkdir -p "${GIT_HOOKS_DIR}"
124+
125+
PLUGINS_BASE="${AGENTIC_PLUGINS_DIR:-/opt/agentic/plugins}"
126+
for src_dir in "${PLUGINS_BASE}/observability/hooks/git" "${PLUGINS_BASE}/git/hooks"; do
127+
[ -d "${src_dir}" ] || continue
128+
for src in "${src_dir}"/*; do
129+
[ -f "${src}" ] || continue
130+
name=$(basename "${src}")
131+
# Skip non-hook files (installer, docs)
132+
case "${name}" in install.py|*.md|*.txt) continue ;; esac
133+
ln -sf "${src}" "${GIT_HOOKS_DIR}/${name}"
134+
done
135+
done
136+
137+
if [ -n "$(ls -A "${GIT_HOOKS_DIR}" 2>/dev/null)" ]; then
118138
git config --global core.hooksPath "${GIT_HOOKS_DIR}"
119-
echo "[entrypoint] Git observability hooks installed from ${GIT_HOOKS_DIR}"
139+
echo "[entrypoint] Workspace git hooks composed at ${GIT_HOOKS_DIR}"
120140
fi
121141

122142
# Also set committer identity (git uses both for commits)

0 commit comments

Comments
 (0)