#!/usr/bin/env node /** * @agent-manifest * name: publish-tick * title: Auto-Publisher Tick * class: plumbing * trigger: cron:run.sh publish-tick.mjs(15m) * description: Once per day after the morning floor, publish ONE eligible draft (SEO-gated) with a randomized earlier-today timestamp. No LLM. * model: - * prompt: - * skills: - * tools: lib/publish.mjs * reads: - * writes: - * created: 2026-07-04 * @end */ // Auto-publisher. Run frequently by cron (e.g. every 15 min). Once per day, after a morning // floor time, it publishes ONE eligible draft (passing the SEO gate) so posts go live in the // morning — but stamps each with a RANDOM earlier-today timestamp so published times aren't a // fixed-minute metronome. News drafts are fast-tracked ahead of evergreen. // Usage: node agents/scripts/publish-tick.mjs [--now] (--now ignores the floor) import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs"; import { dirname, resolve, join } from "node:path"; import { fileURLToPath } from "node:url"; import { loadJson, todayInTz, randomTimestampTodaySoFar } from "./lib/util.mjs"; import { promoteDraft } from "./lib/publish.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const PIPELINE = resolve(HERE, ".."); const cfg = loadJson(join(PIPELINE, "config.json")); const root = cfg.paths.projectRoot; const FORCE_NOW = process.argv.includes("--now"); if (!cfg.publish?.auto) { console.log("[publish-tick] publish.auto is false — nothing to do."); process.exit(0); } const today = todayInTz(cfg.editorial.timezone); const stateDir = join(root, cfg.paths.stateDir); mkdirSync(stateDir, { recursive: true }); const statePath = join(stateDir, `publish-${today}.json`); let state = existsSync(statePath) ? loadJson(statePath) : { published: false, slug: null }; if (state.published) { console.log(`[publish-tick] already published today (${state.slug}).`); process.exit(0); } // Morning floor: don't publish before this Bogotá time (gives the 06:00 writer time to draft + // audit + auto-revise so the day's freshest article is the one that goes live). const notBefore = cfg.publish.notBefore || "07:00"; const floorMs = Date.parse(`${today}T${notBefore}:00-05:00`); if (!FORCE_NOW && Date.now() < floorMs) { console.log(`[publish-tick] waiting — floor ${notBefore} Bogotá (${new Date(floorMs).toISOString()}).`); process.exit(0); } // Find an eligible draft: on disk, has cover, and passes the SEO gate. const minScore = cfg.seo?.minScore ?? 85; const draftsRoot = join(root, cfg.paths.draftsDir); const calPath = join(root, cfg.paths.calendar); const calendar = existsSync(calPath) ? loadJson(calPath) : []; const entryOf = (slug) => calendar.find((e) => e.slug === slug) || {}; const dateOf = (slug) => entryOf(slug).date || "9999-12-31"; function eligible(slug) { const d = join(draftsRoot, slug); if (!existsSync(join(d, "index.mdx")) || !existsSync(join(d, "cover.jpg"))) return false; const auditPath = join(d, "seo-review.json"); if (!existsSync(auditPath)) return false; try { const a = loadJson(auditPath); return a.verdict !== "fail" && (a.overall ?? 0) >= minScore; } catch { return false; } } const candidates = existsSync(draftsRoot) ? readdirSync(draftsRoot, { withFileTypes: true }) .filter((x) => x.isDirectory()) .map((x) => x.name) .filter(eligible) // News drafts first (hottest, then by date); evergreen after, oldest first. .sort((a, b) => { const ea = entryOf(a); const eb = entryOf(b); const na = ea.news ? 1 : 0; const nb = eb.news ? 1 : 0; if (na !== nb) return nb - na; if (na) return (eb.freshnessScore ?? 0) - (ea.freshnessScore ?? 0) || dateOf(a).localeCompare(dateOf(b)); return dateOf(a).localeCompare(dateOf(b)); }) : []; if (!candidates.length) { console.log("[publish-tick] no SEO-passing draft ready to publish (leaving queue as-is)."); process.exit(0); } const slug = candidates[0]; const stamp = randomTimestampTodaySoFar(cfg.editorial.timezone); // morning go-live, random timestamp console.log(`[publish-tick] auto-publishing "${slug}" with timestamp ${stamp}…`); try { const { url, taxonomyAdded } = await promoteDraft(cfg, slug, { dateOverride: stamp }); if (taxonomyAdded.length) console.log(`[publish-tick] registered: ${taxonomyAdded.join(", ")}`); state = { published: true, slug, publishedAt: stamp }; writeFileSync(statePath, JSON.stringify(state, null, 2)); console.log(`[publish-tick] LIVE → ${url}`); } catch (e) { console.error(`[publish-tick] publish failed: ${e.message}`); process.exit(1); }