community-asks.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* community-asks.mjs — decides whether career-ops asks the user for support,
* and which ask.
*
* career-ops never phones home, so the only signals the maintainers get are
* the ones people choose to give. It asks for them in words, rarely, and at
* the right moment (AGENTS.md → "Community asks"):
*
* star after the first saved evaluation report
* manifesto after five reports, at least seven days after the star ask
* (the Hired Wall ask lives in hired-share.mjs, at a hire)
*
* Each ask is shown at most once, and never two in one run.
*
* node community-asks.mjs next prints one line or nothing; always exits 0
* node community-asks.mjs open <ask> opens the ask's page, after the user said yes
* node community-asks.mjs status the state as JSON
* node community-asks.mjs off never ask again
* CAREER_OPS_NO_ASKS=1 the same, without writing anything
*
* Guarantees (tests/community-asks.test.mjs):
* - At most once. `next` claims data/.community-asks/<ask> with an exclusive
* create BEFORE it prints. When the marker exists or cannot be written, it
* prints nothing. The markers are user-layer data (DATA_CONTRACT.md): an
* update, or a reinstall that keeps the data, never asks again.
* - Never unattended: silent under CI, batch workers (CAREER_OPS_HEADLESS,
* CAREER_OPS_BATCH_STATE) and Claude Code's headless entrypoints. Never
* decided by isTTY: an agent's shell has no TTY, so a TTY check would hide
* the ask from almost every user.
* - No network. The script never makes a request. `open` hands one fixed URL
* to the system's browser opener, and the user still clicks the star or
* signs themselves: nothing is ever starred or signed for them (GitHub
* Acceptable Use Policies, section 4, forbid automated starring).
*/
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, statSync, writeSync } from 'fs';
import { join, resolve } from 'path';
import { execFileSync } from 'child_process';
import { isMainModule } from './lib/is-main-module.mjs';
export const STAR_URL = 'https://github.com/career-ops-hq/career-ops';
export const MANIFESTO_URL = 'https://career-ops.org/manifesto?utm_source=ask';
export const MANIFESTO_MIN_REPORTS = 5;
export const MANIFESTO_MIN_DAYS_AFTER_STAR = 7;
export const ASKS = Object.freeze(['star', 'manifesto']);
/** From this many reports the star ask names the count: the user has run career-ops for a while. */
export const VETERAN_MIN_REPORTS = 5;
// The drawing for the star ask ("yours is missing"). `next` prints it in a fenced
// block of its own so the agent copies it exactly. Star ask only, never the manifesto.
// Each emoji closes its row: a terminal that draws it one column wider shifts nothing.
// Never U+2B50 (⭐): some terminals (Claude Code on macOS among them) draw it as a
// text "*" plus a space. 🌟, ✨ and 💫 draw as emoji.
export const STAR_ART = Object.freeze([
" + . '",
" o 🌟",
" / \\",
"*-----* *-----* ✨",
" `. .'",
" * *",
" / .-*-. \\",
" *-' '-* 💫",
]);
const REPORT_FILE = /^\d{3,}-.+\.md$/;
const DAY_MS = 86_400_000;
const CI_VARS = Object.freeze([
'GITHUB_ACTIONS', 'GITLAB_CI', 'BUILDKITE', 'CIRCLECI', 'TRAVIS', 'TF_BUILD', 'JENKINS_URL',
'TEAMCITY_VERSION', 'BITBUCKET_BUILD_NUMBER', 'CODEBUILD_BUILD_ID', 'DRONE', 'APPVEYOR',
]);
const USAGE = `Usage:
node community-asks.mjs next print the one ask that is due, or nothing (always exits 0)
node community-asks.mjs open <ask> open the page of an ask the user said yes to (star | manifesto)
node community-asks.mjs status show the state as JSON
node community-asks.mjs off never ask again (CAREER_OPS_NO_ASKS=1 does the same without writing)
Options:
--root <dir> data root (default: the configured career-ops data root)
--now <iso> clock override, for tests`;
/**
* The line the agent says. The star line says career-ops "collects nothing
* about you": that holds while LEGAL_DISCLAIMER.md says it collects no
* analytics, telemetry or usage data, and the test ties the two together. A
* change that starts sending any data, even opt-in, revisits this sentence in
* the same PR.
*
* @param {string} ask - 'star' or 'manifesto'.
* @param {{reports?: number}} [context]
* @returns {string}
*/
export function askText(ask, { reports = 0 } = {}) {
if (ask === 'star') {
const opening = reports >= VETERAN_MIN_REPORTS
? `You've run ${reports} evaluations with career-ops so far. It's free and collects nothing about you, `
: 'If this report saved you some time: career-ops is free and collects nothing about you, ';
return opening
+ "so we can't tell whether it helped. A Star on GitHub 🌟 (the button at the top right of the page) "
+ 'is one of the few ways we find out, '
+ 'and it helps the next job seeker find career-ops. '
+ `Say "yes" and I'll open the page (${STAR_URL}). I won't ask for a star again.`;
}
if (ask === 'manifesto') {
return "The way you've been searching has a name: CareerOps, and it has a manifesto. "
+ 'If it speaks for you, sign it (your name goes on the public wall). '
+ `Say "yes" and I'll open it (${MANIFESTO_URL}). I won't ask about the manifesto again.`;
}
return '';
}
/**
* What `next` prints for an ask: the star ask comes with its drawing in a
* fenced block of its own, for the agent to copy exactly; the manifesto ask is
* the sentence alone.
*
* @param {string} ask
* @param {{reports?: number}} [context]
* @returns {string}
*/
export function askOutput(ask, context = {}) {
const text = askText(ask, context);
if (!text) return '';
return ask === 'star' ? `\`\`\`\n${STAR_ART.join('\n')}\n\`\`\`\n${text}` : text;
}
const isOn = (value) => value !== undefined && value !== null && value !== ''
&& value !== '0' && String(value).toLowerCase() !== 'false';
/**
* Why this run must not ask, or null when it may.
*
* @param {NodeJS.ProcessEnv} [env]
* @returns {string|null}
*/
export function unattendedReason(env = process.env) {
if (isOn(env.CAREER_OPS_NO_ASKS)) return 'CAREER_OPS_NO_ASKS is set';
if (isOn(env.CAREER_OPS_HEADLESS)) return 'CAREER_OPS_HEADLESS is set';
if (env.CAREER_OPS_BATCH_STATE) return 'this is a batch worker (CAREER_OPS_BATCH_STATE is set)';
if (isOn(env.CI)) return 'CI is set';
const ci = CI_VARS.find((name) => isOn(env[name]));
if (ci) return `${ci} is set`;
const entrypoint = String(env.CLAUDE_CODE_ENTRYPOINT || '');
if (entrypoint.startsWith('sdk-') || entrypoint === 'mcp') return `headless Claude Code (${entrypoint})`;
return null;
}
/**
* Number of evaluation reports (`{###}-{slug}.md`) in a reports directory.
*
* @param {string} reportsDir
* @returns {number}
*/
export function countReports(reportsDir) {
try {
return readdirSync(reportsDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && REPORT_FILE.test(entry.name)).length;
} catch {
return 0;
}
}
/**
* When an ask was shown, or null when it never was. A marker that exists but
* cannot be read still counts as shown: unreadable must never mean "ask again".
*
* @param {string} asksDir
* @param {string} name
* @returns {Date|null}
*/
function shownAt(asksDir, name) {
const file = join(asksDir, name);
if (!existsSync(file)) return null;
try {
const at = Date.parse(readFileSync(file, 'utf8').trim());
if (Number.isFinite(at)) return new Date(at);
} catch { /* fall through to the file time */ }
try { return statSync(file).mtime; } catch { return new Date(0); }
}
/**
* The ask that is due, or null. Pure: the caller reads the state.
*
* @param {{reports?: number, star?: Date|null, manifesto?: Date|null, off?: boolean,
* legacyManifesto?: boolean, now?: Date}} state
* @returns {'star'|'manifesto'|null}
*/
export function decideAsk({ reports = 0, star = null, manifesto = null, off = false, legacyManifesto = false, now = new Date() } = {}) {
if (off) return null;
if (!star) return reports >= 1 ? 'star' : null;
if (manifesto || legacyManifesto) return null;
const daysSinceStar = (now.getTime() - star.getTime()) / DAY_MS;
return reports >= MANIFESTO_MIN_REPORTS && daysSinceStar >= MANIFESTO_MIN_DAYS_AFTER_STAR ? 'manifesto' : null;
}
/**
* Claim an ask: create its marker with an exclusive create. Only the process
* that creates it may print, so two sessions at once still ask once.
*
* @param {string} asksDir
* @param {string} name
* @param {Date} [now]
* @returns {boolean} true when this call created the marker.
*/
export function claim(asksDir, name, now = new Date()) {
try {
mkdirSync(asksDir, { recursive: true });
const fd = openSync(join(asksDir, name), 'wx');
try { writeSync(fd, `${now.toISOString()}\n`); } finally { closeSync(fd); }
return true;
} catch {
return false;
}
}
/**
* Everything decideAsk needs, read from a data root.
*
* @param {string} root
* @param {NodeJS.ProcessEnv} env
* @param {Date} now
*/
export function readState(root, env = process.env, now = new Date()) {
const asksDir = join(root, 'data', '.community-asks');
const reportsDir = resolve(env.CAREER_OPS_REPORTS_DIR || join(root, 'reports'));
// scan.mjs used to show the manifesto once in a terminal and leave this file
// in the folder it ran from. Whoever saw it is not asked again.
const legacyManifesto = [root, process.cwd()].some((dir) => existsSync(join(dir, '.manifesto-noted')));
return {
asksDir,
reportsDir,
reports: countReports(reportsDir),
star: shownAt(asksDir, 'star'),
manifesto: shownAt(asksDir, 'manifesto'),
off: existsSync(join(asksDir, 'off')),
legacyManifesto,
now,
};
}
function openInBrowser(url) {
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
// Bounded: on Linux xdg-open can hand the URL to a handler that waits, and the
// agent's shell would hang after the user said yes. On timeout the URL is printed.
try { execFileSync(cmd, args, { stdio: 'ignore', timeout: 10_000, windowsHide: true }); return true; } catch { return false; }
}
function parseArgs(argv) {
const flags = { root: null, now: null, help: false };
const positional = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--help' || arg === '-h') flags.help = true;
else if (arg === '--root') flags.root = argv[++i] ?? null;
else if (arg.startsWith('--root=')) flags.root = arg.slice('--root='.length);
else if (arg === '--now') flags.now = argv[++i] ?? null;
else if (arg.startsWith('--now=')) flags.now = arg.slice('--now='.length);
else positional.push(arg);
}
return { flags, positional };
}
async function defaultRoot() {
// Loaded lazily: `next` must stay silent and exit 0 even if the resolver
// throws (for example, an unsafe data root), and the tests pass --root.
const { getCareerOpsRoot } = await import('./path-resolver.mjs');
const { resolveWorkspaceRootFor } = await import('./tracker-utils.mjs');
return resolveWorkspaceRootFor(getCareerOpsRoot());
}
/**
* @param {string[]} argv
* @param {NodeJS.ProcessEnv} env
* @returns {Promise<number>} exit code
*/
export async function main(argv = process.argv.slice(2), env = process.env) {
const { flags, positional } = parseArgs(argv);
const [command = 'next', target] = positional;
if (flags.help) { process.stdout.write(`${USAGE}\n`); return 0; }
const parsedNow = flags.now ? new Date(flags.now) : new Date();
const now = Number.isFinite(parsedNow.getTime()) ? parsedNow : new Date();
if (command === 'next') {
try {
if (unattendedReason(env)) return 0;
const root = resolve(flags.root || await defaultRoot());
const state = readState(root, env, now);
const ask = decideAsk(state);
if (ask && claim(state.asksDir, ask, now)) process.stdout.write(`${askOutput(ask, state)}\n`);
} catch { /* an ask is never worth breaking the user's run */ }
return 0;
}
if (command === 'open') {
const url = target === 'star' ? STAR_URL : target === 'manifesto' ? MANIFESTO_URL : null;
if (!url) { process.stderr.write(`community-asks: open needs star or manifesto\n${USAGE}\n`); return 1; }
const opened = !unattendedReason(env) && openInBrowser(url);
process.stdout.write(opened ? `Opened ${url} in your browser.\n` : `Open this page: ${url}\n`);
return 0;
}
if (command === 'status') {
const root = resolve(flags.root || await defaultRoot());
const state = readState(root, env, now);
const iso = (date) => (date ? date.toISOString() : null);
process.stdout.write(`${JSON.stringify({
root,
reportsDir: state.reportsDir,
reports: state.reports,
shown: { star: iso(state.star), manifesto: iso(state.manifesto) },
off: state.off,
legacyManifesto: state.legacyManifesto,
unattended: unattendedReason(env),
due: state.off ? null : decideAsk(state),
}, null, 2)}\n`);
return 0;
}
if (command === 'off') {
const root = resolve(flags.root || await defaultRoot());
const asksDir = join(root, 'data', '.community-asks');
if (claim(asksDir, 'off', now) || existsSync(join(asksDir, 'off'))) {
process.stdout.write("Community asks are off: career-ops won't ask for a star or a signature again. "
+ `Delete ${join(asksDir, 'off')} to undo.\n`);
return 0;
}
process.stderr.write(`community-asks: could not write ${join(asksDir, 'off')}; set CAREER_OPS_NO_ASKS=1 instead.\n`);
return 1;
}
process.stderr.write(`community-asks: unknown command "${command}"\n${USAGE}\n`);
return 1;
}
if (isMainModule(import.meta.url)) {
main().then(
(code) => { process.exitCode = code; },
(err) => { process.stderr.write(`community-asks: ${err.message}\n`); process.exitCode = 1; },
);
}