@codedology/code-forge 0.2.2 → 0.3.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.
@@ -26,7 +26,7 @@ import { parse as parseYAML } from 'yaml';
26
26
  import { DEFAULT_CONFIG_FILENAME, loadConfigFile } from '../../config/load.mjs';
27
27
  import { refreshFromCliCaches } from '../../config/refresh.mjs';
28
28
  import { validateConfig } from '../../config/validate.mjs';
29
- import { describeOpItem, isOpRef, OP_MESSAGES, toOpRef } from '../../keys/onepassword.mjs';
29
+ import { describeOpItem, isOpRef, OP_APPROVE_NOTE, OP_MESSAGES, opItemIdFromInput, toOpRef } from '../../keys/onepassword.mjs';
30
30
  import { createDefaultKeyStore, resolveKey } from '../../keys/store.mjs';
31
31
  import { exec } from '../../util/exec.mjs';
32
32
  import { redact } from '../../util/redact.mjs';
@@ -261,7 +261,12 @@ export async function runInit(args, deps = {}) {
261
261
  const missing = [];
262
262
  let jevRef = null;
263
263
  let jevSource = 'none';
264
- const resolveOp = (/** @type {string} */ input) => toOpRef(input, deps.opExec ? { exec: deps.opExec } : {});
264
+ const resolveOp = (/** @type {string} */ input) => {
265
+ // printed at once (stderr), not buffered: the approval prompt appears while op waits
266
+ // only when op will really be called: an item ID or a 1Password link (an op:// ref is kept as is)
267
+ if (opItemIdFromInput(String(input ?? '')) !== null) stderr.write(`${OP_APPROVE_NOTE}\n`);
268
+ return toOpRef(input, deps.opExec ? { exec: deps.opExec } : {});
269
+ };
265
270
  /** @param {import('./steps.mjs').JevAnswer} a */
266
271
  const takeAnswer = (a) => {
267
272
  jevRef = a.ref;
@@ -11,9 +11,14 @@
11
11
  * reference is built.
12
12
  */
13
13
 
14
+ import { reportErrorKind } from '../util/error-kind.mjs';
14
15
  import { exec as realExec } from '../util/exec.mjs';
15
16
 
16
- export const OP_TIMEOUT_MS = 20_000;
17
+ // Long enough for a person to see and approve the 1Password prompt (Touch ID or password).
18
+ export const OP_TIMEOUT_MS = 60_000;
19
+
20
+ /** Shown just before a call that may make 1Password ask for approval. */
21
+ export const OP_APPROVE_NOTE = '1Password may ask you to approve access: check the 1Password window (Touch ID or password).';
17
22
 
18
23
  /** The 1Password failure kinds; agent JSON carries the kind. */
19
24
  export const OP_ERROR_KINDS = Object.freeze(['op_missing', 'op_locked', 'op_timeout', 'op_not_found', 'op_bad_output', 'op_no_field', 'op_bad_input', 'op_failed']);
@@ -184,10 +189,13 @@ export async function opRead(ref, { exec = realExec, timeoutMs = OP_TIMEOUT_MS }
184
189
  const { res, attempts } = await runOp(['op', 'read', ref], exec, timeoutMs);
185
190
  if (res.result !== 'ok') {
186
191
  const kind = classifyOpFailure(res);
192
+ reportErrorKind(kind); // the error log's kind if the verb then fails (B27)
187
193
  return { value: null, attempts, error: failureMessage(kind, res, `op read ${ref}`, '; check the reference'), kind };
188
194
  }
189
195
  const value = res.stdout.replace(/\r?\n$/, '');
190
- return value.length > 0 ? { value, attempts } : { value: null, attempts, error: OP_MESSAGES.op_bad_output, kind: 'op_bad_output' };
196
+ if (value.length > 0) return { value, attempts };
197
+ reportErrorKind('op_bad_output');
198
+ return { value: null, attempts, error: OP_MESSAGES.op_bad_output, kind: 'op_bad_output' };
191
199
  }
192
200
 
193
201
  /**
@@ -272,8 +280,10 @@ export async function toOpRef(input, opts = {}) {
272
280
  const text = typeof input === 'string' ? input.trim() : input;
273
281
  if (isOpRef(text)) return { ref: /** @type {string} */ (text) };
274
282
  const id = opItemIdFromInput(text);
275
- if (id !== null) return resolveOpItemRef(id, opts);
276
- return { ref: null, error: OP_MESSAGES.op_bad_input, kind: 'op_bad_input' };
283
+ /** @type {OpRefResult} */
284
+ const res = id !== null ? await resolveOpItemRef(id, opts) : { ref: null, error: OP_MESSAGES.op_bad_input, kind: 'op_bad_input' };
285
+ if (res.ref === null) reportErrorKind(res.kind); // the error log's kind if the verb then fails (B27)
286
+ return res;
277
287
  }
278
288
 
279
289
  /**
@@ -0,0 +1,266 @@
1
+ /**
2
+ * The AI cleaning pass of an error report (block B28): a second pass after the deterministic scrub,
3
+ * never the only one.
4
+ *
5
+ * One closed-book session through {@link spawnSession} (plan §5.2; skill/references/security.md
6
+ * §4): print mode, a fresh empty temp cwd, the packet on stdin, no tools — the `reviewer` role,
7
+ * whose closed-book shape is exactly that, at level L1 resolved the normal way (`resolveLevel` on
8
+ * the project's `.code-forge.yml`; outside a project, the shipped defaults of its provider or of
9
+ * `anthropic`). The model only LISTS what it would hide (`{items: [{text, kind, reason}]}`); this
10
+ * module replaces every exact substring itself, longest first, with `<kind>`, never inside one of
11
+ * the tool's own placeholders. An item whose text is not in the report (or is blank or shorter
12
+ * than 3 characters) is ignored. Anything but a valid answer
13
+ * — no CLI, login expired, a timeout, bad JSON — makes the pass `unavailable` with a plain reason.
14
+ */
15
+
16
+ import { randomBytes } from 'node:crypto';
17
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
18
+ import path from 'node:path';
19
+ import { resolveLevel } from '../config/known-ids.mjs';
20
+ import { defaultsForProvider } from '../config/defaults/index.mjs';
21
+ import { loadProjectConfig } from '../config/load.mjs';
22
+ import { SCRUB_PLACEHOLDERS } from '../util/error-log.mjs';
23
+ import { currentRunRoot } from '../util/tmp.mjs';
24
+ import { spawnSession } from './spawn.mjs';
25
+
26
+ /** The kinds an item may have; any other kind the model names counts as `other`. */
27
+ export const AI_KINDS = Object.freeze(['person', 'company', 'project', 'host', 'url', 'account', 'path', 'secret', 'other']);
28
+
29
+ /** Singular and plural for the cleaning summary. */
30
+ const KIND_LABELS = Object.freeze({
31
+ person: ['person', 'people'],
32
+ company: ['company', 'companies'],
33
+ project: ['project', 'projects'],
34
+ host: ['host', 'hosts'],
35
+ url: ['URL', 'URLs'],
36
+ account: ['account', 'accounts'],
37
+ path: ['path', 'paths'],
38
+ secret: ['secret', 'secrets'],
39
+ other: ['other item', 'other items'],
40
+ });
41
+
42
+ /** The whole packet the AI may see, in bytes (cost guard); a longer report is cut first. */
43
+ export const AI_MAX_BYTES = 16 * 1024;
44
+
45
+ /** Wall-clock cap of the AI session. */
46
+ export const AI_TIMEOUT_MS = 180_000;
47
+
48
+ /** An item shorter than this is ignored: replacing it would mangle ordinary words. */
49
+ export const MIN_ITEM_LENGTH = 3;
50
+
51
+ /** The answer schema (source form; `spawnSession` compiles it per provider). */
52
+ export const SCRUB_SCHEMA = Object.freeze({
53
+ type: 'object',
54
+ properties: {
55
+ items: {
56
+ type: 'array',
57
+ items: {
58
+ type: 'object',
59
+ properties: { text: { type: 'string' }, kind: { type: 'string' }, reason: { type: 'string' } },
60
+ required: ['text', 'kind', 'reason'],
61
+ additionalProperties: false,
62
+ },
63
+ },
64
+ },
65
+ required: ['items'],
66
+ additionalProperties: false,
67
+ });
68
+
69
+ /** The system prompt of the session. */
70
+ export const SCRUB_LENS =
71
+ 'You check an error report before it is posted publicly. You have no tools and no files; the report is in the message. ' +
72
+ 'You never rewrite the report: you only list what should be hidden. Answer once, with JSON only.';
73
+
74
+ /**
75
+ * @param {string} report - the title and body, already cleaned by the deterministic scrub.
76
+ * @returns {string} the packet.
77
+ */
78
+ export function buildScrubPrompt(report) {
79
+ return [
80
+ '# Find private details in this error report',
81
+ '',
82
+ 'Find anything in the text that could identify a person, a company, a private project, a host, an internal URL, an account, a path, or anything that looks like a secret or credential.',
83
+ 'Placeholders such as <project>, <slug>, <email>, <item-id>, op://<ref> and ~ are already cleaned: leave them out. Public names (code-forge, Node, GitHub, npm, an OS) are fine.',
84
+ 'Return JSON only, exactly this shape:',
85
+ '{ "items": [{ "text": "<exact substring>", "kind": "person|company|project|host|url|account|path|secret|other", "reason": "<short>" }] }',
86
+ 'Each "text" must be copied exactly from the report. Return { "items": [] } when nothing should be hidden.',
87
+ '',
88
+ '## Report',
89
+ '',
90
+ report,
91
+ '',
92
+ ].join('\n');
93
+ }
94
+
95
+ /** The report text that fits in the packet with the prompt around it, in bytes. */
96
+ export const AI_REPORT_MAX_BYTES = AI_MAX_BYTES - Buffer.byteLength(buildScrubPrompt(''));
97
+
98
+ /**
99
+ * @typedef {{text: string, kind: string}} ScrubItem
100
+ */
101
+
102
+ /**
103
+ * The usable items of an answer: a string `text` of at least {@link MIN_ITEM_LENGTH} characters
104
+ * that is not blank;
105
+ * an unknown `kind` becomes `other`.
106
+ * @param {unknown} answer
107
+ * @returns {ScrubItem[]|null} null when the answer is not `{items: [...]}`.
108
+ */
109
+ export function readItems(answer) {
110
+ if (!answer || typeof answer !== 'object' || !Array.isArray(/** @type {any} */ (answer).items)) return null;
111
+ /** @type {ScrubItem[]} */
112
+ const out = [];
113
+ for (const item of /** @type {any} */ (answer).items) {
114
+ if (!item || typeof item.text !== 'string' || item.text.trim().length === 0 || item.text.length < MIN_ITEM_LENGTH) continue;
115
+ const lower = typeof item.kind === 'string' ? item.kind.toLowerCase() : '';
116
+ const kind = AI_KINDS.includes(lower) ? lower : 'other';
117
+ out.push({ text: item.text, kind });
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /** The placeholders the tool itself writes: the deterministic scrub's and the AI pass's own. */
123
+ export const PROTECTED_PLACEHOLDERS = Object.freeze([...SCRUB_PLACEHOLDERS, ...AI_KINDS.map((k) => `<${k}>`)]);
124
+
125
+ /**
126
+ * Replace every exact occurrence of each item's text with `<kind>`, in one pass over each
127
+ * ORIGINAL text: match ranges are collected for every item, longest text first, then the output
128
+ * is built. Only the tool's own placeholders ({@link PROTECTED_PLACEHOLDERS}) are protected: a
129
+ * range that starts or ends inside one (an item "path" against `<path>`) is skipped, while a range
130
+ * that fully contains one is replaced as a whole (`acme-<project>-prod` → `<host>`). Any other
131
+ * `<…>` text (`<jdoe@acme-corp.com>`, `Map<AcmeBillingClient>`) is ordinary text. A range that
132
+ * overlaps one already taken is skipped. Counts are ranges replaced per kind; a missing text
133
+ * counts 0.
134
+ * @param {string[]} texts
135
+ * @param {ScrubItem[]} items
136
+ * @returns {{texts: string[], counts: Array<{kind: string, count: number}>}}
137
+ */
138
+ export function applyItems(texts, items) {
139
+ const sorted = [...items].sort((a, b) => b.text.length - a.text.length);
140
+ /** @type {Map<string, number>} */
141
+ const tally = new Map();
142
+ const out = texts.map((t) => {
143
+ /** @type {Array<[number, number]>} */
144
+ const guarded = [];
145
+ for (const p of PROTECTED_PLACEHOLDERS) {
146
+ for (let at = t.indexOf(p); at !== -1; at = t.indexOf(p, at + p.length)) guarded.push([at, at + p.length]);
147
+ }
148
+ /** @type {Array<{start: number, end: number, kind: string}>} */
149
+ const taken = [];
150
+ const blocked = (/** @type {number} */ a, /** @type {number} */ b) =>
151
+ // a range that is exactly one of our placeholders is never replaced (nothing new to hide)
152
+ guarded.some(([s, e]) => (a === s && b === e) || (a < e && s < b && !(a <= s && e <= b))) || taken.some((r) => a < r.end && r.start < b);
153
+ for (const { text, kind } of sorted) {
154
+ for (let at = t.indexOf(text); at !== -1; at = t.indexOf(text, at + 1)) {
155
+ const end = at + text.length;
156
+ if (blocked(at, end)) continue;
157
+ taken.push({ start: at, end, kind });
158
+ tally.set(kind, (tally.get(kind) ?? 0) + 1);
159
+ }
160
+ }
161
+ taken.sort((a, b) => a.start - b.start);
162
+ let built = '';
163
+ let pos = 0;
164
+ for (const r of taken) {
165
+ built += `${t.slice(pos, r.start)}<${r.kind}>`;
166
+ pos = r.end;
167
+ }
168
+ return built + t.slice(pos);
169
+ });
170
+ return { texts: out, counts: AI_KINDS.filter((k) => (tally.get(k) ?? 0) > 0).map((k) => ({ kind: k, count: /** @type {number} */ (tally.get(k)) })) };
171
+ }
172
+
173
+ /**
174
+ * "AI pass: 1 host, 1 company." / "AI pass: nothing found." — counts only, never the text.
175
+ * @param {Array<{kind: string, count: number}>} counts
176
+ * @returns {string}
177
+ */
178
+ export function describeAiCounts(counts) {
179
+ const parts = counts.filter((c) => c.count > 0).map((c) => {
180
+ const label = Object.hasOwn(KIND_LABELS, c.kind) ? KIND_LABELS[/** @type {keyof typeof KIND_LABELS} */ (c.kind)] : [c.kind, c.kind];
181
+ return `${c.count} ${c.count === 1 ? label[0] : label[1]}`;
182
+ });
183
+ return parts.length > 0 ? `AI pass: ${parts.join(', ')}.` : 'AI pass: nothing found.';
184
+ }
185
+
186
+ /** @param {{status: string, reason?: string|null}} res @returns {string} */
187
+ function plainReason(res) {
188
+ if (res.status === 'unavailable') {
189
+ if (res.reason === 'cli-missing') return 'the model CLI is not installed';
190
+ if (res.reason === 'login-expired') return 'the model CLI is not logged in';
191
+ if (res.reason === 'rate-limited' || res.reason === 'http-402') return 'the model is not available right now';
192
+ return 'the model is not available';
193
+ }
194
+ if (res.status === 'timeout') return 'it timed out';
195
+ if (res.status === 'invalid-output') return 'its answer was not valid JSON';
196
+ return 'the session failed';
197
+ }
198
+
199
+ /**
200
+ * The config L1 is resolved from: the project's when its L1 resolves, else the shipped defaults
201
+ * alone (of the project's provider, or `anthropic`; never the project's own levels mixed in).
202
+ * @param {string} cwd
203
+ * @returns {Promise<Record<string, any>>}
204
+ * @throws {Error} with a fixed message when the project config cannot be read.
205
+ */
206
+ async function scrubConfig(cwd) {
207
+ const unreadable = new Error('the project config could not be read');
208
+ let loaded;
209
+ try {
210
+ loaded = await loadProjectConfig(cwd);
211
+ } catch {
212
+ throw unreadable;
213
+ }
214
+ if (!loaded.ok) {
215
+ if (loaded.error !== 'not-found') throw unreadable;
216
+ return /** @type {Record<string, any>} */ (defaultsForProvider('anthropic'));
217
+ }
218
+ try {
219
+ resolveLevel(loaded.config, 'L1');
220
+ return loaded.config;
221
+ } catch {
222
+ const provider = typeof loaded.config?.provider === 'string' ? loaded.config.provider : 'anthropic';
223
+ return /** @type {Record<string, any>} */ (defaultsForProvider(provider) ?? defaultsForProvider('anthropic'));
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Run the AI pass over `report` (scrubbed, and cut to {@link AI_REPORT_MAX_BYTES}); a packet over
229
+ * {@link AI_MAX_BYTES} is refused, never sent.
230
+ * @param {{report: string, cwd: string, runRoot?: string, timeoutMs?: number}} opts
231
+ * @param {import('./spawn.mjs').SessionDeps} [deps]
232
+ * @returns {Promise<{ok: true, items: ScrubItem[]} | {ok: false, reason: string}>}
233
+ */
234
+ export async function runAiScrub(opts, deps = {}) {
235
+ const packet = buildScrubPrompt(opts.report);
236
+ if (Buffer.byteLength(packet) > AI_MAX_BYTES) return { ok: false, reason: 'the report is over 16 KB' };
237
+ let cfg;
238
+ try {
239
+ cfg = await scrubConfig(opts.cwd);
240
+ } catch (e) {
241
+ return { ok: false, reason: /** @type {Error} */ (e).message };
242
+ }
243
+ let dir = null;
244
+ try {
245
+ const root = opts.runRoot ?? currentRunRoot();
246
+ dir = path.join(root, 'scrub', `${Date.now()}-${randomBytes(4).toString('hex')}`);
247
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
248
+ const packetPath = path.join(dir, 'packet.md');
249
+ writeFileSync(packetPath, packet, { mode: 0o600 });
250
+ const res = await spawnSession(
251
+ { cfg, level: 'L1', role: 'reviewer', promptPath: packetPath, schema: SCRUB_SCHEMA, systemPromptText: SCRUB_LENS, runRoot: root, timeoutMs: opts.timeoutMs ?? AI_TIMEOUT_MS },
252
+ deps,
253
+ );
254
+ if (res.status !== 'ok') return { ok: false, reason: plainReason(res) };
255
+ const items = readItems(res.answer);
256
+ return items === null ? { ok: false, reason: 'its answer was not valid JSON' } : { ok: true, items };
257
+ } catch {
258
+ return { ok: false, reason: 'the session could not start' };
259
+ } finally {
260
+ try {
261
+ if (dir !== null) rmSync(dir, { recursive: true, force: true });
262
+ } catch {
263
+ // the packet dir lives in the run root, which the next sweep removes
264
+ }
265
+ }
266
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The one-line hint after a logged failure (B28). `bin/code-forge.mjs` calls {@link maybeHint}
3
+ * after the error log took a line; it prints {@link HINT_LINE} on stderr only when:
4
+ * - stderr is a terminal and no agent harness runs code-forge (agent mode: one of
5
+ * {@link AGENT_ENV_VARS}, the same names `src/install/agent-env.mjs` checks — copied here
6
+ * because the router may only load `src/util`; a test keeps the two lists equal);
7
+ * - the entry has a well-formed fingerprint (12 hex characters);
8
+ * - the exit is not 2 (a usage error says what to fix already);
9
+ * - the same fingerprint got no hint in the last 24 hours. The time of each hint is kept in
10
+ * `~/.code-forge/logs/hints.json` (`{"<fp>": "<ISO time>"}`, 0600, older ones dropped).
11
+ * Never throws; a broken state file only means the hint may show again.
12
+ */
13
+
14
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
15
+ import path from 'node:path';
16
+
17
+ export const HINT_LINE = 'code-forge: this error was saved to the local log. To send it to us: code-forge logs report\n';
18
+
19
+ /** A fingerprint: 12 lowercase hex characters. */
20
+ const FP = /^[0-9a-f]{12}$/;
21
+
22
+ /** At most one hint per fingerprint in this window. */
23
+ export const HINT_WINDOW_MS = 24 * 60 * 60 * 1000;
24
+
25
+ /** The agent-harness variables (same list and order as `src/install/agent-env.mjs`). */
26
+ export const AGENT_ENV_VARS = Object.freeze(['CLAUDECODE', 'CLAUDE_CODE', 'CURSOR_AGENT', 'AI_AGENT']);
27
+
28
+ /**
29
+ * @param {string|null|undefined} home
30
+ * @returns {string|null} the hint state file next to the error log, or null without a home.
31
+ */
32
+ export function hintStatePath(home) {
33
+ return typeof home === 'string' && home.length > 0 ? path.join(home, '.code-forge', 'logs', 'hints.json') : null;
34
+ }
35
+
36
+ /** @param {NodeJS.ProcessEnv} env */
37
+ function agentMode(env) {
38
+ return AGENT_ENV_VARS.some((name) => typeof env[name] === 'string' && /** @type {string} */ (env[name]).length > 0);
39
+ }
40
+
41
+ /**
42
+ * @param {object} o
43
+ * @param {{fp?: string}|null} o.entry - the entry the log just took (null: nothing was logged).
44
+ * @param {number} o.exit
45
+ * @param {boolean} o.isTTY - whether stderr is a terminal.
46
+ * @param {NodeJS.ProcessEnv} [o.env]
47
+ * @param {Date} [o.now]
48
+ * @param {{write: (s: string) => unknown}} [o.stderr]
49
+ * @returns {Promise<boolean>} whether the hint was printed.
50
+ */
51
+ export async function maybeHint(o) {
52
+ try {
53
+ const env = o.env ?? process.env;
54
+ const fp = o.entry?.fp;
55
+ if (typeof fp !== 'string' || !FP.test(fp) || o.exit === 2 || o.isTTY !== true || agentMode(env)) return false;
56
+ const file = hintStatePath(env.HOME);
57
+ if (file === null) return false;
58
+ const now = (o.now ?? new Date()).getTime();
59
+ /** @type {Record<string, unknown>} */
60
+ let state = {};
61
+ try {
62
+ const parsed = JSON.parse(await readFile(file, 'utf8'));
63
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) state = parsed;
64
+ } catch {
65
+ // no state yet, or a broken file: start over
66
+ }
67
+ const seen = state[fp];
68
+ const last = typeof seen === 'string' ? Date.parse(seen) : Number.NaN;
69
+ if (Number.isFinite(last) && now - last < HINT_WINDOW_MS && now >= last) return false;
70
+ /** @type {Record<string, string>} */
71
+ const kept = {};
72
+ for (const [key, ts] of Object.entries(state)) {
73
+ if (typeof ts !== 'string' || !FP.test(key)) continue;
74
+ const t = Date.parse(ts);
75
+ if (Number.isFinite(t) && now - t < HINT_WINDOW_MS && now >= t) kept[key] = ts;
76
+ }
77
+ kept[fp] = new Date(now).toISOString();
78
+ (o.stderr ?? process.stderr).write(HINT_LINE);
79
+ const tmp = `${file}.tmp-${process.pid}`;
80
+ try {
81
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
82
+ await writeFile(tmp, `${JSON.stringify(kept)}\n`, { encoding: 'utf8', mode: 0o600 });
83
+ await rename(tmp, file);
84
+ } catch {
85
+ // the hint was shown; only the 24-hour memory is lost (and no temp file is left)
86
+ await rm(tmp, { force: true }).catch(() => {});
87
+ }
88
+ return true;
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The error kind a verb reports for the error log (B27). A module that classifies a failure (for
3
+ * example a 1Password failure: `op_timeout`) calls `reportErrorKind(kind)`; the router clears it
4
+ * before a verb runs and takes the last one reported when the verb fails. A verb that reports
5
+ * nothing is logged as `error` (or `usage`/`crash`, see `bin/code-forge.mjs`).
6
+ */
7
+
8
+ /** A kind is a short snake_case word; anything else is ignored. */
9
+ const KIND = /^[a-z][a-z0-9_]{0,39}$/;
10
+
11
+ /** Kinds the router sets itself; a verb reporting one of these is ignored. */
12
+ const RESERVED = new Set(['crash', 'usage', 'error']);
13
+
14
+ /** @type {string|null} */
15
+ let current = null;
16
+
17
+ /** @param {unknown} kind */
18
+ export function reportErrorKind(kind) {
19
+ if (typeof kind === 'string' && KIND.test(kind) && !RESERVED.has(kind)) current = kind;
20
+ }
21
+
22
+ /** Forget any reported kind (the router calls this before a verb runs). */
23
+ export function clearErrorKind() {
24
+ current = null;
25
+ }
26
+
27
+ /** @returns {string|null} the last kind reported since the last clear; clears it. */
28
+ export function takeErrorKind() {
29
+ const kind = current;
30
+ current = null;
31
+ return kind;
32
+ }