Skip to content
Merged
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
12 changes: 8 additions & 4 deletions cmd/codeaf/do.go
Original file line number Diff line number Diff line change
Expand Up @@ -3425,7 +3425,8 @@ func parseSlots(raw string) (*int, error) {
// dispatches is the belt's.
//
// IT KEEPS THE OLDER ROAD'S CONTRACT WITH THE DIRECTORY: the run edits it in
// place and commits nothing. A landing here once staged the directory's whole
// place and makes no commit of its own, but signs worker commits at the end.
// A landing here once staged the directory's whole
// `git status` and committed it on the checked-out branch — the person's own
// uncommitted edits and untracked files with it — which no `--dir` help line
// ever promised. The files the envelope names are the ones this run changed.
Expand Down Expand Up @@ -3488,7 +3489,7 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome,
} else {
return headlessOutcome{}, fmt.Errorf("inspect directory %s: %w", workspace, statErr)
}
// THE RUN WORKS IN PLACE AND COMMITS NOTHING, which is what `--dir` has
// THE RUN WORKS IN PLACE AND MAKES NO COMMIT OF ITS OWN, which is what `--dir` has
// always promised: "the directory to work in, edited in place". The copy is
// read before the run starts so that, afterwards, the files this run names
// are the ones IT changed — the person's own uncommitted edits and untracked
Expand Down Expand Up @@ -3599,9 +3600,12 @@ func runErrand(request doRequest, seats config.Seats) (outcome headlessOutcome,
errand.BlockedOn, errand.machineHeld = held+" · nothing started before --timeout", true
}
}
if _, err := runengine.SignWork(before); err != nil {
fmt.Fprintf(request.stderr, "codeaf: could not sign the run's commits: %v\n", err)
}
// WHAT THE RUN CHANGED IS WHERE IT STANDS: in the directory it was handed,
// uncommitted, on whatever branch was checked out there. The envelope's
// files are those paths and no others, on every ending — a run stopped short
// committed by its workers or still uncommitted, on the checked-out branch.
// The envelope's files are those paths and no others, on every ending — a run stopped short
// still left its edits on disk, and a caller has to be able to find them.
errand.Artifacts = landedPaths(workspace, before.Changed())
return errand, nil
Expand Down
111 changes: 110 additions & 1 deletion cmd/codeaf/do_engine_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ package main
// so the promises the door's help makes are this road's to keep:
//
// - `--dir` is "the directory to work in, edited in place". The run edits it
// and commits nothing, and the files it names are the ones it changed —
// and makes no commit of its own; the files it names are the ones it changed —
// never the person's own uncommitted edits or untracked files.
// - `--yes-spend` is "spend past today's limit and past the plan-price
// question, without stopping to ask". Without it an unattended run is
Expand Down Expand Up @@ -140,6 +140,115 @@ func TestDoOnTheRunEngineNeverCommitsThePersonsOwnWork(t *testing.T) {
}
}

// Contract 6: the real do door signs a worker's in-place git commit, names its
// file in the envelope, and obeys a repository ban without changing exit 0.
func TestDoOnTheRunEngineSignsWorkerCommitsUnlessContributingForbids(t *testing.T) {
for _, banned := range []bool{false, true} {
name := "signed"
if banned {
name = "CONTRIBUTING ban"
}
t.Run(name, func(t *testing.T) {
beltRunEnv(t)
t.Setenv("CODEAF_PLANDB_BIN", beltPlandbDoor(t))
workspace := beltRepoWorkspace(t)
if banned {
if err := os.WriteFile(filepath.Join(workspace, "CONTRIBUTING.md"), []byte("Do not add AI co-author trailers to commits.\n"), 0o644); err != nil {
t.Fatal(err)
}
beltGit(t, workspace, "add", "CONTRIBUTING.md")
beltGit(t, workspace, "-c", "user.name=Person", "-c", "user.email=person@example.test", "commit", "-m", "set policy")
}
base := doGitIn(t, workspace, "rev-parse", "HEAD")
seat := &beltSeat{
script: []func(context.Context, []ai.Message) (*ai.Response, error){
func(context.Context, []ai.Message) (*ai.Response, error) {
return beltToolReply("printf 'hello from worker\\n' > hello.txt && git add hello.txt && git -c user.name=Worker -c user.email=worker@example.test commit -m 'add hello'"), nil
},
func(context.Context, []ai.Message) (*ai.Response, error) {
return beltToolReply(beltFinish("added hello.txt")), nil
},
},
ever: func(_ context.Context, msgs []ai.Message) (*ai.Response, error) {
if doc := beltDocument(msgs); strings.Contains(doc, "## Who checks this work") {
id := briefTaskID(doc)
return beltToolReply("plandb done " + id + " --agent " + id + " --result 'holds: the acceptance is met'"), nil
}
return beltTextReply("added hello.txt"), nil
},
}
var stdout, stderr strings.Builder
err := doErrand(doRequest{
task: "add hello.txt", workspace: workspace, asJSON: true,
timeout: 60 * time.Second, slots: bound(1), stdout: &stdout, stderr: &stderr,
newBeltCompleter: func(string) session.Completer { return seat },
})
if err != nil {
t.Fatalf("do returned %v, want exit 0\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
}
if got := doGitIn(t, workspace, "rev-parse", "HEAD~1"); got != base {
t.Fatalf("person's earlier commit moved: %s -> %s", base, got)
}
message := doGitIn(t, workspace, "log", "-1", "--format=%B")
if banned {
if message != "add hello" {
t.Fatalf("CONTRIBUTING did not preserve worker message: %q", message)
}
} else if strings.Count(message, "Assisted-by: CodeAF") != 1 || strings.Count(message, "Co-Authored-By: CodeAF") != 1 || strings.Contains(message, "CodeAF (") {
t.Fatalf("worker message was not signed once with bare lines: %q", message)
}
outcome := decodeErrand(t, stdout.String())
if len(outcome.Artifacts) != 1 || outcome.Artifacts[0] != filepath.Join(workspace, "hello.txt") {
t.Fatalf("envelope files = %v", outcome.Artifacts)
}
})
}
}

// Contract 6: an in-place run begun on an unborn branch signs its worker's
// first commit and still reports the file through the do envelope.
func TestDoOnTheRunEngineSignsFirstCommitOnUnbornBranch(t *testing.T) {
beltRunEnv(t)
t.Setenv("CODEAF_PLANDB_BIN", beltPlandbDoor(t))
workspace := t.TempDir()
beltGit(t, workspace, "init")
beltGit(t, workspace, "checkout", "-b", "work")
seat := &beltSeat{
script: []func(context.Context, []ai.Message) (*ai.Response, error){
func(context.Context, []ai.Message) (*ai.Response, error) {
return beltToolReply("printf 'hello from worker\\n' > hello.txt && git add hello.txt && git -c user.name=Worker -c user.email=worker@example.test commit -m 'add hello'"), nil
},
func(context.Context, []ai.Message) (*ai.Response, error) {
return beltToolReply(beltFinish("added hello.txt")), nil
},
},
ever: func(_ context.Context, msgs []ai.Message) (*ai.Response, error) {
if doc := beltDocument(msgs); strings.Contains(doc, "## Who checks this work") {
id := briefTaskID(doc)
return beltToolReply("plandb done " + id + " --agent " + id + " --result 'holds: the acceptance is met'"), nil
}
return beltTextReply("added hello.txt"), nil
},
}
var stdout, stderr strings.Builder
err := doErrand(doRequest{
task: "add hello.txt", workspace: workspace, asJSON: true,
timeout: 60 * time.Second, slots: bound(1), stdout: &stdout, stderr: &stderr,
newBeltCompleter: func(string) session.Completer { return seat },
})
if err != nil {
t.Fatalf("do returned %v; stdout: %s; stderr: %s", err, stdout.String(), stderr.String())
}
message := doGitIn(t, workspace, "log", "-1", "--format=%B")
if strings.Count(message, "Assisted-by: CodeAF") != 1 || strings.Count(message, "Co-Authored-By: CodeAF") != 1 {
t.Fatalf("first commit not signed once: %q", message)
}
outcome := decodeErrand(t, stdout.String())
if len(outcome.Artifacts) != 1 || outcome.Artifacts[0] != filepath.Join(workspace, "hello.txt") {
t.Fatalf("envelope files = %v", outcome.Artifacts)
}
}

// WITHOUT --yes-spend, AN UNATTENDED RUN STOPS AT THE PLAN PRICE.
//
// The worker never finishes and every call costs a dollar; the plan-price
Expand Down
12 changes: 12 additions & 0 deletions docs/changes/unreleased/1495-worker-commits-signed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
kind: fixed
title: worker commits carry attribution when a run ends
pr: 1495
surface: [chat, engine]
invalidates:
- "On the worker harness, a commit a worker made itself carried no attribution unless the worker added it. A run now signs its private worker commits at landing with the bare Assisted-by line, and codeaf do signs its workers' commits in place when the run ends."
- "The harness signed its own landing commit even when CONTRIBUTING forbade AI trailers. It now reads regular CONTRIBUTING files in the repository root, .github and docs, and adds no lines to its landing or worker commits when one forbids them."
- "A run whose worker committed everything answered nothing to land while its work merged home. It now names the branch and files the worker committed; a read-only run still answers nothing to land."
---

Worker commits keep their original author and committer. Commits signed with Git's own signature, already pointed at by another branch, tag or remote-tracking ref, or fetched from elsewhere and merged are left as the worker wrote them. In a repository with no prior commits, `codeaf do` signs the worker's first commits too. A run that commits and then reverts its work still reports the touched files.
44 changes: 44 additions & 0 deletions internal/exec/attribution_contributing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package exec

import (
"regexp"
"strings"
)

var contributingSentence = regexp.MustCompile(`[.!?;]\s+|\n+`)
var contributingDottedAI = regexp.MustCompile(`\ba\.i\.`)
var contributingAttribution = regexp.MustCompile(`\b(?:trailer\w*|attribution\w*|co-authored-by|co-authored|coauthored|co-author\w*|assisted-by|generated-by|generated with|generated by|footer\w*)\b`)
var contributingAI = regexp.MustCompile(`(?:\ba\.i\.|\b(?:ai|ai-generated|ai-assisted|llm\w*|assistant\w*|agent\w*|bots?|copilot|claude|chatgpt|gpt\w*|codeaf|machine-generated|assisted-by|generated-by)\b)`)
var contributingProhibition = regexp.MustCompile(`\b(?:do not|don't|dont|never|must not|mustn't|should not|shouldn't|may not|cannot|can't|not allowed|not permitted|not accepted|forbid\w*|prohibit\w*|bans?|banned|refrain|avoid|remove|strip|omit|delete|drop|edit out|take out|reject\w*)\b`)
var contributingRequirement = regexp.MustCompile(`\b(?:without|with no|missing|lack\w*|must include|must add|must contain|must have|must carry|required|require|requires|mandatory|please add|please include|should include|should add|always add|always include|keep|preserve|retain|leave\b[^.!?;\n]*\bin place)\b`)
var contributingNegatedRemoval = regexp.MustCompile(`\b(?:do not|don't|dont|never|must not|mustn't|should not|shouldn't|may not|cannot|can't)(?:\s+[\w-]+){0,2}\s+(?:remove|strip|omit|delete|drop|edit out|take out)\b`)
var contributingNegatedKeeping = regexp.MustCompile(`\b(?:do not|don't|dont|never|must not|mustn't|should not|shouldn't|may not|cannot|can't)(?:\s+[\w-]+){0,2}\s+(?:keep|preserve|retain|leave\b[^.!?;\n]*\bin place)\b`)
var contributingNo = regexp.MustCompile(`\bno(?:\s+[\w-]+){0,2}\s+(?:trailers?|attributions?|co-authored-by|co-authored|coauthored|co-authors?|assisted-by|generated-by|generated|footers?|ai|a\.i\.|llms?|assistants?|agents?|bots?|copilot|claude|chatgpt|gpt[\w-]*|codeaf|machine-generated)\b`)

// ContributingRefusesTrailers conservatively reads a repository's own ban on
// AI attribution. A requirement to add or keep a trailer wins over negative
// wording such as "do not submit code without one" or "do not remove it" in
// the same sentence: neither asks the harness to omit attribution.
func ContributingRefusesTrailers(text string) bool {
// Keep A.I. together before periods followed by spaces separate sentences.
text = contributingDottedAI.ReplaceAllString(strings.ToLower(text), "ai")
for _, sentence := range contributingSentence.Split(text, -1) {
sentence = strings.Map(func(r rune) rune {
if strings.ContainsRune("*_`~", r) {
return -1
}
return r
}, sentence)
// A negated keeping verb is itself a ban, while a negated removal
// verb asks that attribution stay on the work.
if contributingNegatedRemoval.MatchString(sentence) ||
(contributingRequirement.MatchString(sentence) && !contributingNegatedKeeping.MatchString(sentence)) {
continue
}
if contributingAttribution.MatchString(sentence) && contributingAI.MatchString(sentence) &&
(contributingProhibition.MatchString(sentence) || contributingNo.MatchString(sentence)) {
return true
}
}
return false
}
40 changes: 40 additions & 0 deletions internal/exec/attribution_contributing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package exec

import "testing"

// Contract 4: a CONTRIBUTING requirement cannot be mistaken for a ban.
func TestContributingRefusesTrailersOnlyForAIBans(t *testing.T) {
for _, row := range []struct {
text string
ban bool
}{
{"Do not add AI co-author trailers to commits.", true},
{"Please don't include \"Generated with Claude Code\" or Co-authored-by lines from AI tools.", true},
{"No AI trailers.", true},
{"No A.I. trailers.", true},
{"Commits with AI attribution trailers will be rejected.", true},
{"Never add Assisted-by lines.", true},
{"Avoid **AI attribution** footers.", true},
{"Commits that do not include a Signed-off-by trailer will not be merged.", false},
{"AI-assisted contributions must include an Assisted-by: trailer.", false},
{"Commits missing an Assisted-by trailer will be rejected.", false},
{"Do not submit AI-generated code without an Assisted-by trailer.", false},
{"Do not remove the Signed-off-by trailer added by the tooling.", false},
{"Please run the tests before opening a pull request.", false},
{"Avoid large files. AI attribution trailers are required.", false},
{"Do not remove the AI co-author trailer.", false},
{"Never strip Assisted-by lines from commits.", false},
{"Please keep the Co-authored-by line your AI tool adds.", false},
{"Preserve AI attribution trailers when squashing.", false},
{"Remove any AI co-author trailers before submitting.", true},
{"Strip \"Generated with Claude Code\" footers from pull request descriptions.", true},
{"Avoid large commits. Use Co-authored-by for pair programming.", false},
{"Our bot adds a co-author line; do not edit it.", false},
{"Do not keep AI trailers in commits.", true},
{"Never preserve Assisted-by lines.", true},
} {
if got := ContributingRefusesTrailers(row.text); got != row.ban {
t.Errorf("ContributingRefusesTrailers(%q) = %v, want %v", row.text, got, row.ban)
}
}
}
22 changes: 22 additions & 0 deletions internal/exec/attribution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,28 @@ func TestTheTrailerBlockIsTwoExactLinesWithOrWithoutTheModel(t *testing.T) {
}
}

// Contract 2: a worker's complete message stays byte-exact, and a partial
// message gains just the missing line instead of another trailer block.
func TestSignCommitMessageOncePreservesAndCompletesWorkerTrailers(t *testing.T) {
full := "worker\n\n" + AttributionTrailers("") + "\n"
if got := SignCommitMessageOnce(full, ""); got != full {
t.Fatalf("complete message moved: %q", got)
}
workerFull := "worker\n\n assisted-by: codeaf (worker-model) \n co-authored-by: codeaf <267109073+agentfield-bot@users.noreply.github.com> \n"
if got := SignCommitMessageOnce(workerFull, "different-model"); got != workerFull {
t.Fatalf("case-folded worker lines moved: %q", got)
}
for _, row := range []struct{ message, want string }{
{"worker\n", SignCommitMessage("worker\n", "")},
{"worker\n\n" + AttributionAssistedBy + "\n", "worker\n\n" + AttributionTrailers("")},
{"worker\n\n" + AttributionTrailer + "\n", "worker\n\n" + AttributionTrailer + "\n" + AttributionAssistedBy},
} {
if got := SignCommitMessageOnce(row.message, ""); got != row.want {
t.Errorf("SignCommitMessageOnce(%q) = %q, want %q", row.message, got, row.want)
}
}
}

// THE SETTINGS ROW'S HINT IS THE TWO LINES THIS PACKAGE WRITES. internal/config
// cannot import this package, so it spells them; this holds its spelling to the
// one that reaches a commit.
Expand Down
23 changes: 23 additions & 0 deletions internal/exec/linear.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,29 @@ func SignCommitMessage(message, model string) string {
return strings.TrimRight(message, "\n") + "\n\n" + AttributionTrailers(model)
}

// SignCommitMessageOnce keeps a worker's own attribution when it already has
// both lines. A partly signed message gains just the missing line, without
// moving or duplicating the line the worker wrote.
func SignCommitMessageOnce(message, model string) string {
assisted, coauthor := false, false
for _, line := range strings.Split(message, "\n") {
line = strings.TrimSpace(line)
assisted = assisted || strings.HasPrefix(strings.ToLower(line), strings.ToLower(AttributionAssistedBy))
coauthor = coauthor || strings.EqualFold(line, AttributionTrailer)
}
if assisted && coauthor {
return message
}
if !assisted && !coauthor {
return SignCommitMessage(message, model)
}
missing := AttributionTrailer
if !assisted {
missing = AssistedBy(model)
}
return strings.TrimRight(message, "\n") + "\n" + missing
}

// BareModelName is a model id as the `Assisted-by` line names it: the model and
// nothing about who served it or how.
//
Expand Down
Loading
Loading