docs(plans): record the JavaScript migration, parity, and review

Adds the migration plan and its phase files, the parity report, and the code
review.

Annotates the two records this work supersedes rather than rewriting them: the
2026-09-18 language-choice research, whose first recommendation was to keep the
engine in Go and was overridden the same day, and the 2026-08-18 plan that
produced that engine. Both bodies are left as written, since erasing them would
destroy the reasoning a future reader needs.

Six success criteria stay unticked with the reason inline: they need a push, a
GitHub token, or a Codex run to verify.
This commit is contained in:
tiennm99 committed 2026-09-18 16:24:56 +07:00
1 parent 52bb21cce7
commit 783dd37251
10 files changed
+1556

No files matched your search

@@ -1,6 +1,7 @@
# Migrate Newsletter Scripts from Node.js to Go
**Status:** COMPLETED (2026-08-18) — all 3 phases done; parity report in `plans/reports/parity-260818-newsletter-go-migration-report.md`
**Superseded:** 2026-09-18 — the Go engine this plan delivered was ported back to JavaScript by `plans/260918-0647-migrate-newsletter-engine-to-javascript/`. Body left unchanged; it records the reasoning as it stood.
**Created:** 2026-08-18
**Scope:** Port the 8-file shared engine in `scripts/newsletter/` (~740 LoC JS, stdlib-only) to a single Go package with 6 subcommands, verify behavior parity, cut over all skill/doc call sites, delete the JS.
@@ -0,0 +1,216 @@
---
phase: 1
title: "Port the engine to JavaScript"
status: done
priority: P1
effort: "1.5d"
dependencies: []
---
# Phase 1: Port the engine to JavaScript
## Overview
Translate the 1168-LOC Go engine into Node ESM modules under
`scripts/newsletter/`, one JS file per Go file, replacing hand-rolled HTML/XML
regexes with cheerio. Go and JS coexist at the end of this phase — nothing is
deleted until phase 4.
## Requirements
**Functional**
- `node scripts/newsletter <cmd> [args]` dispatches all seven subcommands from
the repo root, with the same usage text and the same argument shapes.
- Output is byte-identical to the Go engine except where this phase names a
deliberate difference.
- Repo-relative paths (`content/post`) resolve from `process.cwd()`, preserving
the "invoked from the repo root" contract.
- `config/substack-publications.json` stays the editable source of truth, read at
runtime (no `go:embed` equivalent needed), with the same
`["blog.bytebytego.com"]` fallback on read or parse failure.
**Non-functional**
- ESLint clean under the existing `eslint.config.mjs` (`eqeqeq`, `prefer-const`,
`no-unused-vars` with `^_` escape).
- JSDoc type annotations on every exported function, per repo policy — types
without TypeScript.
- Exactly three runtime dependencies. No dev dependencies.
## Architecture
```
scripts/newsletter/
index.js subcommand dispatch, usage, printJson, dep-guard
url-utils.js cleanUrl, bareUrl, isSubstackImage, substackImageUuid,
fetchTextOk, checkAccessibility, collectMarkdown,
checkDuplicate, classifyType, boundary helpers
html-text.js stripTags, itemTitle, itemLink, extractCandidates,
captionForUuid, postTitleFromHtml [cheerio]
add-url.js add-url
detect-image-source.js detect-image-source
find-substack-post.js find-substack-post [cheerio]
find-newsletter-number.js find-newsletter-number
list-existing-tags.js list-existing-tags
fetch-via-defuddle.js fetch-via-defuddle [defuddle]
post-stats.js post-stats
config/substack-publications.json (unchanged)
```
Each command module exports a single `run*(args)` function; `index.js` owns
dispatch and the missing-dependency guard.
**Deviation, made during review:** the shared JSON printer moved out of
`index.js` into its own `json-out.js`. Keeping it in `index.js` meant every
JSON-emitting command imported the dispatcher, and `index.js` runs `main()` at
module scope — so importing a command module executed the CLI against the host
argv, and the pure functions this phase exports for testing could not be imported
at all. One extra module removes the cycle outright.
`printJson(v)` = `process.stdout.write(JSON.stringify(v, null, 2) + "\n")`.
This matches Go's `SetEscapeHTML(false)` + `SetIndent("", " ")` + `Encode`
exactly: JS does not HTML-escape, and `Encode` appends the newline.
## Related code files
- Create: `scripts/newsletter/index.js` and the nine modules above
- Modify: `package.json` (dependencies, `test` script)
- Modify: `package-lock.json` (via `npm install`)
- Unchanged: `scripts/newsletter/config/substack-publications.json`
- Untouched this phase: all `scripts/newsletter/*.go`
## The seven parity traps
Each is a place where the idiomatic JS differs from what the Go does. These are
the reason phase 2 exists; get them right here.
**T1 — `cleanUrl` must not re-encode the query.** `url_utils.go` splits
`u.RawQuery` on `&` and rejoins surviving pairs verbatim, with a comment
explaining that Go's `url.Values` would sort keys and that sorted output must not
leak into stored `clean_url` values. JS `URLSearchParams.delete()` preserves
order but *normalizes percent-encoding* (e.g. `%7E` → `~`, `+` → `%20`), which is
the same class of bug. Split `url.search.slice(1)` on `&` and rejoin the kept
pairs as raw strings. Filter rule: drop any key whose lowercase form starts with
`utm_`, or is in the exact-match set (`fbclid gclid msclkid mc_eid aid ref
ref_src ref_url source s ck_subscriber_id igshid yclid vero_id`).
**T2 — keep `hasBoundaryMatch` as an index loop.** Go hand-rolls it because RE2
has no lookahead. JS regex *does* have lookahead, so the tempting move is to
collapse it into one pattern. Do not: the byte-offset boundary checks
(`uuidBoundaryOk`, `urlBoundaryOk`, including the optional trailing-slash skip
and the `)]"'?#<>_&,` delimiter set) are what make a prefix URL not a false
duplicate. Port the loop literally. A regex rewrite is a separate, separately
verified change.
**T3 — `omitempty` fields must be absent, not empty.** Go omits `title`,
`author` (add-url) and `uuid`, `innerUrl` (detect-image-source) when empty.
Build those objects conditionally; never emit `"title": ""`.
**T4 — the deep-miss `cutoff` is `null`, not omitted.** `find-substack-post
--deep` on a total miss emits `{found, source, scanned, budget, cutoff}` where
`cutoff` is a `*string` — JSON `null` when no sitemap could be fetched. Emit
`null` explicitly.
**T5 — count runes, not UTF-16 units.** `extractCandidates` filters on
`utf8.RuneCountInString(text) < 6 || > 70`. JS `.length` counts UTF-16 code
units, so any non-BMP character (emoji in a TOC bullet) counts double and can
push a valid title past 70. Use `[...text].length`.
**T6 — `list-existing-tags` output is `%6d %s`.** Right-align the count in a
6-character field: `String(count).padStart(6) + " " + tag`. It is plain text, not
JSON, and the skill reads it.
**T7 — `extractInnerUrl` swallows decode errors.** Go's `url.PathUnescape`
returns an error on a malformed `%` sequence and the code falls back to the raw
substring. JS `decodeURIComponent` *throws*. Wrap in try/catch and return the
raw substring on failure.
Two more that are free in JS but worth confirming: `extractCandidates` must
marshal as `[]` not `null` when empty (natural in JS; Go needed an explicit
non-nil slice), and `URL` already serializes an empty path as `/` (Go had to add
that by hand).
## Implementation steps
1. `npm install cheerio defuddle linkedom` — confirm the lockfile records exactly
these three plus their transitive deps, and that `npm ci` works offline
afterward on this ARM64 box.
2. Add to `package.json`: `"test": "node --test scripts/newsletter/*.test.js"`.
Leave `lint` and `projects:refresh` alone.
3. Write `index.js`: usage text (updated to `node scripts/newsletter …`),
`switch` dispatch over `process.argv[2]`, `printJson`, and the R1 dependency
guard — wrap the dynamic import of each command module and translate
`ERR_MODULE_NOT_FOUND` into
`newsletter engine: dependencies missing — run 'npm ci' from the repo root`
on stderr with exit 1.
4. Port `url-utils.js`. No dependencies — pure stdlib (`node:fs`, `node:path`,
`URL`, global `fetch`). Apply T1 and T2. `fetchTextOk` and
`checkAccessibility` use `AbortSignal.timeout(ms)`; `checkAccessibility`
returns the literal string `"000"` on any network error.
5. Port `html-text.js` using cheerio. This is the phase's real work:
- `stripTags` → `cheerio.load(s).text()` then collapse whitespace and trim.
Note cheerio decodes entities, replacing Go's `html.UnescapeString`.
- `itemTitle` / `itemLink` → `$('title').first()` / `$('link').first()` under
`xmlMode: true`, which handles CDATA natively.
- `extractCandidates` → `$('li')` with the T5 rune filter, case-insensitive
dedupe, first-seen order preserved.
- `captionForUuid` → find the element whose serialized subtree contains the
uuid, walk up with `.closest('figure')`, read `figcaption`. This replaces
the `LastIndex("<figure")` / `Index("</figure>")` string surgery; expect
*better* results on nested figures and record any difference under R3.
- `postTitleFromHtml` → `meta[property="og:title"]` → `h1` → `title`, same
precedence.
6. Port the five straightforward commands: `add-url.js`, `detect-image-source.js`
(T7), `find-newsletter-number.js` (keep the per-year early exit: scan a whole
year's months and days, then break only if that year yielded a number),
`list-existing-tags.js` (T6), `post-stats.js`.
7. Port `find-substack-post.js`: `node:util`'s `parseArgs` for `--uuid` /
`--deep`; cheerio `xmlMode` for RSS `<item>` and sitemap `<url>/<loc>/<lastmod>`;
the 3-month cutoff; newest-first stable sort; the shared `deepFetchBudget = 40`
spanning all publications; T4 on the miss output. Read the publications JSON
at runtime with the documented fallback.
8. Port `fetch-via-defuddle.js` with the two-tier design from `plan.md`:
local `defuddle/node` first, then `https://defuddle.md/<url>`. Exit 2 on bad
args, 1 on both tiers failing, 0 with the body on stdout. Stderr names which
tier failed and why.
9. `npx eslint scripts/newsletter/` and fix. Add JSDoc to every exported
function.
10. Smoke every subcommand once by hand against a live URL before handing off to
phase 2.
## Success criteria
- [x] All seven subcommands run under `node scripts/newsletter <cmd>` without a
stack trace.
- [x] `npx eslint scripts/newsletter/` is clean.
- [x] Every exported function carries a JSDoc type annotation.
- [x] `package.json` lists exactly `cheerio`, `defuddle`, `linkedom` as new
dependencies; no new devDependencies.
- [x] Each of T1-T7 is addressed, with a code comment at the site explaining the
constraint (explain the invariant, not the trap number).
- [x] Deleting `node_modules` and running any subcommand prints the `npm ci`
message, not a module-resolution stack trace.
- [x] No `.go` file modified or deleted.
## Risk assessment
**Cheerio's whitespace and entity handling differs from `html.UnescapeString` +
regex.** `stripTags` is used for captions and candidate titles that land verbatim
in published posts.
*Signal:* phase-2 fixture mismatches concentrated in `extractCandidates` /
`captionForUuid`.
*Response:* adjust the cheerio text pipeline to match Go on the fixtures. If a
difference is cheerio being correct on malformed markup, accept and record under
R3 — do not contort the parser back into regex behavior.
**`defuddle/node` may not work on headless ARM64 Linux.** linkedom is pure JS so
it should, but this is unverified.
*Signal:* step 1 or step 8 fails on import or on a real extraction.
*Response:* ship `fetch-via-defuddle` with the `defuddle.md` proxy tier only,
drop `defuddle` and `linkedom` from `package.json`, and note the reduction — the
command keeps exactly its current Go behavior, so nothing regresses.
**`parseArgs` is stricter than Go's `flag`.** Go's package accepts `-uuid` as
well as `--uuid`; `parseArgs` accepts only `--uuid`.
*Signal:* a skill or a user invoking the single-dash form.
*Response:* accept the narrowing — every documented call site uses `--uuid` —
and state it in the phase-2 report as a deliberate CLI difference.
@@ -0,0 +1,164 @@
---
phase: 2
title: "Golden-output parity suite"
status: done
priority: P1
effort: "0.5d"
dependencies: [1]
---
# Phase 2: Golden-output parity suite
## Overview
Capture the Go engine's stdout for a fixed case matrix, commit those outputs as
fixtures, and assert the JS engine reproduces them using `node:test`. This is
both the migration's proof of correctness and the regression net the engine has
never had. It must run while the Go engine still exists.
## Requirements
**Functional**
- Every subcommand covered; every parity trap T1-T7 from phase 1 covered by at
least one case that would fail if the trap were mishandled.
- Fixtures are generated from the Go engine, never hand-written — a hand-written
fixture proves nothing about parity.
- Network-dependent cases are separated from deterministic ones so `npm test`
stays runnable offline.
**Non-functional**
- `node:test` + `node:assert` only. No test framework dependency.
- The suite runs in under 30 seconds offline.
## Architecture
```
scripts/newsletter/
parity.test.js deterministic cases, offline, runs in npm test
network.test.js live-network cases, skipped unless NEWSLETTER_NET=1
__fixtures__/
golden/<case-id>.json or .txt — captured Go stdout, committed
repo/content/post/ a small synthetic content tree; __fixtures__/repo
stands in as a repo root via the subprocess cwd
```
**Two test tiers.** Deterministic cases (URL cleaning, classification, dedup
against a fixture content tree, post-stats, tag listing, newsletter numbering,
HTML/RSS extraction from saved markup) run always. Live-network cases (YouTube
oEmbed, Substack RSS and sitemap, defuddle) run only with `NEWSLETTER_NET=1`,
because upstream content changes and a CI-red-on-someone-else's-outage test
teaches people to ignore red.
**Fixture content tree.** Dedup and the content-scanning commands must not read
the real `content/post` — it grows every day, so a fixture captured today fails
tomorrow. Build a small synthetic tree under `__fixtures__/repo/content/post/`
covering:
a post with a `Newsletter #N` heading, a post with a stored bare URL, a post with
a stored URL that is a strict *prefix* of the test URL (the T2 false-duplicate
case), a post with a Substack uuid in `<figure>` form, a post with a uuid in
markdown-autolink form, and a post with a frontmatter `tags:` array.
Tests point the engine at that tree by **setting the subprocess working
directory**, not by adding a config knob. Both engines already resolve
`content/post` relative to the process CWD — that is the documented repo-root
invocation contract — so `execFileSync(…, { cwd: fixtureRoot })` redirects every
content-scanning command with no change to either engine. The fixture tree
therefore lives at `__fixtures__/repo/content/post/…` so that `__fixtures__/repo`
can stand in as a repo root.
**Saved markup.** RSS and sitemap parsing cases use saved upstream responses in
`__fixtures__/`, captured once. This is what lets cheerio-vs-regex differences be
compared deterministically.
## Related code files
- Create: `scripts/newsletter/parity.test.js`
- Create: `scripts/newsletter/network.test.js`
- Create: `scripts/newsletter/__fixtures__/` (golden outputs, `repo/content/post` tree, saved markup)
- Create: `plans/reports/parity-260918-newsletter-js-migration-report.md`
- Modify: `scripts/newsletter/*.js` — only if a fixture mismatch reveals a port bug
- Modify: `.gitignore` if any capture artifact must be excluded (prefer not to)
## Case matrix
Capture from Go, assert against JS. Minimum coverage:
| Subcommand | Cases |
|---|---|
| `add-url` | article; article with `utm_*` + `fbclid` + `ref` params (T1); article with a param that must survive; `youtube.com/watch?v=`; `youtu.be/`; `youtube.com/shorts/`; a YouTube playlist URL (must *not* route youtube); substackcdn image URL; raw S3 substack image; `.pdf`; `.mp4`; `.avif` with query string; a URL that is a prefix of one already stored (T2, expect `duplicate:false`); a URL genuinely stored (expect `duplicate:true`); a non-YouTube URL (T3, expect no `title` key at all) |
| `detect-image-source` | substackcdn `/image/fetch/` wrapper; raw S3; non-Substack image (T3, no `uuid`/`innerUrl` keys); a wrapper with a malformed `%` sequence (T7) |
| `find-substack-post` | saved RSS containing the uuid; saved RSS missing it (`{found:false}`); `--deep` total miss against a saved sitemap (T4, `cutoff` present and `null` when the sitemap fetch fails); a saved RSS item whose TOC bullets include a non-BMP character near the 70-rune limit (T5) |
| `find-newsletter-number` | fixture tree with newsletters; empty tree (expect `1`); a tree where the newest year has no newsletter but an older year does (per-year early exit) |
| `list-existing-tags` | fixture tree — assert the exact `%6d %s` text including alignment (T6); tie-break ordering between two tags with equal counts |
| `post-stats` | a post with articles, all three Bonus subsections, and a `Newsletter #N` heading; a post with an empty Bonus; a post with no Bonus at all |
| `fetch-via-defuddle` | bad args → exit 2; unreachable host → exit 1; success → exit 0 with a body (network tier) |
## Implementation steps
1. Build `__fixtures__/repo/content/post/` by hand — six or so small `index.md`
files, each documented with the case it exists for.
2. Confirm both engines resolve content paths from CWD with no env override, so
the same fixture root drives the Go capture and the JS assertion.
3. Save the upstream RSS and sitemap responses used by the extraction cases.
4. Write a capture script in the scratchpad (not the repo) that runs every
deterministic case through `go run ./scripts/newsletter …` — with the
content-scanning cases run from `__fixtures__/repo` as CWD, using an absolute
path to the Go package — and writes
`__fixtures__/golden/<case-id>`. Record exit codes alongside stdout.
5. Review every captured fixture by eye before committing. A fixture that
encodes a Go bug becomes a requirement the JS must match forever — catch that
now, and if one is found, record it as a known-bug fixture with a comment
rather than silently "fixing" it in the port.
6. Write `parity.test.js`: for each case, invoke the JS engine as a subprocess
(`node:child_process` `execFileSync`) so the test exercises the real CLI
surface including exit codes, passing `cwd: __fixtures__/repo` for the
content-scanning cases, and compare stdout to the golden file.
7. Write `network.test.js` with the same shape, guarded by
`{ skip: process.env.NEWSLETTER_NET !== "1" }`.
8. Run `npm test`. Every mismatch is triaged into exactly one of: **port bug**
(fix the JS), **accepted improvement** (cheerio extracting better text; update
the fixture and record the reason), or **accepted CLI difference** (e.g.
`parseArgs` rejecting `-uuid`).
9. Run the network tier once manually with `NEWSLETTER_NET=1`.
10. Write `plans/reports/parity-260918-newsletter-js-migration-report.md`: the
matrix, pass/fail per case, and every accepted difference with its
justification. Mirror the structure of the existing
`parity-260818-newsletter-go-migration-report.md`.
## Success criteria
- [x] `npm test` passes offline from a clean `npm ci`.
- [x] `NEWSLETTER_NET=1 npm test` passes against live upstreams.
- [x] Every subcommand has at least one case; T1-T7 each have a case that fails
if the trap is mishandled (verify by temporarily reintroducing the naive
implementation and watching the test go red).
- [x] Every fixture was generated from the Go engine and eyeballed before commit.
- [x] Zero unexplained differences. Every accepted difference is in the report
with a reason.
- [x] Tests read only `__fixtures__/`, never the real `content/post` — verify by
running the suite with the real `content/post` temporarily renamed.
- [x] Neither engine gained a test-only config knob; redirection is CWD alone.
## Risk assessment
**Fixtures that encode Go bugs.** Capturing blindly turns any existing bug into a
permanent contract.
*Signal:* step 5's review finds output that is wrong on its own terms.
*Response:* keep the fixture as-is with a comment naming the wrong behavior, so
the port is provably faithful; fix it as a separate change after cutover.
**Live-network cases rot.** ByteByteGo's RSS window moves; a uuid that resolves
today will not in a month.
*Mitigation:* the network tier is opt-in and never gates `npm test`.
*Response:* if a network case goes stale, re-capture the saved markup and move
the assertion into the deterministic tier rather than chasing live content.
**The synthetic content tree misses a real-world markdown shape.** Dedup
boundary handling is the highest-consequence logic in the engine — a false
negative publishes a duplicate, a false positive silently drops a URL the user
asked for.
*Mitigation:* seed the fixture tree by grepping the real `content/post` for the
distinct shapes stored URLs actually take (bare, trailing slash, autolink
`<…>`, inside a markdown link, inside an image) and cover each.
*Response:* if a shape is found that neither engine handles, that is a
pre-existing bug — record it, do not fix it inside the migration.
@@ -0,0 +1,191 @@
---
phase: 3
title: "Skill restructure and call-site cutover"
status: done
priority: P2
effort: "0.5d"
dependencies: [2]
---
# Phase 3: Skill restructure and call-site cutover
## Overview
Switch every `go run ./scripts/newsletter` call site to `node scripts/newsletter`
and, in the same pass, fix the three structural problems in the skill layer:
duplicated Codex adapters, a shared reference file owned by one skill, and the
engine's command list spelled out in five different files.
## Requirements
**Functional**
- No `go run` anywhere outside `plans/` and `scripts/newsletter/*.go` (the Go
sources document their own invocation in doc comments and are deleted whole in
phase 4; `plans/` are historical records and stay as written).
- The seven engine commands are enumerated in exactly one file. Every other
mention is a link, except a skill inlining the one or two commands it actually
invokes.
- Shared skill prose is owned by no individual skill.
- `.agents/` carries no content that can drift from `.claude/`.
- Setup docs state the `npm ci` prerequisite (R1) once, where a new user will
hit it.
**Non-functional**
- Both Claude Code and Codex still discover and run all six skills afterward.
- Skill frontmatter `description` fields keep their trigger wording — they are
how the runtimes decide to invoke a skill, and rewording them changes routing
behavior.
## Architecture
**Shared prose moves to `docs/newsletter/`,** not into another skill's directory.
`docs/` already exists and is tool-neutral, which is the point: `.claude/` and
`.agents/` both reference it by repo-root-relative path, so nothing breaks under
symlinks and no skill owns another skill's documentation.
```
docs/newsletter/
engine-commands.md the ONE enumeration of the seven subcommands
post-mechanics.md moved from .claude/skills/mt-add-url/references/
```
**`.agents/skills/mt-*/SKILL.md` become symlinks** to their `.claude`
counterparts, eliminating the parallel frontmatter. Verify Codex follows symlinks
before committing; if it does not, keep the current thin pointers (they are
already only 10 lines) and settle for centralizing the command table.
**Renames — confirmed 2026-09-18.** The scheme is uniform
`mt-<verb>-<object>`; four of the six already conform, so only two move:
`mt-add-post` → `mt-add-article` (it adds an article *to* a post) and
`mt-webfetch` → `mt-fetch-url`. `mt-add-url`, `mt-add-video`, `mt-add-image` and
`mt-add-tags` keep their names — renaming a conforming skill is churn. Directory
name and frontmatter `name:` change together, and every cross-reference in the
other skills, `AGENTS.md`, and `README.md` follows.
<!-- Updated: Validation Session 1 - rename scheme confirmed, uniform mt-<verb>-<object>, two directories move -->
## Related code files
- Create: `docs/newsletter/engine-commands.md`
- Create: `docs/newsletter/post-mechanics.md` (git mv from
`.claude/skills/mt-add-url/references/newsletter-post-mechanics.md`)
- Delete: `.claude/skills/mt-add-url/references/` (now empty)
- Rename: `.claude/skills/mt-add-post/` → `mt-add-article/`;
`.claude/skills/mt-webfetch/` → `mt-fetch-url/`; same under `.agents/skills/`
- Modify: all six `.claude/skills/*/SKILL.md`
- Replace: all six `.agents/skills/*/SKILL.md` (symlinks)
- Modify: `AGENTS.md` (lines 42-51 and the routing section), `README.md` (line 42),
`docs/multi-tool-usage.md` (lines 3, 13, 26, 27, 29)
- Modify: `opencode.json` — it already allows `node *` and never allowed `go *`;
confirm no change is needed rather than assuming
## Call-site inventory
Verified by grep at plan time. Every `go run` occurrence outside `plans/`:
| File | Lines | Commands |
|---|---|---|
| `AGENTS.md` | 42, 45-51 | all seven + the prose describing the engine as Go |
| `.claude/skills/mt-add-url/SKILL.md` | 8, 23, 65 | `add-url`, `post-stats` |
| `.claude/skills/mt-add-url/references/newsletter-post-mechanics.md` | 4, 29, 91 | `find-newsletter-number`, `post-stats` |
| `.claude/skills/mt-add-post/SKILL.md` | 10, 16 | `add-url` |
| `.claude/skills/mt-add-video/SKILL.md` | 12, 22 | `add-url` |
| `.claude/skills/mt-add-image/SKILL.md` | 10, 24, 30, 40, 44, 71 | `detect-image-source`, `add-url`, `find-substack-post` (+`--deep`) |
| `.claude/skills/mt-add-tags/SKILL.md` | 53 | `list-existing-tags` |
| `.claude/skills/mt-webfetch/SKILL.md` | 31, 82 | `fetch-via-defuddle` |
| `README.md` | 42 | prose: "Go via `go run`" |
| `docs/multi-tool-usage.md` | 3, 13, 26, 27, 29 | prose + the shared-engine invocation |
## Implementation steps
1. Re-read the confirmed rename scheme above before touching anything — every
later step bakes the names in.
2. Write `docs/newsletter/engine-commands.md`: the seven commands, their
arguments, their output shapes, exit codes (including
`fetch-via-defuddle`'s now-meaningful 2-vs-1), the repo-root invocation
contract, and the `npm ci` prerequisite.
3. `git mv` the shared reference to `docs/newsletter/post-mechanics.md`. Update
its own line 4 (`All shared scripts live in scripts/newsletter/`) and its two
command blocks. Remove the now-empty `references/` directory.
4. Rename the two skill directories with `git mv`, under both `.claude/` and
`.agents/`, and update each `SKILL.md` frontmatter `name:`.
5. Update all six `.claude` skills: `go run ./scripts/newsletter X` →
`node scripts/newsletter X`; `../mt-add-url/references/newsletter-post-mechanics.md`
→ `docs/newsletter/post-mechanics.md`; `mt-add-post` → `mt-add-article` and
`mt-webfetch` → `mt-fetch-url` in every cross-reference; add a link to
`docs/newsletter/engine-commands.md` where a skill currently explains the
engine. Leave the `description:` frontmatter wording alone except for the two
renamed names.
6. Update `mt-fetch-url`'s tier list for the new two-stage tier 1 (local
defuddle, then the `defuddle.md` proxy) from phase 1 — the current text
describes a single defuddle step. The chain becomes: WebFetch → local defuddle
→ defuddle.md → reader proxy → report failure.
7. Test the symlink approach: replace one `.agents` adapter with a symlink, run
Codex, confirm it discovers and follows the skill. On success convert the
other five; on failure revert to thin pointers and note the reason in
`docs/multi-tool-usage.md`.
8. Rewrite `AGENTS.md` lines 40-52: describe the engine as Node, replace the
seven-command block with a one-line pointer to
`docs/newsletter/engine-commands.md` plus the `npm ci` prerequisite, and
update the skill names in the routing section and the Codex `$mt-*` list.
9. Update `README.md` line 42 and `docs/multi-tool-usage.md` lines 3, 13, 26, 27,
29 — Node not Go, link the command reference rather than restating it, and
keep `docs/newsletter/` in the "what you must keep" list in the
pick-one-tool section.
10. Verify: `grep -rn "go run\|mt-add-post\|mt-webfetch" .claude .agents AGENTS.md
README.md docs` returns nothing. Do not include `scripts/` — the Go sources
still exist and reference themselves.
11. Verify the enumeration rule: `grep -rln "find-newsletter-number" .claude
.agents AGENTS.md README.md docs` should show `docs/newsletter/` files and
only those skills that actually invoke that command.
12. Run one real `mt-add-url` flow end to end — an article, a YouTube link, and a
Substack image into a scratch post — under Claude Code, then repeat the
dispatch step under Codex.
## Success criteria
- [x] `grep -rn "go run" .claude .agents AGENTS.md README.md docs` → empty
(`scripts/` is excluded until phase 4: the 10 Go sources carry 16 `go run`
occurrences in their own doc comments)
- [x] `grep -rn "mt-add-post\|mt-webfetch" .claude .agents AGENTS.md README.md docs` → empty
- [x] All seven commands enumerated in `docs/newsletter/engine-commands.md` and
nowhere else
- [x] No skill references a path inside another skill's directory
- [x] `.agents/skills/` contains no prose that can drift from `.claude/skills/`
(symlinks), or the fallback is documented with the reason
- [x] Claude Code lists and runs all six skills, two under new names
- [x] All six skill directory names satisfy `mt-<verb>-<object>`
- [ ] Codex discovers all six skills and `$mt-add-url` dispatches correctly — deferred: the user will verify on their next Codex run (symlinks were chosen over thin pointers at their direction)
- [x] One real end-to-end `mt-add-url` flow produces the expected markdown
- [x] The `npm ci` prerequisite appears in `AGENTS.md`, `README.md`, and
`docs/newsletter/engine-commands.md`
## Risk assessment
**Renaming skills breaks the user's muscle memory and any saved invocation.**
`mt-add-post` and `mt-webfetch` are typed by hand today.
*Signal:* the user or a runtime invoking an old name after cutover.
*Response:* the renames are the user's request, so carry them out — but call out
in the final report that `$mt-add-post` and `/mt-webfetch` no longer resolve.
Do not leave alias directories; two names for one skill is the duplication this
phase exists to remove.
**Frontmatter `description` is routing logic, not documentation.** These strings
are how Claude Code and Codex decide whether to invoke a skill. An edit that
looks like tidying can stop a skill from triggering.
*Mitigation:* change only the skill names inside them; leave every trigger phrase
untouched.
*Signal:* step 12's end-to-end run failing to auto-dispatch.
*Response:* restore the original wording verbatim and change only the name token.
**Symlinked skills may break Codex discovery, or break on a Windows checkout.**
*Signal:* step 7.
*Response:* thin pointers, as today. This is a nice-to-have; the command-table
centralization is the part that actually reduces future edit cost.
**Moving `post-mechanics.md` out of `.claude/skills/` could put it outside what a
runtime packages with a skill.** Claude Code reads repo-relative paths fine, but
this changes the file's relationship to the skill.
*Signal:* a skill run that cannot read the referenced file.
*Response:* move it back under `.claude/skills/` in a shared, non-skill
subdirectory and have `.agents` reference it by repo-root-relative path instead.
@@ -0,0 +1,125 @@
---
phase: 4
title: "Retire the Go toolchain"
status: done
priority: P2
effort: "2h"
dependencies: [2, 3]
---
# Phase 4: Retire the Go toolchain
## Overview
Delete the Go engine and remove Go from the repo, GitHub Actions, and Netlify.
The newsletter engine is Go's only consumer here — Hugo vendors its theme in
`themes/` and `config/_default/` has no `[module]` section, so nothing else
needs a Go toolchain. This is the only irreversible phase; it runs last and only
after phase 2 is green.
## Requirements
**Functional**
- No `.go` file, no `go.mod`, no Go step in CI, no `GO_VERSION` anywhere.
- The Hugo build still succeeds on GitHub Actions and on Netlify.
- The superseded research recommendation is annotated, not left contradicting
the repo.
**Non-functional**
- One commit per concern (engine deletion, CI, docs/report) so a bisect can
isolate a Hugo build break to the CI change.
## Architecture
Go's footprint, verified by grep at plan time:
| File | What to remove |
|---|---|
| `scripts/newsletter/*.go` | all 10 files |
| `go.mod` | the whole file (there is no `go.sum` — the engine was stdlib-only) |
| `.github/workflows/hugo.yml` | `GO_VERSION: 1.25.5` (line 23), the `Setup Go` step (lines 33-37), and the Go line in Verify installations (line 64) |
| `netlify.toml` | `GO_VERSION = "1.25.5"` (line 3) |
Removing the pins also disposes of the latent defect the research report flagged:
`go.mod` declares `go 1.26` while both build environments pin 1.25.5, which only
works because `GOTOOLCHAIN=auto` silently downloads 1.26. Deleting Go resolves it
outright — no version alignment needed.
## Related code files
- Delete: `scripts/newsletter/add_url.go`, `detect_image_source.go`,
`fetch_via_defuddle.go`, `find_newsletter_number.go`, `find_substack_post.go`,
`html_text.go`, `list_existing_tags.go`, `main.go`, `post_stats.go`,
`url_utils.go`
- Delete: `go.mod`
- Modify: `.github/workflows/hugo.yml`, `netlify.toml`
- Modify: `.gitignore` — the Go block (`*.o`, `_cgo_*`, `_testmain.go`, `*.test`,
…) is dead weight afterward; remove it and keep `node_modules`
- Modify: `plans/reports/research-260918-1139-script-language-choice.md`
- Modify: `plans/260818-2056-migrate-newsletter-scripts-to-go/plan.md` — status
line only
## Implementation steps
1. Re-confirm phase 2 is green and phase 3's end-to-end run passed. Everything
below assumes the JS engine is the working engine.
2. Confirm Go has no other consumer: grep the repo's `*.yml`, `*.toml` and `*.md`
(excluding `node_modules/` and `plans/`) for every Go invocation and CI action
spelling, and check `config/_default/` for a `[module]` section. Both must
come back empty of anything but the sites listed above.
3. `git rm scripts/newsletter/*.go go.mod`. Commit alone.
4. Edit `.github/workflows/hugo.yml`: drop the `GO_VERSION` env entry, the whole
`Setup Go` step, and the Go line from Verify installations. Leave the Node
setup and the conditional `npm ci` — the lockfile exists, so `npm ci` runs,
which is what the engine now needs if CI ever invokes it.
5. Edit `netlify.toml`: drop `GO_VERSION`. Keep `NODE_VERSION = "24.12.0"`.
6. Remove the Go section from `.gitignore`.
7. Push and watch both builds. GitHub Actions must deploy; Netlify must build a
preview. Compare the built output against the previous deploy — the site
should be byte-identical, since Go never touched it.
8. Append a dated note to
`plans/reports/research-260918-1139-script-language-choice.md`, directly under
its recommendation #1, recording that the user overrode it on 2026-09-18, the
reason given, and a link to this plan. Do not delete or rewrite the original
recommendation — the report is a stateful record of what was concluded at the
time, and erasing it destroys the reasoning a future reader needs.
9. Update the old Go plan's status line to note it was superseded by this plan,
with the date. Leave its body untouched for the same reason.
10. Run `ak plan close` on this plan and `/ak:journal` for the migration.
## Success criteria
- [x] `find . -name "*.go" -not -path "./node_modules/*" -not -path "./themes/*"` → empty
- [x] `go.mod` gone; no `go.sum` was ever present
- [x] No `GO_VERSION` or Go-setup action reference remains in `.github/` or
`netlify.toml`
- [ ] GitHub Actions `hugo.yml` deploys green — not exercised: nothing pushed
- [ ] Netlify builds green — not exercised: nothing pushed
- [ ] Deployed site output unchanged from the pre-phase-4 deploy — not exercised: nothing pushed
- [x] The research report carries the override note; its original recommendation
is still readable
- [x] The 2026-08-18 Go migration plan is marked superseded
- [x] Every subcommand still works from a fresh clone + `npm ci`
## Risk assessment
**Something else needs Go and the grep missed it.** Hugo Modules are the obvious
candidate — a `[module]` block in Hugo config makes `hugo` itself require Go.
*Signal:* the Hugo build failing on Actions or Netlify after step 4 or 5.
*Response:* restore the Go setup step and the `GO_VERSION` pins (the engine stays
deleted — Hugo needing Go is unrelated to the engine) and align the pins to a
single version, which the research report wanted anyway.
**Netlify caches the Go toolchain and the build passes now but fails on the next
cache eviction.**
*Mitigation:* step 7 checks a real Netlify build, and Netlify builds previews
from a clean environment per deploy.
*Response:* if a cold build fails for a Go-shaped reason, that contradicts step
2 — reopen the investigation rather than re-adding the pin blindly.
**Irreversibility.** After this phase, the Go engine exists only in git history.
*Mitigation:* the phase ordering — parity proven (2) and skills cut over and
exercised end-to-end (3) before anything is deleted.
*Response:* recovery is `git checkout <pre-phase-4-sha> -- scripts/newsletter
go.mod`, which restores a fully working engine. Record that SHA in the journal
entry so it is findable without archaeology.
@@ -0,0 +1,124 @@
---
phase: 5
title: "Unify module extensions on .js"
status: done
priority: P3
effort: "1h"
dependencies: [3]
---
# Phase 5: Unify module extensions on `.js`
## Overview
Rename `.github/scripts/*.mjs` to `.js` so the repo has one module extension.
Independent of the engine migration; sequenced after phase 3 only so the
newsletter cutover lands first and a CI break here is unambiguous.
## Why `.js` and not `.mjs`
Decided during validation (2026-09-18), on three checks against this repo:
1. The root `package.json` declares `"type": "module"`, so a `.js` file here is
already ESM. `.mjs` restates what the manifest states.
2. Node's directory resolution requires `index.js` — verified empirically: with
only `eng/index.mjs` present, `node eng` fails with
`Cannot find module '…/eng'`; adding `eng/index.js` makes it resolve. This is
what keeps the engine invocation at `node scripts/newsletter <cmd>` rather
than a full file path, so `scripts/newsletter/` must use `.js` regardless.
3. `eslint.config.mjs` already globs `**/*.js` alongside `**/*.mjs`, so no lint
config change is needed.
`.mjs` earns its place in a package without `"type": "module"`, or one shipping
dual CJS/ESM entry points. Neither applies. `eslint.config.mjs` itself keeps its
extension — ESLint's own convention, and it sits outside the `type` field's
reach in some resolution paths.
## Requirements
**Functional**
- No `.mjs` under `.github/scripts/`.
- `npm run projects:refresh` works locally.
- The `update-projects.yml` scheduled workflow runs green.
**Non-functional**
- Renames use `git mv` so history follows the file.
- One commit, so a revert is clean if the workflow breaks.
## Architecture
Three files move; five reference sites follow. All verified by grep at plan time:
| File | Action |
|---|---|
| `.github/scripts/update-projects-list.mjs` | → `update-projects-list.js` |
| `.github/scripts/lib/github-api.mjs` | → `lib/github-api.js` |
| `.github/scripts/lib/render-projects.mjs` | → `lib/render-projects.js` |
| Reference site | Line(s) | What changes |
|---|---|---|
| `package.json` | 12 | `projects:refresh` script path |
| `.github/workflows/update-projects.yml` | 41 | `run: node …` path |
| `.github/scripts/update-projects-list.mjs` | 18, 25 | two `import` specifiers |
| `.github/scripts/update-projects-list.mjs` | 36, 37 | two JSDoc `import("…")` types |
| `.github/scripts/lib/render-projects.mjs` | 6, 51, 66, 80 | four JSDoc `import("…")` types |
The JSDoc `import("./github-api.mjs").Repo` annotations are the easy miss: they
are type references inside comments, so a stale one does not throw at runtime —
it silently degrades to `any` and the repo loses the typing it adopted JSDoc for.
## Related code files
- Rename: the three files above
- Modify: `package.json`, `.github/workflows/update-projects.yml`
- Modify: the renamed files' own import specifiers and JSDoc type imports
- Unchanged: `eslint.config.mjs`
## Implementation steps
1. `git mv` the three files.
2. Update the two runtime `import` specifiers in `update-projects-list.js`.
3. Update all six JSDoc `import("…")` type references (two in
`update-projects-list.js`, four in `lib/render-projects.js`).
4. Update `package.json` line 12 and `update-projects.yml` line 41.
5. `grep -rn "\.mjs" package.json .github/` → only `eslint.config.mjs` style
matches should remain, and none under `.github/scripts/`.
6. `npm run lint` and `npm run projects:refresh` locally. The refresh needs a
GitHub token; if one is not available locally, confirm the script gets as far
as the auth check rather than failing on module resolution.
7. Push and let `update-projects.yml` run — trigger it manually rather than
waiting for the daily cron.
## Success criteria
- [x] `find .github/scripts -name "*.mjs"` → empty
- [x] `grep -rn "\.mjs" .github/ package.json` → empty
- [x] `npm run lint` clean
- [x] `npm run projects:refresh` resolves all modules (auth failure is acceptable
locally; a module-resolution failure is not)
- [ ] `update-projects.yml` completes green on a manual dispatch — not exercised: nothing pushed
- [ ] The projects page content it generates is unchanged — not exercised: needs a GitHub token and a workflow run
## Risk assessment
**A stale JSDoc `import("…")` type silently degrades to `any`.** Nothing throws,
lint stays green, and the repo quietly loses type coverage on `Repo` and
`Contribution` — the exact thing the JSDoc-not-TypeScript policy exists to
provide.
*Signal:* step 5's grep, which is why it checks JSDoc strings and not just
`import` statements.
*Response:* fix the reference. If any are found after the commit, add a lint rule
or a grep to CI rather than relying on the next person to remember.
**`update-projects.yml` breaks and the projects page goes stale silently.** It is
a daily cron, so a failure is easy to miss.
*Mitigation:* step 7 triggers it manually instead of waiting.
*Response:* revert the single commit; the rename is cosmetic and nothing depends
on it.
**Scope creep into an unrelated CI path.** This phase touches the projects
workflow, which has nothing to do with the newsletter engine.
*Mitigation:* it is its own phase, its own commit, and it is P3 — droppable
without affecting phases 1-4.
*Response:* if phase 4 leaves CI unstable for any reason, drop this phase rather
than stacking a second CI change on top.
@@ -0,0 +1,238 @@
---
title: "Migrate the newsletter engine from Go to JavaScript"
description: "Port the 1168-LOC stdlib-only Go newsletter engine to Node ESM with a parser dependency, prove parity with a committed golden-output suite, restructure the mt-* skills around a single command reference, and retire Go from the repo."
status: in-progress
priority: P1
effort: "2-3d"
tags: [newsletter, scripts, skills, migration, javascript]
created: 2026-09-18
---
# Migrate the newsletter engine from Go to JavaScript
## Overview
`scripts/newsletter/` is a 10-file, 1168-LOC `package main` in Go, stdlib-only,
invoked as `go run ./scripts/newsletter <cmd>` from ten files
across `.claude/skills/`, `.agents/skills/`, `AGENTS.md`, `README.md`, and
`docs/multi-tool-usage.md`. This plan replaces it with a Node ESM engine invoked
as `node scripts/newsletter <cmd>`, takes on a small set of justified npm
dependencies, proves behavioral parity with committed golden fixtures, folds the
skill layer onto one canonical command reference, and deletes Go from the repo
and CI.
The engine has seven subcommands: `add-url`, `find-newsletter-number`,
`list-existing-tags`, `detect-image-source`, `find-substack-post`,
`fetch-via-defuddle`, `post-stats`.
## Context: this reverses a same-day recommendation
`plans/reports/research-260918-1139-script-language-choice.md` (2026-09-18)
concludes *"`scripts/newsletter/` stays Go. No GitHub API, no deps, no latency
problem, three agent runtimes served identically."* `CLAUDE.md` also ranks Go
above JavaScript for new work.
The user reviewed that and chose to migrate anyway (2026-09-18). Phase 4 records
the override in the report itself so the repo record is not self-contradictory.
The plan does not re-litigate the decision; it does carry the report's strongest
objection forward as the tracked risk (see **Risks**, R1).
## Goals
| # | Goal | Priority |
|---|------|----------|
| 1 | Seven subcommands running on Node with output parity against the Go engine | P1 |
| 2 | Hand-rolled HTML/XML regex extraction replaced by a real parser | P1 |
| 3 | A committed regression suite the engine did not have before | P1 |
| 4 | One canonical command reference; no enumeration of the engine's commands in more than one file | P2 |
| 5 | Go fully removed from the repo, CI, and Netlify | P2 |
| 6 | One module extension across the repo's JavaScript | P3 |
## Non-goals
- `.github/scripts/` keeps its current logic. Its Octokit migration is a separate
recommendation in the same research report and is not in scope. Phase 5 renames
its files' extensions only — no behavior change.
- No change to what the skills *do* — only to what they invoke and where their
shared prose lives.
- No new subcommands. The port is behavior-preserving except where this plan
names a deliberate improvement.
## Design decisions
**Invocation stays positional and short.** `node scripts/newsletter <cmd> [args]`
from the repo root, matching the Go contract one-for-one. Node directory
resolution requires `scripts/newsletter/index.js` (not `.mjs`), so the new engine
uses `.js`; the root `package.json` already declares `"type": "module"`, so `.js`
is ESM and the existing `eslint.config.mjs` already lints `**/*.js`.
`.github/scripts/` currently uses `.mjs`; phase 5 unifies it onto `.js` for the
same reasons (see that phase for the empirical check).
**Module layout mirrors the Go files 1:1** so the parity diff is reviewable:
`index.js` (dispatch) and one module per current `.go` file. A 1:1 map makes any
behavioral drift attributable to a single file.
**Dependencies (3, each load-bearing).** The user authorized npm dependencies
while asking to stay minimal:
| Package | Replaces | Why it earns its place |
|---|---|---|
| `cheerio` ^1.2.0 | ~12 hand-rolled regexes in `html_text.go` + `find_substack_post.go` | One parser covers HTML *and* RSS/sitemap XML (`xmlMode`), so no second XML dep. Eliminates `captionForUUID`'s manual `LastIndex("<figure")` string surgery — the single most fragile function in the engine. |
| `defuddle` ^0.19.4 | nothing — it *adds* a tier ahead of the `defuddle.md` proxy, which stays | Runs extraction locally, so the chain no longer depends on a single third-party service being up. See the two-tier decision below for why the proxy is not dropped. |
| `linkedom` ^0.18.12 | — | `defuddle/node`'s DOM, an optional dep that must be declared explicitly. Chosen by defuddle itself; far lighter than jsdom. |
Rejected: `fast-xml-parser` (cheerio's `xmlMode` covers it), `jsdom` (defuddle
prefers linkedom), any test framework (`node:test` is built in).
**Tests use `node:test` + `node:assert`, zero dependencies.** Adds `npm test`.
**`fetch-via-defuddle` gains a local tier and keeps the remote one.** The Go
version proxies through `https://defuddle.md/<url>`. The point of that proxy is
that it fetches from a *different IP* than the blocked caller. Running defuddle
locally fetches from this box's IP instead — a genuinely different IP from the
one WebFetch uses, but not the same third IP the proxy provides. Both are kept:
local defuddle first, `defuddle.md` on failure. Dropping the proxy would silently
narrow the skill's coverage.
**Exit codes get more precise.** `fetch-via-defuddle` defines exit 2 (bad args)
vs 1 (fetch failed), but `go run` collapses every nonzero exit to 1 — documented
in a comment in `fetch_via_defuddle.go`. Node preserves them, so the distinction
becomes real for the first time.
## Phases
| # | Phase | Status | Depends on |
|---|-------|--------|------------|
| 1 | [Port the engine to JavaScript](./phase-01-port-engine-to-javascript.md) | Done | — |
| 2 | [Golden-output parity suite](./phase-02-parity-test-suite.md) | Done | 1 |
| 3 | [Skill restructure and call-site cutover](./phase-03-skills-restructure-and-cutover.md) | Done | 2 |
| 4 | [Retire the Go toolchain](./phase-04-retire-go-toolchain.md) | Done | 2, 3 |
| 5 | [Unify module extensions on `.js`](./phase-05-unify-module-extensions.md) | Done | 3 |
Go and JS coexist through phases 1-3 — parity cannot be measured against a
deleted reference. Phase 4 is the only irreversible step. Phase 5 is P3 and
independent; drop it without affecting anything else.
## Success criteria
- [x] All seven subcommands run under `node scripts/newsletter <cmd>` and match
the Go engine on the phase-2 case matrix (byte-identical stdout, or drift
recorded as an accepted improvement with a reason).
- [x] `npm test` passes from a clean `npm ci`.
- [x] `npm run lint` passes over `scripts/newsletter/`.
- [x] One real `mt-add-url` flow (article + YouTube + Substack image) completes
end-to-end on the JS engine and writes the same markdown the Go engine
would have.
- [x] `grep -rn "go run" .claude .agents AGENTS.md README.md docs scripts` returns
nothing. Evaluated after phase 4 — phase 3's narrower version of this check
excludes `scripts/`, because the Go sources document their own invocation
and survive until phase 4 deletes them.
- [x] The engine's seven commands are enumerated in exactly one file
(`docs/newsletter/engine-commands.md`); every other mention is a link.
- [x] No `.go` file, no `go.mod`, no Go setup step, no `GO_VERSION` anywhere in
the repo.
- [x] One module extension: no `.mjs` outside `eslint.config.mjs`.
- [x] A fresh `git clone` + `npm ci` runs every subcommand successfully.
## Risks
**R1 — `npm ci` becomes a prerequisite the engine never had.** This is the
research report's strongest objection and the migration's real cost. Go's
stdlib-only engine ran on a bare clone; the JS engine will not, because
`node_modules` is gitignored. Three agent runtimes (Claude Code, OpenCode, Codex)
on a headless ARM64 box all hit this.
*Mitigation:* `index.js` catches `ERR_MODULE_NOT_FOUND` on the dependency imports
and exits with `newsletter engine: dependencies missing — run 'npm ci' from the
repo root` instead of a raw stack trace. Setup docs state it once.
*Signal it broke:* any skill run failing with a module-resolution error.
*Response:* if this proves to be recurring friction rather than a one-time setup
step, vendor the three deps or fall back to the stdlib implementations in
`html_text.js` — do not re-litigate the language choice.
**R2 — silent output drift in the port.** Seven of the Go functions encode
non-obvious decisions (query-string order preservation, `omitempty`, rune-vs-byte
length, `null` vs omitted fields). A JS-idiomatic rewrite breaks them quietly and
the damage lands in published posts.
*Mitigation:* phase 2's golden fixtures are captured from the Go engine *before*
the JS is trusted, and phase 1 lists all seven traps explicitly.
*Signal:* any fixture mismatch.
*Response:* match Go, do not "improve" — parity first, improvements as a separate
recorded decision.
**R3 — cheerio changes extraction results.** Replacing regexes with a real parser
is the point, but a parser will legitimately extract *different* (usually better)
text on malformed markup.
*Mitigation:* treat extraction differences as expected-to-review rather than
expected-to-match; phase 2 records each one with a judgment.
*Response:* accept improvements, document them; reject anything that loses
content the regex found.
**R4 — Codex may not follow symlinked skill files.** Phase 3's `.agents`
de-duplication assumes it does; this is untested.
*Mitigation:* verify before committing the symlinks.
*Response:* fall back to the current thin-pointer adapters, which already work.
## Resolved during validation (2026-09-18)
**Skill naming — uniform `mt-<verb>-<object>`.** Two of the six violate it today;
the other four already conform, so renaming them would be churn.
| Current | New |
|---|---|
| `mt-add-url` | unchanged (dispatcher) |
| `mt-add-post` | **`mt-add-article`** — it adds an article *to* a post |
| `mt-add-video` | unchanged |
| `mt-add-image` | unchanged |
| `mt-add-tags` | unchanged |
| `mt-webfetch` | **`mt-fetch-url`** — verb-object, like its siblings |
**Fixture redirection uses the subprocess CWD, not a config knob.** Both engines
already resolve `content/post` from the process working directory, so phase 2's
tests set `cwd` and neither engine gains a test-only environment variable.
**R1 mitigation stays at a clear error message.** No auto-`npm ci` in the skills
and no self-installing engine; `npm ci` is a documented one-time setup step.
**Module extension unified on `.js`** — see phase 5.
## Validation Log
### Session 1 — 2026-09-18
**Verification pass:** Standard tier (5 phases at time of writing: 4).
Claims checked 24 · Verified 19 · Failed 5.
| # | Claim | Result | Evidence |
|---|---|---|---|
| F1 | `netlify.toml` `GO_VERSION` at line 4 | FAILED | it is line 3; `HUGO_VERSION` is line 4 — corrected in phase 4 |
| F2 | `hugo.yml` Setup Go step at lines 32-37 | FAILED | it is 33-37 — corrected in phase 4 |
| F3 | `hugo.yml` Go verify line at 61 | FAILED | it is line 64 — corrected in phase 4 |
| F4 | Phase 3 criterion `grep -rn "go run" … scripts` → empty | FAILED | the 10 Go sources carry 16 `go run` occurrences in their own doc comments, so it cannot pass before phase 4 — criterion rescoped to exclude `scripts/` |
| F5 | Phase 2 requires `NEWSLETTER_CONTENT_DIR`; phase 1 never adds it | FAILED | contract gap between phases — resolved by dropping the env var for CWD redirection |
| V1 | Node directory resolution needs `index.js`, not `index.mjs` | VERIFIED | empirical: `node eng` fails with `Cannot find module` when only `index.mjs` exists; resolves once `index.js` is added |
| V2 | Hugo has no `[module]` config, so Go has no other consumer | VERIFIED | grep across `config/` returns nothing |
| V3 | All phase-3 call-site line numbers | VERIFIED | `grep -n` across `.claude`, `.agents`, `AGENTS.md`, `README.md`, `docs/` |
| V4 | `defuddle` exports `./node`; `linkedom` is an optional dep | VERIFIED | `npm view defuddle exports optionalDependencies` |
| V5 | `opencode.json` allows `node *` and never allowed `go *` | VERIFIED | read directly |
**Questions asked:** 5 across two rounds. All answered; no unresolved decisions.
### Whole-Plan Consistency Sweep
Run after propagating the decisions above to phases 1-5.
- `NEWSLETTER_CONTENT_DIR` removed from every plan file — 0 remaining hits.
- `mt-add-post` / `mt-webfetch` appear only as *old* names in phase 3's rename
table and call-site inventory, which is correct.
- The design-decisions `.mjs` note in `plan.md` reconciled with phase 5's
existence (phase 1 carried no such note).
- Non-goals reconciled: `.github/scripts/` logic stays out of scope; its
extensions do not.
- Success criteria in `plan.md` and phases 3, 4, 5 agree on what `grep` must
return and when.
- Two contradictions found and fixed during the sweep:
1. The dependency table listed `defuddle` as *replacing* the `defuddle.md`
proxy, contradicting the two-tier design decision that keeps both.
2. `plan.md`'s `go run` criterion and phase 3's differed on whether `scripts/`
is included; both now state which phase they are evaluated after.
- **Unresolved contradictions: 0.**
@@ -0,0 +1,308 @@
# Code Review — Newsletter engine Go → JavaScript migration (2026-09-18)
Reviewer pass over the uncommitted change set on `main`. Read-only: no file in
the change set was modified. Verification was done by reading the JS against
`git show HEAD:scripts/newsletter/*.go`, running `npm run lint` / `npm test`, and
probing the engine directly.
## Scope
- `scripts/newsletter/` — 10 new `.js` modules + 2 test files + `__fixtures__/`
(~1,050 LOC), replacing 10 deleted `.go` files (1,168 LOC) and `go.mod`
- 6 `mt-*` skills restructured, `.agents/skills/` converted to symlinks
- `docs/newsletter/engine-commands.md` (new), `post-mechanics.md` (moved),
`AGENTS.md`, `README.md`, `docs/multi-tool-usage.md`
- `.github/scripts/*.mjs` → `.js`, `hugo.yml`, `update-projects.yml`,
`netlify.toml`, `.gitignore`, `package.json`, `eslint.config.mjs`
## Overall assessment
The port is careful and, on the evidence, faithful. All seven named traps are
genuinely handled — verified by reading, not by trusting the report. The golden
suite is real regression coverage (byte-for-byte stdout plus exit codes through
the actual CLI surface), not phantom tests. No scope drift: no invented
abstractions, no `any` widening, no catch-and-swallow beyond the deliberate
"return empty on network failure" contract the Go engine already had.
`npm run lint` passes clean. `npm test` passes 33/33 (8 network cases skipped).
One structural defect and three contract gaps are worth fixing before this is
committed. None is a security issue; the threat model here (a personal blog's
content CLI, run by the repo owner) does not warrant hardening beyond what is
present.
## Critical
None.
## High
### H1 — `index.js` runs the CLI as an import side effect
`scripts/newsletter/index.js:95` calls `main()` unconditionally at module scope,
with no entry-point guard. The four command modules that emit JSON
(`add-url.js`, `detect-image-source.js`, `find-substack-post.js`,
`post-stats.js`) statically `import { printJson } from "./index.js"`. Importing
any of them therefore *runs the CLI* against the host process's `argv`.
Reproduced:
```
$ node -e 'import("./scripts/newsletter/post-stats.js")'
Usage: node scripts/newsletter <command> [args]
...
exit=1
```
A one-line unit test for an exported pure function fails the same way:
```js
import { countPostEntries } from ".../post-stats.js"; // → suite dies, exit 1
```
Impact — this is the practical answer to "can you break `npm test`": yes, by
adding the most obvious next test. The port exports `detectYouTube`,
`extractInnerUrl`, `countPostEntries`, `parseLastmod` and `loadPublications`
specifically so they can be tested, and today none of them can be. The parity
suite only survives because it imports `html-text.js`, the one helper module with
no back-edge to `index.js`. Worse than the failure itself: if the host argv ever
begins with a valid command name, the import silently *executes that command*
instead of erroring.
The circular import is known — the comment at `index.js:93` works around its
deadlock symptom ("command modules import printJson from this file, so a
top-level await here would deadlock") rather than removing the cycle.
Fix (either one, both small):
- move `printJson` into its own module (`output.js`) so no command module imports
the entry point; or
- guard the call:
`if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) main().catch(...)`
## Medium
### M1 — the local defuddle tier can consume the proxy tier's turn on junk output
`fetch-via-defuddle.js:27,32` gates local success on exactly two conditions:
`res.ok`, and `body.trim() !== ""`. Anything else that comes back 200 with text —
a Cloudflare interstitial, a "Verifying you are human" page, a "Subscribe to keep
reading" paywall stub — extracts to a non-empty body, so `runFetchViaDefuddle`
returns at line 69 and **the `defuddle.md` proxy never runs**.
That is the scenario the proxy was deliberately kept for. `mt-fetch-url` is
invoked only *after* WebFetch was already blocked, which strongly predicts that a
fetch from this same box is also blocked. The agent-level mitigation in
`mt-fetch-url/SKILL.md` ("If tier 1 ... returns a body with no usable content,
try a reader proxy") routes past the failure to `r.jina.ai` — skipping the
proxy stage entirely, because the engine already reported exit 0.
Not re-litigating the two-tier decision (accepted, and the ordering is right).
The gap is the gate. A minimal quality check before returning local output —
e.g. fall through when `result.wordCount` is below a small threshold, or when the
body matches a short challenge/paywall shape — restores the intended chain.
### M2 — the local tier's output shape breaks `mt-fetch-url`'s documented parsing contract
The proxy returns YAML frontmatter plus body:
```
---
title: "Example Domain"
site: "example.com"
source: "https://example.com/"
...
---
<body>
```
The local tier returns `String(result.content)` only — bare markdown, no
frontmatter, no title. Verified against `https://example.com/`.
`mt-fetch-url/SKILL.md` still instructs the agent, for the tier the engine now
tries **first**:
- step 4: "Parse the returned markdown (tier 1 has YAML frontmatter with
title/description/etc.)"
- Output handling: "The frontmatter gives you the page title for free —
preferred over parsing from HTML."
- Failure modes: "Only frontmatter with no body" — a proxy-shaped signal that
cannot occur on the local tier.
So on the common path the agent is told to read a title that is not there. For a
skill whose output feeds an article heading in a published post, a missing title
is exactly the "wrong output lands in a post" failure mode worth catching.
`Defuddle(...)` already returns `title`, `author`, `description`, `domain`,
`site`, `published` and `wordCount` (confirmed by inspecting the result object) —
all discarded at `fetch-via-defuddle.js:31`. Emitting an equivalent frontmatter
block from those fields makes both tiers shape-compatible and satisfies the
skill as written. Alternatively, correct the skill text. Aligning the engine is
the smaller change and keeps the skill's title guidance true.
### M3 — output-contract changes not recorded in the parity report
Acceptance criterion 3 asks for anything that changed without being called out.
Three items, all benign or improvements, none currently documented:
1. **Sitemap `<loc>` entities are now decoded.** Go used
`<loc>([^<]+)</loc>` on raw text; the port uses `$(el).children("loc").text()`,
which decodes. Verified: `<loc>https://x.com/p/a?b=1&amp;c=2</loc>` now yields
`https://x.com/p/a?b=1&c=2` where Go yielded the literal `&amp;`. This changes
both the URL the deep crawl fetches and the `postUrl` emitted on a sitemap
hit. JS is correct per the sitemap spec; Go was fetching a broken URL. Worth
recording as an accepted improvement alongside the RSS `<link>` note.
2. **`itemLink` is not byte-identical to Go for numeric entities.** The report
states link values are "byte-identical to what Go produced". The cheerio
parse/serialize round trip normalizes `&#38;` → `&amp;`:
```
<link>https://x.com/p?a=1&amp;b=2&#38;c=3</link>
Go: …?a=1&amp;b=2&#38;c=3 JS: …?a=1&amp;b=2&amp;c=3
```
`&amp;` and CDATA both round-trip exactly, so real Substack feeds are
unaffected. The claim just needs narrowing to "`&amp;` and CDATA".
3. **`cleanUrl` inherits WHATWG normalizations Go's `url.Parse` did not apply.**
Verified:
| input | Go | JS |
|---|---|---|
| `https://x.com/a/../b?keep=1` | unchanged | `https://x.com/b?keep=1` |
| `https://x.com:443/p` | port kept | port stripped |
| `https://x.com/p?a=b c&d=1` | raw space kept | `a=b%20c` |
The query-verbatim trap itself is correctly handled — the port rebuilds the
query by string surgery and never touches `URLSearchParams`, and
`%20`/`%7e`/valueless-key all survive (confirmed by the golden case and by
direct probe). These three are path/authority-level, not query-level, and are
implausible for real newsletter inputs. Record rather than change.
## Low
### L1 — `engine-commands.md` overstates the missing-dependency contract
"Every subcommand prints `newsletter engine: dependencies missing …` and exits 1
if that step was skipped." Verified against a copy of the engine outside
`node_modules` reach:
- `find-substack-post` → correct message, exit 1 ✔
- `fetch-via-defuddle` → `ERR_MODULE_NOT_FOUND` from the dynamic
`import("defuddle/node")` is caught by `runFetchViaDefuddle`'s own try block,
reported as "local defuddle failed", then the proxy runs and the command
**succeeds with exit 0**
The runtime behaviour is better than the doc (graceful degradation). Fix the
sentence, not the code.
### L2 — `\s` is wider in JS than in Go
`NEWSLETTER_NUM_RE`, `TAGS_LINE_RE` and the three `post-stats` regexes use `\s`,
which in JavaScript matches NBSP and other Unicode spaces; Go's RE2 `\s` is
`[\t\n\f\r ]`. So `Newsletter #42` now parses where it previously did not.
An improvement, but it is a silent widening of a parsing contract and belongs in
the accepted-differences list.
### L3 — one export without JSDoc
`find-newsletter-number.js:9` — `export const NEWSLETTER_NUM_RE` is the only
exported symbol in the engine with no doc comment. Every other export
(30 of them) is annotated. Repo policy is JavaScript + JSDoc; this is the one
gap.
### L4 — nothing in CI runs `npm test` or `npm run lint`
`hugo.yml` runs `npm ci` (guarded, for the site build) and `update-projects.yml`
runs `npm ci` for the projects script. Neither runs the new suite, so the
regression coverage this migration just bought only exists on the developer's
machine. A job on `pull_request` / `push` running `npm ci && npm run lint &&
npm test` is a few lines. Flagging as a recommendation, not a blocker — CI was
explicitly out of scope for this pass.
## Verified correct (risk calibration)
Checked directly rather than taken from the report, because the report is the
artifact under review:
- **Seven traps.** Query rebuilt verbatim by string surgery, never
`URLSearchParams` (`url-utils.js:49-66`). `hasBoundaryMatch` is an index loop
with the same delimiter set and optional-trailing-slash rule as Go
(`url-utils.js:259-298`); `/p/foo` vs stored `/p/foo-bar` probed and correctly
*not* a duplicate. Optional fields are added conditionally, so `title`,
`author`, `uuid`, `innerUrl` are absent rather than `""`. `cutoff` initialises
to `null` and is always emitted. `[...text].length` counts code points.
`padStart(6)` reproduces `%6d`. `decodeURIComponent` is wrapped and falls back
to the raw substring.
- **JSON key order and types** match the Go struct field order one-for-one in all
four JSON-emitting commands, and the suite asserts stdout byte-for-byte.
- **Walk order.** `collectMarkdown` sorts each directory's entries lexically and
recurses in place, reproducing `filepath.WalkDir` ordering — which is what makes
the `list-existing-tags` tie-break deterministic. `Array.prototype.sort` is
stable (ES2019+), matching `sort.SliceStable`.
- **`captionForUuid`** is faithful despite the rewrite. Probed the divergence
candidates — uuid first appearing in a `<meta>`, in an earlier `<a href>`, or
on an ancestor of the figure — all return `""` in both engines.
- **`process.exit()` after a write** is safe here: on Linux, pipe writes from
`process.stdout`/`stderr` are synchronous, so no diagnostic is truncated.
- **Call sites.** Every command named in `.claude/skills/`, `docs/`, `AGENTS.md`
and `README.md` exists with the arguments it is given. `grep "go run"` across
the repo is empty. All six `.agents/skills/*/SKILL.md` symlinks resolve.
`mt-add-post` / `mt-webfetch` survive only inside `plans/`, which is correct.
- **Go retirement is complete.** No `.go`, no `go.mod`/`go.sum`, no `GO_VERSION`,
no `setup-go`. `eslint.config.mjs` is the only remaining `.mjs`.
`.github/scripts` internal imports were updated to `.js`.
## Carried over from Go, not a regression
`urlBoundaryOk` treats `_` as a delimiter, so `https://x.com/p/foo` is reported
as a duplicate of a stored `https://x.com/p/foo_bar` (probed: `true`). The `_`
is there to let `<uuid>_WxH` match, and the behaviour is byte-identical to the Go
original, so it is out of scope for this migration. Recording it because the
consequence — a URL silently dropped from a newsletter as a false duplicate — is
the kind of failure that is hard to notice after the fact.
## Recommended actions
1. **H1** — remove the import cycle (move `printJson` out of `index.js`) or guard
`main()`. Then add unit tests for the pure exports that cannot be tested today.
2. **M2** — emit frontmatter from the local tier's `title`/`author`/`site`/
`description`, or correct `mt-fetch-url/SKILL.md`'s output-handling section.
3. **M1** — add a minimal quality gate before the local tier returns, so a
200-with-challenge-page does not short-circuit the proxy.
4. **M3** — add the three items to the parity report's accepted differences and
narrow the `itemLink` "byte-identical" claim.
5. **L1** — correct the missing-dependency sentence in `engine-commands.md`.
6. **L2, L3, L4** — record the `\s` widening; add JSDoc to `NEWSLETTER_NUM_RE`;
consider a CI job running lint + test.
## Plan status
All five phases in
`plans/260918-0647-migrate-newsletter-engine-to-javascript/plan.md` are marked
Done and every success-criteria checkbox is ticked. Spot-checked against the
repo, each ticked criterion holds. Two carry caveats worth noting before the plan
is closed: the "`npm test` passes from a clean `npm ci`" criterion is true but
fragile per H1, and "one real `mt-add-url` flow completes end-to-end" was not
re-verified in this pass. Plan file not modified — plan mutation belongs to the
lead.
## Metrics
- Type coverage: JSDoc on 30 of 31 exported symbols (L3); no TypeScript, per repo policy
- Test: 33 deterministic assertions pass offline in 2.3s; 8 live cases opt-in
- Lint: 0 issues (`eslint .`)
- Build: n/a (no build step); Go toolchain fully removed from CI and Netlify
## Unresolved questions
1. M2 — align the engine's local tier to the proxy's frontmatter shape, or
rewrite the skill's output-handling section? The engine change is smaller and
keeps the skill's title guidance true, but it is a (small) output-contract
change on a command the user has already accepted as changed.
2. M1 — is a word-count/shape heuristic wanted at all, or is the agent-level
"no usable content" judgement in `mt-fetch-url` considered sufficient? That
judgement currently routes past the proxy rather than into it.
3. L4 — is CI for lint/test in scope, or deliberately deferred with the rest of
the unexercised CI work?
@@ -0,0 +1,174 @@
# Parity Report — Newsletter Engine Go → JavaScript (2026-09-18)
The Go engine's stdout was captured for 26 deterministic cases before the
JavaScript port was trusted, committed under
`scripts/newsletter/__fixtures__/golden/`, and asserted byte-for-byte by
`scripts/newsletter/parity.test.js`. Live-upstream behaviour is covered by
`network.test.js`, which is opt-in.
## Results
`npm test` — 40 assertions, 40 pass, 0 fail, offline.
`NEWSLETTER_NET=1 npm test` — 48 assertions, 48 pass, 0 fail.
| Subcommand | Deterministic cases | Live cases | Result |
|---|---|---|---|
| `add-url` | 15 | 4 | pass |
| `detect-image-source` | 4 | — | pass |
| `find-newsletter-number` | 3 | — | pass |
| `list-existing-tags` | 1 | — | pass |
| `post-stats` | 3 | — | pass |
| `find-substack-post` | 3 (extraction, saved markup) | 3 | pass |
| `fetch-via-defuddle` | 1 (exit 2) | 2 | pass |
Content-scanning commands are pointed at `__fixtures__/repo` by setting the
subprocess working directory. Both engines resolve `content/post` from the
process CWD, so neither gained a test-only configuration knob. Verified by
renaming the real `content/post` away and re-running: 32/32 still pass.
`add-url` performs a HEAD request on every URL, so its deterministic cases use
`.invalid` hosts (RFC 2606, guaranteed not to resolve). The status is a stable
`"000"` whether or not the machine is online.
## Trap coverage
Each of the seven traps identified in phase 1 has a case that goes red when the
naive implementation is reintroduced. Verified by mutating the engine and
re-running:
| Trap | Discriminating case | Reintroducing the trap |
|---|---|---|
| Query kept verbatim | `add-url-query-verbatim` (`%20`, valueless key, lowercase `%7e`) | 1 failure |
| Boundary-aware dedup | `add-url-prefix-not-duplicate` | 3 failures |
| Empty optional fields omitted | `add-url-article-no-metadata`, `detect-image-non-substack` | 3 failures |
| `cutoff` is `null`, not omitted | live deep-miss case | see below |
| Code points, not UTF-16 units | the 70-code-point bullet | 5 failures |
| Six-column right-aligned counts | `list-existing-tags-main` | 3 failures |
| Malformed percent escape | `detect-image-malformed-percent` | 3 failures |
The first discriminating case chosen for the query trap did not discriminate:
`%7E` and `+` survive a `URLSearchParams` round trip unchanged. The case was
replaced with one that does — `%20` becomes `+`, a valueless key gains `=`, and
lowercase `%7e` is upper-cased by the re-encoder.
`cutoff` is exercised live only. The publication list is deliberately not
test-configurable (the "no test-only knob" decision), so the branch that emits
`"cutoff": null` cannot be reached offline. A live `--deep` miss was diffed
against the Go engine directly and came back byte-identical.
## Accepted differences
**The parser recovers a TOC bullet the regex dropped.** On the live ByteByteGo
feed, `extractCandidates` returns one more candidate than the Go regex did — the
first bullet of the "in this issue" list,
`"An LLM needs evidence to answer questions"`. The markup is
`<li><p><span>…</span></p></li>`, identical in shape to the bullets the regex did
match, so this is the regex losing an item, not the parser inventing one. Every
candidate the regex found is still present and in the same order; the suite
asserts that explicitly rather than pinning the full list.
Accepted: more complete candidate lists, nothing lost. This is the R3 case the
plan anticipated.
**`fetch-via-defuddle` exit 2 is now observable.** The Go source defined exit 2
for bad arguments and exit 1 for a failed fetch, but `go run` collapsed every
nonzero exit to 1. Node preserves them, so the distinction is real for the first
time. No caller depended on the collapsed behaviour.
**`fetch-via-defuddle` gained a local extraction tier.** Local defuddle runs
first, the `defuddle.md` proxy remains as the second tier. The proxy is the tier
that matters when this machine's IP is the one being blocked, so it was not
dropped. Verified on ARM64: `defuddle/node` + `linkedom` import and extract
without native dependencies.
**`--uuid` no longer accepts the single-dash form.** Go's `flag` package accepted
`-uuid`; `node:util`'s `parseArgs` accepts only `--uuid`. Every documented call
site uses the double-dash form. Accepted narrowing.
**RSS `<link>` entity handling is unchanged, deliberately.** cheerio's `.text()`
decodes entities, which would have turned a stored `&amp;` into `&`. The port
reads the element's raw serialized inner content instead. `&amp;` and CDATA
round-trip exactly; a *numeric* entity is normalized by the parser's
re-serialization (`&#38;` → `&amp;`), which no observed Substack feed emits.
**Sitemap `<loc>` entities are now decoded.** Go's `<loc>([^<]+)</loc>` kept
`&amp;` literal, so a sitemap entry with an escaped ampersand produced a URL the
crawler then failed to fetch and a broken `postUrl` on a hit. `children("loc").text()`
decodes it. XML says the decoded form is the URL; accepted as a fix.
**`cleanUrl` inherits WHATWG normalizations Go's `url.Parse` did not apply** —
dot-segment removal (`/a/../b` → `/b`), default-port stripping (`:443`), and a
raw space becoming `%20`. These are path- and authority-level; the query is still
rebuilt by string surgery and never re-encoded, which is what the stored
`clean_url` values depend on. No observed input in `content/post` is affected.
**JavaScript `\s` matches more than RE2's `\s`.** `NEWSLETTER_NUM_RE`,
`TAGS_LINE_RE` and the `post-stats` patterns now also match a non-breaking space
where Go did not. A silent widening, and in this content the forgiving direction:
`Newsletter #132` typed with an NBSP would be found rather than missed.
## Carried over from Go, not introduced here
`urlBoundaryOk` treats `_` as a delimiter, so a stored
`https://x.invalid/p/foo_bar` makes `https://x.invalid/p/foo` report as a
duplicate. Byte-identical to the Go behaviour and therefore out of this
migration's scope, recorded because the consequence — a URL silently dropped as a
false duplicate — is hard to notice after the fact.
## Found during review, fixed
**Piping any command into a reader that closes early crashed.**
`node scripts/newsletter list-existing-tags | head -3` printed an EPIPE stack
trace; Go took SIGPIPE and died silently. `index.js` now exits quietly on EPIPE,
with a regression test.
**Importing a command module ran the CLI.** The four JSON-emitting commands
imported `printJson` from `index.js`, which calls `main()` at module scope — so
importing any of them executed the dispatcher against the host process's argv,
and the pure functions the port exports for testing could not be imported at all.
`printJson` moved to `json-out.js`, breaking the cycle. Six unit tests now cover
`detectYouTube`, `extractInnerUrl`, `countPostEntries`, `parseLastmod`,
`loadPublications` and `findMostRecentNewsletter`, which is the standing proof
the cycle stays gone.
**The local defuddle tier could consume the proxy tier's turn.** A bot wall
answers 200 with a real body, which extracted to non-empty and returned exit 0 —
so the `defuddle.md` proxy, the tier that exists precisely because this machine's
IP is blocked, never ran. A challenge-shape check on the extraction title and the
opening of the body now counts that as a local failure. Marker-based rather than
length-based on purpose: a legitimate short page (example.com is 17 words) must
still succeed.
**The two tiers returned different shapes.** The proxy returns YAML frontmatter
plus body; the local tier returned a bare body, while the skill told the agent
tier 1 carries a title in frontmatter — for a value that becomes an article
heading in a published post. The local tier now emits the same frontmatter
(title, author, description, site, published, source) from defuddle's metadata.
## Fixtures
Reviewed by eye before commit. No fixture was found to encode a Go bug.
- `__fixtures__/repo/` — six posts covering every shape a URL is stored in
(bare, trailing slash, markdown autolink, markdown link, image), a newsletter
in an older year holding a *higher* number than the newest year, and the three
`post-stats` shapes.
- `__fixtures__/repo-empty/`, `__fixtures__/repo-year-fallback/` — the empty tree
and the newest-year-has-none cases for `find-newsletter-number`.
- `__fixtures__/markup/bytebytego-feed.xml` — two real feed items, 64 KB.
- `__fixtures__/markup/bytebytego-sitemap.xml` — 40 real sitemap entries, 6 KB.
- `__fixtures__/markup/edge-cases.html` — synthetic: a nested `<figure>`, a
caption-less figure, and bullets at exactly 5, 6, 70 and 71 code points with a
non-BMP character.
The live feed carries no `<figcaption>` at all, which is why the engine's own
comment says ByteByteGo does not caption its images. Caption extraction is
therefore covered by the synthetic nested-figure fixture, where the innermost
enclosing figure must supply the caption and a caption-less figure must yield
nothing rather than borrowing the outer one.
## Verdict
Parity proven. The JavaScript engine reproduces the Go engine on every captured
case, and every difference is deliberate, recorded above, and an improvement or
an accepted narrowing. Safe to cut the call sites over.
@@ -120,6 +120,17 @@ engine does not call the GitHub API.
1. **`scripts/newsletter/` stays Go.** No GitHub API, no deps, no latency problem,
three agent runtimes served identically. Preserve the no-`go.sum` invariant.
> **Superseded 2026-09-18.** The user reviewed this recommendation the same
> day and chose to migrate the engine to JavaScript anyway, to collapse the
> repo onto one language and replace the hand-rolled HTML/XML regexes with a
> real parser. Carried out by
> `plans/260918-0647-migrate-newsletter-engine-to-javascript/`; parity proven
> in `plans/reports/parity-260918-newsletter-js-migration-report.md`. The
> recommendation above is left as written — it records what the evidence
> supported at the time, and its strongest objection (a bare clone no longer
> runs the engine without `npm ci`) is carried forward as risk R1 in that
> plan.
2. **`.github/scripts/` stays JavaScript; migrate to `octokit`.** Add a root
`package.json` (+ lockfile) with `octokit` as the single dependency, plus
ESLint and JSDoc per repo policy. Expected outcome: `lib/github-api.mjs`
@@ -133,6 +144,10 @@ engine does not call the GitHub API.
### Fix first (latent, found while scouting)
> **Resolved 2026-09-18 by removal.** Go was retired from the repo along with the
> engine, so there are no pins left to align.
`go.mod` declares `go 1.26`, but `hugo.yml` pins `GO_VERSION: 1.25.5` and
`netlify.toml` sets `GO_VERSION = "1.25.5"`. Running any `go` command in those
environments only succeeds because `GOTOOLCHAIN=auto` silently downloads 1.26.