<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:googleplay="http://www.google.com/schemas/play-podcasts/1.0"><channel><title><![CDATA[Bruce Mackinlay: AI Writing]]></title><description><![CDATA[This contains a set of posts related to how I use AI in my writing.]]></description><link>https://brucemackinlay1.substack.com/s/ai-writing</link><image><url>https://substackcdn.com/image/fetch/$s_!bvk5!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F33b7be8a-428d-487a-bec1-ebd5fc0cb30f_1024x1024.png</url><title>Bruce Mackinlay: AI Writing</title><link>https://brucemackinlay1.substack.com/s/ai-writing</link></image><generator>Substack</generator><lastBuildDate>Tue, 12 May 2026 19:01:33 GMT</lastBuildDate><atom:link href="https://brucemackinlay1.substack.com/feed" rel="self" type="application/rss+xml"/><copyright><![CDATA[Bruce Mackinlay]]></copyright><language><![CDATA[en]]></language><webMaster><![CDATA[brucemackinlay1@substack.com]]></webMaster><itunes:owner><itunes:email><![CDATA[brucemackinlay1@substack.com]]></itunes:email><itunes:name><![CDATA[Bruce Mackinlay]]></itunes:name></itunes:owner><itunes:author><![CDATA[Bruce Mackinlay]]></itunes:author><googleplay:owner><![CDATA[brucemackinlay1@substack.com]]></googleplay:owner><googleplay:email><![CDATA[brucemackinlay1@substack.com]]></googleplay:email><googleplay:author><![CDATA[Bruce Mackinlay]]></googleplay:author><itunes:block><![CDATA[Yes]]></itunes:block><item><title><![CDATA[Prompt-1 Style Check]]></title><description><![CDATA[Version 1]]></description><link>https://brucemackinlay1.substack.com/p/prompt-1-style-check</link><guid isPermaLink="false">https://brucemackinlay1.substack.com/p/prompt-1-style-check</guid><dc:creator><![CDATA[Bruce Mackinlay]]></dc:creator><pubDate>Mon, 11 May 2026 17:28:40 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!bvk5!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F33b7be8a-428d-487a-bec1-ebd5fc0cb30f_1024x1024.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>All my posts related to AI are in this archive:<br><a href="https://brucemackinlay1.substack.com/s/ai-writing">AI Writing Archive</a></p><div><hr></div><p><strong>Style Check</strong></p><p>Act as a professional style editor enforcing house standards on a draft scene from a serialized political thriller. Catch style violations before the draft moves to fact-checking, beta reading, and line editing. Do not comment on plot, character, pacing, continuity, or factual accuracy.</p><p>This is the first editing pass. Everything you flag here would otherwise slow down every later editor.</p><p><strong>The Author&#8217;s Style Rules</strong></p><p><strong>Strunk &amp; White baseline:</strong></p><ul><li><p>Active voice where possible</p></li><li><p>Short, direct, concrete sentences</p></li><li><p>Emphatic word at the end of the sentence</p></li><li><p>No qualifiers (very, quite, somewhat, rather) unless character voice requires them</p></li><li><p>Definite, specific language over general terms</p></li><li><p>Parallel structure in lists and paired clauses</p></li><li><p>Simple verbs over nominalizations (use, not utilize)</p></li></ul><p><strong>House style (Bruce&#8217;s Rules):</strong></p><ul><li><p>No em-dashes. Use a comma, semicolon, or recast.</p></li><li><p>No curly quotes or smart punctuation. Plain quotes and apostrophes only.</p></li><li><p>No double spaces after periods.</p></li><li><p>Plain, direct narration. No ornate phrasing unless deliberately for effect.</p></li><li><p>Tight paragraphing: each new line earns its break.</p></li><li><p>Readability beats grammatical perfection when character voice demands it.</p></li><li><p>Minimal dialogue tags. Use physical beats instead of repeated &#8220;he said / she said.&#8221;</p></li><li><p>No meta-writing: don&#8217;t describe the act of thinking. Show through action or speech.</p></li><li><p>Show, don&#8217;t tell. Never label an emotion in narration when dialogue, action, or physical detail can carry it. Exception: close POV interiority, where the viewpoint character&#8217;s thoughts and perceptions are the narrative vehicle.</p></li><li><p>No unnecessary italics. Italics only for foreign words or internal thoughts.</p></li></ul><p><strong>Amy&#8217;s Proofreading Rules:</strong></p><ul><li><p>Periods and commas inside quotation marks.</p></li><li><p>Ellipses only for genuine pauses or trailing thoughts.</p></li><li><p>Single question/exclamation marks only. Never double (?? or !!).</p></li><li><p>Consistent spelling throughout (e.g., federalization not federalisation).</p></li><li><p>No adverb clutter: replace &#8220;quietly said&#8221; with an action that conveys quietness.</p></li><li><p>No filter words in close POV (she saw, he felt, she realized) unless they add necessary distance.</p></li><li><p>No comma before short compound verbs (&#8221;He grabbed his coat and left.&#8221;).</p></li><li><p>Pronoun clarity: no ambiguous &#8220;he&#8221; or &#8220;she&#8221; when multiple characters share a paragraph.</p></li></ul><p><strong>Flag only:</strong></p><ul><li><p>Em-dashes (every instance, no exceptions)</p></li><li><p>Curly/smart quotes or apostrophes</p></li><li><p>Double spaces after periods</p></li><li><p>Unnecessary italics for emphasis</p></li><li><p>Filter words in close POV where removal tightens the prose</p></li><li><p>Adverb-cluttered dialogue tags</p></li><li><p>Meta-writing (describing thinking rather than showing it)</p></li><li><p>Telling instead of showing: narration that labels an emotion or state when dialogue, action, or detail could carry it. Exception: close POV interiority.</p></li><li><p>Narrator editorializing: narration that tells the reader what to think or feel rather than letting the scene do the work.</p></li><li><p>Qualifier clutter unless the character voice justifies it</p></li><li><p>Passive voice, where active is stronger, with no stylistic reason for the passive</p></li><li><p>Nominalization, where a simple verb works</p></li><li><p>Overused ellipses</p></li><li><p>Double punctuation</p></li><li><p>Pronoun ambiguity with multiple characters in a paragraph</p></li><li><p>Missing a comma inside the closing quotation marks</p></li><li><p>Comma before a short compound verb</p></li><li><p>Broken parallel structure</p></li><li><p>Inconsistent spelling of established terms</p></li><li><p>Screenplay-style formatting</p></li></ul><p><strong>Output Format</strong></p><p>EDIT|||exact old text|||exact new text|||[Category] &#8212; What&#8217;s wrong in one sentence.</p><p>Proposed fix rationale, if needed, on a second line.</p><p><strong>Categories:</strong></p><p>[Em-Dash], [Smart Punctuation], [Double Space], [Filter Word], [Adverb Tag], [Meta-Writing], [Show Don&#8217;t Tell], [Narrator Editorial], [Qualifier], [Passive Voice], [Nominalization], [Ellipsis Overuse], [Punctuation], [Pronoun Clarity], [Italics], [Parallel Structure], [Spelling Consistency], [Formatting]</p><p><strong>Rules:</strong></p><ul><li><p>Old text must be an exact character-for-character match to the source</p></li><li><p>New text must be a complete drop-in replacement, ready to paste</p></li><li><p>One edit per block</p></li><li><p>Flag every em-dash and every smart/curly punctuation instance. No exceptions.</p></li><li><p>For all other categories, flag only what a careful reader would notice</p></li><li><p>Do not flag intentional stylistic choices (fragments, short declaratives, spare tags)</p></li><li><p>Do not comment on what works or suggest additions</p></li><li><p>Match the author&#8217;s voice: plain, direct, spare</p></li><li><p>Do not end paragraphs with a short, punchy sentence that restates what was just said</p></li></ul><p><strong>Findings that cannot be expressed as text edits:</strong></p><p>NOTE|||[Category] &#8212; Description of the issue.</p><p>Quote a representative passage if applicable.</p><p>Explain the pattern in one or two sentences.</p><p>Do not output anything except EDIT and NOTE blocks.</p><p>At the top of the file, place a Note:</p><p>NOTE|||Prompt 1-Style Check</p>]]></content:encoded></item><item><title><![CDATA[Prompt-2 Fact Check]]></title><description><![CDATA[V1]]></description><link>https://brucemackinlay1.substack.com/p/prompt-2-fact-check</link><guid isPermaLink="false">https://brucemackinlay1.substack.com/p/prompt-2-fact-check</guid><dc:creator><![CDATA[Bruce Mackinlay]]></dc:creator><pubDate>Mon, 11 May 2026 17:27:43 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!bvk5!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F33b7be8a-428d-487a-bec1-ebd5fc0cb30f_1024x1024.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>All my posts related to AI are in this archive:<br><a href="https://brucemackinlay1.substack.com/s/ai-writing">AI Writing Archive</a></p><div><hr></div><p><strong>Fact Checker Prompt</strong></p><p>Act as a professional fact-checker reviewing a scene from a serialized political thriller novel prior to publication. Your job is to identify factual errors, implausibilities, and technical inaccuracies that would undermine credibility with an informed reader.</p><p>This is Pass 2 in a four-pass editing pipeline. Style, formatting, and house rules have already been handled. Beta reading and line editing come after you. Stay in your lane.</p><p><strong>Focus exclusively on:</strong></p><p><strong>Legal &amp; Procedural Accuracy</strong> &#8212; Flag errors in how legal processes, court procedures, or official filings are depicted. Note if dialogue or actions attributed to lawyers, judges, or officers of the court are inconsistent with actual practice.</p><p><strong>Regulatory &amp; Government Accuracy</strong> &#8212; Flag misrepresentations of how federal, state, or local agencies operate, their chain of command, their statutory authority, or how their officials would realistically communicate or act. Base this on actual practices starting in February 2025, not on long-standing norms. If in doubt, review current news and internet reporting before flagging.</p><p><strong>Business &amp; Financial Accuracy</strong> &#8212; Flag errors in how corporate decisions, bankruptcy proceedings, financial instruments, or business operations are depicted.</p><p><strong>Military &amp; Law Enforcement Accuracy</strong> &#8212; Flag errors in rank, procedure, jurisdiction, rules of engagement, or institutional behavior.</p><p><strong>Technical &amp; Industrial Accuracy</strong> &#8212; Flag errors in how manufacturing, logistics, supply chains, or specific industries are depicted.</p><p><strong>Geographic &amp; Logistical Accuracy</strong> &#8212; Flag errors in locations, travel times, facility descriptions, or operational details that a local or professional reader would notice.</p><p><strong>What to skip:</strong></p><ul><li><p>Prose style, pacing, dialogue tone, or character development</p></li><li><p>Show-don&#8217;t-tell, filter words, adverb clutter, or any house style issue</p></li><li><p>Anything that is factually correct. Do not waste my time confirming accurate details. Concentrate only on what is incorrect and what I can do to correct it.</p></li></ul><p><strong>Output Format</strong></p><p>Every finding that can be resolved into a text change must use the EDIT format. One edit per block. The reason field must include the category tag and may span multiple lines.</p><p>EDIT|||exact old text|||exact new text|||[Category] &#8212; What&#8217;s wrong in one sentence.</p><p>Source or explanation on a second line if needed.</p><p><strong>Categories:</strong></p><ul><li><p>[Legal] &#8212; Error in legal process, court procedure, filing, or attorney conduct.</p></li><li><p>[Regulatory] &#8212; Misrepresentation of how a federal, state, or local agency operates or its statutory authority.</p></li><li><p>[Financial] &#8212; Error in corporate, bankruptcy, or financial procedure.</p></li><li><p>[Military] &#8212; Error in rank, procedure, jurisdiction, rules of engagement, or institutional behavior.</p></li><li><p>[Law Enforcement] &#8212; Error in police, federal agent, or corrections procedure or jurisdiction.</p></li><li><p>[Technical] &#8212; Error in manufacturing, logistics, supply chain, or industry-specific detail.</p></li><li><p>[Geographic] &#8212; Error in location, travel time, facility description, or operational detail.</p></li></ul><p><strong>Rules:</strong></p><ul><li><p>The old text must be an exact character-for-character match to the source, including punctuation and whitespace</p></li><li><p>The new text must be a complete drop-in replacement, ready to paste</p></li><li><p>One edit per block</p></li><li><p>Match the author&#8217;s voice in all proposed fixes: plain, direct, spare. This is fiction, not a textbook.</p></li><li><p>Flag only what is wrong or questionable. Do not flag what is accurate.</p></li><li><p>If a factual issue is ambiguous or debatable, say so. Do not present a judgment call as a hard error.</p></li><li><p>When current practice differs from long-standing norms (especially in federal agency behavior post-February 2025), flag based on current practice and note the change.</p></li><li><p>Do not add any Hedging and softening language.</p></li></ul><p><strong>Findings that cannot be expressed as text edits:</strong></p><p>For factual issues where you cannot propose a specific drop-in fix, or where the fix requires the author to make a judgment call, use a separate NOTE block:</p><p>NOTE|||[Category] &#8212; Description of the factual issue.</p><p>Quote the relevant passage if applicable.</p><p>Identify the error and what the correct fact or procedure is in one or two sentences.</p><p>Do not output anything except EDIT and NOTE blocks &#8212; no preamble, no summary, no commentary.</p><p>At the top of the file, place a Note:</p><p>NOTE|||Prompt 2-Fact Check</p>]]></content:encoded></item><item><title><![CDATA[Prompt-3 Beta Reader]]></title><description><![CDATA[V1]]></description><link>https://brucemackinlay1.substack.com/p/prompt-3-beta-reader</link><guid isPermaLink="false">https://brucemackinlay1.substack.com/p/prompt-3-beta-reader</guid><dc:creator><![CDATA[Bruce Mackinlay]]></dc:creator><pubDate>Mon, 11 May 2026 17:26:12 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!bvk5!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F33b7be8a-428d-487a-bec1-ebd5fc0cb30f_1024x1024.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p></p><p>All my posts related to AI are in this archive:<br><a href="https://brucemackinlay1.substack.com/s/ai-writing">AI Writing Archive</a></p><div><hr></div><p><strong>Beta Reader</strong></p><p>Act as a professional beta reader and developmental editor, preparing a scene for serial publication. Do not tell me what works. Identify only what needs to change.</p><p>This is Pass 3 in a four-pass editing pipeline. Style and house-rule compliance were handled in Pass 1. Factual, legal, procedural, geographic, and technical accuracy were handled in Pass 2. Line editing comes after you in Pass 4. Stay in your lane.</p><p>This is a serialized political thriller. Later chapters must work for both longtime readers and someone picking up mid-series. Flag both problems and their category so I can triage.</p><p><strong>Focus on these categories, in priority order:</strong></p><ol><li><p><strong>Continuity &amp; Internal Consistency</strong> &#8212; Contradictions with established facts, character behavior, or prior events. Note the specific conflict.</p></li><li><p><strong>Serial Accessibility</strong> &#8212; A new mid-series reader would be lost or confused. Flag where a brief orienting phrase or sentence would solve it &#8212; don&#8217;t rewrite, just mark the gap. Tolerate some serial accessibility issues; this will encourage the reader to go back and read. Flag only those that may genuinely baffle readers.</p></li><li><p><strong>Pacing &amp; Structure</strong> &#8212; Scenes that drag, skip too fast, or bury the narrative hook. Be specific about where it breaks down, especially in areas where the reader may lose focus.</p></li><li><p><strong>Character Voice &amp; Behavior</strong> &#8212; A character acts or speaks inconsistently with who they&#8217;ve been established to be. Quote the passage.</p></li><li><p><strong>Clarity &amp; Logic</strong> &#8212; Confusing cause-and-effect, unclear antecedents, or logic gaps a reader would stumble on.</p></li><li><p><strong>Line-Level Issues</strong> &#8212; Awkward phrasing, repeated words, or sentences that stop the read. Flag sparingly &#8212; only what genuinely disrupts. This means reading-experience problems only: a sentence that trips you, a word echo that pulls you out, a transition that jars. Do not flag house-style mechanics (em-dashes, filter words, passive voice, adverb tags, punctuation rules) &#8212; those were handled in Pass 1.</p></li></ol><p><strong>What to skip:</strong></p><ul><li><p>Do not comment on what works.</p></li><li><p>Do not suggest additions unless something is genuinely missing.</p></li><li><p>Do not pad findings to appear thorough.</p></li><li><p>Do not flag house-style issues: em-dashes, smart punctuation, filter words, adverb-cluttered tags, passive voice, show-don&#8217;t-tell, italics for emphasis, qualifier clutter, or formatting standards. These were handled in Pass 1 (Style).</p></li><li><p>Do not flag factual, legal, procedural, military, geographic, or technical accuracy. These were handled in Pass 2 (Fact Checker).</p></li><li><p>Do not flag intentional stylistic choices such as sentence fragments, short declarative sentences, or spare dialogue tags.</p></li></ul><p><strong>Output Format</strong></p><p>Every finding that can be resolved into a text change must use the EDIT format. One edit per block. The reason field must include the category tag and may span multiple lines.</p><p>EDIT|||exact old text|||exact new text|||[Category] &#8212; What&#8217;s wrong in one sentence.</p><p>Proposed fix rationale, if needed, on a second line.</p><p><strong>Rules:</strong></p><ul><li><p>The old text must be an exact character-for-character match to the source, including punctuation and whitespace</p></li><li><p>The new text must be a complete drop-in replacement, ready to paste</p></li><li><p>The reason must begin with the category tag in brackets: [Continuity], [Serial Accessibility], [Pacing], [Character Voice], [Clarity], or [Line-Level]</p></li><li><p>The reason may span multiple lines if the issue requires explanation</p></li><li><p>Match the author&#8217;s voice in all proposed fixes: plain, direct, spare</p></li><li><p>Flag sparingly &#8212; only what genuinely disrupts a careful reader</p></li></ul><p><strong>Findings that cannot be expressed as text edits:</strong></p><p>For structural issues, pacing concerns, or problems where you cannot propose a specific fix, use a separate NOTE block:</p><p>NOTE|||[Category] &#8212; Description of the issue.</p><p>Quote the relevant passage if applicable.</p><p>Explain why it is a problem in one or two sentences.</p><p>Do not output anything except EDIT and NOTE blocks &#8212; no preamble, no summary, no commentary.</p><p>At the top of the file, place a Note:</p><p>NOTE|||Prompt 3-Beta Reader</p>]]></content:encoded></item><item><title><![CDATA[Prompt-4 Line Editing]]></title><description><![CDATA[V1]]></description><link>https://brucemackinlay1.substack.com/p/prompt-4-line-editing</link><guid isPermaLink="false">https://brucemackinlay1.substack.com/p/prompt-4-line-editing</guid><dc:creator><![CDATA[Bruce Mackinlay]]></dc:creator><pubDate>Mon, 11 May 2026 17:24:01 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!bvk5!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F33b7be8a-428d-487a-bec1-ebd5fc0cb30f_1024x1024.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><strong><br></strong>All my posts related to AI are in this archive:<br><a href="https://brucemackinlay1.substack.com/s/ai-writing">AI Writing Archive</a></p><div><hr></div><p><strong>Line Editor Prompt</strong></p><p>Act as a professional line editor, preparing a scene for publication in a serialized political thriller. Your job is to improve clarity, rhythm, and precision at the sentence level only. Do not rewrite for style. Do not suggest structural changes. Do not comment on plot, character, or pacing.</p><p>This is Pass 4 in a four-pass editing pipeline. Style and house-rule compliance were handled in Pass 1. Factual and technical accuracy were handled in Pass 2. Continuity, pacing, character voice, and serial accessibility were handled in Pass 3. You are the final polish. Stay in your lane.</p><p><strong>Flag only:</strong></p><ul><li><p>Awkward or tangled phrasing that stops the read</p></li><li><p>Repeated words within close proximity that create an unintended echo</p></li><li><p>Sentences where the syntax fights the meaning</p></li><li><p>Punctuation errors not already covered by house-style rules (Pass 1 handled em-dashes, smart punctuation, double spaces, commas inside quotes, double punctuation, and commas before short compound verbs)</p></li><li><p>Words that can be cut without losing meaning</p></li><li><p>Places where a single word substitution sharpens the sentence</p></li></ul><p><strong>What to skip:</strong></p><ul><li><p>House-style mechanics: em-dashes, smart punctuation, filter words, adverb tags, passive voice, show-don&#8217;t-tell, italics for emphasis, qualifier clutter, nominalization, parallel structure, spelling consistency, or formatting standards. These were handled in Pass 1 (Style).</p></li><li><p>Factual, legal, procedural, military, geographic, or technical accuracy. These were handled in Pass 2 (Fact Checker).</p></li><li><p>Continuity, serial accessibility, pacing, character voice, or logic gaps. These were handled in Pass 3 (Beta Reader).</p></li><li><p>Intentional stylistic choices such as sentence fragments, short declarative sentences, or spare dialogue tags.</p></li><li><p>Do not suggest additions.</p></li><li><p>Do not comment on what works.</p></li></ul><p><strong>Output Format</strong></p><p>Every finding must use the EDIT format. One edit per block. The reason field must include the category tag and may span multiple lines.</p><p>EDIT|||exact old text|||exact new text|||[Category] &#8212; What&#8217;s wrong in one sentence.</p><p>Rationale, if needed, on a second line.</p><p><strong>Categories:</strong></p><ul><li><p>[Awkward] &#8212; Tangled phrasing or syntax that fights the meaning.</p></li><li><p>[Echo] &#8212; Repeated word within close proximity creating unintended repetition.</p></li><li><p>[Cut] &#8212; Word or phrase that can be removed without losing meaning.</p></li><li><p>[Word Choice] &#8212; A single word substitution that sharpens the sentence.</p></li><li><p>[Punctuation] &#8212; Punctuation error not covered by house-style rules in Pass 1.</p></li><li><p>[Syntax] &#8212; Sentence structure that obscures rather than clarifies.</p></li></ul><p><strong>Rules:</strong></p><ul><li><p>The old text must be an exact character-for-character match to the source, including punctuation and whitespace</p></li><li><p>The new text must be a complete drop-in replacement, ready to paste</p></li><li><p>One edit per block</p></li><li><p>The reason must begin with the category tag in brackets</p></li><li><p>Flag sparingly &#8212; only what genuinely disrupts a careful reader</p></li><li><p>Match the author&#8217;s voice in all proposed fixes: plain, direct, spare</p></li></ul><p><strong>Findings that cannot be expressed as text edits:</strong></p><p>For rhythm problems, paragraph-level tangles, or issues where you cannot propose a specific fix, use a separate NOTE block:</p><p>NOTE|||[Category] &#8212; Description of the issue.</p><p>Quote the relevant passage if applicable.</p><p>Explain why it is a problem in one or two sentences.</p><p>Do not output anything except EDIT and NOTE blocks &#8212; no preamble, no summary, no commentary.</p><p>At the top of the file, place a Note:</p><p>NOTE|||Prompt 4-Line Editing</p>]]></content:encoded></item><item><title><![CDATA[Edit markdown ]]></title><description><![CDATA[V1]]></description><link>https://brucemackinlay1.substack.com/p/edit-markdown</link><guid isPermaLink="false">https://brucemackinlay1.substack.com/p/edit-markdown</guid><dc:creator><![CDATA[Bruce Mackinlay]]></dc:creator><pubDate>Mon, 11 May 2026 17:21:14 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!bvk5!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F33b7be8a-428d-487a-bec1-ebd5fc0cb30f_1024x1024.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>All my posts related to AI are in this archive:<br><a href="https://brucemackinlay1.substack.com/s/ai-writing">AI Writing Archive</a></p><div class="highlighted_code_block" data-attrs="{&quot;language&quot;:&quot;python&quot;,&quot;nodeId&quot;:&quot;d0bbee00-2ea3-4484-9d5d-02d793efed65&quot;}" data-component-name="HighlightedCodeBlockToDOM"><pre class="shiki"><code class="language-python">"""
apply_edits_15.py &#8212; Web-Based Manuscript Edit Applicator
========================================================

VERSION: 15
PYTHON:  3.9+
DEPS:    Standard library only (no pip installs required).

WHAT THIS TOOL DOES
-------------------
Opens a local web UI in the browser where an author can:
  1. Point to a manuscript file (Markdown, plain text, etc.).
  2. Paste in a batch of EDIT||| and NOTE||| blocks produced by an
     AI editing prompt (style checker, beta reader, fact checker, etc.).
  3. Preview each proposed edit with surrounding context from the
     manuscript, toggling each between "Change" and "Ignore".
  4. Apply the selected edits in one operation, with automatic backup.
  5. Review a results page showing what was applied and what was skipped.
  6. Loop back to step 1 for additional passes or different files.

INTENDED WORKFLOW
-----------------
  The author runs four AI editing passes on each scene:
    Pass 1: Style Check     (house rules, formatting, em-dashes, etc.)
    Pass 2: Fact Check       (legal, military, geographic accuracy)
    Pass 3: Beta Reader      (continuity, pacing, character voice)
    Pass 4: Line Edit        (rhythm, word choice, cuts)

  Each pass produces output in EDIT||| and NOTE||| format.  The author
  copies that output, runs this tool, pastes it in, reviews each edit,
  applies the ones they agree with, and moves to the next pass.

INPUT FORMAT
------------
  The tool accepts two types of blocks, pasted into the Edits textarea:

  EDIT blocks (find-and-replace operations):
    EDIT|||exact old text|||exact new text|||reason for the change

    - The old text must match the manuscript exactly (character-for-character,
      including punctuation, whitespace, and line breaks).
    - The new text is the drop-in replacement.
    - The reason explains what's wrong (shown on the preview card).
    - Multi-line old/new text is supported.  The parser splits on EDIT|||
      markers, not on newlines, so line breaks within fields are preserved.
    - The reason may also span multiple lines.
    - Triple-pipe ||| is the field delimiter.  It must not appear in the
      old text, new text, or reason (extremely unlikely in prose).

  NOTE blocks (non-actionable observations):
    NOTE|||description of a structural or conceptual issue

    - Notes have no old/new text.  They appear as read-only blue cards
      on the preview screen and the results screen.
    - Use for pacing concerns, missing-content flags, or issues that
      require author judgment rather than a text swap.

  Anything that is not an EDIT||| or NOTE||| block is ignored.  Preamble
  text, blank lines, and commentary between blocks are silently skipped.

EDIT STATUSES
-------------
  When the tool reads the manuscript and checks each EDIT block:

    ready     &#8212; Old text found exactly once in the manuscript.  The edit
                can be applied.  Defaults to "Change" on the preview screen.
    not_found &#8212; Old text not found.  The manuscript may have been edited
                since the AI pass, or the old text was copied imprecisely.
                Defaults to "Ignore".  Cannot be applied.
    duplicate &#8212; Old text found more than once.  Ambiguous; the tool cannot
                determine which occurrence to replace.  Defaults to "Ignore".
    error     &#8212; Parse error (malformed EDIT block, too few ||| delimiters).
                Defaults to "Ignore".
    info      &#8212; A NOTE block.  Read-only.  Not applicable.

THREE-PAGE UI
-------------
  Page 1 &#8212; Configuration  (GET /)
    Fields:
      - Drafts Directory:  Full path to the folder containing manuscripts.
      - Backups Directory: Full path where timestamped backups are saved
                           before edits are applied.
      - Filename:          Name of the specific manuscript file to edit.
      - Edits:             Large textarea for pasting EDIT/NOTE blocks.
                           Always starts empty (paths are remembered).

    Buttons:
      - Exit:              Stop the server immediately, save nothing.
      - Save &amp; Exit:       Save paths to config file, then stop.
      - Save &amp; Preview:    Save paths, read the manuscript, parse edits,
                           redirect to the preview page.
      - Preview:           Read the manuscript, parse edits, redirect to
                           the preview page (without saving paths).

  Page 2 &#8212; Preview  (GET /preview)
    Shows every parsed EDIT and NOTE as a card:
      - EDIT cards show a Change/Ignore toggle, the old text (red), the
        new text (green), surrounding context lines from the manuscript
        (blue, italic), and the reason.  "Ready" edits default to Change.
        "Not Found", "Ambiguous", and "Error" edits default to Ignore
        with the Change button disabled.
      - NOTE cards show the reason text with a blue "Note" badge.
        No toggle (they are informational only).
    Summary bar shows counts: ready, not found, ambiguous, errors, notes.

    Buttons:
      - Back:              Return to the configuration page.
      - Apply Selected:    Confirm, then apply all "Change" edits.

  Page 3 &#8212; Results  (GET /results)
    Shows what happened after Apply:
      - Summary: X applied, Y not applied, Z notes.
      - Cards for every EDIT that was NOT applied (skipped by user,
        not found, ambiguous, or error), plus all NOTE cards.
      - If everything was applied and there are no notes, shows a
        success message.

    Buttons:
      - Back:              Return to config page for another pass.
      - Exit:              Shut down the server.

APPLY SAFETY FEATURES
---------------------
  - Backup before apply:  The original file is copied to the backups
    directory with a timestamp suffix (e.g., Ch13-S3_20260430_143022.md)
    before any edits are written.  If no backups directory is configured,
    a warning is printed but the apply proceeds.

  - Re-read before apply:  The manuscript file is re-read from disk at
    the moment of applying, not from the cached copy loaded at preview
    time.  This means the user can edit the file externally (in their
    text editor) while the preview screen is open, and the edits will
    be applied against the current version of the file.

  - Re-analyze before apply:  After re-reading, all edits are re-checked
    against the fresh file contents.  If the user manually fixed an issue
    while the preview was open, that edit's old text will no longer be
    found, its status will change to "not_found", and it will be silently
    skipped.  The results page will show it as "Not Found".

  - First-occurrence replacement:  Each edit replaces only the first
    occurrence of the old text (using str.replace with count=1).  This
    is why "duplicate" edits are blocked &#8212; if the old text appears more
    than once, the tool cannot know which occurrence to replace.

CONFIG PERSISTENCE
------------------
  Config file: apply_edits_config.json  (same directory as this script)
  Saved keys:  drafts_dir, backups_dir, filename, edits_text
  The config file is written atomically (write to .tmp, then os.replace)
  to prevent corruption from interrupted writes.
  The edits_text field is saved to config but the textarea always starts
  empty when the config page loads &#8212; paths persist, edits don't.

HTTP ENDPOINTS
--------------
  GET  /          Config page (Page 1).
  GET  /preview   Preview page (Page 2).  Redirects to / if no edits parsed.
  GET  /results   Results page (Page 3).  Redirects to / if no edits parsed.
  POST /config    Receives JSON with form fields + action string.
                  Actions: "exit", "save_exit", "save_preview", "preview".
  POST /apply     Receives JSON {"indices": [0, 1, 3, ...]}.
                  Backs up, re-reads, re-analyzes, applies, writes.
  POST /shutdown  Shuts down the server (used by the Exit button on
                  the results page).

SERVER ARCHITECTURE
-------------------
  - Runs on 127.0.0.1:8767 (localhost only, not accessible from network).
  - Uses Python's ThreadingHTTPServer in a daemon thread.
  - Opens the default browser automatically on startup.
  - Mutable state (parsed_edits, manuscript_text, applied_indices) is
    shared between request handlers via closure variables in serve().
  - The server shuts down when the user clicks Exit, Save &amp; Exit, or
    when the main thread's join() returns.
  - No external dependencies.  No frameworks.  No npm.  Just stdlib.

CSS THEME
---------
  Dark theme with CSS custom properties:
    --bg:      #1a1a2e    (page background)
    --surface: #22223a    (card/bar background)
    --border:  #333355    (borders)
    --text:    #e0e0e0    (body text)
    --muted:   #888       (hints, secondary text)
    --accent:  #5b8af5    (buttons, note badges, context lines)
    --danger:  #e05555    (errors, old text, Ignore toggle)
    --warn:    #d4a843    (warnings, ambiguous)
    --success: #4caf50    (ready, new text, Change toggle, applied count)

USAGE
-----
  python apply_edits_15.py

  The browser opens to the config page.  Set the drafts and backups
  directories (only needed once &#8212; they persist).  Enter the filename.
  Paste the EDIT/NOTE output from your AI editing pass.  Click Preview.
  Toggle edits.  Click Apply Selected.  Review results.  Click Back
  for another pass or Exit to quit.

COMPANION PROMPTS
-----------------
  This tool is designed to consume output from these AI editing prompts:
    - Style_Check_Prompt.md        (Pass 1)
    - Fact_Checker_Prompt.md       (Pass 2)
    - Beta_Reader_Prompt.md        (Pass 3)
    - Line_Editor_Prompt.md        (Pass 4)
  Each prompt instructs the AI to output EDIT||| and NOTE||| blocks
  that can be pasted directly into this tool.

CHANGE LOG
----------
  v1-v13:  Initial development, CLI-based, iterative refinement.
  v14:     Web UI with three-page flow (config, preview, results).
           Change/Ignore toggles.  Context lines in blue.  NOTE blocks.
           Re-read-on-apply safety.  Multi-line edit parsing.
           Edits textarea starts empty each session.
  v15:     Documentation pass.  No functional changes from v14.
           All comments and docstrings updated to be self-contained
           so future development can proceed from this file alone.
"""

import os
import sys
import webbrowser
import threading
import json as _json
import shutil
import tempfile
from pathlib import Path
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer


# &#9472;&#9472; Defaults &amp; Constants &#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;
# These defaults are used when apply_edits_config.json doesn't exist or is
# corrupt.  After the first Save, the config file takes over for paths.
# The edits textarea always starts empty regardless of what's in the config.

DEFAULT_DRAFTS_DIR  = ""     # Path to folder containing manuscript files
DEFAULT_BACKUPS_DIR = ""     # Path to folder for timestamped backups
DEFAULT_FILENAME    = ""     # Name of the manuscript file (not full path)
DEFAULT_EDITS_TEXT  = ""     # Pasted EDIT/NOTE blocks (always blank on load)

PORT        = 8767           # Local HTTP server port (localhost only)
CONFIG_FILE = Path(__file__).with_name("apply_edits_config.json")


# &#9472;&#9472; Config Persistence &#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;
# The config file stores drafts_dir, backups_dir, filename, and edits_text
# as a JSON object in the same directory as this script.  It is written
# atomically (write to .tmp, then os.replace) so a crash during write
# cannot corrupt the file.  If the file is missing or corrupt, defaults
# are used and the user configures paths on first run.

def load_config() -&gt; dict:
    """Load config from JSON file; return defaults if missing or corrupt.

    Returns a dict with keys: drafts_dir, backups_dir, filename, edits_text.
    Any missing keys in the JSON file fall back to DEFAULT_* constants.
    """
    try:
        with open(CONFIG_FILE, "r", encoding="utf-8") as fh:
            cfg = _json.load(fh)
        return {
            "drafts_dir":  cfg.get("drafts_dir",  DEFAULT_DRAFTS_DIR),
            "backups_dir": cfg.get("backups_dir", DEFAULT_BACKUPS_DIR),
            "filename":    cfg.get("filename",    DEFAULT_FILENAME),
            "edits_text":  cfg.get("edits_text",  DEFAULT_EDITS_TEXT),
        }
    except (FileNotFoundError, _json.JSONDecodeError, OSError):
        return {
            "drafts_dir":  DEFAULT_DRAFTS_DIR,
            "backups_dir": DEFAULT_BACKUPS_DIR,
            "filename":    DEFAULT_FILENAME,
            "edits_text":  DEFAULT_EDITS_TEXT,
        }


def save_config(cfg: dict) -&gt; None:
    """Write config atomically: write to .tmp file, then os.replace.

    This prevents corruption if the process is killed mid-write.
    If the write fails, the old config file is preserved and a
    warning is printed to the console.
    """
    tmp_path = None
    try:
        fd, tmp_path = tempfile.mkstemp(dir=CONFIG_FILE.parent, suffix=".tmp")
        with os.fdopen(fd, "w", encoding="utf-8") as fh:
            _json.dump(cfg, fh, indent=2, ensure_ascii=False)
        os.replace(tmp_path, CONFIG_FILE)
        print(f"Config saved -&gt; {CONFIG_FILE}")
    except OSError as exc:
        print(f"WARNING: Could not save config: {exc}")
        if tmp_path and os.path.exists(tmp_path):
            try:
                os.remove(tmp_path)
            except OSError:
                pass


# &#9472;&#9472; Edit Parsing &amp; Analysis &#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;
# The parser handles two block types: EDIT||| and NOTE|||.
#
# EDIT blocks are split into three fields by |||:
#   EDIT|||old_text|||new_text|||reason
# Multi-line content is supported because the parser splits on the EDIT|||
# and NOTE||| markers (using regex), not on newlines.  Line breaks within
# any field are preserved exactly.
#
# NOTE blocks have a single field after the marker:
#   NOTE|||reason text (may span multiple lines)
#
# The parser returns a list of dicts, each with:
#   type:     "edit" or "note"
#   old:      exact text to find (empty for notes)
#   new:      replacement text (empty for notes)
#   reason:   explanation string (may contain newlines)
#   line_num: sequential block number (1-based, for display)
#   status:   "pending" (edits, before analysis), "info" (notes),
#             or "error" (malformed blocks)

def parse_edits(edits_text: str) -&gt; list:
    """Parse EDIT||| and NOTE||| blocks from pasted text.

    Uses regex split on 'EDIT|||' and 'NOTE|||' markers to handle
    multi-line content within fields.  Returns a list of item dicts
    ready for analyze_edits().

    Block format:
      EDIT|||old text (may contain newlines)|||new text|||reason
      NOTE|||reason text (may span multiple lines)

    Returns: [{"type": "edit"|"note", "old": str, "new": str,
               "reason": str, "line_num": int, "status": str}, ...]
    """
    items = []

    # We need to find both EDIT||| and NOTE||| markers and process
    # them in order.  Split the text into tokens by finding each marker.
    import re
    # Split on EDIT||| or NOTE|||, keeping the delimiter.
    tokens = re.split(r'(EDIT\|\|\||NOTE\|\|\|)', edits_text)

    # tokens is: [preamble, 'EDIT|||', block1, 'NOTE|||', block2, ...]
    # Process pairs: (marker, block)
    block_num = 0
    i = 0
    while i &lt; len(tokens):
        token = tokens[i]
        if token == 'EDIT|||':
            block_num += 1
            if i + 1 &lt; len(tokens):
                block = tokens[i + 1]
                i += 2
            else:
                block = ""
                i += 1

            segments = block.split("|||")

            if len(segments) &lt; 3:
                items.append({
                    "type": "edit",
                    "old": "", "new": "",
                    "reason": f"Parse error in edit block {block_num}: "
                              f"expected 3 fields separated by |||, "
                              f"got {len(segments)}",
                    "line_num": block_num, "status": "error"
                })
                continue

            old_text = segments[0]
            new_text = segments[1]
            reason   = "|||".join(segments[2:]).strip()
            reason = reason.rstrip("\n\r")

            items.append({
                "type": "edit",
                "old": old_text,
                "new": new_text,
                "reason": reason,
                "line_num": block_num,
                "status": "pending"
            })

        elif token == 'NOTE|||':
            block_num += 1
            if i + 1 &lt; len(tokens):
                block = tokens[i + 1]
                i += 2
            else:
                block = ""
                i += 1

            reason = block.strip().rstrip("\n\r")
            if not reason:
                reason = "(empty note)"

            items.append({
                "type": "note",
                "old": "",
                "new": "",
                "reason": reason,
                "line_num": block_num,
                "status": "info"
            })

        else:
            # Preamble or inter-block text; skip.
            i += 1

    return items


def analyze_edits(manuscript_text: str, edits: list) -&gt; list:
    """Check each EDIT block against the manuscript and set its status.

    Searches the manuscript for each edit's old text:
      - Found exactly once  -&gt; status = "ready" (safe to apply)
      - Not found           -&gt; status = "not_found" (text may have changed)
      - Found multiple times -&gt; status = "duplicate" (ambiguous, blocked)
      - Already "error"      -&gt; unchanged (parse failure)
      - Already "info"       -&gt; unchanged (NOTE block, not applicable)

    This function is called twice:
      1. At preview time, against the cached manuscript.
      2. At apply time, against a fresh re-read of the file, to catch
         any manual edits the user made while the preview was open.

    Modifies the edits list in place and returns it.
    """
    for edit in edits:
        if edit["status"] in ("error", "info"):
            continue
        count = manuscript_text.count(edit["old"])
        if count == 0:
            edit["status"] = "not_found"
        elif count &gt; 1:
            edit["status"] = "duplicate"
            edit["reason"] += f" (found {count} times -- ambiguous)"
        else:
            edit["status"] = "ready"
    return edits


def apply_selected_edits(manuscript_text: str, edits: list, indices: list) -&gt; str:
    """Apply edits at the given indices to the manuscript text.

    For each index in the list:
      - Skip if out of range or status is not "ready".
      - Replace the FIRST occurrence of old text with new text.
        (Using str.replace with count=1 to avoid replacing duplicates.)

    Returns the modified manuscript text.  Does not write to disk &#8212;
    the caller handles file I/O.
    """
    for idx in indices:
        if idx &lt; 0 or idx &gt;= len(edits):
            continue
        edit = edits[idx]
        if edit["status"] != "ready":
            continue
        manuscript_text = manuscript_text.replace(edit["old"], edit["new"], 1)
    return manuscript_text


def resolve_filepath(drafts_dir: str, filename: str) -&gt; Path:
    """Resolve the manuscript file path, trying extensions if needed.

    If the filename has no extension (no '.' in the name), the function
    tries appending '.md' first, then '.txt'.  This lets the user type
    just the stem (e.g., "Ch13-S3-Sunday-Torrance") without remembering
    the extension.

    Resolution order:
      1. drafts_dir / filename           (as given)
      2. drafts_dir / filename.md        (if no extension in filename)
      3. drafts_dir / filename.txt       (if no extension in filename)

    Returns the first Path that points to an existing file.
    If none exist, returns the original Path (drafts_dir / filename)
    so the caller can report a meaningful "file not found" error.
    """
    path = Path(drafts_dir) / filename
    if path.is_file():
        return path

    # Only try extensions if the filename has no extension.
    if '.' not in Path(filename).name:
        for ext in ('.md', '.txt'):
            candidate = Path(drafts_dir) / (filename + ext)
            if candidate.is_file():
                return candidate

    # Return the original path so the error message shows what was tried.
    return path


# &#9472;&#9472; CSS Theme &#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;
# Dark theme shared across all three pages.  Uses CSS custom properties
# so colors can be adjusted in one place.  The theme is designed to be
# easy on the eyes during extended editing sessions.
#
# Color roles:
#   --bg:      Page background (darkest)
#   --surface: Card and action bar background
#   --border:  Borders and separators
#   --text:    Primary text color
#   --muted:   Secondary text, hints, labels
#   --accent:  Buttons, note badges, context lines (blue)
#   --danger:  Errors, old text diff, Ignore toggle active (red)
#   --warn:    Warnings, ambiguous, skipped (amber)
#   --success: Ready, new text diff, Change toggle active, applied (green)

_CSS_VARS = """
  :root {
    --bg:      #1a1a2e;
    --surface: #22223a;
    --border:  #333355;
    --text:    #e0e0e0;
    --muted:   #888;
    --accent:  #5b8af5;
    --danger:  #e05555;
    --warn:    #d4a843;
    --success: #4caf50;
  }
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
         Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text);
         line-height: 1.55; }
  code, pre { font-family: 'SF Mono', 'Fira Code', Consolas, monospace; }
"""

_BTN_CSS = """
  .btn { padding: 0.55rem 1.4rem; border: none; border-radius: 6px;
         font-size: 0.9rem; font-weight: 600; cursor: pointer;
         transition: filter 0.15s; }
  .btn-run    { background: var(--accent); color: #fff; }
  .btn-save   { background: var(--accent); color: #fff; }
  .btn-cancel { background: transparent;   color: var(--muted);
                border: 1px solid var(--border); }
  .btn-danger { background: var(--danger); color: #fff; }
  .btn:hover     { filter: brightness(1.15); }
  .btn:disabled  { opacity: 0.4; cursor: default; filter: none; }
"""


# &#9472;&#9472; HTML Page Builders &#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;
# Each function returns a complete HTML page as a string.  The pages use
# f-strings with doubled braces {{ }} for CSS/JS (Python f-string escaping).
# All user-provided text is HTML-escaped before embedding to prevent XSS.
#
# Page 1: build_config_html()   &#8212; Configuration form
# Page 2: build_preview_html()  &#8212; Edit preview with toggles and context
# Page 3: build_results_html()  &#8212; Post-apply summary

def build_config_html(cfg: dict) -&gt; str:
    """Build Page 1: Configuration form.

    Fields: Drafts Directory, Backups Directory, Filename, Edits textarea.
    Paths are pre-filled from the saved config.  The Edits textarea always
    starts empty so the user pastes fresh edits each session.

    Buttons (sticky action bar at bottom):
      Exit:           POST /config action="exit" -&gt; server shuts down.
      Save &amp; Exit:    POST /config action="save_exit" -&gt; save config, shut down.
      Save &amp; Preview: POST /config action="save_preview" -&gt; save, parse, preview.
      Preview:        POST /config action="preview" -&gt; parse, preview (no save).

    The JavaScript getConfig() reads all four form fields and sends them
    as JSON to POST /config along with the action string.
    """
    drafts_val  = cfg["drafts_dir"].replace('"', '&amp;quot;')
    backups_val = cfg["backups_dir"].replace('"', '&amp;quot;')
    file_val    = cfg["filename"].replace('"', '&amp;quot;')
    # Always start with an empty edits textarea so the user pastes
    # fresh edits each session.  Paths are preserved from config.
    edits_val   = ""

    return f"""&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
&lt;meta charset="utf-8"&gt;
&lt;title&gt;Apply Edits -- Configuration&lt;/title&gt;
&lt;style&gt;
{_CSS_VARS}
{_BTN_CSS}
  .page-wrap  {{ max-width: 900px; margin: 0 auto; padding: 2rem 1.5rem 140px; }}
  h1          {{ font-size: 1.5rem; font-weight: 700; margin-bottom: 0.25rem; }}
  .subtitle   {{ color: var(--muted); font-size: 0.9rem; margin-bottom: 2rem; }}
  .field      {{ margin-bottom: 1.2rem; }}
  .field label {{ display: block; font-size: 0.9rem; font-weight: 600;
                  margin-bottom: 0.3rem; }}
  .field .hint {{ font-size: 0.78rem; color: var(--muted); margin-bottom: 0.3rem; }}
  .field input {{ width: 100%; background: var(--bg); border: 1px solid var(--border);
                  border-radius: 6px; padding: 0.5rem 0.7rem; color: var(--text);
                  font-size: 0.85rem; font-family: monospace; }}
  .field input:focus {{ outline: none; border-color: var(--accent); }}
  .field textarea {{ width: 100%; background: var(--bg); border: 1px solid var(--border);
                     border-radius: 6px; padding: 0.5rem 0.7rem; color: var(--text);
                     font-size: 0.82rem; font-family: monospace; line-height: 1.5;
                     resize: vertical; }}
  .field textarea:focus {{ outline: none; border-color: var(--accent); }}
  .action-bar {{ position: fixed; bottom: 0; left: 0; right: 0;
                 background: var(--surface); border-top: 1px solid var(--border);
                 padding: 1rem 2rem; display: flex; align-items: center;
                 gap: 1rem; z-index: 20; }}
  .action-bar .hint {{ flex: 1; font-size: 0.82rem; color: var(--muted); }}
  .status-msg {{ position: fixed; bottom: 70px; left: 50%;
                 transform: translateX(-50%);
                 background: var(--surface); border: 1px solid var(--border);
                 border-radius: 8px; padding: 0.7rem 1.4rem;
                 font-size: 0.88rem; display: none; z-index: 30; }}
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;div class="page-wrap"&gt;
  &lt;h1&gt;Apply Edits&lt;/h1&gt;
  &lt;p class="subtitle"&gt;Configure paths and paste edits, then click Preview to review changes.&lt;/p&gt;

  &lt;div class="field"&gt;
    &lt;label&gt;Drafts Directory&lt;/label&gt;
    &lt;div class="hint"&gt;Full path to the folder containing your manuscript files.&lt;/div&gt;
    &lt;input type="text" id="drafts-dir" value="{drafts_val}"
           placeholder="/path/to/drafts"&gt;
  &lt;/div&gt;

  &lt;div class="field"&gt;
    &lt;label&gt;Backups Directory&lt;/label&gt;
    &lt;div class="hint"&gt;Full path where backups will be saved before edits are applied.&lt;/div&gt;
    &lt;input type="text" id="backups-dir" value="{backups_val}"
           placeholder="/path/to/backups"&gt;
  &lt;/div&gt;

  &lt;div class="field"&gt;
    &lt;label&gt;Filename&lt;/label&gt;
    &lt;div class="hint"&gt;Name of the manuscript file (e.g. Ch13-S3-Sunday-Torrance.md).&lt;/div&gt;
    &lt;input type="text" id="filename" value="{file_val}"
           placeholder="chapter.md"&gt;
  &lt;/div&gt;

  &lt;div class="field"&gt;
    &lt;label&gt;Edits&lt;/label&gt;
    &lt;div class="hint"&gt;Paste EDIT||| formatted lines below. One edit per line.&lt;/div&gt;
    &lt;textarea id="edits-text" rows="16"&gt;{edits_val}&lt;/textarea&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;div class="status-msg" id="status-msg"&gt;&lt;/div&gt;

&lt;div class="action-bar"&gt;
  &lt;span class="hint"&gt;Settings saved to &lt;code&gt;apply_edits_config.json&lt;/code&gt;&lt;/span&gt;
  &lt;button class="btn btn-cancel" onclick="doAction('exit')"&gt;Exit&lt;/button&gt;
  &lt;button class="btn btn-save"   onclick="doAction('save_exit')"&gt;Save &amp;amp; Exit&lt;/button&gt;
  &lt;button class="btn btn-save"   onclick="doAction('save_preview')"&gt;Save &amp;amp; Preview&lt;/button&gt;
  &lt;button class="btn btn-run"    onclick="doAction('preview')"&gt;Preview&lt;/button&gt;
&lt;/div&gt;

&lt;script&gt;
  function getConfig() {{
    return {{
      drafts_dir:  document.getElementById('drafts-dir').value.trim(),
      backups_dir: document.getElementById('backups-dir').value.trim(),
      filename:    document.getElementById('filename').value.trim(),
      edits_text:  document.getElementById('edits-text').value,
    }};
  }}

  function showStatus(msg) {{
    const el = document.getElementById('status-msg');
    el.textContent = msg; el.style.display = 'block';
    setTimeout(() =&gt; {{ el.style.display = 'none'; }}, 4000);
  }}

  function doAction(action) {{
    const cfg = getConfig();
    cfg.action = action;
    fetch('/config', {{
      method:  'POST',
      headers: {{'Content-Type': 'application/json'}},
      body:    JSON.stringify(cfg)
    }})
    .then(r =&gt; r.json())
    .then(data =&gt; {{
      if (data.error) {{
        showStatus('Error: ' + data.error);
      }} else if (data.redirect) {{
        window.location.href = data.redirect;
      }} else {{
        if (action === 'save_exit') {{
          showStatus('Config saved. You may close this window.');
        }} else if (action === 'exit') {{
          showStatus('Exiting. You may close this window.');
        }}
      }}
    }})
    .catch(err =&gt; showStatus('Error: ' + err));
  }}
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;"""


def build_preview_html(edits: list, filename: str,
                       manuscript_text: str) -&gt; str:
    """Build Page 2: Edit preview with toggles and context lines.

    For each EDIT item, renders a card with:
      - Change/Ignore toggle (Change = green active, Ignore = red active).
        "Ready" edits default to Change.  Others default to Ignore with
        the Change button disabled.
      - Status badge (Ready / Not Found / Ambiguous / Error).
      - Reason text (may contain &lt;br&gt; for multi-line reasons).
      - OLD text in red, NEW text in green.
      - Context: the nearest non-blank line before and after the match,
        shown in blue italic.  Context is found by splitting the manuscript
        into lines, locating which lines the match spans, and searching
        backward/forward past blank lines.  Only shown for "ready" edits.

    For each NOTE item, renders a read-only card with:
      - Blue "Note" badge.
      - Reason text.
      - No toggle, no diff, no context.

    Summary bar shows counts of each status type.

    Buttons:
      Back:           Navigate to / (config page).
      Apply Selected: POST /apply with indices of all "Change" toggles.

    JavaScript:
      setToggle(idx, mode): Switches a card between Change and Ignore.
      applyEdits():         Collects active Change indices, confirms,
                            POSTs to /apply, redirects to /results.
    """
    rows = []
    for idx, edit in enumerate(edits):
        status = edit["status"]
        item_type = edit.get("type", "edit")

        reason_esc = (edit["reason"]
                      .replace('&amp;', '&amp;amp;')
                      .replace('&lt;', '&amp;lt;')
                      .replace('&gt;', '&amp;gt;')
                      .replace('\n', '&lt;br&gt;'))

        # NOTE items: read-only info cards, no toggle, no diff.
        if item_type == "note":
            rows.append(f"""
      &lt;div class="edit-card note"&gt;
        &lt;div class="edit-header"&gt;
          &lt;span class="edit-num"&gt;#{idx + 1}&lt;/span&gt;
          &lt;span class="edit-status status-note"&gt;Note&lt;/span&gt;
        &lt;/div&gt;
        &lt;div class="note-body"&gt;{reason_esc}&lt;/div&gt;
      &lt;/div&gt;""")
            continue

        # EDIT items: toggle, diff, context lines.
        if status == "ready":
            status_cls = "ready"
            status_label = "Ready"
        elif status == "not_found":
            status_cls = "not-found"
            status_label = "Not Found"
        elif status == "duplicate":
            status_cls = "duplicate"
            status_label = "Ambiguous"
        else:
            status_cls = "error"
            status_label = "Error"

        old_esc = (edit["old"]
                   .replace('&amp;', '&amp;amp;')
                   .replace('&lt;', '&amp;lt;')
                   .replace('&gt;', '&amp;gt;'))
        new_esc = (edit["new"]
                   .replace('&amp;', '&amp;amp;')
                   .replace('&lt;', '&amp;lt;')
                   .replace('&gt;', '&amp;gt;'))

        # Extract context lines from manuscript.
        ctx_before = ""
        ctx_after = ""
        if edit["old"] and edit["status"] == "ready":
            pos = manuscript_text.find(edit["old"])
            if pos &gt;= 0:
                # Split entire manuscript into lines with positions.
                lines = manuscript_text.split('\n')
                # Find which line the match starts on.
                char_count = 0
                match_start_line = 0
                for i, line in enumerate(lines):
                    if char_count + len(line) &gt;= pos:
                        match_start_line = i
                        break
                    char_count += len(line) + 1  # +1 for the \n

                # Find which line the match ends on.
                end_pos = pos + len(edit["old"])
                char_count = 0
                match_end_line = 0
                for i, line in enumerate(lines):
                    if char_count + len(line) &gt;= end_pos:
                        match_end_line = i
                        break
                    char_count += len(line) + 1

                # Line before the match (skip blank lines).
                prev_line = ""
                for look_back in range(match_start_line - 1, -1, -1):
                    candidate = lines[look_back].strip()
                    if candidate:
                        prev_line = candidate
                        break
                if prev_line:
                    prev_esc = (prev_line
                                .replace('&amp;', '&amp;amp;')
                                .replace('&lt;', '&amp;lt;')
                                .replace('&gt;', '&amp;gt;'))
                    ctx_before = (f'&lt;div class="ctx-line"&gt;'
                                  f'&lt;code&gt;{prev_esc}&lt;/code&gt;&lt;/div&gt;')

                # Line after the match (skip blank lines).
                next_line = ""
                for look_fwd in range(match_end_line + 1, len(lines)):
                    candidate = lines[look_fwd].strip()
                    if candidate:
                        next_line = candidate
                        break
                if next_line:
                    next_esc = (next_line
                                .replace('&amp;', '&amp;amp;')
                                .replace('&lt;', '&amp;lt;')
                                .replace('&gt;', '&amp;gt;'))
                    ctx_after = (f'&lt;div class="ctx-line"&gt;'
                                 f'&lt;code&gt;{next_esc}&lt;/code&gt;&lt;/div&gt;')

        disabled = '' if status == 'ready' else 'disabled'
        change_active = 'active' if status == 'ready' else ''
        ignore_active = 'active' if status != 'ready' else ''

        rows.append(f"""
      &lt;div class="edit-card {status_cls}"&gt;
        &lt;div class="edit-header"&gt;
          &lt;div class="toggle-group" data-idx="{idx}"&gt;
            &lt;button class="toggle-btn toggle-change {change_active}"
                    {disabled} onclick="setToggle({idx},'change')"&gt;Change&lt;/button&gt;
            &lt;button class="toggle-btn toggle-ignore {ignore_active}"
                    onclick="setToggle({idx},'ignore')"&gt;Ignore&lt;/button&gt;
          &lt;/div&gt;
          &lt;span class="edit-num"&gt;#{idx + 1}&lt;/span&gt;
          &lt;span class="edit-status status-{status_cls}"&gt;{status_label}&lt;/span&gt;
          &lt;span class="edit-reason"&gt;{reason_esc}&lt;/span&gt;
        &lt;/div&gt;
        &lt;div class="edit-diff"&gt;
          {ctx_before}
          &lt;div class="diff-old"&gt;
            &lt;span class="diff-label"&gt;OLD:&lt;/span&gt; &lt;code&gt;{old_esc}&lt;/code&gt;
          &lt;/div&gt;
          &lt;div class="diff-new"&gt;
            &lt;span class="diff-label"&gt;NEW:&lt;/span&gt; &lt;code&gt;{new_esc}&lt;/code&gt;
          &lt;/div&gt;
          {ctx_after}
        &lt;/div&gt;
      &lt;/div&gt;""")

    total     = len(edits)
    ready     = sum(1 for e in edits if e["status"] == "ready")
    not_found = sum(1 for e in edits if e["status"] == "not_found")
    dupes     = sum(1 for e in edits if e["status"] == "duplicate")
    errors    = sum(1 for e in edits if e["status"] == "error")
    notes     = sum(1 for e in edits if e.get("type") == "note")

    cards_html = "\n".join(rows)

    return f"""&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
&lt;meta charset="utf-8"&gt;
&lt;title&gt;Apply Edits -- Preview&lt;/title&gt;
&lt;style&gt;
{_CSS_VARS}
{_BTN_CSS}
  .page-wrap  {{ max-width: 960px; margin: 0 auto; padding: 2rem 1.5rem 140px; }}
  h1          {{ font-size: 1.5rem; font-weight: 700; margin-bottom: 0.25rem; }}
  .subtitle   {{ color: var(--muted); font-size: 0.9rem; margin-bottom: 0.5rem; }}
  .summary    {{ color: var(--muted); font-size: 0.85rem; margin-bottom: 1.5rem;
                 padding: 0.8rem 1rem; background: var(--surface);
                 border: 1px solid var(--border); border-radius: 8px; }}
  .summary .ct-ready    {{ color: var(--success); font-weight: 600; }}
  .summary .ct-notfound {{ color: var(--danger);  font-weight: 600; }}
  .summary .ct-dupe     {{ color: var(--warn);    font-weight: 600; }}
  .summary .ct-error    {{ color: var(--danger);  font-weight: 600; }}
  .summary .ct-note     {{ color: var(--accent);  font-weight: 600; }}
  .edit-card  {{ background: var(--surface); border: 1px solid var(--border);
                 border-radius: 8px; padding: 1rem; margin-bottom: 0.8rem; }}
  .edit-card.not-found {{ border-left: 3px solid var(--danger); }}
  .edit-card.duplicate {{ border-left: 3px solid var(--warn); }}
  .edit-card.error     {{ border-left: 3px solid var(--danger); }}
  .edit-card.ready     {{ border-left: 3px solid var(--success); }}
  .edit-card.note      {{ border-left: 3px solid var(--accent); }}
  .status-note  {{ background: rgba(91,138,245,0.15); color: var(--accent); }}
  .note-body    {{ font-size: 0.85rem; color: var(--text); line-height: 1.6;
                   padding: 0.4rem 0; }}
  .edit-header {{ display: flex; align-items: center; gap: 0.6rem;
                  margin-bottom: 0.6rem; }}
  .toggle-group {{ display: flex; border: 1px solid var(--border);
                   border-radius: 6px; overflow: hidden; flex-shrink: 0; }}
  .toggle-btn {{ padding: 0.25rem 0.6rem; border: none; background: transparent;
                 color: var(--muted); font-size: 0.78rem; font-weight: 600;
                 cursor: pointer; transition: all 0.15s; }}
  .toggle-btn:first-child {{ border-right: 1px solid var(--border); }}
  .toggle-change.active {{ background: var(--success); color: #fff; }}
  .toggle-ignore.active {{ background: var(--danger); color: #fff; }}
  .toggle-btn:disabled {{ opacity: 0.4; cursor: default; }}
  .toggle-btn:disabled.active {{ opacity: 1; }}
  .edit-num    {{ font-weight: 700; font-size: 0.85rem; }}
  .edit-status {{ font-size: 0.78rem; padding: 0.15rem 0.5rem; border-radius: 4px;
                  font-weight: 600; }}
  .status-ready     {{ background: rgba(76,175,80,0.15); color: var(--success); }}
  .status-not-found {{ background: rgba(224,85,85,0.15); color: var(--danger); }}
  .status-duplicate {{ background: rgba(212,168,67,0.15); color: var(--warn); }}
  .status-error     {{ background: rgba(224,85,85,0.15); color: var(--danger); }}
  .edit-reason {{ font-size: 0.82rem; color: var(--muted); flex: 1; }}
  .edit-diff   {{ font-size: 0.82rem; }}
  .diff-old    {{ color: var(--danger); margin-bottom: 0.3rem; }}
  .diff-new    {{ color: var(--success); }}
  .diff-label  {{ font-weight: 700; font-size: 0.75rem; display: inline-block;
                  width: 35px; }}
  .diff-old code, .diff-new code {{
    white-space: pre-wrap; word-break: break-word; }}
  .ctx-line {{ color: #5b8af5; font-size: 0.82rem; padding: 0.2rem 0; }}
  .ctx-line code {{ white-space: pre-wrap; word-break: break-word;
                    font-style: italic; }}
  .action-bar {{ position: fixed; bottom: 0; left: 0; right: 0;
                 background: var(--surface); border-top: 1px solid var(--border);
                 padding: 1rem 2rem; display: flex; align-items: center;
                 gap: 1rem; z-index: 20; }}
  .action-bar .hint {{ flex: 1; font-size: 0.82rem; color: var(--muted); }}
  .status-msg {{ position: fixed; bottom: 70px; left: 50%;
                 transform: translateX(-50%);
                 background: var(--surface); border: 1px solid var(--border);
                 border-radius: 8px; padding: 0.7rem 1.4rem;
                 font-size: 0.88rem; display: none; z-index: 30; }}
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;div class="page-wrap"&gt;
  &lt;h1&gt;Preview Edits&lt;/h1&gt;
  &lt;p class="subtitle"&gt;Reviewing: &lt;strong&gt;{filename}&lt;/strong&gt;&lt;/p&gt;

  &lt;div class="summary"&gt;
    &lt;span class="ct-ready"&gt;{ready} ready&lt;/span&gt; &amp;middot;
    &lt;span class="ct-notfound"&gt;{not_found} not found&lt;/span&gt; &amp;middot;
    &lt;span class="ct-dupe"&gt;{dupes} ambiguous&lt;/span&gt; &amp;middot;
    &lt;span class="ct-error"&gt;{errors} errors&lt;/span&gt; &amp;middot;
    &lt;span class="ct-note"&gt;{notes} notes&lt;/span&gt; &amp;middot;
    {total} total
  &lt;/div&gt;

  {cards_html}
&lt;/div&gt;

&lt;div class="status-msg" id="status-msg"&gt;&lt;/div&gt;

&lt;div class="action-bar"&gt;
  &lt;span class="hint"&gt;Toggle edits between Change and Ignore.
  Only "Ready" edits can be set to Change.&lt;/span&gt;
  &lt;button class="btn btn-cancel" onclick="window.location.href='/'"&gt;Back&lt;/button&gt;
  &lt;button class="btn btn-run" id="apply-btn" onclick="applyEdits()"&gt;
    Apply Selected&lt;/button&gt;
&lt;/div&gt;

&lt;script&gt;
  function showStatus(msg) {{
    const el = document.getElementById('status-msg');
    el.textContent = msg; el.style.display = 'block';
  }}

  function setToggle(idx, mode) {{
    const group = document.querySelector('.toggle-group[data-idx="' + idx + '"]');
    if (!group) return;
    const changeBtn = group.querySelector('.toggle-change');
    const ignoreBtn = group.querySelector('.toggle-ignore');
    if (changeBtn.disabled &amp;&amp; mode === 'change') return;
    if (mode === 'change') {{
      changeBtn.classList.add('active');
      ignoreBtn.classList.remove('active');
    }} else {{
      changeBtn.classList.remove('active');
      ignoreBtn.classList.add('active');
    }}
  }}

  function applyEdits() {{
    const groups = document.querySelectorAll('.toggle-group');
    const indices = [];
    groups.forEach(g =&gt; {{
      const changeBtn = g.querySelector('.toggle-change');
      if (changeBtn.classList.contains('active') &amp;&amp; !changeBtn.disabled) {{
        indices.push(parseInt(g.dataset.idx));
      }}
    }});
    if (indices.length === 0) {{
      showStatus('No edits set to Change.');
      return;
    }}
    if (!confirm('Apply ' + indices.length + ' edit(s)? '
                 + 'A backup will be created first.')) {{
      return;
    }}
    fetch('/apply', {{
      method:  'POST',
      headers: {{'Content-Type': 'application/json'}},
      body:    JSON.stringify({{ indices: indices }})
    }})
    .then(r =&gt; r.json())
    .then(data =&gt; {{
      if (data.error) {{
        showStatus('Error: ' + data.error);
      }} else if (data.redirect) {{
        window.location.href = data.redirect;
      }}
    }})
    .catch(err =&gt; showStatus('Error: ' + err));
  }}
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;"""


def build_results_html(edits: list, applied_indices: set,
                       filename: str) -&gt; str:
    """Build Page 3: Post-apply results summary.

    Shows two sections:
      1. Skipped edits: EDIT items that were NOT applied.  This includes
         edits set to "Ignore" by the user (status "ready" but not in
         applied_indices), edits that were "not_found", "duplicate", or
         "error".  Each is shown with its status badge, reason, and
         old/new text.
      2. Notes: All NOTE items, shown as blue info cards.

    If everything was applied and there are no notes, shows a success
    message instead.

    Summary bar: X applied, Y not applied, Z notes.

    Buttons:
      Back: Navigate to / (config page) to start another editing pass.
            State is fully reset when the user clicks Preview again.
      Exit: POST /shutdown to stop the server.
    """
    applied_count = len(applied_indices)
    skipped = [(idx, e) for idx, e in enumerate(edits)
               if idx not in applied_indices and e.get("type") != "note"]
    note_items = [(idx, e) for idx, e in enumerate(edits)
                  if e.get("type") == "note"]
    skipped_count = len(skipped)
    note_count = len(note_items)

    cards = []

    # Skipped edits.
    for idx, edit in skipped:
        status = edit["status"]
        if status == "ready":
            reason_text = "Set to Ignore by user"
            status_cls = "skipped"
            status_label = "Skipped"
        elif status == "not_found":
            status_cls = "not-found"
            status_label = "Not Found"
            reason_text = edit["reason"]
        elif status == "duplicate":
            status_cls = "duplicate"
            status_label = "Ambiguous"
            reason_text = edit["reason"]
        else:
            status_cls = "error"
            status_label = "Error"
            reason_text = edit["reason"]

        old_esc = (edit["old"]
                   .replace('&amp;', '&amp;amp;')
                   .replace('&lt;', '&amp;lt;')
                   .replace('&gt;', '&amp;gt;'))
        new_esc = (edit["new"]
                   .replace('&amp;', '&amp;amp;')
                   .replace('&lt;', '&amp;lt;')
                   .replace('&gt;', '&amp;gt;'))
        reason_esc = (reason_text
                      .replace('&amp;', '&amp;amp;')
                      .replace('&lt;', '&amp;lt;')
                      .replace('&gt;', '&amp;gt;')
                      .replace('\n', '&lt;br&gt;'))

        cards.append(f"""
      &lt;div class="edit-card {status_cls}"&gt;
        &lt;div class="edit-header"&gt;
          &lt;span class="edit-num"&gt;#{idx + 1}&lt;/span&gt;
          &lt;span class="edit-status status-{status_cls}"&gt;{status_label}&lt;/span&gt;
          &lt;span class="edit-reason"&gt;{reason_esc}&lt;/span&gt;
        &lt;/div&gt;
        &lt;div class="edit-diff"&gt;
          &lt;div class="diff-old"&gt;
            &lt;span class="diff-label"&gt;OLD:&lt;/span&gt; &lt;code&gt;{old_esc}&lt;/code&gt;
          &lt;/div&gt;
          &lt;div class="diff-new"&gt;
            &lt;span class="diff-label"&gt;NEW:&lt;/span&gt; &lt;code&gt;{new_esc}&lt;/code&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;""")

    # Notes.
    for idx, edit in note_items:
        reason_esc = (edit["reason"]
                      .replace('&amp;', '&amp;amp;')
                      .replace('&lt;', '&amp;lt;')
                      .replace('&gt;', '&amp;gt;')
                      .replace('\n', '&lt;br&gt;'))
        cards.append(f"""
      &lt;div class="edit-card note"&gt;
        &lt;div class="edit-header"&gt;
          &lt;span class="edit-num"&gt;#{idx + 1}&lt;/span&gt;
          &lt;span class="edit-status status-note"&gt;Note&lt;/span&gt;
        &lt;/div&gt;
        &lt;div class="note-body"&gt;{reason_esc}&lt;/div&gt;
      &lt;/div&gt;""")

    cards_html = "\n".join(cards) if cards else """
      &lt;div style="color: var(--success); font-size: 1rem;
                  padding: 2rem; text-align: center;"&gt;
        All edits were applied. No notes.
      &lt;/div&gt;"""

    return f"""&lt;!DOCTYPE html&gt;
&lt;html lang="en"&gt;
&lt;head&gt;
&lt;meta charset="utf-8"&gt;
&lt;title&gt;Apply Edits -- Results&lt;/title&gt;
&lt;style&gt;
{_CSS_VARS}
{_BTN_CSS}
  .page-wrap  {{ max-width: 960px; margin: 0 auto; padding: 2rem 1.5rem 140px; }}
  h1          {{ font-size: 1.5rem; font-weight: 700; margin-bottom: 0.25rem; }}
  .subtitle   {{ color: var(--muted); font-size: 0.9rem; margin-bottom: 0.5rem; }}
  .summary    {{ color: var(--muted); font-size: 0.85rem; margin-bottom: 1.5rem;
                 padding: 0.8rem 1rem; background: var(--surface);
                 border: 1px solid var(--border); border-radius: 8px; }}
  .summary .ct-applied  {{ color: var(--success); font-weight: 600; }}
  .summary .ct-skipped  {{ color: var(--warn);    font-weight: 600; }}
  .summary .ct-note     {{ color: var(--accent);  font-weight: 600; }}
  .edit-card  {{ background: var(--surface); border: 1px solid var(--border);
                 border-radius: 8px; padding: 1rem; margin-bottom: 0.8rem; }}
  .edit-card.skipped   {{ border-left: 3px solid var(--warn); }}
  .edit-card.not-found {{ border-left: 3px solid var(--danger); }}
  .edit-card.duplicate {{ border-left: 3px solid var(--warn); }}
  .edit-card.error     {{ border-left: 3px solid var(--danger); }}
  .edit-card.note      {{ border-left: 3px solid var(--accent); }}
  .status-note    {{ background: rgba(91,138,245,0.15); color: var(--accent); }}
  .note-body      {{ font-size: 0.85rem; color: var(--text); line-height: 1.6;
                     padding: 0.4rem 0; }}
  .edit-header {{ display: flex; align-items: center; gap: 0.6rem;
                  margin-bottom: 0.6rem; }}
  .edit-num    {{ font-weight: 700; font-size: 0.85rem; }}
  .edit-status {{ font-size: 0.78rem; padding: 0.15rem 0.5rem; border-radius: 4px;
                  font-weight: 600; }}
  .status-skipped   {{ background: rgba(212,168,67,0.15); color: var(--warn); }}
  .status-not-found {{ background: rgba(224,85,85,0.15); color: var(--danger); }}
  .status-duplicate {{ background: rgba(212,168,67,0.15); color: var(--warn); }}
  .status-error     {{ background: rgba(224,85,85,0.15); color: var(--danger); }}
  .edit-reason {{ font-size: 0.82rem; color: var(--muted); flex: 1; }}
  .edit-diff   {{ font-size: 0.82rem; }}
  .diff-old    {{ color: var(--danger); margin-bottom: 0.3rem; }}
  .diff-new    {{ color: var(--success); }}
  .diff-label  {{ font-weight: 700; font-size: 0.75rem; display: inline-block;
                  width: 35px; }}
  .diff-old code, .diff-new code {{
    white-space: pre-wrap; word-break: break-word; }}
  .action-bar {{ position: fixed; bottom: 0; left: 0; right: 0;
                 background: var(--surface); border-top: 1px solid var(--border);
                 padding: 1rem 2rem; display: flex; align-items: center;
                 gap: 1rem; z-index: 20; }}
  .action-bar .hint {{ flex: 1; font-size: 0.82rem; color: var(--muted); }}
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;

&lt;div class="page-wrap"&gt;
  &lt;h1&gt;Results&lt;/h1&gt;
  &lt;p class="subtitle"&gt;File: &lt;strong&gt;{filename}&lt;/strong&gt;&lt;/p&gt;

  &lt;div class="summary"&gt;
    &lt;span class="ct-applied"&gt;{applied_count} applied&lt;/span&gt; &amp;middot;
    &lt;span class="ct-skipped"&gt;{skipped_count} not applied&lt;/span&gt; &amp;middot;
    &lt;span class="ct-note"&gt;{note_count} notes&lt;/span&gt;
  &lt;/div&gt;

  {cards_html}
&lt;/div&gt;

&lt;div class="action-bar"&gt;
  &lt;span class="hint"&gt;Edits have been applied. Backup saved.&lt;/span&gt;
  &lt;button class="btn btn-save" onclick="window.location.href='/'"&gt;Back&lt;/button&gt;
  &lt;button class="btn btn-cancel" onclick="doExit()"&gt;Exit&lt;/button&gt;
&lt;/div&gt;

&lt;script&gt;
  function doExit() {{
    fetch('/shutdown', {{ method: 'POST' }})
    .then(() =&gt; {{
      document.body.innerHTML = '&lt;div style="padding:4rem;text-align:center;'
        + 'color:#888;font-size:1.1rem;"&gt;Done. You may close this window.&lt;/div&gt;';
    }});
  }}
&lt;/script&gt;
&lt;/body&gt;
&lt;/html&gt;"""


# &#9472;&#9472; HTTP Server &#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;
# The server uses Python's built-in ThreadingHTTPServer (stdlib, no deps).
# It runs on 127.0.0.1 only (not accessible from the network).
#
# Mutable state is shared between handlers via closure variables:
#   parsed_edits:    List of edit/note dicts from parse_edits().
#   manuscript_text: List-of-one containing the manuscript string.
#                    (A list is used so inner functions can mutate it;
#                    a plain string would be immutable in the closure.)
#   applied_indices: Set of indices that were successfully applied.
#
# The server runs in a daemon thread.  main() calls server._server_thread.join()
# to wait for shutdown.  The server shuts down when:
#   - User clicks Exit or Save &amp; Exit on the config page.
#   - User clicks Exit on the results page.
#   - The browser tab is closed (the thread is a daemon, so it dies with main).

def serve(live_cfg: dict, run_event: threading.Event):
    """Start the HTTP server, open the browser, and return the server object.

    Args:
        live_cfg:   Mutable config dict (modified in place by POST /config).
        run_event:  Threading event (currently unused, reserved for future use).

    Returns:
        The ThreadingHTTPServer instance.  The caller should join
        server._server_thread to wait for shutdown.
    """
    # &#9472;&#9472; Mutable shared state (closure variables) &#9472;&#9472;
    # These are shared between all request handlers via closure.
    # They persist for the lifetime of the server.

    parsed_edits    = []      # List of edit/note dicts from parse_edits()
    manuscript_text = [""]    # List-of-one: manuscript_text[0] is the string.
                              # A list is used because str is immutable;
                              # inner functions need to reassign the value.
    applied_indices = set()   # Set of indices successfully applied in the
                              # most recent Apply operation.

    server = None  # Forward declaration; set after Handler class is defined.

    class Handler(BaseHTTPRequestHandler):
        """HTTP request handler for all three pages.

        Handles GET (page rendering) and POST (actions and apply).
        Uses closure variables (parsed_edits, manuscript_text,
        applied_indices, live_cfg, server) for shared state.
        Suppresses default stderr logging for cleaner console output.
        """

        def log_message(self, format, *args):
            pass  # suppress default stderr logging

        def _send_html(self, html, code=200):
            data = html.encode("utf-8")
            self.send_response(code)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            self.wfile.write(data)

        def _send_json(self, obj, code=200):
            data = _json.dumps(obj).encode("utf-8")
            self.send_response(code)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            self.wfile.write(data)

        def do_GET(self):
            path = self.path.split("?")[0]

            if path == "/":
                self._send_html(build_config_html(live_cfg))

            elif path == "/preview":
                if not parsed_edits:
                    self._send_html(build_config_html(live_cfg))
                else:
                    self._send_html(build_preview_html(
                        parsed_edits, live_cfg["filename"],
                        manuscript_text[0]))

            elif path == "/results":
                if not parsed_edits:
                    self._send_html(build_config_html(live_cfg))
                else:
                    self._send_html(build_results_html(
                        parsed_edits, applied_indices,
                        live_cfg["filename"]))

            else:
                self.send_response(404)
                self.end_headers()

        def do_POST(self):
            path = self.path.split("?")[0]
            length = int(self.headers.get("Content-Length", 0))

            if path == "/config":
                try:
                    payload = _json.loads(self.rfile.read(length))
                except Exception:
                    self._send_json({"error": "Invalid JSON"}, 400)
                    return

                action = payload.get("action", "")

                # Update live config from form fields.
                live_cfg["drafts_dir"]  = payload.get(
                    "drafts_dir", "").strip()
                live_cfg["backups_dir"] = payload.get(
                    "backups_dir", "").strip()
                live_cfg["filename"]    = payload.get(
                    "filename", "").strip()
                live_cfg["edits_text"]  = payload.get("edits_text", "")

                if action == "exit":
                    self._send_json({"ok": True})
                    threading.Timer(0.5, server.shutdown).start()
                    return

                if action == "save_exit":
                    save_config(live_cfg)
                    self._send_json({"ok": True})
                    threading.Timer(0.5, server.shutdown).start()
                    return

                if action in ("save_preview", "preview"):
                    # Save config if requested.
                    if action == "save_preview":
                        save_config(live_cfg)

                    # Clear state from any previous run.
                    applied_indices.clear()

                    # Validate inputs.
                    drafts_dir = live_cfg["drafts_dir"]
                    filename   = live_cfg["filename"]
                    edits_text = live_cfg["edits_text"]

                    if not drafts_dir:
                        self._send_json(
                            {"error": "Drafts directory is required."})
                        return
                    if not filename:
                        self._send_json(
                            {"error": "Filename is required."})
                        return
                    if not edits_text.strip():
                        self._send_json({"error": "No edits pasted."})
                        return

                    filepath = resolve_filepath(drafts_dir, filename)
                    if not filepath.is_file():
                        self._send_json(
                            {"error": f"File not found: {filepath} "
                             f"(also tried .md and .txt)"})
                        return

                    # Read manuscript.
                    try:
                        manuscript_text[0] = filepath.read_text(
                            encoding="utf-8")
                    except Exception as exc:
                        self._send_json(
                            {"error": f"Cannot read file: {exc}"})
                        return

                    # Parse and analyze edits.
                    parsed_edits.clear()
                    parsed_edits.extend(parse_edits(edits_text))

                    if not parsed_edits:
                        self._send_json(
                            {"error":
                             "No EDIT||| lines found in pasted text."})
                        return

                    analyze_edits(manuscript_text[0], parsed_edits)

                    self._send_json(
                        {"ok": True, "redirect": "/preview"})
                    return

                self._send_json(
                    {"error": f"Unknown action: {action}"}, 400)

            elif path == "/apply":
                try:
                    payload = _json.loads(self.rfile.read(length))
                except Exception:
                    self._send_json({"error": "Invalid JSON"}, 400)
                    return

                indices = payload.get("indices", [])
                if not indices:
                    self._send_json({"error": "No edits selected."})
                    return

                drafts_dir  = live_cfg["drafts_dir"]
                backups_dir = live_cfg["backups_dir"]
                filename    = live_cfg["filename"]
                filepath    = resolve_filepath(drafts_dir, filename)

                if not filepath.is_file():
                    self._send_json(
                        {"error": f"File not found: {filepath} "
                         f"(also tried .md and .txt)"})
                    return

                # Create backup.
                if backups_dir:
                    backup_path = Path(backups_dir)
                    backup_path.mkdir(parents=True, exist_ok=True)
                    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
                    stem   = filepath.stem
                    suffix = filepath.suffix
                    backup_file = (backup_path
                                   / f"{stem}_{timestamp}{suffix}")
                    try:
                        shutil.copy2(filepath, backup_file)
                        print(f"Backup saved -&gt; {backup_file}")
                    except Exception as exc:
                        self._send_json(
                            {"error": f"Backup failed: {exc}"})
                        return
                else:
                    print("WARNING: No backups directory configured. "
                          "Skipping backup.")

                # Re-read the file fresh in case the user edited it
                # externally while the preview screen was open.
                try:
                    manuscript_text[0] = filepath.read_text(
                        encoding="utf-8")
                except Exception as exc:
                    self._send_json(
                        {"error": f"Cannot re-read file: {exc}"})
                    return

                # Re-analyze edits against the current file contents.
                # Some edits that were "ready" at preview time may no
                # longer match if the user edited the file.
                analyze_edits(manuscript_text[0], parsed_edits)

                # Filter indices to only those still ready.
                valid_indices = [i for i in indices
                                 if 0 &lt;= i &lt; len(parsed_edits)
                                 and parsed_edits[i]["status"] == "ready"]

                if not valid_indices:
                    self._send_json(
                        {"error": "No selected edits match the current "
                         "file. The file may have been modified since "
                         "preview."})
                    return

                skipped = len(indices) - len(valid_indices)
                if skipped &gt; 0:
                    print(f"NOTE: {skipped} edit(s) no longer match "
                          f"the file and were skipped.")

                # Apply edits.
                result = apply_selected_edits(
                    manuscript_text[0], parsed_edits, valid_indices)

                # Track what was applied.
                applied_indices.clear()
                applied_indices.update(valid_indices)

                # Write result.
                try:
                    filepath.write_text(result, encoding="utf-8")
                    print(f"Edits applied -&gt; {filepath}")
                except Exception as exc:
                    self._send_json(
                        {"error": f"Write failed: {exc}"})
                    return

                self._send_json(
                    {"ok": True, "applied": len(valid_indices),
                     "skipped_since_preview": skipped,
                     "redirect": "/results"})

            elif path == "/shutdown":
                self._send_json({"ok": True})
                threading.Timer(0.5, server.shutdown).start()

            else:
                self.send_response(404)
                self.end_headers()

    server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)

    server_ready = threading.Event()

    def _serve():
        server_ready.set()
        server.serve_forever()

    server_thread = threading.Thread(target=_serve, daemon=True)
    server._server_thread = server_thread
    server_thread.start()

    if not server_ready.wait(timeout=5.0):
        print("ERROR: HTTP server did not start within 5 seconds.")
        sys.exit(1)

    url = f"http://127.0.0.1:{PORT}"
    print(f"Server running at {url}")
    print("  Configure paths and paste edits in the browser.\n")
    webbrowser.open(url)
    return server


# &#9472;&#9472; Entry Point &#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;&#9472;

def main():
    """Top-level orchestration: load config, start server, wait for shutdown.

    Flow:
      1. load_config() reads apply_edits_config.json or returns defaults.
      2. serve() starts the HTTP server on localhost:8767 in a daemon
         thread and opens the default browser to the config page.
      3. All user interaction happens via the browser.  The server thread
         handles GET and POST requests for all three pages.
      4. main() blocks on server._server_thread.join(), waiting for the
         server to shut down (triggered by Exit, Save &amp; Exit, or /shutdown).
      5. When the server thread finishes, main() prints "Done." and exits.
    """
    live_cfg  = load_config()
    run_event = threading.Event()

    server = serve(live_cfg, run_event)

    # Wait for the server to shut down.
    server._server_thread.join()
    print("Done.")


if __name__ == "__main__":
    main()
</code></pre></div><p></p>]]></content:encoded></item><item><title><![CDATA[AI Editing]]></title><description><![CDATA[AI-Assisted Workflow and A Cold Civil War]]></description><link>https://brucemackinlay1.substack.com/p/ai-editing-first-post</link><guid isPermaLink="false">https://brucemackinlay1.substack.com/p/ai-editing-first-post</guid><dc:creator><![CDATA[Bruce Mackinlay]]></dc:creator><pubDate>Sun, 10 May 2026 20:48:30 GMT</pubDate><enclosure url="https://substackcdn.com/image/fetch/$s_!bvk5!,w_256,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F33b7be8a-428d-487a-bec1-ebd5fc0cb30f_1024x1024.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1><strong>AI-Assisted Workflow and </strong><em><strong>A Cold Civil War</strong></em></h1><p>Since October, I&#8217;ve been writing a serialized political thriller called <em>A Cold Civil War</em>. The project mixes conventional narrative chapters with fictional congressional reports, legal analyses, internal memos, future university course materials, magazine retrospectives, and other documentary-style material.</p><p>At its core, the project is an attempt to persuade people to think differently about the direction the country may be heading before political division hardens into something irreversible. I&#8217;m trying to use fiction to make institutional and constitutional risks feel personal and human rather than abstract. You rarely change hearts with logic alone. It usually takes a story.</p><p>One thing I realized early was that I did not have time to write this project the way novels are written. If the story was going to influence how people think about where the country may be heading, it had to respond while events were still unfolding and people were still persuadable, before a cold civil conflict hardened into something worse.</p><p>I&#8217;m neurodivergent. In elementary school, they used the word dyslexic. I learned early to use technology to fill the gaps. Before the Apple II was released, I built a word processor with spell check on an HP2000 in HP-BASIC. I solved that problem with technology, and I&#8217;m doing the same thing now. I need to move fast. Very fast. How does someone with my skill set write a novel in months?</p><p>To move this fast, I built a workflow designed around rapid iteration.</p><p>It&#8217;s not &#8220;AI writes the book for me.&#8221; It&#8217;s more like:</p><ul><li><p>AI-assisted editing</p></li><li><p>continuity review</p></li><li><p>structural critique</p></li><li><p>factual stress-testing</p></li><li><p>procedural review</p></li><li><p>serialization support</p></li></ul><p>I still write the drafts myself. But I use AI heavily as part of the editorial process, along with a growing set of prompts, Markdown workflows, Obsidian organization, and small Python tools built with Claude.</p><p>What surprised me was that the biggest gains came after the writing. They came from the editing workflow I built.</p><p>For example:</p><ul><li><p>style passes</p></li><li><p>beta-reader passes</p></li><li><p>fact-check passes</p></li><li><p>line-edit passes</p></li><li><p>continuity passes after structural rewrites</p></li></ul><p>The process feels much closer to running an evolving production pipeline than asking an AI to &#8220;write a scene.&#8221;</p><p>I also don&#8217;t see technology and serious writing as contradictions. Writers have always absorbed the tools available to them. What matters is whether the final work communicates something truthful, emotionally coherent, and worth reading. At least that&#8217;s what I hope I&#8217;m doing.</p><p>I organize everything in Obsidian using Markdown, though the same workflow could probably work in Google Docs or similar tools. The Python program would need modification.</p><p>My process currently looks like this:</p><h2><strong>Human Draft</strong></h2><p>I write the initial draft myself and revise it until it says what I want to say. Sometimes I use AI to brainstorm plot directions or scene structures. For example, in a scene called &#8220;Corruption Is Real,&#8221; I was trying to show how corruption works in the modern world while still making the scene feel human and readable. ChatGPT gave me five possible approaches. I didn't use any of them directly, but the process helped me think through the scene and sharpen what I was trying to do. Here is the scene:</p><p><a href="https://brucemackinlay1.substack.com/p/corruption-is-real">Corruption is Real</a></p><p>In future posts, I hope to dive into how I use AI during the drafting process. This post is focused on editing.</p><h2><strong>Step 0: Setup</strong></h2><p>I upload the scene to an AI. I use Claude and ChatGPT. I also upload character notes and running summaries when needed. I sometimes upload prior scenes because they help keep the AI on course.</p><p>At the same time, I run a small Python tool built with Claude. It helps manage file paths, scene names, and side-by-side review of edits.</p><p><a href="https://brucemackinlay1.substack.com/p/edit-markdown">Python Program to Manage Document Edits</a></p><h2><strong>Step 1: Style Pass (iterative)</strong></h2><p>I run a dedicated style-check prompt on the AI.</p><p><a href="https://brucemackinlay1.substack.com/p/prompt-1-style-check">Prompt 1-Style Check</a></p><p>I cut and paste the AI's output into the Python program and preview the edits. For each edit or note produced in the Python screen, I click:</p><ol><li><p>accept</p></li><li><p>reject</p></li></ol><p>I keep Obsidian open and may jump to the edit or note to make changes by hand.</p><p>After edits are applied, I reload the updated text into the AI before running another pass. This turned out to be critical. Otherwise, the AI keeps editing outdated text. I then tell the AI, &#8220;Re-review with the attached text.&#8221;</p><p>The notes are the most important. They often lead to major rewrites.</p><p>I repeat step 1 until the suggestions become minor or repetitive.</p><h2><strong>Steps 2&#8211;4</strong></h2><p>Then I repeat the same process using different prompts:</p><ul><li><p>Fact Check</p></li><li><p>Beta Reader</p></li><li><p>Line Edit</p></li></ul><p>The Beta Reader step has probably been the most valuable. It often catches pacing or structural problems I missed completely, especially in serialized fiction where scenes move around after major rewrites.</p><p>And if you want to see the project itself, the best starting point is probably the preface:</p><p><a href="https://brucemackinlay1.substack.com/p/preface-a-cold-civil-war">Preface &#8212; A Cold Civil War</a><br><a href="https://brucemackinlay1.substack.com/p/a-cold-civil-war-master-index">Master Index</a><br><a href="https://brucemackinlay1.substack.com/s/a-cold-civil-war">Substack Archive</a><br><br>All my posts related to AI are in this archive:<br><a href="https://brucemackinlay1.substack.com/s/ai-writing">AI Writing Archive</a></p><p>I&#8217;d also be curious how other people here are approaching long-form AI-assisted fiction without falling into generic &#8220;AI voice.&#8221;</p>]]></content:encoded></item></channel></rss>