@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.
- package/README.md +8 -5
- package/dist/chunk-27HK6H5Y.js +69 -0
- package/dist/{chunk-BATF4PEJ.js → chunk-2KBCTMID.js} +31 -8
- package/dist/chunk-3D7B2UAZ.js +149 -0
- package/dist/{chunk-MF3OTILQ.js → chunk-3IOLS5EK.js} +48 -42
- package/dist/{plan-context-hint-FC6P3WFE.js → chunk-722JU5BP.js} +52 -12
- package/dist/{chunk-F46ORPOA.js → chunk-7ZDXBOOU.js} +271 -166
- package/dist/{doctor-QVNPHLJK.js → chunk-E7HJUU34.js} +248 -72
- package/dist/chunk-EOT63RDH.js +36 -0
- package/dist/chunk-FNHDQTPC.js +16 -0
- package/dist/chunk-HORSMSZL.js +26 -0
- package/dist/chunk-NLNH64A3.js +43 -0
- package/dist/{chunk-WU6GAPKH.js → chunk-PTGQAZEW.js} +12 -4
- package/dist/chunk-QFIVFZRH.js +13 -0
- package/dist/chunk-QPAW6IYT.js +387 -0
- package/dist/{chunk-COI5VDFU.js → chunk-WA3DYGSY.js} +1 -2
- package/dist/{config-XJIPZNUP.js → config-A3LTECAY.js} +4 -3
- package/dist/context-UJCGYOT6.js +117 -0
- package/dist/doctor-MDTZWKBK.js +24 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +167 -16
- package/dist/info-7FKBTMVO.js +139 -0
- package/dist/install-v2-RINEA24K.js +3279 -0
- package/dist/{metrics-ACEQFPDU.js → metrics-HMFH4YHK.js} +22 -9
- package/dist/{onboard-coverage-MFCAEBDO.js → onboard-coverage-XSG77LL3.js} +48 -27
- package/dist/plan-context-hint-5TNGH3R4.js +12 -0
- package/dist/scope-explain-HLJZ2M33.js +48 -0
- package/dist/status-4R3TM4FJ.js +37 -0
- package/dist/store-HOCORVL3.js +563 -0
- package/dist/sync-DT5UJMMR.js +418 -0
- package/dist/{uninstall-TAXSUSKH.js → uninstall-IFN2KYBK.js} +128 -140
- package/dist/whoami-ITGEFWH4.js +49 -0
- package/package.json +7 -5
- package/templates/hooks/cite-policy-evict.cjs +412 -160
- package/templates/hooks/configs/README.md +14 -27
- package/templates/hooks/configs/claude-code.json +17 -2
- package/templates/hooks/configs/codex-hooks.json +15 -3
- package/templates/hooks/fabric-hint.cjs +573 -180
- package/templates/hooks/knowledge-hint-broad.cjs +648 -190
- package/templates/hooks/knowledge-hint-narrow.cjs +123 -77
- package/templates/hooks/lib/banner-i18n.cjs +31 -0
- package/templates/hooks/lib/bindings-snapshot-reader.cjs +118 -7
- package/templates/hooks/lib/cite-line-parser.cjs +12 -20
- package/templates/hooks/lib/client-adapter.cjs +66 -7
- package/templates/hooks/lib/injection-log.cjs +91 -0
- package/templates/hooks/lib/nudge-policy.cjs +117 -0
- package/templates/hooks/lib/state-store.cjs +90 -11
- package/templates/hooks/post-tooluse-mutation.cjs +386 -0
- package/templates/hooks/session-end-marker.cjs +140 -0
- package/templates/skills/fabric/SKILL.md +100 -0
- package/templates/skills/fabric-archive/SKILL.md +35 -24
- package/templates/skills/fabric-archive/ref/dry-run-scope.md +1 -1
- package/templates/skills/fabric-archive/ref/i18n-policy.md +2 -3
- package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +2 -3
- package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +1 -1
- package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +1 -1
- package/templates/skills/fabric-archive/ref/phase-3-6-related-edges.md +18 -0
- package/templates/skills/fabric-archive/ref/phase-3-7-semantic-scope.md +47 -0
- package/templates/skills/fabric-audit/SKILL.md +63 -0
- package/templates/skills/fabric-connect/SKILL.md +48 -0
- package/templates/skills/fabric-import/SKILL.md +7 -7
- package/templates/skills/fabric-import/ref/i18n-policy.md +2 -3
- package/templates/skills/fabric-import/ref/state-recovery.md +1 -2
- package/templates/skills/fabric-review/SKILL.md +16 -5
- package/templates/skills/fabric-review/ref/cite-contract.md +56 -0
- package/templates/skills/fabric-review/ref/i18n-policy.md +2 -3
- package/templates/skills/fabric-review/ref/output-contract.md +1 -1
- package/templates/skills/fabric-review/ref/per-mode-flows.md +2 -2
- package/templates/skills/fabric-review/ref/worked-examples.md +1 -1
- package/templates/skills/fabric-store/SKILL.md +44 -0
- package/templates/skills/fabric-sync/SKILL.md +1 -1
- package/templates/skills/lib/shared-policy.md +2 -2
- package/dist/chunk-HFQVXY6P.js +0 -86
- package/dist/chunk-L4Q55UC4.js +0 -52
- package/dist/chunk-LFIKMVY7.js +0 -27
- package/dist/chunk-PWLW3B57.js +0 -18
- package/dist/chunk-RYAFBNES.js +0 -33
- package/dist/chunk-T5RPGCCM.js +0 -40
- package/dist/chunk-WWNXR34K.js +0 -49
- package/dist/install-2HDO5FTQ.js +0 -2683
- package/dist/scope-explain-2F2R5URO.js +0 -33
- package/dist/status-GLQWLWH6.js +0 -23
- package/dist/store-XTSE5TY6.js +0 -105
- package/dist/sync-BJCWDPNC.js +0 -245
- package/dist/whoami-B6AEMSEV.js +0 -31
- package/templates/hooks/configs/cursor-hooks.json +0 -18
- package/templates/hooks/lib/cite-contract-reminder.cjs +0 -179
- 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
|
|
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
|
|
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' |
|
|
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
|
|
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
|
|
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"
|
|
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
|
-
|
|
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
|
|
84
|
+
async function readJsonStateAsync(projectRoot, fileName, validate) {
|
|
46
85
|
const path = cachePath(projectRoot, fileName);
|
|
47
86
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
123
|
+
async function readTextStateAsync(projectRoot, fileName) {
|
|
67
124
|
const path = cachePath(projectRoot, fileName);
|
|
68
125
|
try {
|
|
69
|
-
|
|
70
|
-
|
|
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
|
};
|