@fenglimg/fabric-cli 2.2.0-rc.4 → 2.2.0-rc.8
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-XC5RUHLK.js → chunk-3IOLS5EK.js} +23 -38
- package/dist/{chunk-XHHCRDIR.js → chunk-CMDW3PYK.js} +105 -220
- package/dist/chunk-FEOPLBGA.js +150 -0
- package/dist/{chunk-XCBVSGCS.js → chunk-FNHDQTPC.js} +1 -10
- package/dist/{chunk-2CY4BMTH.js → chunk-HORSMSZL.js} +9 -5
- package/dist/{doctor-U5W4CX5I.js → chunk-JTHWLUD3.js} +103 -51
- 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-REZDNH4A.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-2COC3DO3.js +3277 -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-62F4LNKI.js} +62 -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 +301 -161
- package/templates/hooks/knowledge-hint-broad.cjs +426 -207
- package/templates/hooks/knowledge-hint-narrow.cjs +56 -56
- package/templates/hooks/lib/banner-i18n.cjs +31 -0
- package/templates/hooks/lib/bindings-snapshot-reader.cjs +117 -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/lib/summary-fallback.cjs +82 -19
- package/templates/hooks/post-tooluse-mutation.cjs +112 -11
- package/templates/skills/fabric/SKILL.md +94 -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
|
@@ -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
|
};
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* rc.35 TASK-06 (P0-10.b) — summary-fallback library.
|
|
3
3
|
*
|
|
4
4
|
* Resolves opaque hint entries (where `entry.summary === entry.id` so the
|
|
5
|
-
* AI sees no information beyond the id) by reading the entry's markdown
|
|
6
|
-
*
|
|
5
|
+
* AI sees no information beyond the id) by reading the entry's markdown file
|
|
6
|
+
* from mounted store `knowledge/<type>/<id>--<slug>.md`, extracting the first
|
|
7
7
|
* paragraph under `## Summary`, and substituting that text into the entry
|
|
8
8
|
* before the hook renders it.
|
|
9
9
|
*
|
|
@@ -33,11 +33,13 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
const { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } = require("node:fs");
|
|
36
|
+
const { homedir } = require("node:os");
|
|
36
37
|
const { join } = require("node:path");
|
|
37
38
|
|
|
38
39
|
const CACHE_DIR_REL = ".fabric/.cache";
|
|
39
40
|
const CACHE_FILE_REL = ".fabric/.cache/summary-fallback.json";
|
|
40
|
-
const
|
|
41
|
+
const GLOBAL_CONFIG_FILE = "fabric-global.json";
|
|
42
|
+
const PROJECT_CONFIG_REL = ".fabric/fabric-config.json";
|
|
41
43
|
const SUMMARY_MAX_LEN = 80;
|
|
42
44
|
const KNOWLEDGE_TYPE_DIRS = ["decisions", "pitfalls", "guidelines", "models", "processes"];
|
|
43
45
|
|
|
@@ -112,7 +114,60 @@ function _writeCache(projectRoot, payload) {
|
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
/**
|
|
115
|
-
*
|
|
117
|
+
* Return mounted store directories in the project's read-set
|
|
118
|
+
* (`required_stores` plus implicit personal). This hook helper is deliberately
|
|
119
|
+
* tiny and best-effort: malformed config degrades to an empty read-set rather
|
|
120
|
+
* than throwing during a shell hook.
|
|
121
|
+
*/
|
|
122
|
+
function _readJson(path) {
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function _globalRoot() {
|
|
131
|
+
return join(process.env.FABRIC_HOME || homedir(), ".fabric");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _storeDir(globalRoot, store) {
|
|
135
|
+
return join(globalRoot, "stores", store.mount_name || store.store_uuid);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function _readSetStoreDirs(projectRoot) {
|
|
139
|
+
const globalRoot = _globalRoot();
|
|
140
|
+
const global = _readJson(join(globalRoot, GLOBAL_CONFIG_FILE));
|
|
141
|
+
if (!global || !Array.isArray(global.stores)) return [];
|
|
142
|
+
const project = _readJson(join(projectRoot, PROJECT_CONFIG_REL)) || {};
|
|
143
|
+
const required = Array.isArray(project.required_stores) ? project.required_stores : [];
|
|
144
|
+
const stores = [];
|
|
145
|
+
|
|
146
|
+
for (const req of required) {
|
|
147
|
+
if (!req || typeof req.id !== "string") continue;
|
|
148
|
+
const matched = global.stores.find(
|
|
149
|
+
(store) => store && !store.personal && (store.alias === req.id || store.store_uuid === req.id),
|
|
150
|
+
);
|
|
151
|
+
if (matched) stores.push(matched);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const personal = global.stores.find((store) => store && store.personal);
|
|
155
|
+
if (personal) stores.push(personal);
|
|
156
|
+
|
|
157
|
+
return stores.map((store) => ({
|
|
158
|
+
alias: typeof store.alias === "string" ? store.alias : "",
|
|
159
|
+
dir: _storeDir(globalRoot, store),
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function _splitQualifiedId(id) {
|
|
164
|
+
const idx = typeof id === "string" ? id.indexOf(":") : -1;
|
|
165
|
+
if (idx <= 0) return { alias: "", stableId: id };
|
|
166
|
+
return { alias: id.slice(0, idx), stableId: id.slice(idx + 1) };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Scan mounted store `knowledge/<type>/` for the canonical `<id>--<slug>.md`
|
|
116
171
|
* matching `stableId`. Tries the most likely type-dir first based on the
|
|
117
172
|
* entry's `type` hint, then falls back to scanning all canonical type
|
|
118
173
|
* directories. Returns the absolute path or null.
|
|
@@ -121,8 +176,11 @@ function _writeCache(projectRoot, payload) {
|
|
|
121
176
|
* once per file), so the first match wins.
|
|
122
177
|
*/
|
|
123
178
|
function _findEntryFile(projectRoot, stableId, typeHint) {
|
|
124
|
-
const
|
|
125
|
-
|
|
179
|
+
const parsedId = _splitQualifiedId(stableId);
|
|
180
|
+
const storeDirs = _readSetStoreDirs(projectRoot).filter(
|
|
181
|
+
(store) => parsedId.alias.length === 0 || store.alias === parsedId.alias,
|
|
182
|
+
);
|
|
183
|
+
if (storeDirs.length === 0) return null;
|
|
126
184
|
const tryOrder = [];
|
|
127
185
|
if (typeof typeHint === "string" && typeHint.length > 0) {
|
|
128
186
|
// Accept both singular and plural hints — find the plural form.
|
|
@@ -133,19 +191,23 @@ function _findEntryFile(projectRoot, stableId, typeHint) {
|
|
|
133
191
|
for (const t of KNOWLEDGE_TYPE_DIRS) {
|
|
134
192
|
if (!tryOrder.includes(t)) tryOrder.push(t);
|
|
135
193
|
}
|
|
136
|
-
const prefix = `${stableId}--`;
|
|
137
|
-
for (const
|
|
138
|
-
const
|
|
139
|
-
if (!existsSync(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
194
|
+
const prefix = `${parsedId.stableId}--`;
|
|
195
|
+
for (const store of storeDirs) {
|
|
196
|
+
const baseDir = join(store.dir, "knowledge");
|
|
197
|
+
if (!existsSync(baseDir)) continue;
|
|
198
|
+
for (const t of tryOrder) {
|
|
199
|
+
const typeDir = join(baseDir, t);
|
|
200
|
+
if (!existsSync(typeDir)) continue;
|
|
201
|
+
let files;
|
|
202
|
+
try {
|
|
203
|
+
files = readdirSync(typeDir);
|
|
204
|
+
} catch {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
for (const f of files) {
|
|
208
|
+
if (f.startsWith(prefix) && f.endsWith(".md")) {
|
|
209
|
+
return join(typeDir, f);
|
|
210
|
+
}
|
|
149
211
|
}
|
|
150
212
|
}
|
|
151
213
|
}
|
|
@@ -204,6 +266,7 @@ module.exports = {
|
|
|
204
266
|
_readCache,
|
|
205
267
|
_writeCache,
|
|
206
268
|
_findEntryFile,
|
|
269
|
+
_readSetStoreDirs,
|
|
207
270
|
_isOpaque,
|
|
208
271
|
SUMMARY_MAX_LEN,
|
|
209
272
|
KNOWLEDGE_TYPE_DIRS,
|
|
@@ -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,94 @@
|
|
|
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
|
+
| 用户意图 | 下游 skill |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| 记录/归档/以后记住/always/never/下次注意 | `fabric-archive` |
|
|
23
|
+
| 审批 pending、批量 approve/reject/modify/revisit/defer | `fabric-review` |
|
|
24
|
+
| 从 git log、docs 或历史材料冷启动导入知识 | `fabric-import` |
|
|
25
|
+
| 创建、挂载、绑定、列出、切换 write store | `fabric-store` |
|
|
26
|
+
| 多 store pull --rebase + push、同步冲突处理 | `fabric-sync` |
|
|
27
|
+
| 知识库体检、淘汰陈旧条目、deprecate、rescue-before-delete | `fabric-audit` |
|
|
28
|
+
| 发现 KB 条目关联、补 `related` 边、知识图谱连通性 | `fabric-connect` |
|
|
29
|
+
|
|
30
|
+
## State Machine
|
|
31
|
+
|
|
32
|
+
### S_CLASSIFY
|
|
33
|
+
|
|
34
|
+
提取:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"task_type": "archive|review|import|store|sync|audit|connect",
|
|
39
|
+
"scope": "project|store|entry|paths|null",
|
|
40
|
+
"write_intent": true,
|
|
41
|
+
"confidence": "high|medium|low"
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
低置信度时问 1 个短问题;不要一次性列长菜单。若用户只是说“fabric 帮我处理一下”,默认先运行 `fabric-audit` 做只读体检,再根据输出建议下一步。
|
|
46
|
+
|
|
47
|
+
### S_EXECUTE
|
|
48
|
+
|
|
49
|
+
按 `Intent Map` 直接调用下游 skill,例如:
|
|
50
|
+
|
|
51
|
+
- `fabric-archive "{用户原始意图}"`
|
|
52
|
+
- `fabric-review "{用户原始意图}"`
|
|
53
|
+
- `fabric-import "{用户原始意图}"`
|
|
54
|
+
- `fabric-store "{用户原始意图}"`
|
|
55
|
+
- `fabric-sync "{用户原始意图}"`
|
|
56
|
+
- `fabric-audit "{用户原始意图}"`
|
|
57
|
+
- `fabric-connect "{用户原始意图}"`
|
|
58
|
+
|
|
59
|
+
执行前加载下游 skill 的 `SKILL.md`,只读取完成当前任务所需的 `ref/` 文件。下游 skill 有更具体约束时,以下游约束为准。
|
|
60
|
+
|
|
61
|
+
### S_CHAIN
|
|
62
|
+
|
|
63
|
+
只有这些组合可以自动串联:
|
|
64
|
+
|
|
65
|
+
| 组合意图 | 顺序 |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| “同步后审 pending” | `fabric-sync` -> `fabric-review` |
|
|
68
|
+
| “审计并处理陈旧知识” | `fabric-audit` -> `fabric-review` |
|
|
69
|
+
| “导入历史并审批” | `fabric-import` -> `fabric-review` |
|
|
70
|
+
| “建立 store 后导入” | `fabric-store` -> `fabric-import` |
|
|
71
|
+
| “找关联并落盘” | `fabric-connect` -> `fabric-review` |
|
|
72
|
+
|
|
73
|
+
每个步骤结束后读结果。若前一步失败或给出需要用户决策的冲突,停止并报告;不要猜测继续。
|
|
74
|
+
|
|
75
|
+
## Guardrails
|
|
76
|
+
|
|
77
|
+
- 写入 pending 只走 active write store,并在回复里说明写入目标。
|
|
78
|
+
- 引用 KB id 前必须实际读取正文;多 store read-set 中使用 `<store-alias>:<id>`。
|
|
79
|
+
- pending backlog 超过 10 条时,优先建议 `fabric-review`。
|
|
80
|
+
- 完成一批 Edit 或显著 decision 后,建议 `fabric-archive`。
|
|
81
|
+
- 不要推荐 `fabric doctor --fix` 作为 pending 审批路径;审批走 `fabric-review`。
|
|
82
|
+
- 不要把知识写到项目本地 `.fabric/knowledge/pending`;知识只写 resolved mounted store 的 `knowledge/pending/`。
|
|
83
|
+
- MUST preserve protected tokens exactly: `MUST`, `NEVER`。
|
|
84
|
+
|
|
85
|
+
## Report
|
|
86
|
+
|
|
87
|
+
回复格式保持短:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
Fabric route: <downstream-skill>
|
|
91
|
+
Reason: <why this skill>
|
|
92
|
+
Result: <one-line result or blocker>
|
|
93
|
+
Next: <optional next skill/action>
|
|
94
|
+
```
|