@fenglimg/fabric-cli 2.2.0-rc.1 → 2.2.0-rc.11
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-AOE6AYI7.js → chunk-2KBCTMID.js} +31 -8
- package/dist/chunk-3D7B2UAZ.js +149 -0
- package/dist/{chunk-XC5RUHLK.js → chunk-3IOLS5EK.js} +23 -38
- package/dist/{plan-context-hint-FC6P3WFE.js → chunk-722JU5BP.js} +52 -12
- package/dist/{chunk-2R55HNVD.js → chunk-7ZDXBOOU.js} +234 -206
- package/dist/{doctor-YONYXDX6.js → chunk-E7HJUU34.js} +215 -52
- package/dist/chunk-EOT63RDH.js +36 -0
- package/dist/chunk-FNHDQTPC.js +16 -0
- package/dist/{chunk-2CY4BMTH.js → chunk-HORSMSZL.js} +9 -5
- package/dist/{chunk-BO4XIZWZ.js → chunk-NLNH64A3.js} +5 -18
- 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-XYRBZJDU.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 +133 -22
- package/dist/info-7FKBTMVO.js +139 -0
- package/dist/install-v2-WLEJ5XHT.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-5TNGH3R4.js +12 -0
- package/dist/{scope-explain-CDIZESP5.js → scope-explain-HLJZ2M33.js} +17 -6
- package/dist/status-4R3TM4FJ.js +37 -0
- package/dist/store-HOCORVL3.js +563 -0
- package/dist/{sync-UJ4BBCZJ.js → sync-DT5UJMMR.js} +197 -30
- package/dist/{uninstall-C3QXKOO6.js → uninstall-IFN2KYBK.js} +97 -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 +742 -259
- package/templates/hooks/knowledge-hint-broad.cjs +577 -274
- package/templates/hooks/knowledge-hint-narrow.cjs +113 -73
- package/templates/hooks/lib/banner-i18n.cjs +50 -1
- 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 +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 +47 -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 +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 +14 -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/chunk-4R2CYEA4.js +0 -116
- package/dist/chunk-L4Q55UC4.js +0 -52
- package/dist/chunk-LFIKMVY7.js +0 -27
- package/dist/chunk-RYAFBNES.js +0 -33
- package/dist/chunk-T5RPGCCM.js +0 -40
- package/dist/install-74ANPCCP.js +0 -2737
- package/dist/status-GLQWLWH6.js +0 -23
- package/dist/store-XB3ADT65.js +0 -144
- package/dist/whoami-2MLO4Y37.js +0 -36
- 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
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lifecycle-refactor W2-T3 — PostToolUse mutation marker hook (previously
|
|
4
|
+
* dormant). Closes the mutation env opened by the PreToolUse narrow hint.
|
|
5
|
+
*
|
|
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.
|
|
16
|
+
*
|
|
17
|
+
* Design (lifecycle-concept-final.md §1 FROZEN invariants + §5 row7):
|
|
18
|
+
* - LOW compute: extract paths + tool_call_id, append; the hook never reads
|
|
19
|
+
* or aggregates the ledger, never runs `git diff`. ALL mutation_pool /
|
|
20
|
+
* attribution work is doctor-side (offline, §5 row7).
|
|
21
|
+
* - hook = nudge/marker, never a gate (KT-DEC-0007): every error path ends
|
|
22
|
+
* in a silent exit 0; we never throw upward.
|
|
23
|
+
* - Front-stage O(1) per path: advisory-locked append, no traversal.
|
|
24
|
+
* - Per-event session_id: threaded from the REAL payload when present
|
|
25
|
+
* (omitted when the client omits it — the marker is still useful for the
|
|
26
|
+
* tool_call_id pairing even without a session).
|
|
27
|
+
* - Hooks never require() the server package — only co-located lib/*.cjs.
|
|
28
|
+
*
|
|
29
|
+
* Each emitted line matches `fileMutatedEventSchema`
|
|
30
|
+
* (packages/shared/src/schemas/event-ledger.ts):
|
|
31
|
+
* { kind:"fabric-event", id, ts, schema_version:1, session_id?,
|
|
32
|
+
* event_type:"file_mutated", path, tool_call_id, tool_name? }
|
|
33
|
+
*
|
|
34
|
+
* Stdout/stderr are intentionally empty — PostToolUse is observation-only and
|
|
35
|
+
* never blocks the host's tool pipeline.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const { randomUUID } = require("node:crypto");
|
|
39
|
+
const { existsSync } = require("node:fs");
|
|
40
|
+
const { isAbsolute, join, relative } = require("node:path");
|
|
41
|
+
|
|
42
|
+
// W1-01 (ISS-011) parity: route every shared-ledger append through the
|
|
43
|
+
// advisory-lock primitive so concurrent PostToolUse fires (multi-window /
|
|
44
|
+
// parallel edits) never interleave a partial line. Best-effort, drop-on-
|
|
45
|
+
// contention — same primitive the narrow/broad hooks use.
|
|
46
|
+
const { appendLockedLine } = require("./lib/injection-log.cjs");
|
|
47
|
+
|
|
48
|
+
const FABRIC_DIR_REL = ".fabric";
|
|
49
|
+
const EVENTS_LEDGER_FILE = "events.jsonl";
|
|
50
|
+
|
|
51
|
+
// Tool names that trigger the mutation marker. PostToolUse fires on many tool
|
|
52
|
+
// names across clients; we only react to the file-edit tools (matches the
|
|
53
|
+
// PreToolUse narrow hint's EDIT_TOOL_NAMES so Pre/Post pair on the same set).
|
|
54
|
+
const EDIT_TOOL_NAMES = new Set(["Edit", "Write", "MultiEdit"]);
|
|
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
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read stdin (or a test-supplied raw string) as JSON. Returns null on any
|
|
76
|
+
* parse failure — the hook stays silent rather than crashing the tool pipeline.
|
|
77
|
+
*/
|
|
78
|
+
function readPayload(rawStdin) {
|
|
79
|
+
if (typeof rawStdin !== "string" || rawStdin.length === 0) return null;
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(rawStdin);
|
|
82
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return parsed;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Extract the tool name. Mirrors the narrow hint's probe:
|
|
93
|
+
* - Claude Code / Codex: { tool_name, ... }
|
|
94
|
+
*/
|
|
95
|
+
function extractToolName(payload) {
|
|
96
|
+
if (!payload || typeof payload !== "object") return null;
|
|
97
|
+
if (typeof payload.tool_name === "string") return payload.tool_name;
|
|
98
|
+
if (typeof payload.tool === "string") return payload.tool;
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Extract the tool_input object from the `tool_input`
|
|
104
|
+
* (Claude/Codex) convention.
|
|
105
|
+
*/
|
|
106
|
+
function extractToolInput(payload) {
|
|
107
|
+
if (!payload || typeof payload !== "object") return null;
|
|
108
|
+
if (payload.tool_input && typeof payload.tool_input === "object") {
|
|
109
|
+
return payload.tool_input;
|
|
110
|
+
}
|
|
111
|
+
if (payload.input && typeof payload.input === "object") {
|
|
112
|
+
return payload.input;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Pull file paths out of a tool_input object. Same three shapes the narrow
|
|
119
|
+
* hint handles (single file_path / array file_paths / MultiEdit edits[]).
|
|
120
|
+
* Returns a deduped array of strings — empty when none recognizable.
|
|
121
|
+
*/
|
|
122
|
+
function extractPaths(toolInput) {
|
|
123
|
+
if (!toolInput || typeof toolInput !== "object") return [];
|
|
124
|
+
const collected = [];
|
|
125
|
+
|
|
126
|
+
if (typeof toolInput.file_path === "string" && toolInput.file_path.length > 0) {
|
|
127
|
+
collected.push(toolInput.file_path);
|
|
128
|
+
}
|
|
129
|
+
if (Array.isArray(toolInput.file_paths)) {
|
|
130
|
+
for (const p of toolInput.file_paths) {
|
|
131
|
+
if (typeof p === "string" && p.length > 0) collected.push(p);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (Array.isArray(toolInput.edits)) {
|
|
135
|
+
for (const edit of toolInput.edits) {
|
|
136
|
+
if (
|
|
137
|
+
edit &&
|
|
138
|
+
typeof edit === "object" &&
|
|
139
|
+
typeof edit.file_path === "string" &&
|
|
140
|
+
edit.file_path.length > 0
|
|
141
|
+
) {
|
|
142
|
+
collected.push(edit.file_path);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const seen = new Set();
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const p of collected) {
|
|
150
|
+
if (seen.has(p)) continue;
|
|
151
|
+
seen.add(p);
|
|
152
|
+
out.push(p);
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Extract the per-call id. Claude Code's PostToolUse payload carries the id of
|
|
159
|
+
* the originating tool_use block as `tool_use_id`; older drafts / other clients
|
|
160
|
+
* variously used `tool_call_id` / `call_id` / `id`. We probe in that order.
|
|
161
|
+
*
|
|
162
|
+
* Returns null when none is present — the caller then synthesizes a best-effort
|
|
163
|
+
* fallback key so the marker still lands (per W2-T3: "缺失则用 best-effort
|
|
164
|
+
* fallback key 但仍 append"). A fallback key cannot pair with the Pre half but
|
|
165
|
+
* still records the mutation, which is strictly better than dropping it.
|
|
166
|
+
*/
|
|
167
|
+
function extractToolCallId(payload) {
|
|
168
|
+
if (!payload || typeof payload !== "object") return null;
|
|
169
|
+
const candidates = ["tool_use_id", "tool_call_id", "call_id", "id"];
|
|
170
|
+
for (const key of candidates) {
|
|
171
|
+
const v = payload[key];
|
|
172
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Normalize a path to a project-relative, forward-slash form. Drops paths that
|
|
179
|
+
* escape the project tree (mirrors appendEditIntentToLedger in the narrow
|
|
180
|
+
* hook). Returns null for out-of-tree / empty.
|
|
181
|
+
*/
|
|
182
|
+
function normalizePath(projectRoot, p) {
|
|
183
|
+
if (typeof p !== "string" || p.length === 0) return null;
|
|
184
|
+
let rel;
|
|
185
|
+
if (isAbsolute(p)) {
|
|
186
|
+
rel = relative(projectRoot, p);
|
|
187
|
+
if (rel.startsWith("..")) return null;
|
|
188
|
+
} else {
|
|
189
|
+
if (p.startsWith("..")) return null;
|
|
190
|
+
rel = p;
|
|
191
|
+
}
|
|
192
|
+
const slashed = rel.split(/[\\/]/).join("/");
|
|
193
|
+
return slashed.length > 0 ? slashed : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Append one `file_mutated` marker per edited path to `.fabric/events.jsonl`.
|
|
198
|
+
* Best-effort:
|
|
199
|
+
* - Skips silently when `.fabric/` does not exist (project not init'd).
|
|
200
|
+
* - Skips silently when there are no in-tree paths.
|
|
201
|
+
* - ANY error (append, JSON throw) is swallowed — never blocks the pipeline.
|
|
202
|
+
*
|
|
203
|
+
* One JSON line per path (PIPE_BUF-atomic). The per-path lines share a single
|
|
204
|
+
* tool_call_id so doctor can group them as one tool call.
|
|
205
|
+
*/
|
|
206
|
+
function appendFileMutated(projectRoot, now, paths, toolCallId, toolName, sessionId) {
|
|
207
|
+
try {
|
|
208
|
+
const fabricDir = join(projectRoot, FABRIC_DIR_REL);
|
|
209
|
+
if (!existsSync(fabricDir)) return;
|
|
210
|
+
const pathList = Array.isArray(paths)
|
|
211
|
+
? paths.map((p) => normalizePath(projectRoot, p)).filter((p) => p !== null)
|
|
212
|
+
: [];
|
|
213
|
+
if (pathList.length === 0) return;
|
|
214
|
+
const tsMs = now instanceof Date ? now.getTime() : Number(now);
|
|
215
|
+
// Best-effort fallback key when the client omits the tool-call id: the
|
|
216
|
+
// mutation is still recorded (can't pair with the Pre half, but the path
|
|
217
|
+
// signal is preserved). Per-fire UUID keeps parallel fires distinct.
|
|
218
|
+
const callId =
|
|
219
|
+
typeof toolCallId === "string" && toolCallId.length > 0
|
|
220
|
+
? toolCallId
|
|
221
|
+
: `fallback:${randomUUID()}`;
|
|
222
|
+
const validSessionId =
|
|
223
|
+
typeof sessionId === "string" && sessionId.length > 0 ? sessionId : null;
|
|
224
|
+
const validToolName =
|
|
225
|
+
typeof toolName === "string" && toolName.length > 0 ? toolName : null;
|
|
226
|
+
const lines =
|
|
227
|
+
pathList
|
|
228
|
+
.map((p) =>
|
|
229
|
+
JSON.stringify({
|
|
230
|
+
kind: "fabric-event",
|
|
231
|
+
id: `event:${randomUUID()}`,
|
|
232
|
+
ts: tsMs,
|
|
233
|
+
schema_version: 1,
|
|
234
|
+
...(validSessionId ? { session_id: validSessionId } : {}),
|
|
235
|
+
event_type: "file_mutated",
|
|
236
|
+
path: p,
|
|
237
|
+
tool_call_id: callId,
|
|
238
|
+
...(validToolName ? { tool_name: validToolName } : {}),
|
|
239
|
+
}),
|
|
240
|
+
)
|
|
241
|
+
.join("\n") + "\n";
|
|
242
|
+
appendLockedLine(join(fabricDir, EVENTS_LEDGER_FILE), lines);
|
|
243
|
+
} catch {
|
|
244
|
+
// Silent — marker failure must never block the tool pipeline.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
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
|
+
|
|
317
|
+
// -----------------------------------------------------------------------------
|
|
318
|
+
// Main — invoked as a CLI (require.main === module) and in-process by tests.
|
|
319
|
+
// Wraps the entire flow in try/catch: ANY error → silent exit 0.
|
|
320
|
+
// -----------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
function main(env) {
|
|
323
|
+
try {
|
|
324
|
+
const cwd = (env && env.cwd) || process.cwd();
|
|
325
|
+
const now = (env && env.now) || new Date();
|
|
326
|
+
const payload =
|
|
327
|
+
env && env.payload !== undefined ? env.payload : readPayload(env && env.stdin);
|
|
328
|
+
if (payload === null || payload === undefined) return;
|
|
329
|
+
|
|
330
|
+
const toolName = extractToolName(payload);
|
|
331
|
+
const isEdit = toolName && EDIT_TOOL_NAMES.has(toolName);
|
|
332
|
+
const isRead = toolName && READ_TOOL_NAMES.has(toolName);
|
|
333
|
+
if (!isEdit && !isRead) return;
|
|
334
|
+
|
|
335
|
+
const toolInput = extractToolInput(payload);
|
|
336
|
+
const paths = extractPaths(toolInput);
|
|
337
|
+
if (paths.length === 0) return;
|
|
338
|
+
|
|
339
|
+
const toolCallId = extractToolCallId(payload);
|
|
340
|
+
const sessionId =
|
|
341
|
+
payload && typeof payload === "object" && typeof payload.session_id === "string"
|
|
342
|
+
? payload.session_id
|
|
343
|
+
: null;
|
|
344
|
+
|
|
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
|
+
}
|
|
352
|
+
} catch {
|
|
353
|
+
// Silent — never block the tool pipeline on hook failure.
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
module.exports = {
|
|
358
|
+
main,
|
|
359
|
+
readPayload,
|
|
360
|
+
extractToolName,
|
|
361
|
+
extractToolInput,
|
|
362
|
+
extractPaths,
|
|
363
|
+
extractToolCallId,
|
|
364
|
+
normalizePath,
|
|
365
|
+
appendFileMutated,
|
|
366
|
+
extractKnowledgeBodyRead,
|
|
367
|
+
appendKnowledgeBodyRead,
|
|
368
|
+
CONSTANTS: {
|
|
369
|
+
FABRIC_DIR_REL,
|
|
370
|
+
EVENTS_LEDGER_FILE,
|
|
371
|
+
EDIT_TOOL_NAMES,
|
|
372
|
+
READ_TOOL_NAMES,
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
if (require.main === module) {
|
|
377
|
+
// Read stdin synchronously (small hook payloads, no concurrency concerns).
|
|
378
|
+
let stdinRaw = "";
|
|
379
|
+
try {
|
|
380
|
+
stdinRaw = require("node:fs").readFileSync(0, "utf8");
|
|
381
|
+
} catch {
|
|
382
|
+
// No stdin — proceed with empty payload (E*: no append without paths).
|
|
383
|
+
}
|
|
384
|
+
main({ cwd: process.cwd(), now: new Date(), stdin: stdinRaw });
|
|
385
|
+
process.exit(0);
|
|
386
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lifecycle-refactor W2-T2 — SessionEnd marker hook (previously dormant).
|
|
4
|
+
*
|
|
5
|
+
* SessionEnd fires once when a client session boots down. This hook is a
|
|
6
|
+
* PURE MARKER: it appends a single `session_ended` event (session_id + ts,
|
|
7
|
+
* via the shared envelope) to `.fabric/events.jsonl` and does NOTHING else.
|
|
8
|
+
*
|
|
9
|
+
* Design (lifecycle-concept-final.md §1 FROZEN invariants + §5 row2):
|
|
10
|
+
* - ZERO compute: the hook never reads/aggregates the ledger. ALL
|
|
11
|
+
* surfaced→cited→edited funnel reconciliation is doctor-side (offline).
|
|
12
|
+
* - hook = nudge/marker, never a gate (KT-DEC-0007): every error path ends
|
|
13
|
+
* in a silent exit 0; we never throw upward.
|
|
14
|
+
* - Front-stage O(1): one advisory-locked append, no traversal.
|
|
15
|
+
* - Per-event session_id: when the client omits session_id we DEGRADE by
|
|
16
|
+
* skipping the append entirely (a marker with no session is useless for
|
|
17
|
+
* the per-session funnel doctor reconstructs).
|
|
18
|
+
* - Hooks never require() the server package — only the co-located lib/*.cjs.
|
|
19
|
+
*
|
|
20
|
+
* The emitted line matches `sessionEndedEventSchema`
|
|
21
|
+
* (packages/shared/src/schemas/event-ledger.ts):
|
|
22
|
+
* { kind:"fabric-event", id, ts, schema_version:1, session_id?, event_type:"session_ended" }
|
|
23
|
+
*
|
|
24
|
+
* Stdout/stderr are intentionally empty — SessionEnd is observation-only.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const { randomUUID } = require("node:crypto");
|
|
28
|
+
const { existsSync } = require("node:fs");
|
|
29
|
+
const { join } = require("node:path");
|
|
30
|
+
|
|
31
|
+
// W1-01 (ISS-011) parity: route the shared-ledger append through the
|
|
32
|
+
// advisory-lock primitive so concurrent SessionEnd fires (multi-window) never
|
|
33
|
+
// interleave a partial line. Drop-on-contention, best-effort — same primitive
|
|
34
|
+
// the narrow/broad hooks use.
|
|
35
|
+
const { appendLockedLine } = require("./lib/injection-log.cjs");
|
|
36
|
+
|
|
37
|
+
const FABRIC_DIR_REL = ".fabric";
|
|
38
|
+
const EVENTS_LEDGER_FILE = "events.jsonl";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read stdin (or a test-supplied raw string) as JSON. Returns null on any
|
|
42
|
+
* parse failure — the hook stays silent rather than crashing session teardown.
|
|
43
|
+
*/
|
|
44
|
+
function readPayload(rawStdin) {
|
|
45
|
+
if (typeof rawStdin !== "string" || rawStdin.length === 0) return null;
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(rawStdin);
|
|
48
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return parsed;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Extract the REAL payload session_id (never a synthetic fallback). The
|
|
59
|
+
* funnel reconciliation in doctor keys on the client's own session id, so a
|
|
60
|
+
* fabricated id would silently corrupt the per-session join. Returns null when
|
|
61
|
+
* absent — the caller then skips the append (degraded, per §1).
|
|
62
|
+
*/
|
|
63
|
+
function extractSessionId(payload) {
|
|
64
|
+
if (
|
|
65
|
+
payload &&
|
|
66
|
+
typeof payload === "object" &&
|
|
67
|
+
typeof payload.session_id === "string" &&
|
|
68
|
+
payload.session_id.length > 0
|
|
69
|
+
) {
|
|
70
|
+
return payload.session_id;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Append one `session_ended` marker to `.fabric/events.jsonl`. Best-effort:
|
|
77
|
+
* - Skips silently when `.fabric/` does not exist (project not init'd).
|
|
78
|
+
* - Skips silently when sessionId is null (degraded — no session to mark).
|
|
79
|
+
* - ANY error (append, JSON throw) is swallowed — never blocks teardown.
|
|
80
|
+
*/
|
|
81
|
+
function appendSessionEnded(projectRoot, now, sessionId) {
|
|
82
|
+
try {
|
|
83
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return;
|
|
84
|
+
const fabricDir = join(projectRoot, FABRIC_DIR_REL);
|
|
85
|
+
if (!existsSync(fabricDir)) return;
|
|
86
|
+
const tsMs = now instanceof Date ? now.getTime() : Number(now);
|
|
87
|
+
const event = {
|
|
88
|
+
kind: "fabric-event",
|
|
89
|
+
id: `event:${randomUUID()}`,
|
|
90
|
+
ts: tsMs,
|
|
91
|
+
schema_version: 1,
|
|
92
|
+
session_id: sessionId,
|
|
93
|
+
event_type: "session_ended",
|
|
94
|
+
};
|
|
95
|
+
appendLockedLine(join(fabricDir, EVENTS_LEDGER_FILE), JSON.stringify(event) + "\n");
|
|
96
|
+
} catch {
|
|
97
|
+
// Silent — marker failure must never block session teardown.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// -----------------------------------------------------------------------------
|
|
102
|
+
// Main — invoked as a CLI (require.main === module) and in-process by tests.
|
|
103
|
+
// Wraps the entire flow in try/catch: ANY error → silent exit 0.
|
|
104
|
+
// -----------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function main(env) {
|
|
107
|
+
try {
|
|
108
|
+
const cwd = (env && env.cwd) || process.cwd();
|
|
109
|
+
const now = (env && env.now) || new Date();
|
|
110
|
+
const payload =
|
|
111
|
+
env && env.payload !== undefined ? env.payload : readPayload(env && env.stdin);
|
|
112
|
+
const sessionId = extractSessionId(payload);
|
|
113
|
+
appendSessionEnded(cwd, now, sessionId);
|
|
114
|
+
} catch {
|
|
115
|
+
// Silent — never block session teardown on hook failure.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
main,
|
|
121
|
+
readPayload,
|
|
122
|
+
extractSessionId,
|
|
123
|
+
appendSessionEnded,
|
|
124
|
+
CONSTANTS: {
|
|
125
|
+
FABRIC_DIR_REL,
|
|
126
|
+
EVENTS_LEDGER_FILE,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
if (require.main === module) {
|
|
131
|
+
// Read stdin synchronously (small hook payloads, no concurrency concerns).
|
|
132
|
+
let stdinRaw = "";
|
|
133
|
+
try {
|
|
134
|
+
stdinRaw = require("node:fs").readFileSync(0, "utf8");
|
|
135
|
+
} catch {
|
|
136
|
+
// No stdin — proceed with empty payload (degrades to a no-op append).
|
|
137
|
+
}
|
|
138
|
+
main({ cwd: process.cwd(), now: new Date(), stdin: stdinRaw });
|
|
139
|
+
process.exit(0);
|
|
140
|
+
}
|
|
@@ -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
|
+
```
|