@fenglimg/fabric-cli 2.2.0-rc.4 → 2.2.0-rc.9
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-5JG4QJLO.js → chunk-27HK6H5Y.js} +10 -5
- package/dist/{chunk-F6ITRM7T.js → chunk-2KBCTMID.js} +29 -6
- package/dist/chunk-3D7B2UAZ.js +149 -0
- package/dist/{chunk-XC5RUHLK.js → chunk-3IOLS5EK.js} +23 -38
- package/dist/{chunk-XHHCRDIR.js → chunk-7ZDXBOOU.js} +174 -211
- package/dist/{doctor-U5W4CX5I.js → chunk-E7HJUU34.js} +103 -51
- package/dist/{chunk-XCBVSGCS.js → chunk-FNHDQTPC.js} +1 -10
- package/dist/{chunk-2CY4BMTH.js → chunk-HORSMSZL.js} +9 -5
- package/dist/{chunk-BO4XIZWZ.js → chunk-NLNH64A3.js} +5 -18
- package/dist/{chunk-H3FE6VIK.js → chunk-PTGQAZEW.js} +13 -3
- package/dist/chunk-QFIVFZRH.js +13 -0
- package/dist/{chunk-5SSNE5GM.js → chunk-QPAW6IYT.js} +125 -39
- package/dist/{chunk-COI5VDFU.js → chunk-WA3DYGSY.js} +1 -2
- package/dist/{plan-context-hint-CHVZGOZ5.js → chunk-YM4XATJF.js} +29 -4
- package/dist/{config-VJMXCLXW.js → config-A3LTECAY.js} +4 -3
- package/dist/context-7NUKXDB6.js +117 -0
- package/dist/doctor-MDTZWKBK.js +24 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +131 -21
- package/dist/info-7FKBTMVO.js +139 -0
- package/dist/install-v2-I6PJ6IFT.js +3279 -0
- package/dist/{metrics-RER6NLFC.js → metrics-HMFH4YHK.js} +1 -1
- package/dist/{onboard-coverage-JWQWDZW7.js → onboard-coverage-XSG77LL3.js} +48 -27
- package/dist/plan-context-hint-G75R4P4J.js +12 -0
- package/dist/{scope-explain-BWRWBCCP.js → scope-explain-HLJZ2M33.js} +3 -2
- package/dist/{status-7UFLWRX7.js → status-4R3TM4FJ.js} +8 -5
- package/dist/{store-ZEZMQVG7.js → store-HOCORVL3.js} +96 -350
- package/dist/{sync-EA5HZMXM.js → sync-DT5UJMMR.js} +36 -13
- package/dist/{uninstall-F75MPKQC.js → uninstall-IFN2KYBK.js} +71 -140
- package/dist/{whoami-3FRWYGML.js → whoami-ITGEFWH4.js} +9 -7
- package/package.json +7 -5
- package/templates/hooks/cite-policy-evict.cjs +5 -5
- package/templates/hooks/configs/README.md +14 -27
- package/templates/hooks/configs/claude-code.json +1 -1
- package/templates/hooks/configs/codex-hooks.json +3 -3
- package/templates/hooks/fabric-hint.cjs +326 -161
- package/templates/hooks/knowledge-hint-broad.cjs +431 -271
- package/templates/hooks/knowledge-hint-narrow.cjs +64 -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/nudge-policy.cjs +117 -0
- package/templates/hooks/lib/state-store.cjs +60 -0
- package/templates/hooks/post-tooluse-mutation.cjs +112 -11
- package/templates/skills/fabric/SKILL.md +100 -0
- package/templates/skills/fabric-archive/SKILL.md +29 -26
- 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 +13 -3
- package/templates/skills/fabric-connect/SKILL.md +3 -3
- 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 +5 -5
- package/templates/skills/fabric-review/ref/cite-contract.md +1 -1
- 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 +1 -1
- package/templates/skills/fabric-sync/SKILL.md +1 -1
- package/templates/skills/lib/shared-policy.md +2 -2
- package/dist/install-7XJ64WSC.js +0 -2743
- package/templates/hooks/configs/cursor-hooks.json +0 -30
- 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,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
|
+
};
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
// Namespace import (not destructured) so the atomic write goes through a single
|
|
25
25
|
// mutable fs reference — also what the atomicity tests spy on (ISS-016).
|
|
26
26
|
const fs = require("node:fs");
|
|
27
|
+
const fsp = require("node:fs/promises");
|
|
27
28
|
const { dirname, join } = require("node:path");
|
|
28
29
|
|
|
29
30
|
const CACHE_DIR_REL = join(".fabric", ".cache");
|
|
@@ -52,6 +53,22 @@ function atomicWrite(path, data) {
|
|
|
52
53
|
}
|
|
53
54
|
}
|
|
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
|
+
|
|
55
72
|
function readJsonState(projectRoot, fileName, validate) {
|
|
56
73
|
const path = cachePath(projectRoot, fileName);
|
|
57
74
|
if (!fs.existsSync(path)) return null;
|
|
@@ -64,6 +81,17 @@ function readJsonState(projectRoot, fileName, validate) {
|
|
|
64
81
|
}
|
|
65
82
|
}
|
|
66
83
|
|
|
84
|
+
async function readJsonStateAsync(projectRoot, fileName, validate) {
|
|
85
|
+
const path = cachePath(projectRoot, fileName);
|
|
86
|
+
try {
|
|
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
|
+
|
|
67
95
|
function writeJsonState(projectRoot, fileName, value) {
|
|
68
96
|
try {
|
|
69
97
|
atomicWrite(cachePath(projectRoot, fileName), JSON.stringify(value));
|
|
@@ -73,6 +101,15 @@ function writeJsonState(projectRoot, fileName, value) {
|
|
|
73
101
|
}
|
|
74
102
|
}
|
|
75
103
|
|
|
104
|
+
async function writeJsonStateAsync(projectRoot, fileName, value) {
|
|
105
|
+
try {
|
|
106
|
+
await atomicWriteAsync(cachePath(projectRoot, fileName), JSON.stringify(value));
|
|
107
|
+
return true;
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
76
113
|
function readTextState(projectRoot, fileName) {
|
|
77
114
|
const path = cachePath(projectRoot, fileName);
|
|
78
115
|
if (!fs.existsSync(path)) return null;
|
|
@@ -83,6 +120,15 @@ function readTextState(projectRoot, fileName) {
|
|
|
83
120
|
}
|
|
84
121
|
}
|
|
85
122
|
|
|
123
|
+
async function readTextStateAsync(projectRoot, fileName) {
|
|
124
|
+
const path = cachePath(projectRoot, fileName);
|
|
125
|
+
try {
|
|
126
|
+
return (await fsp.readFile(path, "utf8")).trim();
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
86
132
|
function writeTextState(projectRoot, fileName, text) {
|
|
87
133
|
try {
|
|
88
134
|
atomicWrite(cachePath(projectRoot, fileName), String(text));
|
|
@@ -92,12 +138,26 @@ function writeTextState(projectRoot, fileName, text) {
|
|
|
92
138
|
}
|
|
93
139
|
}
|
|
94
140
|
|
|
141
|
+
async function writeTextStateAsync(projectRoot, fileName, text) {
|
|
142
|
+
try {
|
|
143
|
+
await atomicWriteAsync(cachePath(projectRoot, fileName), String(text));
|
|
144
|
+
return true;
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
95
150
|
module.exports = {
|
|
96
151
|
cachePath,
|
|
97
152
|
readJsonState,
|
|
153
|
+
readJsonStateAsync,
|
|
98
154
|
writeJsonState,
|
|
155
|
+
writeJsonStateAsync,
|
|
99
156
|
readTextState,
|
|
157
|
+
readTextStateAsync,
|
|
100
158
|
writeTextState,
|
|
159
|
+
writeTextStateAsync,
|
|
101
160
|
atomicWrite,
|
|
161
|
+
atomicWriteAsync,
|
|
102
162
|
CACHE_DIR_REL,
|
|
103
163
|
};
|
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
* lifecycle-refactor W2-T3 — PostToolUse mutation marker hook (previously
|
|
4
4
|
* dormant). Closes the mutation env opened by the PreToolUse narrow hint.
|
|
5
5
|
*
|
|
6
|
-
* PostToolUse fires AFTER
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* Pre (intent) and Post (mutation)
|
|
10
|
-
* key also guards
|
|
11
|
-
*
|
|
6
|
+
* PostToolUse fires AFTER a tool call completes. This hook serves two markers,
|
|
7
|
+
* both appended to `.fabric/events.jsonl` and both observation-only:
|
|
8
|
+
* - Edit/Write/MultiEdit → one `file_mutated` event per edited path, carrying
|
|
9
|
+
* the `tool_call_id` so doctor can pair the Pre (intent) and Post (mutation)
|
|
10
|
+
* halves of a single call (the per-call key also guards parallel-fire races).
|
|
11
|
+
* - Read → one `knowledge_body_read` event per Fabric knowledge file opened
|
|
12
|
+
* (KT-DEC-0030). After retrieval collapsed to one lean tool (KT-DEC-0026),
|
|
13
|
+
* fab_recall returns descriptions + paths only; the agent reads a body via a
|
|
14
|
+
* NATIVE Read, and this marker is the observable trace doctor uses for the
|
|
15
|
+
* planned → body_read → cite[applied] funnel.
|
|
12
16
|
*
|
|
13
17
|
* Design (lifecycle-concept-final.md §1 FROZEN invariants + §5 row7):
|
|
14
18
|
* - LOW compute: extract paths + tool_call_id, append; the hook never reads
|
|
@@ -49,6 +53,24 @@ const EVENTS_LEDGER_FILE = "events.jsonl";
|
|
|
49
53
|
// PreToolUse narrow hint's EDIT_TOOL_NAMES so Pre/Post pair on the same set).
|
|
50
54
|
const EDIT_TOOL_NAMES = new Set(["Edit", "Write", "MultiEdit"]);
|
|
51
55
|
|
|
56
|
+
// KT-DEC-0030: tool names that read a file body. After retrieval collapsed to
|
|
57
|
+
// one lean tool (KT-DEC-0026), the agent consumes a knowledge body via a NATIVE
|
|
58
|
+
// Read of the store file — so a Read landing on a `<store>/knowledge/<type>/
|
|
59
|
+
// <ID>--*.md` path is the observable "body opened" signal. Only `Read` is in
|
|
60
|
+
// scope (Edit/Write already covered by the mutation marker above).
|
|
61
|
+
const READ_TOOL_NAMES = new Set(["Read"]);
|
|
62
|
+
|
|
63
|
+
// Matches a Fabric knowledge file path and captures the stable_id from the
|
|
64
|
+
// basename. The id grammar mirrors KT-DEC-0004 (`K[PT]-(DEC|MOD|GLD|PIT|PRO)-NNNN`).
|
|
65
|
+
// The path MUST sit under a `/knowledge/<type>/` segment so arbitrary Reads that
|
|
66
|
+
// merely happen to embed an id-shaped token never false-fire.
|
|
67
|
+
const KNOWLEDGE_BODY_PATH_RE =
|
|
68
|
+
/[\\/]knowledge[\\/][^\\/]+[\\/](K[PT]-(?:DEC|MOD|GLD|PIT|PRO)-\d{3,})--[^\\/]*\.md$/;
|
|
69
|
+
|
|
70
|
+
// Captures the store alias from a multistore path (`.../stores/<alias>/...`).
|
|
71
|
+
// Absent for legacy dual-root layouts → store stays undefined (still a valid event).
|
|
72
|
+
const STORE_ALIAS_RE = /[\\/]stores[\\/]([^\\/]+)[\\/]/;
|
|
73
|
+
|
|
52
74
|
/**
|
|
53
75
|
* Read stdin (or a test-supplied raw string) as JSON. Returns null on any
|
|
54
76
|
* parse failure — the hook stays silent rather than crashing the tool pipeline.
|
|
@@ -69,7 +91,6 @@ function readPayload(rawStdin) {
|
|
|
69
91
|
/**
|
|
70
92
|
* Extract the tool name. Mirrors the narrow hint's probe:
|
|
71
93
|
* - Claude Code / Codex: { tool_name, ... }
|
|
72
|
-
* - Cursor (legacy): { tool, ... }
|
|
73
94
|
*/
|
|
74
95
|
function extractToolName(payload) {
|
|
75
96
|
if (!payload || typeof payload !== "object") return null;
|
|
@@ -79,8 +100,8 @@ function extractToolName(payload) {
|
|
|
79
100
|
}
|
|
80
101
|
|
|
81
102
|
/**
|
|
82
|
-
* Extract the tool_input object
|
|
83
|
-
* (Claude/Codex)
|
|
103
|
+
* Extract the tool_input object from the `tool_input`
|
|
104
|
+
* (Claude/Codex) convention.
|
|
84
105
|
*/
|
|
85
106
|
function extractToolInput(payload) {
|
|
86
107
|
if (!payload || typeof payload !== "object") return null;
|
|
@@ -224,6 +245,75 @@ function appendFileMutated(projectRoot, now, paths, toolCallId, toolName, sessio
|
|
|
224
245
|
}
|
|
225
246
|
}
|
|
226
247
|
|
|
248
|
+
/**
|
|
249
|
+
* KT-DEC-0030: parse a Read path into a knowledge-body-read descriptor. Returns
|
|
250
|
+
* `{ stable_id, store, path }` when the path is a Fabric knowledge file, else
|
|
251
|
+
* null. `store` is omitted when no `stores/<alias>/` segment is present (legacy
|
|
252
|
+
* dual-root layout). `path` is forward-slash-normalized but NOT made
|
|
253
|
+
* project-relative — knowledge bodies live under ~/.fabric, outside the project
|
|
254
|
+
* tree, so the home-anchored path is the meaningful identifier.
|
|
255
|
+
*/
|
|
256
|
+
function extractKnowledgeBodyRead(filePath) {
|
|
257
|
+
if (typeof filePath !== "string" || filePath.length === 0) return null;
|
|
258
|
+
const idMatch = KNOWLEDGE_BODY_PATH_RE.exec(filePath);
|
|
259
|
+
if (idMatch === null) return null;
|
|
260
|
+
const storeMatch = STORE_ALIAS_RE.exec(filePath);
|
|
261
|
+
const slashed = filePath.split(/[\\/]/).join("/");
|
|
262
|
+
return {
|
|
263
|
+
stable_id: idMatch[1],
|
|
264
|
+
store: storeMatch !== null ? storeMatch[1] : null,
|
|
265
|
+
path: slashed,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Append one `knowledge_body_read` marker per Fabric knowledge file read.
|
|
271
|
+
* Best-effort, identical guarantees to appendFileMutated (silent on any error,
|
|
272
|
+
* skips when `.fabric/` absent). Non-knowledge reads produce zero events — the
|
|
273
|
+
* common case (the agent reads source files far more than knowledge bodies),
|
|
274
|
+
* so the hook stays a near-noop on the hot Read path.
|
|
275
|
+
*/
|
|
276
|
+
function appendKnowledgeBodyRead(projectRoot, now, paths, toolCallId, toolName, sessionId) {
|
|
277
|
+
try {
|
|
278
|
+
const fabricDir = join(projectRoot, FABRIC_DIR_REL);
|
|
279
|
+
if (!existsSync(fabricDir)) return;
|
|
280
|
+
const reads = Array.isArray(paths)
|
|
281
|
+
? paths.map((p) => extractKnowledgeBodyRead(p)).filter((r) => r !== null)
|
|
282
|
+
: [];
|
|
283
|
+
if (reads.length === 0) return;
|
|
284
|
+
const tsMs = now instanceof Date ? now.getTime() : Number(now);
|
|
285
|
+
const callId =
|
|
286
|
+
typeof toolCallId === "string" && toolCallId.length > 0
|
|
287
|
+
? toolCallId
|
|
288
|
+
: `fallback:${randomUUID()}`;
|
|
289
|
+
const validSessionId =
|
|
290
|
+
typeof sessionId === "string" && sessionId.length > 0 ? sessionId : null;
|
|
291
|
+
const validToolName =
|
|
292
|
+
typeof toolName === "string" && toolName.length > 0 ? toolName : null;
|
|
293
|
+
const lines =
|
|
294
|
+
reads
|
|
295
|
+
.map((r) =>
|
|
296
|
+
JSON.stringify({
|
|
297
|
+
kind: "fabric-event",
|
|
298
|
+
id: `event:${randomUUID()}`,
|
|
299
|
+
ts: tsMs,
|
|
300
|
+
schema_version: 1,
|
|
301
|
+
...(validSessionId ? { session_id: validSessionId } : {}),
|
|
302
|
+
event_type: "knowledge_body_read",
|
|
303
|
+
stable_id: r.stable_id,
|
|
304
|
+
...(r.store ? { store: r.store } : {}),
|
|
305
|
+
path: r.path,
|
|
306
|
+
tool_call_id: callId,
|
|
307
|
+
...(validToolName ? { tool_name: validToolName } : {}),
|
|
308
|
+
}),
|
|
309
|
+
)
|
|
310
|
+
.join("\n") + "\n";
|
|
311
|
+
appendLockedLine(join(fabricDir, EVENTS_LEDGER_FILE), lines);
|
|
312
|
+
} catch {
|
|
313
|
+
// Silent — marker failure must never block the tool pipeline.
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
227
317
|
// -----------------------------------------------------------------------------
|
|
228
318
|
// Main — invoked as a CLI (require.main === module) and in-process by tests.
|
|
229
319
|
// Wraps the entire flow in try/catch: ANY error → silent exit 0.
|
|
@@ -238,7 +328,9 @@ function main(env) {
|
|
|
238
328
|
if (payload === null || payload === undefined) return;
|
|
239
329
|
|
|
240
330
|
const toolName = extractToolName(payload);
|
|
241
|
-
|
|
331
|
+
const isEdit = toolName && EDIT_TOOL_NAMES.has(toolName);
|
|
332
|
+
const isRead = toolName && READ_TOOL_NAMES.has(toolName);
|
|
333
|
+
if (!isEdit && !isRead) return;
|
|
242
334
|
|
|
243
335
|
const toolInput = extractToolInput(payload);
|
|
244
336
|
const paths = extractPaths(toolInput);
|
|
@@ -250,7 +342,13 @@ function main(env) {
|
|
|
250
342
|
? payload.session_id
|
|
251
343
|
: null;
|
|
252
344
|
|
|
253
|
-
|
|
345
|
+
if (isEdit) {
|
|
346
|
+
appendFileMutated(cwd, now, paths, toolCallId, toolName, sessionId);
|
|
347
|
+
} else {
|
|
348
|
+
// KT-DEC-0030: observe native Reads of store knowledge bodies. Non-
|
|
349
|
+
// knowledge Reads filter out inside appendKnowledgeBodyRead (zero events).
|
|
350
|
+
appendKnowledgeBodyRead(cwd, now, paths, toolCallId, toolName, sessionId);
|
|
351
|
+
}
|
|
254
352
|
} catch {
|
|
255
353
|
// Silent — never block the tool pipeline on hook failure.
|
|
256
354
|
}
|
|
@@ -265,10 +363,13 @@ module.exports = {
|
|
|
265
363
|
extractToolCallId,
|
|
266
364
|
normalizePath,
|
|
267
365
|
appendFileMutated,
|
|
366
|
+
extractKnowledgeBodyRead,
|
|
367
|
+
appendKnowledgeBodyRead,
|
|
268
368
|
CONSTANTS: {
|
|
269
369
|
FABRIC_DIR_REL,
|
|
270
370
|
EVENTS_LEDGER_FILE,
|
|
271
371
|
EDIT_TOOL_NAMES,
|
|
372
|
+
READ_TOOL_NAMES,
|
|
272
373
|
},
|
|
273
374
|
};
|
|
274
375
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fabric
|
|
3
|
+
description: Fabric 入口层路由 — 参考 maestro 的顺序协调方式,把用户意图分派到 fabric-archive/review/import/store/sync/audit/connect。Triggers fabric/知识库/归档/审批/store/同步/关联/审计.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# fabric — Fabric Skill Router
|
|
7
|
+
|
|
8
|
+
这是 Fabric 相关 skills 的入口层。它只负责理解用户意图、选择正确的下游 skill、按顺序直接调用;不直接读写 `~/.fabric` store,不自行解析 store 树,也不替代底层 `fabric-*` skills 的安全门。
|
|
9
|
+
|
|
10
|
+
## Routing Contract
|
|
11
|
+
|
|
12
|
+
1. 先判断用户要做的是哪类 Fabric 工作。
|
|
13
|
+
2. 只调用一个最合适的下游 skill;只有用户明确要求一组维护动作时,才按顺序调用多个。
|
|
14
|
+
3. 每一步完成后读取结果,再决定是否继续下一步。不要并发委派,也不要用 CSV/wave worker。
|
|
15
|
+
4. 如果目标涉及写入、审批、退役或关联,必须走对应下游 skill 的既有写路径;本入口 skill 不直接修改 `knowledge/`。
|
|
16
|
+
5. Store 状态只通过 `fabric info`、`fabric store ...`、`fabric sync`、MCP 工具或下游 skill 获取。MUST NOT 直接遍历或执行 `~/.fabric/stores/` 内容;store 是 data-only。
|
|
17
|
+
|
|
18
|
+
## Intent Map
|
|
19
|
+
|
|
20
|
+
<!-- fabric:router-intent:begin -->
|
|
21
|
+
<!-- 本块由 `fabric install` 从 7 个 leaf skill 的 description Triggers 子句生成。严禁手编;改 leaf description 后重跑 `fabric install`。 -->
|
|
22
|
+
|
|
23
|
+
| 用户意图(leaf description Triggers) | 下游 skill |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| 以后/always/never/下次/记一下;wrong-turn-revert;decision-confirm;dismissal-reason;/fabric-archive | `fabric-archive` |
|
|
26
|
+
| 审批/驳回/复审/重审/approve/reject/review pending | `fabric-review` |
|
|
27
|
+
| 导入历史/bootstrap fabric/mine changelog/挖掘 commit | `fabric-import` |
|
|
28
|
+
| 同步知识库/sync stores/fabric-sync/解决 store 冲突/rebase 冲突 | `fabric-sync` |
|
|
29
|
+
| 创建 store/挂载 store/绑定知识库/store 列表/切换写库/set up knowledge store | `fabric-store` |
|
|
30
|
+
| 审计知识库/清理陈旧知识/知识库体检/deprecate 条目/prune stale knowledge/知识库瘦身/淘汰旧决策 | `fabric-audit` |
|
|
31
|
+
| 连接知识/找关联条目/建知识图谱/link related entries/补 related 边/知识库连通性 | `fabric-connect` |
|
|
32
|
+
|
|
33
|
+
`S_CLASSIFY` 的 `task_type` 枚举:`archive | review | import | sync | store | audit | connect`
|
|
34
|
+
<!-- fabric:router-intent:end -->
|
|
35
|
+
|
|
36
|
+
## State Machine
|
|
37
|
+
|
|
38
|
+
### S_CLASSIFY
|
|
39
|
+
|
|
40
|
+
提取:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"task_type": "<Intent Map task_type 枚举之一>",
|
|
45
|
+
"scope": "project|store|entry|paths|null",
|
|
46
|
+
"write_intent": true,
|
|
47
|
+
"confidence": "high|medium|low"
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
低置信度时问 1 个短问题;不要一次性列长菜单。若用户只是说“fabric 帮我处理一下”,默认先运行 `fabric-audit` 做只读体检,再根据输出建议下一步。
|
|
52
|
+
|
|
53
|
+
### S_EXECUTE
|
|
54
|
+
|
|
55
|
+
按 `Intent Map` 直接调用下游 skill,例如:
|
|
56
|
+
|
|
57
|
+
- `fabric-archive "{用户原始意图}"`
|
|
58
|
+
- `fabric-review "{用户原始意图}"`
|
|
59
|
+
- `fabric-import "{用户原始意图}"`
|
|
60
|
+
- `fabric-store "{用户原始意图}"`
|
|
61
|
+
- `fabric-sync "{用户原始意图}"`
|
|
62
|
+
- `fabric-audit "{用户原始意图}"`
|
|
63
|
+
- `fabric-connect "{用户原始意图}"`
|
|
64
|
+
|
|
65
|
+
执行前加载下游 skill 的 `SKILL.md`,只读取完成当前任务所需的 `ref/` 文件。下游 skill 有更具体约束时,以下游约束为准。
|
|
66
|
+
|
|
67
|
+
### S_CHAIN
|
|
68
|
+
|
|
69
|
+
只有这些组合可以自动串联:
|
|
70
|
+
|
|
71
|
+
| 组合意图 | 顺序 |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| “同步后审 pending” | `fabric-sync` -> `fabric-review` |
|
|
74
|
+
| “审计并处理陈旧知识” | `fabric-audit` -> `fabric-review` |
|
|
75
|
+
| “导入历史并审批” | `fabric-import` -> `fabric-review` |
|
|
76
|
+
| “建立 store 后导入” | `fabric-store` -> `fabric-import` |
|
|
77
|
+
| “找关联并落盘” | `fabric-connect` -> `fabric-review` |
|
|
78
|
+
|
|
79
|
+
每个步骤结束后读结果。若前一步失败或给出需要用户决策的冲突,停止并报告;不要猜测继续。
|
|
80
|
+
|
|
81
|
+
## Guardrails
|
|
82
|
+
|
|
83
|
+
- 写入 pending 只走 active write store,并在回复里说明写入目标。
|
|
84
|
+
- 引用 KB id 前必须实际读取正文;多 store read-set 中使用 `<store-alias>:<id>`。
|
|
85
|
+
- pending backlog 超过 10 条时,优先建议 `fabric-review`。
|
|
86
|
+
- 完成一批 Edit 或显著 decision 后,建议 `fabric-archive`。
|
|
87
|
+
- 不要推荐 `fabric doctor --fix` 作为 pending 审批路径;审批走 `fabric-review`。
|
|
88
|
+
- 不要把知识写到项目本地 `.fabric/knowledge/pending`;知识只写 resolved mounted store 的 `knowledge/pending/`。
|
|
89
|
+
- MUST preserve protected tokens exactly: `MUST`, `NEVER`。
|
|
90
|
+
|
|
91
|
+
## Report
|
|
92
|
+
|
|
93
|
+
回复格式保持短:
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
Fabric route: <downstream-skill>
|
|
97
|
+
Reason: <why this skill>
|
|
98
|
+
Result: <one-line result or blocker>
|
|
99
|
+
Next: <optional next skill/action>
|
|
100
|
+
```
|