@golden-frijoles/kit 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env node
2
+ // notify-setup.mjs — find your Telegram chat id, and send ONE test message, so notifications can be
3
+ // proven before anything scheduled depends on them (distribute-what-we-use S3.3).
4
+ //
5
+ // node scripts/notify-setup.mjs --chat-id list the chats your bot has seen (getUpdates)
6
+ // node scripts/notify-setup.mjs --test send one test message to every destination that is configured
7
+ // node scripts/notify-setup.mjs --test --telegram ...to Telegram only (needs the token AND a chat id)
8
+ // node scripts/notify-setup.mjs --test --slack ...to Slack only (needs SLACK_WEBHOOK_URL)
9
+ //
10
+ // ── Where the secrets come from ──────────────────────────────────────────────────────────────────
11
+ // TELEGRAM_BOT_TOKEN and SLACK_WEBHOOK_URL are read from the environment, then from `.env.local` at the project
12
+ // root. They are NEVER printed and NEVER written anywhere: not to the screen, not to a file, not into an error
13
+ // message (every message that could echo one is scrubbed first). The Telegram chat id is not a secret, so it is
14
+ // printed. The chat id for `--test` is `telegram.chatId` from `reporting.config.json` (with the gitignored
15
+ // `reporting.config.local.json` laid over it, see lib/reporting-config.mjs) or, failing that, TELEGRAM_CHAT_ID (environment or `.env.local`, for a project with no reporting config yet).
16
+ //
17
+ // ── What the scheduled reports actually do (deviation 5) ─────────────────────────────────────────
18
+ // The standup, weekly recap and PMO report post to TELEGRAM only, and nothing reads `reporting.destination`.
19
+ // Slack here covers this script's test message and ad-hoc sends via slack-notify.mjs — this script does not make
20
+ // the scheduled reports post to Slack, and says so rather than implying it.
21
+ //
22
+ // ── Exit codes (LEARNINGS: "could not look" is its own outcome, never the failure code) ──────────
23
+ // 0 did what was asked 1 the API answered and refused (or --chat-id found no chat)
24
+ // 2 bad usage 3 could not look: a token / webhook / chat id is absent, or the network failed
25
+ //
26
+ // Every network path takes an injectable `fetch`; the specs inject one and never touch a real API.
27
+ // Zero deps — Node 18+.
28
+
29
+ import { existsSync, readFileSync } from 'node:fs';
30
+ import { join, resolve } from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { envFileValue } from './lib/jev.mjs';
33
+ import { projectRoot } from './lib/project-root.mjs';
34
+ import { chatIdFor, loadReportingConfig, ReportingConfigError } from './lib/reporting-config.mjs';
35
+ import { send as sendSlack } from './slack-notify.mjs';
36
+
37
+ export const EXIT = { ok: 0, refused: 1, usage: 2, couldNotLook: 3 };
38
+ export const TEST_TEXT = 'Golden Frijoles test message: notifications are wired up. You can ignore this one.';
39
+ const TELEGRAM_API = 'https://api.telegram.org';
40
+ const TIMEOUT_MS = 10_000;
41
+
42
+ /** Pure — the flags. `{ error }` on anything unknown, so a typo never silently means "do nothing". */
43
+ export function parseArgs(argv) {
44
+ const opts = { chatId: false, test: false, telegram: false, slack: false };
45
+ for (const a of argv) {
46
+ if (a === '--chat-id') opts.chatId = true;
47
+ else if (a === '--test') opts.test = true;
48
+ else if (a === '--telegram') opts.telegram = true;
49
+ else if (a === '--slack') opts.slack = true;
50
+ else return { error: `unknown argument ${a}` };
51
+ }
52
+ if (opts.chatId === opts.test) return { error: 'give exactly one of --chat-id or --test' };
53
+ if ((opts.telegram || opts.slack) && !opts.test) return { error: '--telegram and --slack go with --test' };
54
+ return { opts };
55
+ }
56
+
57
+ /** A secret from the environment, then `.env.local` at the project root (then the cwd). null when absent. */
58
+ export function readEnvValue(
59
+ name,
60
+ {
61
+ env = process.env,
62
+ root = projectRoot(),
63
+ cwd = process.cwd(),
64
+ read = readFileSync,
65
+ exists = existsSync,
66
+ } = {}
67
+ ) {
68
+ if (env[name]?.trim()) return env[name].trim();
69
+ for (const dir of [root, cwd]) {
70
+ const p = join(dir, '.env.local');
71
+ if (!exists(p)) continue;
72
+ try {
73
+ const v = envFileValue(read(p, 'utf8'), name);
74
+ if (v) return v;
75
+ } catch {
76
+ /* an unreadable env file is no value from it */
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /** Pure — remove every secret from a string that is about to be shown. */
83
+ export const scrub = (text, secrets) =>
84
+ secrets.filter(Boolean).reduce((t, s) => t.split(s).join('[hidden]'), String(text ?? ''));
85
+
86
+ /** Pure — the distinct chats in a getUpdates result, oldest first: `{ id, type, title }`. */
87
+ export function chatsFromUpdates(updates) {
88
+ const seen = new Map();
89
+ for (const u of updates ?? []) {
90
+ for (const key of [
91
+ 'message',
92
+ 'edited_message',
93
+ 'channel_post',
94
+ 'edited_channel_post',
95
+ 'my_chat_member',
96
+ 'chat_member',
97
+ ]) {
98
+ const chat = u?.[key]?.chat;
99
+ if (!chat || chat.id == null || seen.has(chat.id)) continue;
100
+ const person = [chat.first_name, chat.last_name].filter(Boolean).join(' ');
101
+ seen.set(chat.id, {
102
+ id: chat.id,
103
+ type: chat.type ?? 'unknown',
104
+ title: chat.title || (chat.username ? `@${chat.username}` : person) || '(no title)',
105
+ });
106
+ }
107
+ }
108
+ return [...seen.values()];
109
+ }
110
+
111
+ /**
112
+ * Pure — why `--chat-id` found nothing, given getWebhookInfo's answer. `webhook` is the result object, or null when
113
+ * getWebhookInfo itself could not be read (then both causes are named, since they cannot be told apart).
114
+ */
115
+ export function emptyChatExplanation(webhook) {
116
+ const privacy =
117
+ 'A bot in a GROUP only sees commands and @mentions while group privacy is on: ask @BotFather (/mybots → your bot → ' +
118
+ 'Bot Settings → Group Privacy → Turn off), or send the bot a /command in the group.';
119
+ const webhookCause = (url) =>
120
+ `A webhook is set on this bot (on ${url}), and while one is, getUpdates returns nothing. Remove it with ` +
121
+ `${TELEGRAM_API}/bot<YOUR-TOKEN>/deleteWebhook (open that URL with your real token), message the bot again, ` +
122
+ 'then re-run. Only remove it if nothing else of yours relies on that webhook.';
123
+ const nobody =
124
+ 'Nobody has messaged the bot yet: open a chat with it in Telegram, press Start (or send any message), then re-run.';
125
+ // Host only: a webhook URL often carries a secret path segment (#190 review).
126
+ const host = (u) => {
127
+ try {
128
+ return new URL(u).host;
129
+ } catch {
130
+ return 'a URL';
131
+ }
132
+ };
133
+ if (webhook && webhook.url)
134
+ return `No chats found. Cause: a webhook is set.\n ${webhookCause(host(webhook.url))}\n ${privacy}`;
135
+ if (webhook) return `No chats found. No webhook is set, so: ${nobody}\n ${privacy}`;
136
+ return `No chats found, and getWebhookInfo could not be read, so the cause is one of two.\n 1. ${webhookCause('unknown')}\n 2. ${nobody}\n ${privacy}`;
137
+ }
138
+
139
+ /** POST a Bot API method. Returns { ok, status, body } and never throws. `secrets` are scrubbed from any failure text. */
140
+ export async function telegramCall(method, token, payload, fetchImpl = fetch) {
141
+ try {
142
+ const res = await fetchImpl(`${TELEGRAM_API}/bot${token}/${method}`, {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ body: JSON.stringify(payload ?? {}),
146
+ signal: AbortSignal.timeout(TIMEOUT_MS),
147
+ });
148
+ let body = {};
149
+ try {
150
+ body = await res.json();
151
+ } catch {
152
+ /* a non-JSON error body: the status still says what happened */
153
+ }
154
+ return { ok: res.ok && body?.ok === true, status: res.status, body };
155
+ } catch (err) {
156
+ return {
157
+ ok: false,
158
+ status: 0,
159
+ network: true,
160
+ body: { description: scrub(err?.message ?? err, [token]) },
161
+ };
162
+ }
163
+ }
164
+
165
+ const apiError = (r) =>
166
+ `${r.body?.description ?? `HTTP ${r.status}`}${r.body?.error_code ? ` (error_code ${r.body.error_code})` : ''}`;
167
+
168
+ /** `--chat-id`. */
169
+ async function chatIdCommand({ token, fetchImpl, stdout, stderr }) {
170
+ if (!token) {
171
+ stderr(
172
+ 'could not look: TELEGRAM_BOT_TOKEN is not set. Create a bot with @BotFather (/newbot), then put TELEGRAM_BOT_TOKEN=… in .env.local.\n'
173
+ );
174
+ return EXIT.couldNotLook;
175
+ }
176
+ const updates = await telegramCall('getUpdates', token, { timeout: 0 }, fetchImpl);
177
+ if (updates.network) {
178
+ stderr(`could not look: ${updates.body.description}\n`);
179
+ return EXIT.couldNotLook;
180
+ }
181
+ // Telegram answers 409 to getUpdates while a webhook is active: that is the cause, not an unrelated refusal.
182
+ const webhookActive = updates.status === 409;
183
+ if (!updates.ok && !webhookActive) {
184
+ stderr(`Telegram rejected getUpdates: ${apiError(updates)}\n`);
185
+ return EXIT.refused;
186
+ }
187
+ const chats = webhookActive ? [] : chatsFromUpdates(updates.body?.result);
188
+ if (chats.length) {
189
+ stdout(`Found ${chats.length} chat(s) your bot has seen:\n`);
190
+ for (const c of chats) stdout(` ${String(c.id).padEnd(16)} ${c.type.padEnd(11)} ${c.title}\n`);
191
+ stdout(
192
+ 'Put the id in reporting.config.local.json (a public repo) or reporting.config.json as {"telegram":{"chatId":"<id>"}}.\n'
193
+ );
194
+ return EXIT.ok;
195
+ }
196
+ const info = await telegramCall('getWebhookInfo', token, {}, fetchImpl);
197
+ stdout(`${emptyChatExplanation(info.ok ? (info.body?.result ?? {}) : null)}\n`);
198
+ return EXIT.refused;
199
+ }
200
+
201
+ /** The Telegram chat id `--test` would use, and where it came from — or a reason there is none. */
202
+ export function resolveChatId({ env, load = loadReportingConfig }) {
203
+ try {
204
+ const id = chatIdFor(load(), null, env);
205
+ return id
206
+ ? { id: String(id), from: 'reporting config / TELEGRAM_CHAT_ID' }
207
+ : { reason: 'no telegram.chatId in the reporting config and no TELEGRAM_CHAT_ID' };
208
+ } catch (err) {
209
+ if (!(err instanceof ReportingConfigError)) throw err;
210
+ return env.TELEGRAM_CHAT_ID
211
+ ? { id: String(env.TELEGRAM_CHAT_ID), from: 'TELEGRAM_CHAT_ID' }
212
+ : { reason: err.message.split('\n')[0] };
213
+ }
214
+ }
215
+
216
+ /** `--test`. */
217
+ async function testCommand({ opts, token, webhook, env, chatIdEnv, fetchImpl, load, stdout, stderr }) {
218
+ // TELEGRAM_CHAT_ID may live in .env.local too: a project with no reporting.config.json yet has nowhere else for it.
219
+ const chat = resolveChatId({ env: { ...env, TELEGRAM_CHAT_ID: env.TELEGRAM_CHAT_ID || chatIdEnv }, load });
220
+ // Half-configured Telegram (a token without a chat id, or the reverse) is still WANTED, so the missing half
221
+ // is named — never silently skipped while Slack succeeds (the scheduled reports post to Telegram only).
222
+ const wantTelegram = opts.telegram || (!opts.slack && (Boolean(token) || Boolean(chat.id)));
223
+ const wantSlack = opts.slack || (!opts.telegram && Boolean(webhook));
224
+ const missing = [];
225
+ if (wantTelegram && !token) missing.push('TELEGRAM_BOT_TOKEN is not set (put it in .env.local)');
226
+ if (wantTelegram && !chat.id)
227
+ missing.push(`no Telegram chat id: ${chat.reason} (run --chat-id, then set telegram.chatId)`);
228
+ if (wantSlack && !webhook) missing.push('SLACK_WEBHOOK_URL is not set (put it in .env.local)');
229
+ if (!wantTelegram && !wantSlack)
230
+ missing.push('nothing is configured: set TELEGRAM_BOT_TOKEN + a chat id, and/or SLACK_WEBHOOK_URL');
231
+ if (missing.length) {
232
+ for (const m of missing) stderr(`could not look: ${m}\n`);
233
+ return EXIT.couldNotLook;
234
+ }
235
+
236
+ // One precedence whatever the send order: a refusal (a real configuration answer) outranks could-not-look
237
+ // (weather), which outranks ok (#190 review).
238
+ const RANK = { [EXIT.ok]: 0, [EXIT.couldNotLook]: 1, [EXIT.refused]: 2 };
239
+ const worse = (a, b) => (RANK[b] > RANK[a] ? b : a);
240
+ let code = EXIT.ok;
241
+ if (wantTelegram) {
242
+ const r = await telegramCall(
243
+ 'sendMessage',
244
+ token,
245
+ { chat_id: chat.id, text: TEST_TEXT, disable_web_page_preview: true },
246
+ fetchImpl
247
+ );
248
+ if (r.ok) stdout(`Telegram: sent to chat ${chat.id} (from ${chat.from}): "${TEST_TEXT}"\n`);
249
+ else if (r.network) {
250
+ stderr(`could not look: Telegram unreachable: ${r.body.description}\n`);
251
+ code = worse(code, EXIT.couldNotLook);
252
+ } else {
253
+ stderr(`Telegram rejected the message to chat ${chat.id}: ${apiError(r)}\n`);
254
+ code = worse(code, EXIT.refused);
255
+ }
256
+ }
257
+ if (wantSlack) {
258
+ const r = await sendSlack(webhook, { text: TEST_TEXT }, fetchImpl);
259
+ if (r.ok) stdout(`Slack: sent to the incoming webhook in SLACK_WEBHOOK_URL: "${TEST_TEXT}"\n`);
260
+ else if (r.status === 0) {
261
+ stderr(`could not look: Slack unreachable: ${scrub(r.body, [webhook])}\n`);
262
+ code = worse(code, EXIT.couldNotLook);
263
+ } else {
264
+ stderr(`Slack rejected the message: status=${r.status} body=${scrub(r.body, [webhook])}\n`);
265
+ code = worse(code, EXIT.refused);
266
+ }
267
+ }
268
+ if (code === EXIT.ok)
269
+ stdout('Scheduled reports post to Telegram only today; Slack covers this test and ad-hoc sends.\n');
270
+ return code;
271
+ }
272
+
273
+ /** The CLI with every side effect injected; returns the exit code. */
274
+ export async function run(argv, io = {}) {
275
+ const {
276
+ env = process.env,
277
+ fetchImpl = fetch,
278
+ readValue = (name) => readEnvValue(name, { env }),
279
+ load = loadReportingConfig,
280
+ stdout = (t) => process.stdout.write(t),
281
+ stderr = (t) => process.stderr.write(t),
282
+ } = io;
283
+ const parsed = parseArgs(argv);
284
+ if (parsed.error) {
285
+ stderr(
286
+ `notify-setup: ${parsed.error}\nusage: notify-setup.mjs --chat-id | --test [--telegram] [--slack]\n`
287
+ );
288
+ return EXIT.usage;
289
+ }
290
+ const token = readValue('TELEGRAM_BOT_TOKEN');
291
+ const webhook = readValue('SLACK_WEBHOOK_URL');
292
+ // Everything shown goes through the scrubbing writers, so a secret echoed by any layer cannot reach the screen.
293
+ const secrets = [token, webhook];
294
+ const out = (t) => stdout(scrub(t, secrets));
295
+ const err = (t) => stderr(scrub(t, secrets));
296
+ if (parsed.opts.chatId) return chatIdCommand({ token, fetchImpl, stdout: out, stderr: err });
297
+ return testCommand({
298
+ opts: parsed.opts,
299
+ token,
300
+ webhook,
301
+ env,
302
+ chatIdEnv: readValue('TELEGRAM_CHAT_ID'),
303
+ fetchImpl,
304
+ load,
305
+ stdout: out,
306
+ stderr: err,
307
+ });
308
+ }
309
+
310
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
311
+ if (isMain)
312
+ run(process.argv.slice(2))
313
+ .then((code) => process.exit(code))
314
+ .catch((e) => {
315
+ process.stderr.write(`notify-setup: ${e.message}\n`);
316
+ process.exit(1);
317
+ });
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ // routine-bootstrap.mjs — render one reviewed routine prompt with this project's declared fill-ins.
3
+ //
4
+ // The prompts deliberately mix project values with values a routine learns while it runs. Replacing every
5
+ // angle-bracket string would turn `<date>` or `<PR#>` into stale prose before the first run, so this table is
6
+ // the one authority for the distinction. A missing project value refuses before stdout: a partly-filled prompt
7
+ // pasted into /schedule is a cloud-side configuration bug that is hard to see and harder to unwind.
8
+ //
9
+ // node scripts/routine-bootstrap.mjs <name>
10
+ // node scripts/routine-bootstrap.mjs --list
11
+ //
12
+ // Exit codes: 0 rendered, 1 missing fill-ins/configuration, 2 bad routine name or usage. Zero deps; Node 18+.
13
+
14
+ import { existsSync, readdirSync, realpathSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { readSection } from './lib/config.mjs';
18
+ import { loadPromptBody } from './lib/cross-agent-cli.mjs';
19
+
20
+ const HERE = dirname(fileURLToPath(import.meta.url));
21
+ export const ROUTINES_DIR = join(HERE, 'routines');
22
+
23
+ // Includes every token in the seven prompts and their runbook. `source: 'runbook'` records tokens that are
24
+ // documentation examples rather than substitutions in a rendered prompt; keeping them here makes a new token a
25
+ // visible classification decision. The three descriptive TEMPLATE FILL-IN markers have separate keys because a
26
+ // single generic replacement would leave unrelated framework, rules, and deploy advice in the wrong prompt.
27
+ export const ROUTINE_TOKENS = Object.freeze([
28
+ { token: '<root-repo>', kind: 'fill-in', key: 'root-repo' },
29
+ { token: '<app-repo>', kind: 'fill-in', key: 'app-repo' },
30
+ { token: '<api-repo>', kind: 'fill-in', key: 'api-repo', source: 'runbook' },
31
+ { token: '<your-org>', kind: 'fill-in', key: 'your-org' },
32
+ { token: '<appDir>', kind: 'fill-in', key: 'appDir' },
33
+ { token: '<PROD_URL>', kind: 'fill-in', key: 'PROD_URL' },
34
+ { token: '<PROD_DOMAIN>', kind: 'fill-in', key: 'PROD_DOMAIN' },
35
+ { token: '<vercelProject>', kind: 'fill-in', key: 'vercelProject' },
36
+ { token: '<stalePreviewAgeDays>', kind: 'fill-in', key: 'stalePreviewAgeDays' },
37
+ { token: '<rule one>', kind: 'fill-in', key: 'rule one' },
38
+ { token: '<rule two>', kind: 'fill-in', key: 'rule two' },
39
+ {
40
+ token: "TEMPLATE FILL-IN: name your framework's async gotchas here, e.g. which request APIs are async",
41
+ kind: 'fill-in',
42
+ key: 'async-gotchas',
43
+ },
44
+ {
45
+ token:
46
+ 'TEMPLATE FILL-IN: list them here,\nnumbered, exactly as AGENTS.md states them, so this reviewer checks the same rules the builders follow.\nThe shape the origin project\'s list took, for reference: which system owns which data ("the commerce\nengine owns all orders and payments — never a second table for them"), which layer is never replaced\n("the auth provider is the auth layer — no custom auth pages"), which interfaces must stay accurate (an\nAPI/agent surface), and the copy/locale rule for user-visible text.',
47
+ kind: 'fill-in',
48
+ key: 'rules-context',
49
+ },
50
+ {
51
+ token: "TEMPLATE FILL-IN: the frontend's deploy path, e.g. build → hosting service",
52
+ kind: 'fill-in',
53
+ key: 'deploy-path',
54
+ },
55
+ { token: '<date>', kind: 'runtime' },
56
+ { token: '<id>', kind: 'runtime' },
57
+ { token: '<trigger-id>', kind: 'runtime' },
58
+ { token: '<name>', kind: 'runtime' },
59
+ { token: '<your-file>', kind: 'runtime' },
60
+ { token: '<repo>', kind: 'runtime' },
61
+ { token: '<PR#>', kind: 'runtime' },
62
+ { token: '<PR>', kind: 'runtime' },
63
+ { token: '<N>', kind: 'runtime' },
64
+ { token: '<one-line reason>', kind: 'runtime' },
65
+ { token: '<the files you touched>', kind: 'runtime' },
66
+ { token: '<the spec>', kind: 'runtime' },
67
+ { token: '<your message>', kind: 'runtime' },
68
+ { token: '<[a-z-]*-repo>', kind: 'runtime', source: 'runbook' },
69
+ { token: 'TEMPLATE FILL-IN', kind: 'runtime', source: 'runbook' },
70
+ ]);
71
+
72
+ export function routineNames(dir = ROUTINES_DIR, readDir = readdirSync) {
73
+ // A missing routines/ is "no routines", reported by the CLI's usage line — never a stack trace (agy on #191).
74
+ if (!existsSync(dir)) return [];
75
+ return readDir(dir)
76
+ .filter((name) => name.endsWith('.prompt.md'))
77
+ .map((name) => name.replace(/\.prompt\.md$/, ''))
78
+ .sort();
79
+ }
80
+
81
+ const present = (value) => (typeof value === 'string' ? value.trim().length > 0 : value != null);
82
+
83
+ export function fillInsForPrompt(body) {
84
+ return ROUTINE_TOKENS.filter((entry) => entry.kind === 'fill-in' && body.includes(entry.token));
85
+ }
86
+
87
+ export function renderPrompt(body, values) {
88
+ let rendered = body;
89
+ for (const entry of fillInsForPrompt(body)) {
90
+ if (present(values?.[entry.key])) rendered = rendered.split(entry.token).join(String(values[entry.key]));
91
+ }
92
+ return rendered;
93
+ }
94
+
95
+ export function missingFillIns(body, values) {
96
+ return fillInsForPrompt(body).filter((entry) => !present(values?.[entry.key]));
97
+ }
98
+
99
+ /** Pure: placeholders the table does not know — `<TEMPLATE FILL-IN…`/`TEMPLATE FILL-IN:` markers, and `<token>`s. */
100
+ export function unclassifiedTokens(text) {
101
+ const known = new Set(ROUTINE_TOKENS.map((e) => e.token));
102
+ const found = new Set();
103
+ // Remove every CLASSIFIED marker first, so a new marker beside a known one is still seen (#191 review).
104
+ let rest = text;
105
+ for (const e of ROUTINE_TOKENS) if (e.kind === 'fill-in') rest = rest.split(e.token).join('');
106
+ if (/<TEMPLATE FILL-IN|TEMPLATE FILL-IN:/.test(rest)) found.add('TEMPLATE FILL-IN');
107
+ for (const m of text.matchAll(/<([A-Za-z][A-Za-z0-9_ #.-]{0,40})>/g)) if (!known.has(m[0])) found.add(m[0]);
108
+ return [...found];
109
+ }
110
+
111
+ export function promptPath(name, dir = ROUTINES_DIR) {
112
+ return join(dir, `${name}.prompt.md`);
113
+ }
114
+
115
+ /** CLI seam: all I/O is injected so the refusal contract can be tested without a checkout. */
116
+ export function runRoutineBootstrap(
117
+ argv,
118
+ {
119
+ root,
120
+ routinesDir = ROUTINES_DIR,
121
+ names = routineNames(routinesDir),
122
+ readSectionFor = readSection,
123
+ loadBody = loadPromptBody,
124
+ out = (text) => process.stdout.write(text),
125
+ err = (text) => process.stderr.write(text),
126
+ } = {}
127
+ ) {
128
+ if (argv.length === 1 && argv[0] === '--list') {
129
+ out(`${names.join('\n')}\n`);
130
+ return 0;
131
+ }
132
+ if (argv.length !== 1 || argv[0].startsWith('-')) {
133
+ err(`Usage: node scripts/routine-bootstrap.mjs <name> | --list\nValid routines: ${names.join(', ')}\n`);
134
+ return 2;
135
+ }
136
+ const name = argv[0];
137
+ if (!names.includes(name)) {
138
+ err(`Unknown routine "${name}". Valid routines: ${names.join(', ')}\n`);
139
+ return 2;
140
+ }
141
+
142
+ // CRLF-safe: the multi-line fill-in markers are matched with \n (agy on #191).
143
+ const body = loadBody(promptPath(name, routinesDir)).replace(/\r\n/g, '\n');
144
+ let values;
145
+ try {
146
+ const section = readSectionFor('routines', root === undefined ? {} : { root });
147
+ values = section.raw ?? {};
148
+ } catch (error) {
149
+ err(`routine-bootstrap: could not read routines from golden-frijoles.config.json: ${error.message}\n`);
150
+ return 1;
151
+ }
152
+ const missing = missingFillIns(body, values);
153
+ if (missing.length) {
154
+ err(
155
+ 'routine-bootstrap: refusing to print a partly-filled prompt. Set every missing golden-frijoles.config.json routines key:\n'
156
+ );
157
+ for (const entry of missing) err(` ${entry.key} (for ${entry.token})\n`);
158
+ return 1;
159
+ }
160
+ const rendered = renderPrompt(body, values);
161
+ // Grep-to-zero, whatever the table knows (check-template-drift's syntax): a TEMPLATE FILL-IN marker, or an
162
+ // angle-bracket token nobody classified, refuses — a new token must be a visible decision, never a pass.
163
+ const unclassified = unclassifiedTokens(rendered);
164
+ if (unclassified.length) {
165
+ err(
166
+ `routine-bootstrap: refusing — unclassified placeholder(s) left in "${name}": ${unclassified.join(', ')}. ` +
167
+ 'Classify each in ROUTINE_TOKENS (fill-in or runtime).\n'
168
+ );
169
+ return 1;
170
+ }
171
+ const leftovers = fillInsForPrompt(rendered);
172
+ if (leftovers.length) {
173
+ err(
174
+ `routine-bootstrap: refusing to print a prompt with unresolved fill-ins: ${leftovers.map((entry) => entry.token).join(', ')}\n`
175
+ );
176
+ return 1;
177
+ }
178
+ out(`${rendered}\n`);
179
+ return 0;
180
+ }
181
+
182
+ const isMain = (() => {
183
+ if (!process.argv[1]) return false;
184
+ try {
185
+ // Symlinked kit entrypoints otherwise compare a link on one side to a real module path on the other and silently do nothing.
186
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
187
+ } catch {
188
+ return false;
189
+ }
190
+ })();
191
+
192
+ // No explicit root: readSection's default (projectRoot) honours GF_PROJECT_ROOT and walks up from a subdirectory,
193
+ // like every other kit script (#191 review).
194
+ if (isMain) process.exitCode = runRoutineBootstrap(process.argv.slice(2));