diff --git a/apps/decodex/src/agent/tracker_tool_bridge.rs b/apps/decodex/src/agent/tracker_tool_bridge.rs index 6f4cef0cf..c4b8885a3 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge.rs @@ -631,6 +631,24 @@ struct ProgressCheckpointArgs { pr_url: Option, } +#[derive(Debug)] +struct NormalizedProgressCheckpoint { + phase: ExecutionProgressPhase, + focus: String, + next_action: String, + blockers: Vec, + evidence: Vec, + verification: Vec, + head_sha: Option, + branch: Option, + pr_url: Option, +} +impl NormalizedProgressCheckpoint { + fn public_branch(&self, review_context: &ReviewHandoffContext) -> String { + self.branch.clone().unwrap_or_else(|| review_context.branch_name.clone()) + } +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct LabelArgs { diff --git a/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/progress.rs b/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/progress.rs index 80e1f6f34..b30e6ef57 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/progress.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/tests/mutation/progress.rs @@ -1,5 +1,5 @@ #[test] -fn progress_checkpoint_writes_structured_issue_comment() { +fn progress_checkpoint_preserves_private_payload_and_publishes_projection() { let tracker = FakeTracker::new(); let issue = sample_issue(); let workflow = sample_workflow(); @@ -42,16 +42,43 @@ fn progress_checkpoint_writes_structured_issue_comment() { assert_eq!(record.record_type, records::LINEAR_EXECUTION_EVENT_RECORD_TYPE); assert_eq!(record.event_type, "progress_checkpoint"); assert_eq!(record.phase.as_deref(), Some("implementing")); + assert_eq!(record.summary.as_deref(), Some("Execution phase: implementing.")); + assert_eq!(record.branch.as_deref(), Some("x/decodex-1")); + assert_eq!(record.worktree_path.as_deref(), Some(".worktrees/PUB-618")); + assert_eq!(record.pr_url.as_deref(), Some("https://github.com/hack-ink/decodex/pull/12")); + assert!(record.focus.is_none()); + assert!(record.next_action.is_none()); + assert!(record.blockers.is_none()); + assert!(record.evidence.is_none()); + assert!(record.verification.is_none()); + assert!(record.commit_sha.is_none()); + + let private_events = bridge_state_store(&bridge) + .list_private_execution_events(TEST_SERVICE_ID, &issue.id, "pub-618-attempt-2-123", 2) + .expect("private checkpoint events should list"); + + assert_eq!(private_events.len(), 1); + assert_eq!(private_events[0].event_type(), "progress_checkpoint"); assert_eq!( - record.focus.as_deref(), - Some("Wire the new execution-state skill into tracker-driven flows.") + private_events[0].payload()["focus"], + serde_json::json!("Wire the new execution-state skill into tracker-driven flows.") ); assert_eq!( - record.next_action.as_deref(), - Some("Add the issue_progress_checkpoint runtime tool.") + private_events[0].payload()["next_action"], + serde_json::json!("Add the issue_progress_checkpoint runtime tool.") + ); + assert_eq!( + private_events[0].payload()["evidence"], + serde_json::json!(["Research decision favors Linear-backed execution snapshots."]) + ); + assert_eq!( + private_events[0].payload()["verification"], + serde_json::json!(["Local inventory of active execution-state boundary references completed."]) + ); + assert_eq!( + private_events[0].payload()["head_sha"], + serde_json::json!(sample_local_repo().head_oid) ); - assert_eq!(record.commit_sha.as_deref(), Some(sample_local_repo().head_oid.as_str())); - assert_eq!(record.branch.as_deref(), Some("x/decodex-1")); } #[test] @@ -156,11 +183,20 @@ fn progress_checkpoint_normalizes_matching_short_head_sha_to_full_head() { let record = records::parse_linear_execution_event_record(&tracker.comments.borrow()[0]) .expect("progress checkpoint should be a Linear execution event"); - assert_eq!(record.commit_sha.as_deref(), Some(sample_local_repo().head_oid.as_str())); + assert!(record.commit_sha.is_none()); + + let private_events = bridge_state_store(&bridge) + .list_private_execution_events(TEST_SERVICE_ID, &issue.id, "pub-618-attempt-2-123", 2) + .expect("private checkpoint events should list"); + + assert_eq!( + private_events[0].payload()["head_sha"], + serde_json::json!(sample_local_repo().head_oid) + ); } #[test] -fn progress_checkpoint_retries_do_not_duplicate_same_ledger_event() { +fn progress_checkpoint_retries_preserve_private_events_without_duplicate_public_projection() { let tracker = FakeTracker::new(); let issue = sample_issue(); let workflow = sample_workflow(); @@ -194,10 +230,85 @@ fn progress_checkpoint_retries_do_not_duplicate_same_ledger_event() { assert!(first.success); assert!(second.success); assert_eq!(tracker.comments.borrow().len(), 1); + + let private_events = bridge_state_store(&bridge) + .list_private_execution_events(TEST_SERVICE_ID, &issue.id, "pub-618-attempt-2-123", 2) + .expect("private checkpoint events should list"); + + assert_eq!(private_events.len(), 2); } #[test] -fn progress_checkpoint_rejects_private_public_text_before_write() { +fn progress_checkpoint_public_projection_changes_only_on_material_signal() { + let tracker = FakeTracker::new(); + let issue = sample_issue(); + let workflow = sample_workflow(); + let temp_dir = TempDir::new().expect("tempdir should create"); + let pull_request_inspector = FakePullRequestInspector::new(Vec::new()); + let local_repo_inspector = FakeLocalRepoInspector::new(vec![Ok(sample_local_repo())]); + let bridge = TrackerToolBridge::with_review_handoff_for_test( + &tracker, + &issue, + &workflow, + sample_review_context_in(temp_dir.path()), + &pull_request_inspector, + &local_repo_inspector, + ); + let first = DynamicToolHandler::handle_call( + &bridge, + ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, + serde_json::json!({ + "phase": "implementing", + "focus": "First private implementation focus.", + "next_action": "Continue implementation.", + "blockers": [], + "evidence": ["First private evidence item."], + "head_sha": sample_local_repo().head_oid + }), + ); + let non_material = DynamicToolHandler::handle_call( + &bridge, + ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, + serde_json::json!({ + "phase": "implementing", + "focus": "Changed private implementation focus.", + "next_action": "Changed private next action.", + "blockers": [], + "evidence": ["Changed private evidence item."], + "head_sha": sample_local_repo().head_oid + }), + ); + let material = DynamicToolHandler::handle_call( + &bridge, + ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, + serde_json::json!({ + "phase": "verifying", + "focus": "Verify the implementation.", + "next_action": "Run tests.", + "blockers": [], + "evidence": ["Implementation reached verification."], + "head_sha": sample_local_repo().head_oid + }), + ); + + assert!(first.success); + assert!(non_material.success); + assert!(material.success); + assert_eq!( + tracker.comments.borrow().len(), + 2, + "private-only changes inside the same public phase must not flood Linear" + ); + + let private_events = bridge_state_store(&bridge) + .list_private_execution_events(TEST_SERVICE_ID, &issue.id, "pub-618-attempt-2-123", 2) + .expect("private checkpoint events should list"); + + assert_eq!(private_events.len(), 3); +} + +#[test] +fn progress_checkpoint_stores_private_text_but_redacts_public_projection() { let tracker = FakeTracker::new(); let issue = sample_issue(); let workflow = sample_workflow(); @@ -225,12 +336,33 @@ fn progress_checkpoint_rejects_private_public_text_before_write() { }), ); - assert!(!response.success); - assert!( - response - .content_items - .iter() - .any(|item| matches!(item, DynamicToolContentItem::InputText { text } if text.contains("public/team-visible"))) + assert!(response.success); + assert_eq!(tracker.comments.borrow().len(), 1); + + let body = &tracker.comments.borrow()[0]; + + assert!(!body.contains("/Users/example/code/private")); + assert!(!body.contains("GITHUB_PAT_Y")); + + let record = records::parse_linear_execution_event_record(body) + .expect("public projection should parse as a Linear execution event"); + + assert_eq!(record.phase.as_deref(), Some("implementing")); + assert_eq!(record.summary.as_deref(), Some("Execution phase: implementing.")); + assert!(record.focus.is_none()); + assert!(record.next_action.is_none()); + assert!(record.evidence.is_none()); + + let private_events = bridge_state_store(&bridge) + .list_private_execution_events(TEST_SERVICE_ID, &issue.id, "pub-618-attempt-2-123", 2) + .expect("private checkpoint events should list"); + + assert_eq!( + private_events[0].payload()["focus"], + serde_json::json!("Inspected /Users/example/code/private checkout.") + ); + assert_eq!( + private_events[0].payload()["evidence"], + serde_json::json!(["Missing GITHUB_PAT_Y was observed."]) ); - assert!(tracker.comments.borrow().is_empty()); } diff --git a/apps/decodex/src/agent/tracker_tool_bridge/tools.rs b/apps/decodex/src/agent/tracker_tool_bridge/tools.rs index 9cc2bb619..4428e758c 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/tools.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/tools.rs @@ -7,12 +7,12 @@ use crate::{ ISSUE_LABEL_ADD_TOOL_NAME, ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_HANDOFF_TOOL_NAME, ISSUE_REVIEW_REPAIR_COMPLETE_TOOL_NAME, ISSUE_TERMINAL_FINALIZE_TOOL_NAME, - ISSUE_TRANSITION_TOOL_NAME, LabelArgs, PendingReviewAction, PendingReviewCompletion, - ProgressCheckpointArgs, ReviewCheckpointArgs, ReviewExecutionMode, ReviewHandoffArgs, - ReviewHandoffContext, ReviewPolicyPhase, ReviewPolicyStatus, RunCompletionDisposition, - TerminalFinalizeArgs, TrackerToolBridge, TransitionArgs, + ISSUE_TRANSITION_TOOL_NAME, LabelArgs, NormalizedProgressCheckpoint, PendingReviewAction, + PendingReviewCompletion, ProgressCheckpointArgs, ReviewCheckpointArgs, ReviewExecutionMode, + ReviewHandoffArgs, ReviewHandoffContext, ReviewPolicyPhase, ReviewPolicyStatus, + RunCompletionDisposition, TerminalFinalizeArgs, TrackerToolBridge, TransitionArgs, }, - state, + state::{self, StateStore}, tracker::{ self, records, records::{LinearExecutionEventIdentity, LinearExecutionEventRecord}, @@ -112,7 +112,7 @@ impl<'a> TrackerToolBridge<'a> { pub(super) fn progress_checkpoint_tool_specs(&self) -> [DynamicToolSpec; 1] { [DynamicToolSpec::new( ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, - "Record the current execution-state snapshot for the leased issue as a durable Linear-backed progress checkpoint without changing lifecycle authority. On retained lanes, omit `head_sha` to capture the exact current lane HEAD automatically, or pass a matching current-lane HEAD SHA.", + "Record the current execution-state snapshot for the leased issue as private runtime evidence, then publish only a low-frequency public Linear projection when the public lifecycle signal changes. On retained lanes, omit `head_sha` to capture the exact current lane HEAD automatically, or pass a matching current-lane HEAD SHA.", serde_json::json!({ "type": "object", "properties": { @@ -359,56 +359,140 @@ impl<'a> TrackerToolBridge<'a> { return DynamicToolCallResponse::failure(error); } - let phase = match ExecutionProgressPhase::parse(&parsed.phase) { - Ok(phase) => phase, + let checkpoint = match self.normalize_progress_checkpoint(parsed) { + Ok(checkpoint) => checkpoint, Err(error) => return DynamicToolCallResponse::failure(error), }; + let (review_context, state_store) = match self.progress_checkpoint_context() { + Ok(context) => context, + Err(error) => return DynamicToolCallResponse::failure(error), + }; + + if let Err(error) = + self.append_private_progress_checkpoint(review_context, state_store, &checkpoint) + { + return DynamicToolCallResponse::failure(error); + } + + let public_projection = + self.render_progress_checkpoint_projection(review_context, &checkpoint); + + match self.publish_progress_checkpoint_projection(state_store, &public_projection) { + Ok(true) => DynamicToolCallResponse::success(format!( + "Recorded private `{}` execution state for issue `{}` and published the public Linear projection.", + checkpoint.phase.as_str(), + self.issue.identifier + )), + Ok(false) => DynamicToolCallResponse::success(format!( + "Recorded private `{}` execution state for issue `{}`; public Linear projection is unchanged.", + checkpoint.phase.as_str(), + self.issue.identifier + )), + Err(error) => DynamicToolCallResponse::failure(error), + } + } + + fn normalize_progress_checkpoint( + &self, + parsed: ProgressCheckpointArgs, + ) -> Result { + let phase = ExecutionProgressPhase::parse(&parsed.phase)?; let focus = tracker_tool_bridge::normalize_summary(&parsed.focus); let next_action = tracker_tool_bridge::normalize_summary(&parsed.next_action); let blockers = tracker_tool_bridge::normalize_progress_list(parsed.blockers); let evidence = tracker_tool_bridge::normalize_progress_list(parsed.evidence); let verification = tracker_tool_bridge::normalize_progress_list(parsed.verification); - let head_sha = match self.resolve_progress_checkpoint_head_sha(parsed.head_sha) { - Ok(head_sha) => head_sha, - Err(error) => return DynamicToolCallResponse::failure(error), - }; + let head_sha = self.resolve_progress_checkpoint_head_sha(parsed.head_sha)?; let branch = tracker_tool_bridge::normalize_optional_progress_field(parsed.branch); let pr_url = tracker_tool_bridge::normalize_optional_progress_field(parsed.pr_url); if focus.is_empty() { - return DynamicToolCallResponse::failure(String::from( - "`issue_progress_checkpoint` requires a non-empty `focus`.", - )); + return Err(String::from("`issue_progress_checkpoint` requires a non-empty `focus`.")); } if next_action.is_empty() { - return DynamicToolCallResponse::failure(String::from( + return Err(String::from( "`issue_progress_checkpoint` requires a non-empty `next_action`.", )); } if phase == ExecutionProgressPhase::Blocked && blockers.is_empty() { - return DynamicToolCallResponse::failure(String::from( + return Err(String::from( "`issue_progress_checkpoint` phase `blocked` requires at least one blocker.", )); } - let Some(review_context) = self.review_context.as_ref() else { - return DynamicToolCallResponse::failure(String::from( - "`issue_progress_checkpoint` requires an active Decodex run context.", - )); - }; - let branch = branch.or_else(|| Some(review_context.branch_name.clone())); - let anchor = records::stable_event_anchor(&[ - phase.as_str(), - focus.as_str(), - next_action.as_str(), - head_sha.as_deref().unwrap_or_default(), - branch.as_deref().unwrap_or_default(), - pr_url.as_deref().unwrap_or_default(), - &blockers.join("\n"), - &evidence.join("\n"), - &verification.join("\n"), - ]); - let mut checkpoint = LinearExecutionEventRecord::new( + Ok(NormalizedProgressCheckpoint { + phase, + focus, + next_action, + blockers, + evidence, + verification, + head_sha, + branch, + pr_url, + }) + } + + fn progress_checkpoint_context(&self) -> Result<(&ReviewHandoffContext, &StateStore), String> { + let review_context = self.review_context.as_ref().ok_or_else(|| { + String::from("`issue_progress_checkpoint` requires an active Decodex run context.") + })?; + let state_store = self.state_store.ok_or_else(|| { + format!( + "`issue_progress_checkpoint` requires the Decodex runtime state store for issue `{}`.", + self.issue.identifier + ) + })?; + + Ok((review_context, state_store)) + } + + fn append_private_progress_checkpoint( + &self, + review_context: &ReviewHandoffContext, + state_store: &StateStore, + checkpoint: &NormalizedProgressCheckpoint, + ) -> Result<(), String> { + let branch = checkpoint.public_branch(review_context); + let private_payload = serde_json::json!({ + "phase": checkpoint.phase.as_str(), + "focus": checkpoint.focus.as_str(), + "next_action": checkpoint.next_action.as_str(), + "blockers": &checkpoint.blockers, + "evidence": &checkpoint.evidence, + "verification": &checkpoint.verification, + "head_sha": checkpoint.head_sha.as_deref(), + "branch": branch.as_str(), + "worktree_path": review_context.worktree_path.as_str(), + "pr_url": checkpoint.pr_url.as_deref(), + }); + + state_store + .append_private_execution_event( + &review_context.service_id, + &self.issue.id, + &review_context.run_id, + review_context.attempt_number, + "progress_checkpoint", + private_payload, + ) + .map(|_| ()) + .map_err(|error| { + format!( + "Failed to persist the private execution-state checkpoint for issue `{}`: {error}", + self.issue.identifier + ) + }) + } + + fn render_progress_checkpoint_projection( + &self, + review_context: &ReviewHandoffContext, + checkpoint: &NormalizedProgressCheckpoint, + ) -> LinearExecutionEventRecord { + let branch = checkpoint.public_branch(review_context); + + records::render_progress_checkpoint_public_projection( LinearExecutionEventIdentity { service_id: &review_context.service_id, issue_id: &self.issue.id, @@ -416,49 +500,65 @@ impl<'a> TrackerToolBridge<'a> { run_id: &review_context.run_id, attempt_number: review_context.attempt_number, }, - "progress_checkpoint", tracker_tool_bridge::current_timestamp(), - &anchor, - ); + checkpoint.phase.as_str(), + Some(branch.as_str()), + Some(review_context.worktree_path.as_str()), + checkpoint.pr_url.as_deref(), + ) + } - checkpoint.phase = Some(phase.as_str().to_owned()); - checkpoint.focus = Some(focus); - checkpoint.next_action = Some(next_action); - checkpoint.blockers = Some(blockers); - checkpoint.evidence = Some(evidence); - checkpoint.verification = Some(verification); - checkpoint.commit_sha = head_sha; - checkpoint.branch = branch; - checkpoint.worktree_path = Some(review_context.worktree_path.clone()); - checkpoint.pr_url = pr_url; - - match tracker::create_linear_execution_event_comment( + fn publish_progress_checkpoint_projection( + &self, + state_store: &StateStore, + public_projection: &LinearExecutionEventRecord, + ) -> Result { + if self.progress_checkpoint_projection_cached(state_store, public_projection)? { + return Ok(false); + } + + let comment_created = match tracker::create_linear_execution_event_comment( self.tracker, &self.issue.id, "", - &checkpoint, + public_projection, ) { - Ok(_) => { - if let Some(state_store) = self.state_store - && let Err(error) = state_store.record_linear_execution_event(&checkpoint) - { - return DynamicToolCallResponse::failure(format!( - "Failed to persist the local execution-state checkpoint for issue `{}`: {error}", - self.issue.identifier - )); - } - - DynamicToolCallResponse::success(format!( - "Recorded `{}` execution state for issue `{}`.", - phase.as_str(), + Ok(comment_created) => comment_created, + Err(error) => + return Err(format!( + "Failed to record an execution-state checkpoint for issue `{}`: {error}", self.issue.identifier - )) - }, - Err(error) => DynamicToolCallResponse::failure(format!( - "Failed to record an execution-state checkpoint for issue `{}`: {error}", + )), + }; + + state_store.record_linear_execution_event(public_projection).map_err(|error| { + format!( + "Failed to persist the public progress projection cache for issue `{}`: {error}", self.issue.identifier - )), - } + ) + })?; + + Ok(comment_created) + } + + fn progress_checkpoint_projection_cached( + &self, + state_store: &StateStore, + public_projection: &LinearExecutionEventRecord, + ) -> Result { + let records = state_store + .list_linear_execution_events( + &public_projection.service_id, + &public_projection.issue_id, + ) + .map_err(|error| { + format!( + "Failed to read the public progress projection cache for issue `{}`: {error}", + self.issue.identifier + ) + })?; + + Ok(records.iter().any(|record| record.idempotency_key == public_projection.idempotency_key)) } pub(super) fn handle_transition(&self, arguments: Value) -> DynamicToolCallResponse { diff --git a/apps/decodex/src/tracker/records.rs b/apps/decodex/src/tracker/records.rs index cd0692bc2..4b563c477 100644 --- a/apps/decodex/src/tracker/records.rs +++ b/apps/decodex/src/tracker/records.rs @@ -155,6 +155,33 @@ pub(crate) struct LinearExecutionEventIdentity<'a> { pub(crate) attempt_number: i64, } +/// Render the low-frequency public Linear projection for a private progress checkpoint. +pub(crate) fn render_progress_checkpoint_public_projection( + identity: LinearExecutionEventIdentity<'_>, + event_timestamp: String, + phase: &str, + branch: Option<&str>, + worktree_path: Option<&str>, + pr_url: Option<&str>, +) -> LinearExecutionEventRecord { + let anchor = stable_event_anchor(&[ + phase, + branch.unwrap_or_default(), + worktree_path.unwrap_or_default(), + pr_url.unwrap_or_default(), + ]); + let mut record = + LinearExecutionEventRecord::new(identity, "progress_checkpoint", event_timestamp, &anchor); + + record.phase = Some(phase.to_owned()); + record.branch = branch.map(ToOwned::to_owned); + record.worktree_path = worktree_path.map(ToOwned::to_owned); + record.pr_url = pr_url.map(ToOwned::to_owned); + record.summary = Some(format!("Execution phase: {phase}.")); + + record +} + pub(crate) fn format_structured_comment(record: &T) -> std::result::Result where T: Serialize, @@ -357,11 +384,9 @@ fn validate_linear_execution_event_fields( }, "progress_checkpoint" => { require_string(record.phase.as_deref(), "phase")?; - require_string(record.focus.as_deref(), "focus")?; - require_string(record.next_action.as_deref(), "next_action")?; - require_vec(record.blockers.as_ref(), "blockers")?; + require_string(record.summary.as_deref(), "summary")?; - require_vec(record.evidence.as_ref(), "evidence") + reject_progress_checkpoint_private_fields(record) }, "pr_opened" | "pr_updated" => validate_pr_event_fields(record), "review_handoff" | "repair_handoff" => { @@ -415,6 +440,29 @@ fn validate_linear_execution_event_fields( } } +fn reject_progress_checkpoint_private_fields( + record: &LinearExecutionEventRecord, +) -> Result<(), String> { + for field in [ + ("focus", record.focus.is_some()), + ("next_action", record.next_action.is_some()), + ("blockers", record.blockers.is_some()), + ("evidence", record.evidence.is_some()), + ("verification", record.verification.is_some()), + ("commit_sha", record.commit_sha.is_some()), + ] { + let (field_name, present) = field; + + if present { + return Err(format!( + "`progress_checkpoint` Linear events must use the public projection; `{field_name}` belongs in private execution events." + )); + } + } + + Ok(()) +} + fn validate_pr_event_fields(record: &LinearExecutionEventRecord) -> Result<(), String> { require_string(record.branch.as_deref(), "branch")?; require_string(record.pr_url.as_deref(), "pr_url")?; @@ -468,7 +516,7 @@ mod tests { use crate::tracker::records::{LinearExecutionEventIdentity, LinearExecutionEventRecord}; fn progress_record() -> LinearExecutionEventRecord { - let mut record = LinearExecutionEventRecord::new( + LinearExecutionEventRecord::new( LinearExecutionEventIdentity { service_id: "decodex", issue_id: "issue-id", @@ -479,50 +527,58 @@ mod tests { "progress_checkpoint", String::from("2026-05-25T00:00:00Z"), "anchor", - ); - - record.phase = Some(String::from("implementing")); - record.focus = - Some(String::from("Keep PR https://github.com/hack-ink/decodex/pull/42 reviewable.")); - record.next_action = Some(String::from( - "Update apps/decodex/src/tracker/records.rs on branch y/decodex-xy-519.", - )); - record.blockers = Some(Vec::new()); - record.evidence = Some(vec![String::from("Issue XY-519 remains scoped.")]); - - record + ) } #[test] - fn validates_public_progress_checkpoint_text() { - super::validate_linear_execution_event_record(&progress_record()) + fn validates_public_progress_checkpoint_projection() { + let record = super::render_progress_checkpoint_public_projection( + LinearExecutionEventIdentity { + service_id: "decodex", + issue_id: "issue-id", + issue_identifier: "XY-519", + run_id: "xy-519-attempt-1", + attempt_number: 1, + }, + String::from("2026-05-25T00:00:00Z"), + "implementing", + Some("y/decodex-xy-519"), + Some(".worktrees/XY-519"), + Some("https://github.com/hack-ink/decodex/pull/42"), + ); + + super::validate_linear_execution_event_record(&record) .expect("public collaboration identifiers should validate"); + + assert_eq!(record.summary.as_deref(), Some("Execution phase: implementing.")); + assert!(record.focus.is_none()); + assert!(record.next_action.is_none()); + assert!(record.evidence.is_none()); } #[test] - fn rejects_private_progress_checkpoint_text() { - for (field_name, private_text) in [ - ("focus", "Inspect /Users/example/code/private checkout."), - ("next_action", "Read codex.github-identity before pushing."), - ("blockers", "Missing LINEAR_API_KEY_HACKINK prevented tracker write."), - ("evidence", "Selected account user@example.com was active."), - ("verification", "Checked C:\\Users\\example\\repo."), - ] { + fn rejects_private_progress_checkpoint_fields_in_linear_projection() { + for field_name in ["focus", "next_action", "blockers", "evidence", "verification"] { let mut record = progress_record(); + record.phase = Some(String::from("implementing")); + record.summary = Some(String::from("Execution phase: implementing.")); + match field_name { - "focus" => record.focus = Some(String::from(private_text)), - "next_action" => record.next_action = Some(String::from(private_text)), - "blockers" => record.blockers = Some(vec![String::from(private_text)]), - "evidence" => record.evidence = Some(vec![String::from(private_text)]), - "verification" => record.verification = Some(vec![String::from(private_text)]), + "focus" => record.focus = Some(String::from("private focus")), + "next_action" => record.next_action = Some(String::from("private action")), + "blockers" => record.blockers = Some(vec![String::from("private blocker")]), + "evidence" => record.evidence = Some(vec![String::from("private evidence")]), + "verification" => { + record.verification = Some(vec![String::from("private verification")]); + }, _ => unreachable!("test field names are exhaustive"), } let error = super::validate_linear_execution_event_record(&record) - .expect_err("private free-text should not serialize"); + .expect_err("private progress fields should not serialize"); - assert!(error.contains("public/team-visible")); + assert!(error.contains("belongs in private execution events")); } } } diff --git a/docs/spec/linear-execution-ledger.md b/docs/spec/linear-execution-ledger.md index 2012add02..e94ab0dcc 100644 --- a/docs/spec/linear-execution-ledger.md +++ b/docs/spec/linear-execution-ledger.md @@ -71,11 +71,13 @@ in ledger records. ## Public text baseline Linear comments are public/team-visible tracker text. Before Decodex serializes a new -Linear execution event, free-text fields such as `summary`, `focus`, `next_action`, -`blockers`, `evidence`, `verification`, `failed_command`, and `raw_error` must pass the -baseline public-text guard. The guard rejects known structured leakage shapes, -including host-local paths, routed identity configuration details, credential-like -names, private account details, private config file names, emails, tokens, and secrets. +Linear execution event, public free-text fields such as `summary`, `next_action`, +`blockers`, `evidence`, `failed_command`, and `raw_error` must pass the baseline +public-text guard. The guard rejects known structured leakage shapes, including +host-local paths, routed identity configuration details, credential-like names, private +account details, private config file names, emails, tokens, and secrets. Current +`progress_checkpoint` events must render a public projection instead of copying raw +checkpoint `focus`, `next_action`, `blockers`, `evidence`, or `verification` text. The guard is a baseline structural stop, not the final privacy boundary. Full runtime evidence, local identity routing, account state, and high-frequency diagnostics must @@ -119,9 +121,9 @@ These fields are optional globally and become required for specific event types | `pr_base_ref` | string | PR base ref name when a PR exists or is the event subject. | | `summary` | string | Short human-readable summary of the event. | | `validation_result` | string | Repo-gate or PR validation result when validation is the event subject. | -| `phase` | string | Execution-state phase for progress checkpoint records. | -| `focus` | string | Current execution-state focus for progress checkpoint records. | -| `next_action` | string | Next execution action for progress checkpoint or failure records. | +| `phase` | string | Public execution-state phase for progress checkpoint records. | +| `focus` | string | Legacy progress focus field or private-runtime-only checkpoint input; current progress projections must not emit it. | +| `next_action` | string | Next execution action for failure records; current progress projections must not emit raw checkpoint next-action text. | | `blockers` | array of strings | Concrete blockers, empty when none are present. | | `evidence` | array of strings | Short factual evidence items. | | `verification` | array of strings | Verification commands or checks already run. | @@ -171,7 +173,7 @@ Every event requires the record envelope. Additional required fields are listed | `lease_acquired` | `branch` | `worktree_path`, `summary`; legacy read-only startup record | | `worktree_prepared` | `branch`, `worktree_path`, `commit_sha` | `summary`; legacy read-only startup record | | `agent_started` | `branch`, `worktree_path` | `transport`, `summary`; legacy read-only startup record | -| `progress_checkpoint` | `phase`, `focus`, `next_action`, `blockers`, `evidence` | `branch`, `worktree_path`, `commit_sha`, `pr_url`, `verification`, `summary` | +| `progress_checkpoint` | `phase`, `summary` | `branch`, `worktree_path`, `pr_url` | | `pr_opened` | `branch`, `pr_url`, `pr_head_sha`, `pr_base_ref`, `commit_sha` | `worktree_path`, `summary` | | `pr_updated` | `branch`, `pr_url`, `pr_head_sha`, `pr_base_ref`, `commit_sha` | `worktree_path`, `summary` | | `review_handoff` | `branch`, `worktree_path`, `pr_url`, `pr_head_sha`, `pr_base_ref`, `commit_sha`, `validation_result`, `summary`, `terminal_path` | `verification` | @@ -195,8 +197,10 @@ ran, and must not be emitted automatically from `decodex run`. ## Progress checkpoint records -`progress_checkpoint` records are the Linear form of durable execution memory. They -preserve task-local progress without changing lifecycle authority. +`progress_checkpoint` records are the Linear public projection of private durable +execution memory. They expose low-frequency lifecycle progress without changing +lifecycle authority. The full checkpoint payload from `issue_progress_checkpoint` +lives in private runtime execution events, not in Linear. Required `phase` values are the same normalized phases accepted by `issue_progress_checkpoint`: @@ -214,6 +218,19 @@ Required `phase` values are the same normalized phases accepted by completion, merge readiness, closeout, cleanup completion, or terminal success. Those transitions require their dedicated event type and the governing runtime/tool contract. +Current progress projections must contain only the allowlisted public fields in the +event table above. They must not emit raw `focus`, `next_action`, `blockers`, +`evidence`, `verification`, local head evidence, host-local paths, routed identity +details, account details, token names, or other private runtime evidence. Producers must +render a short public `summary` from the public lifecycle signal, for example the +normalized phase, instead of copying agent-authored checkpoint prose. + +Progress projection idempotency is anchored to the material public signal, such as the +normalized phase plus public branch/worktree/PR projection anchor. Retrying a checkpoint +or adding new private focus, next-action, evidence, blocker, or verification details +inside the same public signal must append private runtime evidence without adding a new +Linear comment. + ## Ledger-only comment contract Decodex writes and reads durable execution outcomes through @@ -252,7 +269,7 @@ commit SHA, PR head SHA, terminal path, or checkpoint sequence key. Use Linear comments for team-visible, low-frequency records: - lane run start, including branch, worktree, current commit, and transport -- durable progress checkpoints +- public progress checkpoint projections - PR opened or updated events - review handoff and retained repair handoff - landed, closeout, needs-attention, terminal failure, and cleanup-complete events @@ -271,6 +288,8 @@ runtime telemetry: - token counts, largest tool-output sizes, and context-pressure warnings - review-policy convergence counters that only guide the current retained-lane retry loop +- full `issue_progress_checkpoint` payloads, including raw focus, next action, + blockers, evidence, verification, and local head evidence - transient diagnostic details that help the local operator understand whether an active process is busy, idle, or stalled diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 894256519..8074ad405 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -209,6 +209,27 @@ If the resolved terminal path is not explicitly finalized through `issue_termina 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. 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 + +`issue_progress_checkpoint` is private-first. Each accepted call appends the full +normalized checkpoint payload to `private_execution_events` in the runtime SQLite +database before attempting any Linear write. The private payload includes phase, focus, +next action, blockers, evidence, verification, resolved lane head, branch, +repository-relative worktree path, and PR URL when present. + +Linear receives only the public projection of that checkpoint. The projection is a +`decodex.linear_execution_event` with `event_type = "progress_checkpoint"` and only +allowlisted public fields such as phase, summary, branch, repository-relative worktree +path, and PR URL. Raw checkpoint focus, next action, blockers, evidence, verification, +local head evidence, host-local paths, identity-routing details, account details, and +token names must stay out of Linear. + +The local `linear_execution_events` table remains the public mirror cache for rendered +Linear records. It is not the private evidence source. Repeated checkpoint calls that +only change private payload fields must append private execution events but must not +append new Linear comments. A new Linear progress projection is written only when the +material public lifecycle signal changes. + When `decodex` runs the repo-native gate during `validating`, it must preserve the repo-gate failure class instead of collapsing everything into one generic failure bucket: - `canonicalize_commands` non-zero exit: continued repair in the retained lane diff --git a/docs/spec/tracker-tools.md b/docs/spec/tracker-tools.md index 3a73c481f..471fda4f3 100644 --- a/docs/spec/tracker-tools.md +++ b/docs/spec/tracker-tools.md @@ -49,7 +49,7 @@ The follow-up MVP should support these issue-scoped operations: - add an exceptional human-readable comment to the current issue for manual-attention blockers or explicit collaboration notes - `issue_progress_checkpoint` - - record the current durable execution-state snapshot for the current issue without changing lifecycle authority + - 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` - record the normalized repo-native bounded-review result for the current handoff or repair phase - `issue_review_handoff` @@ -89,10 +89,23 @@ In either invalid case, `decodex` must fail the attempt rather than infer which - The tool bridge should reject transitions that violate the current repo workflow contract. - Generic `issue_transition` must not move the current issue directly into the configured success state. - `issue_progress_checkpoint` is available during any owned run phase, including retained repair and closeout runs. -- `issue_progress_checkpoint` must keep the routed issue description generic; the durable execution-state payload belongs in issue-scoped checkpoint comments, not in the description. +- `issue_progress_checkpoint` must keep the routed issue description generic. The full + structured checkpoint payload belongs in private runtime execution events, not in + the issue description or Linear comments. - `issue_progress_checkpoint` must accept only the normalized execution phases `probing`, `implementing`, `verifying`, `blocked`, `ready_for_review`, `review_repair`, `ready_to_land`, and `closeout`. - `issue_progress_checkpoint` must not replace `issue_review_checkpoint`, `issue_review_handoff`, `issue_review_repair_complete`, `issue_closeout_complete`, or `issue_terminal_finalize`. - `decodex` treats `issue_progress_checkpoint` as execution memory only. Checkpoint phase, focus, next action, blockers, or evidence do not by themselves authorize review handoff, repair completion, merge, closeout, or terminal success. +- `issue_progress_checkpoint` must persist the full normalized checkpoint payload to + `private_execution_events` before attempting any Linear write. +- The Linear-facing checkpoint record is only a public projection. It may include the + ledger envelope, `phase`, `summary`, `branch`, repository-relative `worktree_path`, + and `pr_url`. It must not include raw `focus`, `next_action`, `blockers`, + `evidence`, `verification`, local head evidence, host-local paths, identity-routing + details, account details, token names, or other private runtime evidence. +- `issue_progress_checkpoint` must publish a new Linear projection only when the + public lifecycle signal changes materially, such as the normalized phase or public + branch/PR projection anchor changing. Repeated private evidence updates inside the + same public signal must append private runtime events without adding Linear comments. - `issue_review_checkpoint` is available only when `codex.internal_review_mode = "loop"`, and only during the pre-PR handoff phase and retained review-repair runs; `closeout` does not expose it. - `issue_review_checkpoint` must accept only these normalized statuses: `clean`, `findings`, `needs_architecture_review`, `blocked`. - `issue_review_checkpoint` must bind every checkpoint to an explicit `head_sha` for the currently reviewed lane head. @@ -117,19 +130,22 @@ In either invalid case, `decodex` must fail the attempt rather than infer which - Comment bodies should remain repository-controlled or agent-authored, but 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, and ordinary progress uses `issue_progress_checkpoint` + `run_started` ledger record. Ordinary progress uses `issue_progress_checkpoint` only when execution phase, focus, next action, blockers, evidence, or verification - changes materially. + changes materially, but Linear receives only the safe public projection for material + public lifecycle changes. - Structured Linear execution event comments must conform to [`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` and `issue_progress_checkpoint` text is public/team-visible. Before - either tool writes to Linear, Decodex must reject known leakage-shaped text such as - host-local paths, routed identity details, credential-like names, private account - details, private config file names, emails, tokens, or secrets. This baseline guard - does not replace the longer-term local-private ledger boundary; detailed runtime - evidence remains local/operator-only. +- `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 + local/operator-only. - Dynamic tool names must satisfy the `codex app-server` identifier restriction `^[a-zA-Z0-9_-]+$`; dotted names are invalid. ## Failure handling