From 87a335e313effe478c5f57ea9b6aa9b5b45c9560 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Mon, 25 May 2026 16:33:00 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Restrict tracker comments to structured public manual-attention summaries","authority":"XY-522"} --- apps/decodex/src/agent/tracker_tool_bridge.rs | 12 +- .../src/agent/tracker_tool_bridge/tests.rs | 13 + .../tests/mutation/continuation.rs | 10 +- .../tests/mutation/dispatch.rs | 284 +++++++++++++++--- .../tests/review/handoff.rs | 4 +- .../src/agent/tracker_tool_bridge/tools.rs | 257 +++++++++++++++- apps/decodex/src/orchestrator/prompting.rs | 20 +- .../tests/intake/workflow_reload.rs | 2 +- docs/spec/linear-execution-ledger.md | 13 + docs/spec/runtime.md | 12 +- docs/spec/tracker-tools.md | 43 ++- 11 files changed, 585 insertions(+), 85 deletions(-) diff --git a/apps/decodex/src/agent/tracker_tool_bridge.rs b/apps/decodex/src/agent/tracker_tool_bridge.rs index c4b8885a3..420621436 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge.rs @@ -598,10 +598,20 @@ struct TransitionArgs { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct CommentArgs { #[serde(flatten)] scope: ScopeArgs, - body: String, + kind: String, + error_class: Option, + next_action: Option, + #[serde(default)] + blockers: Vec, + #[serde(default)] + evidence: Vec, + failed_command: Option, + raw_error: Option, + summary: Option, } #[derive(Debug, Deserialize)] diff --git a/apps/decodex/src/agent/tracker_tool_bridge/tests.rs b/apps/decodex/src/agent/tracker_tool_bridge/tests.rs index fcbcf038f..b9b438e43 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/tests.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/tests.rs @@ -8,6 +8,7 @@ use std::{ process::{self, Command}, }; +use serde_json::Value; use tempfile::TempDir; use crate::{ @@ -444,6 +445,18 @@ fn sample_review_context() -> ReviewHandoffContext { } } +fn manual_attention_comment_args() -> Value { + serde_json::json!({ + "kind": "manual_attention", + "error_class": "operator_decision_required", + "next_action": "resolve the blocker manually, clear the needs-attention label, then restart automation if desired", + "blockers": ["operator decision is required before automation can continue"], + "evidence": ["agent selected the manual-attention path for this run"], + "failed_command": "cargo make test", + "raw_error": "repo gate failed with public test output" + }) +} + fn sample_review_context_in(cwd: &Path) -> ReviewHandoffContext { ReviewHandoffContext { attempt_number: 2, diff --git a/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/continuation.rs b/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/continuation.rs index fea824d84..7a93be155 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/continuation.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/continuation.rs @@ -21,11 +21,19 @@ fn completion_disposition_allows_manual_attention_exit_without_review_handoff() let comment_response = DynamicToolHandler::handle_call( &bridge, ISSUE_COMMENT_TOOL_NAME, - serde_json::json!({ "body": "Blocked on missing tracker permission; handing off for manual repair." }), + manual_attention_comment_args(), ); assert!(response.success); assert!(comment_response.success); + + let comment = tracker.comments.borrow().first().expect("manual attention comment should write").clone(); + let record = records::parse_linear_execution_event_record(&comment) + .expect("manual attention comment should include a ledger record"); + + assert_eq!(record.event_type, "needs_attention"); + assert_eq!(record.error_class.as_deref(), Some("operator_decision_required")); + assert_eq!(record.terminal_path.as_deref(), Some("manual_attention")); assert_eq!( bridge.completion_disposition().expect("manual attention should be accepted"), RunCompletionDisposition::ManualAttention diff --git a/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/dispatch.rs b/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/dispatch.rs index 9ded72662..9c42c42fb 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/dispatch.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/dispatch.rs @@ -1,7 +1,7 @@ use records::CLOSEOUT_RECORD_TYPE; use records::CloseoutRecord; -use crate::tracker; +use crate::tracker::{self, public_text}; #[test] fn closeout_apply_validates_merged_pr_and_completed_issue_state() { @@ -740,10 +740,14 @@ fn rejects_tool_calls_for_another_issue() { let issue = sample_issue(); let workflow = sample_workflow(); let bridge = TrackerToolBridge::new(&tracker, &issue, &workflow); + let mut args = manual_attention_comment_args(); + + args["issue_identifier"] = serde_json::json!("DEC-999"); + let response = DynamicToolHandler::handle_call( &bridge, ISSUE_COMMENT_TOOL_NAME, - serde_json::json!({ "issue_identifier": "DEC-999", "body": "hello" }), + args, ); assert!(!response.success); @@ -751,65 +755,138 @@ fn rejects_tool_calls_for_another_issue() { } #[test] -fn accepts_public_comments_without_sensitive_paths() { - for body in [ - "Started work and running validation now.", - "decodex run failed and will retry\n\n- worktree_path: `.worktrees/DEC-1`", - ] { - let tracker = FakeTracker::new(); - let issue = sample_issue(); - let workflow = sample_workflow(); - let bridge = TrackerToolBridge::new(&tracker, &issue, &workflow); - let response = DynamicToolHandler::handle_call( - &bridge, - ISSUE_COMMENT_TOOL_NAME, - serde_json::json!({ "body": body }), - ); +fn accepts_manual_attention_public_summary_kind() { + let issue = sample_issue(); + let tracker = tracker_with_current_issue_snapshot(&issue); + let workflow = sample_workflow(); + let inspector = FakePullRequestInspector::new(Vec::new()); + let local_repo_inspector = FakeLocalRepoInspector::new(Vec::new()); + let bridge = TrackerToolBridge::with_review_handoff_for_test( + &tracker, + &issue, + &workflow, + sample_review_context(), + &inspector, + &local_repo_inspector, + ); + let label_response = DynamicToolHandler::handle_call( + &bridge, + ISSUE_LABEL_ADD_TOOL_NAME, + serde_json::json!({ "label": "decodex:needs-attention" }), + ); + let comment_response = + DynamicToolHandler::handle_call(&bridge, ISSUE_COMMENT_TOOL_NAME, manual_attention_comment_args()); - assert!(response.success, "comment should be accepted: {body}"); - assert_eq!(tracker.comments.borrow().as_slice(), [body]); - } + assert!(label_response.success); + assert!(comment_response.success); + + let comments = tracker.comments.borrow(); + let comment = comments.first().expect("manual attention summary should write"); + let record = records::parse_linear_execution_event_record(comment) + .expect("manual attention summary should include a ledger record"); + + assert_eq!(comments.len(), 1); + assert!(comment.contains("decodex run needs manual attention")); + assert!(comment.contains("- worktree_path: `.worktrees/PUB-618`")); + assert!(comment.contains("- failed_command: cargo make test")); + assert!(comment.contains("- raw_error: repo gate failed with public test output")); + assert_eq!(record.event_type, "needs_attention"); + assert_eq!(record.error_class.as_deref(), Some("operator_decision_required")); + assert_eq!( + record.failed_command.as_deref(), + Some("cargo make test") + ); + assert_eq!( + record.raw_error.as_deref(), + Some("repo gate failed with public test output") + ); } #[test] -fn rejects_public_comments_with_sensitive_or_unknown_paths() { - for (body, expected_error) in [ +fn rejects_arbitrary_issue_comment_bodies() { + let tracker = FakeTracker::new(); + let issue = sample_issue(); + let workflow = sample_workflow(); + let bridge = TrackerToolBridge::new(&tracker, &issue, &workflow); + let response = DynamicToolHandler::handle_call( + &bridge, + ISSUE_COMMENT_TOOL_NAME, + serde_json::json!({ "body": "Started work and running validation now." }), + ); + + assert!(!response.success); + assert!(tracker.comments.borrow().is_empty()); + assert!(matches!( + response.content_items.as_slice(), + [DynamicToolContentItem::InputText { text }] + if text.contains("Invalid `issue.comment` arguments") + )); +} + +#[test] +fn rejects_manual_attention_comments_with_private_or_unsupported_fields() { + for (field_name, value, expected_error) in [ ( - "decodex run failed and will retry\n\n- worktree_path: `/absolute/path/to/repo/.worktrees/DEC-1`", - "`worktree_path` must be repository-relative, not `/absolute/path/to/repo/.worktrees/DEC-1`.", + "failed_command", + "cargo test --manifest-path /Users/example/repo/Cargo.toml", + "`failed_command` must be public/team-visible text; host-local paths are not allowed.", ), ( - "decodex run failed and will retry\n\n- unexpected_path: `/absolute/path/to/repo/.worktrees/DEC-1`", - "Unsupported structured field `unexpected_path` in public issue comments.", + "raw_error", + "Missing GITHUB_PAT_Y.", + "`raw_error` must be public/team-visible text; credential-like names are not allowed.", ), ( - "decodex run failed and will retry\n\n- worktree_path: `C:/absolute/path/to/repo/.worktrees/DEC-1`", - "`body` must be public/team-visible text; host-local paths are not allowed.", + "next_action", + "inspect /Users/example/repo and recover manually", + "`next_action` must be public/team-visible text; host-local paths are not allowed.", ), ( - "decodex run failed and will retry\n\nMissing credential GITHUB_PAT_Y.", - "`body` must be public/team-visible text; credential-like names are not allowed.", + "blockers", + "local checkout at /Users/example/repo blocked validation", + "`blockers` must be public/team-visible text; host-local paths are not allowed.", ), ( - "decodex run failed and will retry\n\nSelected account user@example.com.", - "`body` must be public/team-visible text; private identity details are not allowed.", + "evidence", + "selected account user@example.com failed", + "`evidence` must be public/team-visible text; private identity details are not allowed.", ), ( - "decodex run failed and will retry\n\ncodex.github-identity resolved to a private route.", - "`body` must be public/team-visible text; local identity or account-routing details are not allowed.", + "summary", + "Missing GITHUB_PAT_Y blocked automation.", + "`summary` must be public/team-visible text; credential-like names are not allowed.", ), ] { - let tracker = FakeTracker::new(); let issue = sample_issue(); + let tracker = tracker_with_current_issue_snapshot(&issue); let workflow = sample_workflow(); - let bridge = TrackerToolBridge::new(&tracker, &issue, &workflow); - let response = DynamicToolHandler::handle_call( + let inspector = FakePullRequestInspector::new(Vec::new()); + let local_repo_inspector = FakeLocalRepoInspector::new(Vec::new()); + let bridge = TrackerToolBridge::with_review_handoff_for_test( + &tracker, + &issue, + &workflow, + sample_review_context(), + &inspector, + &local_repo_inspector, + ); + let label_response = DynamicToolHandler::handle_call( &bridge, - ISSUE_COMMENT_TOOL_NAME, - serde_json::json!({ "body": body }), + ISSUE_LABEL_ADD_TOOL_NAME, + serde_json::json!({ "label": "decodex:needs-attention" }), ); + let mut args = manual_attention_comment_args(); + + args[field_name] = if matches!(field_name, "blockers" | "evidence") { + serde_json::json!([value]) + } else { + serde_json::json!(value) + }; + + let response = DynamicToolHandler::handle_call(&bridge, ISSUE_COMMENT_TOOL_NAME, args); - assert!(!response.success, "comment should be rejected: {body}"); + assert!(label_response.success); + assert!(!response.success, "comment should be rejected for {field_name}"); assert!(tracker.comments.borrow().is_empty()); assert_eq!( response.content_items, @@ -818,6 +895,135 @@ fn rejects_public_comments_with_sensitive_or_unknown_paths() { } } +#[test] +fn rejects_unsupported_issue_comment_kinds_without_mutation() { + let issue = sample_issue(); + let tracker = tracker_with_current_issue_snapshot(&issue); + let workflow = sample_workflow(); + let inspector = FakePullRequestInspector::new(Vec::new()); + let local_repo_inspector = FakeLocalRepoInspector::new(Vec::new()); + let bridge = TrackerToolBridge::with_review_handoff_for_test( + &tracker, + &issue, + &workflow, + sample_review_context(), + &inspector, + &local_repo_inspector, + ); + let label_response = DynamicToolHandler::handle_call( + &bridge, + ISSUE_LABEL_ADD_TOOL_NAME, + serde_json::json!({ "label": "decodex:needs-attention" }), + ); + let response = DynamicToolHandler::handle_call( + &bridge, + ISSUE_COMMENT_TOOL_NAME, + serde_json::json!({ + "kind": "status_note", + "error_class": "operator_decision_required", + "next_action": "resolve manually", + "blockers": ["operator decision is required"], + "evidence": ["agent attempted unsupported comment kind"] + }), + ); + + assert!(label_response.success); + assert!(!response.success); + assert!(tracker.comments.borrow().is_empty()); + assert!(matches!( + response.content_items.as_slice(), + [DynamicToolContentItem::InputText { text }] + if text.contains("Unsupported `issue_comment` kind `status_note`") + )); +} + +#[test] +fn rejects_manual_attention_comment_before_needs_attention_label() { + let issue = sample_issue(); + let tracker = FakeTracker::new(); + let workflow = sample_workflow(); + let inspector = FakePullRequestInspector::new(Vec::new()); + let local_repo_inspector = FakeLocalRepoInspector::new(Vec::new()); + let bridge = TrackerToolBridge::with_review_handoff_for_test( + &tracker, + &issue, + &workflow, + sample_review_context(), + &inspector, + &local_repo_inspector, + ); + let response = + DynamicToolHandler::handle_call(&bridge, ISSUE_COMMENT_TOOL_NAME, manual_attention_comment_args()); + + assert!(!response.success); + assert!(tracker.comments.borrow().is_empty()); + assert!(matches!( + response.content_items.as_slice(), + [DynamicToolContentItem::InputText { text }] + if text.contains("requires a successful `issue_label_add` call") + )); +} + +#[test] +fn rejects_manual_attention_comment_with_non_public_error_class_shape() { + let issue = sample_issue(); + let tracker = tracker_with_current_issue_snapshot(&issue); + let workflow = sample_workflow(); + let inspector = FakePullRequestInspector::new(Vec::new()); + let local_repo_inspector = FakeLocalRepoInspector::new(Vec::new()); + let bridge = TrackerToolBridge::with_review_handoff_for_test( + &tracker, + &issue, + &workflow, + sample_review_context(), + &inspector, + &local_repo_inspector, + ); + let label_response = DynamicToolHandler::handle_call( + &bridge, + ISSUE_LABEL_ADD_TOOL_NAME, + serde_json::json!({ "label": "decodex:needs-attention" }), + ); + let mut args = manual_attention_comment_args(); + + args["error_class"] = serde_json::json!("Missing-GITHUB_PAT_Y"); + + let response = DynamicToolHandler::handle_call(&bridge, ISSUE_COMMENT_TOOL_NAME, args); + + assert!(label_response.success); + assert!(!response.success); + assert!(tracker.comments.borrow().is_empty()); + assert_eq!( + response.content_items, + vec![DynamicToolContentItem::InputText { + text: String::from("`error_class` must be a public snake_case identifier.") + }] + ); +} + +#[test] +fn rejects_legacy_public_comments_with_sensitive_or_unknown_paths() { + for (body, expected_error) in [ + ( + "decodex run failed and will retry\n\n- worktree_path: `/absolute/path/to/repo/.worktrees/DEC-1`", + "`worktree_path` must be repository-relative, not `/absolute/path/to/repo/.worktrees/DEC-1`.", + ), + ( + "decodex run failed and will retry\n\n- unexpected_path: `/absolute/path/to/repo/.worktrees/DEC-1`", + "Unsupported structured field `unexpected_path` in public issue comments.", + ), + ( + "decodex run failed and will retry\n\n- worktree_path: `C:/absolute/path/to/repo/.worktrees/DEC-1`", + "`body` must be public/team-visible text; host-local paths are not allowed.", + ), + ] { + let error = public_text::validate_public_comment_body(body) + .expect_err("legacy free-form body should still fail public text validation"); + + assert_eq!(error, expected_error); + } +} + #[test] fn adds_allowed_workflow_label() { let issue = sample_issue(); diff --git a/apps/decodex/src/agent/tracker_tool_bridge/tests/review/handoff.rs b/apps/decodex/src/agent/tracker_tool_bridge/tests/review/handoff.rs index 3347e6652..73638b229 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/tests/review/handoff.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/tests/review/handoff.rs @@ -176,9 +176,7 @@ fn terminal_finalize_accepts_matching_manual_attention_path() { let comment_response = DynamicToolHandler::handle_call( &bridge, ISSUE_COMMENT_TOOL_NAME, - serde_json::json!({ - "body": "Blocked on missing tracker permission; handing off for manual repair." - }), + manual_attention_comment_args(), ); let finalize_response = DynamicToolHandler::handle_call( &bridge, diff --git a/apps/decodex/src/agent/tracker_tool_bridge/tools.rs b/apps/decodex/src/agent/tracker_tool_bridge/tools.rs index 4428e758c..b1fedc8eb 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/tools.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/tools.rs @@ -19,6 +19,20 @@ use crate::{ }, }; +const COMMENT_KIND_MANUAL_ATTENTION: &str = "manual_attention"; +const MANUAL_ATTENTION_TERMINAL_PATH: &str = "manual_attention"; + +#[derive(Debug)] +struct NormalizedManualAttentionComment { + error_class: String, + next_action: String, + blockers: Vec, + evidence: Vec, + failed_command: Option, + raw_error: Option, + summary: Option, +} + impl<'a> TrackerToolBridge<'a> { pub(super) fn build_tool_specs(&self) -> Vec { let mut tool_specs = match self.review_context.as_ref().map(|context| context.mode) { @@ -95,15 +109,31 @@ impl<'a> TrackerToolBridge<'a> { pub(super) fn comment_tool_specs(&self) -> Vec { vec![DynamicToolSpec::new( ISSUE_COMMENT_TOOL_NAME, - "Add an exceptional human-readable comment to the currently leased issue for manual-attention blockers or explicit collaboration notes. Use progress checkpoints for routine progress.", + "Add an allowlisted public summary comment to the currently leased issue. The supported automation kind is `manual_attention`; Decodex renders the Linear comment from structured public fields.", serde_json::json!({ - "type": "object", - "properties": { + "type": "object", + "properties": { "issue_id": { "type": "string" }, "issue_identifier": { "type": "string" }, - "body": { "type": "string" } + "kind": { + "type": "string", + "enum": [COMMENT_KIND_MANUAL_ATTENTION] + }, + "error_class": { "type": "string" }, + "next_action": { "type": "string" }, + "blockers": { + "type": "array", + "items": { "type": "string" } + }, + "evidence": { + "type": "array", + "items": { "type": "string" } + }, + "failed_command": { "type": "string" }, + "raw_error": { "type": "string" }, + "summary": { "type": "string" } }, - "required": ["body"], + "required": ["kind", "error_class", "next_action", "blockers", "evidence"], "additionalProperties": false }), )] @@ -631,34 +661,164 @@ impl<'a> TrackerToolBridge<'a> { return DynamicToolCallResponse::failure(error); } - if parsed.body.trim().is_empty() { - return DynamicToolCallResponse::failure(String::from( - "`issue.comment` requires a non-empty `body`.", + match parsed.kind.as_str() { + COMMENT_KIND_MANUAL_ATTENTION => self.handle_manual_attention_comment(parsed), + other => DynamicToolCallResponse::failure(format!( + "Unsupported `{ISSUE_COMMENT_TOOL_NAME}` kind `{other}`. Supported kinds: `{COMMENT_KIND_MANUAL_ATTENTION}`." + )), + } + } + + fn handle_manual_attention_comment(&self, parsed: CommentArgs) -> DynamicToolCallResponse { + if !*self.manual_attention_requested.borrow() { + return DynamicToolCallResponse::failure(format!( + "`{ISSUE_COMMENT_TOOL_NAME}` kind `{COMMENT_KIND_MANUAL_ATTENTION}` requires a successful `{ISSUE_LABEL_ADD_TOOL_NAME}` call for label `{}` before writing the explanatory comment.", + self.workflow.frontmatter().tracker().needs_attention_label() )); } - if let Err(error) = tracker_tool_bridge::validate_public_comment_body(&parsed.body) { + let review_context = match self.review_context.as_ref() { + Some(review_context) => review_context, + None => { + return DynamicToolCallResponse::failure(format!( + "`{ISSUE_COMMENT_TOOL_NAME}` kind `{COMMENT_KIND_MANUAL_ATTENTION}` requires an active Decodex run context." + )); + }, + }; + let state_store = match self.state_store { + Some(state_store) => state_store, + None => { + return DynamicToolCallResponse::failure(format!( + "`{ISSUE_COMMENT_TOOL_NAME}` kind `{COMMENT_KIND_MANUAL_ATTENTION}` requires the Decodex runtime state store for issue `{}`.", + self.issue.identifier + )); + }, + }; + let comment = match Self::normalize_manual_attention_comment(parsed) { + Ok(comment) => comment, + Err(error) => return DynamicToolCallResponse::failure(error), + }; + let record = self.manual_attention_execution_event(review_context, &comment); + let body = format_manual_attention_comment(review_context, &comment); + + if let Err(error) = records::validate_linear_execution_event_record(&record) { + return DynamicToolCallResponse::failure(error); + } + if let Err(error) = tracker_tool_bridge::validate_public_comment_body(&body) { return DynamicToolCallResponse::failure(error); } - match self.tracker.create_comment(&self.issue.id, &parsed.body) { - Ok(()) => { - if *self.manual_attention_requested.borrow() { - self.manual_attention_comment_recorded.replace(true); + match tracker::create_linear_execution_event_comment( + self.tracker, + &self.issue.id, + &body, + &record, + ) { + Ok(created) => { + if let Err(error) = state_store.record_linear_execution_event(&record) { + return DynamicToolCallResponse::failure(format!( + "Failed to persist the public manual-attention summary for issue `{}`: {error}", + self.issue.identifier + )); } + self.manual_attention_comment_recorded.replace(true); + + let verb = if created { "added" } else { "already existed for" }; + DynamicToolCallResponse::success(format!( - "Comment added to issue `{}`.", + "Manual-attention public summary {verb} issue `{}`.", self.issue.identifier )) }, Err(error) => DynamicToolCallResponse::failure(format!( - "Failed to add a comment to issue `{}`: {error}", + "Failed to add a manual-attention public summary to issue `{}`: {error}", self.issue.identifier )), } } + fn normalize_manual_attention_comment( + parsed: CommentArgs, + ) -> Result { + let error_class = normalize_required_comment_field(parsed.error_class, "error_class")?; + let next_action = normalize_required_comment_field(parsed.next_action, "next_action")?; + let blockers = tracker_tool_bridge::normalize_progress_list(parsed.blockers); + let evidence = tracker_tool_bridge::normalize_progress_list(parsed.evidence); + let failed_command = + tracker_tool_bridge::normalize_optional_progress_field(parsed.failed_command); + let raw_error = tracker_tool_bridge::normalize_optional_progress_field(parsed.raw_error); + let summary = tracker_tool_bridge::normalize_optional_progress_field(parsed.summary); + + validate_public_error_class(&error_class)?; + + if blockers.is_empty() { + return Err(format!( + "`{ISSUE_COMMENT_TOOL_NAME}` kind `{COMMENT_KIND_MANUAL_ATTENTION}` requires at least one public `blockers` item." + )); + } + if evidence.is_empty() { + return Err(format!( + "`{ISSUE_COMMENT_TOOL_NAME}` kind `{COMMENT_KIND_MANUAL_ATTENTION}` requires at least one public `evidence` item." + )); + } + + Ok(NormalizedManualAttentionComment { + error_class, + next_action, + blockers, + evidence, + failed_command, + raw_error, + summary, + }) + } + + fn manual_attention_execution_event( + &self, + review_context: &ReviewHandoffContext, + comment: &NormalizedManualAttentionComment, + ) -> LinearExecutionEventRecord { + let anchor = records::stable_event_anchor(&[ + COMMENT_KIND_MANUAL_ATTENTION, + comment.error_class.as_str(), + comment.next_action.as_str(), + comment.failed_command.as_deref().unwrap_or_default(), + comment.raw_error.as_deref().unwrap_or_default(), + ]); + let mut record = LinearExecutionEventRecord::new( + LinearExecutionEventIdentity { + service_id: &review_context.service_id, + issue_id: &self.issue.id, + issue_identifier: &self.issue.identifier, + run_id: &review_context.run_id, + attempt_number: review_context.attempt_number, + }, + "needs_attention", + tracker_tool_bridge::current_timestamp(), + &anchor, + ); + + record.branch = Some(review_context.branch_name.clone()); + record.worktree_path = Some(review_context.worktree_path.clone()); + record.pr_url = review_context.recorded_pr_url.clone(); + record.summary = Some( + comment + .summary + .clone() + .unwrap_or_else(|| format!("Manual attention required: {}.", comment.error_class)), + ); + record.error_class = Some(comment.error_class.clone()); + record.next_action = Some(comment.next_action.clone()); + record.blockers = Some(comment.blockers.clone()); + record.evidence = Some(comment.evidence.clone()); + record.terminal_path = Some(String::from(MANUAL_ATTENTION_TERMINAL_PATH)); + record.failed_command = comment.failed_command.clone(); + record.raw_error = comment.raw_error.clone(); + + record + } + pub(super) fn handle_review_checkpoint(&self, arguments: Value) -> DynamicToolCallResponse { let parsed = match serde_json::from_value::(arguments) { Ok(parsed) => parsed, @@ -1162,3 +1322,70 @@ impl<'a> TrackerToolBridge<'a> { )) } } + +fn normalize_required_comment_field( + value: Option, + field_name: &str, +) -> Result { + let value = tracker_tool_bridge::normalize_optional_progress_field(value).ok_or_else(|| { + format!( + "`{ISSUE_COMMENT_TOOL_NAME}` kind `{COMMENT_KIND_MANUAL_ATTENTION}` requires `{field_name}`." + ) + })?; + + Ok(value) +} + +fn validate_public_error_class(error_class: &str) -> Result<(), String> { + let mut chars = error_class.chars(); + let Some(first) = chars.next() else { + return Err(String::from("`error_class` must be a public snake_case identifier.")); + }; + + if !first.is_ascii_lowercase() + || !chars.all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_' + }) { + return Err(String::from("`error_class` must be a public snake_case identifier.")); + } + + Ok(()) +} + +fn format_manual_attention_comment( + review_context: &ReviewHandoffContext, + comment: &NormalizedManualAttentionComment, +) -> String { + let mut lines = vec![ + String::from("decodex run needs manual attention"), + String::new(), + format!("- run_id: `{}`", review_context.run_id), + format!("- attempt: `{}`", review_context.attempt_number), + format!("- reported_at: `{}`", tracker_tool_bridge::current_timestamp()), + format!("- branch: `{}`", review_context.branch_name), + format!("- worktree_path: `{}`", review_context.worktree_path), + format!("- comment_kind: `{COMMENT_KIND_MANUAL_ATTENTION}`"), + format!("- error_class: `{}`", comment.error_class), + format!("- next_action: {}", comment.next_action), + ]; + + if let Some(summary) = comment.summary.as_deref() { + lines.push(format!("- summary: {summary}")); + } + + for blocker in &comment.blockers { + lines.push(format!("- blocker: {blocker}")); + } + for evidence in &comment.evidence { + lines.push(format!("- evidence: {evidence}")); + } + + if let Some(failed_command) = comment.failed_command.as_deref() { + lines.push(format!("- failed_command: {failed_command}")); + } + if let Some(raw_error) = comment.raw_error.as_deref() { + lines.push(format!("- raw_error: {raw_error}")); + } + + lines.join("\n") +} diff --git a/apps/decodex/src/orchestrator/prompting.rs b/apps/decodex/src/orchestrator/prompting.rs index 1fc9cb9c1..caf83ff02 100644 --- a/apps/decodex/src/orchestrator/prompting.rs +++ b/apps/decodex/src/orchestrator/prompting.rs @@ -1,5 +1,5 @@ pub(crate) const TRACKER_PUBLIC_TEXT_BOUNDARY_INSTRUCTION: &str = - "Tracker public text boundary\n- Linear tracker text is public/team-visible. Do not include local host paths, routed identity details, account details, credential-like names, private config paths, tokens, or secrets in issue comments, progress checkpoints, review summaries, closeout summaries, blockers, evidence, verification, failed commands, or raw errors.\n- Use public collaboration identifiers when needed: PR URLs, issue identifiers, branch names, commit SHAs, and repository-relative paths."; + "Tracker public text boundary\n- Linear tracker text is public/team-visible. Do not include local host paths, routed identity details, account details, credential-like names, private config paths, tokens, or secrets in issue comments, progress checkpoints, review summaries, closeout summaries, blockers, evidence, verification, failed commands, or raw errors.\n- `issue_comment` accepts only allowlisted public comment kinds. For manual attention, call it with `kind: \"manual_attention\"` and structured public fields; do not send arbitrary comment bodies.\n- Use public collaboration identifiers when needed: PR URLs, issue identifiers, branch names, commit SHAs, and repository-relative paths."; const PROMPT_ONLY_INTERNAL_REVIEW_INSTRUCTION: &str = "Review your work repeatedly and fix any logic bugs until no new issues are found."; @@ -81,7 +81,7 @@ where let internal_review_mode = project.codex().internal_review_mode(); let tracker_contract = match issue_run.dispatch_mode { IssueDispatchMode::ReviewRepair => format!( - "Tracker tool contract\n- You own issue-scoped tracker writes for `{issue}` on retained PR `{pr_url}`.\n- This run resumes an existing `{success}` lane. Do not move the issue back to `{in_progress}` and do not call `{review_handoff_tool}`.\n- Update `{progress_checkpoint_tool}` whenever the execution phase, focus, next action, blockers, evidence, or verification state changes materially.\n{internal_review_guidance}- For each actionable review item on `{pr_url}`, including non-thread review summaries, validate the claim against the codebase, tests, and requirements before changing code, and keep pushback or clarification threads open until the repaired head is ready.\n- If this run was triggered by retained landing fallback, handle only the implementation-shaped blocker such as branch sync, conflict resolution, ambiguous mergeability, or repository-specific recovery. Do not merge or land the PR yourself.\n{repair_architecture_guidance}- Repair the current PR head on branch `{branch}`, run the repository validation needed to justify the repaired head, and push the repaired head.\n- Treat failures from repo-native `canonicalize_commands`, `verify_commands`, or tracked rewrites left by that repo gate as continued repair by default: keep fixing the lane and rerun the gate instead of taking `manual_attention` unless the blocker is clearly toolchain, environment, or operator-owned.\n- Do not request fresh external review yourself. `decodex` will post the next runtime-owned external review request after `{review_repair_tool}` succeeds.\n- After the repaired head is pushed, reply in-thread for every addressed comment and resolve only the GitHub review threads whose fixes landed and verified on the repaired head.\n{completion_guidance}- If you determine the issue needs human attention, add label `{needs_attention}` with `{label_tool}`, explain the exact observed blocker in a comment, including the failed command and raw error when available, and then call `{terminal_finalize_tool}` with path `manual_attention`. Do not speculate about capabilities you did not directly verify.\n- Keep the tracker issue in `{success}`. `decodex` will handle the later external review request or clean-path runtime landing, closeout, and cleanup lifecycle.\n- Do not report the run as complete or treat `{progress_checkpoint_tool}` as terminal completion until `{terminal_finalize_tool}` succeeds.{continuation_guidance}\n- Never write to any other issue.", + "Tracker tool contract\n- You own issue-scoped tracker writes for `{issue}` on retained PR `{pr_url}`.\n- This run resumes an existing `{success}` lane. Do not move the issue back to `{in_progress}` and do not call `{review_handoff_tool}`.\n- Update `{progress_checkpoint_tool}` whenever the execution phase, focus, next action, blockers, evidence, or verification state changes materially.\n{internal_review_guidance}- For each actionable review item on `{pr_url}`, including non-thread review summaries, validate the claim against the codebase, tests, and requirements before changing code, and keep pushback or clarification threads open until the repaired head is ready.\n- If this run was triggered by retained landing fallback, handle only the implementation-shaped blocker such as branch sync, conflict resolution, ambiguous mergeability, or repository-specific recovery. Do not merge or land the PR yourself.\n{repair_architecture_guidance}- Repair the current PR head on branch `{branch}`, run the repository validation needed to justify the repaired head, and push the repaired head.\n- Treat failures from repo-native `canonicalize_commands`, `verify_commands`, or tracked rewrites left by that repo gate as continued repair by default: keep fixing the lane and rerun the gate instead of taking `manual_attention` unless the blocker is clearly toolchain, environment, or operator-owned.\n- Do not request fresh external review yourself. `decodex` will post the next runtime-owned external review request after `{review_repair_tool}` succeeds.\n- After the repaired head is pushed, reply in-thread for every addressed comment and resolve only the GitHub review threads whose fixes landed and verified on the repaired head.\n{completion_guidance}- If you determine the issue needs human attention, add label `{needs_attention}` with `{label_tool}`, then call `issue_comment` with kind `manual_attention` and structured public fields (`error_class`, `next_action`, `blockers`, `evidence`; include `failed_command` and `raw_error` only when public-safe), and then call `{terminal_finalize_tool}` with path `manual_attention`. Do not speculate about capabilities you did not directly verify.\n- Keep the tracker issue in `{success}`. `decodex` will handle the later external review request or clean-path runtime landing, closeout, and cleanup lifecycle.\n- Do not report the run as complete or treat `{progress_checkpoint_tool}` as terminal completion until `{terminal_finalize_tool}` succeeds.{continuation_guidance}\n- Never write to any other issue.", issue = issue_run.issue.identifier, pr_url = recorded_pr_url.unwrap_or("(missing review handoff marker)"), progress_checkpoint_tool = ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, @@ -99,7 +99,7 @@ where completion_guidance = build_repair_completion_guidance(internal_review_mode), ), IssueDispatchMode::Closeout => format!( - "Tracker tool contract\n- You own issue-scoped tracker writes for `{issue}` on retained PR `{pr_url}`.\n- This run resumes a merged post-review lane for the same PR lineage. The tracker issue may still be in `{success}` or may already be in `{completed}` while deterministic closeout tail work remains. Do not move the issue back to `{in_progress}` and do not call `{review_handoff_tool}` or `{review_repair_tool}`.\n- Treat retained closeout as a short deterministic tail. Reuse the existing merged PR evidence instead of restarting broad discovery, and only rerun the minimum validation needed to justify `Done` plus cleanup.\n- Update `{progress_checkpoint_tool}` whenever the execution phase, focus, next action, blockers, evidence, or verification state changes materially.\n- If you call `{progress_checkpoint_tool}` during closeout, either omit `head_sha` and let `decodex` record the exact current lane HEAD automatically, or pass the exact full current `HEAD` SHA. Do not send an abbreviated SHA that differs from the live lane head.\n- Merge is already authoritative for `{pr_url}` before this run starts. Do not land, merge, or request review from this closeout run.\n- If the issue is still in `{success}`, transition it once to `{completed}` with `{transition_tool}` before `{closeout_tool}`. If it is already in `{completed}`, leave it there.\n- Finish the remaining Linear closeout tail work for this same merged PR lineage, then call `{closeout_tool}` with PR `{pr_url}` and a short result summary, then call `{terminal_finalize_tool}` with path `closeout`.\n- Do not end the turn without either `{closeout_tool}` plus `{terminal_finalize_tool}`, or the manual-attention path.\n- If you determine the issue needs human attention, add label `{needs_attention}` with `{label_tool}`, explain the exact observed blocker in a comment, including the failed command and raw error when available, and then call `{terminal_finalize_tool}` with path `manual_attention`. Do not speculate about capabilities you did not directly verify.\n- Keep all tracker and PR writes scoped to this retained lane. `decodex` will validate the merged PR lineage, the resolved completed state, and the later cleanup boundary.\n- Do not report the run as complete or treat `{progress_checkpoint_tool}` as terminal completion until `{terminal_finalize_tool}` succeeds.{continuation_guidance}\n- Never write to any other issue.", + "Tracker tool contract\n- You own issue-scoped tracker writes for `{issue}` on retained PR `{pr_url}`.\n- This run resumes a merged post-review lane for the same PR lineage. The tracker issue may still be in `{success}` or may already be in `{completed}` while deterministic closeout tail work remains. Do not move the issue back to `{in_progress}` and do not call `{review_handoff_tool}` or `{review_repair_tool}`.\n- Treat retained closeout as a short deterministic tail. Reuse the existing merged PR evidence instead of restarting broad discovery, and only rerun the minimum validation needed to justify `Done` plus cleanup.\n- Update `{progress_checkpoint_tool}` whenever the execution phase, focus, next action, blockers, evidence, or verification state changes materially.\n- If you call `{progress_checkpoint_tool}` during closeout, either omit `head_sha` and let `decodex` record the exact current lane HEAD automatically, or pass the exact full current `HEAD` SHA. Do not send an abbreviated SHA that differs from the live lane head.\n- Merge is already authoritative for `{pr_url}` before this run starts. Do not land, merge, or request review from this closeout run.\n- If the issue is still in `{success}`, transition it once to `{completed}` with `{transition_tool}` before `{closeout_tool}`. If it is already in `{completed}`, leave it there.\n- Finish the remaining Linear closeout tail work for this same merged PR lineage, then call `{closeout_tool}` with PR `{pr_url}` and a short result summary, then call `{terminal_finalize_tool}` with path `closeout`.\n- Do not end the turn without either `{closeout_tool}` plus `{terminal_finalize_tool}`, or the manual-attention path.\n- If you determine the issue needs human attention, add label `{needs_attention}` with `{label_tool}`, then call `issue_comment` with kind `manual_attention` and structured public fields (`error_class`, `next_action`, `blockers`, `evidence`; include `failed_command` and `raw_error` only when public-safe), and then call `{terminal_finalize_tool}` with path `manual_attention`. Do not speculate about capabilities you did not directly verify.\n- Keep all tracker and PR writes scoped to this retained lane. `decodex` will validate the merged PR lineage, the resolved completed state, and the later cleanup boundary.\n- Do not report the run as complete or treat `{progress_checkpoint_tool}` as terminal completion until `{terminal_finalize_tool}` succeeds.{continuation_guidance}\n- Never write to any other issue.", issue = issue_run.issue.identifier, pr_url = recorded_pr_url.unwrap_or("(missing review handoff marker)"), progress_checkpoint_tool = ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, @@ -116,7 +116,7 @@ where continuation_guidance = continuation_guidance, ), _ => format!( - "Tracker tool contract\n- You own issue-scoped tracker writes for `{issue}`.\n- At the start of execution, call `{transition_tool}` to move the issue to `{in_progress}`. Decodex already records the run-start Linear ledger, so do not add a separate start comment.\n- Update `{progress_checkpoint_tool}` whenever the execution phase, focus, next action, blockers, evidence, or verification state changes materially.\n{internal_review_guidance}- Treat failures from repo-native `canonicalize_commands`, `verify_commands`, or tracked rewrites left by that repo gate as continued repair by default: keep fixing the lane and rerun the gate instead of taking `manual_attention` unless the blocker is clearly toolchain, environment, or operator-owned.\n- When the implementation is ready, commit the lane, push branch `{branch}`, and create or update a non-draft PR titled `{pr_title}` for that branch.\n{completion_guidance}- If you determine the issue needs human attention, add label `{needs_attention}` with `{label_tool}`, explain the exact observed blocker in a comment, including the failed command and raw error when available, and then call `{terminal_finalize_tool}` with path `manual_attention`. Do not speculate about capabilities you did not directly verify. Do not call `{review_handoff_tool}` in that case; `decodex` will stop the lane as a human-required failure without automatic retry.\n- Do not move the issue directly to `{success}` with `{transition_tool}`. `decodex` will complete the success writeback only after its own validation passes.\n- Do not report the run as complete or treat `{progress_checkpoint_tool}` as terminal completion until `{terminal_finalize_tool}` succeeds.{continuation_guidance}\n- Never write to any other issue.", + "Tracker tool contract\n- You own issue-scoped tracker writes for `{issue}`.\n- At the start of execution, call `{transition_tool}` to move the issue to `{in_progress}`. Decodex already records the run-start Linear ledger, so do not add a separate start comment.\n- Update `{progress_checkpoint_tool}` whenever the execution phase, focus, next action, blockers, evidence, or verification state changes materially.\n{internal_review_guidance}- Treat failures from repo-native `canonicalize_commands`, `verify_commands`, or tracked rewrites left by that repo gate as continued repair by default: keep fixing the lane and rerun the gate instead of taking `manual_attention` unless the blocker is clearly toolchain, environment, or operator-owned.\n- When the implementation is ready, commit the lane, push branch `{branch}`, and create or update a non-draft PR titled `{pr_title}` for that branch.\n{completion_guidance}- If you determine the issue needs human attention, add label `{needs_attention}` with `{label_tool}`, then call `issue_comment` with kind `manual_attention` and structured public fields (`error_class`, `next_action`, `blockers`, `evidence`; include `failed_command` and `raw_error` only when public-safe), and then call `{terminal_finalize_tool}` with path `manual_attention`. Do not speculate about capabilities you did not directly verify. Do not call `{review_handoff_tool}` in that case; `decodex` will stop the lane as a human-required failure without automatic retry.\n- Do not move the issue directly to `{success}` with `{transition_tool}`. `decodex` will complete the success writeback only after its own validation passes.\n- Do not report the run as complete or treat `{progress_checkpoint_tool}` as terminal completion until `{terminal_finalize_tool}` succeeds.{continuation_guidance}\n- Never write to any other issue.", issue = issue_run.issue.identifier, transition_tool = ISSUE_TRANSITION_TOOL_NAME, label_tool = ISSUE_LABEL_ADD_TOOL_NAME, @@ -172,7 +172,7 @@ where match issue_run.dispatch_mode { IssueDispatchMode::ReviewRepair => format!( - "Continue retained review repair for Linear issue {identifier}: {title}\n\nDescription:\n{description}\n\nCurrent PR:\n- `{pr_url}`\n\nExecution checklist:\n- Resume from the current branch and PR state in this worktree. Do not move the issue back to `{in_progress}`.\n- Update `{progress_checkpoint_tool}` whenever the execution phase, focus, next action, blockers, evidence, or verification state changes materially.\n{internal_review_guidance}- Read the current review feedback on `{pr_url}`, including non-thread review summaries, validate each actionable claim against the codebase, tests, and requirements, fix only the verified issues on branch `{branch}`, and keep scope limited to the outstanding retained repair.\n- If the lane is here because retained landing was not a deterministic clean path, handle only the branch sync, conflict resolution, ambiguous mergeability, or repository-specific recovery needed to make the PR clean again. Do not merge or land the PR yourself.\n- Leave pushback or clarification threads open until the repaired head is ready.\n{repair_architecture_guidance}- Treat failures from repo-native `canonicalize_commands`, `verify_commands`, or tracked rewrites left by that repo gate as continued repair by default: keep fixing the lane and rerun the gate instead of taking `manual_attention` unless the blocker is clearly toolchain, environment, or operator-owned.\n- Run the repository validation needed to justify the repaired head.\n- Commit the repair and push the same branch. Do not request fresh external review yourself; `decodex` will post the next runtime-owned external review request after `{review_repair_tool}` succeeds.\n- After the repaired head is pushed, reply in-thread for every addressed comment and resolve only the GitHub review threads whose fixes landed and verified on the repaired head.\n{completion_guidance}- If the issue needs manual attention, add label `{needs_attention}` with `{label_tool}`, explain why in a comment, and then call `{terminal_finalize_tool}` with path `manual_attention`.\n- Keep the issue in `{success}` and do not treat `{progress_checkpoint_tool}` as terminal completion until `{terminal_finalize_tool}` succeeds.{continuation_guidance}", + "Continue retained review repair for Linear issue {identifier}: {title}\n\nDescription:\n{description}\n\nCurrent PR:\n- `{pr_url}`\n\nExecution checklist:\n- Resume from the current branch and PR state in this worktree. Do not move the issue back to `{in_progress}`.\n- Update `{progress_checkpoint_tool}` whenever the execution phase, focus, next action, blockers, evidence, or verification state changes materially.\n{internal_review_guidance}- Read the current review feedback on `{pr_url}`, including non-thread review summaries, validate each actionable claim against the codebase, tests, and requirements, fix only the verified issues on branch `{branch}`, and keep scope limited to the outstanding retained repair.\n- If the lane is here because retained landing was not a deterministic clean path, handle only the branch sync, conflict resolution, ambiguous mergeability, or repository-specific recovery needed to make the PR clean again. Do not merge or land the PR yourself.\n- Leave pushback or clarification threads open until the repaired head is ready.\n{repair_architecture_guidance}- Treat failures from repo-native `canonicalize_commands`, `verify_commands`, or tracked rewrites left by that repo gate as continued repair by default: keep fixing the lane and rerun the gate instead of taking `manual_attention` unless the blocker is clearly toolchain, environment, or operator-owned.\n- Run the repository validation needed to justify the repaired head.\n- Commit the repair and push the same branch. Do not request fresh external review yourself; `decodex` will post the next runtime-owned external review request after `{review_repair_tool}` succeeds.\n- After the repaired head is pushed, reply in-thread for every addressed comment and resolve only the GitHub review threads whose fixes landed and verified on the repaired head.\n{completion_guidance}- If the issue needs manual attention, add label `{needs_attention}` with `{label_tool}`, call `issue_comment` with kind `manual_attention` and structured public fields, and then call `{terminal_finalize_tool}` with path `manual_attention`.\n- Keep the issue in `{success}` and do not treat `{progress_checkpoint_tool}` as terminal completion until `{terminal_finalize_tool}` succeeds.{continuation_guidance}", identifier = issue.identifier, title = issue.title, description = description, @@ -191,7 +191,7 @@ where completion_guidance = build_repair_completion_guidance(internal_review_mode), ), IssueDispatchMode::Closeout => format!( - "Continue retained closeout for Linear issue {identifier}: {title}\n\nDescription:\n{description}\n\nCurrent PR:\n- `{pr_url}`\n\nExecution checklist:\n- Resume from the current branch and merged PR lineage in this worktree. Do not move the issue back to `{in_progress}`.\n- Treat retained closeout as a short deterministic tail. Reuse the existing merged PR evidence instead of restarting broad discovery, and only rerun the minimum validation needed to justify `Done` plus cleanup.\n- Update `{progress_checkpoint_tool}` whenever the execution phase, focus, next action, blockers, evidence, or verification state changes materially.\n- If you call `{progress_checkpoint_tool}` during closeout, either omit `head_sha` and let `decodex` record the exact current lane HEAD automatically, or pass the exact full current `HEAD` SHA.\n- Merge is already authoritative for `{pr_url}` before this run starts. Do not land, merge, or request review from this closeout run.\n- The tracker issue may already be in `{completed}` while this deterministic tail work remains pending.\n- If the issue is still in `{success}`, move it once to `{completed}` with `{transition_tool}` before `{closeout_tool}`.\n- Call `{closeout_tool}` with `{pr_url}` and a short result summary, then call `{terminal_finalize_tool}` with path `closeout`.\n- Do not end the turn without either `{closeout_tool}` plus `{terminal_finalize_tool}`, or the manual-attention path.\n- If the issue needs manual attention, add label `{needs_attention}` with `{label_tool}`, explain why in a comment, and then call `{terminal_finalize_tool}` with path `manual_attention`.\n- Keep the lane scoped to this retained post-review work and do not treat `{progress_checkpoint_tool}` as terminal completion until `{terminal_finalize_tool}` succeeds.{continuation_guidance}", + "Continue retained closeout for Linear issue {identifier}: {title}\n\nDescription:\n{description}\n\nCurrent PR:\n- `{pr_url}`\n\nExecution checklist:\n- Resume from the current branch and merged PR lineage in this worktree. Do not move the issue back to `{in_progress}`.\n- Treat retained closeout as a short deterministic tail. Reuse the existing merged PR evidence instead of restarting broad discovery, and only rerun the minimum validation needed to justify `Done` plus cleanup.\n- Update `{progress_checkpoint_tool}` whenever the execution phase, focus, next action, blockers, evidence, or verification state changes materially.\n- If you call `{progress_checkpoint_tool}` during closeout, either omit `head_sha` and let `decodex` record the exact current lane HEAD automatically, or pass the exact full current `HEAD` SHA.\n- Merge is already authoritative for `{pr_url}` before this run starts. Do not land, merge, or request review from this closeout run.\n- The tracker issue may already be in `{completed}` while this deterministic tail work remains pending.\n- If the issue is still in `{success}`, move it once to `{completed}` with `{transition_tool}` before `{closeout_tool}`.\n- Call `{closeout_tool}` with `{pr_url}` and a short result summary, then call `{terminal_finalize_tool}` with path `closeout`.\n- Do not end the turn without either `{closeout_tool}` plus `{terminal_finalize_tool}`, or the manual-attention path.\n- If the issue needs manual attention, add label `{needs_attention}` with `{label_tool}`, call `issue_comment` with kind `manual_attention` and structured public fields, and then call `{terminal_finalize_tool}` with path `manual_attention`.\n- Keep the lane scoped to this retained post-review work and do not treat `{progress_checkpoint_tool}` as terminal completion until `{terminal_finalize_tool}` succeeds.{continuation_guidance}", identifier = issue.identifier, title = issue.title, description = description, @@ -208,7 +208,7 @@ where continuation_guidance = continuation_guidance, ), _ => format!( - "Resolve Linear issue {identifier}: {title}\n\nDescription:\n{description}\n\n{recovery_context}Execution checklist:\n- Move the issue to `{in_progress}` with `{transition_tool}`. Decodex already records the run-start Linear ledger, so do not leave a separate start comment.\n- Update `{progress_checkpoint_tool}` whenever the execution phase, focus, next action, blockers, evidence, or verification state changes materially.\n- Keep discovery bounded to the minimal implementation files needed for this issue; defer broader docs or upstream reading unless a concrete ambiguity blocks the change.\n- Implement the fix in the current worktree.\n{internal_review_guidance}- Treat failures from repo-native `canonicalize_commands`, `verify_commands`, or tracked rewrites left by that repo gate as continued repair by default: keep fixing the lane and rerun the gate instead of taking `manual_attention` unless the blocker is clearly toolchain, environment, or operator-owned.\n- Run the repository validation needed to justify a reviewable PR.\n- Commit the lane, push branch `{branch}`, and create or update a non-draft PR titled `{pr_title}` for that branch.\n{completion_guidance}- If the issue needs manual attention, add label `{needs_attention}` with `{label_tool}`, explain why in a comment, and then call `{terminal_finalize_tool}` with path `manual_attention`. Do not call `{review_handoff_tool}` in that case; `decodex` will stop the lane as a human-required failure without automatic retry.\n- Do not move the issue directly to `{success}` with `{transition_tool}`; `decodex` will finish that writeback after its own validation passes.\n- Do not report the run as complete or treat `{progress_checkpoint_tool}` as terminal completion until `{terminal_finalize_tool}` succeeds.{continuation_guidance}", + "Resolve Linear issue {identifier}: {title}\n\nDescription:\n{description}\n\n{recovery_context}Execution checklist:\n- Move the issue to `{in_progress}` with `{transition_tool}`. Decodex already records the run-start Linear ledger, so do not leave a separate start comment.\n- Update `{progress_checkpoint_tool}` whenever the execution phase, focus, next action, blockers, evidence, or verification state changes materially.\n- Keep discovery bounded to the minimal implementation files needed for this issue; defer broader docs or upstream reading unless a concrete ambiguity blocks the change.\n- Implement the fix in the current worktree.\n{internal_review_guidance}- Treat failures from repo-native `canonicalize_commands`, `verify_commands`, or tracked rewrites left by that repo gate as continued repair by default: keep fixing the lane and rerun the gate instead of taking `manual_attention` unless the blocker is clearly toolchain, environment, or operator-owned.\n- Run the repository validation needed to justify a reviewable PR.\n- Commit the lane, push branch `{branch}`, and create or update a non-draft PR titled `{pr_title}` for that branch.\n{completion_guidance}- If the issue needs manual attention, add label `{needs_attention}` with `{label_tool}`, call `issue_comment` with kind `manual_attention` and structured public fields, and then call `{terminal_finalize_tool}` with path `manual_attention`. Do not call `{review_handoff_tool}` in that case; `decodex` will stop the lane as a human-required failure without automatic retry.\n- Do not move the issue directly to `{success}` with `{transition_tool}`; `decodex` will finish that writeback after its own validation passes.\n- Do not report the run as complete or treat `{progress_checkpoint_tool}` as terminal completion until `{terminal_finalize_tool}` succeeds.{continuation_guidance}", identifier = issue.identifier, title = issue.title, description = description, @@ -247,7 +247,7 @@ fn build_continuation_user_input( match dispatch_mode { IssueDispatchMode::ReviewRepair => format!( - "Continue retained review repair for Linear issue {identifier} in the current thread and worktree.\n\nContinuation checklist:\n- Resume from the current repository state and outstanding review feedback or retained landing fallback on `{pr_url}`.\n- Keep changes scoped to the same retained review lane and do not move the issue out of `{success}`.\n{internal_review_guidance}- Validate each actionable review claim against the codebase, tests, and requirements before changing code, and keep pushback or clarification threads open until the repaired head is ready.\n- If the blocker is landing fallback, repair only the branch sync, conflict, ambiguous mergeability, or repository-specific recovery issue; do not merge or land the PR yourself.\n- Treat failures from repo-native `canonicalize_commands`, `verify_commands`, or tracked rewrites left by that repo gate as continued repair by default: keep fixing the lane and rerun the gate instead of taking `manual_attention` unless the blocker is clearly toolchain, environment, or operator-owned.\n- If the repaired head is ready, push it. Do not request fresh external review yourself; Decodex will post the next runtime-owned external review request after `{review_repair_tool}` succeeds.\n- After the repaired head is pushed, reply in-thread for every addressed comment and resolve only the GitHub review threads whose fixes landed and verified on the repaired head.\n{completion_guidance}- If the issue requires manual attention, record the manual-attention tracker path before ending the turn.\n- If more work still remains after this turn, you may end the turn without terminal finalization and Decodex will decide whether to continue.", + "Continue retained review repair for Linear issue {identifier} in the current thread and worktree.\n\nContinuation checklist:\n- Resume from the current repository state and outstanding review feedback or retained landing fallback on `{pr_url}`.\n- Keep changes scoped to the same retained review lane and do not move the issue out of `{success}`.\n{internal_review_guidance}- Validate each actionable review claim against the codebase, tests, and requirements before changing code, and keep pushback or clarification threads open until the repaired head is ready.\n- If the blocker is landing fallback, repair only the branch sync, conflict, ambiguous mergeability, or repository-specific recovery issue; do not merge or land the PR yourself.\n- Treat failures from repo-native `canonicalize_commands`, `verify_commands`, or tracked rewrites left by that repo gate as continued repair by default: keep fixing the lane and rerun the gate instead of taking `manual_attention` unless the blocker is clearly toolchain, environment, or operator-owned.\n- If the repaired head is ready, push it. Do not request fresh external review yourself; Decodex will post the next runtime-owned external review request after `{review_repair_tool}` succeeds.\n- After the repaired head is pushed, reply in-thread for every addressed comment and resolve only the GitHub review threads whose fixes landed and verified on the repaired head.\n{completion_guidance}- If the issue requires manual attention, add the needs-attention label, call `issue_comment` with kind `manual_attention` and structured public fields, then finalize path `manual_attention` before ending the turn.\n- If more work still remains after this turn, you may end the turn without terminal finalization and Decodex will decide whether to continue.", identifier = issue.identifier, pr_url = recorded_pr_url.unwrap_or("(missing review handoff marker)"), success = success_state, @@ -260,7 +260,7 @@ fn build_continuation_user_input( ), ), IssueDispatchMode::Closeout => format!( - "Continue retained closeout for Linear issue {identifier} in the current thread and worktree.\n\nContinuation checklist:\n- Resume from the current repository state and merged PR lineage on `{pr_url}`.\n- Keep changes scoped to the same retained post-review lane. Do not move the issue back to implementation; the tracker may already be in `{completed}` while closeout or cleanup remains pending.\n- Treat this resumed closeout as a short deterministic tail. Reuse the existing merged PR evidence instead of restarting broad discovery, and only rerun the minimum validation needed to justify `Done` plus cleanup.\n- If you record `{progress_checkpoint_tool}` during closeout, either omit `head_sha` or pass the exact full current `HEAD` SHA.\n- Merge is already authoritative for `{pr_url}` before this run starts. Do not land, merge, or request review from this closeout run.\n- If the issue is still in `{success}`, transition it once to `{completed}` with `{transition_tool}` before `{closeout_tool}`.\n- If Linear closeout is complete, call `{closeout_tool}` and then call `{terminal_finalize_tool}` with path `closeout`.\n- Do not end the turn without either `{closeout_tool}` plus `{terminal_finalize_tool}`, or the manual-attention path.\n- If the issue requires manual attention, record the manual-attention tracker path before ending the turn.", + "Continue retained closeout for Linear issue {identifier} in the current thread and worktree.\n\nContinuation checklist:\n- Resume from the current repository state and merged PR lineage on `{pr_url}`.\n- Keep changes scoped to the same retained post-review lane. Do not move the issue back to implementation; the tracker may already be in `{completed}` while closeout or cleanup remains pending.\n- Treat this resumed closeout as a short deterministic tail. Reuse the existing merged PR evidence instead of restarting broad discovery, and only rerun the minimum validation needed to justify `Done` plus cleanup.\n- If you record `{progress_checkpoint_tool}` during closeout, either omit `head_sha` or pass the exact full current `HEAD` SHA.\n- Merge is already authoritative for `{pr_url}` before this run starts. Do not land, merge, or request review from this closeout run.\n- If the issue is still in `{success}`, transition it once to `{completed}` with `{transition_tool}` before `{closeout_tool}`.\n- If Linear closeout is complete, call `{closeout_tool}` and then call `{terminal_finalize_tool}` with path `closeout`.\n- Do not end the turn without either `{closeout_tool}` plus `{terminal_finalize_tool}`, or the manual-attention path.\n- If the issue requires manual attention, add the needs-attention label, call `issue_comment` with kind `manual_attention` and structured public fields, then finalize path `manual_attention` before ending the turn.", identifier = issue.identifier, pr_url = recorded_pr_url.unwrap_or("(missing review handoff marker)"), progress_checkpoint_tool = ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, @@ -271,7 +271,7 @@ fn build_continuation_user_input( terminal_finalize_tool = ISSUE_TERMINAL_FINALIZE_TOOL_NAME, ), _ => format!( - "Continue working on Linear issue {identifier} in the current thread and worktree.\n\nContinuation checklist:\n- Resume from the current repository state instead of restarting broad discovery.\n- Keep changes scoped to the same issue lane.\n{internal_review_guidance}- Treat failures from repo-native `canonicalize_commands`, `verify_commands`, or tracked rewrites left by that repo gate as continued repair by default: keep fixing the lane and rerun the gate instead of taking `manual_attention` unless the blocker is clearly toolchain, environment, or operator-owned.\n{completion_guidance}- If the issue requires manual attention, record the manual-attention tracker path before ending the turn.\n- If more work still remains after this turn, you may end the turn without terminal finalization and Decodex will decide whether to continue.", + "Continue working on Linear issue {identifier} in the current thread and worktree.\n\nContinuation checklist:\n- Resume from the current repository state instead of restarting broad discovery.\n- Keep changes scoped to the same issue lane.\n{internal_review_guidance}- Treat failures from repo-native `canonicalize_commands`, `verify_commands`, or tracked rewrites left by that repo gate as continued repair by default: keep fixing the lane and rerun the gate instead of taking `manual_attention` unless the blocker is clearly toolchain, environment, or operator-owned.\n{completion_guidance}- If the issue requires manual attention, add the needs-attention label, call `issue_comment` with kind `manual_attention` and structured public fields, then finalize path `manual_attention` before ending the turn.\n- If more work still remains after this turn, you may end the turn without terminal finalization and Decodex will decide whether to continue.", identifier = issue.identifier, internal_review_guidance = build_handoff_continuation_review_guidance( internal_review_mode diff --git a/apps/decodex/src/orchestrator/tests/intake/workflow_reload.rs b/apps/decodex/src/orchestrator/tests/intake/workflow_reload.rs index c81f64685..fbb94daec 100644 --- a/apps/decodex/src/orchestrator/tests/intake/workflow_reload.rs +++ b/apps/decodex/src/orchestrator/tests/intake/workflow_reload.rs @@ -178,7 +178,7 @@ fn expected_developer_instructions( )); sections.push(format!( - "Tracker tool contract\n- You own issue-scoped tracker writes for `{issue}`.\n- At the start of execution, call `{transition_tool}` to move the issue to `{in_progress}`. Decodex already records the run-start Linear ledger, so do not add a separate start comment.\n- Update `{progress_checkpoint_tool}` whenever the execution phase, focus, next action, blockers, evidence, or verification state changes materially.\n- Follow the repo-native bounded review method from `WORKFLOW.md`: review the actual current diff and branch state, run both the requirements pass and the adversarial reviewer pass, fix only the smallest coherent owned batch, rerun verification, and re-read `HEAD` before deciding the next normalized review status.\n- Every time the repo-native bounded review method produces a result for the current head, call `{review_checkpoint_tool}` with that normalized status, the exact current `HEAD` SHA, and any concise evidence items.\n- Treat failures from repo-native `canonicalize_commands`, `verify_commands`, or tracked rewrites left by that repo gate as continued repair by default: keep fixing the lane and rerun the gate instead of taking `manual_attention` unless the blocker is clearly toolchain, environment, or operator-owned.\n- When the implementation is ready, commit the lane, push branch `{branch}`, and create or update a non-draft PR titled `{pr_title}` for that branch.\n- Call `{review_handoff_tool}` only after the latest `{review_checkpoint_tool}` for this handoff phase and current `HEAD` is `clean`. Then call `{terminal_finalize_tool}` with path `review_handoff`.\n- If you determine the issue needs human attention, add label `{needs_attention}` with `{label_tool}`, explain the exact observed blocker in a comment, including the failed command and raw error when available, and then call `{terminal_finalize_tool}` with path `manual_attention`. Do not speculate about capabilities you did not directly verify. Do not call `{review_handoff_tool}` in that case; `decodex` will stop the lane as a human-required failure without automatic retry.\n- Do not move the issue directly to `{success}` with `{transition_tool}`. `decodex` will complete the success writeback only after its own validation passes.\n- Do not report the run as complete or treat `{progress_checkpoint_tool}` as terminal completion until `{terminal_finalize_tool}` succeeds.{continuation_guidance}\n- Never write to any other issue.", + "Tracker tool contract\n- You own issue-scoped tracker writes for `{issue}`.\n- At the start of execution, call `{transition_tool}` to move the issue to `{in_progress}`. Decodex already records the run-start Linear ledger, so do not add a separate start comment.\n- Update `{progress_checkpoint_tool}` whenever the execution phase, focus, next action, blockers, evidence, or verification state changes materially.\n- Follow the repo-native bounded review method from `WORKFLOW.md`: review the actual current diff and branch state, run both the requirements pass and the adversarial reviewer pass, fix only the smallest coherent owned batch, rerun verification, and re-read `HEAD` before deciding the next normalized review status.\n- Every time the repo-native bounded review method produces a result for the current head, call `{review_checkpoint_tool}` with that normalized status, the exact current `HEAD` SHA, and any concise evidence items.\n- Treat failures from repo-native `canonicalize_commands`, `verify_commands`, or tracked rewrites left by that repo gate as continued repair by default: keep fixing the lane and rerun the gate instead of taking `manual_attention` unless the blocker is clearly toolchain, environment, or operator-owned.\n- When the implementation is ready, commit the lane, push branch `{branch}`, and create or update a non-draft PR titled `{pr_title}` for that branch.\n- Call `{review_handoff_tool}` only after the latest `{review_checkpoint_tool}` for this handoff phase and current `HEAD` is `clean`. Then call `{terminal_finalize_tool}` with path `review_handoff`.\n- If you determine the issue needs human attention, add label `{needs_attention}` with `{label_tool}`, then call `issue_comment` with kind `manual_attention` and structured public fields (`error_class`, `next_action`, `blockers`, `evidence`; include `failed_command` and `raw_error` only when public-safe), and then call `{terminal_finalize_tool}` with path `manual_attention`. Do not speculate about capabilities you did not directly verify. Do not call `{review_handoff_tool}` in that case; `decodex` will stop the lane as a human-required failure without automatic retry.\n- Do not move the issue directly to `{success}` with `{transition_tool}`. `decodex` will complete the success writeback only after its own validation passes.\n- Do not report the run as complete or treat `{progress_checkpoint_tool}` as terminal completion until `{terminal_finalize_tool}` succeeds.{continuation_guidance}\n- Never write to any other issue.", issue = issue_run.issue.identifier, transition_tool = ISSUE_TRANSITION_TOOL_NAME, label_tool = ISSUE_LABEL_ADD_TOOL_NAME, diff --git a/docs/spec/linear-execution-ledger.md b/docs/spec/linear-execution-ledger.md index e94ab0dcc..0995a18d6 100644 --- a/docs/spec/linear-execution-ledger.md +++ b/docs/spec/linear-execution-ledger.md @@ -86,6 +86,13 @@ activity markers. Linear records should continue to use public collaboration identifiers such as PR URLs, issue identifiers, branch names, commit SHAs, and repository-relative paths. +Agent-requested manual-attention comments are not arbitrary Linear comment bodies. +They are `needs_attention` ledger records rendered from the allowlisted +`issue_comment` kind `manual_attention`. The agent supplies only structured public +fields. `failed_command` and `raw_error` are optional and must be omitted or rejected +when they contain host-local paths, credential-like names, private identity details, +tokens, secrets, or other private runtime evidence. + ## Record envelope All field names are snake_case. @@ -190,6 +197,12 @@ that writes the event. For normal review handoff this is `review_handoff`; for r repair completion this is `review_repair`; for explicit human-required exits this is `manual_attention`. +`failed_command` and `raw_error` are public-summary fields, not private evidence +escape hatches. Producers must validate those values before writing a Linear comment. +When the exact failed command or raw error contains private information, producers must +omit it and use public `error_class`, `next_action`, `blockers`, and `evidence` +instead. + `review_handoff_rebind` is only for an explicit operator recovery command that restores a missing runtime DB review handoff marker after validating the retained worktree and PR lineage. It is not a normal agent terminal signal, does not imply `issue_terminal_finalize` diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 49db35899..166c58a71 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -203,12 +203,12 @@ Before applying success or failure writeback, `decodex` must classify the finish | Disposition | Required agent signal | Forbidden co-signal | Runtime effect | | --- | --- | --- | --- | | `review_handoff` | `issue_review_handoff` plus `issue_terminal_finalize(path = "review_handoff")` | `decodex:needs-attention` | Run the repo-native gate, revalidate PR state, post completion comment, transition to `In Review`. | -| `manual_attention` | `decodex:needs-attention` plus an explanatory issue comment, then `issue_terminal_finalize(path = "manual_attention")` | `issue_review_handoff` | Skip PR-backed success writeback and the repo-native gate, then treat the run as a human-required failure immediately. | +| `manual_attention` | `decodex:needs-attention` plus an explanatory public issue summary, then `issue_terminal_finalize(path = "manual_attention")` | `issue_review_handoff` | Skip PR-backed success writeback and the repo-native gate, then treat the run as a human-required failure immediately. | If neither signal exists, or both signals exist, `decodex` must fail the attempt instead of inferring operator intent. If the label is recorded without the required explanatory comment, `decodex` must also fail the attempt instead of treating it as a valid `manual_attention` exit. If the resolved terminal path is not explicitly finalized through `issue_terminal_finalize`, the app-server wrapper must fail the turn before `decodex` records the attempt as successful. -The explanatory comment for `manual_attention` must describe the exact observed blocker and should include the failed command plus raw error text when available instead of speculating about unverified capability limits. +The explanatory public summary for `manual_attention` must describe the exact observed blocker and should include the failed command plus raw error text only when those values are public-safe, instead of speculating about unverified capability limits. Execution-state checkpoints are durable progress overlays only. Their phase, focus, next action, blockers, evidence, or verification fields are never a substitute for the explicit terminal-finalization call. ### Progress checkpoint writeback @@ -288,7 +288,8 @@ This path applies to retryable failures, retry exhaustion, and explicit `manual_ Retryable failures with remaining budget: -- Keep the issue in `In Progress`, typically through an agent-authored retry comment. +- Keep the issue in `In Progress`, typically through a runtime-owned retry ledger + comment. - Queue the retry in the runtime database rather than immediately redispatching inside the same poll tick. - Clean worker exits after a nonterminal continuation boundary schedule a short continuation retry. - Abnormal worker exits schedule exponential backoff capped by `execution.max_retry_backoff_ms`. @@ -307,6 +308,11 @@ Retry-exhausted or human-required failures: 4. Finalize the terminal path with `issue_terminal_finalize(path = "manual_attention")`. If the coding agent explicitly requests human attention by adding `decodex:needs-attention`, `decodex` must stop automatic retries for that attempt, skip PR-backed success writeback, and treat the lane as a human-required failure immediately. +The paired explanatory comment must use the issue-scoped `issue_comment` allowlist, +currently kind `manual_attention`, so the Linear-visible summary is rendered from +structured public fields instead of an arbitrary agent-authored body. Private command +or error details must remain in local runtime evidence when they cannot pass the +public-text guard. Runtime-owned review-policy stops use the same human-required failure path, but with dedicated `error_class` values: - `review_policy_exhausted` diff --git a/docs/spec/tracker-tools.md b/docs/spec/tracker-tools.md index 471fda4f3..22947de68 100644 --- a/docs/spec/tracker-tools.md +++ b/docs/spec/tracker-tools.md @@ -46,8 +46,11 @@ The follow-up MVP should support these issue-scoped operations: - `issue_transition` - move the current issue to an allowed target state - `issue_comment` - - add an exceptional human-readable comment to the current issue for - manual-attention blockers or explicit collaboration notes + - add an allowlisted public comment summary to the current issue for a known + lifecycle case + - current automation kind: `manual_attention` + - the tool accepts structured public fields and renders the Linear comment itself; + it must not accept arbitrary agent-authored comment bodies - `issue_progress_checkpoint` - append the current durable execution-state snapshot to private runtime evidence and publish only the low-frequency public projection when the public lifecycle signal changes - `issue_review_checkpoint` @@ -116,8 +119,18 @@ In either invalid case, `decodex` must fail the attempt rather than infer which - `issue_review_repair_complete` must validate that the supplied PR belongs to the current repository and retained lane branch, points at the validated lane HEAD, is open, and is ready for fresh review before `decodex` accepts retained repair completion. - `issue_review_handoff` records the success metadata during the turn, but `decodex` owns the final completion comment and `In Review` transition after service-side validation succeeds. - `issue_review_repair_complete` records retained repair completion metadata during the turn, but `decodex` owns the final completion comment and refreshed retained-lineage marker after service-side validation succeeds. -- Adding the configured `needs_attention_label` is an explicit human-required failure exit for the active lane. In that case the agent must leave a comment explaining the blocker, must not also record `issue_review_handoff`, and `decodex` must stop automatic retries for that attempt. -- Human-attention comments must describe the exact observed blocker and should include the failed command plus raw error text when available. The agent must not speculate about capabilities or environment restrictions that it did not directly verify. +- Adding the configured `needs_attention_label` is an explicit human-required + failure exit for the active lane. In that case the agent must call + `issue_comment` with kind `manual_attention` so Decodex can render the + explanatory `needs_attention` ledger comment, must not also record + `issue_review_handoff`, and `decodex` must stop automatic retries for that + attempt. +- Human-attention comments must describe the exact observed blocker through + structured public fields: `error_class`, `next_action`, `blockers`, and + `evidence`. `failed_command` and `raw_error` may be included only when their + values are public-safe. The tool must reject private-looking command or error + text before any Linear mutation. The agent must not speculate about + capabilities or environment restrictions that it did not directly verify. - The human-attention exit is not complete until the explanatory comment is successfully written after the label request. A label-only signal must be rejected as an invalid completion disposition. - The run is not complete until `issue_terminal_finalize` succeeds against the matching terminal path. An execution-state checkpoint or an agent summary message is not a substitute. - Issues that carry the configured `needs_attention_label` must remain ineligible for future automatic selection until a human clears the label. @@ -127,7 +140,9 @@ In either invalid case, `decodex` must fail the attempt rather than infer which - `decodex` must preflight the local GitHub CLI dependency at the PR-backed review boundary itself: - when a normal lane is about to validate and write back `issue_review_handoff` - when a retained post-review lane is about to re-enter `review_repair` -- Comment bodies should remain repository-controlled or agent-authored, but all tool calls must be journaled by `decodex` for recovery and audit. +- Decodex execution comment bodies should be rendered by Decodex from + structured, validated fields. All tool calls must be journaled by `decodex` + for recovery and audit. - Routine start and progress visibility should use Linear execution ledger records instead of ad hoc `issue_comment` text. A normal run start is represented by one `run_started` ledger record. Ordinary progress uses `issue_progress_checkpoint` @@ -138,13 +153,17 @@ In either invalid case, `decodex` must fail the attempt rather than infer which [`linear-execution-ledger.md`](./linear-execution-ledger.md). - Structured comment fields such as `worktree_path` must use repository-relative paths; absolute host paths should be rejected before writing to the tracker. -- `issue_comment` text is public/team-visible and must pass the public-text guard - before it writes to Linear. `issue_progress_checkpoint` payload text is private - runtime evidence; only its rendered public projection is public/team-visible and - subject to Linear event validation. Before any Linear write, Decodex must reject - known leakage-shaped public projection text such as host-local paths, routed - identity details, credential-like names, private account details, private config - file names, emails, tokens, or secrets. Detailed checkpoint evidence remains +- `issue_comment` is public/team-visible but not free-form. It must accept only + allowlisted public comment kinds and structured public fields. For + `manual_attention`, Decodex renders a `needs_attention` Linear execution ledger + comment from those fields. Unsupported kinds, arbitrary `body` arguments, and + private-looking `failed_command` or `raw_error` values must be rejected before + any Linear write. `issue_progress_checkpoint` payload text is private runtime + evidence; only its rendered public projection is public/team-visible and subject + to Linear event validation. Before any Linear write, Decodex must reject known + leakage-shaped public projection text such as host-local paths, routed identity + details, credential-like names, private account details, private config file + names, emails, tokens, or secrets. Detailed checkpoint evidence remains local/operator-only. - Dynamic tool names must satisfy the `codex app-server` identifier restriction `^[a-zA-Z0-9_-]+$`; dotted names are invalid.