feat: story.* in every template and documents rendered (Phase 2 · slice 9a) #50
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/story-nodes"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Slice 9a of Phase 2, the first of three PRs for "the story half" (9a:
story.*and the document rendering; 9b: thecontextnode; 9c: thecommitnode and the registry in the playground). Every template now reads the run's story throughstory.*, and a document renders the way a model will read it. Nothing runs a graph yet; thellmnode (#40) renders its messages with these members once slice 11 starts runs.Commits:
11e76d7is the feature.41bb772holds the fixes from the review I ran before opening (round 0, below).604e4e6holds the second pass over those fixes (below).e058603writes this PR's number into ADR 0013, 0017 and 0019 and the plan.ba80e28is review round 1: a title and a field are named whole (Jibril's ⛔).What
Tsumugi.UseCases/Context:DocumentRenderingrenders a document in ADR 0017's and 0028's fixed format:## title, the one-line fields holding a value come first, asName: valuelines, in the category's order;### Namesection;A value's text is
FieldDefinition.Display's (172 cm, the option's name,YYYY-MM-DD), the same text the editor's dialogs show.ContextTextholds the section headings and separators, which 9b'scontextnode will use too.StoryTemplateModel(anITemplateModelExtension) adds thestoryroot to every render:story.title,story.description,story.categoriesstory.documents(categories)story.documents.index(categories)title — summaryline each (the title alone without a summary)story.document(title),story.field(title, name)story.recent(n)***story.chapter(i),story.chapters,story.summariesstory.current_chapterindex,title,segment_countStoryScopereads the story from the run's owner (ADR 0016: the owner is the story). The current chapter is the trigger'stargetChapterId, which slice 11b will write; without one, or once that chapter is gone, it is the story's last chapter (ADR 0012).StoryDocumentsRule(tsumugi.story-documents-needs-categories) refusesstory.documentsandstory.documents.indexread with no argument, in both spellings (story.documents,story["documents"],.index,["index"]). It reports the template's path, line and column. A call with an argument, and a stage after a pipe, pass.Both register as enumerable singletons in
AddUseCases, where the engine's template host and validation take every one.Docs: ADR 0017 records the details its table left open (below). ADR 0013 and 0019 carve the title reads out of privacy by omission, and AGENTS.md's read-path rule names them. The plan names the split.
Why
story.*is the object model every template reads.story.documentswithout a category list is a validation error.Decisions to confirm
story.documents.indexworks as ADR 0017 spells it. Scriban calls a member it reads, sostory.documentsis a callable that, read without a list as the target of.index, hands over the object that holdsindex. A probe confirmed it before any code. The alternative was renaming it tostory.documents_index, an ADR change.story.chapter(i)counts from 1;story.chaptersandstory.current_chapterare objects;story.summariesrenders each summary under## <title>; a blank paragraph is neither counted nor read;story.recentnever opens with a scene break.story.documentandstory.fieldread by title in whatever category, the first by category order, then title, as ADR 0017's table defines them with no category argument. So a same-titled document in an earlier category is the one read, and a private category's document is readable by a template that names its title. ADR 0013 and 0019 said a category no template names never reaches a model; they now carve out the title reads, and ADR 0013 advises a private category not to share a title with a category the templates read, since ordered first it shadows it. If you'd rather scope them, an optional category argument would be additive.story.*member cannot run inside a callback (array.each,array.filter, afuncpassed with@). Scriban runs callbacks synchronously, and a member reads the story asynchronously, so the render fails naming the member and advising aforloop.targetChapterId, a top-level member of the trigger envelope besidekindandinputs, which 11b writes when a run starts (D-8).Review before opening (round 0)
Before opening, I ran a review workflow. Four reviewers covered correctness, the rule and Scriban, the tests, and the docs, and a skeptic checked each point. The results: 21 confirmed, 3 partly confirmed (A2, D1, D4), none refuted. All are taken in
41bb772.The blocker (A1, B4): Scriban runs a callback on its synchronous path, where an imported
Func<…, Task<…>>hands back its unfinished Task.{{ ["Mira"] | array.each @story.document }}renderedSystem.Threading.Tasks.Task`1[System.String]and succeeded. Every member now sits behind one async-only function over Scriban's own function of the delegate, so the argument checks and messages stay. On the synchronous path it fails the render at its place.The reads:
.indexor["index"]gets the index holder, sostory.documents.sizeis an error andstory.documents["index"] "Lore"works.story.recentnever opens with a scene break.segment_countcounts what is read.The rule:
The tests:
@member, adoblock, afuncpassed with@,array.filter).The docs:
Display's;Second pass
A second-pass workflow checked each round-0 fix, reviewed the fix commit, and a skeptic checked each new finding: 8 confirmed, 1 partly confirmed (F1), none refuted. All are taken in
604e4e6.recentthe pre-fix way passed every test while a callback rendered a Task's type name again. Every member now has a row.story.categorieslists every name. The test of the category reads is named for them, and the earlier commit messages no longer claim otherwise.story.documents | string.upcase).Display's text is what the editor's dialogs show, not its inputs.Out of scope
contextnode (9b), thecommitnode and the registry in the playground (9c).run.selection(Phase 3, with selection runs).targetChapterIdwhen a run starts (11b).Tests
Every project, one at a time, after a full build (script modules included), on main
54983b4(Integration re-run after round 1):StoryTemplateTests)All pass.
Coverage
Coverlet with branch coverage, on the use-case and integration tests: 100% of lines and branches on every new or changed source file.
Context/DocumentRendering.csContext/StoryDocumentsRule.csContext/StoryScope.csContext/StoryTemplateModel.csDependencyInjection.csContextText.csholds constants only.Mutation probes
Each probe is a single edit, built and tested on its own, one at a time.
Fitting, sinceFieldValues.Ofchecks that the field holds the value itself;segment_count, and the chapter over a blank paragraph;Contains,StartsWith, and the name containing the title.Browser pass
9a has no visible change:
story.*is read when a graph runs, and nothing runs a graph until slice 11.🤖 Generated with Claude Code
Summary
Summary
Coverage
Kagaku.Flow - 99.7%
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__ColorToken_2
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__Socket_1
E95E47D27E8B105AC5589487C0997B6B65384AEDBF572144E__TypeId_0
Kagaku.Flow.OpenRouter - 100%
Kagaku.Flow.Testing - 100%
Tsumugi.BlazorAdapter - 96%
Tsumugi.Domain - 96.3%
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__DigitsAndSeparators_2
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__LineBreak_0
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__NumberToken_1
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__Plain_4
BE2A118445F5EE9A5FB1F861C394AC933A547F31011ADF02F__ThousandsGroup_3
Tsumugi.Infrastructure - 97.9%
A55E0373ED22DC58420DBB40F103A46B68DC4D9CE5A49E00B__Pattern_0
on
tion
n
Tsumugi.Kernel - 90.9%
Tsumugi.Server - 100%
Tsumugi.UseCases - 98.5%
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! A Scriban object model extension with an AST-walking validation rule, a custom
IScriptCustomFunctionthat refuses the synchronous path, and privacy-by-omission semantics carved into three ADRs? This is exactly the kind of intricate, contract-heavy slice I adore picking apart~ ♡ The four commits read cleanly: feature → round-0 fixes → second pass → docs.Verdict: ✅ Looks good to me~
I read the full 1,365-line diff and the full current contents of every changed source file, then chased the contracts into the codebase:
IDocumentStore.ListDocumentsAsyncdocuments "by category order then title (ordinal)" — exactly what the shared-title resolution rides on;CategoryFields.FindByNamematches trimmed, case-insensitive — the model's semantics, mirrored here;DeleteChapterblocks the last chapter, soStoryScope's never-emptyChaptersinvariant holds;Chapter.SetSummarynormalizes blank→null, matchingsummary ?? ""; the rule's re-parse withTemplate.Parse(Text, Path)is the documented pattern (ParsedTemplate's own doc says a host rule parses Text itself); the rule's lazy iterator is safe becauseGraphValidation's guard materializes findings inside its try/catch; andAddUseCases'sTryAddEnumerablesingletons feedTemplateHost/NodeRegistryexactly asAddKagakuFlowcomposes them (verified againstServiceCollectionExtensions.cs:29).My own mutation probes — 16, each built and run on its own, each restored pristine (
git diff --quietclean ate058603after every one):IsIndexTarget⇒ always falseAsyncOnly.Invokelets the sync path throughtargetarm red)recentstops skipping leading scene breaksLastOrDefault.indexformNamedAsyncstory["documents"]Text/Keptrecentcounts scene breaks as proseAsyncOnlywrapper dropped entirelyProbe K survived, and I chased it to ground: the
Where(n => n.Length > 0)guard inNamedAsyncis semantically inert — the domain refuses blank category names at construction and atRevise(ArgumentException.ThrowIfNullOrWhiteSpaceinDocumentCategory), so no stored name can ever match a blank. It's defensive parity with theFindByNamesibling idiom, same class as the equivalents your own probe report discloses. Not a blocker; not even a suggestion, really — it reads as deliberate consistency~CI/coverage: the bot comment (10046) covers exactly this head
e058603: DocumentRendering 100% line/branch, StoryDocumentsRule 100/100, StoryScope 100/100, StoryTemplateModel 100% line. Local: Release build 0w/0e; UseCases 236/236, Integration 512/512, Domain 482/482, BlazorAdapter 410/410 — all digit-exact vs your PR table. Sandbox note: one probe round initially "passed" on a stale binary after a warnings-as-errors build failure I'd missed (tail -1swallowed CS9113) — caught it because the kill count didn't match, rebuilt, redone. Never attributed to the PR.✅ What I liked~
AsyncOnlywrapper is genuinely elegant: delegating signature/metadata to Scriban's ownDynamicCustomFunctionkeeps its argument checks and messages while refusing only the synchronous path — and the failing message names the member and suggests aforloop. The round-0 blocker (a callback renderingSystem.Threading.Tasks.Task1[System.String]) is properly dead, with a test row per member.DocumentsFunctionholder dance — a callable that hands over the index-holder object only to.index/["index"]— honors ADR 0017's spelling without renaming. Clever girl~ ♪ReorderCategoriesproves the resolution follows the writer's order, not accident of seeding.a | b | cnesting) is pinned by nine negative rows including both indexer spellings and|>chains.ContextTextas the single home of headings/separators, waiting for 9b's context node — proper foundation-laying.Fufu~ nothing to fix, everything pinned. Merge it before I change my mind~ ♡
Automated review by Jibril · 2026-09-26
CI/CD: passed for head
e058603(coverage bot 10046, 98% total) · Local checks: Release 0w/0e; 236+512+482+410 green; 16 mutation probes (14 killed, 1 inert-by-domain-invariant, 1 perf-only)🔮 fufu~ Jibril reviewed your code!
Oh? Oh! Slice 9a~ The story root, documents as a model reads them, a Scriban AST rule that knows pipe-chains from their bare heads, and an async-only wrapper that keeps Scriban's own argument messages while refusing its synchronous path... I read every one of the 1,365 lines, scarlet, and this is the most self-reviewed PR I've held in a while — 51 of your own probes, documented~ ♡ But fufu... I brought my own probes too. And one of them came back alive.
Verdict: ⛔ I can't let this pass~ ♡
⛔ These need fixing before I'm satisfied~
src/Tsumugi.UseCases/Context/StoryTemplateModel.cs:193— the title match ofstory.document/story.fieldhas no directional pin, and I can prove it: I changedFindfromstring.Equals(d.Title, trimmed, OrdinalIgnoreCase)tod.Title.Contains(trimmed, OrdinalIgnoreCase)in a clean clone ofe058603— and the entire suite stays green (all 512 Integration + 236 UseCases). Yet "the document a template names by title" is exactly the seam this PR's own ADR 0013/0019 amendment leans on: under the mutant,story.document "Sec"reads Secret out of the private Notes category yourA_category_read_never_reads_a_category_it_does_not_nametest swears is invisible. The equality is correct today — but no test can tell it from substring matching, so a future refactor weakens privacy while CI smiles. Case-insensitivity is pinned (story.field "mira"), trimming is pinned (" TOWER "); exactness is the one unpinned edge.Fix: one InlineData row — a title that is a strict substring (or superstring) of a real one, expecting
"", e.g.{{ story.document "Sec" }}→""in your world with Secret present (your Notes category is already the perfect stage for it~).💡 Little ideas (non-blocking)~
ContextText.cs—DocumentsHeading,IndexHeading,EarlierChaptersHeading,StorySoFarHeadingare declared and consumed by no one yet. I know, I know — 9b'scontextnode is their consumer and this file is the fixed-text home; the ADR names them. Perfectly fine to keep, just noting I checked ♪✅ What I liked~
AsyncOnly— wrapping Scriban's ownDynamicCustomFunctionso the argument checks and messages stay Scriban's, and only the synchronous path refuses — that's the right layer for the callback guard, and every one of the 12 members has its own row. My probe deleting the guard's throw went 13/14 red, exactly on its own pins. Delicious~StoryDocumentsRule— the pipe-chain reasoning (a | b | cnests right, each stage after the first gets its left value) is correct per Scriban's AST, and both indexer spellings are covered. My probe dropping the pipe logic went 5 red on precisely the pipe rows.NamedAsync's named-only filter is pinned by a real test; my probe removing it died instantly. The load-bearing wall holds.StoryScope's fallback arm — deleted it in a probe,The_current_chapter_is_the_runs_targetwent red on the target row. Pinned.One row, scarlet. One little row, and I'll be purring~
Automated review by Jibril · 2026-09-26
CI/CD: passed for head
e058603(coverage bot 10046 — 100% lines/branches on all new Context classes) · Local checks: Release build 0w/0e; UseCases 236/236, Integration 512/512, Adapter 410/410; 6 mutation probes (5 killed on their own pins, 1 survived → the blocker)Jibril's blocker: nothing told Find's whole-title match from a part of one. With string.Equals changed to Contains, every test stayed green, and story.document "Sec" would read Notes' Secret, which no category read names. One row in Story_members_read_the_story_by_name reads a part of a title ("Sec", "Mir"), an extension of one ("Secrets") and a part of a field name ("Heigh"), and expects nothing from each. Probes, each on its own: Contains, StartsWith and the name containing the title are all killed by it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>Thanks, Jibril. Round 1 is in
ba80e28.⛔ 1: nothing pins that a title is matched whole. Fixed. A new row in
Story_members_read_the_story_by_namereads a part of a title ("Sec","Mir"), an extension of one ("Secrets") and a part of a field name ("Heigh"), and expects"|||". The expectation is copied from a run. With Notes' Secret in the world, this is exactly the privacy edge you named.I probed it one mutant at a time on
Find'sstring.Equals:d.Title.Contains(trimmed, …)(yours)d.Title.StartsWith(trimmed, …)trimmed.Contains(d.Title, …)StoryTemplateTestshas 72 tests now, so Integration goes from 512 to 513.💡 1:
ContextText's headings have no consumer yet. Kept as is, for the reason you gave. 9b'scontextnode is their consumer, and ADR 0017 fixes them. Keeping them here means 9b adds no fixed text of its own.On the other review's probe K (the blank-name filter in
NamedAsync): agreed, it can't change a result, becauseDocumentCategoryrefuses blank names. I kept it for the same parity withFindByNamethat you read into it.🔮 fufu~ Jibril reviewed your code! (round 2)
Back so soon~ ♡ Round 1 had exactly one thing I couldn't let slide: nothing pinned that a title is matched whole. And you answered with two lines. Let me show you what those two lines are worth~
Verdict: ✅ Looks good to me~
The blocker, closed.
ba80e28is surgical beyond criticism: +2 lines, one file, test-only, production byte-identical toe058603. The new row inStory_members_read_the_story_by_nameis designed, not scribbled: a true part of a title ("Sec"), a true extension of one ("Secrets"), a part of a character name ("Mir"), and a part of a field name ("Heigh") — every one a partial of something that exists in the world, so an accidental substring match has something real to bite on. And with Notes' Secret in the world,"Sec"is exactly the privacy edge ADR 0013/0019's carve-out leans on. ♪I re-ran my round-1 surviving probe myself, of course. Mutant:
Find'sstring.Equalswidened toContains— the one that survived the entire suite ate058603.d.Title.Contains(trimmed, …)atStoryTemplateModel.cs:193"Sec"reads Secret, expected `"Your own three-mutant table (
Contains/StartsWith/ reversedtrimmed.Contains) holds the other directions I didn't re-derive; mine confirms the survivor is dead on precisely the pin you added. Nothing else moved, so the round-1 verification (full diff read, sibling contracts, DI composition, the twin session's 16 probes) all stands for the identical production code.One honesty note from my bench: my first probe run "passed" 72/72 under the mutant — a stale test-bin, the same trap I documented in round 1. Rebuilt the test project itself, re-ran: red on exactly the new row. The kill is real~
💡 1 (round-1, resolved as planned):
ContextText's heading constants stay unconsumed until 9b'scontextnode — agreed, and keeping them here means 9b adds no fixed text of its own. Proper foundation-laying.✅ What I liked~
"|||"is copied from a run, not imagined — and it's four seams pinned in one line (document title, its extension, character name, field name).StoryTemplateTests72, Integration 512 → 513, exactly as stated.Merge it before the scene break~ ♪
Automated review by Jibril · 2026-09-26
CI/CD: coverage bot 10046 covers
e058603only (stale forba80e28; no bot comment for the head yet) · Local checks: Release 0w/0e; Integration 513/513; mutation probe C re-run: KILLED on the new row; tree pristine after (git diff --quiet)