diff --git a/.gitignore b/.gitignore index b1cc6ee..449b3ad 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ Thumbs.db .env.* !.env.example +# Secrets (never commit outreach tracker tokens) +*.airtable-token +*-token + # Logs and temporary files *.log tmp/ diff --git a/LAUNCH.md b/LAUNCH.md index ee23532..7d936f4 100644 --- a/LAUNCH.md +++ b/LAUNCH.md @@ -4,18 +4,24 @@ Use this when sharing `codex-profiles` with developer communities. The goal is developer credibility first: clear problem, quick install, concrete demo, and public feedback loops. -## Current Handoff - -- Live operating file: `LAUNCH.md`. -- Full historical evidence: [`archive/launch-ledger-history.md`](archive/launch-ledger-history.md). +## Where Tracking Lives + +Outreach tracking moved to Airtable. This file holds only positioning, channel +copy, launch order, and policy — the parts that rarely change and are not +per-target state. + +- Live tracker: Airtable base `appcezSUhDxz7uaQW` — `Targets` (one row per + platform) and `Log` (append-only event history). +- Read and write it through `scripts/outreach-tracker.mjs` (see `agent.md`), not + by editing this file. Do not re-add tracking tables here: concurrent runs would + race on them, which is exactly why tracking moved out. +- Pre-Airtable history: + [`archive/launch-ledger-history.md`](archive/launch-ledger-history.md) and this + file's git history. - Current outreach posture: broad near-identical awesome-list PRs are paused. Prefer issue-first unless the target is Codex-specific, explicitly requests Codex CLI/Desktop workflow tooling, or has a structured registry/package - format. -- Before any PR or issue-to-PR conversion, follow the template-compliance gate - below and record the inspected paths/checks in this ledger. -- Treat every "last ledger status" row as stale until reverified with GitHub - CLI or the target site. Do not duplicate existing issues or PRs. + format. See Policy Gates below. ## Positioning @@ -184,24 +190,10 @@ right environment. 6. Launch on Product Hunt after screenshots, demo video, README, and install path have all been click-tested. -## Distribution Channel Tracking - -This section is the live queue for future agents. The detailed historical -ledger was archived to -[`archive/launch-ledger-history.md`](archive/launch-ledger-history.md) on -2026-06-10. - -Update rule: +## Policy Gates -- Keep `LAUNCH.md` short: current status, link, owner-visible next action. -- Put long evidence, clone notes, screenshots, and per-target research in the - archive or a dated companion note. -- Use exact dates for status claims. Use "last ledger status" when a link has - not been rechecked in the current pass. -- Do not remove active links until they are merged, closed, withdrawn, or - intentionally abandoned with a dated reason. - -### Current Policy Gates +Apply these before any outreach action; record the specific target status in the +Airtable tracker, not here. - Broad awesome-list PRs remain paused after Taskade declined PR #22 on 2026-06-02 and cited the pattern of near-identical `Add codex-profiles` PRs. @@ -218,211 +210,6 @@ Update rule: only when it is genuinely true. If a template cannot be satisfied truthfully, skip or use issue-first if allowed. -### Priority Follow-Up - -| Priority | Target | Last ledger status | Link | Next action | -| - | - | - | - | - | -| P0 | Awesome Codex CLI | Replacement PR open on 2026-06-02 | | Monitor; respond only to maintainer feedback. | -| P0 | CLIhub | PR open on 2026-06-02 | | Monitor registry review; preserve schema/tests if changes are requested. | -| P0 | Awesome Agentic Coding CLI | PR open on 2026-06-02 | | Monitor; do not use this as a template for broad list PRs. | -| P0 | Awesome CLI Apps in a CSV | PR open on 2026-06-02 | | Monitor structured CSV submission. | -| P0 | OpenAI Developer Community | Existing Codex / Codex CLI topic reverified by forum search on 2026-06-11 | | Monitor replies; do not repost duplicate topic. | -| P1 | Hacker News Show HN | Submit attempt and same-day retry reached HN temporary Show HN restriction page on 2026-06-11 | | Retry only after account/community readiness; do not bypass restriction. | -| P1 | Product Hunt | Launch draft fully prepared on 2026-06-23; scheduling the Jun 24 launch returned a Product Hunt 403 after confirmation | | Finish scheduling manually in the open browser tab or retry once Product Hunt clears the 403; avoid duplicate listing. | -| P1 | Unikorn.vn | Product submitted on 2026-06-24; Unikorn shows `codex-profiles` pending AI/manual review after accepting logo, screenshots, category, tags, tech stack, and GitHub link | | Monitor approval notification or profile page; publish the public link after approval. | -| P1 | OpenAgent.bot | No exact-name listing found in sitemap on 2026-06-22 after 2026-06-02 submission | | Wait for editorial review or contact response; do not resubmit blindly. | -| P1 | CLIHunt | No exact-name listing found on homepage on 2026-06-22 after 2026-06-02 submission | | Wait or contact before duplicate submission. | -| P2 | ToolHunter | Draft deferred on 2026-06-02 | | Resume only with an approved project contact email. | - -### Broad Open PR Backlog - -These PRs are historical submissions from before the broad-list pause. Reverify -state before touching them, avoid new broad-list PRs, and consider withdrawal if -maintainer feedback suggests reputation risk. - -| Target | Last ledger status | Link | Next action | -| - | - | - | - | -| Awesome DevTools | PR open on 2026-06-02 | | Monitor only. | -| Awesome AI Coding Tools | PR open on 2026-06-02 | | Monitor only. | -| Awesome Terminals AI | PR open on 2026-06-02 | | Monitor only. | -| Awesome Vibe Coding by bluegalaxy111 | PR open on 2026-06-02 | | Monitor only. | -| Awesome OpenAI Codex | PR open on 2026-06-02 | | Monitor only. | -| Awesome OpenAI Codex CLI by taahro | PR open on 2026-06-02 | | Monitor only. | -| Awesome Agentic Coding by tranhoangpich | Still inaccessible on 2026-06-22 | | Treat as dead unless the repo reappears. | -| Awesome CLI Coding Agents | PR open on 2026-06-02 | | Monitor only. | -| Awesome AI Dev Tools | PR open on 2026-06-02 | | Monitor only. | -| Awesome AI Coding Assistants Playbook | PR open on 2026-06-02 | | Monitor only. | -| Awesome AI Coding by wsxiaoys | PR open on 2026-06-02 | | Monitor only. | -| Awesome Harness Engineering | PR open on 2026-06-02 | | Monitor only. | -| Awesome Vibe Coding by ai-for-developers | PR open on 2026-06-02 | | Monitor only. | -| Awesome Vibe Coding by filipecalegario | PR open on 2026-06-02 | | Monitor only. | -| Awesome AI DevTools by jamesmurdza | PR open on 2026-06-02 | | Monitor only. | -| Awesome Vibe Coding Tools by jiji262 | PR open on 2026-06-02 | | Monitor only. | -| Awesome Vibe Coding by 0xWelt | PR open on 2026-06-02 | | Monitor only. | -| Awesome OpenAI Codex by KarelDO | PR open on 2026-06-02 | | Monitor only. | -| Awesome Vibe Coding Tools by furudo-erika | PR open on 2026-06-02 | | Monitor only. | -| LaunchApp Awesome AI Coding Tools | Existing prior PR found on 2026-06-02 | | Monitor or withdraw if broad-list reputation risk rises. | - -### Issue-First Queue - -Convert any of these to a PR only after maintainer confirmation and a fresh -pass through the template-compliance gate. - -| Target | Last ledger status | Link | Next action | -| - | - | - | - | -| Awesome Vibe Coding by no-fluff | Issue open on 2026-05-22 | | Wait for maintainer scope confirmation. | -| Awesome Codex Agents by AlexZander-666 | Issue open on 2026-06-09 | | Wait for maintainer scope confirmation. | -| Awesome AI Coding Tools by furudo-erika | Issue open on 2026-06-09 | | Wait for maintainer scope confirmation. | -| Awesome AI Coding Tools by runaicode | Issue open on 2026-06-09 | | Wait for maintainer scope confirmation. | -| Awesome AI Developer Tools by ColinEberhardt | Issue open on 2026-06-09 | | Wait for maintainer scope confirmation. | -| Awesome Vibe Coding by tysoncung | Issue open on 2026-06-09 | | Wait for maintainer scope confirmation. | -| Awesome AI Coding Tools by Icloudeng | Issue open on 2026-06-09 | | Wait for maintainer scope confirmation. | -| Awesome AI Coding Tools by tomrzv | Issue open on 2026-06-09 | | Wait for maintainer scope confirmation. | -| Awesome AI Coding Tools by danielrosehill | Issue open on 2026-06-09 | | Wait for maintainer scope confirmation. | -| Awesome AI Coding Tools by tyler-j-dao | Issue open on 2026-06-09 | | Wait for maintainer scope confirmation. | -| Awesome Vibe Coding by tangyuan-dev | Issue open on 2026-06-09 | | Wait for maintainer scope confirmation. | -| Awesome AI Coding Tools 2026 by kax168 | Issue open on 2026-06-09 | | Wait for maintainer scope confirmation. | -| Awesome AI Coding Tools by dingjiu1989-hue | Issue open on 2026-06-09 | | Wait for maintainer scope confirmation. | -| Awesome AI DevTools by Ravi-Chandraa | Issue open on 2026-06-09 | | Wait for maintainer scope confirmation. | -| Awesome AI DevTools Multilingual | Issue open on 2026-06-09 | | Confirm scope and language coverage before PR. | -| Awesome AI DevTools by yasir27uk | Issue open on 2026-06-09 | | Wait for maintainer scope confirmation. | -| Awesome Coding Agents by quome-cloud | Issue open on 2026-06-09 | | Wait for maintainer scope confirmation. | -| Awesome Coding Agents by wdzhwsh4067 | Issue open on 2026-06-09 | | Wait for maintainer scope confirmation. | -| Awesome Vibe Coding by YuyaoGe | Issue open on 2026-06-09 | | Wait for maintainer scope confirmation. | -| Awesome Vibe Coding by vibe-coding-labs | Issue open on 2026-06-09 | | Wait for maintainer scope confirmation. | -| Awesome Code Agents by EuniAI | Issue open on 2026-06-22 | | Wait for maintainer scope confirmation. | -| Awesome Code Agents by sorrycc | Issue open on 2026-06-22 | | Wait for maintainer scope confirmation. | -| Awesome Terminal Agents | Issue open on 2026-06-22 | | Wait for maintainer scope confirmation. | -| Awesome AI Developer Tools by ayushrajdev9-cmyk | Issue open on 2026-06-22 | | Wait for maintainer scope confirmation. | -| Awesome AI Coding Tools by klymaxltd-ctrl | Issue open on 2026-06-22 | | Wait for maintainer scope confirmation. | -| Awesome AI Coding Tools by KnoSkillz | Issue open on 2026-06-22 | | Wait for maintainer scope confirmation. | -| Awesome AI DevTools by tamilselvanarjun | Issue open on 2026-06-22 | | Wait for maintainer scope confirmation. | -| Awesome AI Developer Tools by dbpunk-labs | Issue open on 2026-06-22 | | Wait for maintainer scope confirmation. | -| Awesome Agentic Coding by Supersynergy | Issue open on 2026-06-22 | | Wait for maintainer scope confirmation. | -| Awesome Coding Agents by tiennm99 | Issue open on 2026-06-22 | | Wait for maintainer scope confirmation. | -| Awesome Coding Agents by closedloop-technologies | Issue open on 2026-06-22 | | Wait for maintainer scope confirmation. | -| Awesome Coding Agents by outer-joined | Issue open on 2026-06-22 | | Wait for maintainer scope confirmation. | -| Awesome Coding Agents by Caldalis | Issue open on 2026-06-22 | | Wait for maintainer scope confirmation. | -| Awesome Agentic Coding by fecet | Issue open on 2026-06-22 | | Wait for maintainer scope confirmation. | -| Awesome Agentic Coding by li0nel | Issue open on 2026-06-22 | | Wait for maintainer scope confirmation. | - -### Manual Or Gated Directories - -| Target | Last ledger status | Link | Next action | -| - | - | - | - | -| StackShare | Deferred on 2026-05-14 | | Requires stable browser and GitHub OAuth. | -| OpenAlternative | Deferred on 2026-05-14 | | Requires sign-in. | -| LibHunt | Deferred on 2026-05-14 | | Requires stable browser; position as alternative to a relevant project. | -| SaaSHub | Deferred on 2026-05-14 | | Requires stable browser and verification. | -| Gated targets | Gated 2026-06-02 | See archive | Requires account setup. | - -### Accepted Or Listed - -No current action unless a listed link breaks or a maintainer requests changes. - -- Verified accepted/listed by 2026-06-02: - , - , - , - , - , and - . -- Verified independently listed on 2026-06-11: - includes `codex-profiles` - in its Tools section. -- Verified accepted/listed on 2026-06-22: - , - , and - . The related - yeaight7 issue #8 and adriannoes issue #3 are closed as completed. -- Closed or declined items, skipped targets, rejected fits, and detailed - validation logs are archived in - [`archive/launch-ledger-history.md`](archive/launch-ledger-history.md). - -## Outreach Candidate Backlog - -The detailed 50-repository discovery table is archived. Do not use archived -candidates directly for new outreach; treat them as stale leads. - -Current backlog posture: - -- The June 9 passes already opened 20 targeted maintainer issues. Do not create - duplicate issues or PRs for those repositories. -- 2026-06-11 fresh discovery note: `vanna-ai/Awesome-Vibe-Coding-CLI` is a - possible but lower-priority CLI list with stale open PRs; use issue-first or - wait for maintainer activity. `techiediaries/awesome-vibe-coding` is broad - and has a stale open-PR backlog; skip unless maintainers become active. - `onurkanbakirci/awesome-codex-automations` is Codex-specific but scoped to - automation templates, not profile-switching tooling. -- Remaining uncontacted candidates from the archived table were lower-confidence - or deferred. Re-run discovery and duplicate checks before using any of them. -- For each new candidate, record only: repository, fit reason, inspected - template/guideline paths, duplicate terms, chosen channel, and next action. -- Skip targets with disabled issues and no credible contribution flow, generic - raw-ZIP download pages, archived repositories, or rules that prohibit the - submission. - -## Monthly Reconciliation - -Keep this list compact. Full dated notes are archived. - -- 2026-06-22, 15-repo issue-first outreach wave: opened scope-check issues - with no PRs and no directory submissions. Targets had issues enabled, no - visible issue templates, and no duplicate `codex-profile(s)` issue/PR history - in fresh checks. Opened: - , - , - , - , - , - , - , - , - , - , - , - , - , - , and - . Skipped quick - candidates with issues disabled, duplicate PR history, or narrower plugin, - skill, ACP, pet, paper, prompt, or raw-rule scope. -- 2026-06-22, GitHub status audit: verified - and - are merged, and - closed after - maintainer PR - merged. Moved those items out of active follow-up. Rechecked - ; it remains - inaccessible. Exact-name checks still found no OpenAgent.bot or CLIHunt - listing. External comments/submissions: 0. -- 2026-06-11, current outreach wave: opened - , replied on - , checked - OpenAgent.bot sitemap/search and CLIHunt API search, reverified the existing - OpenAI Developer Community topic, and attempted Hacker News Show HN. HN - redirected to its temporary `showlim` restriction page before publishing; - the same-day retry reached the normal submit form but redirected back to - `showlim` after submit. - External submissions: 1 PR, 1 issue comment, 0 directory resubmissions, - 0 new forum posts, 0 HN posts published. -- 2026-06-10, this pruning pass: archived full ledger history and slimmed - `LAUNCH.md` for next-agent handoff. External submissions: 0. -- 2026-06-10, : added the - mandatory template/contribution-guideline compliance gate. External - submissions: 0. -- 2026-06-09, : opened 10 - additional maintainer-scope issues. New PRs: 0. -- 2026-06-09, : opened 10 - maintainer-scope issues. New PRs: 0. -- 2026-06-02, `PinZheng/distribution-pass-2026-06-02`: opened 2 PRs, submitted - to OpenAgent.bot and CLIHunt, and paused broad-list PRs after maintainer - warning. -- 2026-05-21/22, `pinzheng/update-launch-pr-log`: reconciled open PRs and - added a limited high-signal outreach wave. -- 2026-05-18, archived automation pass: established the original distribution - ledger and later open-PR gate. - ## Metrics Track these for each channel: diff --git a/Makefile b/Makefile index 79ab73e..0c49337 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ PREFIX ?= $(HOME)/.local BINDIR ?= $(PREFIX)/bin -.PHONY: install uninstall lint test npm-package-test +.PHONY: install uninstall lint test npm-package-test outreach install: install -d "$(BINDIR)" @@ -46,3 +46,8 @@ npm-package-test: else \ printf '%s\n' 'npm not found; skipping npm package smoke test.'; \ fi + +# Operational helper for the outreach tracker (not part of the shipped CLI). +# Usage: make outreach ARGS="list --owned" +outreach: + node scripts/outreach-tracker.mjs $(ARGS) diff --git a/agent.md b/agent.md index c0e0819..ede7cd3 100644 --- a/agent.md +++ b/agent.md @@ -23,8 +23,20 @@ brew install Ducksss/tap/codex-profile Start every run by reading: - `README.md` for the current product surface, install path, and positioning. -- `LAUNCH.md` for prior PRs, missed channels, deferred channels, ineligible - repositories, and launch notes. +- `LAUNCH.md` for positioning, channel copy, launch order, and the current + policy gates. It no longer holds tracking data. + +Outreach tracking — every target, its status, and each event — lives in Airtable, +not in `LAUNCH.md`. Read and write it only through the tracker helper: + +```sh +node scripts/outreach-tracker.mjs list # state of all targets +node scripts/outreach-tracker.mjs list --status "PR Open" +``` + +The helper needs a token: set `AIRTABLE_TOKEN`, or place it at +`~/.codex-outreach-airtable-token` (`AIRTABLE_TOKEN_FILE` overrides). It defaults +to base `appcezSUhDxz7uaQW` and requires Node 18+. Do not duplicate prior submissions unless there is a clear reason, such as a closed PR that needs a replacement. @@ -33,7 +45,9 @@ closed PR that needs a replacement. Before researching or submitting anything: -- Verify `agent.md`, `README.md`, and `LAUNCH.md` exist in the repository root. +- Verify `agent.md`, `README.md`, and `LAUNCH.md` exist in the repository root, + and that the tracker is reachable (`node scripts/outreach-tracker.mjs list` + succeeds). - Run `git status --porcelain=v1 --branch` and record the branch in the final report. - Work only from a clean automation worktree. If unrelated dirty files are @@ -41,8 +55,8 @@ Before researching or submitting anything: into them. - Fetch the latest remote refs before checking open PRs or preparing new branches. -- Reconcile existing `LAUNCH.md` PR and issue links before discovering new - targets. +- Reconcile existing tracker entries (open PRs and issues, plus deferred and + pending targets) before discovering new targets. ## Monthly Operating Mode @@ -51,7 +65,8 @@ not a daily volume pass. At the start of every run: -- Check the state of all PRs and issues already recorded in `LAUNCH.md`. +- Check the state of all PRs and issues already recorded in the tracker + (`outreach-tracker.mjs list`, filtering by status). - Update merged, closed, stale, replied-to, or blocked entries before looking for new targets. - If 15 or more submitted PRs are still open, do not open more than one new @@ -60,6 +75,23 @@ At the start of every run: - Prefer maintainer follow-up, status cleanup, and eligibility revisits over expanding into weak directories. +## Concurrency + +Several copies of this agent may run at the same time. Coordinate through the +tracker so two runs never act on the same target: + +- At startup, pick a unique run id to use for `--workflow` and `--by`, e.g. + `run--`. +- Before acting on a target, claim it. If the claim exits non-zero, another live + run holds it — skip that target and move on: + +```sh +node scripts/outreach-tracker.mjs claim --by +``` + +- `release` the target as soon as you finish or abandon it. Claims older than 15 + minutes are treated as stale and may be re-claimed automatically. + ## Autonomy You have full execution authority for this distribution pass. Do not ask the @@ -96,7 +128,8 @@ Defer when: When contribution rules are ambiguous but the repository is relevant, make a best-effort judgment. Prefer opening an issue over a PR for borderline cases, -and document the rationale in `LAUNCH.md`. +and document the rationale in the tracker (`upsert` the target and `log` the +decision). ## Candidate Priorities @@ -121,7 +154,7 @@ Avoid: - Only open a PR when `codex-profiles` clearly fits the repository scope and contribution rules. - If contribution rules ask for an issue first, open an issue instead of a PR. -- If eligibility is not met, skip it and document why in `LAUNCH.md`. +- If eligibility is not met, skip it and document why in the tracker. - Keep every edit minimal and consistent with the target repository's style. - Validate structured files before opening a PR, especially CSV, JSON, YAML, Markdown tables, or generated indexes. @@ -156,33 +189,65 @@ Repo: https://github.com/Ducksss/codex-profiles ## Required Logging -For every candidate considered, update `LAUNCH.md` with: +Record everything in the Airtable tracker, never in `LAUNCH.md`. -- Repository or channel URL. -- Status: `PR opened`, `issue opened`, `merged`, `closed`, `not eligible`, - `not a fit`, `deferred`, or `failed`. -- Why it fits or why it was skipped. -- PR or issue URL if opened. -- Branch and commit if applicable. -- Validation performed. -- Any blocker requiring non-interactive authentication, maintainer interaction, - or a later retry. +For every candidate considered, `upsert` its target row (dedup is on `Key`, so +choose a stable slug such as `awesome-foo-bar` and reuse it on later runs): -Preserve existing `LAUNCH.md` history. Add new entries under the most relevant -section, and do not remove prior notes. +```sh +node scripts/outreach-tracker.mjs upsert \ + --name "" \ + --channel "" \ + --status "" \ + --link "" \ + --last-checked \ + --next-action "" +``` + +Then append one event per action taken. The log is append-only and is the audit +trail that replaces the old reconciliation notes: + +```sh +node scripts/outreach-tracker.mjs log --target --workflow \ + --action "" \ + --result "" \ + --link "" +``` + +Capture the same facts the old ledger required: fit reason or skip reason, the +PR/issue URL, validation performed, and any blocker needing non-interactive +authentication, maintainer interaction, or a later retry. Never delete tracker +rows or log entries — correct state by upserting the row and appending a new +event. ## Durable State -Before finishing: +Outreach state now lives in Airtable, so a normal run makes **no repository +commit**. Before finishing: + +- Verify every target touched this run is reflected in the tracker — `upsert`ed + with its current status and `log`ged — using `outreach-tracker.mjs list`. +- `release` every target you claimed this run. Release on failure too, so a + crashed run does not hold a claim (stale claims also free themselves after 15 + minutes). +- Commit and push only when you intentionally changed repository files, such as + `agent.md` or `scripts/outreach-tracker.mjs`. Run `git diff --check` first and + keep the commit scoped to those files. +- If a tracker write is blocked, report the exact target, command, and error. Do + not fall back to editing `LAUNCH.md`. + +## Owned Listings On Release -- Run `git diff --check`. -- Verify `LAUNCH.md` contains every PR, issue, listing request, skipped target, - and deferred follow-up from the run. -- Commit only scoped `LAUNCH.md` updates and any intentional automation - instruction changes. -- Push the commit to the current automation branch. -- If committing or pushing is blocked, leave `LAUNCH.md` updated and report the - exact blocker, command, and error. +Some targets are listings we control (`Owned?` = true — currently Product Hunt, +Unikorn, and the OpenAI Developer Community thread). When a run follows a new +`codex-profiles` release, refresh those listings and record the version you told +them about: + +```sh +node scripts/outreach-tracker.mjs list --owned +node scripts/outreach-tracker.mjs upsert \ + --last-version-told --last-checked +``` ## Final Report diff --git a/archive/launch-ledger-history.md b/archive/launch-ledger-history.md index ecc129e..10c67f2 100644 --- a/archive/launch-ledger-history.md +++ b/archive/launch-ledger-history.md @@ -1,9 +1,13 @@ # Launch Ledger Historical Archive This is the full pre-prune launch ledger snapshot archived on 2026-06-10. -Use it for audit history, old per-target evidence, and reconstruction. Keep -`LAUNCH.md` focused on the active handoff, current policy, open follow-up, and -compact reconciliation notes for the next AI agent. +Use it for audit history, old per-target evidence, and reconstruction. + +**Frozen.** Active outreach tracking has since moved to Airtable (base +`appcezSUhDxz7uaQW`, tables `Targets` + `Log`), managed via +`scripts/outreach-tracker.mjs`. This file and the git history are the +pre-Airtable record; nothing is written here anymore. `LAUNCH.md` now holds only +positioning, channel copy, launch order, and policy. --- diff --git a/scripts/outreach-tracker.mjs b/scripts/outreach-tracker.mjs new file mode 100644 index 0000000..1d6cb4d --- /dev/null +++ b/scripts/outreach-tracker.mjs @@ -0,0 +1,327 @@ +#!/usr/bin/env node + +// Outreach tracker: concurrency-safe read/write for the codex-profiles outreach +// ledger, which lives in Airtable (base "codex-profiles Outreach Tracker"). +// +// This is operational tooling for the agent.md distribution agent. It is NOT part +// of the shipped CLI: it is excluded from the npm package (package.json `files`), +// and it deliberately uses only the Node standard library + global fetch (Node 18+) +// to stay dependency-free, matching test/geo-site-test.mjs. +// +// Why it exists: multiple outreach agents run in parallel. Editing a shared +// Markdown ledger would race (lost updates, duplicate submissions). Airtable gives +// atomic upserts (dedup on `Key`) and a claim protocol so two agents never act on +// the same target at once. +// +// Config (env): +// AIRTABLE_TOKEN Personal access token. If unset, read from a file. +// AIRTABLE_TOKEN_FILE Token file path (default ~/.codex-outreach-airtable-token). +// AIRTABLE_BASE Base id (default appcezSUhDxz7uaQW). +// +// Commands: +// list [--status S] [--priority P] [--channel C] [--owned] [--json] +// get [--json] +// claim --by +// release --by +// upsert [--name .. --channel .. --status .. --priority .. --link .. +// --owned true|false --last-checked YYYY-MM-DD --next-action .. +// --last-version-told .. --notes ..] +// set-status +// log --target --workflow --action [--result T] [--link URL] + +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; + +const BASE = process.env.AIRTABLE_BASE || 'appcezSUhDxz7uaQW'; +const TARGETS = 'tblHOr51tpYHiaYWQ'; +const LOG = 'tbloEavouTn5Z7fOw'; +const CLAIM_TTL_MS = 15 * 60 * 1000; // a claim older than this is stale/steal-able +const RECHECK_DELAY_MS = 2000; // wait before confirming a claim stuck + +// Exit codes: 0 ok, 1 API/runtime error, 2 usage error, 3 claim lost. +const EXIT = { OK: 0, ERROR: 1, USAGE: 2, CLAIM_LOST: 3 }; + +function fail(code, message) { + process.stderr.write(`${message}\n`); + process.exit(code); +} + +function token() { + if (process.env.AIRTABLE_TOKEN) return process.env.AIRTABLE_TOKEN.trim(); + const path = process.env.AIRTABLE_TOKEN_FILE || join(homedir(), '.codex-outreach-airtable-token'); + try { + return readFileSync(path, 'utf8').trim(); + } catch { + fail(EXIT.USAGE, `No token: set AIRTABLE_TOKEN or create ${path}`); + } +} + +const TOKEN = token(); + +// --- Airtable REST helper (with one 429 back-off retry) ------------------------ + +async function api(method, table, { search, body } = {}) { + let url = `https://api.airtable.com/v0/${BASE}/${table}`; + if (search) url += `?${search}`; + const init = { + method, + headers: { Authorization: `Bearer ${TOKEN}` }, + }; + if (body) { + init.headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + for (let attempt = 0; attempt < 2; attempt++) { + const res = await fetch(url, init); + if (res.status === 429) { + // Airtable caps at 5 req/s per base; back off the documented 30s once. + if (attempt === 0) { + await sleep(30_000); + continue; + } + } + const text = await res.text(); + if (!res.ok) { + fail(EXIT.ERROR, `Airtable ${method} ${table} -> ${res.status}: ${text}`); + } + return text ? JSON.parse(text) : {}; + } +} + +// Escape a value for use inside an Airtable formula string literal. Our values +// never contain double quotes, so wrapping in double quotes avoids apostrophe +// breakage (e.g. statuses/keys are safe; free text has no `"`). +const lit = (value) => `"${String(value).replace(/"/g, '\\"')}"`; + +async function getRecords(table, { filterByFormula, fields } = {}) { + const records = []; + let offset; + do { + const params = new URLSearchParams({ pageSize: '100' }); + if (filterByFormula) params.set('filterByFormula', filterByFormula); + if (fields) for (const f of fields) params.append('fields[]', f); + if (offset) params.set('offset', offset); + const data = await api('GET', table, { search: params.toString() }); + records.push(...data.records); + offset = data.offset; + } while (offset); + return records; +} + +async function resolveTarget(key) { + const [record] = await getRecords(TARGETS, { filterByFormula: `{Key}=${lit(key)}` }); + if (!record) fail(EXIT.ERROR, `No target with Key=${key}`); + return record; +} + +// Upsert on Key so concurrent writers merge into one row instead of duplicating. +async function upsertTarget(fields) { + return api('PATCH', TARGETS, { + body: { + performUpsert: { fieldsToMergeOn: ['Key'] }, + records: [{ fields }], + typecast: true, + }, + }); +} + +async function appendLog({ target, workflow, action, result, link }) { + const fields = { + Event: `${action} — ${target}`, + Timestamp: new Date().toISOString(), + Workflow: workflow || '', + Action: action, + }; + if (result) fields.Result = result; + if (link) fields.Link = link; + // Link to the Target record when it exists (log is append-only). + const [rec] = await getRecords(TARGETS, { filterByFormula: `{Key}=${lit(target)}`, fields: ['Key'] }); + if (rec) fields.Target = [rec.id]; + return api('POST', LOG, { body: { records: [{ fields }], typecast: true } }); +} + +// --- Arg parsing --------------------------------------------------------------- + +function parseArgs(argv) { + const positionals = []; + const flags = {}; + for (let i = 0; i < argv.length; i++) { + const tok = argv[i]; + if (tok.startsWith('--')) { + const eq = tok.indexOf('='); + if (eq !== -1) { + flags[tok.slice(2, eq)] = tok.slice(eq + 1); + } else { + const name = tok.slice(2); + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith('--')) { + flags[name] = next; + i++; + } else { + flags[name] = true; + } + } + } else { + positionals.push(tok); + } + } + return { positionals, flags }; +} + +const asBool = (v) => !(v === false || v === 'false' || v === '0' || v === 'no'); + +// Map --flags to Target field names. +function fieldsFromFlags(flags) { + const map = { + name: 'Name', + channel: 'Channel', + status: 'Status', + priority: 'Priority', + link: 'Link', + 'next-action': 'Next Action', + 'last-version-told': 'Last Version Told', + notes: 'Notes', + }; + const fields = {}; + for (const [flag, col] of Object.entries(map)) { + if (flags[flag] !== undefined) fields[col] = flags[flag]; + } + if (flags['last-checked'] !== undefined) fields['Last Checked'] = flags['last-checked']; + if (flags.owned !== undefined) fields['Owned?'] = asBool(flags.owned); + return fields; +} + +// --- Output -------------------------------------------------------------------- + +const COLS = ['Key', 'Name', 'Status', 'Priority', 'Channel', 'Owned?', 'Claimed By', 'Last Checked', 'Next Action']; + +function printTable(records) { + if (!records.length) { + process.stdout.write('(no matching targets)\n'); + return; + } + const rows = records.map((r) => COLS.map((c) => { + const v = r.fields[c]; + if (v === true) return 'yes'; + if (v === undefined || v === false) return ''; + return String(v); + })); + const widths = COLS.map((c, i) => Math.min(40, Math.max(c.length, ...rows.map((row) => row[i].length)))); + const fmt = (cells) => cells.map((cell, i) => cell.slice(0, widths[i]).padEnd(widths[i])).join(' '); + process.stdout.write(`${fmt(COLS)}\n`); + process.stdout.write(`${widths.map((w) => '-'.repeat(w)).join(' ')}\n`); + for (const row of rows) process.stdout.write(`${fmt(row)}\n`); + process.stdout.write(`\n${records.length} target(s)\n`); +} + +// --- Commands ------------------------------------------------------------------ + +async function cmdList(flags) { + const clauses = []; + if (flags.status) clauses.push(`{Status}=${lit(flags.status)}`); + if (flags.priority) clauses.push(`{Priority}=${lit(flags.priority)}`); + if (flags.channel) clauses.push(`{Channel}=${lit(flags.channel)}`); + if (flags.owned) clauses.push('{Owned?}=1'); + const filterByFormula = clauses.length ? (clauses.length === 1 ? clauses[0] : `AND(${clauses.join(',')})`) : undefined; + const records = await getRecords(TARGETS, { filterByFormula }); + records.sort((a, b) => (a.fields.Key || '').localeCompare(b.fields.Key || '')); + if (flags.json) { + process.stdout.write(`${JSON.stringify(records.map((r) => r.fields), null, 2)}\n`); + } else { + printTable(records); + } +} + +async function cmdGet(positionals, flags) { + const key = positionals[0]; + if (!key) fail(EXIT.USAGE, 'usage: get '); + const rec = await resolveTarget(key); + if (flags.json) process.stdout.write(`${JSON.stringify(rec.fields, null, 2)}\n`); + else printTable([rec]); +} + +async function cmdClaim(positionals, flags) { + const key = positionals[0]; + const by = flags.by; + if (!key || !by || by === true) fail(EXIT.USAGE, 'usage: claim --by '); + const rec = await resolveTarget(key); + const holder = rec.fields['Claimed By']; + const at = rec.fields['Claimed At']; + const fresh = at && Date.now() - new Date(at).getTime() < CLAIM_TTL_MS; + if (holder && holder !== by && fresh) { + fail(EXIT.CLAIM_LOST, `Claim lost: ${key} is held by ${holder} (claimed ${at}).`); + } + await upsertTarget({ Key: key, 'Claimed By': by, 'Claimed At': new Date().toISOString() }); + // Confirm the write stuck and no one raced us in the same window. + await sleep(RECHECK_DELAY_MS); + const after = await resolveTarget(key); + if (after.fields['Claimed By'] !== by) { + fail(EXIT.CLAIM_LOST, `Claim lost: ${key} is now held by ${after.fields['Claimed By']}.`); + } + await appendLog({ target: key, workflow: by, action: 'Claimed' }); + process.stdout.write(`Claimed ${key} as ${by}.\n`); +} + +async function cmdRelease(positionals, flags) { + const key = positionals[0]; + const by = flags.by; + if (!key || !by || by === true) fail(EXIT.USAGE, 'usage: release --by '); + await upsertTarget({ Key: key, 'Claimed By': '', 'Claimed At': null }); + await appendLog({ target: key, workflow: by, action: 'Released Claim' }); + process.stdout.write(`Released ${key}.\n`); +} + +async function cmdUpsert(positionals, flags) { + const key = positionals[0]; + if (!key) fail(EXIT.USAGE, 'usage: upsert [--name ... --status ... ...]'); + const fields = { Key: key, ...fieldsFromFlags(flags) }; + await upsertTarget(fields); + process.stdout.write(`Upserted ${key}.\n`); +} + +async function cmdSetStatus(positionals) { + const [key, status] = positionals; + if (!key || !status) fail(EXIT.USAGE, 'usage: set-status '); + await upsertTarget({ Key: key, Status: status }); + await appendLog({ target: key, action: 'Status Change', result: `-> ${status}` }); + process.stdout.write(`Set ${key} status to ${status}.\n`); +} + +async function cmdLog(flags) { + const target = flags.target; + const action = flags.action; + if (!target || target === true || !action || action === true) { + fail(EXIT.USAGE, 'usage: log --target --workflow --action [--result T] [--link URL]'); + } + await appendLog({ + target, + workflow: flags.workflow === true ? '' : flags.workflow, + action, + result: flags.result === true ? undefined : flags.result, + link: flags.link === true ? undefined : flags.link, + }); + process.stdout.write(`Logged "${action}" for ${target}.\n`); +} + +// --- Dispatch ------------------------------------------------------------------ + +async function main() { + const [command, ...rest] = process.argv.slice(2); + const { positionals, flags } = parseArgs(rest); + switch (command) { + case 'list': return cmdList(flags); + case 'get': return cmdGet(positionals, flags); + case 'claim': return cmdClaim(positionals, flags); + case 'release': return cmdRelease(positionals, flags); + case 'upsert': return cmdUpsert(positionals, flags); + case 'set-status': return cmdSetStatus(positionals); + case 'log': return cmdLog(flags); + default: + process.stderr.write('Commands: list | get | claim | release | upsert | set-status | log\n'); + process.exit(command ? EXIT.USAGE : EXIT.OK); + } +} + +main().catch((err) => fail(EXIT.ERROR, err?.stack || String(err)));