@@ -34,6 +34,7 @@ use std::path::{Path, PathBuf};
3434
3535/// Major contract version this build implements (contract §6).
3636pub const CONTRACT_VERSION : & str = "3" ;
37+ pub const COMPATIBLE_CONTRACT_VERSIONS : & [ & str ] = & [ "2" , "3" ] ;
3738
3839/// Environment variable carrying the GitHub App **installation access token**
3940/// used to authenticate `git push`. This is the ONLY git credential channel; it
@@ -150,11 +151,11 @@ impl SessionBrief {
150151 /// "Consumers MUST reject a payload whose major version they do not
151152 /// implement, rather than silently mis-parsing it."
152153 pub fn ensure_supported_version ( & self ) -> anyhow:: Result < ( ) > {
153- if self . contract_version != CONTRACT_VERSION {
154+ if ! COMPATIBLE_CONTRACT_VERSIONS . contains ( & self . contract_version . as_str ( ) ) {
154155 bail ! (
155156 "unsupported headless contract version {:?}; this build implements {:?}" ,
156157 self . contract_version,
157- CONTRACT_VERSION
158+ COMPATIBLE_CONTRACT_VERSIONS
158159 ) ;
159160 }
160161 Ok ( ( ) )
@@ -195,6 +196,17 @@ impl SessionBrief {
195196
196197 /// Build the first-turn user prompt injected into the headless session.
197198 pub fn to_prompt ( & self ) -> String {
199+ self . to_prompt_for_mode ( false )
200+ }
201+
202+ /// Build the first-turn prompt for a trusted hosted repair. The runtime may
203+ /// edit repository files, while the worker remains responsible for trusted
204+ /// validation, committing, and pushing.
205+ pub fn to_hosted_repair_prompt ( & self ) -> String {
206+ self . to_prompt_for_mode ( true )
207+ }
208+
209+ fn to_prompt_for_mode ( & self , hosted_repair : bool ) -> String {
198210 let mut lines = vec ! [
199211 format!(
200212 "You are {}, the Coven coding familiar assigned to {}/{} through the coven-github App." ,
@@ -268,6 +280,23 @@ impl SessionBrief {
268280 lines. push ( instruction. trim ( ) . to_string ( ) ) ;
269281 }
270282
283+ if hosted_repair {
284+ lines. push ( String :: new ( ) ) ;
285+ lines. push (
286+ "Hosted repair mode: inspect the cited findings and make only the repository file edits needed to fix them. Use the available file read and edit tools directly in the current workspace. Do not merely describe a patch."
287+ . to_string ( ) ,
288+ ) ;
289+ lines. push (
290+ "Command execution, network access, git operations, and test execution are intentionally unavailable. Do not create a branch, commit, or push. The trusted worker will validate, commit, and push the edited workspace after this runtime exits."
291+ . to_string ( ) ,
292+ ) ;
293+ lines. push (
294+ "If no safe source-backed edit can be made, explain the blocker. Otherwise, finish with a concise summary of the files you actually changed."
295+ . to_string ( ) ,
296+ ) ;
297+ return lines. join ( "\n " ) ;
298+ }
299+
271300 if self . review_mode ( ) != ReviewMode :: None {
272301 lines. push ( String :: new ( ) ) ;
273302 lines. push (
@@ -331,6 +360,7 @@ pub fn apply_to_config(config: &mut claurst_core::config::Config, brief: &Sessio
331360 config. hosted_review . enabled = true ;
332361 config. hosted_review . allow_user_memory = false ;
333362 config. hosted_review . allow_write_tools = false ;
363+ config. hosted_review . allow_file_write_tools = false ;
334364 config. hosted_review . allow_mcp_servers = false ;
335365 config. hosted_review . allow_plugins = false ;
336366 config. hosted_review . allow_auto_memory_persistence = false ;
@@ -418,6 +448,7 @@ pub struct GitSummary {
418448 pub branch : Option < String > ,
419449 pub commits : Vec < CommitSummary > ,
420450 pub files_changed : Vec < String > ,
451+ pub workspace_dirty : bool ,
421452}
422453
423454/// Inspect the workspace and summarize the branch, the commits ahead of the base
@@ -448,6 +479,8 @@ pub fn collect_git_summary(workspace: &Path, default_branch: &str) -> GitSummary
448479 } )
449480 . unwrap_or_default ( ) ;
450481
482+ let workspace_dirty = git_stdout ( workspace, & [ "diff" , "--name-only" , "HEAD" ] ) . is_some ( )
483+ || git_stdout ( workspace, & [ "diff" , "--name-only" , "--cached" ] ) . is_some ( ) ;
451484 let files_changed = git_stdout ( workspace, & [ "diff" , "--name-only" , "HEAD" ] )
452485 . into_iter ( )
453486 . chain ( git_stdout ( workspace, & [ "diff" , "--name-only" , "--cached" ] ) )
@@ -470,6 +503,7 @@ pub fn collect_git_summary(workspace: &Path, default_branch: &str) -> GitSummary
470503 branch,
471504 commits,
472505 files_changed,
506+ workspace_dirty,
473507 }
474508}
475509
@@ -1467,6 +1501,7 @@ fn build_result(
14671501 final_text,
14681502 review_trace,
14691503 ReviewMemoryUse :: default ( ) ,
1504+ false ,
14701505 )
14711506}
14721507
@@ -1479,10 +1514,11 @@ pub fn build_result_with_memory(
14791514 final_text : & str ,
14801515 review_trace : Option < & ReviewTrace > ,
14811516 memory : ReviewMemoryUse ,
1517+ hosted_repair : bool ,
14821518) -> ( ResultEnvelope , i32 ) {
1483- let comment_only = brief. map ( SessionBrief :: is_comment_only) . unwrap_or ( false ) ;
1484- let ( mut status , mut exit_reason , code ) =
1485- classify ( outcome, !git . commits . is_empty ( ) , comment_only) ;
1519+ let comment_only = !hosted_repair && brief. map ( SessionBrief :: is_comment_only) . unwrap_or ( false ) ;
1520+ let has_progress = !git . commits . is_empty ( ) || ( hosted_repair && git . workspace_dirty ) ;
1521+ let ( mut status , mut exit_reason , code ) = classify ( outcome, has_progress , comment_only) ;
14861522
14871523 let review = ReviewResult :: from_brief_with_memory ( brief, review_trace, final_text, memory) ;
14881524 if review. mode != ReviewMode :: None
@@ -1496,7 +1532,9 @@ pub fn build_result_with_memory(
14961532 let pr_body = compose_pr_body ( brief, final_text, git, status) ;
14971533
14981534 let envelope = ResultEnvelope {
1499- contract_version : CONTRACT_VERSION . to_string ( ) ,
1535+ contract_version : brief
1536+ . map ( |value| value. contract_version . clone ( ) )
1537+ . unwrap_or_else ( || CONTRACT_VERSION . to_string ( ) ) ,
15001538 status,
15011539 branch : git. branch . clone ( ) ,
15021540 commits : git. commits . clone ( ) ,
@@ -1518,7 +1556,9 @@ pub fn infra_error_result(
15181556) -> ( ResultEnvelope , i32 ) {
15191557 let name = familiar_name ( brief) ;
15201558 let envelope = ResultEnvelope {
1521- contract_version : CONTRACT_VERSION . to_string ( ) ,
1559+ contract_version : brief
1560+ . map ( |value| value. contract_version . clone ( ) )
1561+ . unwrap_or_else ( || CONTRACT_VERSION . to_string ( ) ) ,
15221562 status : Status :: Failure ,
15231563 branch : git. branch . clone ( ) ,
15241564 commits : git. commits . clone ( ) ,
@@ -1678,6 +1718,19 @@ mod tests {
16781718 serde_json:: from_str ( raw) . expect ( "review brief parses" )
16791719 }
16801720
1721+ fn sample_repair_brief ( ) -> SessionBrief {
1722+ let raw = r#"{
1723+ "contract_version": "2",
1724+ "trigger": "pull_request_autoreview",
1725+ "repo": { "owner": "o", "name": "r", "clone_url": "https://github.com/o/r.git", "default_branch": "main" },
1726+ "task": { "kind": "respond_to_mention", "issue_number": 7, "comment_body": "Fix the source-backed finding." },
1727+ "familiar": { "id": "cody", "display_name": "Cody", "skills": [] },
1728+ "workspace": { "root": "/tmp/ws" },
1729+ "audit_instruction": "Edit src/lib.rs to fix the finding."
1730+ }"# ;
1731+ serde_json:: from_str ( raw) . expect ( "repair brief parses" )
1732+ }
1733+
16811734 fn review_workspace ( ) -> ( tempfile:: TempDir , ReviewTrace ) {
16821735 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
16831736 let ws = dir. path ( ) . to_path_buf ( ) ;
@@ -1793,6 +1846,7 @@ mod tests {
17931846 "## Findings\n - [low] src/lib.rs:1 — fine\n \n ## Supporting Context Used\n - src/support.rs: checked" ,
17941847 Some ( & trace) ,
17951848 memory,
1849+ false ,
17961850 ) ;
17971851
17981852 let value = serde_json:: to_value ( & envelope) . unwrap ( ) ;
@@ -1916,6 +1970,33 @@ mod tests {
19161970 ) ;
19171971 }
19181972
1973+ #[ test]
1974+ fn contract_v2_pull_request_review_remains_supported_end_to_end ( ) {
1975+ let raw = r#"{
1976+ "contract_version": "2",
1977+ "trigger": "pull_request_autoreview",
1978+ "repo": { "owner": "OpenCoven", "name": "example", "clone_url": "https://github.com/OpenCoven/example.git", "default_branch": "main" },
1979+ "task": { "kind": "respond_to_mention", "issue_number": 73, "comment_body": "Review pull request #73 at the captured head." },
1980+ "familiar": { "id": "covencat", "display_name": "Covencat", "skills": [] },
1981+ "workspace": { "root": "/workspace" },
1982+ "review_context": { "kind": "pull_request", "files": [{ "filename": "src/app.ts" }] }
1983+ }"# ;
1984+ let brief: SessionBrief = serde_json:: from_str ( raw) . expect ( "v2 review brief parses" ) ;
1985+ brief
1986+ . ensure_supported_version ( )
1987+ . expect ( "v2 remains supported" ) ;
1988+ assert_eq ! ( brief. review_mode( ) , ReviewMode :: PullRequest ) ;
1989+ assert ! ( brief. to_prompt( ) . contains( "Review pull request #73" ) ) ;
1990+ let ( envelope, _) = build_result (
1991+ Some ( & brief) ,
1992+ & GitSummary :: default ( ) ,
1993+ RunOutcome :: Completed ,
1994+ "### Files inspected\n - `src/app.ts`\n ### Supporting context used\n - `src/app.ts` - reviewed source\n ### Findings\n None\n ### No-findings justification\n No defect was found in `src/app.ts`.\n ### Tests/commands considered\n - `npm test` - not run: host validates\n ### Confidence/limitations\n Host validation is pending." ,
1995+ None ,
1996+ ) ;
1997+ assert_eq ! ( envelope. contract_version, "2" ) ;
1998+ }
1999+
19192000 #[ test]
19202001 fn prompt_is_derived_from_task_and_never_leaks_a_token ( ) {
19212002 let brief = sample_brief ( ) ;
@@ -1928,6 +2009,16 @@ mod tests {
19282009 assert ! ( !prompt. contains( "x-access-token:" ) ) ;
19292010 }
19302011
2012+ #[ test]
2013+ fn hosted_repair_prompt_requires_real_edits_and_delegates_privileged_steps ( ) {
2014+ let prompt = sample_brief ( ) . to_hosted_repair_prompt ( ) ;
2015+
2016+ assert ! ( prompt. contains( "Do not merely describe a patch" ) ) ;
2017+ assert ! ( prompt. contains( "current workspace" ) ) ;
2018+ assert ! ( prompt. contains( "trusted worker will validate, commit, and push" ) ) ;
2019+ assert ! ( !prompt. contains( "make the change on a new branch" ) ) ;
2020+ }
2021+
19312022 #[ test]
19322023 fn review_prompt_requires_structured_review_sections ( ) {
19332024 let prompt = sample_review_brief ( ) . to_prompt ( ) ;
@@ -1953,6 +2044,7 @@ mod tests {
19532044 let mut config = claurst_core:: config:: Config {
19542045 hosted_review : claurst_core:: hosted_review:: HostedReviewConfig {
19552046 allow_write_tools : true ,
2047+ allow_file_write_tools : true ,
19562048 allow_mcp_servers : true ,
19572049 allow_plugins : true ,
19582050 allow_user_memory : true ,
@@ -1966,6 +2058,7 @@ mod tests {
19662058
19672059 assert ! ( config. hosted_review. enabled) ;
19682060 assert ! ( !config. hosted_review. allow_write_tools) ;
2061+ assert ! ( !config. hosted_review. allow_file_write_tools) ;
19692062 assert ! ( !config. hosted_review. allow_mcp_servers) ;
19702063 assert ! ( !config. hosted_review. allow_plugins) ;
19712064 assert ! ( !config. hosted_review. allow_user_memory) ;
@@ -2065,6 +2158,7 @@ mod tests {
20652158 message: "Add clock-skew buffer" . to_string( ) ,
20662159 } ] ,
20672160 files_changed : vec ! [ "src/auth/refresh.rs" . to_string( ) ] ,
2161+ workspace_dirty : false ,
20682162 } ;
20692163 let ( env, code) = build_result (
20702164 Some ( & sample_brief ( ) ) ,
@@ -2417,6 +2511,52 @@ N/A
24172511 }
24182512 }
24192513
2514+ #[ test]
2515+ fn hosted_repair_requires_a_workspace_edit ( ) {
2516+ let brief = sample_repair_brief ( ) ;
2517+ let git = GitSummary {
2518+ files_changed : vec ! [ "src/lib.rs" . to_string( ) ] ,
2519+ workspace_dirty : false ,
2520+ ..Default :: default ( )
2521+ } ;
2522+ let ( env, code) = build_result_with_memory (
2523+ Some ( & brief) ,
2524+ & git,
2525+ RunOutcome :: Completed ,
2526+ "No changes were necessary." ,
2527+ None ,
2528+ ReviewMemoryUse :: default ( ) ,
2529+ true ,
2530+ ) ;
2531+
2532+ assert_eq ! ( code, 1 ) ;
2533+ assert_eq ! ( env. status, Status :: Failure ) ;
2534+ assert_eq ! ( env. exit_reason, Some ( ExitReason :: AmbiguousSpec ) ) ;
2535+ }
2536+
2537+ #[ test]
2538+ fn hosted_repair_accepts_uncommitted_workspace_edits_for_worker_validation ( ) {
2539+ let brief = sample_repair_brief ( ) ;
2540+ let git = GitSummary {
2541+ files_changed : vec ! [ "src/lib.rs" . to_string( ) ] ,
2542+ workspace_dirty : true ,
2543+ ..Default :: default ( )
2544+ } ;
2545+ let ( env, code) = build_result_with_memory (
2546+ Some ( & brief) ,
2547+ & git,
2548+ RunOutcome :: Completed ,
2549+ "Updated `src/lib.rs` to address the finding." ,
2550+ None ,
2551+ ReviewMemoryUse :: default ( ) ,
2552+ true ,
2553+ ) ;
2554+
2555+ assert_eq ! ( code, 0 ) ;
2556+ assert_eq ! ( env. status, Status :: Success ) ;
2557+ assert ! ( env. exit_reason. is_none( ) ) ;
2558+ }
2559+
24202560 #[ test]
24212561 fn address_review_comment_without_commits_is_successful_review_output ( ) {
24222562 let raw = r#"{
@@ -2457,6 +2597,7 @@ N/A
24572597 message: "do the thing" . to_string( ) ,
24582598 } ] ,
24592599 files_changed : vec ! [ "a.rs" . to_string( ) ] ,
2600+ workspace_dirty : false ,
24602601 } ;
24612602 let ( env, _) = build_result (
24622603 Some ( & sample_brief ( ) ) ,
@@ -2481,6 +2622,7 @@ N/A
24812622 message: "m" . to_string( ) ,
24822623 } ] ,
24832624 files_changed : vec ! [ "a.rs" . to_string( ) , "b.rs" . to_string( ) ] ,
2625+ workspace_dirty : false ,
24842626 } ;
24852627 let ( env, _) = build_result (
24862628 Some ( & sample_brief ( ) ) ,
@@ -2541,6 +2683,12 @@ N/A
25412683 assert_eq ! ( summary. commits. len( ) , 1 , "one commit ahead of main" ) ;
25422684 assert_eq ! ( summary. commits[ 0 ] . message, "add feature" ) ;
25432685 assert ! ( summary. files_changed. iter( ) . any( |f| f == "feature.rs" ) ) ;
2686+ assert ! ( !summary. workspace_dirty) ;
2687+
2688+ std:: fs:: write ( ws. join ( "base.txt" ) , "edited" ) . unwrap ( ) ;
2689+ let dirty = collect_git_summary ( ws, "main" ) ;
2690+ assert ! ( dirty. workspace_dirty) ;
2691+ assert ! ( dirty. files_changed. iter( ) . any( |f| f == "base.txt" ) ) ;
25442692 }
25452693
25462694 #[ test]
0 commit comments