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
2 changes: 2 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,8 @@ The user account that runs thirdshift needs:

Sessions run headless with the full permissions of that user account, including `sudo` if the user has it, and nobody is there to approve anything. On Claude Code they run in auto mode (`claude -p --permission-mode auto`): its classifier checks each action and may block ones it judges risky, such as destructive commands or actions outside the task. A blocked action the agent can't work around can end the Run as a Failed run. The other Harnesses run with approvals and their sandbox turned off ([ADR-0013](adr/0013-every-harness-runs-unattended-and-fully-trusted.md)), so no approval step or sandbox stands between an action and the account.

Every factory session on Claude Code, Repairs, reviews and Resumes included, also passes `--settings '{"attribution":{"commit":"","pr":""}}'`. These per-session settings override the user's and repository's attribution settings, hiding Claude Code's commit trailer and pull request attribution. The pull request keeps thirdshift's `Built with` line as its only attribution; nothing replaces the commit trailer. Claude Code run by hand keeps its own settings. The release-summary invocation, which runs with tools disabled, keeps its existing arguments.

`claude -p` exits as soon as the agent ends its turn, killing any background task it started. So every prompt tells the agent to run long commands, such as tests, in the foreground, and to stop any background task it no longer needs, with the `TaskStop` tool, before it ends its turn. If a session still ends with background work running, thirdshift takes it as work the session was waiting on and gives it a **Resume**: it continues that same session once (`claude -p --resume`), asking the agent to re-run the work in the foreground and finish. If the Resume ends the same way, the work may have been abandoned, such as a hung test run the agent could not stop: thirdshift says which task was killed and carries on, so the pull request check, CI and the rest decide how the Run ends. If the Run then fails, its cause names the killed task ahead of what failed.

### What sessions leave behind
Expand Down
4 changes: 2 additions & 2 deletions prompts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ Every Session prompt thirdshift can start or resume an agent session with, one f
Every session runs `claude` headless in the Run's worktree, in auto permission mode. The Factory skills are linked into the worktree's `.claude/skills/`, where Claude Code finds them, and kept out of git:

```sh
claude -p --permission-mode auto --output-format stream-json --verbose <prompt>
claude -p --permission-mode auto --settings '{"attribution":{"commit":"","pr":""}}' --output-format stream-json --verbose <prompt>
```

A [Resume](resume.md) continues the session that ended:

```sh
claude -p --permission-mode auto --output-format stream-json --verbose --resume <session id> <prompt>
claude -p --permission-mode auto --settings '{"attribution":{"commit":"","pr":""}}' --output-format stream-json --verbose --resume <session id> <prompt>
```

With `harness codex`, each session runs `codex exec --json --dangerously-bypass-approvals-and-sandbox -m <model> -c model_reasoning_effort="<effort>" -c project_doc_fallback_filenames=["CLAUDE.md"] <prompt>` instead, without `-m <model>` or the Effort's `-c` where they are not set, and with `resume <session id>` before the prompt for a Resume. The Factory skills are linked into `.agents/skills/`, and a prompt's first line loads its skill as `$thirdshift-<skill>`.
Expand Down
1 change: 1 addition & 0 deletions site/docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@
<details class="fold"><summary><h3>Unattended sessions</h3></summary>
<div class="manual-content" id="unattended-sessions">
<p>Sessions run headless with the full permissions of that user account, including <code>sudo</code> if the user has it, and nobody is there to approve anything. On Claude Code they run in auto mode (<code>claude -p --permission-mode auto</code>): its classifier checks each action and may block ones it judges risky, such as destructive commands or actions outside the task. A blocked action the agent can&#39;t work around can end the Run as a Failed run. The other Harnesses run with approvals and their sandbox turned off (<a href="https://github.com/JacobStephens2/thirdshift/blob/main/docs/adr/0013-every-harness-runs-unattended-and-fully-trusted.md">ADR-0013</a>), so no approval step or sandbox stands between an action and the account.</p>
<p>Every factory session on Claude Code, Repairs, reviews and Resumes included, also passes <code>--settings &#39;{&quot;attribution&quot;:{&quot;commit&quot;:&quot;&quot;,&quot;pr&quot;:&quot;&quot;}}&#39;</code>. These per-session settings override the user&#39;s and repository&#39;s attribution settings, hiding Claude Code&#39;s commit trailer and pull request attribution. The pull request keeps thirdshift&#39;s <code>Built with</code> line as its only attribution; nothing replaces the commit trailer. Claude Code run by hand keeps its own settings. The release-summary invocation, which runs with tools disabled, keeps its existing arguments.</p>
<p><code>claude -p</code> exits as soon as the agent ends its turn, killing any background task it started. So every prompt tells the agent to run long commands, such as tests, in the foreground, and to stop any background task it no longer needs, with the <code>TaskStop</code> tool, before it ends its turn. If a session still ends with background work running, thirdshift takes it as work the session was waiting on and gives it a <strong>Resume</strong>: it continues that same session once (<code>claude -p --resume</code>), asking the agent to re-run the work in the foreground and finish. If the Resume ends the same way, the work may have been abandoned, such as a hung test run the agent could not stop: thirdshift says which task was killed and carries on, so the pull request check, CI and the rest decide how the Run ends. If the Run then fails, its cause names the killed task ahead of what failed.</p>
</div></details>
<details class="fold"><summary><h3>What sessions leave behind</h3></summary>
Expand Down
4 changes: 2 additions & 2 deletions site/prompts/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ <h1 class="misreg">Prompts and skills</h1>
<details class="fold">
<summary class="sec-head"><span class="eyebrow">The command line</span><h2 id="command-title">How each session starts</h2></summary>
<p class="lede">Every session runs <code>claude</code> headless in the Run's worktree, in auto permission mode. The Factory skills are linked into the worktree's <code>.claude/skills/</code>, where Claude Code finds them, and kept out of git. The prompt is one of the templates below.</p>
<pre class="job-text" aria-label="A new session"><code>claude -p --permission-mode auto --output-format stream-json --verbose <mark class="ph">&lt;prompt&gt;</mark></code></pre>
<pre class="job-text" aria-label="A new session"><code>claude -p --permission-mode auto --settings '{"attribution":{"commit":"","pr":""}}' --output-format stream-json --verbose <mark class="ph">&lt;prompt&gt;</mark></code></pre>
<p class="lede">A <a href="#prompt-resume">Resume</a> continues the session that ended:</p>
<pre class="job-text" aria-label="A Resume"><code>claude -p --permission-mode auto --output-format stream-json --verbose --resume <mark class="ph">&lt;session id&gt;</mark> <mark class="ph">&lt;prompt&gt;</mark></code></pre>
<pre class="job-text" aria-label="A Resume"><code>claude -p --permission-mode auto --settings '{"attribution":{"commit":"","pr":""}}' --output-format stream-json --verbose --resume <mark class="ph">&lt;session id&gt;</mark> <mark class="ph">&lt;prompt&gt;</mark></code></pre>
<p class="lede">With <code>harness codex</code>, each session runs <code>codex exec --json --dangerously-bypass-approvals-and-sandbox -m <mark class="ph">&lt;model&gt;</mark> -c model_reasoning_effort="<mark class="ph">&lt;effort&gt;</mark>" -c project_doc_fallback_filenames=["CLAUDE.md"] <mark class="ph">&lt;prompt&gt;</mark></code> instead, without <code>-m <mark class="ph">&lt;model&gt;</mark></code> or the Effort's <code>-c</code> where they are not set, and with <code>resume <mark class="ph">&lt;session id&gt;</mark></code> before the prompt for a Resume. The Factory skills are linked into <code>.agents/skills/</code>, and a prompt's first line loads its skill as <code>$thirdshift-&lt;skill&gt;</code>.</p>
</details>
</div>
Expand Down
66 changes: 62 additions & 4 deletions src/harness/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::process::Command;

use anyhow::{Context, Result, bail};

use super::adapter::{Adapter, Invocation, SkillLoading};
use super::adapter::{Adapter, Invocation, SkillLoading, without};
use super::interpretation::{Interpretation, Stream};
use super::settings::{Terminal, ask_setting};
use super::{Choice, Harness, ModelAndEffort, Settings, said};
Expand All @@ -14,6 +14,8 @@ use crate::progress;
pub struct Claude;
pub const REGISTRATION: (Harness, &dyn Adapter) = (Harness::Claude, &Claude);

const ATTRIBUTION_SETTINGS: &str = r#"{"attribution":{"commit":"","pr":""}}"#;

impl Adapter for Claude {
fn name(&self) -> &'static str {
"claude"
Expand Down Expand Up @@ -51,6 +53,7 @@ impl Adapter for Claude {
}
fn summary(&self, choice: &Choice, prompt: &str, _prompt_file: &Path) -> Invocation {
let mut invocation = self.session(choice, None, prompt);
invocation.args = without(invocation.args, &["--settings", ATTRIBUTION_SETTINGS]);
invocation.args.pop();
invocation.stdin = Some(prompt.to_string());
invocation.args.splice(
Expand Down Expand Up @@ -85,14 +88,21 @@ pub fn model_args(choice: &Choice) -> Vec<&str> {
}

/// The arguments every session runs `claude` with: headless in auto mode,
/// on the Model and Effort `harness` sets, if any, streaming JSON. With
/// `resume`, the session with that id continues. The prompt comes last.
/// on the Model and Effort `harness` sets, if any, streaming JSON, with
/// Claude Code's commit and PR attribution hidden. With `resume`, the session
/// with that id continues. The prompt comes last.
pub fn claude_args<'a>(
harness: &'a Choice,
resume: Option<&'a str>,
prompt: &'a str,
) -> Vec<&'a str> {
let mut args = vec!["-p", "--permission-mode", "auto"];
let mut args = vec![
"-p",
"--permission-mode",
"auto",
"--settings",
ATTRIBUTION_SETTINGS,
];
args.extend(model_args(harness));
args.extend(["--output-format", "stream-json", "--verbose"]);
if let Some(session_id) = resume {
Expand Down Expand Up @@ -165,3 +175,51 @@ fn ask_claude(outside: &mut dyn Terminal, current: &ModelAndEffort) -> Result<Mo
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn release_summaries_keep_their_existing_arguments() {
let invocation = Claude.summary(&Choice::default(), "summarize", Path::new("unused"));
assert_eq!(
invocation.args,
[
"-p",
"--tools",
"",
"--strict-mcp-config",
"--permission-mode",
"auto",
"--output-format",
"stream-json",
"--verbose",
]
);
assert_eq!(invocation.stdin.as_deref(), Some("summarize"));
}

#[test]
fn factory_sessions_and_resumes_hide_claudes_commit_and_pr_attribution() {
let choice = Choice::default();
for resume in [None, Some("session-id")] {
for invocation in [
Claude.session(&choice, resume, "implement or repair"),
Claude.code_review_session(&choice, resume, "review"),
Claude.security_session(&choice, resume, "security review", false),
] {
let settings = invocation
.args
.windows(2)
.find(|pair| pair[0] == "--settings")
.expect("every factory session must pass attribution settings");
let settings: serde_json::Value = serde_json::from_str(&settings[1]).unwrap();
assert_eq!(
settings["attribution"],
serde_json::json!({"commit": "", "pr": ""})
);
}
}
}
}
41 changes: 40 additions & 1 deletion src/prompts_page.rs
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,18 @@ fn render() -> String {
/// resumes with, with the page's placeholders.
fn command(resume: Option<&str>) -> String {
let harness = Choice::default();
format!("claude {}", claude_args(&harness, resume, PROMPT).join(" "))
let args: Vec<_> = claude_args(&harness, resume, PROMPT)
.into_iter()
.map(|arg| {
// Keep inline settings JSON intact when copied into a shell.
if arg.starts_with('{') {
format!("'{arg}'")
} else {
arg.to_string()
}
})
.collect();
format!("claude {}", args.join(" "))
}

/// The `codex` command line a session on a Model and Effort starts with,
Expand Down Expand Up @@ -640,6 +651,34 @@ mod tests {
std::env::var_os("UPDATE_PROMPTS").is_some()
}

#[test]
fn prompts_page_claude_commands_preserve_attribution_settings_as_one_shell_argument() {
for resume in [None, Some(SESSION_ID)] {
let command = command(resume)
.replace(PROMPT, "prompt")
.replace(SESSION_ID, "session-id");
let output = std::process::Command::new("bash")
.args([
"-c",
&format!("claude() {{ printf '%s\\n' \"$@\"; }}; {command}"),
])
.output()
.unwrap();
assert!(output.status.success(), "{output:?}");
let stdout = String::from_utf8(output.stdout).unwrap();
let args: Vec<_> = stdout.lines().collect();
let settings = args
.windows(2)
.find(|pair| pair[0] == "--settings")
.expect("the documented command must pass attribution settings");
let settings: serde_json::Value = serde_json::from_str(settings[1]).unwrap();
assert_eq!(
settings,
serde_json::json!({"attribution": {"commit": "", "pr": ""}})
);
}
}

/// The golden-file test: the page carries the fragment as rendered now.
/// The crates.io package leaves `site/` out, so it skips there.
#[test]
Expand Down
Loading