"Cycle-review draft generator — CUO/COO-persona LLM compose at cycle close with completion stats, blocker recap, and editable memory draft"
§1 — Description (BCP-14 normative)
The cycle-review generator MUST compose a draft review at every cycle close. The contract:
- MUST schedule a cycle-close detector running every 15 minutes; identifies cycles whose
ends_at < NOW()andreview_drafted_at IS NULL; queues review generation for each. - MUST compute
CycleStatsfor each detected cycle:
completed_count(issues with status=Done where cycle_id matches).incomplete_count(status != Done|Cancelled).cancelled_count.velocity_estimate_points(sum of estimate values on completed issues; per TASK-PROJ-013 estimate field).velocity_actual_hours(sum of TASK-TIME-005 billable_hours on completed issues).blocker_count_resolved(TASK-PROJ-011 blockers resolved during cycle).blocker_count_stale_at_close(active blockers at cycle end).time_in_status_p50per status (median dwell).
- 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:
- Did we hit the cycle goal?
- What slowed us down?
- One specific learning per stale-blocker pattern.
- Recommendation for next cycle's priorities. Tone: factual, constructive, Vietnamese-context-aware. Avoid blame. ```
- MUST save the draft as a memory memory at
memories/projects/<engagement_id>/cycle-reviews/<cycle_id>.mdwith frontmatter{kind: cycle_review, status: draft, sync_class: shareable, generated_by_persona: chief-operating-officer@<version>, generated_at: <iso>}. The body is markdown. - MUST emit
proj.cycle_review_draftedmemory audit row with payload{cycle_id, engagement_id, memory_path, draft_byte_count, persona_version, generated_at_ns, trace_id}. - MUST NEVER auto-promote draft to
status: accepted. Operator (COO) edits + accepts via UI / CLI; acceptance flips frontmatterstatusand emitsproj.cycle_review_acceptedrow. - MUST redact PII in input context via TASK-MEMORY-111 before LLM call (issue descriptions may contain customer info; redact before composition).
- MUST expose REST
POST /api/proj/cycles/:id/review-draftfor force re-generation (operator can iterate); preserves all prior drafts as memory revisions. - 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.
- MUST RLS-enforce.
- MUST emit OTel metrics:
proj_cycle_reviews_drafted_total(counter).proj_cycle_reviews_acceptance_minutes(histogram — time from draft to accept; ops-quality signal).proj_cycle_review_llm_failures_total(counter).
- MUST support locale-aware drafts:
cyberos_proj_engagement_settings.cycle_review_locale(defaultvi-VN); prompt template includes the locale instruction so VN engagements get Vietnamese drafts. - 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. - 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.
- 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.
- MUST support per-engagement persona override:
cyberos_proj_engagement_settings.cycle_review_persona = "coo"|"pm"|"engineering_lead". Different engagements may want different review voice. - MUST emit a
proj.cycle_review_iteratedaudit row each time the operator re-generates a draft; payload includes iteration_count + reason (optional). - 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.
- MUST include
comparison_to_prior_cyclesection: if the prior cycle's review exists + was accepted, the prompt includes its stats for delta context ("velocity improved 15% over prior cycle"). - MUST redact LLM response too (not just input) before memory save — LLM may echo or hallucinate PII from training data.
- 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_skippedwith reason. - MUST include cycle's
learningssection if the cycle hadlearningsfield 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
- Cycle close triggers draft — cycle.ends_at < NOW + 15min poll → draft generated.
- Stats accurate — fixture: 5 done, 2 incomplete, 1 cancelled → counts match.
- Velocity points computed — sum of completed-issues' estimate field.
- Velocity hours from billable time entries — only billable_snapshot = true entries counted.
- Blocker counts split active vs resolved — accurate per TASK-PROJ-011 state.
- Time-in-status p50 computed — 10 issues went through in_progress for various durations → median present.
- Draft saved at canonical path — memory at
memories/projects/<eng>/cycle-reviews/<cycle>.md. - Frontmatter
status: draft— never auto-accepted. - memory audit
cycle_review_drafted— row appears post-save. - PII redacted before LLM — fixture title with email → redacted in prompt sent to AI Gateway.
- Force re-generate endpoint — POST → new draft saved as memory revision.
- LLM 5xx retries — mock gateway 503 thrice → retries with backoff.
- LLM permanent failure → stub — mock 503 four times → stats-only stub draft saved; sev-3 alarm.
- Acceptance flips status — operator UI accept → frontmatter status changes to accepted;
cycle_review_acceptedrow. - RLS isolates — tenant A can't see tenant B's reviews.
- OTel metric
proj_cycle_reviews_drafted_total— counter increments. - Acceptance-minutes histogram — time from draft to accept measured.
- Locale-aware draft — engagement vi-VN → draft in Vietnamese; en-US → English (AC for §1 #12).
- Estimate-vs-actual ratio in stats — completed issues have estimate sum 40, actual 50 → ratio=0.8 (AC for §1 #13).
- Top-5 longest-in-status listed — fixture issues with varying dwell → top 5 in stats (AC for §1 #14).
- Blocker recap by category — fixture 3 customer-feedback blockers → grouped in input (AC for §1 #15).
- Per-engagement persona override — set persona=pm; observe persona_version=pm@... in audit (AC for §1 #16).
- Iteration audit emitted — force re-gen →
proj.cycle_review_iteratedrow with iteration_count (AC for §1 #17). - All revisions preserved — 3 re-generations → 3 memory memory revisions; latest = canonical (AC for §1 #18).
- Comparison to prior cycle in prompt — prior accepted cycle stats included if available (AC for §1 #19).
- LLM response redacted — fixture LLM output containing PII → memory memory has redacted form (AC for §1 #20).
- Skip-cycle annotation honoured — operator marks skip → no draft generated; audit row emitted (AC for §1 #21).
- 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
- TASK-PROJ-001 — cycles + issues tables.
- TASK-PROJ-004 — status FSM (Done detection).
- TASK-PROJ-011 — blocker_state for blocker counts.
- TASK-AI-014 — persona-stamped LLM calls.
- TASK-AI-015 — ZDR enforcement on the external LLM call.
- TASK-MEMORY-111 — PII redaction.
- TASK-CUO-101 (placeholder) — future routing of draft notifications.
§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:
- Multi-language drafts (English vs Vietnamese based on engagement locale) — slice 4+.
- Side-by-side comparison of multi-cycle reviews (operator picks elements from each) — slice 4+.
- Auto-accept after N days idle — never (per DEC-331).
§10 — Failure modes inventory
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| AI Gateway 5xx | retry exhausted | stats-only stub; sev-3 | Operator iterates manually |
| Cycle not closed yet | scheduler condition | skipped; checked next poll | None |
| Cycle deleted mid-generation | sqlx Err on read | task abort; logged | None |
| PII redactor crash | catch_unwind | redacted body marked <SCAN_FAILED>; LLM still called with safer placeholder | Operator updates ruleset |
| memory put_memory fails | sqlx Err | draft body NOT saved; sev-2 | Operator retries via force endpoint |
| Concurrent close detect for same cycle | row-level lock on cycles table | One generates; other no-op | None |
| Operator never accepts | acceptance-minutes histogram tail | Visible in metrics | Operator nudge via CUO |
| Force-regenerate races acceptance | second draft saved as revision; status reverts to draft | Operator re-accepts | None |
| LLM returns < 200 words | accept anyway (content may still be useful) | None | Operator edits |
| LLM returns > 400 words | accept anyway | None | Operator trims |
| RLS bypass | RLS policy | 0 rows | None |
| Memory path collision (multiple drafts) | memory handles revisions | All preserved | None |
| Locale config missing | defaults to vi-VN | None | None |
| Locale config invalid | falls back to en-US + warn | None | Operator fixes |
| Persona override invalid (e.g. "ceo" not in registry) | startup validation | 422 at config save | None |
| Prior cycle review missing (first cycle) | comparison section omitted | draft still generated | None |
| Skip annotation race (cycle marked skip after draft generated) | annotation honoured next time | None | None |
| Skip annotation reversed | force re-generate runs | None | None |
| LLM response redaction overzealous | manual review catches | None | Operator |
| Stats query slow for large cycle | bounded by cycle.issue_count | sev-3 if > 30s | None |
| Iteration count > 20 | bounded; warn at 10 | None | Operator |
| Revision count > 50 | memory handles; UI paginates | None | None |
| Persona registry unavailable | falls back to default coo persona | None | Operator restores AI-014 |
| Locale switch mid-cycle | uses locale-at-draft-time | None | None |
| Estimate field missing on issues | velocity_estimate=null; ratio undefined | None | None |
| All issues in cycle have no time entries | velocity_actual=0; ratio undefined | None | None |
§11 — Implementation notes
- The schedule is a Postgres cron via pg_cron OR a tokio interval (15min); chosen tokio for simpler local dev. Production may switch to pg_cron for high-availability.
- The COO persona version (
coo@1.0.0) is fetched from TASK-AI-014 registry at compose time; stamped in frontmatter for reproducibility. - The stats-only stub on LLM failure carries the JSON of CycleStats verbatim — operator can read and write the prose.
redact_titlescalls TASK-MEMORY-111'sscan_and_redactper issue title; results cached for the cycle.compute_status_dwell_mediansqueriesissue_status_history(TASK-PROJ-004) for transitions within cycle period.- Force-regenerate creates a new memory revision via TASK-MEMORY-101's content-addressed write; old revisions preserved by memory's natural append-only chain.
- The 200-400 word target is a hint, not enforced; LLM may produce shorter or longer.
- Locale-aware prompts include
Locale: {{locale}}. Compose the review in {{language_name}}.Default is vi-VN. - The estimate-vs-actual ratio is informational; operators may have intentionally over/under-estimated; the LLM should not blame.
- Top-5 longest-in-status uses
issue_status_historydurations; tied entries broken by issue create_at ascending. - Blocker recap by category groups via fuzzy text-matching on target_ref (slice 3+ uses ML clustering).
- Per-engagement persona override defaults to "coo" if engagement setting absent.
- Iteration audit row's reason field is operator-provided (optional); empty if not provided.
- Revisions are linear (no branching); the latest is canonical; revert = save the old content as a new revision.
- Comparison-to-prior-cycle fetches the most recent accepted review for the engagement; absent → section omitted.
- LLM response redaction runs the same TASK-MEMORY-111 ruleset; same redaction tokens as input.
- Skip-cycle annotation lives in
cycle.skip_reviewBOOLEAN column; toggled via PATCH /cycles/:id. - Cycle learnings: optional
learnings TEXT[]column on cycles; operator adds via PATCH; concatenated into prompt with each as a bullet. - We considered fully-automated review acceptance after N days (slice 4+) but rejected because: quality drift; operator trust loss.
- The LLM call is rate-limited per tenant (TASK-AI-014's budget); 1 review per cycle is well under any reasonable cap.
- Memory revision storage cost: ~5KB per revision × 5 revisions per cycle × 26 cycles/year × 10 engagements ≈ 6.5MB/year per tenant. Negligible.
- The "tone: factual, constructive, Vietnamese-context-aware. Avoid blame" line in the prompt is the most important calibration; tested empirically against 20 sample cycles.
- The cycle review is
sync_class: shareablebecause it's a team artifact; ACL can restrict if engagement has it (per TASK-PROJ-002 engagement default ACL). - Operator acceptance triggers TASK-PROJ-002
proj.decision-style audit row withdecision_idpointing at the cycle review memory.
End of TASK-PROJ-012.