forgedock 1.0.8 → 1.0.10

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,211 @@
1
+ /**
2
+ * init-enrich-api.mjs — Anthropic API enrichment backend for ForgeDock init.
3
+ *
4
+ * Implements the same enrich(ConfigDraft) contract as the skill backend so the
5
+ * selection ladder in forgedock.mjs can treat both backends interchangeably.
6
+ *
7
+ * Uses Node.js built-in fetch (Node 18+) — no SDK dependency required.
8
+ * Reads ANTHROPIC_API_KEY from the environment; cleanly skips enrichment with
9
+ * a helpful message when the key is absent, returning the original draft.
10
+ */
11
+
12
+ import { dim, yellow, RESET } from "./tui.mjs";
13
+
14
+ /** Anthropic API endpoint for Messages. */
15
+ const ANTHROPIC_MESSAGES_URL = "https://api.anthropic.com/v1/messages";
16
+
17
+ /** Model used for config enrichment — cost-effective, fast, fits the task. */
18
+ const ENRICH_MODEL = "claude-haiku-4-5";
19
+
20
+ /**
21
+ * System prompt defining the enrich(ConfigDraft) contract for the API backend.
22
+ *
23
+ * IMPORTANT: This prompt is for a stateless Anthropic Messages API call.
24
+ * The model has NO filesystem access, NO gh CLI, NO shell, and NO external
25
+ * tool calls of any kind. Enrichment is limited strictly to what can be
26
+ * reasoned from the ConfigDraft JSON provided in the user message.
27
+ */
28
+ const SYSTEM_PROMPT =
29
+ "You are the init-enrich API backend for ForgeDock. " +
30
+ "You receive a ConfigDraft JSON object and return an enriched version. " +
31
+ "\n\n" +
32
+ "EXECUTION ENVIRONMENT CONSTRAINTS:\n" +
33
+ "- You have NO filesystem access and cannot scan codebases.\n" +
34
+ "- You have NO gh CLI and cannot query GitHub.\n" +
35
+ "- You have NO external tool calls of any kind.\n" +
36
+ "- You may ONLY reason from the ConfigDraft JSON provided.\n" +
37
+ "\n" +
38
+ "ENRICHMENT SCOPE (what you MAY improve):\n" +
39
+ "- review.tech_stack: infer from project.description and paths if recognizable patterns appear.\n" +
40
+ "- review.context: improve wording or add standard context based on the project description.\n" +
41
+ "- verification.health_patterns: suggest standard patterns for known frameworks.\n" +
42
+ "\n" +
43
+ "STRICTLY PROHIBITED — set confidence to 'low' and do NOT invent values for:\n" +
44
+ "- project_board.project_id (must match ^PVT_[A-Za-z0-9_=-]+$ — you cannot verify these).\n" +
45
+ "- project_board.field_ids.* (PVTSSF_ strings — you cannot verify these).\n" +
46
+ "- project_board.project_number (you cannot query the GitHub Projects API).\n" +
47
+ "- repos.satellites (you cannot list or verify satellite repositories).\n" +
48
+ "Never invent a PVT_ or PVTSSF_ string. If you cannot verify a field, set confidence to 'low'.\n" +
49
+ "\n" +
50
+ "OUTPUT RULES:\n" +
51
+ "- Return ONLY the enriched ConfigDraft as a valid JSON object.\n" +
52
+ "- Every leaf must have shape { value, confidence, source, why }.\n" +
53
+ "- Do not modify project, paths, branches, or meta sections.\n" +
54
+ "- Output the JSON object alone with no surrounding prose.";
55
+
56
+ /**
57
+ * Extract and parse the enriched ConfigDraft JSON from a backend response string.
58
+ *
59
+ * Backends may emit human-readable prose before and/or after the JSON blob.
60
+ * This function finds the outermost JSON object in the output using a
61
+ * string-aware balanced-brace scanner: it tracks brace depth while skipping
62
+ * over string literal contents (including backslash-escaped characters), so
63
+ * braces inside JSON string values do not affect depth tracking and trailing
64
+ * prose with braces does not extend the match past the correct closing '}'.
65
+ * Falls back to the original draft if extraction or parsing fails.
66
+ *
67
+ * @param {string} output - Raw text from the API response content block
68
+ * @param {object} draft - Original ConfigDraft (returned on failure)
69
+ * @returns {object} Enriched ConfigDraft, or original draft on failure
70
+ */
71
+ export function parseEnrichedDraft(output, draft) {
72
+ if (!output || typeof output !== "string") return draft;
73
+
74
+ // Find the first '{' then walk forward tracking brace depth, skipping over
75
+ // string literal contents (with backslash-escape handling) so that:
76
+ // - Braces inside JSON string values (e.g. Go template syntax in
77
+ // project.description, Windows paths) do not affect depth.
78
+ // - Trailing prose containing '{' or '}' after the JSON blob does not
79
+ // extend the extraction past the correct top-level closing '}'.
80
+ const start = output.indexOf("{");
81
+ if (start === -1) return draft;
82
+
83
+ let depth = 0;
84
+ let inString = false;
85
+ let escape = false;
86
+
87
+ for (let i = start; i < output.length; i++) {
88
+ const ch = output[i];
89
+ if (escape) {
90
+ escape = false;
91
+ continue;
92
+ }
93
+ if (ch === "\\" && inString) {
94
+ escape = true;
95
+ continue;
96
+ }
97
+ if (ch === '"') {
98
+ inString = !inString;
99
+ continue;
100
+ }
101
+ if (inString) continue;
102
+ if (ch === "{") {
103
+ depth++;
104
+ } else if (ch === "}") {
105
+ depth--;
106
+ if (depth === 0) {
107
+ // Found the closing brace of the top-level object — parse the candidate.
108
+ try {
109
+ const enriched = JSON.parse(output.slice(start, i + 1));
110
+ // Basic sanity check: must have the required top-level sections and
111
+ // each section must be a plain non-null object. The typeof+null guard
112
+ // rejects null and primitives; Array.isArray() additionally rejects
113
+ // arrays since typeof [] === 'object' would otherwise pass them.
114
+ if (
115
+ typeof enriched.project !== "object" || enriched.project === null || Array.isArray(enriched.project) ||
116
+ typeof enriched.paths !== "object" || enriched.paths === null || Array.isArray(enriched.paths) ||
117
+ typeof enriched.branches !== "object" || enriched.branches === null || Array.isArray(enriched.branches) ||
118
+ typeof enriched.meta !== "object" || enriched.meta === null || Array.isArray(enriched.meta)
119
+ ) {
120
+ return draft;
121
+ }
122
+ return enriched;
123
+ } catch {
124
+ return draft;
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ // No complete top-level JSON object found.
131
+ return draft;
132
+ }
133
+
134
+ /**
135
+ * Enrich a ConfigDraft by calling the Anthropic Messages API directly.
136
+ *
137
+ * This is the api backend for the init-enrich interface. It implements the same
138
+ * enrich(ConfigDraft) contract as the skill backend so the selection ladder can
139
+ * treat both interchangeably.
140
+ *
141
+ * When ANTHROPIC_API_KEY is absent, logs a helpful message and returns the
142
+ * original draft unchanged so the caller can fall through to the deterministic
143
+ * baseline.
144
+ *
145
+ * @param {object} draft - ConfigDraft from detectConfig()
146
+ * @returns {Promise<object>} Enriched ConfigDraft, or the original draft on failure
147
+ */
148
+ export async function enrich(draft) {
149
+ const apiKey = process.env.ANTHROPIC_API_KEY;
150
+
151
+ if (!apiKey) {
152
+ console.error(
153
+ ` ${yellow("[!]")} No ANTHROPIC_API_KEY set — skipping API enrichment.` +
154
+ ` ${dim("Set ANTHROPIC_API_KEY to enable BYO-key enrichment.")}${RESET}`,
155
+ );
156
+ return draft;
157
+ }
158
+
159
+ try {
160
+ const draftJson = JSON.stringify(draft, null, 2);
161
+
162
+ const response = await fetch(ANTHROPIC_MESSAGES_URL, {
163
+ method: "POST",
164
+ headers: {
165
+ "Content-Type": "application/json",
166
+ "x-api-key": apiKey,
167
+ "anthropic-version": "2023-06-01",
168
+ },
169
+ body: JSON.stringify({
170
+ model: ENRICH_MODEL,
171
+ max_tokens: 4096,
172
+ system: SYSTEM_PROMPT,
173
+ messages: [
174
+ {
175
+ role: "user",
176
+ content: draftJson,
177
+ },
178
+ ],
179
+ }),
180
+ });
181
+
182
+ if (!response.ok) {
183
+ throw new Error(`Anthropic API returned ${response.status}`);
184
+ }
185
+
186
+ const data = await response.json();
187
+ if (data?.stop_reason === "max_tokens" && process.env.FORGEDOCK_DEBUG) {
188
+ console.error(
189
+ ` ${dim("[debug]")} api enrichment: response truncated by max_tokens — JSON parse may fail, falling back to baseline draft.`,
190
+ );
191
+ }
192
+ const text =
193
+ data?.content?.[0]?.type === "text" ? data.content[0].text : "";
194
+ return parseEnrichedDraft(text, draft);
195
+ } catch (err) {
196
+ const errCategory = err.status
197
+ ? `HTTP ${err.status}`
198
+ : err.code
199
+ ? err.code
200
+ : err.message;
201
+ console.error(
202
+ ` ${yellow("[!]")} API enrichment unavailable: ${errCategory} — falling back to baseline draft.${RESET}`,
203
+ );
204
+ if (process.env.FORGEDOCK_DEBUG) {
205
+ console.error(
206
+ ` ${dim("[debug]")} api enrichment failed: ${err.message}`,
207
+ );
208
+ }
209
+ return draft;
210
+ }
211
+ }
@@ -0,0 +1,358 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * registry.mjs — Per-directory ForgeDock state registry.
4
+ *
5
+ * Resolves, for any directory, whether ForgeDock is *managed* there
6
+ * (a forge.yaml or .forgedock marker is present) and whether the user has
7
+ * explicitly *opted out* via the central registry file.
8
+ *
9
+ * Exports:
10
+ * resolveState(dir) → 'managed-active' | 'managed-optedout' | 'unmanaged'
11
+ * setOptOut(dir, optedOut) → Promise<void> (adds/removes dir from opt-out set)
12
+ * nudgeSeen(dir) → boolean (true if nudge was already shown for dir)
13
+ * markNudgeSeen(dir) → Promise<void> (records that nudge was shown for dir)
14
+ *
15
+ * Registry file: ~/.claude/forgedock/registry.json
16
+ * Registry schema:
17
+ * {
18
+ * "version": 1,
19
+ * "optedOut": {
20
+ * "/absolute/path/to/dir": { "at": "<ISO-8601 timestamp>" }
21
+ * },
22
+ * "nudgeSeen": {
23
+ * "/absolute/path/to/dir": { "at": "<ISO-8601 timestamp>" }
24
+ * }
25
+ * }
26
+ *
27
+ * State model:
28
+ * - A directory is **managed** iff it contains `forge.yaml` OR a `.forgedock` marker file.
29
+ * - A directory is **opted-out** iff its resolved absolute path appears in the
30
+ * `optedOut` map of the registry.
31
+ * - Opt-out wins over managed: a managed+opted-out directory is silenced.
32
+ * - Missing or corrupt registry.json is treated as an empty opt-out set — the
33
+ * registry always fails open (never blocks a Claude Code session).
34
+ * - A nudge is shown at most once per unmanaged directory; `nudgeSeen` tracks
35
+ * which directories have already received the nudge.
36
+ *
37
+ * Contract guarantees:
38
+ * - Safe: every try/catch degrades gracefully; resolveState never throws
39
+ * - Isolated: imports only Node builtins (os, path, fs)
40
+ * - Atomic writes: registry.json is written via a .tmp file + renameSync
41
+ * - Testable: inject `dir` argument to point at fixture directories
42
+ */
43
+
44
+ import os from "os";
45
+ import { resolve, join } from "path";
46
+ import {
47
+ existsSync,
48
+ readFileSync,
49
+ writeFileSync,
50
+ renameSync,
51
+ realpathSync,
52
+ } from "fs";
53
+ import { mkdir } from "fs/promises";
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Constants
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Cross-platform user home directory.
61
+ * Mirrors the HOME resolution pattern used in forgedock.mjs.
62
+ */
63
+ const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
64
+
65
+ /**
66
+ * Directory that holds ForgeDock's per-user runtime state files.
67
+ * Created on first registry write if absent (mode 0o700).
68
+ */
69
+ const REGISTRY_DIR = join(HOME, ".claude", "forgedock");
70
+
71
+ /**
72
+ * Absolute path to the registry JSON file.
73
+ */
74
+ const REGISTRY_PATH = join(REGISTRY_DIR, "registry.json");
75
+
76
+ /**
77
+ * Empty registry structure — returned whenever the file is missing or corrupt.
78
+ * Using a factory function avoids shared mutable state across calls.
79
+ *
80
+ * @returns {{ version: number, optedOut: Record<string, { at: string }>, nudgeSeen: Record<string, { at: string }> }}
81
+ */
82
+ function emptyRegistry() {
83
+ return { version: 1, optedOut: {}, nudgeSeen: {} };
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Internal helpers
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /**
91
+ * Resolve and normalize a directory path for use as a registry key.
92
+ *
93
+ * First resolves the path to an absolute path with `resolve()`, then
94
+ * dereferences any symbolic links with `realpathSync.native()` so that a
95
+ * project entered via a symlinked path and the same project entered via its
96
+ * real path produce the same registry key. A try/catch fallback to the
97
+ * `resolve()` result ensures fail-open behaviour for non-existent or
98
+ * inaccessible paths (ENOENT, EACCES, EPERM).
99
+ *
100
+ * On Windows only, the drive letter of the resulting canonical path is
101
+ * lowercased (the character before the first `:`). This ensures that
102
+ * `C:\Users\foo` and `c:\Users\foo` hash to the same key, since NTFS is
103
+ * case-insensitive but JavaScript string comparison is not.
104
+ *
105
+ * On POSIX systems the canonical path is returned as-is; POSIX filesystems
106
+ * are case-sensitive by convention and no normalization is needed.
107
+ *
108
+ * Only the drive letter is lowercased — the rest of the path is preserved
109
+ * verbatim so that intentional casing in directory names is not altered.
110
+ *
111
+ * @param {string} dir - Directory path (absolute or relative).
112
+ * @returns {string} Normalized canonical absolute path suitable for use as a registry key.
113
+ */
114
+ function normalizeDir(dir) {
115
+ const abs = resolve(dir);
116
+ // Dereference symbolic links so that a symlinked project path and its
117
+ // real path produce the same registry key. Fall back to the resolve()
118
+ // result for paths that do not exist yet (e.g. ENOENT) or are not
119
+ // accessible (EACCES/EPERM) — preserves the fail-open contract.
120
+ // realpathSync.native uses the OS-native implementation (available since
121
+ // Node 9.2) and avoids extra JS stat syscalls vs the JS fallback.
122
+ let canonical;
123
+ try {
124
+ canonical = realpathSync.native(abs);
125
+ } catch {
126
+ canonical = abs;
127
+ }
128
+ // On Windows, drive letters vary in casing (C:\ vs c:\). Normalize to
129
+ // lowercase so registry lookups are case-insensitive for drive letters.
130
+ // Detect: canonical[1] === ':' is the Windows drive-letter pattern (e.g. C:\).
131
+ if (
132
+ process.platform === "win32" &&
133
+ canonical.length >= 2 &&
134
+ canonical[1] === ":"
135
+ ) {
136
+ return canonical[0].toLowerCase() + canonical.slice(1);
137
+ }
138
+ return canonical;
139
+ }
140
+
141
+ /**
142
+ * Read and parse the registry file.
143
+ *
144
+ * Fail-open: any read or parse error returns an empty registry rather than
145
+ * throwing. This ensures that a corrupt or missing file never blocks a
146
+ * Claude Code session.
147
+ *
148
+ * @returns {{ version: number, optedOut: Record<string, { at: string }>, nudgeSeen: Record<string, { at: string }> }}
149
+ */
150
+ function readRegistry() {
151
+ try {
152
+ const raw = readFileSync(REGISTRY_PATH, "utf-8");
153
+ const parsed = JSON.parse(raw);
154
+ // Basic schema validation — must be an object with an optedOut map
155
+ if (
156
+ parsed &&
157
+ typeof parsed === "object" &&
158
+ parsed.optedOut &&
159
+ typeof parsed.optedOut === "object"
160
+ ) {
161
+ // Ensure nudgeSeen is present (may be absent in older registry files)
162
+ if (!parsed.nudgeSeen || typeof parsed.nudgeSeen !== "object") {
163
+ parsed.nudgeSeen = {};
164
+ }
165
+ return parsed;
166
+ }
167
+ // Schema mismatch — treat as empty (fail open)
168
+ return emptyRegistry();
169
+ } catch {
170
+ // File missing, unreadable, or invalid JSON — all treated as empty
171
+ return emptyRegistry();
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Module-level write queue — serializes concurrent registry mutations so that
177
+ * two callers (e.g. setOptOut + markNudgeSeen in the same tick) never race and
178
+ * produce a last-write-wins corruption. Each enqueued mutation waits for the
179
+ * previous one to resolve before executing. Errors are swallowed per the
180
+ * existing best-effort contract so a failed write never blocks the next caller.
181
+ *
182
+ * The queue serializes the FULL read-modify-write cycle, not just the disk
183
+ * write. Each task reads the freshest on-disk state inside the critical section,
184
+ * applies the caller-supplied mutation, then writes atomically. This prevents
185
+ * the snapshot-before-enqueue race where two concurrent callers both read stale
186
+ * state and the second enqueued write silently stomps the first one's change.
187
+ * <!-- fix: forge#438 -->
188
+ */
189
+ let _writeQueue = Promise.resolve();
190
+
191
+ /**
192
+ * Enqueue a read-modify-write mutation against the registry.
193
+ *
194
+ * Accepts a `mutate` function rather than a pre-read data snapshot. The
195
+ * mutation is deferred until it reaches the head of the serial queue, at
196
+ * which point it reads the freshest on-disk registry state, applies the
197
+ * mutation, and writes the result atomically. This guarantees that concurrent
198
+ * callers each see the previous caller's changes rather than racing on a
199
+ * shared stale snapshot.
200
+ *
201
+ * Best-effort: errors inside the mutation or the disk write are silently
202
+ * suppressed so callers are never blocked.
203
+ *
204
+ * @param {(registry: { version: number, optedOut: Record<string, { at: string }>, nudgeSeen: Record<string, { at: string }> }) => void} mutate
205
+ * Mutation callback. Called with the current registry object; should
206
+ * modify it in place. Return value is ignored. Callbacks may have side
207
+ * effects (e.g. reading the clock) — they are not required to be pure.
208
+ * @returns {Promise<void>}
209
+ */
210
+ async function writeRegistry(mutate) {
211
+ // Chain onto the existing queue; swallow errors so callers are never blocked
212
+ _writeQueue = _writeQueue
213
+ .then(() => _doWriteRegistryWith(mutate))
214
+ .catch(() => {});
215
+ return _writeQueue;
216
+ }
217
+
218
+ /**
219
+ * Inner implementation — performs the atomic read-modify-write cycle.
220
+ * Called exclusively through writeRegistry's queue chain.
221
+ *
222
+ * Reads the current registry from disk (fail-open), applies the caller's
223
+ * mutation function, then writes the result via a .tmp file + renameSync.
224
+ *
225
+ * @param {(registry: { version: number, optedOut: Record<string, { at: string }>, nudgeSeen: Record<string, { at: string }> }) => void} mutate
226
+ * Mutation callback — same contract as `writeRegistry`. Modifies the
227
+ * registry object in place; return value is ignored.
228
+ * @returns {Promise<void>}
229
+ */
230
+ async function _doWriteRegistryWith(mutate) {
231
+ try {
232
+ await mkdir(REGISTRY_DIR, { recursive: true, mode: 0o700 });
233
+ // Read the freshest on-disk state inside the critical section so that
234
+ // concurrent callers each build on the previous caller's committed write.
235
+ const registry = readRegistry();
236
+ mutate(registry);
237
+ const tmp = REGISTRY_PATH + ".tmp";
238
+ writeFileSync(tmp, JSON.stringify(registry, null, 2) + "\n", {
239
+ encoding: "utf-8",
240
+ });
241
+ renameSync(tmp, REGISTRY_PATH);
242
+ } catch {
243
+ // Best-effort — a failed write is non-fatal; the registry remains as-is
244
+ }
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Public API
249
+ // ---------------------------------------------------------------------------
250
+
251
+ /**
252
+ * Resolve the ForgeDock state for a given directory.
253
+ *
254
+ * State matrix:
255
+ *
256
+ * | forge.yaml | .forgedock | in optedOut | Result |
257
+ * |:----------:|:----------:|:-----------:|:-------------------|
258
+ * | true | any | false | managed-active |
259
+ * | true | any | true | managed-optedout |
260
+ * | false | true | false | managed-active |
261
+ * | false | true | true | managed-optedout |
262
+ * | false | false | any | unmanaged |
263
+ *
264
+ * Opt-out wins over managed: a directory that has a marker but is also listed
265
+ * in the registry's optedOut map is reported as managed-optedout.
266
+ *
267
+ * Missing or corrupt registry.json is treated as an empty opt-out set —
268
+ * resolveState never throws.
269
+ *
270
+ * @param {string} dir - Absolute path to the directory to resolve.
271
+ * @returns {'managed-active' | 'managed-optedout' | 'unmanaged'}
272
+ */
273
+ export function resolveState(dir) {
274
+ const absDir = normalizeDir(dir);
275
+
276
+ // Check for managed markers (forge.yaml or .forgedock)
277
+ const hasForgeYaml = existsSync(join(absDir, "forge.yaml"));
278
+ const hasMarker = existsSync(join(absDir, ".forgedock"));
279
+ const isManaged = hasForgeYaml || hasMarker;
280
+
281
+ if (!isManaged) {
282
+ return "unmanaged";
283
+ }
284
+
285
+ // Directory is managed — check if opted out
286
+ const registry = readRegistry();
287
+ const isOptedOut = Object.prototype.hasOwnProperty.call(
288
+ registry.optedOut,
289
+ absDir,
290
+ );
291
+
292
+ return isOptedOut ? "managed-optedout" : "managed-active";
293
+ }
294
+
295
+ /**
296
+ * Add or remove a directory from the opt-out set in the registry.
297
+ *
298
+ * The directory path is normalized with normalizeDir() before being stored,
299
+ * ensuring consistent key lookup regardless of trailing slashes, symlinks, or
300
+ * drive-letter casing on Windows.
301
+ *
302
+ * @param {string} dir - Absolute path to the directory.
303
+ * @param {boolean} optedOut - true to opt out, false to remove from opt-out set.
304
+ * @returns {Promise<void>}
305
+ */
306
+ export async function setOptOut(dir, optedOut) {
307
+ const absDir = normalizeDir(dir);
308
+ // Pass a mutation closure rather than a pre-read snapshot. The actual
309
+ // readRegistry() call is deferred to inside the serial queue so that
310
+ // concurrent mutations each see the previous caller's committed write.
311
+ await writeRegistry((registry) => {
312
+ if (optedOut) {
313
+ registry.optedOut[absDir] = { at: new Date().toISOString() };
314
+ } else {
315
+ delete registry.optedOut[absDir];
316
+ }
317
+ });
318
+ }
319
+
320
+ /**
321
+ * Check whether the one-time "Enable ForgeDock here?" nudge has already been
322
+ * shown for a given directory.
323
+ *
324
+ * Fail-open: returns false on any registry read error, so the nudge fires
325
+ * once more on the next session rather than never.
326
+ *
327
+ * @param {string} dir - Absolute path to the directory.
328
+ * @returns {boolean} true if the nudge has already been shown for this directory.
329
+ */
330
+ export function nudgeSeen(dir) {
331
+ try {
332
+ const absDir = normalizeDir(dir);
333
+ const registry = readRegistry();
334
+ return Object.prototype.hasOwnProperty.call(registry.nudgeSeen, absDir);
335
+ } catch {
336
+ return false;
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Record that the one-time nudge has been shown for a directory so it is not
342
+ * shown again in future sessions.
343
+ *
344
+ * Best-effort: a failed write means the nudge may appear one extra time, which
345
+ * is acceptable — it never blocks a Claude Code session.
346
+ *
347
+ * @param {string} dir - Absolute path to the directory.
348
+ * @returns {Promise<void>}
349
+ */
350
+ export async function markNudgeSeen(dir) {
351
+ const absDir = normalizeDir(dir);
352
+ // Pass a mutation closure rather than a pre-read snapshot. The actual
353
+ // readRegistry() call is deferred to inside the serial queue so that
354
+ // concurrent mutations each see the previous caller's committed write.
355
+ await writeRegistry((registry) => {
356
+ registry.nudgeSeen[absDir] = { at: new Date().toISOString() };
357
+ });
358
+ }