Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ add_executable(vivid MACOSX_BUNDLE
src/cli/project_recovery.cpp
src/cli/control_handlers_packages.cpp
src/cli/control_handlers_edit.cpp
src/cli/control_handlers_work.cpp
src/cli/control_handlers_music_eval.cpp
src/cli/control_handlers_visual_eval.cpp
src/cli/control_handlers_video_export.cpp
Expand Down
6 changes: 5 additions & 1 deletion app/src/app/review_workspace.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,11 @@ void ReviewWorkspace::leave(Window& win) {
(void)win;
audition_.pause();
comment_active_ = false;
if (has_project_) work::append_event(project_dir_, { "", "review_visited", json() });
// Anchor "since your last review" — but only for a project that already HAS records. Leaving
// Review must not create a work/ folder in a project that never had one.
std::error_code ec;
if (has_project_ && fs::is_directory(work::work_dir(project_dir_), ec))
work::append_event(project_dir_, { "", "review_visited", json() });
}

int ReviewWorkspace::pending_count() const {
Expand Down
1 change: 1 addition & 0 deletions app/src/cli/control_handlers.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ void register_audio_handlers(Handlers& handlers_); // authoring + clip
void register_project_handlers(Handlers& handlers_); // session author/persist + project workflow
void register_package_handlers(Handlers& handlers_); // ADR-0024 Phase 7: operator-package authoring
void register_edit_handlers(Handlers& handlers_); // ADR-0017 undo/redo
void register_work_handlers(Handlers& handlers_); // ADR-0061/0062 work records (gate-1 slice: status + snapshot)
void register_video_export_handlers(Handlers& handlers_); // realtime AV video export (File > Export Video / MCP)
void register_audio_export_handlers(Handlers& handlers_); // ADR-0032: offline master-mix bounce to .wav (File > Export Audio / MCP)
void register_audio_io_handlers(Handlers& handlers_); // ADR-0032 Phase A: output device enumerate/select
Expand Down
6 changes: 6 additions & 0 deletions app/src/cli/control_handlers_project.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
#include "app/project_io.h" // folder-aware save/load + project-local operators
#include "app/project_paths.h" // is_folder_project / session_json_path
#include "app/app.h"
#include "app/edit_gateway.h" // ADR-0018: mark_saved
#include "app/autosave.h" // ADR-0018: clear the recovery slot after a clean save
#include "app/mcp_bridge.h" // ADR-0040: the bundled MCP bridge location + setup command
#include "audio/plugin_catalog.h" // check_tutorial_prereqs: installed plugin readiness
#include "ui/node_graph.h"
Expand Down Expand Up @@ -516,6 +518,10 @@ void register_project_handlers(Handlers& handlers_) {
if (path.empty()) return err(code::kBadArg, "need path for first save");
auto sr = project_io::save(*c.app, *c.graph, *c.win_w, *c.win_h, *c.split_x, *c.dock_h, path);
if (!sr.ok) return err(code::kIoError, sr.error);
// ADR-0018: the document is now clean — same as the GUI's save (file_actions::save_path). Without
// this an agent's save left the edited-dot on, the quit prompt armed, and the autosave slot live.
if (c.app->edit_gateway) c.app->edit_gateway->mark_saved();
autosave::clear();
json r = ok(); r["path"] = c.app->project.current_project_path; r["session_file"] = sr.session_file; return r;
};
handlers_["load_project"] = [](const ControlCtx& c, const json& b) {
Expand Down
83 changes: 83 additions & 0 deletions app/src/cli/control_handlers_work.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#include "cli/control_handlers_internal.h"

#include "app/app.h"
#include "app/edit_gateway.h"
#include "app/project_paths.h"
#include "audio/vst3_host.h"
#include "work/work_records.h"

#include <filesystem>
#include <string>

namespace vivid {

namespace fs = std::filesystem;

// ADR-0061/0062 — the WORK RECORDS surface, gate-1 slice: enough for an agent to publish honest
// candidates for the Review workspace without reimplementing the records layer. The records
// themselves are plain JSON under <project>/work/ that an agent reads/writes directly (that is the
// point of the format); these handlers cover the two things only the app can do well:
// work_status — where the records are, what the document derives from, what is open
// work_snapshot_version — an immutable version of the LOADED project folder (complete copy +
// SHA-256 per dependency + plugin identities), optionally INTO another
// project's records (the candidate-copy flow: edit a copy, snapshot it into
// the original's work/, never touching the foreground document)
// The full work/version/review/execution tool families are gate 2 (ADR-0063).
void register_work_handlers(Handlers& handlers_) {
handlers_["work_status"] = [](const ControlCtx& c, const json&) {
if (!c.app) return err(code::kInternal, "no app");
json r = ok();
const std::string& p = c.app->project.current_project_path;
const bool folder = !p.empty() && project_paths::is_folder_project(p);
r["project_dir"] = folder ? p : "";
r["is_folder_project"] = folder;
r["preferred_version"] = c.session ? std::string(session::session_preferred_version(c.session)) : std::string();
r["dirty"] = c.app->edit_gateway ? c.app->edit_gateway->dirty() : false;
if (!folder) { r["has_records"] = false; r["summary"] = "no folder project open — work records need a project folder"; return r; }
std::error_code ec;
const bool has = fs::is_directory(work::work_dir(p), ec);
r["has_records"] = has;
if (has) {
work::Brief b; r["brief_rev"] = work::load_brief(p, b) ? b.rev : 0;
int open = 0; json reviews = json::array();
for (const auto& rv : work::list_reviews(p)) {
if (rv.status == "open") ++open;
reviews.push_back({ {"id", rv.id}, {"status", rv.status}, {"question", rv.question}, {"candidates", rv.candidates} });
}
r["open_reviews"] = open; r["reviews"] = reviews;
r["versions"] = work::list_versions(p).size();
r["candidates"] = work::list_candidates(p).size();
}
return r;
};

// {label, source?="manual", parent?="", brief_rev?=0, into?=<records project dir>} -> {id, dependencies, plugins}.
// Reads project.json from DISK, so the loaded document must be saved first: an unsaved document
// is refused rather than snapshotting something the creator has not seen written.
handlers_["work_snapshot_version"] = [](const ControlCtx& c, const json& b) {
if (!c.app) return err(code::kInternal, "no app");
const std::string& p = c.app->project.current_project_path;
if (p.empty() || !project_paths::is_folder_project(p)) return err(code::kBadArg, "the loaded project is not a folder project");
if (c.app->edit_gateway && c.app->edit_gateway->dirty())
return err(code::kConflict, "the document has unsaved changes — save_project first (a snapshot reads project.json on disk)");
work::SnapshotOptions opt;
opt.label = b.value("label", std::string());
opt.source = b.value("source", std::string("manual"));
opt.parent = b.value("parent", std::string());
opt.brief_rev = b.value("brief_rev", 0);
const std::string into = b.value("into", p);
if (!into.empty() && into != p) {
std::error_code ec;
if (!fs::is_regular_file(fs::path(into) / "project.json", ec)) return err(code::kBadArg, "into: not a project folder: " + into);
}
std::string e;
const std::string id = work::snapshot_version_into(p, into, opt, &e);
if (id.empty()) return err(code::kIoError, e);
work::Version v;
json r = ok(); r["id"] = id; r["records_dir"] = into;
if (work::load_version(into, id, v)) { r["dependencies"] = v.dependencies.size(); r["plugins"] = v.plugins.size(); r["document_sha256"] = v.document_sha256; }
return r;
};
}

} // namespace vivid
1 change: 1 addition & 0 deletions app/src/cli/control_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ void ControlServer::register_handlers() {
register_package_handlers(handlers_); // ---- ADR-0024 Phase 7: operator-package authoring ----

register_edit_handlers(handlers_); // ---- ADR-0017 undo/redo ----
register_work_handlers(handlers_); // ---- ADR-0061/0062 work records: status + version snapshot ----

register_video_export_handlers(handlers_); // ---- realtime AV video export ----
register_audio_export_handlers(handlers_); // ---- ADR-0032: offline master-mix WAV bounce ----
Expand Down
10 changes: 7 additions & 3 deletions app/src/work/work_records.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,10 @@ static std::vector<PluginDependency> plugins_from_document(const json& doc) {
}

std::string snapshot_version(const fs::path& project_dir, const SnapshotOptions& opt, std::string* err) {
return snapshot_version_into(project_dir, project_dir, opt, err);
}

std::string snapshot_version_into(const fs::path& project_dir, const fs::path& records_dir, const SnapshotOptions& opt, std::string* err) {
std::error_code ec;
const fs::path doc_src = project_dir / "project.json";
if (!fs::is_regular_file(doc_src, ec)) { set_err(err, "no project.json in " + project_dir.string()); return {}; }
Expand All @@ -288,11 +292,11 @@ std::string snapshot_version(const fs::path& project_dir, const SnapshotOptions&
v.document_sha256 = Sha256::of(doc_text);
v.plugins = plugins_from_document(doc);

const fs::path dst = version_dir(project_dir, v.id);
const fs::path dst = version_dir(records_dir, v.id);
fs::create_directories(dst, ec);
if (ec) { set_err(err, "cannot create " + dst.string()); return {}; }

const fs::path work = work_dir(project_dir);
const fs::path work = work_dir(project_dir); // the source's own records (if any) are never content
for (fs::recursive_directory_iterator it(project_dir, fs::directory_options::skip_permission_denied, ec), end;
it != end && !ec; it.increment(ec)) {
const fs::path& p = it->path();
Expand Down Expand Up @@ -320,7 +324,7 @@ std::string snapshot_version(const fs::path& project_dir, const SnapshotOptions&
if (ec) { set_err(err, "walk failed: " + ec.message()); return {}; }
std::sort(v.dependencies.begin(), v.dependencies.end(),
[](const Dependency& a, const Dependency& b) { return a.path < b.path; });
if (!save_version(project_dir, v, err)) return {};
if (!save_version(records_dir, v, err)) return {};
return v.id;
}

Expand Down
5 changes: 5 additions & 0 deletions app/src/work/work_records.h
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ struct SnapshotOptions {
};
std::string snapshot_version(const std::filesystem::path& project_dir, const SnapshotOptions& opt,
std::string* err = nullptr);
// Same, but the version lands in ANOTHER project's records (`records_dir`/work/versions/<id>): the
// gate-1 candidate flow edits an isolated COPY of the project and snapshots it into the original's
// work/ (ADR-0062 §2 — candidate writes never touch the foreground document or its folder).
std::string snapshot_version_into(const std::filesystem::path& project_dir, const std::filesystem::path& records_dir,
const SnapshotOptions& opt, std::string* err = nullptr);
// Is `rel` a build output the snapshot leaves behind? (Exposed for the tests.)
bool is_build_output(const std::filesystem::path& rel);

Expand Down
16 changes: 16 additions & 0 deletions app/tests/test_work_records.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,22 @@ static void test_snapshot_version() {
const auto all = list_versions(proj);
CHECK(all.size() == 2);
CHECK(all[1].parent == id || all[0].parent == id);
// Snapshot an isolated candidate COPY into the original's records (ADR-0062 §2).
{
const fs::path copy = fresh_dir("copy");
write_file(copy / "project.json", std::string(kDoc) + "\n");
write_file(copy / "aurora.glsl", "// shader v1");
write_file(copy / "work" / "junk.json", "{}"); // the copy's own records are not content
SnapshotOptions o; o.label = "candidate C"; o.source = "candidate"; o.parent = id;
const std::string cid = snapshot_version_into(copy, proj, o, &err);
CHECK(!cid.empty());
CHECK(fs::exists(version_dir(proj, cid) / "project.json")); // landed in the ORIGINAL's work/
CHECK(!fs::exists(version_dir(proj, cid) / "work"));
CHECK(!fs::exists(copy / "work" / "versions")); // nothing written into the copy's records
Version cv; CHECK(load_version(proj, cid, cv));
CHECK(cv.parent == id && cv.dependencies.size() == 2);
CHECK(list_versions(proj).size() == 3);
}
// No project.json → clean failure.
CHECK(snapshot_version(fresh_dir("empty"), opt, &err).empty());
CHECK(err.find("project.json") != std::string::npos);
Expand Down
20 changes: 12 additions & 8 deletions docs/roadmap/continuous-creative-workflow-gate1.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,18 @@ and reports what is missing or changed; it never substitutes content (ADR-0062
- Every action writes `work/` records and works with no control-server client connected.

### PR 4 — real media + the measurement
- `mcp/tools/` (or `examples/`) script: load `song-sketch`, snapshot the baseline version, write a
brief with two protections (drums, bass) and one preference (expansive chorus, visuals open up),
apply two directed edits on isolated copies, `export_av` each with a recorded recipe, write the
candidates and one review item.
- The measurement: the creator returns cold, two minutes, no transcript. Record in ADR-0064's
acceptance section: understood the change (y/n, seconds), chose or rejected (which), left a
passage-specific comment (y/n), what was confusing. Also test: no agent connection, feedback on
an older preview, choosing neither, returning to Create without losing selection.
- `mcp/gate1/publish_review.py`: loads a project folder, saves + snapshots the baseline, records the
brief, renders the baseline excerpt (`export_av`), renders two directions on **isolated copies**
(`load_project` the copy → edits → `save_project` → `export_av` → `work_snapshot_version … into=<original>`),
writes candidate records with provenance + evidence (export result, measured RMS for the level match),
reloads the original, publishes one review item. Nothing in `work/` is hand-written.
- Control server + MCP: `work_status`, `work_snapshot_version` (the two things only the app can do
well; the full tool families are gate 2). `save_project` over MCP now clears the dirty flag like
the GUI save (it never did).
- Leaving Review no longer creates a `work/` folder in a project that never had records.
- `docs/roadmap/gate1-measurement.md`: the protocol — the creator returns cold, two minutes, no
transcript; eight observations, the ADR-0064 probes (no agent, undo/redo, feedback on an older
preview, neither, back to Create, save prompt); the result goes into ADR-0064's acceptance section.

## Out of scope for gate 1
- Any runner, worker, job, lease, or background execution (gate 3).
Expand Down
84 changes: 84 additions & 0 deletions docs/roadmap/gate1-measurement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Gate 1 measurement — can a returning creator steer in two minutes?

Status: protocol ready; **result pending** (fill in the Result section after the session)

Date: 2026-09-19

The acceptance evidence ADR-0064 asks for: *use real audio and moving visuals to compare a baseline
and two alternatives; measure whether a returning creator can understand the change, choose or
reject it, and leave passage-specific feedback within two minutes without reading a transcript.*
This is a measurement, not a demo: the creator has **not** seen the candidates before, and the
person running the protocol says nothing until the clock stops.

## Setup (the agent, before the creator sits down)

1. Build `main` and launch Vivid. Keep the display awake (`caffeinate -dimsu`) and the window frontmost
while exporting (a sleeping display or occluded window collapses the frame loop).
2. Publish the round on a **writable copy** of the song sketch:
```sh
uv run --directory mcp python gate1/publish_review.py \
--project ~/Music/vivid-gate1/song-sketch --from-example --scene 0 --bars 8
```
This snapshots the baseline, records the brief, renders the baseline excerpt, renders two
directions on isolated copies (snapshotted into the original's `work/`), and publishes one review
item. Nothing in `work/` is hand-written.
3. Leave the app in **Create** on the original project, with the pending badge showing `1` on the
`Create | Review` switch. Do not open Review. Quit any MCP client; the creator must not read a
transcript.
4. Start a stopwatch when the creator first touches the mouse or keyboard.

## The session (the creator)

Scenario given aloud, once, before the clock starts (this is the brief the creator gave earlier —
it is also on screen under *Direction & protections*):

> "Yesterday you asked for the chorus to feel more expansive, with the visuals opening up, and for the
> drums and the chord part to stay as they are. Two directions came back. Decide."

Then nothing. The creator switches to Review and does whatever they do. Stop the clock when they say
they are done, or at **2:00**, whichever comes first.

## What is recorded (the observer, silently)

| # | Question | Record |
|---|---|---|
| 1 | Did they find the decision without help? | yes / no · seconds until the review item was on screen |
| 2 | Did they play the comparison? Did they switch between Baseline / A / B while it played? | yes / no · which sources were heard · used tiles, keys, or both |
| 3 | Could they say what changed, in their own words, without looking at anything but the screen? | verbatim |
| 4 | Did they make a choice — Use / Keep / Revise / Dismiss / Neither? | which · seconds |
| 5 | Did they leave a passage-specific comment (a bar, not "the whole thing")? | yes / no · verbatim · the bar the record carries |
| 6 | Did they open anything that was not the review (Create, a graph, a log) to understand the change? | what |
| 7 | Did anything confuse them? Asked aloud or visibly hesitated? | verbatim / where |
| 8 | Total time to done | m:ss |

Pass = 1, 3, 4 and 5 all yes within 2:00 and nothing in 6 was *needed* to answer 3.
Anything else is a fail that names the cause — that is the useful outcome.

## The follow-up probes (after the clock, in this order)

These are the ADR-0064 edge cases; each is one action and one observation.

- **No agent connected.** Nothing was connected during the session. Confirm the choice and the comment
are in `work/feedback.jsonl` and `work/reviews/<id>.json` (`resolution`), written by the app alone.
- **Undo.** ⌘Z. Did the *Current version* card and the *Since your last review* list tell the truth
(pointer back, candidate back to *kept*, `promotion_undone` event)? Redo. Same question.
- **Feedback on an older preview.** Select the resolved review, comment at a bar. Does the record target
the candidate's version, not the promoted document?
- **Neither.** (If they chose one.) Ask: "if you had liked neither?" — do they find *Neither — dismiss
all* without help?
- **Back to Create.** Does the session grid / graph come back exactly as they left it (selection,
dock, preview)?
- **Save prompt.** ⌘Q. The promotion left the document dirty; the prompt should appear. Don't Save.

## Result

_Fill in after the session. Copy the table with the observations, the verbatim answers to 3, 5 and 7,
and one paragraph: what the layout gets right, what it gets wrong, and what changes before the exact
layout is fixed (ADR-0064: "exact layout should follow this interaction proof")._

- Date / build:
- Creator:
- Pass / fail:
- Observations:
- Probes:
- Changes to make:
Loading
Loading