//! Task-card parser — Slice 5.
//!
//! Watches `run_events` payloads for the INT-XX marker protocol
//! (see skills/foundation/int-xx-marker-protocol.md) and materializes
//! `mission_tasks` rows with typed state so the mission canvas Tasks
//! tab can render a status timeline.
//!
//! Recognized markers (case-sensitive, on their own line):
//! TASK: INT-NN —
→ status='created'
//! WORK: INT-NN → status='working'
//! HANDOFF: INT-NN → status='validating'
//! TEST_PASS: INT-NN → status='validating' (unchanged if already validating+)
//! TEST_FAIL: INT-NN — → status='failed'
//! REVIEW_APPROVE: INT-NN → status='validating'
//! REVIEW_BLOCK: INT-NN — → status='failed'
//! COMPLETED: INT-NN → status='complete'
//!
//! Uses UPSERT keyed on (phase_id, external_id) so the same INT
//! progressing through states updates a single row; state transitions
//! are monotonic where reasonable (once complete, only failed can
//! demote — but the loop-once semantics of INT items mean this rarely
//! matters in practice).
use serde_json::Value;
use sqlx::PgPool;
use sqlx::Row;
use uuid::Uuid;
use cm_db::repo::missions::UpsertTask;
/// A parsed marker from a single run-event payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Marker {
pub int_id: String, // "INT-05"
pub kind: MarkerKind,
pub title: Option,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MarkerKind {
Task,
PlanComplete,
Work,
Handoff,
TestPass,
TestFail,
ReviewApprove,
ReviewBlock,
Completed,
}
impl MarkerKind {
/// The status this marker implies. Preferences monotonic forward
/// motion — the UPSERT layer may still overwrite prior states.
pub fn status(&self) -> &'static str {
match self {
// The planner finished specifying; no work has started, so the item
// is in the same state a fresh TASK leaves it in. A distinct status
// would need a column value the UI does not render, and inventing
// one to look complete is how a status stops meaning anything.
MarkerKind::Task | MarkerKind::PlanComplete => "created",
MarkerKind::Work => "working",
MarkerKind::Handoff | MarkerKind::TestPass | MarkerKind::ReviewApprove => "validating",
MarkerKind::TestFail | MarkerKind::ReviewBlock => "failed",
MarkerKind::Completed => "complete",
}
}
}
/// Line-scanner over the raw text of a run's output. Cheap enough to
/// re-run per event; the UPSERT layer collapses duplicates.
pub fn parse(text: &str) -> Vec {
let mut out = Vec::new();
for line in text.lines() {
let trimmed = line.trim();
if let Some(m) = parse_line(trimmed) {
out.push(m);
}
}
out
}
/// `INT-` followed by at least one digit and nothing else.
fn is_int_id(id: &str) -> bool {
match id.strip_prefix("INT-") {
Some(rest) => !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()),
None => false,
}
}
fn parse_line(line: &str) -> Option {
// Match `: INT-NN` (rest optional). Strict on the colon and
// the INT- prefix — anything laxer starts matching prose.
let (kind_str, rest) = line.split_once(':')?;
let kind = match kind_str.trim() {
"TASK" => MarkerKind::Task,
// Documented in `skills/foundation/int-xx-marker-protocol.md` since the
// skill was written, and never implemented here. Agents that followed
// the skill exactly emitted it and were silently ignored — observed on
// a live mission, found by the Skill-Use measurement. Implemented
// rather than removed from the skill: the planner needs a way to say
// it is done specifying, and agents already emit this one.
"PLAN_COMPLETE" => MarkerKind::PlanComplete,
"WORK" => MarkerKind::Work,
"HANDOFF" => MarkerKind::Handoff,
"TEST_PASS" => MarkerKind::TestPass,
"TEST_FAIL" => MarkerKind::TestFail,
"REVIEW_APPROVE" => MarkerKind::ReviewApprove,
"REVIEW_BLOCK" => MarkerKind::ReviewBlock,
"COMPLETED" => MarkerKind::Completed,
_ => return None,
};
let rest = rest.trim();
let (id_tok, tail) = match rest.split_once(char::is_whitespace) {
Some((a, b)) => (a, Some(b.trim())),
None => (rest, None),
};
let int_id = id_tok.trim_end_matches(&[',', ';', '.'][..]).to_string();
// Strictly `INT-`. `starts_with("INT-")` alone accepted range forms
// like `INT-01..02`, which parse into an id matching no real item — so a
// task card appeared for something that did not exist while the two items
// it was meant to cover stayed open. Observed live. Rejecting is right:
// the marker is ignored, which is visible, instead of creating a plausible
// row, which is not.
if !is_int_id(&int_id) {
return None;
}
// Title: after the id + any of ` — / – / - ` separators
let title = tail.and_then(|t| {
let t = t.trim_start_matches(['—', '–', '-', ':'].as_slice()).trim();
if t.is_empty() {
None
} else {
Some(t.to_string())
}
});
Some(Marker {
int_id,
kind,
title,
})
}
/// Scan every event of a topology_run that has a mission_id set;
/// parse markers out of the event payload's `text` / `output` fields;
/// UPSERT each into `mission_tasks`.
///
/// Idempotent: re-running against the same run's events collapses
/// into the current-truth state (last marker per INT wins per pass).
pub async fn apply_for_run(pool: &PgPool, run_id: Uuid) -> Result {
// Load the run + its mission binding. Bail cheaply if no mission.
let run = sqlx::query(
"SELECT mission_id, mission_phase_id
FROM topology_runs WHERE id = $1",
)
.bind(run_id)
.fetch_optional(pool)
.await
.map_err(|e| format!("load run: {e}"))?;
let Some(run) = run else {
return Ok(0);
};
let mission_id: Option = run.try_get("mission_id").ok().flatten();
let phase_id: Option = run.try_get("mission_phase_id").ok().flatten();
let (Some(mission_id), Some(phase_id)) = (mission_id, phase_id) else {
return Ok(0);
};
// Read event payloads in order.
let rows = sqlx::query(
"SELECT payload
FROM run_events WHERE run_id = $1
ORDER BY seq ASC",
)
.bind(run_id)
.fetch_all(pool)
.await
.map_err(|e| format!("load events: {e}"))?;
let mut all_markers: Vec = Vec::new();
for r in rows {
let payload: Value = r.get("payload");
for text in extract_text_fields(&payload) {
all_markers.extend(parse(&text));
}
}
let mut applied = 0usize;
for m in &all_markers {
let title = m.title.clone().unwrap_or_else(|| m.int_id.clone());
cm_db::repo::missions::upsert_task(
pool,
UpsertTask {
mission_id,
phase_id,
external_id: &m.int_id,
title: &title,
assigned_agent_id: None,
status: m.kind.status(),
run_id: Some(run_id),
},
)
.await
.map_err(|e| format!("upsert_task {}: {e}", m.int_id))?;
applied += 1;
}
Ok(applied)
}
/// Walk a payload's JSON tree and yield every string field named
/// `text`, `output`, `content`, or `message`. Handles the shapes we
/// see across ZeroClaw events (chunk / tool_result / done / final).
fn extract_text_fields(v: &Value) -> Vec {
fn walk(v: &Value, out: &mut Vec) {
match v {
Value::Object(map) => {
for (k, child) in map {
if matches!(k.as_str(), "text" | "output" | "content" | "message") {
if let Value::String(s) = child {
out.push(s.clone());
}
}
walk(child, out);
}
}
Value::Array(items) => {
for item in items {
walk(item, out);
}
}
_ => {}
}
}
let mut out = Vec::new();
walk(v, &mut out);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_completed_marker() {
let out = parse("COMPLETED: INT-05");
assert_eq!(out.len(), 1);
assert_eq!(out[0].int_id, "INT-05");
assert_eq!(out[0].kind, MarkerKind::Completed);
}
#[test]
fn parses_task_with_title() {
let out = parse("TASK: INT-12 — wire the loader");
assert_eq!(out[0].kind, MarkerKind::Task);
assert_eq!(out[0].title.as_deref(), Some("wire the loader"));
}
#[test]
fn parses_test_fail_with_reason() {
let out = parse("TEST_FAIL: INT-03 — flake in async_setup");
assert_eq!(out[0].kind, MarkerKind::TestFail);
assert_eq!(out[0].title.as_deref(), Some("flake in async_setup"));
}
#[test]
fn ignores_marker_inside_prose() {
// Not on its own line + not starting with the exact kind:
let out = parse("we should not TASK: INT-05 like this");
assert!(out.is_empty());
}
#[test]
fn scans_multiline_batch() {
let src = "TASK: INT-01 — foo\nWORK: INT-01\nother text\nCOMPLETED: INT-01\n";
let out = parse(src);
assert_eq!(out.len(), 3);
assert_eq!(out[2].kind, MarkerKind::Completed);
}
}