Task — engineering-spec@1

"Cycle-review draft generator — CUO/COO-persona LLM compose at cycle close with completion stats, blocker recap, and editable memory draft"

doneTASK-PROJ-012
module proj · class product · priority p0 · created 2026-05-16 · shipped null
depends on TASK-PROJ-004, TASK-CUO-101 · blocks none

§1 — Description (BCP-14 normative)

The cycle-review generator MUST compose a draft review at every cycle close. The contract:

  1. MUST schedule a cycle-close detector running every 15 minutes; identifies cycles whose ends_at < NOW() and review_drafted_at IS NULL; queues review generation for each.
  2. MUST compute CycleStats for each detected cycle:
  1. MUST compose a markdown draft via TASK-AI-014 COO persona using a fixed prompt template: ``` You are the Chief Operations Officer reviewing cycle "{{cycle_name}}" ({{cycle_id}}). Cycle stats: {{stats_json}}. Top incomplete issues: {{incomplete_list}}. Stale blockers: {{stale_blockers}}. Compose a 200-400 word review covering:
  2. Did we hit the cycle goal?
  3. What slowed us down?
  4. One specific learning per stale-blocker pattern.
  5. Recommendation for next cycle's priorities. Tone: factual, constructive, Vietnamese-context-aware. Avoid blame. ```
  6. MUST save the draft as a memory memory at memories/projects/<engagement_id>/cycle-reviews/<cycle_id>.md with frontmatter {kind: cycle_review, status: draft, sync_class: shareable, generated_by_persona: chief-operating-officer@<version>, generated_at: <iso>}. The body is markdown.
  7. MUST emit proj.cycle_review_drafted memory audit row with payload {cycle_id, engagement_id, memory_path, draft_byte_count, persona_version, generated_at_ns, trace_id}.
  8. MUST NEVER auto-promote draft to status: accepted. Operator (COO) edits + accepts via UI / CLI; acceptance flips frontmatter status and emits proj.cycle_review_accepted row.
  9. MUST redact PII in input context via TASK-MEMORY-111 before LLM call (issue descriptions may contain customer info; redact before composition).
  10. MUST expose REST POST /api/proj/cycles/:id/review-draft for force re-generation (operator can iterate); preserves all prior drafts as memory revisions.
  11. MUST handle LLM failure gracefully: AI Gateway 5xx → retry 3× with exp backoff; final failure → save stats-only stub draft (no prose) + sev-3 alarm; operator iterates manually.
  12. MUST RLS-enforce.
  13. MUST emit OTel metrics:
  1. MUST support locale-aware drafts: cyberos_proj_engagement_settings.cycle_review_locale (default vi-VN); prompt template includes the locale instruction so VN engagements get Vietnamese drafts.
  2. MUST include TASK-PROJ-013 estimate-vs-actual delta in stats: velocity_estimate_vs_actual_ratio (estimate_points / actual_hours). Operators see whether estimates were optimistic / pessimistic.
  3. MUST include "Top 5 longest-in-status issues" list in the input context: issues that spent the most cumulative time in any non-Done status, with status name + days.
  4. MUST include TASK-PROJ-011 stale-blocker recap by category: e.g. "3 blockers stale on customer feedback, 2 on design review." Helps operator identify systemic patterns.
  5. MUST support per-engagement persona override: cyberos_proj_engagement_settings.cycle_review_persona = "coo"|"pm"|"engineering_lead". Different engagements may want different review voice.
  6. MUST emit a proj.cycle_review_iterated audit row each time the operator re-generates a draft; payload includes iteration_count + reason (optional).
  7. MUST preserve all prior draft revisions as memory memory revisions (TASK-MEMORY-101 versioning); the latest is the canonical draft; operators can revert to a prior revision.
  8. MUST include comparison_to_prior_cycle section: if the prior cycle's review exists + was accepted, the prompt includes its stats for delta context ("velocity improved 15% over prior cycle").
  9. MUST redact LLM response too (not just input) before memory save — LLM may echo or hallucinate PII from training data.
  10. MUST support "skip cycle" annotation: operator marks a cycle "no review needed" (e.g. cycle with 1 issue, vacation week); skips draft generation. Audit row proj.cycle_review_skipped with reason.
  11. MUST include cycle's learnings section if the cycle had learnings field populated during the cycle (TASK-PROJ-014 kanban allows operator to add running learnings); concatenated into the prompt input.

§2 — Why this design (rationale for humans)

Why auto-draft (DEC-331)? COO time-on-review consistently runs 60-90min per cycle. A draft cuts to 15min edit. At 10 engagements × 26 cycles/year, that's 200 hours/year saved.

Why never auto-accept (DEC-331)? Reviews are quality artifacts — LLM-generated text needs human read. Auto-accept turns reviews into noise; manual accept keeps quality.

Why save as memory memory (DEC-332)? Reviews are knowledge — searchable, citable, referenced in future cycle planning. Saving to memory with sync_class: shareable makes them findable via TASK-MEMORY-108 search.

Why fixed prompt template (§1 #3)? Drift-prone if free-form. Fixed template = consistent structure across reviews → operators read faster.

Why fail-graceful with stats-only stub (§1 #9)? AI Gateway outage shouldn't block cycle close. Stats stub gives operator the numbers; they write the prose manually if needed.

Why force re-generate endpoint (§1 #8)? Operator finds draft poor → iterates with edits to underlying stats / context → re-runs. Each iteration is a new memory revision (history preserved).

Why redact before LLM (§1 #7)? External LLM provider (Anthropic/OpenAI) shouldn't see customer PII. TASK-MEMORY-111 redaction is the boundary. ZDR contract (TASK-AI-015) ensures even after-redaction data isn't retained.

Why locale-aware (§1 #12)? VN-team reviews in Vietnamese, English-team in English. Wrong locale = friction.

Why estimate-vs-actual ratio (§1 #13)? Operators tracking estimate accuracy need the ratio explicitly; baseline observation for TASK-PROJ-013 calibration.

Why top-5 longest-in-status (§1 #14)? Outliers tell the story; aggregates don't. The operator wants "this issue spent 12 days in review" not "average review time was 3 days."

Why blocker recap by category (§1 #15)? Pattern detection: 3 blockers all "waiting for customer" = sales-process issue; 3 different categories = mixed bag.

Why per-engagement persona (§1 #16)? Internal-engineering retro voice differs from client-facing review tone. Persona override respects audience.

Why iteration audit (§1 #17)? Operators iterating drafts produce N revisions; tracking the iteration count surfaces "did the COO have to rewrite 5 times?"

Why preserve all revisions (§1 #18)? Comparison across iterations shows the editing pattern; revert is a safety net.

Why comparison to prior cycle (§1 #19)? Context is the value-add: "velocity improved 15%" beats raw "velocity 32 points."

Why redact LLM response (§1 #20)? Defence in depth; LLM may hallucinate PII not in input (training data leak).

Why skip-cycle annotation (§1 #21)? Vacation weeks / 1-issue cycles generating drafts is noise. Explicit skip is the operator override.

Why include running learnings (§1 #22)? Operators capturing insights during the cycle (not just at close) get them reflected in the draft.


§3 — API contract

CycleStats struct

// services/proj-sync/src/cycle_review/mod.rs
#[derive(Clone, Debug, Serialize)]
pub struct CycleStats {
    pub cycle_id:                  uuid::Uuid,
    pub completed_count:           i32,
    pub incomplete_count:          i32,
    pub cancelled_count:           i32,
    pub velocity_estimate_points:  i32,
    pub velocity_actual_hours:     f64,
    pub blocker_count_resolved:    i32,
    pub blocker_count_stale:       i32,
    pub time_in_status_p50:        std::collections::HashMap<String, f64>,   // status → median dwell hours
}

Stats computation

// services/proj-sync/src/cycle_review/stats.rs
pub async fn compute(pool: &sqlx::PgPool, cycle_id: uuid::Uuid) -> anyhow::Result<CycleStats> {
    let counts = sqlx::query!(
        "SELECT
            COUNT(*) FILTER (WHERE status = 'done')      AS completed,
            COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled,
            COUNT(*) FILTER (WHERE status NOT IN ('done', 'cancelled')) AS incomplete,
            COALESCE(SUM(estimate) FILTER (WHERE status = 'done'), 0)::int4 AS velocity_points
         FROM issues WHERE cycle_id = $1",
        cycle_id
    ).fetch_one(pool).await?;

    let hours = sqlx::query_scalar!(
        "SELECT COALESCE(SUM(duration_minutes), 0)::float8 / 60.0
         FROM time_entries te JOIN issues i ON i.id = te.issue_id
         WHERE i.cycle_id = $1 AND te.billable_snapshot = true",
        cycle_id
    ).fetch_one(pool).await?.unwrap_or(0.0);

    let resolved = sqlx::query_scalar!(
        "SELECT COUNT(*)::int4 FROM blocker_state bs JOIN issues i ON i.id = bs.issue_id
         WHERE i.cycle_id = $1 AND bs.resolved_at IS NOT NULL", cycle_id
    ).fetch_one(pool).await?.unwrap_or(0);

    let stale = sqlx::query_scalar!(
        "SELECT COUNT(*)::int4 FROM blocker_state bs JOIN issues i ON i.id = bs.issue_id
         WHERE i.cycle_id = $1 AND bs.resolved_at IS NULL", cycle_id
    ).fetch_one(pool).await?.unwrap_or(0);

    let time_in_status_p50 = compute_status_dwell_medians(pool, cycle_id).await?;

    Ok(CycleStats {
        cycle_id,
        completed_count: counts.completed.unwrap_or(0) as i32,
        incomplete_count: counts.incomplete.unwrap_or(0) as i32,
        cancelled_count: counts.cancelled.unwrap_or(0) as i32,
        velocity_estimate_points: counts.velocity_points.unwrap_or(0),
        velocity_actual_hours: hours,
        blocker_count_resolved: resolved,
        blocker_count_stale: stale,
        time_in_status_p50,
    })
}

Composer

// services/proj-sync/src/cycle_review/compose.rs
pub async fn compose(
    pool: &sqlx::PgPool,
    cycle: &Cycle,
    stats: &CycleStats,
) -> anyhow::Result<String> {
    let incomplete = list_incomplete_issues(pool, cycle.id).await?;
    let stale_blockers = list_stale_blockers(pool, cycle.id).await?;

    // PII redaction (TASK-MEMORY-111)
    let redacted_incomplete = redact_titles(&incomplete);
    let redacted_blockers   = redact_blockers(&stale_blockers);

    let prompt = format!(r#"
You are the Chief Operations Officer reviewing cycle "{}" ({}).

Cycle stats:
{}

Top incomplete issues:
{}

Stale blockers:
{}

Compose a 200-400 word review covering:
1. Did we hit the cycle goal?
2. What slowed us down?
3. One specific learning per stale-blocker pattern.
4. Recommendation for next cycle's priorities.

Tone: factual, constructive, Vietnamese-context-aware. Avoid blame.
"#, cycle.name, cycle.id,
    serde_json::to_string_pretty(stats)?,
    format_incomplete_list(&redacted_incomplete),
    format_blockers_list(&redacted_blockers));

    let resp = ai_gateway::chat_complete(ChatRequest {
        persona: "coo".into(),
        prompt,
        max_tokens: 800,
        ..Default::default()
    }).await?;

    Ok(resp.text)
}

Save as memory

pub async fn save_draft(
    cycle: &Cycle,
    body: String,
    persona_version: &str,
) -> anyhow::Result<String> {
    let memory_path = format!(
        "memories/projects/{}/cycle-reviews/{}.md",
        cycle.engagement_id, cycle.id
    );
    let frontmatter = format!(r#"---
kind: cycle_review
sync_class: shareable
cycle_id: {}
engagement_id: {}
generated_by_persona: {}
generated_at: {}
---
"#, cycle.id, cycle.engagement_id, persona_version,
    chrono::Utc::now().to_rfc3339());

    let full = format!("{frontmatter}\n{body}");
    memory_writer::put_memory(&memory_path, full.as_bytes()).await?;
    Ok(memory_path)
}

§4 — Acceptance criteria

  1. Cycle close triggers draft — cycle.ends_at < NOW + 15min poll → draft generated.
  2. Stats accurate — fixture: 5 done, 2 incomplete, 1 cancelled → counts match.
  3. Velocity points computed — sum of completed-issues' estimate field.
  4. Velocity hours from billable time entries — only billable_snapshot = true entries counted.
  5. Blocker counts split active vs resolved — accurate per TASK-PROJ-011 state.
  6. Time-in-status p50 computed — 10 issues went through in_progress for various durations → median present.
  7. Draft saved at canonical path — memory at memories/projects/<eng>/cycle-reviews/<cycle>.md.
  8. Frontmatter status: draft — never auto-accepted.
  9. memory audit cycle_review_drafted — row appears post-save.
  10. PII redacted before LLM — fixture title with email → redacted in prompt sent to AI Gateway.
  11. Force re-generate endpoint — POST → new draft saved as memory revision.
  12. LLM 5xx retries — mock gateway 503 thrice → retries with backoff.
  13. LLM permanent failure → stub — mock 503 four times → stats-only stub draft saved; sev-3 alarm.
  14. Acceptance flips status — operator UI accept → frontmatter status changes to accepted; cycle_review_accepted row.
  15. RLS isolates — tenant A can't see tenant B's reviews.
  16. OTel metric proj_cycle_reviews_drafted_total — counter increments.
  17. Acceptance-minutes histogram — time from draft to accept measured.
  18. Locale-aware draft — engagement vi-VN → draft in Vietnamese; en-US → English (AC for §1 #12).
  19. Estimate-vs-actual ratio in stats — completed issues have estimate sum 40, actual 50 → ratio=0.8 (AC for §1 #13).
  20. Top-5 longest-in-status listed — fixture issues with varying dwell → top 5 in stats (AC for §1 #14).
  21. Blocker recap by category — fixture 3 customer-feedback blockers → grouped in input (AC for §1 #15).
  22. Per-engagement persona override — set persona=pm; observe persona_version=pm@... in audit (AC for §1 #16).
  23. Iteration audit emitted — force re-gen → proj.cycle_review_iterated row with iteration_count (AC for §1 #17).
  24. All revisions preserved — 3 re-generations → 3 memory memory revisions; latest = canonical (AC for §1 #18).
  25. Comparison to prior cycle in prompt — prior accepted cycle stats included if available (AC for §1 #19).
  26. LLM response redacted — fixture LLM output containing PII → memory memory has redacted form (AC for §1 #20).
  27. Skip-cycle annotation honoured — operator marks skip → no draft generated; audit row emitted (AC for §1 #21).
  28. Cycle learnings included — operator added learnings during cycle → included in prompt (AC for §1 #22).

§5 — Verification

#[tokio::test]
async fn cycle_close_generates_draft() {
    let env = TestEnv::new().await;
    let cycle = env.create_cycle_ending_yesterday().await;
    env.add_completed_issues(cycle, 5).await;
    env.run_close_detector().await;
    let mem = env.memory.read_memory(&format!(
        "memories/projects/{}/cycle-reviews/{}.md", cycle.engagement_id, cycle.id
    )).await.unwrap();
    assert!(mem.contains("status: draft"));
    assert!(mem.len() > 500);
}

#[tokio::test]
async fn pii_redacted_before_llm() {
    let env = TestEnv::new().await;
    let cycle = env.create_cycle().await;
    env.add_issue_with_title_containing_email(cycle).await;
    let mock = env.ai_gateway.spy().await;
    env.run_close_detector().await;
    let prompt = mock.last_prompt().await;
    assert!(!prompt.contains("alice@cyberskill.world"));
    assert!(prompt.contains("<EMAIL>"));
}

#[tokio::test]
async fn llm_failure_yields_stub() {
    let env = TestEnv::new().await;
    env.ai_gateway.always_503().await;
    let cycle = env.create_cycle().await;
    env.run_close_detector().await;
    let mem = env.memory.read_memory_for_cycle(cycle.id).await.unwrap();
    assert!(mem.contains("status: draft"));
    assert!(mem.contains("AI Gateway unavailable"));   // stub body marker
    assert!(mem.contains("completed_count"));           // stats present
}

#[tokio::test]
async fn force_regenerate() {
    let env = TestEnv::new().await;
    let cycle = env.create_cycle_with_existing_draft().await;
    let _ = post_review_draft(cycle.id).await.unwrap();
    // Verify revision count
    let revisions = env.memory.memory_revisions_for_cycle(cycle.id).await;
    assert!(revisions.len() >= 2);
}

§6 — Implementation skeleton

(API + components above.)


§7 — Dependencies


§8 — Example payloads

{
  "kind": "proj.cycle_review_drafted",
  "payload": {
    "cycle_id": "cyc-...",
    "engagement_id": "eng-...",
    "memory_path": "memories/projects/eng-.../cycle-reviews/cyc-....md",
    "draft_byte_count": 2418,
    "persona_version": "coo@1.0.0",
    "generated_at_ns": 1747407137483000000,
    "trace_id": "0af..."
  }
}

§9 — Open questions

All resolved. Deferred:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
AI Gateway 5xxretry exhaustedstats-only stub; sev-3Operator iterates manually
Cycle not closed yetscheduler conditionskipped; checked next pollNone
Cycle deleted mid-generationsqlx Err on readtask abort; loggedNone
PII redactor crashcatch_unwindredacted body marked <SCAN_FAILED>; LLM still called with safer placeholderOperator updates ruleset
memory put_memory failssqlx Errdraft body NOT saved; sev-2Operator retries via force endpoint
Concurrent close detect for same cyclerow-level lock on cycles tableOne generates; other no-opNone
Operator never acceptsacceptance-minutes histogram tailVisible in metricsOperator nudge via CUO
Force-regenerate races acceptancesecond draft saved as revision; status reverts to draftOperator re-acceptsNone
LLM returns < 200 wordsaccept anyway (content may still be useful)NoneOperator edits
LLM returns > 400 wordsaccept anywayNoneOperator trims
RLS bypassRLS policy0 rowsNone
Memory path collision (multiple drafts)memory handles revisionsAll preservedNone
Locale config missingdefaults to vi-VNNoneNone
Locale config invalidfalls back to en-US + warnNoneOperator fixes
Persona override invalid (e.g. "ceo" not in registry)startup validation422 at config saveNone
Prior cycle review missing (first cycle)comparison section omitteddraft still generatedNone
Skip annotation race (cycle marked skip after draft generated)annotation honoured next timeNoneNone
Skip annotation reversedforce re-generate runsNoneNone
LLM response redaction overzealousmanual review catchesNoneOperator
Stats query slow for large cyclebounded by cycle.issue_countsev-3 if > 30sNone
Iteration count > 20bounded; warn at 10NoneOperator
Revision count > 50memory handles; UI paginatesNoneNone
Persona registry unavailablefalls back to default coo personaNoneOperator restores AI-014
Locale switch mid-cycleuses locale-at-draft-timeNoneNone
Estimate field missing on issuesvelocity_estimate=null; ratio undefinedNoneNone
All issues in cycle have no time entriesvelocity_actual=0; ratio undefinedNoneNone

§11 — Implementation notes


End of TASK-PROJ-012.