| name | scenario-factory |
| description | Run one scenario-factory cycle โ generate 3 fresh scenario YAMLs, execute each on pastura-harness with real local-LLM inference, judge the transcripts in-session, and append the local digest. Use when the user asks to run the scenario factory, run a factory cycle, generate and field-test new scenarios, or dogfood scenarios overnight. |
| allowed-tools | Read, Write, Edit, Grep, Glob, Bash |
| disable-model-invocation | true |
/scenario-factory
One full factory cycle: generate โ run โ judge โ digest (ADR-013
Phase 2, #521). Run from the repository root.
Non-goals:
- No scheduled execution. This skill never registers itself as a
Routine / cron โ invocation is manual or via a Phase 3 routine that
calls it.
- No content-safety screening. The judge scores quality only; safety
is enforced by the blocklist pre-commit gate when a scenario is later
promoted to a bundled preset under
Resources/Presets/. The
shared-scenario gallery channel (docs/gallery/) does NOT pass
through that gate โ curate gallery content by hand. Both channels: see
ยง Promotion below.
- No external LLM APIs. Generation and judging are done by THIS
session. Only the local harness (llama.cpp + bundled GGUF) burns
inference.
Constants
MODEL: ~/Models/gemma-4-E2B-it-Q4_K_M.gguf (sha256 matches the
ModelRegistry pin)
DATE: today as YYYY-MM-DD; DATESTAMP: YYYYMMDD
RUN_ID: this cycle's start time as HH:MM:SS, captured ONCE here at the
top of the cycle and reused verbatim in every append it makes. It is the
second half of the digest section key (DATE, RUN_ID), which is what stops
a second cycle on the same date from overwriting the first (#1542). Taking
the clock again when re-appending a partially-failed cycle would write a
SECOND section instead of replacing its own โ reuse the original value. If
two cycles could plausibly start within the same second, give one a
disambiguating suffix (01:23:45-b) rather than sharing the value.
A resumed session has no memory of it, which is exactly the re-append
case โ but both places it could be read back from are DATE-keyed, not
run-keyed: /tmp/factory_results_<DATE>.json is overwritten by a second
same-day cycle, and one date can now carry several ## <DATE> โ <RUN_ID>
sections in data/factory/digest.md. Recovering a sibling's value REPLACES
that sibling's section; over-minting only adds a spurious one โ so recover
only when today has exactly one candidate (one results temp file whose
run_id has no section yet, or exactly one section for <DATE>). When the
date carries more than one run_id and you cannot identify your own, mint a
suffixed RUN_ID (01:23:45-b) rather than guess. A genuinely new cycle
mints with date +%H:%M:%S.
- Generated YAMLs:
data/factory/scenarios/<DATE>/ (gitignored; kept
local for later promotion โ do NOT write into runs/, that is the
harness output dir)
- Run logs:
data/factory/runs/<DATE>/<id>.jsonl (gitignored; harness
stderr lands in a .stderr.log sidecar next to each log)
- Digest:
data/factory/digest.md (gitignored local log โ appended in
place, never committed; bootstrapped by append_digest.py if absent)
- Sketch seed bank:
data/factory/sketches/<DATE>.md (gitignored; the
paper tournament of Step 2a, read back as seeds by Step 1(d))
- Incubator queue:
data/factory/incubator.md (gitignored; one-line
header, bootstrapped by the session on first append โ same pattern as
lessons-inbox.md)
- Naming: a factory
_v2 / _v3 id suffix means an incubated re-author
of a FACTORY candidate (Step 2b lane, Step 4 queue); /scenario-refine's
__v2 (double underscore, under improvements/) is an A/B candidate for a
SHIPPED scenario โ different lanes, different journals.
- Per-run timeout:
600 seconds (not the harness default 1800 โ bounds a
wedged run at 2 attempts ร 600 s)
- Helper scripts:
.claude/skills/scenario-factory/scripts/ (incl.
gallery_census.py โ the Step 1.5 novelty census)
Step 0 โ Preflight
ls ~/Models/gemma-4-E2B-it-Q4_K_M.gguf โ abort with a clear message
if the model is missing.
command -v jq โ required by the run wrapper.
swift build once to warm the harness build (incremental afterwards).
A build failure aborts the cycle โ report it, do not generate.
Step 1 โ Read prior scenarios (dedup)
Read four sources, in order:
- (a) The PLAYBOOK first โ
.claude/skills/scenario-factory/PLAYBOOK.md
(repo-tracked), in full. It is the GENERATION GATE: every generated
scenario must comply with its [validated] rules; [hypothesis] rules are
levers to test deliberately (note in the Step 5 digest comment when one is
exercised). Its "Saturated premise families" section is dedup input.
- (b)
data/factory/digest-index.jsonl for FULL-history dedup โ one line
per past scenario (id / name / theme / axis / status / scores, no comments;
~19 KB vs the ~97 KB digest). Collect every past id, name, theme here.
- (c) Only the NEWEST 2 DATES' sections of
data/factory/digest.md for
the freshest nuance โ last nights' comments, lessons, and axis-rotation
context. Headings are ## <date> โ <run_id> and one date can carry several
of them (#1542), so count DATES, not headings, or a night that ran twice
crowds out the previous night entirely. NEVER read the whole digest (it
exceeds the session Read cap, ~39k tokens): read with offset/limit, or
stop at the first heading whose date is the 3rd distinct one.
- (d) The newest 2 files in
data/factory/sketches/ as a seed bank โ
grep the ### S heading and paper: lines only, never Read them whole
(context budget). A runner-up that lost only on axis-distinctness may be
selected tonight; a sketch that lost twice on paper is a saturated-family
signal โ do not re-sketch it. Also grep '^- open' data/factory/incubator.md
here, so Step 1.5 already knows whether the incubation lane fires tonight.
Dedup semantics (sourced from (a)+(b)+(c)): the new batch must not repeat same
premise, same persona cast, or a theme judged โค2 on humor twice in a row.
Low-scoring past entries are signals about what NOT to generate again; high
scorers indicate directions worth varying further.
Missing-index fallback: if digest-index.jsonl is absent, do NOT read the
full digest to compensate. Proceed with newest-2-sections dedup plus a cheap
grep-based id/slug collision check against digest.md, and include a notice
in the Step 6 report: index missing โ full-history dedup degraded; run append_digest.py --digest data/factory/digest.md --rebuild-index.
Also collect the name + description of every already-shipped
scenario, so generation can avoid colliding with the inventory it might
later be promoted into (ยง Promotion):
- Bundled presets โ
Pastura/Pastura/Resources/Presets/*.yaml, excluding
the *_en.yaml English mirrors (they duplicate the ja originals).
- Shared-scenario gallery โ
docs/gallery/*_v1.yaml (read only the YAML
name: / description: scalars; skip README.md / gallery.json /
shared-scenario-reports.md).
A new scenario that repeats a shipped preset/gallery premise, mechanics, or
persona cast is a dedup miss even when the digest is clean.
Step 1.5 โ Pick under-represented axes (novelty census)
The gallery has skewed toward a vote โ score_calc โ summarize scoring
spine (most entries share it) and category: creative โ without a counter-
force, every batch piles onto the crowded majority. Steer toward the gaps:
python3 .claude/skills/scenario-factory/scripts/gallery_census.py
The census is deterministic and gallery-only โ it counts phase-type
presence (does a scenario contain a vote phase at all?), not mechanical
depth, and ranks 10 structural mechanic axes + the 6 categories by rarity.
Read its Suggested targets block and assign each of the 3 scenarios a
DISTINCT under-represented axis (a mechanic axis, a category, or both),
avoiding the crowded ones. Valid categories: social_psychology,
game_theory, ethics, roleplay, creative, experimental โ the
zero-entry ones (game_theory / experimental) are the rarest possible.
Cross-night rotation is an in-session reasoning step, not the script:
scan the digest's recent axis column (Step 5) and, if a suggested axis was
already targeted in the last 1โ2 nights but is not yet promoted to the
gallery, rotate to the next gap so the same hole isn't refilled before
promotion catches up.
The census also prints a โ ๏ธ NEW ENGINE MECHANICS not yet in the census axes
warning when PhaseType gains a phase no axis covers (the auto-follow
tripwire). When it fires, treat the listed phase(s) as the TOP-priority axis
assignment for the batch โ they are by definition 0-represented. BEFORE
authoring with an unfamiliar phase, read
Pastura/Pastura/Engine/Phases/<X>Handler.swift (its header docs give the
semantics, output-field contract, and cost) and tracking issue #906 (the
interestingness umbrella โ design considerations for every recent / planned
phase).
When the incubation lane fires (Step 2b) the distinct-axis requirement covers
the 2 fresh slots only โ the v2 keeps its original axis. A fired tripwire
outranks the lane: skip the lane that night so the batch stays 3 fresh.
Step 2 โ Paper tournament, then generate 3 YAMLs
Sketching in-session burns no inference; the harness run (~4โ10 min each) is
the bottleneck, so selecting at sketch level buys ~3ร candidate diversity per
inference minute (rounds 1โ2 tuned judging and lessons; this round moves
selection BEFORE the run).
2a โ Sketch 8โ10 candidates
Sketch into data/factory/sketches/<DATE>.md (create the dir). One block each,
โค7 lines: ### S<n> <slug>, premise (1 line), cast (3โ4 personas, 1 line),
axis: (a Step 1.5 target), phase spine + inference estimate (2c formula) on
one line, PLAYBOOK rules relied on + any [hypothesis] lever, and a paper:
line. Span โฅ3 distinct Step 1.5 axes, including โฅ1 comedy sketch (rule 29). If
8 are out of budget, sketch at least 5 and say so in the Step 6 report โ a
stated shortfall beats a silent one.
Paper rubric (0โ2 each, max 8), written as
paper: N=<n> R=<n> P=<n> B=<n> total=<n>:
- N novelty โ vs digest-index ids/themes + PLAYBOOK saturated families; a
topic-skin of a saturated family = 0.
- R rule compliance โ any
[validated] violation = DQ (not 0); 2 =
every rule relied on is cited.
- P payoff mechanism โ arc / humor mechanically forced by a phase or an
assign rotation = 2; merely hoped for from personas = 0.
- B budget fit โ โค50 = 2, โค80 = 1, 81โ100 = 0, >100 = DQ. Ties break
toward the rarer axis.
2b โ Select
Rank by total; take the top 3 (top 2 when the incubation lane fires) subject to:
distinct axes across the fresh slots, at least one comedy slot among the
fresh slots (rule 29 is a floor โ the humor datapoint), batch inference
โค ~120. Precedence when constraints collide:
census tripwire > rule-29 comedy slot > incubation lane. Append a
## Selection block to the sketch file (ranking + one-line reason per
pick/drop) so Step 1(d) can read it back next night.
Incubation lane (max 1 slot per night): if data/factory/incubator.md has
an open entry (line starts with - open, attempts: <2), the 3rd slot is that
scenario's v2 โ take the OLDEST open entry. Copy its original YAML from
data/factory/scenarios/<orig date>/ to
data/factory/scenarios/<DATE>/<id>.yaml with
id: factory_<DATESTAMP>_<slug>_v2 (_v3 on the second attempt โ the file
follows the id, same as a fresh slot), and apply ONLY the entry's recorded
fix: โ single-lever discipline, so the score delta is attributable (same rule
as /scenario-refine's A/B). If the original YAML is missing (pruned /
different checkout), rewrite the entry's leading - open to - retired with
retired: <DATE> source-missing and skip the lane tonight (all 3 fresh). The
v2 never counts as the comedy slot even when it is comedy; the lane is skipped
when the incubator is absent, empty, or all-closed.
2c โ Author the full YAMLs
For the selected sketches only โ write the FRESH files (3, or 2 when the lane
fired; the v2 already exists from 2b) to data/factory/scenarios/<DATE>/<id>.yaml
with id: factory_<DATESTAMP>_<slug> (snake_case slug, also the filename). The
v2's Step 5 results row keeps axis as the original two-part axis (the
rotation scan reads that column against the census vocabulary) and marks the
lane in theme, which begins v2 of <original id> โ <fix applied>.
Theme: driven by the per-scenario axis from Step 1.5, not a fixed focus. The
oogiri / comedy family is a strong default tone โ use
Pastura/Pastura/Resources/Presets/bokete.yaml as the schema/tone reference โ
but the assigned mechanic axis dictates the format (e.g. elimination โ
knockout bracket, branching โ conditional divergent paths, reactive_event
โ event_inject, scoring_free โ observation / discussion with no vote), and
an assigned category axis may rotate the premise out of comedy entirely (a
game_theory cooperation dilemma, an experimental social-psych setup ร la the
original asch/trolley seeds). A non-comedy scenario is judged on its own terms โ
see Step 4. Each of the 3 must differ from the other 2 AND from digest history
in premise or mechanics, not merely in topic strings.
Design defaults โ subtract by default (#919): author the SMALLER shape unless
the assigned axis needs more. Concretely: 2 output fields (statement +
inner_thought), 3โ4 agents, and no mandatory scoring (a narrate
score-free ending or scoring_free observation is fine โ drop
vote โ score_calc โ summarize when the payoff is the phenomenon). Each addition
trades against 2B breakdown rate, tokens, and latency โ justify it. Canonical
guidelines (the numbers live here): PLAYBOOK.md ยง "Subtract by default".
Schema requirements (ScenarioLoader โ all required):
Before writing any phase type, output field name, or score_calc logic, read
web/src/content/scenario-format.en.md โ the gate-enforced canonical list
(#1120 asserts every PhaseType / ScoreCalcLogic rawValue appears there, so
it never drifts from the engine). The bullets below add only the
generation-specific judgment on top of that reference.
id, name, description, language: ja, agents, rounds,
context, personas (name + description each; count == agents),
phases
- Agents 2โ10, rounds โค 30. Personas need distinct comedic stances
(the bokete preset's ใ็ซๅ ดใ/ใ็ฎ็ใ+ example-line format works well).
- LLM phases (
speak_all / speak_each / vote / choose) need
prompt + output field maps; vote needs exclude_self thought
through; scoring usually wants a score_calc (logic: vote_tally)
and a summarize.
- Canonical
output field names: take the per-phase primary /
private-thought names from the format reference (enforced at commit by
ScenarioValidator.validateForCommit). One commit-gate trap the reference
does not call out: do NOT author reason on a choose/speak phase โ it streams
live but goes blank on the committed row, and the gate rejects it (#760).
reflect (per-agent private memo, #907) โ output field note (canonical,
required; no secondary thought field). The memo is stored under the reserved
notes_<name> namespace and re-injected into that agent's OWN later prompts
only (system-prompt section + {my_notes}) โ never into the shared
conversation log. NOT allowed inside a conditional branch. Inference cost =
agents per round. Reference preset:
Pastura/Pastura/Resources/Presets/word_wolf.yaml.
whisper (pair-private conversation, #908) โ prompt optional (a
language-aware default exists); phase-level rounds: optional (default 1;
the same key speak_each uses โ it maps to sub_rounds, the exchanges per
pair). Output: statement (canonical primary, required) + optional
inner_thought. Active agents pair off in persona order, rotated per round
(an odd agent sits out); exchanges NEVER enter the shared conversation log โ
viewers see them (dramatic irony), other agents don't. Each participant's
latest exchange is surfaced back only to them via the reserved
whispers_<name> key (overwrite โ latest only) + . NOT
allowed inside a branch. Inference cost =
(agents รท 2) ร sub_rounds ร 2 (integer division; odd agent sits out).
Inference budget โ compute BEFORE writing each file:
per round: speak_all = agents | speak_each = agents ร sub_rounds
vote = agents | choose = agents ร 2 (round_robin) / agents
reflect = agents | whisper = (agents/2) ร sub_rounds ร 2
code phases (assign / score_calc / eliminate / summarize /
event_inject) = 0 | conditional = max(then, else)
total = per-round sum ร rounds โ target โค 50 (hard block > 100)
A bokete-shaped scenario (5 agents, 2 rounds, speak_all + vote) costs 20
and runs โ 4 min; keep the whole batch โค ~120 total so the cycle fits the
night budget even with retries.
Step 3 โ Run each scenario on the harness
Sequentially (one model in RAM at a time), for each generated YAML:
bash .claude/skills/scenario-factory/scripts/run_scenario.sh \
data/factory/scenarios/<DATE>/<id>.yaml \
~/Models/gemma-4-E2B-it-Q4_K_M.gguf \
data/factory/runs/<DATE>/<id>.jsonl 600
The wrapper prints one JSON status line (status: ok / failed /
config_error) and always exits 0 โ parse the line, record it, and
move to the next scenario regardless of outcome. Crash tolerance is the
contract (#253: a known llama.cpp SIGABRT kills a fraction of runs;
events up to the crash survive in the JSONL):
failed โ keep the partial JSONL + .stderr.log for diagnosis; do NOT
retry in-cycle (the harness already retried once internally).
config_error โ the generated YAML itself is broken (schema or >100
inference estimate). Fix the YAML once and re-run that scenario once;
if it config-errors again, record and move on.
Use a generous Bash timeout (โฅ 900 000 ms) or run_in_background per
run; expect ~4โ10 min each.
Step 4 โ Judge in-session
For each run with status == ok:
python3 .claude/skills/scenario-factory/scripts/format_transcript.py \
data/factory/runs/<DATE>/<id>.jsonl
Read the transcript and score the rubric โ each axis 1โ5 plus a one-line
comment per scenario:
| Axis | What 5 looks like | What 1 looks like |
|---|
| (a) coherence | Outputs consistently honor premise & personas | Agents ignore the setting |
| (b) interaction | Agents react to each other; votes track content | Parallel monologues |
| (c) breakdown_free | No format breaks, language drift, or nonsense loops | Frequent breakdowns |
| (d) humor | Genuinely funny lines a human would quote | Flat or incoherent |
| (e) development | The situation, relationships, or choices genuinely move across rounds; late rounds couldn't be predicted from round 1 | Each round replays round 1 (one-note gimmick repetition) |
Development (e) is UNIVERSAL โ score it for every scenario (unlike humor,
which is category-gated below), on the same 1โ5 scale. null is allowed only
for a single-round scenario (nothing can develop across one round; the digest
renders โ). In this skill development is column (e) after humor; the
sibling /scenario-refine journal renders it as (d) before payoff โ the
axes are keyed by NAME (development), not by letter, so the two orders are
intentionally different.
Category-aware (d): humor is the right axis for a comedy-family
scenario, but a scenario whose Step 1.5 axis rotated to a non-creative
category (game_theory / experimental / social_psychology / ethics)
should NOT be penalized for not being funny โ that would teach the loop to
avoid the diversity the census requested. For those, score (d) null (the
digest renders โ) and judge the scenario on (a)โ(c) + (e) development plus
whether it delivers its category's intended payoff (a real dilemma, a
believable experiment). Note this in the comment.
Failed runs get no scores โ record the status and the wrapper's
error (skim the partial transcript only to classify the failure for
the comment). The judge is a quality filter, not a safety screen.
Incubator (near-miss queue)
After scoring, append to data/factory/incubator.md (create with a one-line
header if absent) when a fresh-slot run is a near miss โ an incubated
v2 is never queued as a new entry; its result goes ONLY into the v2-outcome
update below, or each _vN id would restart at attempts: 0 and defeat the
cap: status ok, total โฅ 60% of
the available max (15/25; 12/20 when humor is null; 9/15 when humor and
development both are), AND the judge comment names ONE concrete promotion
blocker with a mechanical fix mapped to a PLAYBOOK rule or lever (rule 5
vote_winner leak; rule 27 one-note by R3 โ rounds cap + assign rotation;
rule 1 field-name drift โ guard). A failed run whose crash is design-caused
with a known fix (rules 2/3) also qualifies. NOT a near miss: a vague blocker
("not funny enough") or a model-limit one (rules 19/28 territory) โ those are
lessons-inbox material. Entry shape (status token line-initial so grep anchors);
grep-before-append on the base <slug>, not the exact id, so a scenario
family is never queued twice:
- open <DATE> <id> total=<n>/<max> blocker: <one line> fix: <one line, single lever> attempts: 0
v2 outcome (when tonight ran an incubated v2): update its entry in place โ
blocker cleared AND total โฅ original โ change - open to - resolved, append
resolved: <DATE> ฮ+<n>; it is now a promotion suggestion (Step 6 reports
both YAML paths; never auto-promoted โ ยง Promotion). Otherwise bump attempts:
and append <DATE>: <why still blocked>; if it now reads attempts: 2,
change - open to - retired and append retired: <DATE>, so the lane
never sinks a third night into it.
Step 5 โ Append the digest
- Write the results JSON (schema documented in
append_digest.py's
docstring: date / run_id / model / notes / per-scenario id, name, theme,
axis, yaml, run_log, status, attempts, duration_sec, scores (5 axes:
coherence / interaction / breakdown_free / humor / development), comment,
error) to a temp file. Set run_id to the RUN_ID fixed in ยง Constants โ
reuse that value, do not re-read the clock here. Set axis to the Step 1.5
axis this scenario targeted (e.g. "elimination / creative") so cross-night
rotation can read it back. Keep
notes a single line (a line-initial ## or | would split the digest
section on re-parse): tournament: <N> sketched โ <selected ids>; sketches: data/factory/sketches/<DATE>.md; incubator: <none | v2 of <id> โ resolved/blocked/retired>.
-
python3 .claude/skills/scenario-factory/scripts/append_digest.py \
--results /tmp/factory_results_<DATE>.json \
--digest data/factory/digest.md
- Verify:
grep -c 'factory-digest:' data/factory/digest.md must print
2 (both markers survived), and the new ## <DATE> โ <RUN_ID> section
exists.
Step 5.5 โ Propose lessons (inbox)
If the night VALIDATED or REFUTED a design lesson not already covered
by a PLAYBOOK rule โ or produced strong new evidence that should change a
rule's status โ append a dated, concept-level entry to
data/factory/lessons-inbox.md (gitignored; create it with a one-line header
if absent). Entry shape:
- <DATE> [candidate-status] <one-two sentence rule> (evidence: <ids>)
Grep-before-append: if an entry for the same lesson concept already
exists, append re-observed: <DATE> to that entry's line instead of
duplicating. Plain Read / Grep / Write โ no helper script.
The inbox is a PROPOSAL queue only โ the nightly cycle NEVER edits
PLAYBOOK.md. Promotion is human-driven (ยง Lessons promotion).
Step 6 โ Report
Summarize for the user: per-scenario status + scores + best line of the
night, failures with one-line causes, and where the artifacts live
(scenarios/<DATE>/, runs/<DATE>/, the appended digest section; the
lessons-inbox.md entries if any were proposed in Step 5.5). Add the tournament
summary (N sketched, picks with one-line reasons, any shortfall) and the
incubator outcomes (resolved โ "ready for promotion" with both YAML paths;
any retired entries). If the index was missing (Step 1 fallback), surface the
degraded-dedup notice with the --rebuild-index command.
data/factory/digest.md is a gitignored local log โ the new section is
appended in place and is NOT committed or pushed. Only promoting a
winning scenario (bundled preset or shared-scenario gallery; see
ยง Promotion) goes through the normal /orchestrate flow.
Promotion
The digest itself is never promoted โ it is a local judging journal. To
ship a winning scenario, pick a distribution channel by reach and commit
via an /orchestrate PR either way:
- Bundled preset (ships in the app binary): copy the winning YAML from
data/factory/scenarios/<date>/ to Pastura/Pastura/Resources/Presets/.
Landing under Resources/ routes it through the blocklist pre-commit
gate; reaches users only on the next TestFlight / App Store build.
- Shared-scenario gallery (remote โ served from
main via
raw.githubusercontent.com, so merge is the deploy): copy to
docs/gallery/<slug>_v1.yaml (rename the YAML's id: from
factory_<date>_<slug> to <slug>_v1; an incubated ..._v2 / _v3 drops
that suffix too and ships as <slug>_v1 โ it is a generation marker, not a
gallery version), then run
scripts/add-gallery-entry.sh. The gallery does not pass through the
blocklist gate โ curate by the judge scores yourself (hold back
low-coherence / low-humor runs). Full bridge: docs/gallery/README.md
ยง "Promoting from the scenario factory".
- Then consider a highlight (ADR-029). A promoted scenario whose judge
scores were strong is exactly the supply a highlight needs, and the
promotion PR is when its transcript is still on disk. Separate human step
with its own sign-off โ never bundled into an unattended run. Procedure and
the fitness call (utterance-driven scenarios excerpt well,
assign-driven
ones cannot): docs/gallery/README.md ยง Highlights. /scenario-refine
Step 3.5 surfaces candidates for entries already in the gallery.
Lessons promotion
data/factory/lessons-inbox.md (Step 5.5) โ PLAYBOOK.md happens in a
human-driven /orchestrate PR โ same pattern as scenario promotion. The PR:
- Compresses each promoted entry to PLAYBOOK's concept-level register
(invariant + why + evidence pointer; see the PLAYBOOK header discipline,
soft cap ~150 lines) โ never paste inbox prose verbatim.
- After merge, an operator step CLEARS promoted / duplicate / stale entries
from the local inbox, and clears
resolved / retired lines from the local
incubator.md. The PR itself can't do this โ both are gitignored local
files, invisible to the merge โ mirroring how the gitignored digest is
maintained locally rather than by the PR.
Scheduling (how the unattended run works)
- The skill never self-registers (see Non-goals "No scheduled
execution"). Scheduling is an operator action: a Claude Desktop
local Routine drives the cycle (see the recipe below). Cloud
routines are unusable here โ generation + judging burn the llama.cpp +
GGUF harness, which a cloud clone can't reach.
- The digest is a gitignored local log โ never committed or pushed.
Each night appends a section in place; there is no rolling PR and no
branch state.
append_digest.py bootstraps the file if it is absent.
A manual /scenario-factory behaves identically. (Promoting a winning
scenario is a separate /orchestrate PR โ see ยง Promotion.)
- Run in the user's main checkout โ not a throwaway worktree. The
gitignored digest, its
digest-index.jsonl sidecar, scenarios/<DATE>/,
runs/<DATE>/, sketches/<DATE>.md, and incubator.md (the cross-night
near-miss queue) must persist between nights so the full history stays
available โ Step 1 dedup reads the index (rebuilt from the digest); a fresh
per-run worktree would start with an empty bootstrapped digest and lose that
history. The digest being gitignored is what keeps the main working tree
clean despite running there.
- Routine recipe (Desktop โ Routines โ New routine):
- Type: Local
- Name:
scenario-factory-nightly
- Working folder: the user's main checkout (
/Users/tyabu12/Work/pastura)
- worktree toggle: OFF (must run in the persistent main checkout)
- Schedule: any slot works as long as the machine is awake + idle + on
AC and it does not overlap another family routine (queue-consumer is
1:30). The cycle is time-agnostic; same-day re-runs replace that date's
section (date-idempotent append).
- Permission mode:
acceptEdits (not bypassPermissions) โ
auto-accepts in-session file writes (generated YAMLs); least-privilege.
- Instructions:
/scenario-factory โ the slash must be leading, so
the harness expands it as a slash command. Prose around it ("Run the
/scenario-factory skillโฆ") needs model invocation, which this skill's
disable-model-invocation: true frontmatter blocks.