From 28df515b6acb0eca2865b562ef9669cd9b081250 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 17:47:45 +0000 Subject: [PATCH 1/2] feat(git): replace attribution with Claude native + add --co-author CLI flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kapsis used to append its own attribution block to every commit regardless of what Claude Code already added via its native `attribution.commit` setting — causing duplicated or inconsistent attribution depending on whether Claude or Kapsis made the commit. Now Kapsis injects `attribution.commit` and `attribution.pr` into `~/.claude/settings.local.json` so Claude writes the attribution itself, and the host-side commit detects the signature and skips duplicates. For non-Claude agents (codex, gemini, aider), Kapsis continues to append the same template via `KAPSIS_ATTRIBUTION_COMMIT` env var. Default attribution is now a markdown-linked `[Generated by Kapsis](...)` that renders as a clickable link on GitHub. Supports `{version}`, `{agent_id}`, `{branch}`, `{worktree}` placeholders. Empty string disables attribution entirely. Also adds a `--co-author "Name "` CLI flag (repeatable) that merges with co-authors from config. Existing dedup in `build_coauthor_trailers` handles duplicates across config/CLI/git user/commit message. https://claude.ai/code/session_018mdcbzthMYwuwE3fC66y2R --- configs/claude.yaml | 14 ++ docs/CONFIG-REFERENCE.md | 18 +++ docs/GIT-WORKFLOW.md | 17 +- scripts/entrypoint.sh | 16 +- scripts/launch-agent.sh | 76 +++++++++ scripts/lib/inject-status-hooks.sh | 28 +++- scripts/post-container-git.sh | 46 ++++-- tests/test-coauthor-fork.sh | 249 +++++++++++++++++++++++++++++ 8 files changed, 440 insertions(+), 24 deletions(-) diff --git a/configs/claude.yaml b/configs/claude.yaml index 49f02f61..dbd7801a 100644 --- a/configs/claude.yaml +++ b/configs/claude.yaml @@ -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 " - multiple co-authors supported + # Also addable via `--co-author "Name "` CLI flag (repeatable). co_authors: - "Aviad Shiber " diff --git a/docs/CONFIG-REFERENCE.md b/docs/CONFIG-REFERENCE.md index 788325a8..d942aa37 100644 --- a/docs/CONFIG-REFERENCE.md +++ b/docs/CONFIG-REFERENCE.md @@ -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 " (repeatable) co_authors: - "Aviad Shiber " # - "Another Author " diff --git a/docs/GIT-WORKFLOW.md b/docs/GIT-WORKFLOW.md index b1861fb2..0a691a35 100644 --- a/docs/GIT-WORKFLOW.md +++ b/docs/GIT-WORKFLOW.md @@ -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 ``` +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 diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 572227f4..7d43814c 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -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" || { diff --git a/scripts/launch-agent.sh b/scripts/launch-agent.sh index 8fc417f9..ab696b33 100755 --- a/scripts/launch-agent.sh +++ b/scripts/launch-agent.sh @@ -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 @@ -235,6 +236,8 @@ Options: filtered (default, DNS allowlist), open (unrestricted) --security-profile Security hardening: minimal, standard (default), strict, paranoid + --co-author "Name " + Add a git Co-authored-by trailer (repeatable; merges with config) -h, --help Show this help message Available Agents: @@ -462,6 +465,15 @@ parse_args() { export KAPSIS_SECURITY_PROFILE="$2" shift 2 ;; + --co-author) + # Validate format: must contain "" with an @ inside angle brackets + if [[ ! "$2" =~ \<[^\>]+@[^\>]+\> ]]; then + log_error "Invalid --co-author format (expected 'Name '): $2" + exit 1 + fi + CLI_CO_AUTHORS+=("$2") + shift 2 + ;; -h|--help) usage ;; @@ -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") @@ -912,11 +930,56 @@ 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. + local kapsis_version_str="${KAPSIS_VERSION:-unknown}" + if [[ -z "${KAPSIS_VERSION:-}" ]] && [[ -f "$KAPSIS_ROOT/VERSION" ]]; then + kapsis_version_str=$(tr -d '[:space:]' < "$KAPSIS_ROOT/VERSION" 2>/dev/null || echo "unknown") + fi + 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)..." } #=============================================================================== @@ -1625,6 +1688,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") diff --git a/scripts/lib/inject-status-hooks.sh b/scripts/lib/inject-status-hooks.sh index 7686cf75..f2130835 100755 --- a/scripts/lib/inject-status-hooks.sh +++ b/scripts/lib/inject-status-hooks.sh @@ -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 //= {} | @@ -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" diff --git a/scripts/post-container-git.sh b/scripts/post-container-git.sh index f43385b5..245482da 100755 --- a/scripts/post-container-git.sh +++ b/scripts/post-container-git.sh @@ -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 diff --git a/tests/test-coauthor-fork.sh b/tests/test-coauthor-fork.sh index 91b57d97..0c75c6f6 100755 --- a/tests/test-coauthor-fork.sh +++ b/tests/test-coauthor-fork.sh @@ -436,6 +436,243 @@ test_config_fork_workflow_parsing() { assert_equals "$fallback" "fork" "Fork fallback should be 'fork' by default" } +#=============================================================================== +# TEST CASES: Attribution Templates +#=============================================================================== + +test_config_attribution_parsing() { + log_test "Testing git.attribution config parsing with yq" + + if ! command -v yq &>/dev/null; then + log_info "yq not installed, skipping attribution parsing test" + return 0 + fi + + local config_file="$KAPSIS_ROOT/configs/claude.yaml" + if [[ ! -f "$config_file" ]]; then + log_warn "Config file not found: $config_file" + return 0 + fi + + local commit_attr + commit_attr=$(yq -r '.git.attribution.commit' "$config_file" 2>/dev/null || echo "null") + + assert_contains "$commit_attr" "Generated by Kapsis" \ + "Should parse attribution.commit from config" + assert_contains "$commit_attr" "{version}" \ + "Attribution should include {version} placeholder" + assert_contains "$commit_attr" "{agent_id}" \ + "Attribution should include {agent_id} placeholder" + + local pr_attr + pr_attr=$(yq -r '.git.attribution.pr' "$config_file" 2>/dev/null || echo "null") + assert_contains "$pr_attr" "Generated by Kapsis" \ + "Should parse attribution.pr from config" + assert_contains "$pr_attr" "(https://github.com/aviadshiber/kapsis)" \ + "PR attribution should include markdown link to repo" +} + +test_attribution_placeholder_substitution() { + log_test "Testing placeholder substitution in attribution template" + + local template="[Generated by Kapsis](https://github.com/aviadshiber/kapsis) v{version} +Agent: {agent_id} +Branch: {branch}" + + local version="2.16.6" + local agent_id="abc123" + local branch="feature/test" + + # Simulate launch-agent.sh's substitution logic + local result="$template" + result="${result//\{version\}/$version}" + result="${result//\{agent_id\}/$agent_id}" + result="${result//\{branch\}/$branch}" + + assert_contains "$result" "v2.16.6" "Should substitute {version}" + assert_contains "$result" "Agent: abc123" "Should substitute {agent_id}" + assert_contains "$result" "Branch: feature/test" "Should substitute {branch}" + # Ensure no placeholders remain + if [[ "$result" == *"{version}"* || "$result" == *"{agent_id}"* || "$result" == *"{branch}"* ]]; then + log_error "Unsubstituted placeholders remain in: $result" + return 1 + fi + log_info "All placeholders substituted correctly" +} + +test_attribution_commit_uses_env_var() { + log_test "Testing commit_changes uses KAPSIS_ATTRIBUTION_COMMIT env var" + + local temp_repo + temp_repo=$(create_temp_git_repo) + cd "$temp_repo" + echo "hello" > file.txt + + source "$POST_CONTAINER_GIT" + + # Inject a custom attribution via env + export KAPSIS_ATTRIBUTION_COMMIT="CUSTOM-ATTR v1.2.3" + export KAPSIS_AGENT_TYPE="codex-cli" + + commit_changes "$temp_repo" "feat: my change" "test-agent" "" >/dev/null 2>&1 || true + + local last_msg + last_msg=$(git -C "$temp_repo" log -1 --format=%B 2>/dev/null || echo "") + + unset KAPSIS_ATTRIBUTION_COMMIT + unset KAPSIS_AGENT_TYPE + rm -rf "$temp_repo" + + assert_contains "$last_msg" "CUSTOM-ATTR v1.2.3" \ + "Commit should contain attribution from KAPSIS_ATTRIBUTION_COMMIT env" +} + +test_attribution_empty_disables() { + log_test "Testing empty KAPSIS_ATTRIBUTION_COMMIT disables attribution" + + local temp_repo + temp_repo=$(create_temp_git_repo) + cd "$temp_repo" + echo "hello" > file.txt + + source "$POST_CONTAINER_GIT" + + # Empty string must disable the attribution block + export KAPSIS_ATTRIBUTION_COMMIT="" + export KAPSIS_AGENT_TYPE="codex-cli" + + commit_changes "$temp_repo" "feat: my change" "test-agent" "" >/dev/null 2>&1 || true + + local last_msg + last_msg=$(git -C "$temp_repo" log -1 --format=%B 2>/dev/null || echo "") + + unset KAPSIS_ATTRIBUTION_COMMIT + unset KAPSIS_AGENT_TYPE + rm -rf "$temp_repo" + + assert_not_contains "$last_msg" "Generated by Kapsis" \ + "Empty KAPSIS_ATTRIBUTION_COMMIT should suppress attribution" +} + +test_attribution_claude_skip_duplicate() { + log_test "Testing Claude agent skips duplicate attribution when already in message" + + local temp_repo + temp_repo=$(create_temp_git_repo) + cd "$temp_repo" + echo "hello" > file.txt + + source "$POST_CONTAINER_GIT" + + export KAPSIS_ATTRIBUTION_COMMIT="[Generated by Kapsis](https://github.com/aviadshiber/kapsis) v1.0" + export KAPSIS_AGENT_TYPE="claude-cli" + + # Commit message already contains the Kapsis signature (as if Claude already appended it) + local preset_msg="feat: change + +[Generated by Kapsis](https://github.com/aviadshiber/kapsis) v1.0" + + commit_changes "$temp_repo" "$preset_msg" "test-agent" "" >/dev/null 2>&1 || true + + local last_msg + last_msg=$(git -C "$temp_repo" log -1 --format=%B 2>/dev/null || echo "") + + unset KAPSIS_ATTRIBUTION_COMMIT + unset KAPSIS_AGENT_TYPE + rm -rf "$temp_repo" + + # Count occurrences — should be exactly 1 (not duplicated) + local count + count=$(echo "$last_msg" | grep -c "Generated by Kapsis" || echo "0") + if [[ "$count" -ne 1 ]]; then + log_error "Expected exactly 1 'Generated by Kapsis' occurrence, got: $count" + log_error "Commit message: $last_msg" + return 1 + fi + log_info "Claude attribution correctly deduplicated" +} + +#=============================================================================== +# TEST CASES: CLI --co-author Flag +#=============================================================================== + +test_cli_co_author_merge() { + log_test "Testing CLI co-authors merge with config co-authors (pipe-separated)" + + # Simulate the merge logic from launch-agent.sh parse_config() + local GIT_CO_AUTHORS="Config Person " + local CLI_CO_AUTHORS=("CLI One " "CLI Two ") + + for c in "${CLI_CO_AUTHORS[@]}"; do + if [[ -n "${GIT_CO_AUTHORS:-}" ]]; then + GIT_CO_AUTHORS+="|$c" + else + GIT_CO_AUTHORS="$c" + fi + done + + assert_contains "$GIT_CO_AUTHORS" "Config Person " \ + "Merged list keeps config co-author" + assert_contains "$GIT_CO_AUTHORS" "CLI One " \ + "Merged list includes first CLI co-author" + assert_contains "$GIT_CO_AUTHORS" "CLI Two " \ + "Merged list includes second CLI co-author" + + # Pipe-separator count: 2 pipes for 3 entries + local pipe_count + pipe_count=$(echo "$GIT_CO_AUTHORS" | tr -cd '|' | wc -c | tr -d ' ') + assert_equals "$pipe_count" "2" "Should have 2 pipe separators for 3 co-authors" +} + +test_cli_co_author_empty_config_merge() { + log_test "Testing CLI co-authors when config has none" + + local GIT_CO_AUTHORS="" + local CLI_CO_AUTHORS=("Solo ") + + for c in "${CLI_CO_AUTHORS[@]}"; do + if [[ -n "${GIT_CO_AUTHORS:-}" ]]; then + GIT_CO_AUTHORS+="|$c" + else + GIT_CO_AUTHORS="$c" + fi + done + + assert_equals "$GIT_CO_AUTHORS" "Solo " \ + "Single CLI co-author should not be prefixed with pipe" +} + +test_cli_co_author_format_validation() { + log_test "Testing --co-author format validation regex" + + # The same regex used in launch-agent.sh parse_args + local valid_inputs=( + "Jane Doe " + "X " + "Aviad Shiber " + ) + local invalid_inputs=( + "no-angle-brackets@test.com" + "Name without email" + "Name " + "" + ) + + for input in "${valid_inputs[@]}"; do + if [[ ! "$input" =~ \<[^\>]+@[^\>]+\> ]]; then + log_error "Valid input rejected: $input" + return 1 + fi + done + for input in "${invalid_inputs[@]}"; do + if [[ "$input" =~ \<[^\>]+@[^\>]+\> ]]; then + log_error "Invalid input accepted: $input" + return 1 + fi + done + log_info "Format validation correct for all inputs" +} + #=============================================================================== # MAIN #=============================================================================== @@ -472,6 +709,18 @@ main() { run_test test_config_co_authors_parsing run_test test_config_fork_workflow_parsing + # Attribution tests + run_test test_config_attribution_parsing + run_test test_attribution_placeholder_substitution + run_test test_attribution_commit_uses_env_var + run_test test_attribution_empty_disables + run_test test_attribution_claude_skip_duplicate + + # CLI --co-author flag tests + run_test test_cli_co_author_merge + run_test test_cli_co_author_empty_config_merge + run_test test_cli_co_author_format_validation + # Summary print_summary } From df5a9abf136dfb6010b3c3f231aad3763b8f47f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 19:20:43 +0000 Subject: [PATCH 2/2] fix(launch-agent): resolve {version} placeholder via package.json/git Previous code checked a nonexistent VERSION file at repo root, causing the attribution template to render as "vunknown". Match the logic of get_kapsis_version() in post-container-git.sh: prefer package.json, fall back to `git describe`, else "dev". https://claude.ai/code/session_018mdcbzthMYwuwE3fC66y2R --- scripts/launch-agent.sh | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/scripts/launch-agent.sh b/scripts/launch-agent.sh index ab696b33..81cb9b00 100755 --- a/scripts/launch-agent.sh +++ b/scripts/launch-agent.sh @@ -963,10 +963,16 @@ parse_config() { # Substitute placeholders: {version}, {agent_id}, {branch}, {worktree} # {worktree} is resolved later once WORKTREE_PATH is known. - local kapsis_version_str="${KAPSIS_VERSION:-unknown}" - if [[ -z "${KAPSIS_VERSION:-}" ]] && [[ -f "$KAPSIS_ROOT/VERSION" ]]; then - kapsis_version_str=$(tr -d '[:space:]' < "$KAPSIS_ROOT/VERSION" 2>/dev/null || echo "unknown") + # 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:-}}"