chore(plans): remove completed migration plans and reports

This commit is contained in:
tiennm99 committed 2026-10-15 21:53:11 +07:00
1 parent cbe5083ae4
commit d20f97c2ad
16 files changed
+1 -2035

No files matched your search

+1 -2
View File
@@ -202,5 +202,4 @@ NEWSLETTER_NET=1 npm test # adds the live-upstream tier
```
Golden outputs under `scripts/newsletter/__fixtures__/golden/` were captured from
the previous Go implementation; see
`plans/reports/parity-260918-newsletter-js-migration-report.md`.
the previous Go implementation, so they pin the engine's original behavior.
@@ -1,64 +0,0 @@
# Phase 1 — Port to Go
## Context
- Source: `scripts/newsletter/*.js` (8 files, ~740 LoC, Node stdlib only, CommonJS).
- Read first: all JS files, `AGENTS.md` (Shared Engine section), `docs/multi-tool-usage.md`.
- Go 1.26.5 available (linux/arm64). No existing `go.mod` anywhere in the repo.
## Files to Create
```
go.mod module github.com/tiennm99/miti99, go 1.26
scripts/newsletter/main.go subcommand dispatch + usage text
scripts/newsletter/url_utils.go cleanUrl, bareUrl, isSubstackImage, substackImageUuid,
httpGet/httpHead helpers, checkAccessibility,
checkDuplicate, classifyType, collectMarkdown
scripts/newsletter/html_text.go stripTags, itemTitle, itemLink, extractCandidates,
captionForUuid, postTitleFromHtml
scripts/newsletter/add_url.go add-url: YouTube detect, oEmbed meta, route JSON
scripts/newsletter/detect_image_source.go detect-image-source: CDN unwrap + uuid JSON
scripts/newsletter/find_substack_post.go find-substack-post: RSS search, --deep sitemap crawl
scripts/newsletter/find_newsletter_number.go find-newsletter-number: scan YYYY/MM/DD, print max+1
scripts/newsletter/list_existing_tags.go list-existing-tags: frontmatter tag frequency top-40
scripts/newsletter/fetch_via_defuddle.go fetch-via-defuddle: proxy fetch, exit codes 0/1/2
```
`scripts/newsletter/config/substack-publications.json` — unchanged, loaded via `go:embed` (fallback `["blog.bytebytego.com"]` on parse error, matching JS).
All files are one `package main`; no `internal/` packaging (KISS — this is a script bundle, not a library).
## Implementation Steps
1. `go.mod` at repo root; verify `hugo` build still works untouched (it should — no `[module]` config in use).
2. `main.go`: `os.Args[1]` switch → handler funcs; unknown/missing subcommand prints usage to stderr, exit 1.
3. Port `url-utils.js` → `url_utils.go`. See parity traps below — this file has all the hard ones.
4. Port `html-text-utils.js` → `html_text.go`. Use `html.UnescapeString` (stdlib) instead of hand-rolled `decodeEntities` — strictly more complete, acceptable improvement.
5. Port the 6 entrypoints. JSON output via structs + `json.MarshalIndent(v, "", " ")` with field order matching the JS key order; optional fields (`title`, `author`, `uuid`, `innerUrl`, `caption`…) use `omitempty` to reproduce JS's conditional key emission. **Skills parse these JSON shapes — field names are a public contract.**
6. `gofmt` + `go vet ./scripts/newsletter/`.
## Parity Traps (must handle explicitly)
| # | JS behavior | Go trap | Resolution |
|---|-------------|---------|------------|
| 1 | `checkDuplicate` boundary regexes use negative lookahead `(?![0-9a-f])` | RE2 has **no lookahead** | Scan `strings.Index` occurrences of the needle; check the following byte(s) in code: uuid → next char ∉ `[0-9a-f]`; URL → optional `/` then one of `)]"'?#<_&,` whitespace or end |
| 2 | `cleanUrl` rebuilds query via `URLSearchParams` preserving insertion order | `url.Values.Encode()` **sorts keys alphabetically** | Split `RawQuery` on `&`, drop `utm_*`/tracker keys (case-insensitive match on the key before `=`), rejoin the surviving pairs verbatim. Closer to "don't corrupt" than JS re-encoding; document any percent-encoding drift in phase 2 |
| 3 | `new URL()` lowercases host in output | Go keeps host casing as parsed | Lowercase host manually when rebuilding (`clean_url`, `bareUrl`) |
| 4 | `[\s\S]*?` in figure/caption/candidate regexes | Go `.` excludes newline by default | Use `(?s)` flag |
| 5 | `new Date(lastmod)` accepts RFC3339 and date-only | `time.Parse` needs explicit layouts | Try `time.RFC3339`, then `"2006-01-02"`; unparseable → skip entry (JS `isNaN` path) |
| 6 | `fetch` HEAD, 10s timeout, follows redirects; network error → status `"000"` | — | `http.Client{Timeout: 10s}` (follows redirects by default); any error → `"000"` |
| 7 | defuddle: 30s timeout, UA `mt-webfetch/1.0`, exit 0/1/2, raw body to stdout, diagnostics to stderr | — | Same client pattern; `os.Exit` codes identical |
| 8 | `--uuid <v> --deep` flag parsing | — | stdlib `flag` on a subcommand FlagSet (`--uuid v` and `--uuid=v` both accepted); missing uuid → usage + exit 1 |
| 9 | uuid/substack regexes are case-insensitive | — | `(?i)` prefix |
| 10 | oEmbed URL built with `encodeURIComponent` | — | `url.QueryEscape` |
| 11 | Deep-crawl shared budget `DEEP_FETCH_BUDGET = 40` across publications; miss JSON includes `scanned`, `budget`, `cutoff` (ISO date) | — | Same constant, same miss shape; cutoff `time.Format("2006-01-02")` |
| 12 | `classifyType` extension regexes allow trailing `?query` | — | Port regex as-is (RE2-safe) |
## Validation
- `go vet` clean; each subcommand runs without args → same usage/exit behavior as its JS counterpart.
- Spot-run each subcommand once (real output sanity, not yet full parity — that is phase 2).
## Risks / Rollback
- No call sites change in this phase; JS remains the live engine. Rollback = delete the `.go` files and `go.mod`.
@@ -1,38 +0,0 @@
# Phase 2 — Parity Verification (JS vs Go side-by-side)
## Context
Both engines coexist. For each case below, run `node scripts/newsletter/<script>.js …` and `go run ./scripts/newsletter <cmd> …` with identical args and `diff` the outputs. Work from repo root. Use the scratchpad for output capture — no files in the repo.
## Verification Matrix
**Deterministic (must be byte-identical):**
| Command | Cases |
|---------|-------|
| `find-newsletter-number` | no args (repo content as-is) |
| `list-existing-tags` | no args — full 40-line ranking identical |
| `detect-image-source` | (a) real substackcdn wrapper URL pulled from an existing post, (b) raw S3 URL, (c) non-Substack image URL, (d) garbage non-URL |
| `add-url` routing/dedup fields | compare all fields except `http_status`/`accessible` timing flakes — rerun on mismatch |
**Network-dependent (identical modulo transient upstream changes):**
| Command | Cases |
|---------|-------|
| `add-url` | (a) YouTube watch URL, (b) `youtu.be` short link, (c) `/shorts/` URL — all three must emit the same canonical `clean_url` + oEmbed `title`/`author`; (d) article URL with `utm_*`+`fbclid` params (check `clean_url`); (e) URL already present in `content/post` (must report `duplicate: true`); (f) Substack image URL (route `image`); (g) `.pdf` URL (route `document`) |
| `find-substack-post` | (a) uuid from a recent ByteByteGo post (grep an existing post's markdown for a substack uuid) → RSS hit shape; (b) fabricated uuid → `{ found: false }`; (c) fabricated uuid with `--deep` → miss shape with `scanned`/`budget`/`cutoff` |
| `fetch-via-defuddle` | (a) fetchable article — compare exit code + first ~40 lines of body; (b) unreachable URL — exit 1, stderr message |
**Duplicate-boundary regression (the lookahead rewrite):** craft two checks against real repo content — a stored URL where the probe is a strict prefix (`/p/foo` vs stored `/p/foo-bar`, must be `duplicate: false`) and a stored substack uuid probed exactly (must be `true`).
## Acceptable Drift (document, don't fix)
- `clean_url` percent-encoding differences from query-rebuild strategy (phase-1 trap #2), as long as `bareUrl`-based dedup results agree.
- Entity decoding: `html.UnescapeString` decodes entities the JS map missed — Go output may be *more* correct in `postTitle`/`caption`/`candidates`.
Any other divergence is a bug: fix Go, rerun the failing case.
## Exit Criteria
- Matrix fully executed; results table (case → identical / drift-documented / fixed) recorded in `plans/reports/parity-260818-newsletter-go-migration-report.md`.
- One real end-to-end `mt-add-url` flow executed manually against the Go engine (temporarily invoke the Go command in place of the node line) to prove the JSON contract holds for skill consumption.
@@ -1,41 +0,0 @@
# Phase 3 — Cutover, Docs, Cleanup
## Context
Phase 2 passed. Replace every `node scripts/newsletter/<name>.js` invocation with `go run ./scripts/newsletter <name-without-.js>`, update prose, delete JS.
## Call Sites to Update (verified by grep, 2026-08-18)
| File | Lines | Change |
|------|-------|--------|
| `.claude/skills/mt-add-url/SKILL.md` | 23 | `add-url` invocation |
| `.claude/skills/mt-add-post/SKILL.md` | 16 | `add-url` invocation |
| `.claude/skills/mt-add-video/SKILL.md` | 22 | `add-url` invocation |
| `.claude/skills/mt-add-image/SKILL.md` | 24, 30, 37, 41 | `detect-image-source`, `add-url`, `find-substack-post` (×2, incl. `--deep`) |
| `.claude/skills/mt-add-tags/SKILL.md` | 53 | `list-existing-tags` invocation |
| `.claude/skills/mt-webfetch/SKILL.md` | 31, 73 | `fetch-via-defuddle` invocation |
| `.claude/skills/mt-add-url/references/newsletter-post-mechanics.md` | 21 | `find-newsletter-number` invocation |
| `AGENTS.md` | 42–52 | Shared Engine block: command list + prose "plain Node (stdlib only, no deps)" → "Go (stdlib only, `go run`, no deps)" |
| `README.md` | ~42 | shared-engine sentence mentioning node |
| `docs/multi-tool-usage.md` | 3, 13 | "all three call `node scripts/newsletter/*.js`" → Go invocation |
`.agents/skills/` Codex adapters reference the `.claude/skills/` SKILL.md files, not the scripts directly — verify with a final grep, no direct edits expected.
## Steps
1. Update all call sites above (mechanical string replacement per file; keep surrounding comments like `# expect route:image` intact).
2. Add `"Bash(go *)"` to `.claude/settings.json` permissions allow (alphabetical position). Leave `Bash(node *)` pending the plan's open question 1.
3. Delete `scripts/newsletter/*.js` (all 8). Keep `config/substack-publications.json`.
4. Verify: `grep -rn "node scripts/newsletter\|newsletter/[a-z-]*\.js" .claude .agents AGENTS.md README.md docs scripts` → only the go:embed reference to the config JSON may remain.
5. Run each of the 6 Go subcommands once from repo root as a smoke test.
6. `hugo --quiet` (or `hugo server` spot check) to confirm the site build is untouched by root `go.mod`.
7. Commit as `refactor(scripts): migrate newsletter engine from Node to Go` (conventional, no AI references). Run the pre-commit tag check per Git Workflow Rules — no post content changes expected, so it should be a no-op.
## Docs Policy Check
Per documentation-management rules this change affects commands + architecture docs → `AGENTS.md`, `README.md`, `docs/multi-tool-usage.md` updates above are required; no changelog file exists in this repo, so nothing more.
## Risks / Rollback
- Risk: an AI tool (Codex/OpenCode) with a cold Go build cache sees a slow first `go run` (~1–3 s compile). Acceptable; subsequent runs are cached.
- Rollback: `git revert` the cutover commit — JS engine and call sites restore together (single commit contains both).
@@ -1,37 +0,0 @@
# 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.
## Design Decisions
- **One `package main` with subcommands** at `scripts/newsletter/`, not 6 separate binaries. Subcommand names mirror the old script names (`add-url`, `find-newsletter-number`, `list-existing-tags`, `detect-image-source`, `find-substack-post`, `fetch-via-defuddle`) so skills stay greppable.
- **`go.mod` at repo root** (`module github.com/tiennm99/miti99`). Hugo does not use Go modules here (theme vendored in `themes/`, no `[module]` config), so this is inert to the site build. Gives the simplest documented invocation: `go run ./scripts/newsletter <cmd> [args]` from repo root. (Alternative considered: module inside `scripts/newsletter/` + `go run -C scripts/newsletter . <cmd>` — works, verified, but clunkier in 8 documented call sites.)
- **`go run`, no committed binary.** Build cache makes reruns fast; no stale-binary risk across the 3 AI tools.
- **`go:embed` for `config/substack-publications.json`.** `go run` recompiles on change, so editing the JSON still takes effect; the file stays the editable source of truth. Solves the `__dirname` problem (`os.Executable()` under `go run` points at the build cache, not the source dir).
- **Repo paths resolve from CWD** (`content/post`). The documented contract is already "invoked from the repo root"; missing dir degrades gracefully exactly like the JS (`0` / no duplicates).
## Phases
| Phase | File | Depends on |
|-------|------|------------|
| 1. Port to Go | [phase-01-port-to-go.md](phase-01-port-to-go.md) | — |
| 2. Parity verification | [phase-02-parity-verification.md](phase-02-parity-verification.md) | 1 |
| 3. Cutover, docs, cleanup | [phase-03-cutover-and-docs.md](phase-03-cutover-and-docs.md) | 2 |
JS and Go coexist during phases 1–2 (Go toolchain ignores `.js` files in the package dir); JS is deleted only after phase 2 passes.
## Acceptance Criteria
- All 6 subcommands match the Node output on the phase-02 verification matrix (byte-identical JSON, or drift explicitly documented as acceptable).
- One real `mt-add-url` skill flow works end-to-end on the Go engine.
- `grep -rn "node scripts/newsletter"` across the repo returns nothing (skills, AGENTS.md, README.md, docs/).
- All `.js` files under `scripts/newsletter/` deleted; `config/substack-publications.json` retained.
- `.claude/settings.json` allows `Bash(go *)`.
## Resolved Decisions (user, 2026-08-18)
1. Drop the `Bash(node *)` allow rule at cutover (phase 3).
2. Module path: `github.com/tiennm99/miti99` confirmed.
@@ -1,216 +0,0 @@
---
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.
@@ -1,164 +0,0 @@
---
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.
@@ -1,191 +0,0 @@
---
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.
@@ -1,125 +0,0 @@
---
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`
- [x] GitHub Actions `hugo.yml` deploys green — run 35329443556: build, Cloudflare Pages deploy and IndexNow all pass with no Go step
- [x] Netlify builds green — moot: the site is deployed to Cloudflare Pages by `hugo.yml` and served by Cloudflare, and `netlify.toml` was referenced nowhere else in the repo, so there was no Netlify build to observe. The user dropped Netlify entirely on 2026-09-18 and the file is deleted.
- [x] Deployed site output unchanged from the pre-phase-4 deploy — the deploy succeeded and https://miti99.com/ serves 200; Go never participated in the Hugo build
- [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.
@@ -1,124 +0,0 @@
---
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)
- [x] `update-projects.yml` completes green on a manual dispatch — run 35329575936
- [x] The projects page content it generates is unchanged — the run produced its usual `chore(projects): refresh projects list` commit containing only upstream star-count drift and the existing tie ordering, no structural change
## 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.
@@ -1,238 +0,0 @@
---
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.**
@@ -1,88 +0,0 @@
---
title: Research Report - Codex Project Workflow Format
date: 2026-07-14T02:11:00Z
status: complete
---
# Research Report: Codex Project Workflow Format
## Summary
Use `.agents/skills/<name>/SKILL.md` for checked-in Codex newsletter workflows. Do not restore `codex/prompts/` or its installers: OpenAI marks custom prompts deprecated, and those prompts are user-local rather than repository-shared. Use `.codex/config.toml` only when the repository needs Codex runtime settings such as model, sandbox, approvals, MCP, hooks, or rules.
## Navigation
- [Method](#method)
- [Findings](#findings)
- [Decision](#decision)
- [Implementation](#implementation)
- [References](#references)
- [Unresolved questions](#unresolved-questions)
## Method
- Researched: 2026-07-14 UTC
- Checked installed CLI: `codex-cli 0.144.3`
- Consulted current official Codex manual sections for skills, custom prompts, customization, and project config
- Compared official locations against the repository's deleted `codex/prompts/` installer design
## Findings
### `.agents/skills` is the official workflow location
Codex scans `.agents/skills` from the working directory through the repository root. Each skill is a directory containing `SKILL.md`, with optional scripts and references. Skills support explicit `$skill-name` invocation and implicit selection from their descriptions.
### Custom prompts are deprecated
The former `~/.codex/prompts` mechanism still exists for compatibility but is deprecated. It requires local installation, only supports explicit slash invocation, and does not travel with a repository. Restoring the old installer would preserve a legacy surface instead of fixing the underlying layout.
### `.codex` has a different purpose
Project `.codex/config.toml` is for trusted-repository runtime configuration. It is appropriate for model, approval, sandbox, MCP, hook, or rules overrides. This newsletter integration only needs reusable workflow instructions, so adding project config would be unnecessary.
## Comparative analysis
| Format | Official role | Repo-shared | Invocation | Decision |
|---|---|---:|---|---|
| `.agents/skills/<name>/SKILL.md` | Reusable workflow | Yes | Explicit or implicit | Use |
| `.codex/config.toml` | Codex runtime settings | Yes, trusted repos | Automatic config | Not needed |
| `~/.codex/prompts/*.md` | Deprecated custom prompts | No | Explicit slash command | Remove |
| `codex/prompts/` plus installer | Repository workaround | Indirect copy | Explicit slash command | Do not restore |
## Decision
Add thin Codex-native skills under `.agents/skills/`. They delegate to the existing canonical `.claude/skills/mt-*` workflow files, avoiding instruction drift while keeping the deterministic Node scripts in `scripts/newsletter/` shared by every tool.
## Implementation
```text
.agents/skills/mt-*/SKILL.md
│
└── reads canonical .claude/skills/mt-*/SKILL.md
│
└── runs scripts/newsletter/*.js
```
No installation or synchronization command is required. Launch Codex at the repository root and use `$mt-add-url`, or describe the newsletter task normally for implicit matching.
## Security and performance
- No new executable installer or home-directory writes.
- No project-level sandbox or approval override.
- Progressive disclosure loads skill instructions only after selection; runtime cost is limited to small skill metadata before invocation.
## References
- [OpenAI: Build skills](https://learn.chatgpt.com/docs/build-skills)
- [OpenAI: Custom prompts](https://learn.chatgpt.com/docs/custom-prompts)
- [OpenAI: Codex customization](https://learn.chatgpt.com/docs/customization/overview)
- [OpenAI: Config basics](https://learn.chatgpt.com/docs/config-file/config-basic)
## Next steps
1. Keep `.agents/skills` adapters and canonical `.claude/skills` workflows in sync only when a workflow name is added or removed.
2. Add `.codex/config.toml` later only if the project needs shared Codex runtime policy.
## Unresolved questions
None.
@@ -1,308 +0,0 @@
# 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?
@@ -1,51 +0,0 @@
# Parity Report — Newsletter Engine JS → Go (2026-08-18)
Method: identical args to `node scripts/newsletter/<script>.js` and `go run ./scripts/newsletter <cmd>`, `diff` on stdout, exit codes compared. Live network cases against real upstreams.
## Results
| Case | Result |
|------|--------|
| find-newsletter-number | IDENTICAL |
| list-existing-tags (full 40-line ranking) | IDENTICAL |
| detect-image-source: substackcdn wrapper (real URL from posts) | IDENTICAL |
| detect-image-source: raw S3 URL | IDENTICAL |
| detect-image-source: non-Substack image w/ utm param | IDENTICAL |
| detect-image-source: garbage non-URL | IDENTICAL |
| add-url: YouTube watch / youtu.be / shorts (same video id) | IDENTICAL ×3; all canonicalize to same watch URL, oEmbed title+author present |
| add-url: stored article + `utm_source`+`fbclid`+`x=1` | IDENTICAL; trackers dropped, `x=1` kept, `duplicate: true` |
| add-url: prefix probe `/p/goclaw-30-mot-buoc` vs stored `…-ngoat-lon` | IDENTICAL; `duplicate: false` — lookahead→index-scan rewrite verified |
| add-url: stored S3 image (uuid dedup) | IDENTICAL; `route: image`, `duplicate: true` |
| add-url: `.pdf` | IDENTICAL; `route: document` |
| find-substack-post: live ByteByteGo RSS uuid | IDENTICAL (hit shape incl. candidates list) |
| find-substack-post: fabricated uuid | IDENTICAL (`{found:false}`) |
| find-substack-post: fabricated uuid `--deep` | IDENTICAL (miss shape: scanned/budget/cutoff) |
| fetch-via-defuddle: example.com | exit 0=0, body byte-identical |
| fetch-via-defuddle: unresolvable host | exit 1=1 |
| fetch-via-defuddle: no args | node 2, go-run 1 — see drift |
## Accepted Drift
1. **`go run` collapses nonzero exits to 1.** In-program codes (1 fail / 2 bad-args) are correct in a compiled binary, but `go run` reports any child failure as 1. No skill distinguishes 1 vs 2 (verified mt-webfetch SKILL.md acts on stderr/body, not codes). Noted in `fetch_via_defuddle.go` header.
2. **Entity decoding**: Go uses stdlib `html.UnescapeString` (superset of the JS hand-rolled map). No divergence observed on live data; Go can only be more correct on exotic entities.
3. **`clean_url` query strategy**: Go keeps surviving query pairs verbatim (no re-encode) vs JS re-serialization. No divergence observed in the matrix; could differ on already-percent-encoded params. Dedup unaffected (path/identity-based).
4. **list-existing-tags tie order**: Go walks lexically (deterministic); Node readdir order is FS-dependent. Full output was identical on current content; a future tie could order differently between engines — moot once JS is deleted.
## Verdict
All matrix cases pass. Go engine is behavior-equivalent for skill consumption. Proceed to cutover.
## Post-cutover validation addendum (old-post URLs, 2026-08-18)
Ran the Go engine against URLs stored in old posts (2020 and 2025/02):
| Case | Result |
|------|--------|
| 2025/02 article, plain + tracker-variant | duplicate:true both, trackers stripped |
| 2025/02 YouTube stored watch URL + youtu.be variant | both canonicalize, duplicate:true, oEmbed title |
| 2025/02 S3 image (add-url + detect-image-source) | route:image, duplicate:true, uuid extracted |
| 18-month-old uuid via find-substack-post | `{found:false}` — correct, outside RSS window |
| 404 negative control | duplicate:false, http_status 404 |
| 2020 URL stored as markdown autolink `<https://…>` | **duplicate:false — defect found** |
Defect: the URL boundary set (`)]"'?#<_&,`, inherited verbatim from the JS) lacked `>`, so URLs stored in autolink form never dedup. Pre-existing JS behavior, not a port regression. Fixed post-cutover by adding `>` to the set in `url_utils.go`; regression-checked prefix-probe (still false), stored full URL (still true), deterministic outputs (unchanged).
@@ -1,174 +0,0 @@
# 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.
@@ -1,174 +0,0 @@
# Research Report: Script Language Choice for This Repo
Conducted 2026-09-18 11:39 (+07). Scope: both script populations in `tiennm99/blog`
— agent-invoked scripts and GitHub Actions scripts. Fresh evaluation, no bias
toward the incumbent Go/JS split.
## Executive Summary
The repo has **two script populations with disjoint requirements**, and the
"GitHub SDK" criterion applies to only one of them. Treating it as one language
question is the actual mistake.
- `scripts/newsletter/` (Go, 1168 LOC, stdlib-only) **never calls the GitHub
API** — verified by grep: zero hits for `api.github.com`. Its real constraints
are zero-install, three different agent runtimes, and fast repeated invocation.
Go already satisfies all three (`go run` warm = 0.09s). **Keep Go. No change.**
- `.github/scripts/` (JS, 426 LOC, stdlib `fetch`) is REST + GraphQL heavy and
will keep growing. **Keep JavaScript, but adopt Octokit** instead of hand-rolled
`fetch`. It is the only candidate that is simultaneously first-party, covers
REST *and* GraphQL in one library, and runs on a preinstalled runner language.
Ruby is the emotionally appealing answer (GitHub-maintained `octokit.rb`, rank 5
in repo preferences, preinstalled on the runner) and it **loses on a hard fact**:
`octokit.rb` has no GraphQL support, and list 3 of the projects page needs the
GraphQL search API. Python's `githubkit` is the best-*designed* non-JS SDK and
also loses: self-declared unstable, third-party, avoided language.
## Methodology
- Sources: 4 (2 web searches, 2 doc fetches) + direct repo measurement
- Date range: GitHub changelog 2026-06-11 → 2026-09-17; docs current
- Search terms: official GitHub SDK languages Octokit 2026; ubuntu-latest
preinstalled tools 2026; go-github vs githubkit vs octokit.js GraphQL
- Empirical: `go run` / `node` startup timing on this box; grep for API usage;
a live `gh api graphql --paginate` attempt
## Repo Inventory (Scout)
| Population | Files | Lang | LOC | Deps | Runs where | GitHub API? |
|---|---|---|---|---|---|---|
| Newsletter engine | `scripts/newsletter/*.go` (10) | Go 1.26 | 1168 | none (no `go.sum`) | local, by Claude Code / OpenCode / Codex via `go run` | **no** |
| Projects list | `.github/scripts/update-projects-list.mjs` + `lib/` (3) | Node ESM | 426 | none (no `package.json`) | CI (`update-projects.yml`), daily cron | **yes** — REST + GraphQL search |
Both are currently dependency-free by deliberate choice. Workflows: `hugo.yml`
(build/deploy, sets up Go 1.25.5 + Node 24.12.0), `update-projects.yml`
(Node 24, runs the projects script). Agent skills are markdown-only
(`.claude/skills/`, `.agents/skills/`) and shell out to `go run`.
## Key Findings
### 1. Only four languages have a GitHub-maintained SDK
Per GitHub's own library page, official Octokit clients exist for exactly:
**JavaScript** (`octokit.js`), **Ruby** (`octokit.rb`), **.NET** (`octokit.net`),
**Terraform**. Everything else — Go, Python, Java, Rust, PHP, shell — is
explicitly "not maintained by GitHub". Go's `google/go-github` is a Google-staffed
community project, not a GitHub product.
### 2. GraphQL is the discriminator, not REST
Every candidate can do REST; the projects script needs the GraphQL **search** API
for merged-PR counts. Coverage:
| Language | REST | GraphQL | Same library? |
|---|---|---|---|
| JS `octokit` | yes | yes (`octokit.graphql`) | **yes** |
| Ruby `octokit.rb` | yes | **no** | — |
| Go `go-github` | yes | no — needs `shurcooL/githubv4` | **no, two libs** |
| Python `githubkit` | yes | yes | yes (but unstable) |
| Python `PyGithub` | yes | **no** | — |
| `gh api` CLI | yes | yes | yes (but see §4) |
### 3. Runner preinstall favors Node / Python / Ruby / gh — not Go
`ubuntu-latest` migrates to Ubuntu 26.04 between 2026-10-19 and 2026-11-19.
On that image: **Node.js 24.20.0 preinstalled**, Python 3.14.4 preinstalled,
Ruby 3.3.8 preinstalled, **GitHub CLI 2.100.0 preinstalled**, Java cached.
**Go is not preinstalled** — it is a cached tool (1.24.13 / 1.25.14 / 1.26.8),
so `actions/setup-go` is fast but still a required step. A Go rewrite of the
projects script would *add* a setup step; the JS version needs none.
### 4. `gh api` is not a serious option for this script
`gh` is first-party, zero-install, and auto-authenticated in CI — genuinely the
best tool for one-liners. It fails here on two counts:
- **Measured in-session:** `gh api graphql --paginate` over the merged-PR search
query hung past a 120s timeout and produced no output. GraphQL pagination via
`--paginate` requires `pageInfo` at an exact position and is fragile.
- Grouping 175 PRs by repo, ranking, tie-breaking on `pushedAt`, and emitting
markdown tables in `jq` is strictly harder to maintain than the ~80 lines of JS
that do it now.
### 5. `go run` latency is a non-issue for agent scripts
Warm build cache: **0.09s** for `go run ./scripts/newsletter <cmd>` (first run
after edits: 1.15s). `node -e` is 0.03s. The gap is irrelevant at agent-invocation
frequency, so there is no performance argument for moving the newsletter engine.
### 6. Go's real advantage here is the dep-free invariant
`scripts/newsletter` has **no `go.sum`** — stdlib only. That is why it works
identically under Claude Code, OpenCode, and Codex with zero setup on a headless
ARM64 box. Adding `go-github` would break that invariant for no gain, since the
engine does not call the GitHub API.
## Comparative Analysis — `.github/scripts/` only
| Option | Easy to run | GitHub SDK | Repo-policy fit | Verdict |
|---|---|---|---|---|
| **Node + Octokit** | Node preinstalled; one `npm ci` step (hugo.yml already has the conditional) | **first-party, REST+GraphQL, pagination/throttle/retry plugins** | JS + JSDoc + npm ✓ | **Recommended** |
| Node + stdlib `fetch` (status quo) | zero deps, zero setup | none — ~100 of the current 235 lines re-implement pagination/error handling | ✓ | Acceptable; violates DRY |
| `gh api` + jq | best possible: gh 2.100.0 preinstalled | first-party CLI | shell, fine | Rejected — §4 |
| `actions/github-script` | zero files, pre-authed Octokit | first-party | — | Good for <30 LOC; 426 LOC inline in YAML is not maintainable, no local run |
| Go + `go-github` + `githubv4` | needs setup-go + first `go.sum` in repo | two third-party libs, split REST/GraphQL | Go ranked #1 ✓ | Rejected — worse SDK story, adds CI step |
| Python + `githubkit` | Python preinstalled | REST+GraphQL, typed, octokit-inspired | **avoided language** | Rejected — unstable + policy |
| Ruby + `octokit.rb` | Ruby preinstalled | **GitHub-maintained but no GraphQL** | rank 5 ✓ | Rejected — GraphQL gap is fatal |
## Recommendations
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`
drops from 235 LOC to roughly 80-100, and gains free retry, secondary
rate-limit backoff, and correct GraphQL cursor pagination.
3. **Do not introduce a third language.** Two is already the cost ceiling for a
blog repo. Python/Ruby/Java each lose on a hard constraint above.
4. **Draw the boundary by API surface, not by folder:** anything that calls the
GitHub API → JS + Octokit. Anything that parses content, files, or arbitrary
web pages → Go.
### 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.
Dormant today (the newsletter engine is never invoked in CI), but it will bite
the first time a workflow calls it, and Netlify's sandbox may block the download.
Align the pins to 1.26.x.
## Resources
- [Libraries for the REST API — GitHub Docs](https://docs.github.com/en/rest/using-the-rest-api/libraries-for-the-rest-api)
- [octokit/octokit.js](https://github.com/octokit/octokit.js/) · [octokit/graphql.js](https://github.com/octokit/graphql.js/)
- [Ubuntu 26 generally available and latest migration](https://github.blog/changelog/2026-09-17-ubuntu-26-generally-available-and-latest-migration/)
- [Ubuntu 26.04 runner image manifest](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2604-Readme.md)
- [google/go-github](https://github.com/google/go-github) · [yanyongyu/githubkit](https://github.com/yanyongyu/githubkit)
- [GitHub-hosted runners reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners)
## Unresolved Questions
1. Accept a first dependency (`octokit`) in this repo, or keep the strict
zero-dependency stance and the ~100 lines of hand-rolled HTTP plumbing?
2. Is `secrets.PAGES_FETCH_TOKEN` scoped with `read:org`? The new
`/user/memberships/orgs` call needs it; untested in CI.
3. Any appetite for aligning `GO_VERSION` pins now, or defer until a workflow
actually invokes the newsletter engine?