Skip to content

Assemble the Downs Style archive and Charles voice system - #1788

Open
4444J99 wants to merge 3 commits into
mainfrom
docs/charles-downs-style-archive
Open

Assemble the Downs Style archive and Charles voice system#1788
4444J99 wants to merge 3 commits into
mainfrom
docs/charles-downs-style-archive

Conversation

@4444J99

@4444J99 4444J99 commented Aug 3, 2026

Copy link
Copy Markdown
Member

What this gives Charles

  • a complete, searchable archive of all 258 public Downs Style posts
  • an evidence-backed Natural Center and channel voice profile derived from the 257 pre-assisted posts
  • a concrete editorial runway for cotton, silk, candle care, and the Transcend Essentials rebrand
  • a refreshed studio preview with the six-product rose article and 11-row comparison

Verification

  • archive integrity verifier: 258 unique posts, nine categories, 257-post voice baseline, body-free site data
  • production build: passed
  • rendered HTML tests: 6/6 passed
  • scoped repository verification: passed

The live Squarespace site is unchanged.

Summary by CodeRabbit

  • New Features
    • Added the Downs Style Studio with a rosewater skincare comparison article.
    • Added an interactive archive explorer with search, category and year filters, post links, and word counts.
    • Added draft comparison views showing editorial changes and decisions.
    • Added a voice-system page documenting style principles, evidence, guardrails, and future content.
  • Documentation
    • Added archive audits, voice metrics, editorial plans, channel guidance, and review workflows.
  • Quality
    • Added rendering, content-integrity, disclosure, and archive-validation checks.

Copilot AI review requested due to automatic review settings August 3, 2026 00:22
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Multi-agent review roll call
@codex review

(CodeRabbit and Claude review automatically. Reviewers: post substantive findings only. Authors/agents: address every thread, push fixes to this branch, reply and resolve, then re-request review.)

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@4444J99, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 44 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f18fccd0-15c2-468a-a22d-99dfaccf1638

📥 Commits

Reviewing files that changed from the base of the PR and between eed96f6 and 738e468.

📒 Files selected for processing (1)
  • docs/continuations/charles/rose-toners-share/tests/rendered-html.test.mjs
📝 Walkthrough

Walkthrough

This PR adds Downs Style archive and voice-analysis artifacts, editorial continuation contracts, and a review-only Next.js studio. The studio includes article, comparison, archive, and voice pages with Cloudflare Worker wiring and rendered HTML tests.

Changes

Downs Style evidence and editorial contracts

Layer / File(s) Summary
Archive audit and voice metrics
scripts/audit-downs-style-archive.py, scripts/analyze-downs-style-voice.py, scripts/verify-downs-style-archive.py, docs/continuations/charles/downs-style-archive-audit.md, docs/continuations/charles/downs-style-voice-metrics.json
The scripts discover, parse, fingerprint, analyze, and verify the public archive. The documentation records 258 posts, distributions, baseline rules, reproduction commands, and artifact hashes.
Editorial identity and continuation rules
docs/continuations/charles-downs-style-archive/workstream.json, docs/continuations/charles/downs-style-channel-profile.yaml, docs/continuations/charles/downs-style-natural-center.yaml, docs/continuations/charles/downs-style-voice-evidence.md, docs/continuations/charles/next-posts.md
The documents define workstream controls, Charles-specific voice rules, factual guardrails, evidence requirements, and queued editorial briefs.

Rose Toners studio

Layer / File(s) Summary
Studio runtime and deployment wiring
docs/continuations/charles/rose-toners-share/package.json, app/layout.tsx, app/chatgpt-auth.ts, app/site-header.tsx, vite.config.ts, worker/index.ts, db/*, README.md, *.config.*
The project adds the Next.js/Vinext runtime, shared layout and navigation, authentication helpers, Cloudflare Worker routing, local bindings, Drizzle setup, and review-only project documentation.
Article, comparison, archive, and voice pages
docs/continuations/charles/rose-toners-share/app/page.tsx, app/compare/page.tsx, app/archive/*, app/voice/page.tsx, app/globals.css, data/posts.json
The studio renders the rosewater article, eleven-section draft comparison with word-level diffing, a searchable 331-record archive catalog, and the evidence-backed voice system.
D1 example and rendered-site validation
docs/continuations/charles/rose-toners-share/examples/d1/*, tests/rendered-html.test.mjs
The opt-in D1 example defines a notes table and API. Server-rendering tests validate page content, disclosures, archive bounds, navigation, data shape, and excluded starter artifacts.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant CloudflareWorker
  participant AppRouter
  participant ArchiveExplorer
  Browser->>CloudflareWorker: request studio page
  CloudflareWorker->>AppRouter: delegate application route
  AppRouter->>ArchiveExplorer: render archive data and filters
  CloudflareWorker->>Browser: return optimized image response
  AppRouter->>Browser: return rendered HTML
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: assembling the Downs Style archive and Charles voice system.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/charles-downs-style-archive

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a reproducible Downs Style public-post archive (258 posts) plus a derived, non-verbatim voice evidence system and a private “studio” preview site that presents the archive, voice system, and rosewater editorial review surfaces.

Changes:

  • Add Python scripts to audit the live Downs Style archive, derive aggregate voice metrics, and verify the committed artifacts for integrity and boundedness.
  • Add a self-contained Vinext/Next-based “Downs Style Studio” preview (article, archive explorer, voice page, and draft comparison) with rendered-HTML tests.
  • Add documentation artifacts (evidence sheet, Natural Center, channel profile, next-post runway) and a workstream receipt for the archive/voice assembly.

Reviewed changes

Copilot reviewed 37 out of 43 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
scripts/verify-downs-style-archive.py Verifies ledger/metrics/site JSON counts, categories/years, baseline cutoff, and “no body text” constraints.
scripts/audit-downs-style-archive.py Crawls sitemap + collection feeds, fetches posts, extracts metadata/body fingerprints, and writes CSV + bounded site JSON.
scripts/analyze-downs-style-voice.py Produces aggregate, non-verbatim voice metrics from a private full-text corpus JSON.
docs/continuations/charles/rose-toners-share/worker/index.ts Cloudflare Worker entry for the studio build, including vinext image optimization routing.
docs/continuations/charles/rose-toners-share/vite.config.ts Vite/Vinext configuration for local bindings and Cloudflare vite-plugin integration.
docs/continuations/charles/rose-toners-share/tsconfig.json TypeScript configuration for the studio template.
docs/continuations/charles/rose-toners-share/tests/rendered-html.test.mjs Node test suite asserting server-rendered routes, archive boundedness, and key content expectations.
docs/continuations/charles/rose-toners-share/README.md Studio overview, routes, data-boundedness intent, and local usage.
docs/continuations/charles/rose-toners-share/postcss.config.mjs Tailwind PostCSS configuration for the studio build.
docs/continuations/charles/rose-toners-share/package.json Studio package scripts and dependency set for build/test/dev.
docs/continuations/charles/rose-toners-share/next.config.ts Next.js configuration placeholder for the studio.
docs/continuations/charles/rose-toners-share/examples/d1/db/schema.ts Example Drizzle D1 schema (opt-in sample).
docs/continuations/charles/rose-toners-share/examples/d1/app/api/notes/route.ts Example API route demonstrating D1-backed notes with clearer operational errors.
docs/continuations/charles/rose-toners-share/eslint.config.mjs ESLint configuration aligned with Next core-web-vitals + TypeScript presets.
docs/continuations/charles/rose-toners-share/drizzle/meta/_journal.json Drizzle metadata scaffold.
docs/continuations/charles/rose-toners-share/drizzle.config.ts Drizzle-kit config pointing at the (currently empty) default schema.
docs/continuations/charles/rose-toners-share/db/schema.ts Empty-by-default schema placeholder to keep DB opt-in.
docs/continuations/charles/rose-toners-share/db/index.ts D1 Drizzle initialization with a clear error when DB binding is absent.
docs/continuations/charles/rose-toners-share/build/sites-vite-plugin.ts Vite plugin to package Sites metadata + migrations into build output.
docs/continuations/charles/rose-toners-share/app/voice/page.tsx “Voice system” page describing Natural Center, evidence, and editorial runway.
docs/continuations/charles/rose-toners-share/app/site-header.tsx Shared navigation header for studio routes.
docs/continuations/charles/rose-toners-share/app/page.tsx Main rosewater article draft page with product sections, disclosure, and comparison narrative.
docs/continuations/charles/rose-toners-share/app/layout.tsx Root layout with metadata generation based on request headers and font setup.
docs/continuations/charles/rose-toners-share/app/compare/page.tsx Three-panel draft comparison page with in-page word-diff rendering.
docs/continuations/charles/rose-toners-share/app/chatgpt-auth.ts Header-based ChatGPT auth helper with safe return_to handling.
docs/continuations/charles/rose-toners-share/app/archive/page.tsx Archive route wiring bounded JSON into the archive explorer UI.
docs/continuations/charles/rose-toners-share/app/archive/archive-explorer.tsx Client-side filtering/search UI for the 258-post archive.
docs/continuations/charles/rose-toners-share/.openai/hosting.json Preserves Sites project identity and optional binding placeholders.
docs/continuations/charles/rose-toners-share/.gitignore Studio-scoped ignores and explicit unignore rules for build helpers/public artwork.
docs/continuations/charles/next-posts.md Editorial briefs and truth gates for the next-post slate (cotton/silk/candle/rebrand).
docs/continuations/charles/downs-style-voice-evidence.md Evidence sheet tying quantitative signals to editorial consequences/guardrails.
docs/continuations/charles/downs-style-natural-center.yaml Portable Natural Center profile with confidence scores, negative space, and system prompt.
docs/continuations/charles/downs-style-channel-profile.yaml Channel profile translating the Natural Center into channel/platform guidance.
docs/continuations/charles/downs-style-archive-audit.md Human-readable archive audit summary and reproduction commands/hashes.
docs/continuations/charles-downs-style-archive/workstream.json Workstream receipt capturing the contract/runway context for this effort.

Comment on lines +271 to +275
if any("body" in key for key in output):
raise AssertionError("derived output must not expose article bodies")
serialized = json.dumps(output, indent=2, ensure_ascii=False, sort_keys=False) + "\n"
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(serialized, encoding="utf-8")
Comment on lines +126 to +130
prohibited = {"body", "content", "html", "tags"}
require(
all(not (prohibited & post.keys()) for post in site_posts),
"site data includes article bodies or noisy raw tags",
)
Comment on lines +23 to +26
from typing import Iterable
from urllib.parse import urljoin, urlparse

from bs4 import BeautifulSoup

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eed96f6725

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

overall_confidence: 0.88

source_asset_ids:
- "downs-style-post-ledger.csv@sha256:00568b0f472b0e6cf7792064c40a1f8c32dde764195109e27d507684bd88dcad"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record the committed ledger fingerprint

The declared SHA-256 does not identify the ledger stored in this commit: hashing the exact Git blob produces d3a03b2510c6a4b25450cbf463dc7d34b76f6ccfb0b81446c574d84ce3fb8c95, not 00568b0f.... Consequently, any consumer validating source_asset_ids will reject the voice profile's primary provenance artifact; compute the fingerprint after Git's line-ending normalization or define and verify a canonical byte representation.

Useful? React with 👍 / 👎.

Comment on lines +145 to +146
"count": normalized_text.count(marker),
"per_1000_words": safe_rate(normalized_text.count(marker), word_total),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count whole phrases rather than substrings

For phrase markers that prefix another phrase, str.count inflates the evidence: every occurrence of i loved is also counted as i love, so the published baseline's 226 I love uses include all 72 I loved uses. The evidence sheet and voice page then present that corrupted count as a causal voice signal; tokenize or use phrase-boundary matching before calculating counts and rates.

Useful? React with 👍 / 👎.

"dev": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext dev",
"build": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext build",
"start": "WRANGLER_LOG_PATH=.wrangler/wrangler.log vinext start",
"test": "npm run build && node --test tests/rendered-html.test.mjs",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register the new archive checks as a gate

Although this package defines a production build and rendered-route test, no entry in institutio/governance/gates.yaml maps that command—or verify-downs-style-archive.py—to the new site, ledger, or metrics paths. Future edits can therefore pass scoped verification with only generic syntax/diff checks while breaking the build, route rendering, or archive integrity; register these predicates and their implicated paths in the gate registry.

AGENTS.md reference: AGENTS.md:L61-L66

Useful? React with 👍 / 👎.

sources_by_url: dict[str, set[str]] = {post.url: {"sitemap"} for post in sitemap_posts}
lastmod_by_url = {post.url: post.lastmod for post in sitemap_posts}

collections = sorted({fallback_date_and_collection(post.url)[1] for post in sitemap_posts})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enumerate collection feeds independently of the sitemap

The collection cross-check is seeded exclusively from collection slugs already present in the sitemap. If Squarespace omits every post from one collection—the exact class of omission these feeds are meant to detect—the audit never requests that collection's JSON feed and can still report zero failures; even discovering one current post on the homepage does not update this precomputed set. Iterate the known collection registry, or otherwise discover collection feeds independently, before claiming complete archive coverage.

Useful? React with 👍 / 👎.

Comment on lines +271 to +272
if any("body" in key for key in output):
raise AssertionError("derived output must not expose article bodies")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check nested output before declaring the corpus body-free

This privacy assertion examines only the top-level keys (schema_version, source, baseline, and so on), so it cannot detect a body key introduced inside baseline.metrics, all_public_posts, or any by_* result returned by summarize. In that scenario the script would serialize article text while still hard-coding contains_verbatim_article_text to false; recursively inspect the complete output structure before writing the tracked artifact.

Useful? React with 👍 / 👎.

};
}

export default function RootLayout({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Protect the private editorial studio before deployment

When this existing Sites project is deployed, every route—including the original draft and editorial rationale on /compare—renders for requests with no authentication headers. The repository-wide wiring shows that requireChatGPTUser is never invoked, and the rendered-route tests explicitly send an unauthenticated request and expect HTTP 200, despite the README defining this as a private studio; enforce authentication in the shared layout before exposing these pages.

Useful? React with 👍 / 👎.

edited: thoughtClusters(
"Mario Badescu Facial Spray With Aloe, Herbs And Rosewater",
"If you had any sort of affliction with skincare like we did in the late 2010s, then you already know this bottle. From your favorite celebrities to your next-door neighbor, someone you knew owned at least one Mario Badescu product, if not this one specifically.",
"Not without good cause, of course. Rosewater and aloe give the facial spray the light, refreshing personality we all remember, while the supporting botanicals keep it from feeling like plain floral water.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the comparison copy identical to the finished preview

The comparison presents this text as the complete edited version and links readers to the finished article, but the article renders the supporting botanicals kept it while this panel renders keep it. A reviewer therefore approves text different from the actual preview, and the current rendered tests do not compare the two routes; source the edited copy from one shared value or add a parity assertion.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (6)
scripts/analyze-downs-style-voice.py (2)

101-149: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

summarize tokenizes each body several times.

Line 102 tokenizes every body, and Line 105 tokenizes them again for post_lengths. summarize then runs once for the baseline, once for all posts, once per year, once per category, and once per era. The whole corpus is re-tokenized about five times. The corpus is small, so this is not blocking. If you extend the marker sets, cache the token list per post.

♻️ Proposed fix
-    all_words = [word for post in posts for word in words(post["body"])]
-    word_counts = Counter(all_words)
-    word_total = len(all_words)
-    post_lengths = [len(words(post["body"])) for post in posts]
+    words_by_post = [words(post["body"]) for post in posts]
+    all_words = [word for post_words in words_by_post for word in post_words]
+    word_counts = Counter(all_words)
+    word_total = len(all_words)
+    post_lengths = [len(post_words) for post_words in words_by_post]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/analyze-downs-style-voice.py` around lines 101 - 149, Update
summarize to reuse tokenized words for each post instead of calling
words(post["body"]) repeatedly when building all_words and post_lengths. Cache
each post’s token list within the function, then derive both aggregates from
that cache while preserving the existing summary results.

271-272: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

The body guard checks only top-level keys.

output has seven fixed top-level keys. None can ever contain the substring body, so this check always passes and proves nothing about the nested metrics blocks. Either walk the structure recursively or state in a comment that the guard is a literal-key tripwire for future top-level additions.

♻️ Proposed fix
-    if any("body" in key for key in output):
-        raise AssertionError("derived output must not expose article bodies")
-    serialized = json.dumps(output, indent=2, ensure_ascii=False, sort_keys=False) + "\n"
+    serialized = json.dumps(output, indent=2, ensure_ascii=False, sort_keys=False) + "\n"
+    if '"body"' in serialized:
+        raise SystemExit("derived output must not expose article bodies")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/analyze-downs-style-voice.py` around lines 271 - 272, Update the body
guard near the output validation to inspect nested structures recursively,
including the metrics blocks, rather than checking only top-level keys; ensure
it raises AssertionError whenever any nested key exposes “body”.
scripts/audit-downs-style-archive.py (1)

130-153: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Confirm the format=json append cannot duplicate the parameter.

Line 151 adds format=json to every nextPageUrl. If Squarespace returns a nextPageUrl that already carries format=json, the request URL gets the parameter twice. The loop still terminates because visited_pages blocks repeats, but the second page can be requested with a malformed query. Confirm the observed nextPageUrl shape, or strip an existing format parameter before you append.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/audit-downs-style-archive.py` around lines 130 - 153, Update
discover_collection_posts so pagination URLs cannot receive duplicate format
parameters: either verify nextPageUrl never includes format=json and preserve
that invariant, or normalize the parsed URL/query by removing any existing
format parameter before appending format=json. Keep pagination traversal and
visited_pages cycle detection unchanged.
docs/continuations/charles/rose-toners-share/app/globals.css (1)

776-782: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the unreachable .compare-header selector.

SiteHeader always renders <header className="site-header">, regardless of the active page — there is no element with class compare-header anywhere in the studio. The active-link underline these rules attempt to add is already handled by the general .site-header nav a[aria-current="page"] rule (Line 135). This selector is dead code.

🧹 Proposed cleanup
-.compare-header nav a[aria-current="page"] {
-  color: var(--ink);
-}
-
-.compare-header nav a[aria-current="page"]::after {
-  transform: scaleX(1);
-}
-
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/continuations/charles/rose-toners-share/app/globals.css` around lines
776 - 782, Remove the unreachable .compare-header nav active-link rules,
including the color and ::after declarations. Keep the existing general
.site-header nav a[aria-current="page"] styling unchanged.
docs/continuations/charles/rose-toners-share/app/page.tsx (1)

384-444: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the repeated affiliate rel ternary.

The same "affiliate" in product && product.affiliate ? "sponsored noreferrer" : "noreferrer" expression appears three times (image link, placeholder link, title link). A future change to the affiliate-link rule risks updating only some occurrences. Extract a small helper, for example function relFor(product: Product) { return "affiliate" in product && product.affiliate ? "sponsored noreferrer" : "noreferrer"; }, and call it at each site.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/continuations/charles/rose-toners-share/app/page.tsx` around lines 384 -
444, The affiliate rel-attribute logic is duplicated across the image,
placeholder, and title links. Add a small helper near the product rendering
code, such as relFor accepting the existing Product type, and replace all three
inline ternaries with calls to that helper while preserving the current rel
values.
docs/continuations/charles/rose-toners-share/app/archive/page.tsx (1)

71-96: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Derive the hero stats and bookends from the data instead of hardcoding them.

"Public posts" (258), "Editorial categories" (09), and "Calendar span" (10) are literal strings, while posts, categoryCounts, and years are already computed above from postsData. The bookend titles/dates (lines 87-96) are also hardcoded separately from posts.

If the archive is regenerated with a different post count, category set, or year range, these labels will silently go stale even though the explorer below renders the correct data. Compute them instead:

  • "Public posts": {posts.length}
  • "Editorial categories": {categoryCounts.length}
  • "Calendar span": derive from years (e.g. Number(years[0]) - Number(years[years.length - 1]) + 1)
  • Bookends: derive from posts[0] (newest) and posts[posts.length - 1] (oldest), since posts preserves the source ordering.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/continuations/charles/rose-toners-share/app/archive/page.tsx` around
lines 71 - 96, Update the archive hero stats and bookends in the page component
to derive values from the existing posts, categoryCounts, and years symbols
instead of literals. Render posts.length, categoryCounts.length, and the
inclusive year span from the first and last years; derive the latest and oldest
bookend dates and titles from posts[0] and posts[posts.length - 1], preserving
the existing source ordering.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/continuations/charles/downs-style-archive-audit.md`:
- Around line 69-80: Update the Reproduction command for
scripts/audit-downs-style-archive.py to include the --site-json option targeting
the checked site data file used by scripts/verify-downs-style-archive.py,
docs/continuations/charles/rose-toners-share/data/posts.json. Keep the existing
output and corpus arguments unchanged so the documented workflow regenerates
both the ledger and site data.

In `@docs/continuations/charles/rose-toners-share/app/layout.tsx`:
- Around line 20-28: Update the origin construction in generateMetadata to use
the canonical origin from trusted deployment configuration instead of directly
trusting x-forwarded-host, x-forwarded-proto, or host. Before creating
metadataBase or social-image URLs, validate any preview origin against an
explicit allowlist and reject or fall back on invalid host, path, or protocol
values so new URL(origin) always receives a safe canonical origin.

In `@docs/continuations/charles/rose-toners-share/worker/index.ts`:
- Around line 29-44: Update the fallback call in the fetch method to invoke
handler.fetch with only request, matching the declared vinext handler signature.
Keep the existing image-optimization branch and worker-level env/ctx handling
unchanged.

In `@scripts/analyze-downs-style-voice.py`:
- Around line 210-219: Validate each post’s published_date in the validation
loop before the baseline list comprehension parses it. Reject empty or malformed
values by raising SystemExit with a readable post-specific schema error, while
preserving the existing required-field validation and baseline filtering for
valid dates.
- Around line 216-221: Update the baseline/excluded partition in the analysis
flow to classify each post once using the same published-date cutoff, rather
than deriving excluded via post dictionary membership. Preserve the existing
cutoff boundary and ensure duplicate posts are independently assigned to
baseline or excluded, avoiding full-dictionary comparisons.

In `@scripts/audit-downs-style-archive.py`:
- Line 26: Update scripts/audit-downs-style-archive.py lines 26-26 to catch a
missing bs4 dependency and exit with an installation instruction, and guard the
sitemap fetch at line 326 against its expected environment/input failure. In
scripts/verify-downs-style-archive.py lines 69-72, require --ledger, --metrics,
and --site-json before opening them. In scripts/analyze-downs-style-voice.py
lines 210-219, validate published_date with date.fromisoformat inside the
existing field-check loop and raise SystemExit including the offending index and
value.
- Around line 76-91: Replace the `assert last_error is not None` in `fetch` with
an explicit raise that propagates `last_error` after all retry attempts are
exhausted, ensuring the function never falls through and preserves the original
network error even under `python -O`.

In `@scripts/verify-downs-style-archive.py`:
- Around line 101-102: Update the date-bound assertions in the archive
verification logic to derive the earliest and latest published dates from all
records rather than relying on records[0] and records[-1] ordering. Preserve the
existing expected dates and require messages while making the predicates
independent of CSV row order.
- Around line 69-72: Update main around args.ledger.open to catch a missing-file
error and route it through the existing require-based validation path, producing
one readable verification failure line instead of an uncaught FileNotFoundError
traceback. Preserve the current CSV parsing behavior when the ledger exists.
- Around line 14-35: Register scripts/verify-downs-style-archive.py as an
archive-validation gate in institutio/governance/gates.yaml, and map
docs/continuations/charles/** to that gate so scripts/verify-scoped.sh invokes
it for changed continuation paths. Preserve the existing archive count contract
defined by EXPECTED_CATEGORIES and EXPECTED_YEARS.

---

Nitpick comments:
In `@docs/continuations/charles/rose-toners-share/app/archive/page.tsx`:
- Around line 71-96: Update the archive hero stats and bookends in the page
component to derive values from the existing posts, categoryCounts, and years
symbols instead of literals. Render posts.length, categoryCounts.length, and the
inclusive year span from the first and last years; derive the latest and oldest
bookend dates and titles from posts[0] and posts[posts.length - 1], preserving
the existing source ordering.

In `@docs/continuations/charles/rose-toners-share/app/globals.css`:
- Around line 776-782: Remove the unreachable .compare-header nav active-link
rules, including the color and ::after declarations. Keep the existing general
.site-header nav a[aria-current="page"] styling unchanged.

In `@docs/continuations/charles/rose-toners-share/app/page.tsx`:
- Around line 384-444: The affiliate rel-attribute logic is duplicated across
the image, placeholder, and title links. Add a small helper near the product
rendering code, such as relFor accepting the existing Product type, and replace
all three inline ternaries with calls to that helper while preserving the
current rel values.

In `@scripts/analyze-downs-style-voice.py`:
- Around line 101-149: Update summarize to reuse tokenized words for each post
instead of calling words(post["body"]) repeatedly when building all_words and
post_lengths. Cache each post’s token list within the function, then derive both
aggregates from that cache while preserving the existing summary results.
- Around line 271-272: Update the body guard near the output validation to
inspect nested structures recursively, including the metrics blocks, rather than
checking only top-level keys; ensure it raises AssertionError whenever any
nested key exposes “body”.

In `@scripts/audit-downs-style-archive.py`:
- Around line 130-153: Update discover_collection_posts so pagination URLs
cannot receive duplicate format parameters: either verify nextPageUrl never
includes format=json and preserve that invariant, or normalize the parsed
URL/query by removing any existing format parameter before appending
format=json. Keep pagination traversal and visited_pages cycle detection
unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f7ee034b-545d-48df-8288-50bd15727edd

📥 Commits

Reviewing files that changed from the base of the PR and between d59af05 and eed96f6.

⛔ Files ignored due to path filters (6)
  • docs/continuations/charles/downs-style-post-ledger.csv is excluded by !**/*.csv
  • docs/continuations/charles/rose-toners-share/build/sites-vite-plugin.ts is excluded by !**/build/**
  • docs/continuations/charles/rose-toners-share/package-lock.json is excluded by !**/package-lock.json
  • docs/continuations/charles/rose-toners-share/public/og-archive.png is excluded by !**/*.png
  • docs/continuations/charles/rose-toners-share/public/og-comparison.png is excluded by !**/*.png
  • docs/continuations/charles/rose-toners-share/public/og.png is excluded by !**/*.png
📒 Files selected for processing (37)
  • docs/continuations/charles-downs-style-archive/workstream.json
  • docs/continuations/charles/downs-style-archive-audit.md
  • docs/continuations/charles/downs-style-channel-profile.yaml
  • docs/continuations/charles/downs-style-natural-center.yaml
  • docs/continuations/charles/downs-style-voice-evidence.md
  • docs/continuations/charles/downs-style-voice-metrics.json
  • docs/continuations/charles/next-posts.md
  • docs/continuations/charles/rose-toners-share/.gitignore
  • docs/continuations/charles/rose-toners-share/.openai/hosting.json
  • docs/continuations/charles/rose-toners-share/README.md
  • docs/continuations/charles/rose-toners-share/app/archive/archive-explorer.tsx
  • docs/continuations/charles/rose-toners-share/app/archive/page.tsx
  • docs/continuations/charles/rose-toners-share/app/chatgpt-auth.ts
  • docs/continuations/charles/rose-toners-share/app/compare/page.tsx
  • docs/continuations/charles/rose-toners-share/app/globals.css
  • docs/continuations/charles/rose-toners-share/app/layout.tsx
  • docs/continuations/charles/rose-toners-share/app/page.tsx
  • docs/continuations/charles/rose-toners-share/app/site-header.tsx
  • docs/continuations/charles/rose-toners-share/app/voice/page.tsx
  • docs/continuations/charles/rose-toners-share/data/posts.json
  • docs/continuations/charles/rose-toners-share/db/index.ts
  • docs/continuations/charles/rose-toners-share/db/schema.ts
  • docs/continuations/charles/rose-toners-share/drizzle.config.ts
  • docs/continuations/charles/rose-toners-share/drizzle/meta/_journal.json
  • docs/continuations/charles/rose-toners-share/eslint.config.mjs
  • docs/continuations/charles/rose-toners-share/examples/d1/app/api/notes/route.ts
  • docs/continuations/charles/rose-toners-share/examples/d1/db/schema.ts
  • docs/continuations/charles/rose-toners-share/next.config.ts
  • docs/continuations/charles/rose-toners-share/package.json
  • docs/continuations/charles/rose-toners-share/postcss.config.mjs
  • docs/continuations/charles/rose-toners-share/tests/rendered-html.test.mjs
  • docs/continuations/charles/rose-toners-share/tsconfig.json
  • docs/continuations/charles/rose-toners-share/vite.config.ts
  • docs/continuations/charles/rose-toners-share/worker/index.ts
  • scripts/analyze-downs-style-voice.py
  • scripts/audit-downs-style-archive.py
  • scripts/verify-downs-style-archive.py

Comment on lines +69 to +80
## Reproduction

```sh
python3 scripts/audit-downs-style-archive.py \
--output docs/continuations/charles/downs-style-post-ledger.csv \
--corpus-json /tmp/downs-style-corpus.json

python3 scripts/analyze-downs-style-voice.py \
--corpus-json /tmp/downs-style-corpus.json \
--output docs/continuations/charles/downs-style-voice-metrics.json \
--baseline-cutoff 2024-12-31
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The reproduction block does not regenerate data/posts.json.

scripts/verify-downs-style-archive.py checks docs/continuations/charles/rose-toners-share/data/posts.json and requires its URL set to equal the ledger URL set. The commands here omit --site-json, so a reader who follows the documented reproduction produces a ledger and metrics but a stale site file. Add the flag.

📝 Proposed fix
 python3 scripts/audit-downs-style-archive.py \
   --output docs/continuations/charles/downs-style-post-ledger.csv \
-  --corpus-json /tmp/downs-style-corpus.json
+  --corpus-json /tmp/downs-style-corpus.json \
+  --site-json docs/continuations/charles/rose-toners-share/data/posts.json
 
 python3 scripts/analyze-downs-style-voice.py \
   --corpus-json /tmp/downs-style-corpus.json \
   --output docs/continuations/charles/downs-style-voice-metrics.json \
   --baseline-cutoff 2024-12-31
+
+python3 scripts/verify-downs-style-archive.py
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## Reproduction
```sh
python3 scripts/audit-downs-style-archive.py \
--output docs/continuations/charles/downs-style-post-ledger.csv \
--corpus-json /tmp/downs-style-corpus.json
python3 scripts/analyze-downs-style-voice.py \
--corpus-json /tmp/downs-style-corpus.json \
--output docs/continuations/charles/downs-style-voice-metrics.json \
--baseline-cutoff 2024-12-31
```
## Reproduction
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/continuations/charles/downs-style-archive-audit.md` around lines 69 -
80, Update the Reproduction command for scripts/audit-downs-style-archive.py to
include the --site-json option targeting the checked site data file used by
scripts/verify-downs-style-archive.py,
docs/continuations/charles/rose-toners-share/data/posts.json. Keep the existing
output and corpus arguments unchanged so the documented workflow regenerates
both the ledger and site data.

Comment on lines +20 to +28
const requestHeaders = await headers();
const host =
requestHeaders.get("x-forwarded-host") ??
requestHeaders.get("host") ??
"localhost:3000";
const protocol =
requestHeaders.get("x-forwarded-proto") ??
(host.startsWith("localhost") ? "http" : "https");
const origin = `${protocol}://${host}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file context =="
sed -n '1,120p' docs/continuations/charles/rose-toners-share/app/layout.tsx 2>/dev/null || true

echo
echo "== related layout.tsx files =="
fd -a 'layout\.tsx$' docs/continuations/charles/rose-toners-share | sed 's#^\./##'

echo
echo "== forwarded header usage in repo =="
rg -n "x-forwarded-(host|proto)|generateMetadata|metadataBase|OpenGraph|images" -S docs/continuations/charles/rose-toners-share .gitignore README.md 2>/dev/null || true

echo
echo "== possible deployment/proxy config =="
git ls-files | rg -i '(^|/)(wrangler\.toml|netlify|vercel|railway|caddy|nginx|apache|edge|middleware|cache|robots|sitemap|next\.)' | sed -n '1,200p'

Repository: organvm/limen

Length of output: 4093


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== docs/continuations/charles/rose-toners-share/next.config.ts =="
sed -n '1,220p' docs/continuations/charles/rose-toners-share/next.config.ts

echo
echo "== organs media NEXT.md =="
sed -n '1,220p' organs/media/NEXT.md

echo
echo "== web app next.config.js =="
sed -n '1,220p' web/app/next.config.js

echo
echo "== wrangler.toml =="
sed -n '1,220p' web/worker/wrangler.toml

echo
echo "== docs/continuations/charles/rose-toners-share files mentioning cache headers =="
rg -n "cache-control|surrogate|stale-while-revalidate|isCacheable|incremental-static|unstable_noStore|no-cache|private" docs/continuations/charles/rose-toners-share 2>/dev/null || true

echo
echo "== node URL behavior for forwarded-origin edge cases =="
node - <<'JS'
const cases = [
  "https://evil.example:65537",
  "http://evil.example:65536",
  "http://evil.example:aaa",
  "https://evil.example/path?foo=bar",
  "https://evil.example#fragment",
];
for (const raw of cases) {
  try {
    const u = new URL(raw);
    console.log(JSON.stringify({ input: raw, ok: true, href: u.href, host: u.hostname }))
  } catch (e) {
    console.log(JSON.stringify({ input: raw, ok: false, error: e.name + ": " + e.message }))
  }
}
JS

Repository: organvm/limen

Length of output: 12814


Use a trusted origin for generated metadata.

generateMetadata() trusts x-forwarded-host, xForwardedProto, and host to build metadataBase and social-images. A spoofed forwarded host can change canonical metadata, while an invalid host/path/protocol value can throw at new URL(origin). Define a canonical origin from trusted deployment config, and allowlist preview origins before URL construction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/continuations/charles/rose-toners-share/app/layout.tsx` around lines 20
- 28, Update the origin construction in generateMetadata to use the canonical
origin from trusted deployment configuration instead of directly trusting
x-forwarded-host, x-forwarded-proto, or host. Before creating metadataBase or
social-image URLs, validate any preview origin against an explicit allowlist and
reject or fall back on invalid host, path, or protocol values so new URL(origin)
always receives a safe canonical origin.

Comment on lines +29 to +44
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);

if (url.pathname === "/_vinext/image") {
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
return handleImageOptimization(request, {
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
transformImage: async (body, { width, format, quality }) => {
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
return result.response();
},
}, allowedWidths);
}

return handler.fetch(request, env, ctx);
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What is the type signature of the default export from vinext/server/app-router-entry in vinext 0.0.50, and does its fetch method accept env and ctx parameters?

💡 Result:

In vinext 0.0.50, the default export from vinext/server/app-router-entry is an object containing a fetch method [1][2]. The type signature for this fetch method is: fetch(request: Request): Promise While common patterns for wrapping this entry point in a custom Cloudflare Worker often define signatures that include env and ctx parameters (e.g., fetch(request: Request, env: any, ctx: ExecutionContext)) [2], the default export's fetch method itself is designed to accept only the Request object as its primary argument [1][3]. When integrating, developers typically pass these extra parameters to other services or handle them before calling handler.fetch(request) [4][5].

Citations:


Pass only request to handler.fetch.

vinext/server/app-router-entry declares handler.fetch(request) for vinext 0.0.50, so handler.fetch(request, env, ctx) does not match the declared signature. This can trigger a TypeScript argument-count error during build/checks; call handler.fetch(request) instead and keep env/ctx available through other bindings or the worker wrapper.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/continuations/charles/rose-toners-share/worker/index.ts` around lines 29
- 44, Update the fallback call in the fetch method to invoke handler.fetch with
only request, matching the declared vinext handler signature. Keep the existing
image-optimization branch and worker-level env/ctx handling unchanged.

Comment on lines +210 to +219
required = {"published_date", "category", "title", "url", "body"}
for index, post in enumerate(posts):
missing = required - post.keys()
if missing:
raise SystemExit(f"post {index} is missing fields: {sorted(missing)}")

baseline = [
post
for post in posts
if date.fromisoformat(post["published_date"][:10]) <= args.baseline_cutoff

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Validate published_date before you parse it.

Line 219 calls date.fromisoformat on the first ten characters. The auditor writes an empty published_date when a post fails to parse and no fallback date is available. An empty or malformed value raises a raw ValueError here. The surrounding code reports schema problems with SystemExit and a readable message. Apply the same handling.

🛡️ Proposed fix
     for index, post in enumerate(posts):
         missing = required - post.keys()
         if missing:
             raise SystemExit(f"post {index} is missing fields: {sorted(missing)}")
+        try:
+            date.fromisoformat(post["published_date"][:10])
+        except ValueError:
+            raise SystemExit(
+                f"post {index} has an unparsable published_date: {post['published_date']!r}"
+            )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
required = {"published_date", "category", "title", "url", "body"}
for index, post in enumerate(posts):
missing = required - post.keys()
if missing:
raise SystemExit(f"post {index} is missing fields: {sorted(missing)}")
baseline = [
post
for post in posts
if date.fromisoformat(post["published_date"][:10]) <= args.baseline_cutoff
required = {"published_date", "category", "title", "url", "body"}
for index, post in enumerate(posts):
missing = required - post.keys()
if missing:
raise SystemExit(f"post {index} is missing fields: {sorted(missing)}")
try:
date.fromisoformat(post["published_date"][:10])
except ValueError:
raise SystemExit(
f"post {index} has an unparsable published_date: {post['published_date']!r}"
)
baseline = [
post
for post in posts
if date.fromisoformat(post["published_date"][:10]) <= args.baseline_cutoff
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/analyze-downs-style-voice.py` around lines 210 - 219, Validate each
post’s published_date in the validation loop before the baseline list
comprehension parses it. Reject empty or malformed values by raising SystemExit
with a readable post-specific schema error, while preserving the existing
required-field validation and baseline filtering for valid dates.

Source: Path instructions

Comment on lines +216 to +221
baseline = [
post
for post in posts
if date.fromisoformat(post["published_date"][:10]) <= args.baseline_cutoff
]
excluded = [post for post in posts if post not in baseline]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Derive excluded from the cutoff, not from list membership.

Line 221 tests post not in baseline. Python compares each candidate dict against every baseline dict, including the full body string. That is O(n²) over the whole corpus text. It also misclassifies any two posts with identical field values: the duplicate is treated as included and drops out of excluded_urls. Partition once on the cutoff.

♻️ Proposed fix
-    baseline = [
-        post
-        for post in posts
-        if date.fromisoformat(post["published_date"][:10]) <= args.baseline_cutoff
-    ]
-    excluded = [post for post in posts if post not in baseline]
+    baseline: list[dict[str, Any]] = []
+    excluded: list[dict[str, Any]] = []
+    for post in posts:
+        published = date.fromisoformat(post["published_date"][:10])
+        (baseline if published <= args.baseline_cutoff else excluded).append(post)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
baseline = [
post
for post in posts
if date.fromisoformat(post["published_date"][:10]) <= args.baseline_cutoff
]
excluded = [post for post in posts if post not in baseline]
baseline: list[dict[str, Any]] = []
excluded: list[dict[str, Any]] = []
for post in posts:
published = date.fromisoformat(post["published_date"][:10])
(baseline if published <= args.baseline_cutoff else excluded).append(post)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/analyze-downs-style-voice.py` around lines 216 - 221, Update the
baseline/excluded partition in the analysis flow to classify each post once
using the same published-date cutoff, rather than deriving excluded via post
dictionary membership. Preserve the existing cutoff boundary and ensure
duplicate posts are independently assigned to baseline or excluded, avoiding
full-dictionary comparisons.

from typing import Iterable
from urllib.parse import urljoin, urlparse

from bs4 import BeautifulSoup

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Fleet scripts raise raw tracebacks on expected failure conditions. All three scripts handle their own domain errors well but let environment and input errors escape as tracebacks. The shared root cause is that a missing dependency, a missing artifact, and a malformed field are treated as programmer errors rather than as reportable, fail-open exits.

  • scripts/audit-downs-style-archive.py#L26-L26: wrap the bs4 import in try/except ImportError and exit with an install instruction; apply the same treatment to the unguarded sitemap fetch at Line 326.
  • scripts/verify-downs-style-archive.py#L69-L72: check that --ledger, --metrics, and --site-json exist through require before you open them.
  • scripts/analyze-downs-style-voice.py#L210-L219: validate published_date with date.fromisoformat inside the existing field-check loop and raise SystemExit with the offending index and value.

As per path instructions: "Fleet scripts must be fail-open, idempotent, and offline-safe; flag any that raise on a missing dep".

📍 Affects 3 files
  • scripts/audit-downs-style-archive.py#L26-L26 (this comment)
  • scripts/verify-downs-style-archive.py#L69-L72
  • scripts/analyze-downs-style-voice.py#L210-L219
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/audit-downs-style-archive.py` at line 26, Update
scripts/audit-downs-style-archive.py lines 26-26 to catch a missing bs4
dependency and exit with an installation instruction, and guard the sitemap
fetch at line 326 against its expected environment/input failure. In
scripts/verify-downs-style-archive.py lines 69-72, require --ledger, --metrics,
and --site-json before opening them. In scripts/analyze-downs-style-voice.py
lines 210-219, validate published_date with date.fromisoformat inside the
existing field-check loop and raise SystemExit including the offending index and
value.

Source: Path instructions

Comment on lines +76 to +91
def fetch(url: str, *, timeout: float, attempts: int = 3) -> tuple[int, str, str]:
"""Return status, final URL, and decoded response text with bounded retries."""
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
last_error: Exception | None = None
for attempt in range(attempts):
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
charset = response.headers.get_content_charset() or "utf-8"
body = response.read().decode(charset, errors="replace")
return response.status, response.geturl(), body
except (urllib.error.URLError, TimeoutError) as exc:
last_error = exc
if attempt + 1 < attempts:
time.sleep(0.5 * (attempt + 1))
assert last_error is not None
raise last_error

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Replace the bare assert with an explicit raise.

python -O removes the assert at Line 90. The loop then falls through and the function returns None. Callers unpack a 3-tuple, so this produces a TypeError instead of the original network error.

🛡️ Proposed fix
         except (urllib.error.URLError, TimeoutError) as exc:
             last_error = exc
             if attempt + 1 < attempts:
                 time.sleep(0.5 * (attempt + 1))
-    assert last_error is not None
-    raise last_error
+    raise last_error if last_error else RuntimeError(f"fetch failed for {url}")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def fetch(url: str, *, timeout: float, attempts: int = 3) -> tuple[int, str, str]:
"""Return status, final URL, and decoded response text with bounded retries."""
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
last_error: Exception | None = None
for attempt in range(attempts):
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
charset = response.headers.get_content_charset() or "utf-8"
body = response.read().decode(charset, errors="replace")
return response.status, response.geturl(), body
except (urllib.error.URLError, TimeoutError) as exc:
last_error = exc
if attempt + 1 < attempts:
time.sleep(0.5 * (attempt + 1))
assert last_error is not None
raise last_error
def fetch(url: str, *, timeout: float, attempts: int = 3) -> tuple[int, str, str]:
"""Return status, final URL, and decoded response text with bounded retries."""
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
last_error: Exception | None = None
for attempt in range(attempts):
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
charset = response.headers.get_content_charset() or "utf-8"
body = response.read().decode(charset, errors="replace")
return response.status, response.geturl(), body
except (urllib.error.URLError, TimeoutError) as exc:
last_error = exc
if attempt + 1 < attempts:
time.sleep(0.5 * (attempt + 1))
raise last_error if last_error else RuntimeError(f"fetch failed for {url}")
🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 81-81: Request-controlled URL passed to urlopen; validate against an allowlist to prevent SSRF.
Context: urllib.request.urlopen(request, timeout=timeout)
Note: [CWE-918] Server-Side Request Forgery (SSRF).

(urlopen-unsanitized-data)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/audit-downs-style-archive.py` around lines 76 - 91, Replace the
`assert last_error is not None` in `fetch` with an explicit raise that
propagates `last_error` after all retry attempts are exhausted, ensuring the
function never falls through and preserves the original network error even under
`python -O`.

Comment on lines +14 to +35
EXPECTED_CATEGORIES = {
"Candles": 18,
"Eat": 12,
"Gift Inspo": 7,
"Interior Design": 8,
"Look Book": 36,
"Masks": 51,
"Skincare": 112,
"Travel": 7,
"Workouts/Diet": 7,
}
EXPECTED_YEARS = {
"2017": 16,
"2018": 78,
"2019": 22,
"2020": 54,
"2021": 22,
"2022": 5,
"2023": 36,
"2024": 24,
"2026": 1,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Confirm the archive verifier is wired into the gate registry and the scoped push gate.
set -uo pipefail

fd -H -t f 'gates.yaml' | while IFS= read -r f; do
  echo "== $f"
  rg -n -C 5 'downs-style|charles|continuations' "$f"
done

fd -H -t f 'verify-scoped.sh|verify.py' scripts | while IFS= read -r f; do
  echo "== $f"
  rg -n -C 5 'gates.yaml|downs-style|docs/continuations' "$f"
done

Repository: organvm/limen

Length of output: 2663


🏁 Script executed:

#!/bin/bash
set -uo pipefail

echo "== gates candidates =="
git ls-files | rg '(^|/)gates\.ya?ml$|scripts/(verify|verify-scoped|check-gates)\.(py|sh)$' | sed -n '1,120p'

echo
echo "== gates references around archive/continuations/files =="
python3 - <<'PY'
from pathlib import Path
p=Path('institutio/governance/gates.yaml')
print('exists', p.exists())
if p.exists():
    text=p.read_text()
    for i,line in enumerate(text.splitlines(),1):
        if any(s in line for s in ['verify-downs-style-archive.py','docs/continuations/charles','downs-style','continuations']):
            print(f'{i}: {line}')
PY

echo
echo "== scripts verify.py relevant excerpts =="
python3 - <<'PY'
from pathlib import Path
p=Path('scripts/verify.py')
for start,end in [(1,180),(180,360),(360,540),(540,760),(760,900)]:
    if p.exists():
        lines=p.read_text().splitlines()
        print(f'--- scripts/verify.py {start}-{min(end,len(lines))} ---')
        for n in range(max(1,start), min(end,len(lines))+1):
            line=lines[n-1]
            if any(s in line for s in ['gates', 'changed', 'paths', 'registry']):
                print(f'{n}: {line}')
PY

echo
echo "== deterministic gate registry parse probe =="
python3 - <<'PY'
from pathlib import Path
import re
from pathlib import Path

registry=Path('institutio/governance/gates.yaml')
text=registry.read_text()
lines=text.splitlines()

# simple parser matching top-level "gate-name:" entries and collecting exact command/path values
name=None
current={'paths': []}
results=[]
for i,l in enumerate(lines,1):
    m=re.match(r'^([A-Za-z0-9_-]+[A-Za-z0-9._-]*):\s*$', l)
    if m:
        if current and current.get('name') and any(s in current['command']+' '+''.join(current['paths']) for s in ['verify-downs-style-archive.py','docs/continuations/charles','continuations']):
            results.append((i,name,current))
        name=m.group(1)
        current={'name': name, 'command': None, 'paths': []}
    elif name:
        if l.lstrip().startswith('command:'):
            val=l.split(':',1)[1].strip().strip('"\'')
            current['command']=val
        elif l.lstrip().startswith('paths:'):
            parts=[]
            for raw in re.split(r',(?!\s*$)', l.split(':',1)[1] if ':' in l else ''):
                raw=raw.strip().strip('"\'')
                if raw:
                    parts.append(raw)
            current['paths']=parts
        elif l.lstrip().startswith('- '):
            parts=[]
            for raw in re.split(r',(?!\s*$)', l.strip()[2:]):
                raw=raw.strip().strip('"\'')
                if raw:
                    parts.append(raw)
            current['paths'].extend(parts)
if current and current.get('name'):
    results.append((len(lines),name,current))
seen=set()
for m, name, current in results:
    print(f"gate={name} command_line={m}")
    print(" command:", current.get('command'))
    print(" paths:", current.get('paths'))
    print(" has_script_ref:", 'scripts/verify-downs-style-archive.py' in str(current))
print("total_gate_refs_found:", len(results))
PY

Repository: organvm/limen

Length of output: 7184


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== gates registry gate entries referencing archive verifier/continuations/charles =="
python3 - <<'PY'
from pathlib import Path
import yaml

registry = yaml.safe_load(Path('institutio/governance/gates.yaml').read_text())
targets = {'scripts/verify-downs-style-archive.py', 'docs/continuations/charles'}
for gate_id, gate in registry.get('gates', {}).items():
    paths = gate.get('paths') or []
    command = gate.get('command') or ''
    if any('verify-downs-style-archive.py' in str(p or '') or 'verify-downs-style-archive.py' in command
           for p in paths) or any('docs/continuations/charles' in str(p or '') for p in paths):
        print(gate_id)
        print("command:", command)
        print("paths:", paths)
        print("count_paths:", len(paths))
PY

echo
echo "== all gate entry summary for archive verifier/continuation charles targets =="
python3 - <<'PY'
from pathlib import_Path
import yaml, re
text = Path('institutio/governance/gates.yaml').read_text()
m = re.compile(r'^([A-Za-z0-9_-]+[A-Za-z0-9._-]*):')
current = None
for i, line in enumerate(text.splitlines(), 1):
    m_line = m.match(line)
    if m_line:
        current = {'gate_id': m_line.group(1), 'line': i, 'command': None, 'paths': []}
    if current:
        if line.lstrip().startswith('command:'):
            current['command'] = line.split(':', 1)[1].strip().strip('"\'')
        pathpart = ''
        if line.lstrip().startswith('paths:'):
            pathpart += line.split(':', 1)[1]
        for j in range(i, min(i+80, len(text.splitlines())) + 1):
            nextline = text.splitlines()[j-1] if j <= len(text.splitlines()) else ''
            if j != i and nextline and not nextline.startswith(' ') and not nextline.startswith('\t'):
                break
            if nextline.lstrip().startswith('- ') or nextline.lstrip().startswith('? '):
                pathpart += ' ' + nextline.strip()
        current['raw_paths'] = current.get('raw_paths', '') + pathpart + ' '
hits = []
for k, v in registry.get('gates', {}).items():
    command = v.get('command') or ''
    paths = v.get('paths') or []
    s = command + ' ' + ' '.join(map(str, paths))
    if 'docs/continuations/charles' in s or 'scripts/verify-downs-style-archive.py' in s:
        hits.append(k)
print(hits)
PY

echo
echo "== changed-set simulation for target dir =="
python3 - <<'PY'
from pathlib import Path
import re, yaml

registry = yaml.safe_load(Path('institutio/governance/gates.yaml').read_text())
changed = [
    'docs/continuations/charles/intent.md',
    'scripts/verify-downs-style-archive.py',
]
def glob_to_regex(glob):
    import glob
    return re.compile('^' + glob.replace('?', '.').replace('*', '.*') + '$')

def select(registry, changed_paths):
    for gate_id, gate in registry.get('gates', {}).items():
        paths = gate.get('paths') or []
        if not paths:
            continue
        regexes = [glob_to_regex(g) for g in paths]
        if any(r.match(p) for p in changed_paths for r in regexes):
            yield gate_id, paths

print('selected_gates:')
for gate_id, paths in select(registry, changed):
    print(gate_id, paths)
PY

Repository: organvm/limen

Length of output: 353


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate gates.yaml exact context =="
python3 - <<'PY'
from pathlib import Path
p = Path('institutio/governance/gates.yaml')
lines = p.read_text().splitlines()
for i, line in enumerate(lines, 1):
    if 'verify-downs-style-archive.py' in line or 'docs/continuations/charles' in line or '*continuations*' in line:
        start=max(1,i-8); end=min(len(lines),i+10)
        print(f'--- lines {start}-{end} ---')
        for n in range(start,end+1):
            print(f'{n}: {lines[n-1]}')
PY

echo
echo "== simple text search for verifier in registry =="
rg -n 'verify-downs-style-archive|docs/continuations/charles|continuations/\*/intent' institutio/governance/gates.yaml scripts/verify.py || true

Repository: organvm/limen

Length of output: 507


Register scripts/verify-downs-style-archive.py as a gate.

scripts/verify-scoped.sh is a wrapper around scripts/verify.py, which selects gates only from institutio/governance/gates.yaml; this archive verifier is not in that registry and no docs/continuations/charles/** path is mapped to it. Add an archive-validation gate so changed continuation paths run the archive count contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/verify-downs-style-archive.py` around lines 14 - 35, Register
scripts/verify-downs-style-archive.py as an archive-validation gate in
institutio/governance/gates.yaml, and map docs/continuations/charles/** to that
gate so scripts/verify-scoped.sh invokes it for changed continuation paths.
Preserve the existing archive count contract defined by EXPECTED_CATEGORIES and
EXPECTED_YEARS.

Source: Coding guidelines

Comment on lines +69 to +72
def main() -> int:
args = parse_args()
with args.ledger.open(newline="", encoding="utf-8") as handle:
records = list(csv.DictReader(handle))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Report a missing artifact as a verification failure, not a traceback.

Line 71 opens the ledger without a guard. If the path is absent, the script exits with a FileNotFoundError traceback. The rest of the script uses require to produce a single readable failure line. Apply the same treatment to file access.

🛡️ Proposed fix
 def main() -> int:
     args = parse_args()
+    for path in (args.ledger, args.metrics, args.site_json):
+        require(path.is_file(), f"missing artifact: {path}")
     with args.ledger.open(newline="", encoding="utf-8") as handle:
         records = list(csv.DictReader(handle))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def main() -> int:
args = parse_args()
with args.ledger.open(newline="", encoding="utf-8") as handle:
records = list(csv.DictReader(handle))
def main() -> int:
args = parse_args()
for path in (args.ledger, args.metrics, args.site_json):
require(path.is_file(), f"missing artifact: {path}")
with args.ledger.open(newline="", encoding="utf-8") as handle:
records = list(csv.DictReader(handle))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/verify-downs-style-archive.py` around lines 69 - 72, Update main
around args.ledger.open to catch a missing-file error and route it through the
existing require-based validation path, producing one readable verification
failure line instead of an uncaught FileNotFoundError traceback. Preserve the
current CSV parsing behavior when the ledger exists.

Source: Path instructions

Comment on lines +101 to +102
require(records[0]["published_date"][:10] == "2017-12-04", "earliest date changed")
require(records[-1]["published_date"][:10] == "2026-08-02", "latest date changed")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Sort the rows before you assert the first and last dates.

Lines 101 and 102 read records[0] and records[-1]. That assumes the CSV keeps the auditor's ascending sort. A re-exported or manually edited ledger with the same 258 rows then fails the predicate for a reason that has nothing to do with archive integrity. Derive the bounds from the values.

♻️ Proposed fix
-    require(records[0]["published_date"][:10] == "2017-12-04", "earliest date changed")
-    require(records[-1]["published_date"][:10] == "2026-08-02", "latest date changed")
+    dates = sorted(record["published_date"][:10] for record in records)
+    require(dates[0] == "2017-12-04", "earliest date changed")
+    require(dates[-1] == "2026-08-02", "latest date changed")

As per coding guidelines: "predicates must be runnable, self-verifying, and idempotent".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
require(records[0]["published_date"][:10] == "2017-12-04", "earliest date changed")
require(records[-1]["published_date"][:10] == "2026-08-02", "latest date changed")
dates = sorted(record["published_date"][:10] for record in records)
require(dates[0] == "2017-12-04", "earliest date changed")
require(dates[-1] == "2026-08-02", "latest date changed")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/verify-downs-style-archive.py` around lines 101 - 102, Update the
date-bound assertions in the archive verification logic to derive the earliest
and latest published dates from all records rather than relying on records[0]
and records[-1] ordering. Preserve the existing expected dates and require
messages while making the predicates independent of CSV row order.

Source: Coding guidelines

@4444J99 4444J99 added the lifecycle:active-human Owned by a live human-protected session; autonomous merge, close, retune, or reap is forbidden label Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lifecycle:active-human Owned by a live human-protected session; autonomous merge, close, retune, or reap is forbidden

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants