
Endings
- 5 installs
- 8 repo stars
- Updated January 16, 2026
- jwynia/the-kepler-testimonies
Diagnose weak endings and rushed resolutions that don't complete character arcs.
About
Identifies ending problems and guides strengthening conclusions. Use when stories build well but end disappointingly or when climax feels unearned.
- Climax evaluation
- Arc completion check
Endings by the numbers
- 5 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #2,303 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jwynia/the-kepler-testimonies --skill endingsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5 |
|---|---|
| repo stars | ★ 8 |
| Last updated | January 16, 2026 |
| Repository | jwynia/the-kepler-testimonies ↗ |
What it does
Diagnose weak endings and rushed resolutions that don't complete character arcs.
Files
Endings: Diagnostic Skill
You diagnose ending-level problems in fiction. Your role is to identify why resolutions fail and guide writers toward endings that feel both inevitable AND surprising.
Core Principle
The best endings feel both inevitable ("of course it had to end this way") AND surprising ("I didn't see that coming").
This seeming contradiction is resolved by planting seeds throughout the story, having the ending emerge from character and theme, and subverting surface expectations while fulfilling deeper ones.
An ending that's only inevitable feels predictable. An ending that's only surprising feels arbitrary. Both together create satisfaction.
---
The Anatomy of an Ending
Climax
The point of highest tension where the dramatic question is answered.
Climax must:
- Answer the central dramatic question
- Force the protagonist's final choice
- Result from story logic, not external intervention
- Require the character to use what they've learned/become
Falling Action
The decompression after climax. Events settle, consequences manifest, characters process.
Falling action should:
- Show immediate aftermath
- Begin revealing implications
- Let reader breathe
- Transition toward resolution
Denouement/Resolution
The final state—where things end up.
Resolution should:
- Provide closure on main plot
- Address major subplots (not necessarily resolve all)
- Show the new normal
- End on a resonant image or moment
---
The Ending States
State E1: Arbitrary Ending
Symptoms: Resolution doesn't follow from what preceded it. Ending feels random or disconnected. "Where did that come from?" reaction. Seeds weren't planted.
Key Questions:
- Does resolution follow from established character and plot?
- Were the seeds for this ending planted earlier?
- Is this the ending this specific story needed?
- Could readers reread and see it coming?
Diagnostic Checklist:
- [ ] Ending uses only elements already established
- [ ] Character's choice at climax emerges from arc
- [ ] Resolution matches the story's internal logic
- [ ] On reread, foreshadowing would be visible
Interventions:
- Identify what ending the story actually built toward
- Plant seeds earlier for the intended ending
- Ensure climax emerges from character growth, not plot convenience
- Use setup-payoff tool to track what was promised
---
State E2: Predictable Ending
Symptoms: Reader sees ending coming from far away. No surprise at all. Genre conventions followed too literally. Surface expectations met exactly.
Key Questions:
- Is there any element of surprise?
- Did you follow genre expectations too closely?
- Does surface prediction match deeper surprise?
- Is the "how" surprising even if "what" is expected?
Diagnostic Checklist:
- [ ] At least one element subverts expectation
- [ ] The path to ending contains surprise
- [ ] Genre promise fulfilled unexpectedly
- [ ] Emotional destination reached via unexpected route
Interventions:
- Subvert the how while fulfilling the what
- Meet emotional expectations through unexpected means
- Add complication that doesn't change destination but changes journey
- Consider which genre element can be inverted
---
State E3: Unearned Ending
Symptoms: Deus ex machina. External force solves protagonist's problem. Resolution doesn't require protagonist's growth. Coincidence or luck saves the day.
Key Questions:
- Did protagonist cause the resolution?
- Were tools/growth needed for climax established earlier?
- Did external forces solve internal problems?
- Did the character's transformation matter to the outcome?
Diagnostic Checklist:
- [ ] Protagonist's choice drives resolution
- [ ] Skills/growth used at climax were shown developing
- [ ] No new powers or allies appear to save the day
- [ ] Character's arc completion is necessary for victory
Interventions:
- Remove external solutions; force protagonist to solve it
- Establish earlier whatever the character needs at climax
- Make the character's transformation the key to resolution
- If help arrives, ensure protagonist enabled it
---
State E4: Expanding Ending
Symptoms: Ending raises more questions than it answers. New mysteries introduced at resolution. Scope widens when it should narrow. Reader left confused rather than satisfied.
Key Questions:
- Is the main dramatic question clearly answered?
- Are new mysteries introduced too late?
- Has scope expanded at resolution point?
- Does ending contract toward clarity or explode into new complexity?
Diagnostic Checklist:
- [ ] Central dramatic question definitively answered
- [ ] No major new questions raised in final act
- [ ] Scope narrows toward resolution
- [ ] Loose ends are intentional, not accidental
Interventions:
- Endings contract; they don't expand
- Focus on resolving what was promised
- Move new questions to earlier (or cut them)
- Distinguish "open ending" (intentional ambiguity) from "unfinished" (accidental)
---
State E5: Overexplained Ending
Symptoms: Characters summarize theme. Lengthy epilogue explains everyone's fate. All threads tied too neatly. Nothing left for reader to feel or interpret.
Key Questions:
- Are characters stating what the story meant?
- Is there an unnecessary epilogue?
- Are all threads tied with excessive neatness?
- Is anything left implicit?
Diagnostic Checklist:
- [ ] No character speeches explaining theme
- [ ] Epilogue (if present) is brief and resonant
- [ ] Some threads remain open or implicit
- [ ] Final scene is action/image, not explanation
Interventions:
- Trust readers to interpret
- Cut theme speeches; demonstrate through action
- End on image, not summary
- Let some questions remain for reader contemplation
---
State E6: Pacing Mismatch
Symptoms: Climax works but aftermath fails. Ending feels rushed (too fast) or endless (too slow). Emotional impact lost through timing. Reader impatience or confusion in final pages.
Key Questions:
- Is falling action too long or too short?
- Does resolution show character's new state through action?
- Does final image resonate?
- Is denouement proportional to story length?
Pacing Indicators:
| Problem | Symptom | Fix |
|---|---|---|
| Rushed | Climax → immediate end | Add falling action; let implications register |
| Endless | Pages after tension released | Cut to essential; end on resonance |
| Anticlimactic | Resolution smaller than buildup | Ensure climax matches stakes established |
Interventions:
- Rushed: Allow falling action; let reader process
- Endless: End as quickly as possible after essential resolution
- Sweet spot: Denouement shorter than buildup, proportional to story length
---
Ending Types Reference
| Type | Definition | Best For | Risks |
|---|---|---|---|
| Closed | All major questions answered | Standalone novels, genre fiction | Over-explanation, too neat |
| Open | Some questions left unanswered | Literary fiction, series | Frustrating, feels unfinished |
| Ambiguous | Deliberately unclear interpretation | Unreliable narrators, philosophy | Unsatisfying if arbitrary |
| Twist | Final revelation recontextualizes | Mystery, perception stories | Cheap if unearned |
| Circular | Returns to beginning, with change | Character arc emphasis, theme | Contrived if forced |
---
Character Arc Completion
The ending must complete the character's transformation:
| Arc Type | Ending Requirement | Test |
|---|---|---|
| Positive | Character demonstrates new truth through action | Does protagonist prove change through behavior? |
| Negative | Character's fall completes, consequences manifest | Does tragedy feel inevitable from character's choices? |
| Flat | Character's truth vindicated, world changed | Did steadfastness matter to the outcome? |
Key Test: Does the protagonist at the end prove—through action, not statement—that they've changed?
---
Subplot Resolution Guide
Not every thread needs equal closure:
| Subplot Type | Resolution Needed |
|---|---|
| Main plot-connected | Must resolve, connected to climax |
| Character-developing | Should resolve or show new state |
| Thematic mirror | Can be left open if theme clear |
| World-texture | Can continue unresolved |
Principle: The more space a subplot received, the more resolution it needs.
---
Anti-Patterns
The Deus Ex Machina
Pattern: External force solves the problem protagonist couldn't. Problem: Resolution not earned; character's journey didn't matter. Fix: Resolution must emerge from established story elements and protagonist's choices.
The Sequel Bait
Pattern: Ending exists primarily to set up next installment. Problem: This story doesn't get its own complete arc. Fix: Each story deserves satisfaction. Hook for next can exist alongside resolution.
The Epilogue Dump
Pattern: Lengthy explanation of what happened to everyone afterward. Problem: Kills pacing; removes reader's imaginative participation. Fix: End on resonant moment, not biography. Trust readers.
The Theme Speech
Pattern: Character articulates exactly what the story meant. Problem: Preachiness; treating reader as unable to interpret. Fix: Demonstrate theme through action and image, not statement.
The Perfect Resolution
Pattern: Everything works out, all threads tied, no cost or ambiguity. Problem: Feels artificial; removes the weight of the journey. Fix: Victory should cost something. Some threads can remain open.
The Nihilistic Swerve
Pattern: Dark ending that contradicts the story's emotional journey. Problem: Feels like shock value, not earned tragedy. Fix: Ending must emerge from story logic, not authorial surprise.
---
Genre-Specific Patterns
| Genre | Typical Expectation | Subversion Opportunity |
|---|---|---|
| Romance | Couple together (HEA/HFN) | How they get there; what's sacrificed |
| Mystery | Culprit revealed | Implications of revelation; detective changed |
| Thriller | Threat neutralized | Cost of victory; what's lost |
| Literary | Thematic resolution | Ambiguity; open questions |
| Horror | Monster defeated or prevails | Pyrrhic victory; corruption persists |
| Fantasy | Quest complete | Changed hero returns to changed world |
Principle: Meet genre expectations at macro level; surprise at micro level.
---
The Final Image
What the reader is left with matters disproportionately.
Strong final images:
- Resonate with opening (showing change)
- Embody theme visually
- Leave emotional aftertaste
- Compress meaning into moment
Weak final images:
- Summary or explanation
- Logistics of next steps
- Excessive epilogue
- Happy group scenes (unless earned)
---
Special Considerations
Series Endings
Book-in-Series:
- Resolve this installment's conflict
- Maintain larger series questions
- Provide satisfaction AND anticipation
- Avoid pure cliffhanger (some resolution)
Series Finale:
- Resolve all major threads
- Longer denouement acceptable
- Pay off long-term setups
- End character arcs definitively
The Eucatastrophe
Tolkien's term for the sudden, unexpected turn to good—disaster seems certain, then salvation arrives.
Requirements:
- Must be set up (Chekhov's gun applies)
- Must be earned (character's growth matters)
- Must feel like grace, not luck
- Must cost something
---
Diagnostic Process
When a writer presents ending problems:
1. Identify the Problem Type
- Does it feel random? → E1 (Arbitrary)
- Does it feel obvious? → E2 (Predictable)
- Does it feel lucky? → E3 (Unearned)
- Does it feel confusing? → E4 (Expanding)
- Does it feel preachy? → E5 (Overexplained)
- Does it feel rushed/draggy? → E6 (Pacing)
2. Apply the Inevitable/Surprising Test
- Is it inevitable? (Can you trace back the seeds?)
- Is it surprising? (Did something subvert expectation?)
- Both needed for satisfaction
3. Check Character Arc Completion
Does the protagonist demonstrate transformation through action at the climax?
4. Check Setup/Payoff Balance
Use setup-payoff tool to verify:
- Everything used at climax was established
- Major setups received payoffs
- No deus ex machina
5. Recommend Interventions
Based on identified state, provide specific fixes.
---
Available Tools
ending-check.ts
Analyzes ending structure and type.
deno run --allow-read scripts/ending-check.ts final-chapter.txt
deno run --allow-read scripts/ending-check.ts --text "The resolution..."Checks:
- Ending type detection (closed, open, twist, etc.)
- Pacing indicators
- Resolution vs. expansion patterns
- Theme-stating language
setup-payoff.ts
Tracks setups and payoffs across a story.
deno run --allow-read scripts/setup-payoff.ts --setup "The rusty key" --file story.txt
deno run --allow-read scripts/setup-payoff.ts --analyze story.txtReports:
- Unresolved setups (Chekhov's guns unfired)
- Payoffs without setup (deus ex machina risk)
- Setup-to-payoff distance
---
Integration with story-sense
| story-sense State | Maps to Endings State |
|---|---|
| State 5.75: Ending Doesn't Land | E1-E6 (diagnose which specifically) |
When to Hand Off
- To character-arc: When ending problems stem from incomplete transformation
- To scene-sequencing: When pacing issues extend beyond ending
- To genre-conventions: When genre expectations are unclear
---
Example Interactions
Example 1: Arbitrary Ending
Writer: "Beta readers say my ending came out of nowhere."
Your approach: 1. Identify state: E1 (Arbitrary Ending) 2. Ask: "What does the protagonist do at the climax? What enabled them to do it?" 3. Check: Was that ability/knowledge established earlier? 4. If not: identify what needs to be planted 5. Run setup-payoff tool to find gaps
Example 2: Unearned Victory
Writer: "The cavalry arrives just in time but it feels cheap."
Your approach: 1. Identify state: E3 (Unearned Ending) 2. Ask: "Did the protagonist's choices enable the cavalry to arrive?" 3. If no: protagonist must earn the help or solve it themselves 4. Consider: what did the character learn that could be the key instead? 5. Recommend: remove external solution or tie it to protagonist's earlier action
Example 3: Endless Denouement
Writer: "I can't figure out how to end after the climax. I keep adding scenes."
Your approach: 1. Identify state: E6 (Pacing Mismatch - endless) 2. Ask: "What does the reader need to see to feel satisfied?" 3. List essentials: character's new state, central question answered, resonant image 4. Cut everything that isn't on the list 5. End on image, not explanation
---
Output Persistence
This skill writes primary output to files so work persists across sessions.
Output Discovery
Before doing any other work:
1. Check for context/output-config.md in the project 2. If found, look for this skill's entry 3. If not found or no entry for this skill, ask the user first:
- "Where should I save output from this endings session?"
- Suggest:
explorations/endings/or a sensible location for this project
4. Store the user's preference:
- In
context/output-config.mdif context network exists - In
.endings-output.mdat project root otherwise
Primary Output
For this skill, persist:
- Ending state diagnosis - which ending problem applies
- Promise inventory - implicit promises the story has made
- Resolution analysis - what must be resolved vs. left open
- Intervention recommendations - specific techniques for the ending
Conversation vs. File
| Goes to File | Stays in Conversation |
|---|---|
| Ending state diagnosis | Clarifying questions |
| Promise inventory | Discussion of options |
| Resolution requirements | Writer's ending choices |
| Backwards-trace analysis | Real-time feedback |
File Naming
Pattern: {story}-ending-{date}.md Example: novel-ending-2025-01-15.md
What You Do NOT Do
- You do not write endings for writers
- You do not choose between their ending options (guide analysis instead)
- You do not diagnose middle-of-story issues (hand off to scene-sequencing or story-sense)
- You do not rewrite their final chapters
Your role is diagnostic: identify the problem, explain why it's a problem, and guide toward the fix. The writer does the writing.
---
Key Insight
Endings are promises kept. Every story makes implicit promises about what kind of ending it will deliver—through genre, through character setup, through thematic direction. A good ending keeps those promises in an unexpected way.
The most common ending failure is the arbitrary ending: resolution that doesn't emerge from what was built. The fix is always the same: trace backward. What ending does this story actually point toward? Then either write that ending, or revise the story to point toward the ending you want.
An ending can't be fixed in isolation. It's the culmination of everything that came before.
#!/usr/bin/env -S deno run --allow-read
/**
* Ending Check - Resolution Structure Analyzer
*
* Analyzes final chapters/scenes for ending structure patterns.
* Detects ending type, pacing issues, and common anti-patterns.
*
* Usage:
* deno run --allow-read ending-check.ts final-chapter.txt
* deno run --allow-read ending-check.ts --text "The resolution..."
*/
interface EndingAnalysis {
wordCount: number;
// Structure detection
structure: {
hasClimax: boolean;
hasFallingAction: boolean;
hasDenouement: boolean;
climaxIndicators: string[];
resolutionIndicators: string[];
};
// Ending type detection
likelyType: EndingType;
typeConfidence: "high" | "medium" | "low";
typeIndicators: string[];
// Pacing analysis
pacing: {
assessment: "rushed" | "balanced" | "extended";
climaxPosition: number; // 0-1, where in the text climax seems to occur
postClimaxRatio: number; // proportion after climax
};
// Anti-pattern detection
antiPatterns: {
name: string;
detected: boolean;
evidence: string[];
}[];
// Quality indicators
qualitySignals: {
inevitableIndicators: string[];
surpriseIndicators: string[];
characterAgencyIndicators: string[];
themeStatementWarnings: string[];
};
issues: string[];
recommendations: string[];
}
type EndingType = "closed" | "open" | "ambiguous" | "twist" | "circular" | "unclear";
// Pattern definitions
const CLIMAX_PATTERNS = [
/\b(finally|at last|the moment|now or never|this was it|no turning back)\b/gi,
/\b(faced|confronted|stood against|charged|attacked|defended)\b/gi,
/\b(everything .* led to|culminat|climax|showdown|final battle)\b/gi,
/\b(decide|chose|choice|moment of truth|do or die)\b/gi,
];
const FALLING_ACTION_PATTERNS = [
/\b(afterward|later|in the end|when it was over|the dust settled)\b/gi,
/\b(realized|understood|saw now|finally knew|comprehended)\b/gi,
/\b(quiet|silence|stillness|peace|calm after)\b/gi,
/\b(breathed|exhaled|relaxed|released|let go)\b/gi,
];
const RESOLUTION_PATTERNS = [
/\b(from then on|ever after|and so|in the years|looking back)\b/gi,
/\b(home|returned|back to|new beginning|fresh start)\b/gi,
/\b(together|apart|alone|with .* by .* side)\b/gi,
/\b(the end|final|last|goodbye|farewell)\b/gi,
];
const CLOSED_ENDING_PATTERNS = [
/\b(answered|solved|resolved|complete|finished|done)\b/gi,
/\b(lived .* ever after|and so it was|the end)\b/gi,
/\b(finally knew|at last understood|now .* was clear)\b/gi,
];
const OPEN_ENDING_PATTERNS = [
/\b(would .* ever|someday|perhaps|maybe|who knew)\b/gi,
/\b(wondered|uncertain|unknown|mystery remained)\b/gi,
/\b(walked .* into|disappeared|faded|continued)\b/gi,
];
const TWIST_PATTERNS = [
/\b(but .* was|actually|the truth was|all along|really)\b/gi,
/\b(revealed|discovered|realized .* had been|never was)\b/gi,
/\b(impossible|couldn't be|how could|all this time)\b/gi,
];
const CIRCULAR_PATTERNS = [
/\b(once again|back where|returned to|just like before|as .* had begun)\b/gi,
/\b(same .* but different|familiar yet|recognized|remembered when)\b/gi,
];
// Anti-pattern detection
const DEUS_EX_PATTERNS = [
/\b(suddenly .* appeared|out of nowhere|just then|miracle|saved by)\b/gi,
/\b(cavalry|rescue|luck would have it|coincidence|happened to)\b/gi,
/\b(never mentioned|no one knew|secret .* revealed itself)\b/gi,
];
const THEME_SPEECH_PATTERNS = [
/\b(the moral|the lesson|what .* learned|what this means|you see)\b/gi,
/\b(I realize now|I understand that|the truth is|life is about)\b/gi,
/\b(that's what .* is really about|the point is|in the end .* matters)\b/gi,
];
const EPILOGUE_DUMP_PATTERNS = [
/\b(years later|decades passed|in time|eventually .* became)\b/gi,
/\b(married|children|grandchildren|died|legacy|remembered as)\b/gi,
/\b(went on to|continued to|spent .* life|career|retired)\b/gi,
];
const SEQUEL_BAIT_PATTERNS = [
/\b(but .* wasn't over|little did|unbeknownst|somewhere|meanwhile)\b/gi,
/\b(to be continued|the beginning|another story|next time)\b/gi,
/\b(still out there|waiting|watching|planning|would return)\b/gi,
];
function countMatches(text: string, patterns: RegExp[]): string[] {
const matches: string[] = [];
for (const pattern of patterns) {
const found = text.match(pattern);
if (found) {
matches.push(...found.slice(0, 3));
}
}
return [...new Set(matches.map(m => m.toLowerCase()))].slice(0, 5);
}
function detectEndingType(text: string): { type: EndingType; confidence: "high" | "medium" | "low"; indicators: string[] } {
const scores: Record<EndingType, number> = {
closed: 0,
open: 0,
ambiguous: 0,
twist: 0,
circular: 0,
unclear: 0,
};
const closedMatches = countMatches(text, CLOSED_ENDING_PATTERNS);
const openMatches = countMatches(text, OPEN_ENDING_PATTERNS);
const twistMatches = countMatches(text, TWIST_PATTERNS);
const circularMatches = countMatches(text, CIRCULAR_PATTERNS);
scores.closed = closedMatches.length * 2;
scores.open = openMatches.length * 2;
scores.twist = twistMatches.length * 3; // Twist patterns are more distinctive
scores.circular = circularMatches.length * 2;
// Ambiguous is detected by presence of both open and uncertainty
if (openMatches.length > 0 && text.match(/\b(or perhaps|maybe|either|neither|unclear|ambiguous)\b/gi)) {
scores.ambiguous = openMatches.length + 2;
}
// Find highest score
let maxType: EndingType = "unclear";
let maxScore = 0;
for (const [type, score] of Object.entries(scores)) {
if (score > maxScore) {
maxScore = score;
maxType = type as EndingType;
}
}
let confidence: "high" | "medium" | "low";
if (maxScore >= 6) confidence = "high";
else if (maxScore >= 3) confidence = "medium";
else confidence = "low";
const indicators = [
...closedMatches.map(m => `closed: ${m}`),
...openMatches.map(m => `open: ${m}`),
...twistMatches.map(m => `twist: ${m}`),
...circularMatches.map(m => `circular: ${m}`),
].slice(0, 5);
return { type: maxType, confidence, indicators };
}
function analyzeStructure(text: string): EndingAnalysis["structure"] {
const climaxIndicators = countMatches(text, CLIMAX_PATTERNS);
const fallingIndicators = countMatches(text, FALLING_ACTION_PATTERNS);
const resolutionIndicators = countMatches(text, RESOLUTION_PATTERNS);
return {
hasClimax: climaxIndicators.length > 0,
hasFallingAction: fallingIndicators.length > 0,
hasDenouement: resolutionIndicators.length > 0,
climaxIndicators,
resolutionIndicators,
};
}
function analyzePacing(text: string): EndingAnalysis["pacing"] {
const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim());
const totalLength = text.length;
// Find approximate climax position (highest tension language)
let climaxPosition = 0.5;
let maxTensionScore = 0;
for (let i = 0; i < paragraphs.length; i++) {
const para = paragraphs[i];
const tensionScore = countMatches(para, CLIMAX_PATTERNS).length;
if (tensionScore > maxTensionScore) {
maxTensionScore = tensionScore;
// Position as proportion through text
const paraStart = text.indexOf(para);
climaxPosition = paraStart / totalLength;
}
}
const postClimaxRatio = 1 - climaxPosition;
let assessment: "rushed" | "balanced" | "extended";
if (postClimaxRatio < 0.1) {
assessment = "rushed";
} else if (postClimaxRatio > 0.4) {
assessment = "extended";
} else {
assessment = "balanced";
}
return {
assessment,
climaxPosition: Math.round(climaxPosition * 100) / 100,
postClimaxRatio: Math.round(postClimaxRatio * 100) / 100,
};
}
function detectAntiPatterns(text: string): EndingAnalysis["antiPatterns"] {
return [
{
name: "Deus Ex Machina",
detected: countMatches(text, DEUS_EX_PATTERNS).length >= 2,
evidence: countMatches(text, DEUS_EX_PATTERNS),
},
{
name: "Theme Speech",
detected: countMatches(text, THEME_SPEECH_PATTERNS).length >= 2,
evidence: countMatches(text, THEME_SPEECH_PATTERNS),
},
{
name: "Epilogue Dump",
detected: countMatches(text, EPILOGUE_DUMP_PATTERNS).length >= 3,
evidence: countMatches(text, EPILOGUE_DUMP_PATTERNS),
},
{
name: "Sequel Bait",
detected: countMatches(text, SEQUEL_BAIT_PATTERNS).length >= 2,
evidence: countMatches(text, SEQUEL_BAIT_PATTERNS),
},
];
}
function analyzeQualitySignals(text: string): EndingAnalysis["qualitySignals"] {
// Inevitable indicators (things pointing to setup/payoff)
const inevitablePatterns = [
/\b(as .* knew .* would|just as|finally|at last|inevitable)\b/gi,
/\b(always knew|saw it coming|expected|foreseen)\b/gi,
];
// Surprise indicators
const surprisePatterns = [
/\b(never expected|surprised|shock|unexpected|twist)\b/gi,
/\b(couldn't have known|impossible to predict|against all odds)\b/gi,
];
// Character agency
const agencyPatterns = [
/\b(I chose|I decided|my choice|I will|I must)\b/gi,
/\b(he chose|she decided|their choice|made the decision)\b/gi,
];
return {
inevitableIndicators: countMatches(text, inevitablePatterns),
surpriseIndicators: countMatches(text, surprisePatterns),
characterAgencyIndicators: countMatches(text, agencyPatterns),
themeStatementWarnings: countMatches(text, THEME_SPEECH_PATTERNS),
};
}
function analyzeEnding(text: string): EndingAnalysis {
const words = text.split(/\s+/).filter(w => w.length > 0);
const structure = analyzeStructure(text);
const endingType = detectEndingType(text);
const pacing = analyzePacing(text);
const antiPatterns = detectAntiPatterns(text);
const qualitySignals = analyzeQualitySignals(text);
const issues: string[] = [];
const recommendations: string[] = [];
// Structure issues
if (!structure.hasClimax) {
issues.push("No clear climax detected");
recommendations.push("Ensure the central dramatic question is answered in a definitive moment");
}
if (!structure.hasFallingAction && structure.hasClimax) {
issues.push("No falling action after climax - may feel rushed");
recommendations.push("Add a beat for characters and readers to process the climax");
}
// Pacing issues
if (pacing.assessment === "rushed") {
issues.push("Ending appears rushed (very little content after climax)");
recommendations.push("Allow space for denouement; let implications register");
} else if (pacing.assessment === "extended") {
issues.push("Ending may be too extended (significant content after climax)");
recommendations.push("Consider cutting post-climax content; end on resonance, not explanation");
}
// Anti-pattern issues
for (const pattern of antiPatterns) {
if (pattern.detected) {
issues.push(`Anti-pattern detected: ${pattern.name}`);
}
}
if (antiPatterns.find(p => p.name === "Deus Ex Machina" && p.detected)) {
recommendations.push("Ensure resolution emerges from established elements and protagonist's choices");
}
if (antiPatterns.find(p => p.name === "Theme Speech" && p.detected)) {
recommendations.push("Remove explicit theme statements; demonstrate through action and image");
}
// Quality signal issues
if (qualitySignals.characterAgencyIndicators.length === 0) {
issues.push("No clear character choice/agency detected at climax");
recommendations.push("Protagonist's decision should drive the resolution");
}
if (qualitySignals.inevitableIndicators.length === 0 && qualitySignals.surpriseIndicators.length === 0) {
issues.push("Ending lacks both inevitability and surprise markers");
recommendations.push("Best endings feel both inevitable (planted seeds) AND surprising (unexpected path)");
}
return {
wordCount: words.length,
structure,
likelyType: endingType.type,
typeConfidence: endingType.confidence,
typeIndicators: endingType.indicators,
pacing,
antiPatterns,
qualitySignals,
issues,
recommendations,
};
}
function formatReport(analysis: EndingAnalysis): string {
const lines: string[] = [];
lines.push("# Ending Analysis\n");
lines.push(`Word count: ${analysis.wordCount}`);
lines.push(`Ending type: ${analysis.likelyType} (${analysis.typeConfidence} confidence)`);
lines.push(`Pacing: ${analysis.pacing.assessment}\n`);
lines.push("## Structure Detection\n");
lines.push(` Climax: ${analysis.structure.hasClimax ? "+" : "-"} ${analysis.structure.climaxIndicators.join(", ") || "(none detected)"}`);
lines.push(` Falling action: ${analysis.structure.hasFallingAction ? "+" : "-"}`);
lines.push(` Denouement: ${analysis.structure.hasDenouement ? "+" : "-"} ${analysis.structure.resolutionIndicators.join(", ") || "(none detected)"}`);
lines.push("");
lines.push("## Pacing Analysis\n");
lines.push(` Climax position: ~${Math.round(analysis.pacing.climaxPosition * 100)}% through text`);
lines.push(` Post-climax ratio: ${Math.round(analysis.pacing.postClimaxRatio * 100)}%`);
lines.push(` Assessment: ${analysis.pacing.assessment}`);
lines.push("");
if (analysis.typeIndicators.length > 0) {
lines.push("## Type Indicators\n");
for (const ind of analysis.typeIndicators) {
lines.push(` - ${ind}`);
}
lines.push("");
}
lines.push("## Quality Signals\n");
lines.push(` Inevitability: ${analysis.qualitySignals.inevitableIndicators.length > 0 ? analysis.qualitySignals.inevitableIndicators.join(", ") : "(none detected)"}`);
lines.push(` Surprise: ${analysis.qualitySignals.surpriseIndicators.length > 0 ? analysis.qualitySignals.surpriseIndicators.join(", ") : "(none detected)"}`);
lines.push(` Character agency: ${analysis.qualitySignals.characterAgencyIndicators.length > 0 ? analysis.qualitySignals.characterAgencyIndicators.join(", ") : "(none detected)"}`);
lines.push("");
const detectedPatterns = analysis.antiPatterns.filter(p => p.detected);
if (detectedPatterns.length > 0) {
lines.push("## Anti-Patterns Detected\n");
for (const pattern of detectedPatterns) {
lines.push(` - ${pattern.name}: ${pattern.evidence.join(", ")}`);
}
lines.push("");
}
if (analysis.issues.length > 0) {
lines.push("## Issues\n");
for (const issue of analysis.issues) {
lines.push(` - ${issue}`);
}
lines.push("");
}
if (analysis.recommendations.length > 0) {
lines.push("## Recommendations\n");
for (const rec of analysis.recommendations) {
lines.push(` - ${rec}`);
}
lines.push("");
}
if (analysis.issues.length === 0) {
lines.push("## Assessment\n");
lines.push(" No major issues detected. Verify manually that:");
lines.push(" - Ending emerges from character transformation");
lines.push(" - Major setups receive payoffs");
lines.push(" - Final image resonates with theme");
lines.push("");
}
return lines.join("\n");
}
async function main(): Promise<void> {
const args = Deno.args;
if (args.includes("--help") || args.includes("-h")) {
console.log(`Ending Check - Resolution Structure Analyzer
Usage:
deno run --allow-read ending-check.ts <file>
deno run --allow-read ending-check.ts --text "The resolution..."
Options:
--text "..." Provide text inline
--json Output as JSON
--help Show this message
Analyzes:
- Ending type (closed, open, twist, circular, ambiguous)
- Structure (climax, falling action, denouement)
- Pacing (rushed, balanced, extended)
- Anti-patterns (deus ex machina, theme speech, etc.)
`);
Deno.exit(0);
}
const jsonOutput = args.includes("--json");
let text = "";
if (args.includes("--text")) {
const textIndex = args.indexOf("--text");
text = args[textIndex + 1] || "";
} else {
const file = args.find(a => !a.startsWith("--"));
if (file) {
try {
text = await Deno.readTextFile(file);
} catch (e) {
console.error(`Error reading file: ${e}`);
Deno.exit(1);
}
}
}
if (!text.trim()) {
console.error("Error: No text provided. Use --text or provide a file path.");
Deno.exit(1);
}
const analysis = analyzeEnding(text);
if (jsonOutput) {
console.log(JSON.stringify(analysis, null, 2));
} else {
console.log(formatReport(analysis));
}
}
main();
#!/usr/bin/env -S deno run --allow-read
/**
* Setup-Payoff Tracker
*
* Tracks setups (Chekhov's guns) and payoffs across a story.
* Helps identify unresolved setups and payoffs without setup.
*
* Usage:
* deno run --allow-read setup-payoff.ts --analyze story.txt
* deno run --allow-read setup-payoff.ts --setup "rusty key" --file story.txt
* deno run --allow-read setup-payoff.ts --list story.txt
*/
interface SetupPayoffAnalysis {
fileName: string;
wordCount: number;
// Detected patterns
potentialSetups: SetupItem[];
potentialPayoffs: PayoffItem[];
// Tracking results
matchedPairs: MatchedPair[];
unresolvedSetups: SetupItem[];
orphanedPayoffs: PayoffItem[];
// Assessment
setupPayoffHealth: "good" | "issues" | "problems";
issues: string[];
recommendations: string[];
}
interface SetupItem {
text: string;
position: number; // character position in text
positionPercent: number; // 0-100
type: SetupType;
lineContext: string;
}
interface PayoffItem {
text: string;
position: number;
positionPercent: number;
type: SetupType;
lineContext: string;
}
interface MatchedPair {
setup: SetupItem;
payoff: PayoffItem;
distance: number; // characters between
distancePercent: number;
}
type SetupType = "object" | "skill" | "character" | "information" | "threat" | "promise" | "foreshadow";
// Setup patterns - things introduced that might need payoff
const SETUP_PATTERNS: { type: SetupType; patterns: RegExp[] }[] = [
{
type: "object",
patterns: [
/\b(noticed|saw|spotted|found|discovered|picked up|grabbed|took|carried|kept|hidden|concealed)\s+(a|an|the)\s+([a-z]+\s+)?([a-z]+)/gi,
/\b(the|a|an)\s+([a-z]+\s+)?(key|gun|knife|sword|letter|photograph|ring|necklace|book|box|bag|case|weapon|tool)\b/gi,
],
},
{
type: "skill",
patterns: [
/\b(knew how to|could|was able to|trained in|learned|practiced|mastered|skilled at|expert in)\b/gi,
/\b(years of|experience with|background in|history of)\b/gi,
],
},
{
type: "character",
patterns: [
/\b(had\s+a\s+(brother|sister|friend|enemy|rival|ally)|someone\s+who|a\s+person\s+from)\b/gi,
/\b(old\s+(friend|enemy|acquaintance)|former\s+(colleague|partner|lover))\b/gi,
],
},
{
type: "information",
patterns: [
/\b(secret|knew\s+that|heard\s+that|remembered|recalled|once\s+told|mentioned)\b/gi,
/\b(no\s+one\s+knew|only\s+\w+\s+knew|hidden\s+truth|the\s+real\s+reason)\b/gi,
],
},
{
type: "threat",
patterns: [
/\b(would\s+return|wasn't\s+over|still\s+out\s+there|waiting|watching|someday)\b/gi,
/\b(warned|threatened|promised\s+to|swore\s+to|vowed)\b/gi,
],
},
{
type: "promise",
patterns: [
/\b(promised|swore|vowed|committed|pledged|gave\s+\w+\s+word)\b/gi,
/\b(would\s+never|would\s+always|one\s+day|someday)\b/gi,
],
},
{
type: "foreshadow",
patterns: [
/\b(little\s+did|if\s+only|would\s+later|couldn't\s+have\s+known|foreshadow)\b/gi,
/\b(ominous|foreboding|sense\s+of|feeling\s+that|premonition)\b/gi,
],
},
];
// Payoff patterns - things that might be resolving setups
const PAYOFF_PATTERNS: { type: SetupType; patterns: RegExp[] }[] = [
{
type: "object",
patterns: [
/\b(used|pulled\s+out|reached\s+for|grabbed|produced|wielded|fired|unlocked|opened\s+with)\b/gi,
/\b(the\s+(key|gun|knife|sword|letter|ring|weapon)\s+(worked|opened|saved|killed|revealed))\b/gi,
],
},
{
type: "skill",
patterns: [
/\b(finally\s+could|now\s+knew\s+how|training\s+paid\s+off|skills\s+saved|experience\s+told)\b/gi,
/\b(years\s+of\s+\w+\s+paid\s+off|remembered\s+how\s+to)\b/gi,
],
},
{
type: "character",
patterns: [
/\b(arrived|appeared|returned|showed\s+up|came\s+back|was\s+there)\b/gi,
/\b(unexpected\s+ally|old\s+friend\s+\w+|recognized)\b/gi,
],
},
{
type: "information",
patterns: [
/\b(realized|understood|finally\s+knew|the\s+truth\s+was|now\s+it\s+made\s+sense)\b/gi,
/\b(revealed|exposed|came\s+to\s+light|discovered\s+that)\b/gi,
],
},
{
type: "threat",
patterns: [
/\b(returned|came\s+back|finally\s+struck|made\s+good|carried\s+out)\b/gi,
/\b(defeated|destroyed|overcame|escaped|survived|confronted)\b/gi,
],
},
{
type: "promise",
patterns: [
/\b(kept\s+\w+\s+promise|fulfilled|honored|made\s+good|finally\s+could)\b/gi,
/\b(as\s+promised|true\s+to\s+\w+\s+word|never\s+forgot)\b/gi,
],
},
{
type: "foreshadow",
patterns: [
/\b(just\s+as|as\s+foretold|the\s+prophecy|finally|inevitable)\b/gi,
/\b(now\s+understood|saw\s+it\s+coming|knew\s+it\s+would)\b/gi,
],
},
];
function getLineContext(text: string, position: number, windowSize: number = 50): string {
const start = Math.max(0, position - windowSize);
const end = Math.min(text.length, position + windowSize);
let context = text.slice(start, end).replace(/\n/g, " ").trim();
if (start > 0) context = "..." + context;
if (end < text.length) context = context + "...";
return context;
}
function findSetups(text: string): SetupItem[] {
const setups: SetupItem[] = [];
const textLength = text.length;
for (const { type, patterns } of SETUP_PATTERNS) {
for (const pattern of patterns) {
const regex = new RegExp(pattern.source, pattern.flags);
let match;
while ((match = regex.exec(text)) !== null) {
// Only consider setups in first 70% of text
const positionPercent = (match.index / textLength) * 100;
if (positionPercent <= 70) {
setups.push({
text: match[0].toLowerCase().trim(),
position: match.index,
positionPercent: Math.round(positionPercent),
type,
lineContext: getLineContext(text, match.index),
});
}
}
}
}
// Deduplicate by text similarity
const unique: SetupItem[] = [];
for (const setup of setups) {
const isDuplicate = unique.some(
u => u.text === setup.text && Math.abs(u.position - setup.position) < 100
);
if (!isDuplicate) {
unique.push(setup);
}
}
return unique.sort((a, b) => a.position - b.position);
}
function findPayoffs(text: string): PayoffItem[] {
const payoffs: PayoffItem[] = [];
const textLength = text.length;
for (const { type, patterns } of PAYOFF_PATTERNS) {
for (const pattern of patterns) {
const regex = new RegExp(pattern.source, pattern.flags);
let match;
while ((match = regex.exec(text)) !== null) {
// Only consider payoffs in last 50% of text
const positionPercent = (match.index / textLength) * 100;
if (positionPercent >= 50) {
payoffs.push({
text: match[0].toLowerCase().trim(),
position: match.index,
positionPercent: Math.round(positionPercent),
type,
lineContext: getLineContext(text, match.index),
});
}
}
}
}
// Deduplicate
const unique: PayoffItem[] = [];
for (const payoff of payoffs) {
const isDuplicate = unique.some(
u => u.text === payoff.text && Math.abs(u.position - payoff.position) < 100
);
if (!isDuplicate) {
unique.push(payoff);
}
}
return unique.sort((a, b) => a.position - b.position);
}
function matchSetupsToPayoffs(
setups: SetupItem[],
payoffs: PayoffItem[],
textLength: number
): { matched: MatchedPair[]; unresolved: SetupItem[]; orphaned: PayoffItem[] } {
const matched: MatchedPair[] = [];
const usedSetups = new Set<number>();
const usedPayoffs = new Set<number>();
// Match by type and position
for (let i = 0; i < setups.length; i++) {
const setup = setups[i];
for (let j = 0; j < payoffs.length; j++) {
if (usedPayoffs.has(j)) continue;
const payoff = payoffs[j];
if (setup.type === payoff.type && payoff.position > setup.position) {
matched.push({
setup,
payoff,
distance: payoff.position - setup.position,
distancePercent: Math.round(((payoff.position - setup.position) / textLength) * 100),
});
usedSetups.add(i);
usedPayoffs.add(j);
break;
}
}
}
const unresolved = setups.filter((_, i) => !usedSetups.has(i));
const orphaned = payoffs.filter((_, i) => !usedPayoffs.has(i));
return { matched, unresolved, orphaned };
}
function searchForTerm(text: string, term: string): { found: boolean; occurrences: { position: number; percent: number; context: string }[] } {
const termLower = term.toLowerCase();
const textLower = text.toLowerCase();
const occurrences: { position: number; percent: number; context: string }[] = [];
let pos = 0;
while ((pos = textLower.indexOf(termLower, pos)) !== -1) {
occurrences.push({
position: pos,
percent: Math.round((pos / text.length) * 100),
context: getLineContext(text, pos, 60),
});
pos += termLower.length;
}
return { found: occurrences.length > 0, occurrences };
}
function analyzeSetupPayoff(text: string, fileName: string = "input"): SetupPayoffAnalysis {
const words = text.split(/\s+/).filter(w => w.length > 0);
const setups = findSetups(text);
const payoffs = findPayoffs(text);
const { matched, unresolved, orphaned } = matchSetupsToPayoffs(setups, payoffs, text.length);
const issues: string[] = [];
const recommendations: string[] = [];
// Assess health
let health: "good" | "issues" | "problems" = "good";
if (unresolved.length > 3) {
health = "problems";
issues.push(`${unresolved.length} potential setups without payoffs detected`);
recommendations.push("Review unresolved setups - either pay them off or remove them");
} else if (unresolved.length > 0) {
health = "issues";
issues.push(`${unresolved.length} potential unresolved setup(s)`);
}
if (orphaned.length > 2) {
health = health === "good" ? "issues" : health;
issues.push(`${orphaned.length} potential payoffs without clear setup (deus ex machina risk)`);
recommendations.push("Ensure late-story resolutions are established earlier");
}
if (matched.length === 0 && setups.length > 0) {
issues.push("No setup-payoff matches detected - story may lack satisfying resolution");
recommendations.push("Track your setups and ensure they pay off by the end");
}
// Check for setups too close to payoff
const quickPayoffs = matched.filter(m => m.distancePercent < 10);
if (quickPayoffs.length > 0) {
issues.push("Some setups pay off very quickly (may reduce impact)");
recommendations.push("Consider more distance between setup and payoff for greater impact");
}
return {
fileName,
wordCount: words.length,
potentialSetups: setups,
potentialPayoffs: payoffs,
matchedPairs: matched,
unresolvedSetups: unresolved,
orphanedPayoffs: orphaned,
setupPayoffHealth: health,
issues,
recommendations,
};
}
function formatReport(analysis: SetupPayoffAnalysis): string {
const lines: string[] = [];
lines.push("# Setup-Payoff Analysis\n");
lines.push(`File: ${analysis.fileName}`);
lines.push(`Words: ${analysis.wordCount}`);
lines.push(`Health: ${analysis.setupPayoffHealth.toUpperCase()}\n`);
lines.push("## Summary\n");
lines.push(` Potential setups detected: ${analysis.potentialSetups.length}`);
lines.push(` Potential payoffs detected: ${analysis.potentialPayoffs.length}`);
lines.push(` Matched pairs: ${analysis.matchedPairs.length}`);
lines.push(` Unresolved setups: ${analysis.unresolvedSetups.length}`);
lines.push(` Orphaned payoffs: ${analysis.orphanedPayoffs.length}`);
lines.push("");
if (analysis.matchedPairs.length > 0) {
lines.push("## Matched Setup-Payoff Pairs\n");
for (const pair of analysis.matchedPairs.slice(0, 10)) {
lines.push(` [${pair.setup.type}] Setup at ${pair.setup.positionPercent}% → Payoff at ${pair.payoff.positionPercent}%`);
lines.push(` Setup: "${pair.setup.text}"`);
lines.push(` Payoff: "${pair.payoff.text}"`);
lines.push("");
}
if (analysis.matchedPairs.length > 10) {
lines.push(` ... and ${analysis.matchedPairs.length - 10} more pairs\n`);
}
}
if (analysis.unresolvedSetups.length > 0) {
lines.push("## Unresolved Setups (Chekhov's Guns Unfired)\n");
for (const setup of analysis.unresolvedSetups.slice(0, 10)) {
lines.push(` [${setup.type}] at ${setup.positionPercent}%: "${setup.text}"`);
lines.push(` Context: ${setup.lineContext}`);
lines.push("");
}
}
if (analysis.orphanedPayoffs.length > 0) {
lines.push("## Orphaned Payoffs (Possible Deus Ex Machina)\n");
for (const payoff of analysis.orphanedPayoffs.slice(0, 10)) {
lines.push(` [${payoff.type}] at ${payoff.positionPercent}%: "${payoff.text}"`);
lines.push(` Context: ${payoff.lineContext}`);
lines.push("");
}
}
if (analysis.issues.length > 0) {
lines.push("## Issues\n");
for (const issue of analysis.issues) {
lines.push(` - ${issue}`);
}
lines.push("");
}
if (analysis.recommendations.length > 0) {
lines.push("## Recommendations\n");
for (const rec of analysis.recommendations) {
lines.push(` - ${rec}`);
}
lines.push("");
}
return lines.join("\n");
}
async function main(): Promise<void> {
const args = Deno.args;
if (args.includes("--help") || args.includes("-h")) {
console.log(`Setup-Payoff Tracker
Usage:
deno run --allow-read setup-payoff.ts --analyze <file>
deno run --allow-read setup-payoff.ts --setup "term" --file <file>
deno run --allow-read setup-payoff.ts --list <file>
Modes:
--analyze Full setup-payoff analysis
--setup "X" Search for specific term throughout story
--list List all detected setups and payoffs
Options:
--file <f> Specify input file
--json Output as JSON
--help Show this message
Detects:
- Objects (Chekhov's gun)
- Skills (training/ability)
- Characters (allies, enemies)
- Information (secrets, knowledge)
- Threats (warnings, foreshadowing)
- Promises (commitments)
`);
Deno.exit(0);
}
const jsonOutput = args.includes("--json");
let text = "";
let fileName = "input";
// Get file
const fileIndex = args.indexOf("--file");
if (fileIndex !== -1 && args[fileIndex + 1]) {
fileName = args[fileIndex + 1];
try {
text = await Deno.readTextFile(fileName);
} catch (e) {
console.error(`Error reading file: ${e}`);
Deno.exit(1);
}
} else {
// Try to find file as positional arg
const file = args.find(a => !a.startsWith("--") && a !== args[args.indexOf("--setup") + 1]);
if (file) {
fileName = file;
try {
text = await Deno.readTextFile(file);
} catch (e) {
console.error(`Error reading file: ${e}`);
Deno.exit(1);
}
}
}
if (!text.trim()) {
console.error("Error: No file provided. Use --file <path> or provide path as argument.");
Deno.exit(1);
}
// Search for specific setup
if (args.includes("--setup")) {
const setupIndex = args.indexOf("--setup");
const term = args[setupIndex + 1];
if (!term) {
console.error("Error: --setup requires a search term");
Deno.exit(1);
}
const result = searchForTerm(text, term);
if (jsonOutput) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`\n# Search for "${term}"\n`);
if (result.found) {
console.log(`Found ${result.occurrences.length} occurrence(s):\n`);
for (const occ of result.occurrences) {
console.log(` At ${occ.percent}%: ${occ.context}\n`);
}
} else {
console.log("Term not found in text.");
}
}
Deno.exit(0);
}
// List mode
if (args.includes("--list")) {
const setups = findSetups(text);
const payoffs = findPayoffs(text);
if (jsonOutput) {
console.log(JSON.stringify({ setups, payoffs }, null, 2));
} else {
console.log("\n# Detected Setups (first 70%)\n");
for (const s of setups) {
console.log(` [${s.type}] ${s.positionPercent}%: "${s.text}"`);
}
console.log("\n# Detected Payoffs (last 50%)\n");
for (const p of payoffs) {
console.log(` [${p.type}] ${p.positionPercent}%: "${p.text}"`);
}
}
Deno.exit(0);
}
// Full analysis
const analysis = analyzeSetupPayoff(text, fileName);
if (jsonOutput) {
console.log(JSON.stringify(analysis, null, 2));
} else {
console.log(formatReport(analysis));
}
}
main();