@fenglimg/fabric-cli 2.1.0-rc.2 → 2.2.0-rc.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.
Files changed (88) hide show
  1. package/README.md +8 -5
  2. package/dist/chunk-27HK6H5Y.js +69 -0
  3. package/dist/{chunk-BATF4PEJ.js → chunk-2KBCTMID.js} +31 -8
  4. package/dist/chunk-3D7B2UAZ.js +149 -0
  5. package/dist/{chunk-MF3OTILQ.js → chunk-3IOLS5EK.js} +48 -42
  6. package/dist/{plan-context-hint-FC6P3WFE.js → chunk-722JU5BP.js} +52 -12
  7. package/dist/{chunk-F46ORPOA.js → chunk-7ZDXBOOU.js} +271 -166
  8. package/dist/{doctor-QVNPHLJK.js → chunk-E7HJUU34.js} +248 -72
  9. package/dist/chunk-EOT63RDH.js +36 -0
  10. package/dist/chunk-FNHDQTPC.js +16 -0
  11. package/dist/chunk-HORSMSZL.js +26 -0
  12. package/dist/chunk-NLNH64A3.js +43 -0
  13. package/dist/{chunk-WU6GAPKH.js → chunk-PTGQAZEW.js} +12 -4
  14. package/dist/chunk-QFIVFZRH.js +13 -0
  15. package/dist/chunk-QPAW6IYT.js +387 -0
  16. package/dist/{chunk-COI5VDFU.js → chunk-WA3DYGSY.js} +1 -2
  17. package/dist/{config-XJIPZNUP.js → config-A3LTECAY.js} +4 -3
  18. package/dist/context-UJCGYOT6.js +117 -0
  19. package/dist/doctor-MDTZWKBK.js +24 -0
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +167 -16
  22. package/dist/info-7FKBTMVO.js +139 -0
  23. package/dist/install-v2-RINEA24K.js +3279 -0
  24. package/dist/{metrics-ACEQFPDU.js → metrics-HMFH4YHK.js} +22 -9
  25. package/dist/{onboard-coverage-MFCAEBDO.js → onboard-coverage-XSG77LL3.js} +48 -27
  26. package/dist/plan-context-hint-5TNGH3R4.js +12 -0
  27. package/dist/scope-explain-HLJZ2M33.js +48 -0
  28. package/dist/status-4R3TM4FJ.js +37 -0
  29. package/dist/store-HOCORVL3.js +563 -0
  30. package/dist/sync-DT5UJMMR.js +418 -0
  31. package/dist/{uninstall-TAXSUSKH.js → uninstall-IFN2KYBK.js} +128 -140
  32. package/dist/whoami-ITGEFWH4.js +49 -0
  33. package/package.json +7 -5
  34. package/templates/hooks/cite-policy-evict.cjs +412 -160
  35. package/templates/hooks/configs/README.md +14 -27
  36. package/templates/hooks/configs/claude-code.json +17 -2
  37. package/templates/hooks/configs/codex-hooks.json +15 -3
  38. package/templates/hooks/fabric-hint.cjs +573 -180
  39. package/templates/hooks/knowledge-hint-broad.cjs +648 -190
  40. package/templates/hooks/knowledge-hint-narrow.cjs +123 -77
  41. package/templates/hooks/lib/banner-i18n.cjs +31 -0
  42. package/templates/hooks/lib/bindings-snapshot-reader.cjs +118 -7
  43. package/templates/hooks/lib/cite-line-parser.cjs +12 -20
  44. package/templates/hooks/lib/client-adapter.cjs +66 -7
  45. package/templates/hooks/lib/injection-log.cjs +91 -0
  46. package/templates/hooks/lib/nudge-policy.cjs +117 -0
  47. package/templates/hooks/lib/state-store.cjs +90 -11
  48. package/templates/hooks/post-tooluse-mutation.cjs +386 -0
  49. package/templates/hooks/session-end-marker.cjs +140 -0
  50. package/templates/skills/fabric/SKILL.md +100 -0
  51. package/templates/skills/fabric-archive/SKILL.md +35 -24
  52. package/templates/skills/fabric-archive/ref/dry-run-scope.md +1 -1
  53. package/templates/skills/fabric-archive/ref/i18n-policy.md +2 -3
  54. package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +2 -3
  55. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +1 -1
  56. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +1 -1
  57. package/templates/skills/fabric-archive/ref/phase-3-6-related-edges.md +18 -0
  58. package/templates/skills/fabric-archive/ref/phase-3-7-semantic-scope.md +47 -0
  59. package/templates/skills/fabric-audit/SKILL.md +63 -0
  60. package/templates/skills/fabric-connect/SKILL.md +48 -0
  61. package/templates/skills/fabric-import/SKILL.md +7 -7
  62. package/templates/skills/fabric-import/ref/i18n-policy.md +2 -3
  63. package/templates/skills/fabric-import/ref/state-recovery.md +1 -2
  64. package/templates/skills/fabric-review/SKILL.md +16 -5
  65. package/templates/skills/fabric-review/ref/cite-contract.md +56 -0
  66. package/templates/skills/fabric-review/ref/i18n-policy.md +2 -3
  67. package/templates/skills/fabric-review/ref/output-contract.md +1 -1
  68. package/templates/skills/fabric-review/ref/per-mode-flows.md +2 -2
  69. package/templates/skills/fabric-review/ref/worked-examples.md +1 -1
  70. package/templates/skills/fabric-store/SKILL.md +44 -0
  71. package/templates/skills/fabric-sync/SKILL.md +1 -1
  72. package/templates/skills/lib/shared-policy.md +2 -2
  73. package/dist/chunk-HFQVXY6P.js +0 -86
  74. package/dist/chunk-L4Q55UC4.js +0 -52
  75. package/dist/chunk-LFIKMVY7.js +0 -27
  76. package/dist/chunk-PWLW3B57.js +0 -18
  77. package/dist/chunk-RYAFBNES.js +0 -33
  78. package/dist/chunk-T5RPGCCM.js +0 -40
  79. package/dist/chunk-WWNXR34K.js +0 -49
  80. package/dist/install-2HDO5FTQ.js +0 -2683
  81. package/dist/scope-explain-2F2R5URO.js +0 -33
  82. package/dist/status-GLQWLWH6.js +0 -23
  83. package/dist/store-XTSE5TY6.js +0 -105
  84. package/dist/sync-BJCWDPNC.js +0 -245
  85. package/dist/whoami-B6AEMSEV.js +0 -31
  86. package/templates/hooks/configs/cursor-hooks.json +0 -18
  87. package/templates/hooks/lib/cite-contract-reminder.cjs +0 -179
  88. package/templates/hooks/lib/summary-fallback.cjs +0 -210
@@ -1,27 +1,27 @@
1
1
  /**
2
2
  * v2.0.0-rc.37 NEW-30: shared client-protocol adapter for hook scripts.
3
3
  *
4
- * The three host clients (Claude Code / Codex CLI / Cursor) differ in how a
4
+ * The two host clients (Claude Code / Codex CLI) differ in how a
5
5
  * hook surfaces context back to the model:
6
6
  * - Claude Code reads a stdout JSON envelope
7
7
  * ({ hookSpecificOutput: { hookEventName, additionalContext } }).
8
- * - Codex CLI and Cursor read plain stderr text.
8
+ * - Codex CLI reads plain stderr text.
9
9
  * Each hook had its own copy of the detect-client + read-stdin + pick-channel
10
10
  * logic (fabric-hint.detectClient, cite-policy-evict.isClaudeCode + readStdinJson,
11
11
  * knowledge-hint-broad inline CLAUDE_PROJECT_DIR check). This module is the
12
12
  * single canonical implementation so the protocol choice lives in one place.
13
13
  *
14
14
  * Provides:
15
- * - detectClient(dirnameHint?) → 'cc' | 'codex' | 'cursor' | undefined
15
+ * - detectClient(dirnameHint?) → 'cc' | 'codex' | undefined
16
16
  * 3-tier: FABRIC_HINT_CLIENT env override → CLAUDE_PROJECT_DIR (cc) →
17
- * __dirname path heuristic (.claude / .codex / .cursor). dirnameHint
17
+ * __dirname path heuristic (.claude / .codex). dirnameHint
18
18
  * defaults to this lib's own dir (which still lives under the client
19
19
  * dir, e.g. .claude/hooks/lib), so the heuristic stays accurate.
20
20
  * - isClaudeCode() → boolean (CLAUDE_PROJECT_DIR present)
21
21
  * - readStdinJson({ timeoutMs }) → Promise<object | null>
22
22
  * Async stdin JSON reader; null on parse error / closed stdin / timeout.
23
23
  * - emitContext(text, { client, eventName, streams, forceStderr }) → void
24
- * Standardised output: Claude Code → stdout JSON envelope; Codex/Cursor
24
+ * Standardised output: Claude Code → stdout JSON envelope; Codex
25
25
  * → plain stderr. forceStderr pins stderr even on Claude Code (used for
26
26
  * SessionStart one-shot reminders). Best-effort — never throws.
27
27
  *
@@ -40,7 +40,7 @@ function detectClient(dirnameHint) {
40
40
  const envClient = process.env.FABRIC_HINT_CLIENT;
41
41
  if (typeof envClient === "string" && envClient.length > 0) {
42
42
  const normalised = envClient.trim().toLowerCase();
43
- if (normalised === "cc" || normalised === "codex" || normalised === "cursor") {
43
+ if (normalised === "cc" || normalised === "codex") {
44
44
  return normalised;
45
45
  }
46
46
  }
@@ -51,7 +51,6 @@ function detectClient(dirnameHint) {
51
51
  try {
52
52
  if (dir.includes(".claude/") || dir.includes(".claude\\")) return "cc";
53
53
  if (dir.includes(".codex/") || dir.includes(".codex\\")) return "codex";
54
- if (dir.includes(".cursor/") || dir.includes(".cursor\\")) return "cursor";
55
54
  } catch {
56
55
  // fall through
57
56
  }
@@ -98,9 +97,69 @@ function emitContext(text, opts) {
98
97
  }
99
98
  }
100
99
 
100
+ // v2.2 dual-sink (Goal A / D7): two-channel emit. Unlike emitContext (which
101
+ // picks ONE channel), emitDualSink surfaces a knowledge breadcrumb to BOTH the
102
+ // human and the AI in one render, split into two fields, with the protocol
103
+ // shaped per client:
104
+ //
105
+ // cc / codex (symmetric, D7): a single stdout JSON envelope carrying
106
+ // { systemMessage: <human>, // the human sink
107
+ // hookSpecificOutput: { hookEventName, additionalContext: <ai> } } // AI sink
108
+ // camelCase + nested. `systemMessage` is the universal human-facing field
109
+ // (verified against official hook docs in the mode④ design session); it is
110
+ // what fixes the "stderr human channel is dead on CC" gap — CC suppresses
111
+ // hook stderr at exit 0, so the human never saw the old breadcrumb.
112
+ //
113
+ // unknown client (detection failed, not CC): fall back to a plain stderr
114
+ // breadcrumb (human preferred, else ai) — no known JSON contract to target.
115
+ //
116
+ // Either field may be null/empty: pass { human, ai } and only the present
117
+ // channels are written (e.g. a PreToolUse miss passes human:null → AI-only;
118
+ // nudge_mode silent passes human:null too). The AI field is ALWAYS the caller's
119
+ // to decide independently — this fn never derives one channel from the other,
120
+ // preserving the flow ⊥ observation invariant (D5).
121
+ //
122
+ // Never-throw contract (KT-DEC-0007): every path degrades silently.
123
+ function emitDualSink(payload, opts) {
124
+ const { human = null, ai = null } = payload || {};
125
+ const { client, eventName = "SessionStart", streams = {} } = opts || {};
126
+ const stdout = streams.stdout || process.stdout;
127
+ const stderr = streams.stderr || process.stderr;
128
+ const hasHuman = typeof human === "string" && human.length > 0;
129
+ const hasAi = typeof ai === "string" && ai.length > 0;
130
+ const resolved = client || detectClient();
131
+ try {
132
+ const useEnvelope =
133
+ resolved === "cc" ||
134
+ resolved === "codex" ||
135
+ (resolved === undefined && isClaudeCode());
136
+ if (useEnvelope) {
137
+ const envelope = {};
138
+ if (hasHuman) envelope.systemMessage = human;
139
+ if (hasAi) {
140
+ envelope.hookSpecificOutput = {
141
+ hookEventName: eventName,
142
+ additionalContext: ai,
143
+ };
144
+ }
145
+ if (Object.keys(envelope).length > 0) {
146
+ stdout.write(`${JSON.stringify(envelope)}\n`);
147
+ }
148
+ return;
149
+ }
150
+ // Unknown client: no JSON contract — surface the human breadcrumb (or ai)
151
+ // on stderr as a last resort so something is visible.
152
+ const fallback = hasHuman ? human : hasAi ? ai : null;
153
+ if (fallback !== null) stderr.write(`${fallback}\n`);
154
+ } catch {
155
+ // best-effort — never throw
156
+ }
157
+ }
158
+
101
159
  module.exports = {
102
160
  isClaudeCode,
103
161
  detectClient,
104
162
  readStdinJson,
105
163
  emitContext,
164
+ emitDualSink,
106
165
  };
@@ -0,0 +1,91 @@
1
+ // v2.2 HK3-telemetry (W3-T1): injection-side telemetry. `.fabric/metrics.jsonl`
2
+ // (server) records the CONSUMPTION side — which knowledge the agent actually
3
+ // fetched/consumed. But nothing recorded the INJECTION side — which knowledge a
4
+ // hook OFFERED the agent at SessionStart / PreToolUse. Without that denominator
5
+ // the "true hit rate" (consumed ÷ injected) cannot be computed: a high consume
6
+ // count tells you nothing if the hook injected ten times as many entries.
7
+ //
8
+ // This lib appends one row per injection to `.fabric/injections.jsonl`:
9
+ // { ts, surface: "broad"|"narrow", count, stable_ids: [...], revision_hash }
10
+ //
11
+ // Best-effort + synchronous: hooks are short-lived processes, so a sync append
12
+ // is simpler than threading async, and ANY failure is swallowed — telemetry
13
+ // must never break or delay the hook (failure invariant: silent). Concurrent
14
+ // writers from multiple windows are serialized with an advisory lock (see
15
+ // appendLockedLine below) so a contended write can't corrupt a line.
16
+
17
+ const { appendFileSync, mkdirSync, openSync, closeSync, statSync, rmSync } = require("node:fs");
18
+ const { join, dirname } = require("node:path");
19
+
20
+ // Multi-window concurrency guard (ADJ-W3-INJECTION-CONCURRENCY): the same repo
21
+ // is frequently edited from several client sessions at once, so multiple hook
22
+ // processes can append to injections.jsonl simultaneously. A bare appendFileSync
23
+ // can interleave a partial write under contention and corrupt a line. We guard
24
+ // each append with an advisory lock file created atomically via O_EXCL ("wx"):
25
+ // - acquired → write the row, then release the lock
26
+ // - contended → DROP this row. Telemetry is best-effort; a missing row only
27
+ // shrinks the denominator slightly, and dropping is what keeps
28
+ // the ledger from ever being corrupted by an interleave.
29
+ // - stale → a holder that crashed leaves the lock behind; reclaim it once
30
+ // past STALE_LOCK_MS so contention can't wedge forever.
31
+ const STALE_LOCK_MS = 5000;
32
+
33
+ function appendLockedLine(path, line) {
34
+ const lockPath = `${path}.lock`;
35
+ let fd;
36
+ try {
37
+ fd = openSync(lockPath, "wx"); // atomic create-exclusive = acquire
38
+ } catch (err) {
39
+ if (!err || err.code !== "EEXIST") return; // unexpected → drop (best-effort)
40
+ try {
41
+ if (Date.now() - statSync(lockPath).mtimeMs <= STALE_LOCK_MS) return; // fresh holder → drop
42
+ rmSync(lockPath, { force: true }); // stale holder crashed → reclaim
43
+ fd = openSync(lockPath, "wx");
44
+ } catch {
45
+ return; // racing another reclaimer → drop
46
+ }
47
+ }
48
+ try {
49
+ closeSync(fd);
50
+ appendFileSync(path, line);
51
+ } finally {
52
+ try {
53
+ rmSync(lockPath, { force: true });
54
+ } catch {
55
+ /* lock already released */
56
+ }
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Append one injection record to `<projectRoot>/.fabric/injections.jsonl`.
62
+ *
63
+ * @param {string} projectRoot
64
+ * @param {{ surface: "broad"|"narrow", stableIds?: string[], count?: number, revisionHash?: string|null, ts?: number }} record
65
+ */
66
+ function logInjection(projectRoot, record) {
67
+ try {
68
+ if (!projectRoot || !record || typeof record.surface !== "string") {
69
+ return;
70
+ }
71
+ const stableIds = Array.isArray(record.stableIds) ? record.stableIds.filter((id) => typeof id === "string") : [];
72
+ const count = typeof record.count === "number" ? record.count : stableIds.length;
73
+ if (count <= 0) {
74
+ return; // nothing injected → no row (keeps the denominator honest)
75
+ }
76
+ const row = {
77
+ ts: typeof record.ts === "number" ? record.ts : Date.now(),
78
+ surface: record.surface,
79
+ count,
80
+ stable_ids: stableIds,
81
+ revision_hash: typeof record.revisionHash === "string" ? record.revisionHash : null,
82
+ };
83
+ const path = join(projectRoot, ".fabric", "injections.jsonl");
84
+ mkdirSync(dirname(path), { recursive: true });
85
+ appendLockedLine(path, `${JSON.stringify(row)}\n`);
86
+ } catch {
87
+ // Telemetry is best-effort — never crash or delay the hook.
88
+ }
89
+ }
90
+
91
+ module.exports = { logInjection, appendLockedLine };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * v2.2 dual-sink (Goal A / D4): nudge_mode + observe.* resolver for hook scripts.
3
+ *
4
+ * This lib answers ONE question for the human-facing sink: given the configured
5
+ * nudge_mode preset, the per-event observe.* overrides, and the event's
6
+ * structural gate (PreToolUse hit / Stop value), should this lifecycle event
7
+ * emit a human-facing `systemMessage`, and at what verbosity?
8
+ *
9
+ * CORE INVARIANT (D5 / KT-DEC-0007): this resolver governs ONLY the human sink.
10
+ * It has NO say over the AI sink (`hookSpecificOutput.additionalContext`). The
11
+ * model receives the same knowledge regardless of how quiet the human channel
12
+ * is — flow ⊥ observation. There is deliberately no `emitAi()` here; callers
13
+ * always compute and emit the AI payload unconditionally, then ask this resolver
14
+ * whether to additionally surface a human breadcrumb. The dedicated invariant
15
+ * test asserts that no nudge_mode / observe combination changes the AI branch.
16
+ *
17
+ * Resolution order for `resolveHumanSink(projectRoot, event, gate)`:
18
+ * 1. observe.<event> === false → suppress (explicit per-event mute wins)
19
+ * 2. structural gate fails → suppress (PreToolUse miss / Stop low-value:
20
+ * nothing meaningful to show the human, mode-independent per C5/D2/D6)
21
+ * 3. observe.<event> === true → emit (explicit per-event opt-in)
22
+ * 4. nudge_mode === "silent" → suppress (global human-channel mute)
23
+ * 5. otherwise → emit at the preset's verbosity
24
+ *
25
+ * `verbosity` (minimal | normal | verbose) is forwarded to the renderer so it
26
+ * can scale the human breadcrumb's detail; it never affects the AI payload.
27
+ *
28
+ * Never-throw contract: any read/parse failure degrades to the "normal" preset
29
+ * with the structural gate respected — a malfunctioning config must not silence
30
+ * the human channel by surprise (it falls back to the historical visible
31
+ * behavior), nor block the hook.
32
+ */
33
+
34
+ const { readConfig } = require("./config-cache.cjs");
35
+
36
+ const NUDGE_MODES = ["silent", "minimal", "normal", "verbose"];
37
+ const DEFAULT_NUDGE_MODE = "normal";
38
+ // The three observe.* event keys, mirroring observeConfigSchema in
39
+ // packages/shared/src/schemas/fabric-config.ts. Hooks pass the matching key.
40
+ const OBSERVE_EVENTS = ["session_start", "pre_tool_use", "stop"];
41
+
42
+ /**
43
+ * Resolve the configured nudge_mode preset. Unknown / absent → "normal".
44
+ */
45
+ function readNudgeMode(projectRoot) {
46
+ try {
47
+ const v = readConfig(projectRoot).nudge_mode;
48
+ return typeof v === "string" && NUDGE_MODES.includes(v) ? v : DEFAULT_NUDGE_MODE;
49
+ } catch {
50
+ return DEFAULT_NUDGE_MODE;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Resolve the per-event observe.* override for one event. Returns a strict
56
+ * boolean when explicitly set, otherwise undefined (preset decides). Tolerant of
57
+ * a malformed observe value (non-object → undefined).
58
+ */
59
+ function readObserveOverride(projectRoot, event) {
60
+ try {
61
+ const observe = readConfig(projectRoot).observe;
62
+ if (!observe || typeof observe !== "object") return undefined;
63
+ const v = observe[event];
64
+ return typeof v === "boolean" ? v : undefined;
65
+ } catch {
66
+ return undefined;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Decide whether `event` should emit a human-facing systemMessage, and at what
72
+ * verbosity. `gate` carries the event's structural signal:
73
+ * - { hit: boolean } for pre_tool_use (false = narrow miss → suppress)
74
+ * - { highValue: boolean } for stop (false = no high-value archive
75
+ * candidate → suppress, D6 value-gate)
76
+ * - {} for session_start (no structural gate)
77
+ * Omitting a gate field means "gate passes" (e.g. session_start never gates).
78
+ *
79
+ * Returns { emitHuman: boolean, verbosity: "minimal"|"normal"|"verbose", mode }.
80
+ */
81
+ function resolveHumanSink(projectRoot, event, gate) {
82
+ const mode = readNudgeMode(projectRoot);
83
+ const verbosity = mode === "silent" ? "minimal" : mode;
84
+ const override = OBSERVE_EVENTS.includes(event)
85
+ ? readObserveOverride(projectRoot, event)
86
+ : undefined;
87
+
88
+ // 1. explicit per-event mute wins over everything (even a hit).
89
+ if (override === false) return { emitHuman: false, verbosity, mode };
90
+
91
+ // 2. structural gate (mode-independent, C5/D2/D6): nothing to show → mute.
92
+ const g = gate || {};
93
+ if (event === "pre_tool_use" && g.hit === false) {
94
+ return { emitHuman: false, verbosity, mode };
95
+ }
96
+ if (event === "stop" && g.highValue === false) {
97
+ return { emitHuman: false, verbosity, mode };
98
+ }
99
+
100
+ // 3. explicit per-event opt-in (gate already passed above).
101
+ if (override === true) return { emitHuman: true, verbosity, mode };
102
+
103
+ // 4. global human-channel mute.
104
+ if (mode === "silent") return { emitHuman: false, verbosity, mode };
105
+
106
+ // 5. preset default — emit at the preset's verbosity.
107
+ return { emitHuman: true, verbosity, mode };
108
+ }
109
+
110
+ module.exports = {
111
+ readNudgeMode,
112
+ readObserveOverride,
113
+ resolveHumanSink,
114
+ NUDGE_MODES,
115
+ DEFAULT_NUDGE_MODE,
116
+ OBSERVE_EVENTS,
117
+ };
@@ -21,7 +21,10 @@
21
21
  * acceptable — the hook never blocks user flow on sidecar I/O, KT-DEC-0007).
22
22
  */
23
23
 
24
- const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs");
24
+ // Namespace import (not destructured) so the atomic write goes through a single
25
+ // mutable fs reference — also what the atomicity tests spy on (ISS-016).
26
+ const fs = require("node:fs");
27
+ const fsp = require("node:fs/promises");
25
28
  const { dirname, join } = require("node:path");
26
29
 
27
30
  const CACHE_DIR_REL = join(".fabric", ".cache");
@@ -30,11 +33,47 @@ function cachePath(projectRoot, fileName) {
30
33
  return join(projectRoot, CACHE_DIR_REL, fileName);
31
34
  }
32
35
 
36
+ // ISS-016: write to a unique temp file then rename over the target. rename is
37
+ // atomic on POSIX, so a reader sees either the old or the new file in full —
38
+ // never a truncated/garbled write from a crash or concurrent writer. The temp
39
+ // suffix (pid + clock) keeps concurrent windows from colliding on the temp.
40
+ function atomicWrite(path, data) {
41
+ fs.mkdirSync(dirname(path), { recursive: true });
42
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
43
+ try {
44
+ fs.writeFileSync(tmp, data);
45
+ fs.renameSync(tmp, path);
46
+ } catch (err) {
47
+ try {
48
+ if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
49
+ } catch {
50
+ // best-effort temp cleanup
51
+ }
52
+ throw err;
53
+ }
54
+ }
55
+
56
+ async function atomicWriteAsync(path, data) {
57
+ await fsp.mkdir(dirname(path), { recursive: true });
58
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
59
+ try {
60
+ await fsp.writeFile(tmp, data);
61
+ await fsp.rename(tmp, path);
62
+ } catch (err) {
63
+ try {
64
+ await fsp.rm(tmp, { force: true });
65
+ } catch {
66
+ // best-effort temp cleanup
67
+ }
68
+ throw err;
69
+ }
70
+ }
71
+
33
72
  function readJsonState(projectRoot, fileName, validate) {
34
73
  const path = cachePath(projectRoot, fileName);
35
- if (!existsSync(path)) return null;
74
+ if (!fs.existsSync(path)) return null;
36
75
  try {
37
- const parsed = JSON.parse(readFileSync(path, "utf8"));
76
+ const parsed = JSON.parse(fs.readFileSync(path, "utf8"));
38
77
  if (typeof validate === "function" && !validate(parsed)) return null;
39
78
  return parsed;
40
79
  } catch {
@@ -42,11 +81,29 @@ function readJsonState(projectRoot, fileName, validate) {
42
81
  }
43
82
  }
44
83
 
45
- function writeJsonState(projectRoot, fileName, value) {
84
+ async function readJsonStateAsync(projectRoot, fileName, validate) {
46
85
  const path = cachePath(projectRoot, fileName);
47
86
  try {
48
- mkdirSync(dirname(path), { recursive: true });
49
- writeFileSync(path, JSON.stringify(value));
87
+ const parsed = JSON.parse(await fsp.readFile(path, "utf8"));
88
+ if (typeof validate === "function" && !validate(parsed)) return null;
89
+ return parsed;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ function writeJsonState(projectRoot, fileName, value) {
96
+ try {
97
+ atomicWrite(cachePath(projectRoot, fileName), JSON.stringify(value));
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ async function writeJsonStateAsync(projectRoot, fileName, value) {
105
+ try {
106
+ await atomicWriteAsync(cachePath(projectRoot, fileName), JSON.stringify(value));
50
107
  return true;
51
108
  } catch {
52
109
  return false;
@@ -55,19 +112,35 @@ function writeJsonState(projectRoot, fileName, value) {
55
112
 
56
113
  function readTextState(projectRoot, fileName) {
57
114
  const path = cachePath(projectRoot, fileName);
58
- if (!existsSync(path)) return null;
115
+ if (!fs.existsSync(path)) return null;
59
116
  try {
60
- return readFileSync(path, "utf8").trim();
117
+ return fs.readFileSync(path, "utf8").trim();
61
118
  } catch {
62
119
  return null;
63
120
  }
64
121
  }
65
122
 
66
- function writeTextState(projectRoot, fileName, text) {
123
+ async function readTextStateAsync(projectRoot, fileName) {
67
124
  const path = cachePath(projectRoot, fileName);
68
125
  try {
69
- mkdirSync(dirname(path), { recursive: true });
70
- writeFileSync(path, String(text));
126
+ return (await fsp.readFile(path, "utf8")).trim();
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ function writeTextState(projectRoot, fileName, text) {
133
+ try {
134
+ atomicWrite(cachePath(projectRoot, fileName), String(text));
135
+ return true;
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ async function writeTextStateAsync(projectRoot, fileName, text) {
142
+ try {
143
+ await atomicWriteAsync(cachePath(projectRoot, fileName), String(text));
71
144
  return true;
72
145
  } catch {
73
146
  return false;
@@ -77,8 +150,14 @@ function writeTextState(projectRoot, fileName, text) {
77
150
  module.exports = {
78
151
  cachePath,
79
152
  readJsonState,
153
+ readJsonStateAsync,
80
154
  writeJsonState,
155
+ writeJsonStateAsync,
81
156
  readTextState,
157
+ readTextStateAsync,
82
158
  writeTextState,
159
+ writeTextStateAsync,
160
+ atomicWrite,
161
+ atomicWriteAsync,
83
162
  CACHE_DIR_REL,
84
163
  };