Skip to content
Merged
13 changes: 13 additions & 0 deletions docs/decisions.log.md
Original file line number Diff line number Diff line change
Expand Up @@ -1024,3 +1024,16 @@ Architectural decisions go to [`adr/`](adr/) instead.
the renderer's wrapped row count and terminal size, leaking layout into
the interaction core). Accepted: `render_inspector` takes `&mut self`,
and a snapshot can now normalise the stored offset as a side effect.

- 2026-06-11 — In the context of the S8 curation backlog (rename/tag,
`preview/src/curation.rs` / `tui.rs`), facing the curator editing tags
blind (the dock showed only the pending a/x decision, not what the
record already carries), we decided for **a read-only record digest
first**: `summarize_record` flattens title/reviewer/tags to UI-level
strings in the schema's wire casing (derived via serde so names cannot
drift) and the inspector shows them under the live curation line — and
against starting with tag *editing* (a write path before the curator
can see current state inverts the workflow), and against passing
`ChunkMeta` into the renderer (ADR-0016: no domain types in the UI).
Accepted: one more startup file read, warn-and-continue when the
record is unreadable (quit-time persist still fails loudly).
10 changes: 10 additions & 0 deletions docs/process-backlog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Process backlog

Repository-process follow-ups that belong to no roadmap stage. Keep entries
short; delete them when done.

- [ ] **Codex review sweep script** (requested 2026-06-11): a script that
walks all PRs and visits those where Codex never left a review (e.g. the
usage-limit window around PR #40), re-requesting review with
`@codex review`. Note: the exact trigger phrase matters — free-form
variants may spawn a cloud task instead of a review.
3 changes: 3 additions & 0 deletions docs/stages/S8-preview-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ front-ends and audio build on them:
pending-decision line in the TUI inspector, and
`griff-preview --record=<chunk.json>` persisting the decision into the
record's `reviewer` field on quit (`curation::decide_record`).
**Second slice landed 2026-06-11**: the inspector surfaces the loaded
record's current state — title, prior reviewer decision, tags — via
`curation::summarize_record` (schema wire names, UI-level strings).
Remaining: split/merge/rename/tag.
- [ ] Boundary overlays (S4) and candidate history — **overlays landed
2026-06-11**: `Analysis.boundaries` carries the S4 start ticks under a
Expand Down
45 changes: 45 additions & 0 deletions preview/src/curation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ use griff_core::corpus::{ChunkMeta, ReviewerDecision};

use crate::viewport::CurationDecision;

/// A UI-level digest of a chunk record's curation state.
///
/// Plain strings in the schema's `snake_case` wire names, so renderers show
/// exactly what the corpus stores and no domain type crosses into the UI
/// (ADR-0016).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecordSummary {
/// The record's `title`.
pub title: String,
/// The prior reviewer decision in wire casing, if any.
pub reviewer: Option<String>,
/// The record's tags in wire casing, in stored order.
pub tags: Vec<String>,
}

/// Errors the curation persistence seam can emit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CurationError {
Expand All @@ -26,6 +41,36 @@ pub enum CurationError {
///
/// # Errors
/// [`CurationError::ParseFailed`] when `json` is not a `ChunkMeta` record.
/// Digests a serialized `ChunkMeta` record into the [`RecordSummary`] the
/// inspector shows: title, prior reviewer decision, and tags, all in the
/// schema's wire casing (via serde, so the names cannot drift).
///
/// # Errors
/// [`CurationError::ParseFailed`] when `json` is not a `ChunkMeta` record.
pub fn summarize_record(json: &str) -> Result<RecordSummary, CurationError> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Split the TDD red and green changes

This commit introduces the new public curation API (summarize_record here, plus RecordSummary) in the same commit as the tests that cover it in preview/tests/curation.rs and preview/src/tui.rs. The repo’s AGENTS.md TDD hard rule says never to commit a new pub fn/pub struct implementation together with its covering tests; for this non-trivial S8 curation slice, please split the red tests and green implementation into separate commits so the failing red phase is reviewable.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The split is in place — the flattened PR diff hides it, but the history has the rule's exact shape:

  • red d34f00e "test: red — the inspector surfaces the loaded record's curation state (S8)" touches only preview/tests/curation.rs (+44) and the tui.rs test module (+21); it references summarize_record/RecordSummary/set_record, which did not exist yet, so the workspace fails to compile at that commit.
  • green 466565c "feat(preview): …" touches only implementation — curation.rs (+45), main.rs (+15), tui.rs non-test code (+28) — and adds zero #[test] items.

No code change needed; happy to reorganize if you see a commit where the two actually mix.


Generated by Claude Code

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Split the record summary API from its tests

This commit adds new public API (RecordSummary/summarize_record) in the same changeset as the tests that cover it, but /workspace/griff/AGENTS.md makes the TDD workflow mandatory and says: "Never commit new pub fn / pub struct implementation in the same commit as the tests that cover it, for any new functionality." Because this is new functionality rather than a characterization test, the change should be split into the required red test commit and the green implementation commit.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same finding as the thread above (#42 (comment)) — the rule is satisfied in the commit history, which the flattened PR diff can't show: red d34f00e (tests only, fails to compile), green 466565c (implementation only, zero #[test] items). The follow-up Codex P2 fix repeated the same shape: red 3c4d8d5, green 8f6e56c. Every TDD PR in this repo will look "mixed" at the changeset level by construction; the reviewable unit is the commit sequence.


Generated by Claude Code

let meta: ChunkMeta = serde_json::from_str(json).map_err(|_| CurationError::ParseFailed)?;
Ok(RecordSummary {
reviewer: meta
.reviewer
.and_then(|d| wire_name(serde_json::to_value(d))),
tags: meta
.tags
.iter()
.filter_map(|t| wire_name(serde_json::to_value(t)))
.collect(),
title: meta.title,
})
}

/// A serialized value's wire name (`snake_case` per the schema), if it is a
/// plain string on the wire.
fn wire_name(value: serde_json::Result<serde_json::Value>) -> Option<String> {
match value {
Ok(serde_json::Value::String(s)) => Some(s),
_ => None,
}
}

pub fn decide_record(json: &str, decision: CurationDecision) -> Result<String, CurationError> {
let mut meta: ChunkMeta = serde_json::from_str(json).map_err(|_| CurationError::ParseFailed)?;
meta.reviewer = Some(match decision {
Expand Down
15 changes: 14 additions & 1 deletion preview/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use std::{env, fs};

use griff_core::midi::import_score;
use griff_preview::analysis::analyze;
use griff_preview::curation::decide_record;
use griff_preview::curation::{decide_record, summarize_record};
use griff_preview::tui::{self, App};
use griff_preview::view::build_view;
use griff_preview::viewport::CurationDecision;
Expand Down Expand Up @@ -71,6 +71,19 @@ fn main() -> ExitCode {

let mut app = App::new(build_view(&score), analyze(&score), path);

// Surface the record's current curation state in the inspector. Best
// effort: an unreadable record still fails loudly at quit-time persist,
// so here a warning suffices (printed before the TUI owns the screen).
if let Some(record_path) = record.as_deref() {
match fs::read_to_string(record_path).map(|json| summarize_record(&json)) {
Ok(Ok(summary)) => app.set_record(summary),
Ok(Err(err)) => {
eprintln!("warning: cannot summarize record {record_path}: {err:?}");
}
Err(err) => eprintln!("warning: cannot read record {record_path}: {err}"),
}
}

match snapshot {
Some((w, h)) => match app.snapshot(w, h) {
Ok(lines) => {
Expand Down
49 changes: 49 additions & 0 deletions preview/src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ use ratatui::{DefaultTerminal, Frame, Terminal};
use griff_core::classify::BarClass;

use crate::analysis::Analysis;
use crate::curation::RecordSummary;
use crate::scene::{resolve, CellRole, GridSize, Scene, SceneCell, GUTTER};
use crate::view::PianoRollView;
use crate::viewport::{CurationDecision, Intent, Step, ViewContext, Viewport};
Expand Down Expand Up @@ -81,6 +82,7 @@ pub struct App {
view: PianoRollView,
analysis: Analysis,
file: String,
record: Option<RecordSummary>,
vp: Viewport,
ctx: ViewContext,
}
Expand All @@ -101,11 +103,18 @@ impl App {
view,
analysis,
file,
record: None,
vp,
ctx,
}
}

/// Attaches the digest of the `--record` chunk so the inspector shows
/// the record's current curation state (title, reviewer, tags).
pub fn set_record(&mut self, record: RecordSummary) {
self.record = Some(record);
}

/// Renders one frame into a `TestBackend` of the given size and returns the
/// buffer as plain text rows (colours dropped) — used for headless previews
/// and tests.
Expand Down Expand Up @@ -258,6 +267,23 @@ impl App {
frame.render_widget(paragraph.scroll((scroll, 0)), inner);
}

/// Appends the loaded record's curation state (title, prior reviewer
/// decision, tags) to the inspector lines, when a `--record` is attached.
fn push_record_lines(&self, lines: &mut Vec<Line<'static>>, dim: Style) {
let Some(rec) = &self.record else { return };
lines.push(Line::from(vec![
Span::styled("record ", dim),
Span::raw(rec.title.clone()),
]));
lines.push(Line::from(format!(
"review {}",
rec.reviewer.as_deref().unwrap_or("—")
)));
if !rec.tags.is_empty() {
lines.push(Line::from(format!("tags {}", rec.tags.join(" "))));
}
}

/// Builds the inspector's content lines: track, section, curation,
/// transport (live state first — the PR #38 liveness ordering), then the
/// static structure and complexity blocks.
Expand Down Expand Up @@ -301,6 +327,8 @@ impl App {
}
)));

self.push_record_lines(&mut lines, dim);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep transport visible with record summaries

When --record is used on a short terminal, these record lines are inserted before the transport block, so the initial inspector can hide the live play/pause and position state entirely; I checked griff-preview ... --snapshot=80x12 --record=core/tests/fixtures/minimal_chunk.json, and the frame stops at the tags line with no transport/pos visible. That regresses the liveness ordering documented immediately below (and previously tested for the no-record path), because curation users who attach a record now lose the playback state unless they know to scroll; putting the record digest below transport would preserve the existing guarantee while still showing it in the dock.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 8f6e56c (red → green): the digest is static — loaded once at startup — so it now sits below the transport block with the other static content, exactly per the PR #38 liveness ordering (clipping eats static tails, never live state). Regression test pins transport visible on an 80×12 terminal with a record attached (the first version of the test asserted on pos, which the header also shows — sharpened to the dock-only line). On very short terminals the digest itself clips first; the PR #41 scroll reaches it. Decision recorded in docs/decisions.log.md.


Generated by Claude Code


// Transport sits above the metrics blocks: when the content exceeds
// the dock, clipping eats the tail of the static metrics, never the
// live play state (Codex P2, PR #38).
Expand Down Expand Up @@ -618,6 +646,27 @@ mod tests {
);
}

// TDD red phase: the inspector surfaces the loaded record's curation
// state (the S8 slice before rename/tag). References `App::set_record`,
// which does not exist yet, so the crate fails to compile until the
// green step.
#[test]
fn inspector_shows_the_loaded_record_state() {
use crate::curation::RecordSummary;

let mut app = demo_app();
app.set_record(RecordSummary {
title: "Curated".to_string(),
reviewer: Some("accepted".to_string()),
tags: vec!["clean_riff".to_string(), "maj7".to_string()],
});
let frame = app.snapshot(80, 24).expect("snapshot");
let text = frame.join("\n");
assert!(text.contains("Curated"), "record title shows in the dock");
assert!(text.contains("accepted"), "prior reviewer decision shows");
assert!(text.contains("clean_riff"), "record tags show");
}

// TDD red phase: Codex P2 (PR #41, round 2) — overscrolling must not
// accumulate hidden excess: one PgUp from the bottom moves the dock,
// because the render writes the clamped offset back.
Expand Down
44 changes: 44 additions & 0 deletions preview/tests/curation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,47 @@ fn malformed_json_is_a_typed_error() {
Err(CurationError::ParseFailed)
));
}

// ── record summary (S8 curation slice: surface the record's current state) ──
// TDD red phase: the curator must *see* the loaded record's curation state
// (title, reviewer, tags) before rename/tag can build on it. References
// `summarize_record` and `RecordSummary`, which do not exist yet, so the
// suite fails to compile until the green step.

#[test]
fn summarize_record_surfaces_title_reviewer_and_tags() {
use griff_preview::curation::summarize_record;

let mut meta = record();
meta.reviewer = Some(ReviewerDecision::Accepted);
meta.tags = vec![SwancoreTag::CleanRiff, SwancoreTag::Maj7];
let json = serde_json::to_string(&meta).expect("serialize");

let summary = summarize_record(&json).expect("summary ok");
assert_eq!(summary.title, "Curated");
assert_eq!(summary.reviewer.as_deref(), Some("accepted"));
assert_eq!(
summary.tags,
vec!["clean_riff".to_owned(), "maj7".to_owned()],
"tags use the schema's snake_case wire names"
);
}

#[test]
fn summarize_record_handles_an_unreviewed_record() {
use griff_preview::curation::summarize_record;

let json = serde_json::to_string(&record()).expect("serialize");
let summary = summarize_record(&json).expect("summary ok");
assert_eq!(summary.reviewer, None);
}

#[test]
fn garbage_summary_input_fails() {
use griff_preview::curation::summarize_record;

assert_eq!(
summarize_record("not json").unwrap_err(),
CurationError::ParseFailed
);
}