Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions configs/claude.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,22 @@ git:
Generated by Claude Code via Kapsis
Branch: {branch}

# Attribution block appended to commits and PR descriptions.
# Placeholders: {version}, {agent_id}, {branch}, {worktree}
# For Claude Code, these values are injected into ~/.claude/settings.local.json
# as Claude's native `attribution.commit` / `attribution.pr` — Claude itself
# writes them on its commits. For other agents, Kapsis appends them to commit
# messages directly. Empty string ("") disables that attribution. Omit the
# `attribution` key entirely to use the built-in Kapsis default.
attribution:
commit: |
[Generated by Kapsis](https://github.com/aviadshiber/kapsis) v{version}
Agent: {agent_id}
pr: "[Generated by Kapsis](https://github.com/aviadshiber/kapsis)"

# Co-authors added to every commit (Git trailer format)
# Format: "Name <email>" - multiple co-authors supported
# Also addable via `--co-author "Name <email>"` CLI flag (repeatable).
co_authors:
- "Aviad Shiber <aviadshiber@gmail.com>"

Expand Down
18 changes: 18 additions & 0 deletions docs/CONFIG-REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -666,9 +666,27 @@ git:
push_flags:
- "--set-upstream"

# Attribution block appended to commits and PR descriptions.
# Placeholders: {version}, {agent_id}, {branch}, {worktree}
#
# For Claude Code (agent.type claude-cli/claude/claude-code), these templates
# are injected into ~/.claude/settings.local.json as Claude's native
# `attribution.commit` and `attribution.pr` — Claude writes them itself on
# each commit/PR it creates. For other agents (codex, gemini, aider), Kapsis
# appends the commit template to its host-side commits directly.
#
# Empty string ("") disables that attribution. Omit the `attribution` key
# entirely to use the built-in default (Kapsis-only, no Claude co-author).
attribution:
commit: |
[Generated by Kapsis](https://github.com/aviadshiber/kapsis) v{version}
Agent: {agent_id}
pr: "[Generated by Kapsis](https://github.com/aviadshiber/kapsis)"

# Co-authors added to every commit (Git trailer format)
# These are appended as "Co-authored-by:" trailers
# Automatically deduplicated against git config user.email
# Can also be added via the CLI flag: --co-author "Name <email>" (repeatable)
co_authors:
- "Aviad Shiber <aviadshiber@gmail.com>"
# - "Another Author <another@example.com>"
Expand Down
17 changes: 13 additions & 4 deletions docs/GIT-WORKFLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,14 +355,23 @@ Default commit message template:
```
feat: {task_summary}

Generated by Kapsis AI Agent Sandbox v{version}
https://github.com/aviadshiber/kapsis
Agent ID: {agent_id}
Worktree: {worktree_name}
[Generated by Kapsis](https://github.com/aviadshiber/kapsis) v{version}
Agent: {agent_id}

Co-authored-by: Aviad Shiber <aviadshiber@gmail.com>
```

The "Generated by Kapsis" line is rendered as a **clickable link** in GitHub,
GitLab, and other markdown-aware commit viewers.

For Claude Code specifically, this attribution is injected into Claude's native
`attribution.commit` setting (in `~/.claude/settings.local.json`), so Claude
writes it itself on each commit it creates — no duplication with Kapsis's
host-side commit logic. Customize the template with `git.attribution.commit` /
`git.attribution.pr` in config, or disable entirely by setting them to `""`.

Supported placeholders: `{version}`, `{agent_id}`, `{branch}`, `{worktree}`.

Customize in config:

```yaml
Expand Down
16 changes: 12 additions & 4 deletions scripts/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -970,11 +970,19 @@ post_exit_git() {
local task_summary="${KAPSIS_TASK:-AI agent changes}"
task_summary="${task_summary:0:72}" # Truncate

local commit_msg="feat: ${task_summary}
# Attribution is templated by launch-agent.sh and passed via env.
# Empty string = attribution disabled. Unset = minimal default.
local attribution_block
if [[ -n "${KAPSIS_ATTRIBUTION_COMMIT+x}" ]]; then
attribution_block="$KAPSIS_ATTRIBUTION_COMMIT"
else
attribution_block="[Generated by Kapsis](https://github.com/aviadshiber/kapsis)"$'\n'"Agent: ${KAPSIS_AGENT_ID:-unknown}"
fi

Generated by Kapsis AI Agent Sandbox
Agent ID: ${KAPSIS_AGENT_ID:-unknown}
Branch: ${KAPSIS_BRANCH}"
local commit_msg="feat: ${task_summary}"
if [[ -n "$attribution_block" ]]; then
commit_msg+=$'\n\n'"$attribution_block"
fi

# Commit
git commit -m "$commit_msg" || {
Expand Down
82 changes: 82 additions & 0 deletions scripts/launch-agent.sh
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ RESUME_MODE=false # Fix #1: Auto-resume existing worktree
FORCE_CLEAN=false # Fix #1: Force remove existing worktree
KEEP_WORKTREE="${KAPSIS_KEEP_WORKTREE:-false}" # Fix #169: Preserve worktree after completion
KEEP_VOLUMES="${KAPSIS_KEEP_VOLUMES:-false}" # Fix #191: Preserve build cache volumes after completion
CLI_CO_AUTHORS=() # Co-authors added via --co-author CLI flag (merged with config)
INTERACTIVE=false
DRY_RUN=false
# Use KAPSIS_IMAGE env var if set (for CI), otherwise default
Expand Down Expand Up @@ -235,6 +236,8 @@ Options:
filtered (default, DNS allowlist), open (unrestricted)
--security-profile <profile>
Security hardening: minimal, standard (default), strict, paranoid
--co-author "Name <email>"
Add a git Co-authored-by trailer (repeatable; merges with config)
-h, --help Show this help message

Available Agents:
Expand Down Expand Up @@ -462,6 +465,15 @@ parse_args() {
export KAPSIS_SECURITY_PROFILE="$2"
shift 2
;;
--co-author)
# Validate format: must contain "<email>" with an @ inside angle brackets
if [[ ! "$2" =~ \<[^\>]+@[^\>]+\> ]]; then
log_error "Invalid --co-author format (expected 'Name <email>'): $2"
exit 1
fi
CLI_CO_AUTHORS+=("$2")
shift 2
;;
-h|--help)
usage
;;
Expand Down Expand Up @@ -752,6 +764,12 @@ parse_config() {
# Parse co-authors (newline-separated list)
GIT_CO_AUTHORS=$(yq -r '.git.co_authors[]' "$CONFIG_FILE" 2>/dev/null | tr '\n' '|' | sed 's/|$//' || echo "")

# Parse attribution templates (commit trailer + PR description).
# A null/missing value yields the string "null" from yq -r; treat that as unset
# so defaults kick in. An explicit empty string in config disables attribution.
GIT_ATTRIBUTION_COMMIT_RAW=$(yq -r '.git.attribution.commit' "$CONFIG_FILE" 2>/dev/null || echo "null")
GIT_ATTRIBUTION_PR_RAW=$(yq -r '.git.attribution.pr' "$CONFIG_FILE" 2>/dev/null || echo "null")

# Parse fork workflow settings
GIT_FORK_ENABLED=$(yq -r '.git.fork_workflow.enabled // "false"' "$CONFIG_FILE")
GIT_FORK_FALLBACK=$(yq -r '.git.fork_workflow.fallback // "fork"' "$CONFIG_FILE")
Expand Down Expand Up @@ -912,11 +930,62 @@ parse_config() {
# Expand environment variables in paths (fixes #104)
SANDBOX_UPPER_BASE=$(expand_path_vars "$SANDBOX_UPPER_BASE")

# Merge CLI --co-author entries into GIT_CO_AUTHORS (pipe-separated).
# Dedup happens downstream in build_coauthor_trailers().
if [[ "${#CLI_CO_AUTHORS[@]}" -gt 0 ]]; then
for c in "${CLI_CO_AUTHORS[@]}"; do
if [[ -n "${GIT_CO_AUTHORS:-}" ]]; then
GIT_CO_AUTHORS+="|$c"
else
GIT_CO_AUTHORS="$c"
fi
done
log_debug "Merged ${#CLI_CO_AUTHORS[@]} CLI co-author(s) with config"
fi

# Resolve attribution templates — fall back to Kapsis-only default when
# config has no attribution key (yq returns "null" for missing keys).
# An explicit empty string in config disables attribution entirely.
local default_commit_attr
default_commit_attr="[Generated by Kapsis](https://github.com/aviadshiber/kapsis) v{version}"$'\n'"Agent: {agent_id}"
local default_pr_attr="[Generated by Kapsis](https://github.com/aviadshiber/kapsis)"

if [[ "${GIT_ATTRIBUTION_COMMIT_RAW:-null}" == "null" ]]; then
GIT_ATTRIBUTION_COMMIT="$default_commit_attr"
else
GIT_ATTRIBUTION_COMMIT="$GIT_ATTRIBUTION_COMMIT_RAW"
fi
if [[ "${GIT_ATTRIBUTION_PR_RAW:-null}" == "null" ]]; then
GIT_ATTRIBUTION_PR="$default_pr_attr"
else
GIT_ATTRIBUTION_PR="$GIT_ATTRIBUTION_PR_RAW"
fi

# Substitute placeholders: {version}, {agent_id}, {branch}, {worktree}
# {worktree} is resolved later once WORKTREE_PATH is known.
# Version resolution matches get_kapsis_version() in post-container-git.sh:
# prefer package.json, fall back to git describe, else "dev".
local kapsis_version_str="${KAPSIS_VERSION:-}"
if [[ -z "$kapsis_version_str" ]] && [[ -f "$KAPSIS_ROOT/package.json" ]]; then
kapsis_version_str=$(grep -o '"version": *"[^"]*"' "$KAPSIS_ROOT/package.json" 2>/dev/null | head -1 | sed 's/.*"\([^"]*\)"$/\1/')
fi
if [[ -z "$kapsis_version_str" ]] && command -v git &>/dev/null; then
kapsis_version_str=$(git -C "$KAPSIS_ROOT" describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo "")
fi
[[ -z "$kapsis_version_str" ]] && kapsis_version_str="dev"
GIT_ATTRIBUTION_COMMIT="${GIT_ATTRIBUTION_COMMIT//\{version\}/$kapsis_version_str}"
GIT_ATTRIBUTION_COMMIT="${GIT_ATTRIBUTION_COMMIT//\{agent_id\}/$AGENT_ID}"
GIT_ATTRIBUTION_COMMIT="${GIT_ATTRIBUTION_COMMIT//\{branch\}/${BRANCH:-}}"
GIT_ATTRIBUTION_PR="${GIT_ATTRIBUTION_PR//\{version\}/$kapsis_version_str}"
GIT_ATTRIBUTION_PR="${GIT_ATTRIBUTION_PR//\{agent_id\}/$AGENT_ID}"
GIT_ATTRIBUTION_PR="${GIT_ATTRIBUTION_PR//\{branch\}/${BRANCH:-}}"

log_debug "Config parsed successfully:"
log_debug " AGENT_COMMAND=$AGENT_COMMAND"
log_debug " RESOURCE_MEMORY=$RESOURCE_MEMORY"
log_debug " RESOURCE_CPUS=$RESOURCE_CPUS"
log_debug " IMAGE_NAME=$IMAGE_NAME"
log_debug " GIT_ATTRIBUTION_COMMIT=$(echo "$GIT_ATTRIBUTION_COMMIT" | head -1)..."
}

#===============================================================================
Expand Down Expand Up @@ -1625,6 +1694,19 @@ generate_env_vars() {
ENV_VARS+=("-e" "KAPSIS_AGENT_TYPE=${agent_type}")
log_debug "Agent type for status tracking: $agent_type"

# Attribution templates (commit trailer + PR description).
# - Claude Code (claude-cli): inject-status-hooks.sh writes these into
# ~/.claude/settings.local.json so Claude Code uses them natively.
# - Other agents: entrypoint.sh and host-side post-container-git.sh read
# KAPSIS_ATTRIBUTION_COMMIT and append it to commit messages directly.
# Empty string disables attribution (Claude Code honors this explicitly).
ENV_VARS+=("-e" "KAPSIS_ATTRIBUTION_COMMIT=${GIT_ATTRIBUTION_COMMIT:-}")
ENV_VARS+=("-e" "KAPSIS_ATTRIBUTION_PR=${GIT_ATTRIBUTION_PR:-}")
# Export for host-side post-container-git.sh (sourced later in same process).
export KAPSIS_ATTRIBUTION_COMMIT="${GIT_ATTRIBUTION_COMMIT:-}"
export KAPSIS_ATTRIBUTION_PR="${GIT_ATTRIBUTION_PR:-}"
export KAPSIS_AGENT_TYPE="${agent_type}"

# Mode-specific variables
if [[ "$SANDBOX_MODE" == "worktree" ]]; then
ENV_VARS+=("-e" "KAPSIS_WORKTREE_MODE=true")
Expand Down
28 changes: 27 additions & 1 deletion scripts/lib/inject-status-hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,22 @@ inject_claude_hooks() {
local tmp_file
tmp_file=$(mktemp)

if jq --arg status_hook "$STATUS_HOOK" --arg stop_hook "$STOP_HOOK" '
# Attribution: only merge when the env vars are defined (set — including the
# empty string, which is a valid "disable" per Claude Code's spec). When
# unset, leave any existing user-configured attribution untouched.
local attr_commit_set="false"
local attr_pr_set="false"
[[ -n "${KAPSIS_ATTRIBUTION_COMMIT+x}" ]] && attr_commit_set="true"
[[ -n "${KAPSIS_ATTRIBUTION_PR+x}" ]] && attr_pr_set="true"

if jq \
--arg status_hook "$STATUS_HOOK" \
--arg stop_hook "$STOP_HOOK" \
--arg attr_commit "${KAPSIS_ATTRIBUTION_COMMIT:-}" \
--arg attr_pr "${KAPSIS_ATTRIBUTION_PR:-}" \
--arg attr_commit_set "$attr_commit_set" \
--arg attr_pr_set "$attr_pr_set" \
'
# Ensure hooks object exists
.hooks //= {} |

Expand All @@ -91,6 +106,17 @@ inject_claude_hooks() {
.hooks.Stop += [{
"hooks": [{"type": "command", "command": $stop_hook, "timeout": 5}]
}]
else . end |

# Attribution: Kapsis writes Claude Code native attribution templates.
# Empty string is valid ("hide attribution" per Claude Code spec).
if $attr_commit_set == "true" then
.attribution //= {} |
.attribution.commit = $attr_commit
else . end |
if $attr_pr_set == "true" then
.attribution //= {} |
.attribution.pr = $attr_pr
else . end
' "$settings_local" > "$tmp_file" 2>/dev/null; then
mv "$tmp_file" "$settings_local"
Expand Down
46 changes: 31 additions & 15 deletions scripts/post-container-git.sh
Original file line number Diff line number Diff line change
Expand Up @@ -409,21 +409,37 @@ commit_changes() {
coauthor_trailers=$(build_coauthor_trailers "$co_authors" "$worktree_path" "$commit_message")
fi

# Get Kapsis version
local kapsis_version
kapsis_version=$(get_kapsis_version)

# Generate full commit message with metadata and co-authors
local full_message
full_message=$(cat << EOF
${commit_message}

Generated by Kapsis AI Agent Sandbox v${kapsis_version}
https://github.com/aviadshiber/kapsis
Agent ID: ${agent_id}
Worktree: $(basename "$worktree_path")
EOF
)
# Resolve attribution text from env (set by launch-agent.sh with placeholders
# already substituted). If unset, fall back to a minimal Kapsis default.
# An explicitly empty value means "no attribution block".
local attribution_text
if [[ -n "${KAPSIS_ATTRIBUTION_COMMIT+x}" ]]; then
attribution_text="$KAPSIS_ATTRIBUTION_COMMIT"
else
local kapsis_version
kapsis_version=$(get_kapsis_version)
attribution_text="[Generated by Kapsis](https://github.com/aviadshiber/kapsis) v${kapsis_version}"$'\n'"Agent: ${agent_id}"
fi

# Resolve {worktree} placeholder — only known at this point.
attribution_text="${attribution_text//\{worktree\}/$(basename "$worktree_path")}"

# For Claude Code: the agent itself injects attribution via its native
# `attribution.commit` setting. Avoid duplicating it here if the fallback
# commit_message already contains our Kapsis signature.
local agent_type="${KAPSIS_AGENT_TYPE:-unknown}"
if [[ "$agent_type" == "claude-cli" || "$agent_type" == "claude" || "$agent_type" == "claude-code" ]]; then
if [[ -n "$attribution_text" ]] && [[ "$commit_message" == *"Generated by Kapsis"* ]]; then
log_debug "Attribution already present in commit message (Claude native) — skipping host-side append"
attribution_text=""
fi
fi

# Generate full commit message
local full_message="$commit_message"
if [[ -n "$attribution_text" ]]; then
full_message+=$'\n\n'"$attribution_text"
fi

# Append co-author trailers if present
if [[ -n "$coauthor_trailers" ]]; then
Expand Down
Loading
Loading