Skip to content

Commit b831ed8

Browse files
fix(skills): recipe freeze reads workflow from BRIEF.md; style_preset records require workflow scope
Two holes found by a live companion-run freeze: the agent-supplied --workflow contradicted the run's actual workflow (recipe.json said faceless-explainer, brief-skeleton said general-video), and the style_preset lookup missed because the preference had been recorded under the bare key. - freezeRecipe resolves the workflow from BRIEF.md frontmatter; the flag is a fallback for briefless projects and a contradicting flag is ignored (noted). - recordPreference refuses a bare style_preset — the scoped key is the only writable shape; freeze tolerates legacy bare records via read fallback. - review-loop § 4 / media-use SKILL / brief-format wording follow the machinery.
1 parent 286ac44 commit b831ed8

9 files changed

Lines changed: 135 additions & 13 deletions

File tree

skills-manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"files": 11
3131
},
3232
"hyperframes-core": {
33-
"hash": "1e5f886e4b9971b0",
33+
"hash": "cf6e9f7827bc86d1",
3434
"files": 17
3535
},
3636
"hyperframes-creative": {
@@ -46,7 +46,7 @@
4646
"files": 10
4747
},
4848
"media-use": {
49-
"hash": "9a4f585129692a22",
49+
"hash": "5bb2cf3a97d0b09c",
5050
"files": 130
5151
},
5252
"motion-graphics": {

skills/hyperframes-core/references/brief-format.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ YAML block at the top: one key per deterministic field — the run's shape first
1616
| `message` | the ONE thing the video must communicate | `"Ship it in an afternoon"` |
1717
| `destination` / `aspect` / `language` / `audience` / `length` / `angle`| the registry fields this route confirmed ||
1818

19-
**Which keys are memory.** Only the preference-backed subset — `destination`, `aspect`, `language`, `flow`, `storyboard`, `voice`, `style_preset` — is recorded with `media-use``scripts/prefs.mjs record` (the store rejects any other key). `message`, `audience`, `length`, `angle` live in the frontmatter only: they describe this video, not the user.
19+
**Which keys are memory.** Only the preference-backed subset — `destination`, `aspect`, `language`, `flow`, `storyboard`, `voice`, `style_preset` — is recorded with `media-use``scripts/prefs.mjs record` (the store rejects any other key). `style_preset` is stored per workflow: record it with `--workflow <w>` (the store refuses it bare — a look confirmed for one genre is not a default for the others). `message`, `audience`, `length`, `angle` live in the frontmatter only: they describe this video, not the user.
2020

2121
## Body — the intent in prose
2222

skills/hyperframes-core/references/review-loop.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,4 @@ Mark each frame `animated` as it lands. The build gate carries the loop's condit
3838

3939
After the workflow's checks pass, use the **final composition preview**. In collaborative mode Studio may already be serving from § 1; hand the timeline URL and ask one thing: render now, or what changes? In autonomous mode this is the one question the mode keeps: ask “preview first, or render?” Open the final preview on yes; render on an explicit render answer. Render only on approval.
4040

41-
**After approval, offer the recipe — once.** An approved run is a proven bundle. At delivery, offer to freeze it: `media-use``scripts/recipe.mjs freeze --name <name> --workflow <workflow>` keeps the design spec, the storyboard skeleton (structure kept, content blanked), the brief skeleton, and the confirmed brief values, and the next run of this type starts from it (the intent layer checks for a matching recipe before its first question). When the freeze lands, teach the recall in the confirmation — "Saved as **<name>** (v<N>). Next time say _make another <name>_, or just _like last time_." — the name is something the system reminds the user of, never something they must remember. In autonomous mode don't ask — name the freeze command in the delivery note instead.
41+
**After approval, offer the recipe — once.** An approved run is a proven bundle. At delivery, offer to freeze it: `media-use``scripts/recipe.mjs freeze --name <name>` (the workflow comes from BRIEF.md; pass `--workflow` only in a project without one) keeps the design spec, the storyboard skeleton (structure kept, content blanked), the brief skeleton, and the confirmed brief values, and the next run of this type starts from it (the intent layer checks for a matching recipe before its first question). When the freeze lands, teach the recall in the confirmation — "Saved as **<name>** (v<N>). Next time say _make another <name>_, or just _like last time_." — the name is something the system reminds the user of, never something they must remember. In autonomous mode don't ask — name the freeze command in the delivery note instead.

skills/media-use/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ Only what the user actually confirmed gets recorded — never an inferred or def
335335
The heavyweight tier of user memory: one approved run frozen as a named, versioned bundle — `frame.md`, the storyboard skeleton (structure kept, content blanked to per-frame fill-ins), the brief skeleton (from `BRIEF.md` when the project has one — reusable frontmatter kept, run-shape and prose blanked), and the confirmed brief values. Same two tiers: project `.media/recipes/<name>/` (committed) and `~/.media/recipes/<name>/` (a freeze is already a confirmed bundle, so it promotes immediately — no two-project rule). Re-freezing a name bumps `version` and archives the old folder as `<name>@v<N>`.
336336

337337
```bash
338-
node <SKILL_DIR>/scripts/recipe.mjs freeze --hyperframes . --name weekly-promo --workflow product-launch-video
338+
node <SKILL_DIR>/scripts/recipe.mjs freeze --hyperframes . --name weekly-promo # workflow read from BRIEF.md (--workflow only for briefless projects)
339339
node <SKILL_DIR>/scripts/recipe.mjs list --hyperframes . --workflow product-launch-video
340340
node <SKILL_DIR>/scripts/recipe.mjs use --hyperframes . --name weekly-promo # also: resolve.mjs --type recipe --entity weekly-promo
341341
```

skills/media-use/scripts/lib/prefs-store.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,9 @@ export function recordPreference({ projectDir, key, value, workflow }) {
158158
if (typeof value !== "string" || !value.trim()) {
159159
throw new Error("a preference needs a non-empty string value");
160160
}
161+
if (key === "style_preset" && (!workflow || !String(workflow).trim())) {
162+
throw new Error("style_preset is stored per workflow — pass --workflow <w>");
163+
}
161164
const fullKey = preferenceKeyFor(key, workflow);
162165
const projectName = basename(resolve(projectDir));
163166
const trimmed = value.trim();

skills/media-use/scripts/lib/prefs-store.test.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,16 @@ test("style_preset is keyed per workflow", () => {
159159
});
160160
});
161161

162+
test("style_preset without a workflow is rejected, never stored bare", () => {
163+
withSandbox(({ projectA }) => {
164+
assert.throws(
165+
() => recordPreference({ projectDir: projectA, key: "style_preset", value: "pin-and-paper" }),
166+
/pass --workflow/,
167+
);
168+
assert.deepEqual(mergedPreferences(projectA), {});
169+
});
170+
});
171+
162172
test("unknown keys and empty values are rejected", () => {
163173
withSandbox(({ projectA }) => {
164174
assert.throws(() => recordPreference({ projectDir: projectA, key: "vibe", value: "x" }));

skills/media-use/scripts/lib/recipe-store.mjs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,20 @@ export function skeletonizeStoryboard(source) {
167167
return out.join("\n").replace(/\n{3,}/g, "\n\n");
168168
}
169169

170+
/** The run's workflow as BRIEF.md records it — the source of truth a freeze
171+
* must not contradict. Undefined when no BRIEF.md (or no `workflow:`) exists. */
172+
function briefWorkflow(root) {
173+
const brief = join(root, "BRIEF.md");
174+
if (!existsSync(brief)) return undefined;
175+
const lines = readFileSync(brief, "utf8").split(/\r?\n/);
176+
if (lines[0]?.trim() !== "---") return undefined;
177+
for (let i = 1; i < lines.length && lines[i].trim() !== "---"; i++) {
178+
const match = lines[i].match(/^workflow\s*:\s*(.+?)\s*$/);
179+
if (match) return match[1].replace(/^["']|["']$/g, "") || undefined;
180+
}
181+
return undefined;
182+
}
183+
170184
function readRecipeJson(dir) {
171185
try {
172186
const parsed = JSON.parse(readFileSync(join(dir, "recipe.json"), "utf8"));
@@ -189,9 +203,15 @@ function prefValue(prefs, key) {
189203
* freeze is already confirmed — it promotes immediately).
190204
*/
191205
export function freezeRecipe({ projectDir, name, workflow, blocks }) {
192-
if (!workflow || !String(workflow).trim()) throw new Error("freeze needs --workflow");
193206
const slug = slugifyRecipeName(name);
194207
const root = resolve(projectDir);
208+
const fromBrief = briefWorkflow(root);
209+
const fromFlag = workflow && String(workflow).trim() ? String(workflow).trim() : undefined;
210+
// BRIEF.md decides; the flag only covers projects briefed before it existed.
211+
const resolvedWorkflow = fromBrief ?? fromFlag;
212+
if (!resolvedWorkflow) {
213+
throw new Error("no workflow found — BRIEF.md names none and no --workflow was given");
214+
}
195215
const frameSpec = join(root, "frame.md");
196216
const storyboard = join(root, "STORYBOARD.md");
197217
if (!existsSync(frameSpec)) throw new Error("no frame.md to freeze — run the design step first");
@@ -212,14 +232,17 @@ export function freezeRecipe({ projectDir, name, workflow, blocks }) {
212232
const recipe = {
213233
version,
214234
name: slug,
215-
workflow: String(workflow).trim(),
235+
workflow: resolvedWorkflow,
216236
approved_at: new Date().toISOString(),
217237
source_project: basename(root),
218238
destination: prefValue(prefs, "destination"),
219239
aspect: prefValue(prefs, "aspect"),
220240
language: prefValue(prefs, "language"),
221241
voice: prefValue(prefs, "voice"),
222-
style_preset: prefValue(prefs, `style_preset.${String(workflow).trim()}`),
242+
// The bare-key fallback tolerates records made before the store required
243+
// style_preset to be workflow-scoped.
244+
style_preset:
245+
prefValue(prefs, `style_preset.${resolvedWorkflow}`) ?? prefValue(prefs, "style_preset"),
223246
blocks: Array.isArray(blocks) && blocks.length > 0 ? blocks : undefined,
224247
};
225248

@@ -261,7 +284,15 @@ export function freezeRecipe({ projectDir, name, workflow, blocks }) {
261284
// The project-tier freeze already landed.
262285
}
263286

264-
return { id, slug, version, dir, briefSkeleton };
287+
return {
288+
id,
289+
slug,
290+
version,
291+
dir,
292+
briefSkeleton,
293+
workflow: resolvedWorkflow,
294+
workflowOverridden: Boolean(fromBrief && fromFlag && fromBrief !== fromFlag),
295+
};
265296
}
266297

267298
function scanRecipesDir(dir, source) {

skills/media-use/scripts/lib/recipe-store.test.mjs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,78 @@ test("freeze without a BRIEF.md degrades: no skeleton, use returns no path", ()
229229
});
230230
});
231231

232+
test("freeze takes the workflow from BRIEF.md over a contradicting flag", () => {
233+
withSandbox(({ project }) => {
234+
writeFileSync(
235+
join(project, "BRIEF.md"),
236+
BRIEF.replace("workflow: product-launch-video", "workflow: general-video"),
237+
);
238+
const frozen = freezeRecipe({
239+
projectDir: project,
240+
name: "promo",
241+
workflow: "faceless-explainer",
242+
});
243+
assert.equal(frozen.workflow, "general-video");
244+
assert.equal(frozen.workflowOverridden, true);
245+
const recipe = JSON.parse(
246+
readFileSync(join(project, ".media/recipes/promo/recipe.json"), "utf8"),
247+
);
248+
assert.equal(recipe.workflow, "general-video");
249+
});
250+
});
251+
252+
test("freeze without BRIEF.md falls back to --workflow; with neither it refuses", () => {
253+
withSandbox(({ project }) => {
254+
const frozen = freezeRecipe({
255+
projectDir: project,
256+
name: "promo",
257+
workflow: "product-launch-video",
258+
});
259+
assert.equal(frozen.workflow, "product-launch-video");
260+
assert.equal(frozen.workflowOverridden, false);
261+
assert.throws(() => freezeRecipe({ projectDir: project, name: "other" }), /no workflow found/);
262+
});
263+
});
264+
265+
test("freeze finds a legacy bare style_preset record; the scoped key wins over it", () => {
266+
withSandbox(({ project }) => {
267+
writeFileSync(join(project, "BRIEF.md"), BRIEF);
268+
// A record made before the store required workflow scoping.
269+
mkdirSync(join(project, ".media"), { recursive: true });
270+
writeFileSync(
271+
join(project, ".media/preferences.json"),
272+
JSON.stringify({
273+
version: 1,
274+
preferences: {
275+
style_preset: {
276+
value: "source-paper-flowchart",
277+
confirmed_in: ["my-launch"],
278+
updated_at: "2026-07-15T00:00:00.000Z",
279+
},
280+
},
281+
sightings: {},
282+
}),
283+
);
284+
freezeRecipe({ projectDir: project, name: "promo" });
285+
const legacy = JSON.parse(
286+
readFileSync(join(project, ".media/recipes/promo/recipe.json"), "utf8"),
287+
);
288+
assert.equal(legacy.style_preset, "source-paper-flowchart");
289+
290+
recordPreference({
291+
projectDir: project,
292+
key: "style_preset",
293+
value: "pin-and-paper",
294+
workflow: "product-launch-video",
295+
});
296+
freezeRecipe({ projectDir: project, name: "promo" });
297+
const scoped = JSON.parse(
298+
readFileSync(join(project, ".media/recipes/promo/recipe.json"), "utf8"),
299+
);
300+
assert.equal(scoped.style_preset, "pin-and-paper");
301+
});
302+
});
303+
232304
test("re-freezing bumps the version and archives the old folder", () => {
233305
withSandbox(({ project }) => {
234306
freezeRecipe({ projectDir: project, name: "promo", workflow: "product-launch-video" });

skills/media-use/scripts/recipe.mjs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
/**
33
* Recipes CLI — the heavyweight tier of HyperFrames user memory.
44
*
5-
* node recipe.mjs freeze --hyperframes . --name <n> --workflow <w> [--blocks a,b,c]
5+
* node recipe.mjs freeze --hyperframes . --name <n> [--workflow <w>] [--blocks a,b,c]
66
* Freeze the current approved run as a named recipe: frame.md + the
77
* storyboard skeleton (structure kept, content blanked) + the brief
88
* skeleton (when BRIEF.md exists) + the confirmed brief values.
9-
* Re-freezing the same name bumps the version and archives the old
10-
* folder as <name>@v<N>. Promotes to ~/.media/recipes/ immediately.
9+
* The workflow is read from BRIEF.md; --workflow only covers projects
10+
* briefed before BRIEF.md existed. Re-freezing the same name bumps the
11+
* version and archives the old folder as <name>@v<N>. Promotes to
12+
* ~/.media/recipes/ immediately.
1113
*
1214
* node recipe.mjs list --hyperframes . [--workflow <w>] [--json]
1315
* Two-tier merged listing (project wins), newest approval first.
@@ -57,7 +59,11 @@ try {
5759
});
5860
if (args.json) console.log(JSON.stringify({ ok: true, ...frozen }));
5961
else {
60-
console.log(`froze recipe ${frozen.slug} (v${frozen.version}) → ${frozen.dir}`);
62+
console.log(
63+
`froze recipe ${frozen.slug} (v${frozen.version}, ${frozen.workflow}) → ${frozen.dir}`,
64+
);
65+
if (frozen.workflowOverridden)
66+
console.log(` (BRIEF.md says "${frozen.workflow}" — the --workflow flag was ignored)`);
6167
if (!frozen.briefSkeleton)
6268
console.log(" (no BRIEF.md in the project — brief skeleton skipped)");
6369
}

0 commit comments

Comments
 (0)