@fenglimg/fabric-cli 2.0.1 → 2.2.0-rc.1
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/dist/{chunk-PWLW3B57.js → chunk-2CY4BMTH.js} +5 -1
- package/dist/{chunk-D25XJ4BC.js → chunk-2R55HNVD.js} +105 -5
- package/dist/chunk-4R2CYEA4.js +116 -0
- package/dist/{chunk-BATF4PEJ.js → chunk-AOE6AYI7.js} +2 -2
- package/dist/{chunk-WWNXR34K.js → chunk-BO4XIZWZ.js} +8 -1
- package/dist/chunk-L4Q55UC4.js +52 -0
- package/dist/chunk-LFIKMVY7.js +27 -0
- package/dist/chunk-RYAFBNES.js +33 -0
- package/dist/chunk-T5RPGCCM.js +40 -0
- package/dist/chunk-WU6GAPKH.js +36 -0
- package/dist/{chunk-MF3OTILQ.js → chunk-XC5RUHLK.js} +29 -8
- package/dist/{config-XJIPZNUP.js → config-XYRBZJDU.js} +3 -3
- package/dist/{doctor-EJDSEJSS.js → doctor-YONYXDX6.js} +147 -24
- package/dist/index.js +58 -10
- package/dist/{install-EKWMFLUU.js → install-74ANPCCP.js} +320 -75
- package/dist/{metrics-ACEQFPDU.js → metrics-RER6NLFC.js} +22 -9
- package/dist/{onboard-coverage-MFCAEBDO.js → onboard-coverage-JWQWDZW7.js} +1 -1
- package/dist/scope-explain-CDIZESP5.js +37 -0
- package/dist/status-GLQWLWH6.js +23 -0
- package/dist/store-XB3ADT65.js +144 -0
- package/dist/sync-UJ4BBCZJ.js +251 -0
- package/dist/{uninstall-MH7ZIB6M.js → uninstall-C3QXKOO6.js} +47 -7
- package/dist/whoami-2MLO4Y37.js +36 -0
- package/package.json +3 -3
- package/templates/hooks/fabric-hint.cjs +139 -7
- package/templates/hooks/knowledge-hint-broad.cjs +204 -9
- package/templates/hooks/knowledge-hint-narrow.cjs +49 -4
- package/templates/hooks/lib/bindings-snapshot-reader.cjs +81 -0
- package/templates/hooks/lib/cite-contract-reminder.cjs +15 -9
- package/templates/hooks/lib/cite-line-parser.cjs +48 -26
- package/templates/hooks/lib/injection-log.cjs +91 -0
- package/templates/hooks/lib/state-store.cjs +30 -11
- package/templates/skills/fabric-archive/SKILL.md +4 -0
- package/templates/skills/fabric-audit/SKILL.md +53 -0
- package/templates/skills/fabric-connect/SKILL.md +48 -0
- package/templates/skills/fabric-import/SKILL.md +4 -0
- package/templates/skills/fabric-review/SKILL.md +6 -0
- package/templates/skills/fabric-review/ref/cite-contract.md +56 -0
- package/templates/skills/fabric-store/SKILL.md +44 -0
- package/templates/skills/fabric-sync/SKILL.md +46 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// v2.1.0-rc.1 P4 — hook-side resolved-bindings snapshot reader (F4/S63/S65).
|
|
3
|
+
//
|
|
4
|
+
// Hooks are a REMINDER layer (KT-DEC-0007) and must never block. They are also
|
|
5
|
+
// FORBIDDEN from re-resolving stores or walking `.fabric` store trees directly
|
|
6
|
+
// — a hook reads ONLY the CLI-pre-generated snapshot at
|
|
7
|
+
// `~/.fabric/state/bindings/<project_id>_resolved.json` (written by P3
|
|
8
|
+
// install/sync/bind). This keeps the resolver logic in one place (the CLI) and
|
|
9
|
+
// keeps hooks a thin, store-unaware-by-construction projection. Missing /
|
|
10
|
+
// unreadable / malformed snapshot → null (harmless degrade; the hook proceeds
|
|
11
|
+
// without store labels). Zero-dep CJS so it inline-loads at hook runtime.
|
|
12
|
+
|
|
13
|
+
const { existsSync, readFileSync } = require("node:fs");
|
|
14
|
+
const { join } = require("node:path");
|
|
15
|
+
const { homedir } = require("node:os");
|
|
16
|
+
|
|
17
|
+
// `~/.fabric` (FABRIC_HOME override mirrors the CLI's resolveGlobalRoot).
|
|
18
|
+
function resolveGlobalRoot() {
|
|
19
|
+
return join(process.env.FABRIC_HOME || homedir(), ".fabric");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function bindingsSnapshotPath(projectId, globalRoot) {
|
|
23
|
+
return join(
|
|
24
|
+
globalRoot || resolveGlobalRoot(),
|
|
25
|
+
"state",
|
|
26
|
+
"bindings",
|
|
27
|
+
projectId + "_resolved.json",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Read + shallow-validate the snapshot. Returns the parsed object, or null when
|
|
32
|
+
// absent / unreadable / not the expected shape. NEVER throws.
|
|
33
|
+
function readBindingsSnapshot(projectId, globalRoot) {
|
|
34
|
+
if (typeof projectId !== "string" || projectId.length === 0) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const path = bindingsSnapshotPath(projectId, globalRoot);
|
|
38
|
+
if (!existsSync(path)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
43
|
+
if (
|
|
44
|
+
parsed &&
|
|
45
|
+
typeof parsed === "object" &&
|
|
46
|
+
parsed.read_set &&
|
|
47
|
+
Array.isArray(parsed.read_set.stores)
|
|
48
|
+
) {
|
|
49
|
+
return parsed;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Render a compact, per-store label line for a SessionStart / Stop hook from a
|
|
58
|
+
// snapshot. Empty string when there is nothing to show (degrade silently). The
|
|
59
|
+
// label is provenance only — it never re-resolves; it just echoes the read-set
|
|
60
|
+
// the CLI already computed, with the write-target flagged (F4 store labels).
|
|
61
|
+
function formatStoreLabels(snapshot) {
|
|
62
|
+
if (!snapshot || !snapshot.read_set || !Array.isArray(snapshot.read_set.stores)) {
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
const writeAlias = snapshot.write_target && snapshot.write_target.alias;
|
|
66
|
+
const parts = snapshot.read_set.stores.map((store) => {
|
|
67
|
+
const tag = store.alias === writeAlias ? " (write)" : store.writable ? "" : " (ro)";
|
|
68
|
+
return store.alias + tag;
|
|
69
|
+
});
|
|
70
|
+
if (parts.length === 0) {
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
73
|
+
return "[fabric] read-set stores: " + parts.join(", ");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
resolveGlobalRoot,
|
|
78
|
+
bindingsSnapshotPath,
|
|
79
|
+
readBindingsSnapshot,
|
|
80
|
+
formatStoreLabels,
|
|
81
|
+
};
|
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
// Reads `.fabric/agents.meta.json` to build a stable_id → knowledge_type lookup
|
|
4
4
|
// map, then scans summarised assistant turns (cite_ids + cite_tags +
|
|
5
5
|
// cite_commitments parallel arrays produced by lib/cite-line-parser.cjs) for
|
|
6
|
-
// turns that cited a decision-class or pitfall-class id with [
|
|
7
|
-
// but no operator commitment and no skip:<reason>.
|
|
6
|
+
// turns that cited a decision-class or pitfall-class id with [applied] tag
|
|
7
|
+
// but no operator commitment and no skip:<reason>. (v2.1.0-rc.1 ADJ-P4-1:
|
|
8
|
+
// legacy [recalled] is remapped to [applied] by the parser upstream.)
|
|
8
9
|
//
|
|
9
10
|
// Emits one reminder line per offending id (deduplicated across the turn
|
|
10
11
|
// summary). Non-blocking — caller writes the lines to stderr; failure to
|
|
11
12
|
// load the meta file or absence of offenders means zero output.
|
|
12
13
|
//
|
|
13
14
|
// Reminder template (rc.24 lock B2 / L1 enforcement layer):
|
|
14
|
-
// ⚠ KB: <id> cited as [
|
|
15
|
+
// ⚠ KB: <id> cited as [applied] but missing contract; add → edit:<glob>
|
|
15
16
|
// or → skip:<reason> next turn
|
|
16
17
|
//
|
|
17
18
|
// Type filter rationale: only `decision` and `pitfall` types are contract-
|
|
@@ -34,7 +35,7 @@ const { join } = require("node:path");
|
|
|
34
35
|
const FABRIC_DIR = ".fabric";
|
|
35
36
|
const AGENTS_META_FILE = "agents.meta.json";
|
|
36
37
|
|
|
37
|
-
// Knowledge types that require contract commitments on [
|
|
38
|
+
// Knowledge types that require contract commitments on [applied] cites.
|
|
38
39
|
// Matches the singular form persisted by `withDerivedAgentsMetaNodeDefaults`
|
|
39
40
|
// in packages/shared/src/schemas/agents-meta.ts. We accept both singular
|
|
40
41
|
// and plural defensively so a future schema change to plurals doesn't
|
|
@@ -98,13 +99,17 @@ function readKnowledgeTypeMap(projectRoot) {
|
|
|
98
99
|
* don't, returning the reminder lines to emit.
|
|
99
100
|
*
|
|
100
101
|
* Filter (all must hold for a given index i within a turn):
|
|
101
|
-
* 1. cite_tags includes "
|
|
102
|
+
* 1. cite_tags includes "applied" (turn-level — applies to the cited id)
|
|
102
103
|
* 2. cite_commitments[i].operators is empty AND cite_commitments[i].skip_reason is null
|
|
103
104
|
* 3. idTypeMap.get(cite_ids[i]) is in {decision, pitfall}
|
|
104
105
|
*
|
|
106
|
+
* v2.1.0-rc.1 (ADJ-P4-1, full remap): the gate is the rc.37 NEW-1 `applied`
|
|
107
|
+
* tag. Legacy [recalled] cites are remapped to [applied] by the parser before
|
|
108
|
+
* they reach here, so gating on "applied" covers both old and new authoring.
|
|
109
|
+
*
|
|
105
110
|
* Tag-level filter clarification: rc.20 cite_tags is parallel to ALL parsed
|
|
106
111
|
* lines (including sentinels), but for the contract-missing reminder we use
|
|
107
|
-
* the turn-level semantic — if the assistant tagged the cite as [
|
|
112
|
+
* the turn-level semantic — if the assistant tagged the cite as [applied],
|
|
108
113
|
* the operator-or-skip contract applies. Per TASK-04 invariant, cite_ids and
|
|
109
114
|
* cite_commitments are parallel index-aligned arrays (length-N each).
|
|
110
115
|
*
|
|
@@ -131,8 +136,9 @@ function formatContractMissingReminders({ assistant_turns, idTypeMap }) {
|
|
|
131
136
|
const citeTags = Array.isArray(turn.cite_tags) ? turn.cite_tags : [];
|
|
132
137
|
const commitments = Array.isArray(turn.cite_commitments) ? turn.cite_commitments : [];
|
|
133
138
|
|
|
134
|
-
// Turn-level: the [
|
|
135
|
-
|
|
139
|
+
// Turn-level: the [applied] tag must appear in the turn's tag set
|
|
140
|
+
// (v2.1.0-rc.1 ADJ-P4-1: legacy [recalled] is remapped to [applied]).
|
|
141
|
+
if (!citeTags.includes("applied")) continue;
|
|
136
142
|
|
|
137
143
|
// Iterate by cite_ids.length — sentinel entries don't have ids so they
|
|
138
144
|
// contribute zero iterations even if cite_tags carries "none".
|
|
@@ -160,7 +166,7 @@ function formatContractMissingReminders({ assistant_turns, idTypeMap }) {
|
|
|
160
166
|
const reminders = [];
|
|
161
167
|
for (const id of offenders) {
|
|
162
168
|
reminders.push(
|
|
163
|
-
`⚠ KB: ${id} cited as [
|
|
169
|
+
`⚠ KB: ${id} cited as [applied] but missing contract; add \`→ edit:<glob>\` or \`→ skip:<reason>\` next turn`,
|
|
164
170
|
);
|
|
165
171
|
}
|
|
166
172
|
return reminders;
|
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
// auto-ships to cc/codex/cursor with no install pipeline change.
|
|
19
19
|
//
|
|
20
20
|
// Vocabulary contract (mirrored 1:1 with the TS source):
|
|
21
|
-
// - cite_tags enum:
|
|
21
|
+
// - cite_tags enum: applied | dismissed | none (rc.37 NEW-1 2-state vocab).
|
|
22
|
+
// v2.1.0-rc.1 (ADJ-P4-1, full remap): legacy rc≤36 tags (planned / recalled
|
|
23
|
+
// / chained-from) are REMAPPED to `applied` here — accepted as input but no
|
|
24
|
+
// longer emitted verbatim, so the TS source and this twin stay in lockstep.
|
|
22
25
|
// - operator kinds: edit | not_edit | require | forbid
|
|
23
26
|
// (source token `!edit:` → schema kind `not_edit`)
|
|
24
27
|
// - skip:<reason> captures everything after the first colon, so
|
|
@@ -29,31 +32,44 @@
|
|
|
29
32
|
const ID_RE = /^K[TP]-[A-Z]+-\d+$/;
|
|
30
33
|
const SENTINEL_RE = /^KB:\s*none\b\s*(?:\[[^\]]*\])?\s*$/i;
|
|
31
34
|
// v2.0.0-rc.27 TASK-003 (audit §2.18): multi-id citations supported via
|
|
32
|
-
// comma-separated ID group.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
// comma-separated ID group. v2.1.0-rc.1 P4 (F3/S62): each id may carry an
|
|
36
|
+
// optional `<store>:` prefix. Mirrors packages/shared/src/cite-line-parser.ts.
|
|
37
|
+
const QUALIFIED_ID = "(?:[^\\s,:]+:)?K[TP]-[A-Z]+-\\d+";
|
|
38
|
+
const FULL_RE = new RegExp(
|
|
39
|
+
"^KB:\\s+(" +
|
|
40
|
+
QUALIFIED_ID +
|
|
41
|
+
"(?:\\s*,\\s*" +
|
|
42
|
+
QUALIFIED_ID +
|
|
43
|
+
")*)(?:\\s+\\(([^)]*)\\))?(?:\\s+\\[([^\\]]+)\\])?(?:\\s+→\\s*(.+))?\\s*$",
|
|
44
|
+
);
|
|
35
45
|
const CHAINED_FROM_ID_RE = /chained-from\s+(K[TP]-[A-Z]+-\d+)/i;
|
|
36
46
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
// Split `<store>:<id>` into qualifier + local id; bare id → null qualifier.
|
|
48
|
+
function splitStorePrefix(token) {
|
|
49
|
+
const colon = token.lastIndexOf(":");
|
|
50
|
+
return colon === -1
|
|
51
|
+
? { store: null, id: token }
|
|
52
|
+
: { store: token.slice(0, colon), id: token.slice(colon + 1) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// v2.1.0-rc.1 (ADJ-P4-1, full remap): legacy rc≤36 tags collapse to `applied`.
|
|
56
|
+
// Mirrors LEGACY_CITE_TAG_REMAP / normalizeCiteTag in the TS source — accepted
|
|
57
|
+
// as input but emitted as the 2-state vocab so cite-coverage never undercounts.
|
|
58
|
+
const LEGACY_CITE_TAG_REMAP = {
|
|
59
|
+
planned: "applied",
|
|
60
|
+
recalled: "applied",
|
|
61
|
+
"chained-from": "applied",
|
|
62
|
+
};
|
|
50
63
|
|
|
51
64
|
function parseTag(rawTag) {
|
|
52
65
|
if (!rawTag) return "none";
|
|
53
66
|
// Tags may carry tails like `chained-from KT-DEC-0001` or
|
|
54
67
|
// `dismissed:scope-mismatch`; head token (whitespace/colon-bounded) wins.
|
|
55
68
|
const head = rawTag.trim().split(/[\s:]+/)[0].toLowerCase();
|
|
56
|
-
|
|
69
|
+
if (head === "applied" || head === "dismissed" || head === "none") {
|
|
70
|
+
return head;
|
|
71
|
+
}
|
|
72
|
+
return LEGACY_CITE_TAG_REMAP[head] || "none";
|
|
57
73
|
}
|
|
58
74
|
|
|
59
75
|
function parseContractTail(tail) {
|
|
@@ -89,17 +105,21 @@ function parseLine(line) {
|
|
|
89
105
|
const trimmed = line.trim();
|
|
90
106
|
if (trimmed.length === 0) return null;
|
|
91
107
|
if (SENTINEL_RE.test(trimmed)) {
|
|
92
|
-
return { ids: [], tag: "none", commitment: null };
|
|
108
|
+
return { ids: [], stores: [], tag: "none", commitment: null };
|
|
93
109
|
}
|
|
94
110
|
const fullMatch = trimmed.match(FULL_RE);
|
|
95
111
|
if (fullMatch) {
|
|
96
112
|
// v2.0.0-rc.27 TASK-003 (audit §2.18): split + revalidate each id;
|
|
97
|
-
// capture chained-from tail id when present.
|
|
98
|
-
|
|
113
|
+
// capture chained-from tail id when present. v2.1.0-rc.1 P4 (F3): strip +
|
|
114
|
+
// surface any `<store>:` prefix into a parallel stores array.
|
|
115
|
+
const split = fullMatch[1]
|
|
99
116
|
.split(",")
|
|
100
117
|
.map((part) => part.trim())
|
|
101
|
-
.filter((part) => part.length > 0)
|
|
102
|
-
|
|
118
|
+
.filter((part) => part.length > 0)
|
|
119
|
+
.map(splitStorePrefix);
|
|
120
|
+
if (split.some((entry) => !ID_RE.test(entry.id))) return null;
|
|
121
|
+
const primaryIds = split.map((entry) => entry.id);
|
|
122
|
+
const primaryStores = split.map((entry) => entry.store);
|
|
103
123
|
|
|
104
124
|
const rawTag = fullMatch[3];
|
|
105
125
|
const tag = parseTag(rawTag);
|
|
@@ -114,6 +134,7 @@ function parseLine(line) {
|
|
|
114
134
|
|
|
115
135
|
return {
|
|
116
136
|
ids: primaryIds.concat(chainedIds),
|
|
137
|
+
stores: primaryStores.concat(chainedIds.map(() => null)),
|
|
117
138
|
tag,
|
|
118
139
|
commitment: parseContractTail(fullMatch[4]),
|
|
119
140
|
};
|
|
@@ -131,14 +152,15 @@ function parseLine(line) {
|
|
|
131
152
|
* embedded id as an additional cite_id. cite_tags carries one tag per LINE.
|
|
132
153
|
*/
|
|
133
154
|
function parseCiteLine(raw) {
|
|
134
|
-
const result = { cite_ids: [], cite_tags: [], cite_commitments: [] };
|
|
155
|
+
const result = { cite_ids: [], cite_tags: [], cite_commitments: [], cite_stores: [] };
|
|
135
156
|
if (typeof raw !== "string") return result;
|
|
136
157
|
for (const line of raw.split(/\r?\n/)) {
|
|
137
158
|
const parsed = parseLine(line);
|
|
138
159
|
if (!parsed) continue;
|
|
139
160
|
result.cite_tags.push(parsed.tag);
|
|
140
|
-
for (
|
|
141
|
-
result.cite_ids.push(
|
|
161
|
+
for (let i = 0; i < parsed.ids.length; i += 1) {
|
|
162
|
+
result.cite_ids.push(parsed.ids[i]);
|
|
163
|
+
result.cite_stores.push(parsed.stores[i] == null ? null : parsed.stores[i]);
|
|
142
164
|
}
|
|
143
165
|
if (parsed.commitment !== null) {
|
|
144
166
|
// v2.0.0-rc.27.1 (Codex review fix): cite_commitments MUST be index-
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// v2.2 HK3-telemetry (W3-T1): injection-side telemetry. `.fabric/metrics.jsonl`
|
|
2
|
+
// (server) records the CONSUMPTION side — which knowledge the agent actually
|
|
3
|
+
// fetched/consumed. But nothing recorded the INJECTION side — which knowledge a
|
|
4
|
+
// hook OFFERED the agent at SessionStart / PreToolUse. Without that denominator
|
|
5
|
+
// the "true hit rate" (consumed ÷ injected) cannot be computed: a high consume
|
|
6
|
+
// count tells you nothing if the hook injected ten times as many entries.
|
|
7
|
+
//
|
|
8
|
+
// This lib appends one row per injection to `.fabric/injections.jsonl`:
|
|
9
|
+
// { ts, surface: "broad"|"narrow", count, stable_ids: [...], revision_hash }
|
|
10
|
+
//
|
|
11
|
+
// Best-effort + synchronous: hooks are short-lived processes, so a sync append
|
|
12
|
+
// is simpler than threading async, and ANY failure is swallowed — telemetry
|
|
13
|
+
// must never break or delay the hook (failure invariant: silent). Concurrent
|
|
14
|
+
// writers from multiple windows are serialized with an advisory lock (see
|
|
15
|
+
// appendLockedLine below) so a contended write can't corrupt a line.
|
|
16
|
+
|
|
17
|
+
const { appendFileSync, mkdirSync, openSync, closeSync, statSync, rmSync } = require("node:fs");
|
|
18
|
+
const { join, dirname } = require("node:path");
|
|
19
|
+
|
|
20
|
+
// Multi-window concurrency guard (ADJ-W3-INJECTION-CONCURRENCY): the same repo
|
|
21
|
+
// is frequently edited from several client sessions at once, so multiple hook
|
|
22
|
+
// processes can append to injections.jsonl simultaneously. A bare appendFileSync
|
|
23
|
+
// can interleave a partial write under contention and corrupt a line. We guard
|
|
24
|
+
// each append with an advisory lock file created atomically via O_EXCL ("wx"):
|
|
25
|
+
// - acquired → write the row, then release the lock
|
|
26
|
+
// - contended → DROP this row. Telemetry is best-effort; a missing row only
|
|
27
|
+
// shrinks the denominator slightly, and dropping is what keeps
|
|
28
|
+
// the ledger from ever being corrupted by an interleave.
|
|
29
|
+
// - stale → a holder that crashed leaves the lock behind; reclaim it once
|
|
30
|
+
// past STALE_LOCK_MS so contention can't wedge forever.
|
|
31
|
+
const STALE_LOCK_MS = 5000;
|
|
32
|
+
|
|
33
|
+
function appendLockedLine(path, line) {
|
|
34
|
+
const lockPath = `${path}.lock`;
|
|
35
|
+
let fd;
|
|
36
|
+
try {
|
|
37
|
+
fd = openSync(lockPath, "wx"); // atomic create-exclusive = acquire
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (!err || err.code !== "EEXIST") return; // unexpected → drop (best-effort)
|
|
40
|
+
try {
|
|
41
|
+
if (Date.now() - statSync(lockPath).mtimeMs <= STALE_LOCK_MS) return; // fresh holder → drop
|
|
42
|
+
rmSync(lockPath, { force: true }); // stale holder crashed → reclaim
|
|
43
|
+
fd = openSync(lockPath, "wx");
|
|
44
|
+
} catch {
|
|
45
|
+
return; // racing another reclaimer → drop
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
closeSync(fd);
|
|
50
|
+
appendFileSync(path, line);
|
|
51
|
+
} finally {
|
|
52
|
+
try {
|
|
53
|
+
rmSync(lockPath, { force: true });
|
|
54
|
+
} catch {
|
|
55
|
+
/* lock already released */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Append one injection record to `<projectRoot>/.fabric/injections.jsonl`.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} projectRoot
|
|
64
|
+
* @param {{ surface: "broad"|"narrow", stableIds?: string[], count?: number, revisionHash?: string|null, ts?: number }} record
|
|
65
|
+
*/
|
|
66
|
+
function logInjection(projectRoot, record) {
|
|
67
|
+
try {
|
|
68
|
+
if (!projectRoot || !record || typeof record.surface !== "string") {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const stableIds = Array.isArray(record.stableIds) ? record.stableIds.filter((id) => typeof id === "string") : [];
|
|
72
|
+
const count = typeof record.count === "number" ? record.count : stableIds.length;
|
|
73
|
+
if (count <= 0) {
|
|
74
|
+
return; // nothing injected → no row (keeps the denominator honest)
|
|
75
|
+
}
|
|
76
|
+
const row = {
|
|
77
|
+
ts: typeof record.ts === "number" ? record.ts : Date.now(),
|
|
78
|
+
surface: record.surface,
|
|
79
|
+
count,
|
|
80
|
+
stable_ids: stableIds,
|
|
81
|
+
revision_hash: typeof record.revisionHash === "string" ? record.revisionHash : null,
|
|
82
|
+
};
|
|
83
|
+
const path = join(projectRoot, ".fabric", "injections.jsonl");
|
|
84
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
85
|
+
appendLockedLine(path, `${JSON.stringify(row)}\n`);
|
|
86
|
+
} catch {
|
|
87
|
+
// Telemetry is best-effort — never crash or delay the hook.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { logInjection, appendLockedLine };
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
* acceptable — the hook never blocks user flow on sidecar I/O, KT-DEC-0007).
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// Namespace import (not destructured) so the atomic write goes through a single
|
|
25
|
+
// mutable fs reference — also what the atomicity tests spy on (ISS-016).
|
|
26
|
+
const fs = require("node:fs");
|
|
25
27
|
const { dirname, join } = require("node:path");
|
|
26
28
|
|
|
27
29
|
const CACHE_DIR_REL = join(".fabric", ".cache");
|
|
@@ -30,11 +32,31 @@ function cachePath(projectRoot, fileName) {
|
|
|
30
32
|
return join(projectRoot, CACHE_DIR_REL, fileName);
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
// ISS-016: write to a unique temp file then rename over the target. rename is
|
|
36
|
+
// atomic on POSIX, so a reader sees either the old or the new file in full —
|
|
37
|
+
// never a truncated/garbled write from a crash or concurrent writer. The temp
|
|
38
|
+
// suffix (pid + clock) keeps concurrent windows from colliding on the temp.
|
|
39
|
+
function atomicWrite(path, data) {
|
|
40
|
+
fs.mkdirSync(dirname(path), { recursive: true });
|
|
41
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
42
|
+
try {
|
|
43
|
+
fs.writeFileSync(tmp, data);
|
|
44
|
+
fs.renameSync(tmp, path);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
try {
|
|
47
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
48
|
+
} catch {
|
|
49
|
+
// best-effort temp cleanup
|
|
50
|
+
}
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
33
55
|
function readJsonState(projectRoot, fileName, validate) {
|
|
34
56
|
const path = cachePath(projectRoot, fileName);
|
|
35
|
-
if (!existsSync(path)) return null;
|
|
57
|
+
if (!fs.existsSync(path)) return null;
|
|
36
58
|
try {
|
|
37
|
-
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
59
|
+
const parsed = JSON.parse(fs.readFileSync(path, "utf8"));
|
|
38
60
|
if (typeof validate === "function" && !validate(parsed)) return null;
|
|
39
61
|
return parsed;
|
|
40
62
|
} catch {
|
|
@@ -43,10 +65,8 @@ function readJsonState(projectRoot, fileName, validate) {
|
|
|
43
65
|
}
|
|
44
66
|
|
|
45
67
|
function writeJsonState(projectRoot, fileName, value) {
|
|
46
|
-
const path = cachePath(projectRoot, fileName);
|
|
47
68
|
try {
|
|
48
|
-
|
|
49
|
-
writeFileSync(path, JSON.stringify(value));
|
|
69
|
+
atomicWrite(cachePath(projectRoot, fileName), JSON.stringify(value));
|
|
50
70
|
return true;
|
|
51
71
|
} catch {
|
|
52
72
|
return false;
|
|
@@ -55,19 +75,17 @@ function writeJsonState(projectRoot, fileName, value) {
|
|
|
55
75
|
|
|
56
76
|
function readTextState(projectRoot, fileName) {
|
|
57
77
|
const path = cachePath(projectRoot, fileName);
|
|
58
|
-
if (!existsSync(path)) return null;
|
|
78
|
+
if (!fs.existsSync(path)) return null;
|
|
59
79
|
try {
|
|
60
|
-
return readFileSync(path, "utf8").trim();
|
|
80
|
+
return fs.readFileSync(path, "utf8").trim();
|
|
61
81
|
} catch {
|
|
62
82
|
return null;
|
|
63
83
|
}
|
|
64
84
|
}
|
|
65
85
|
|
|
66
86
|
function writeTextState(projectRoot, fileName, text) {
|
|
67
|
-
const path = cachePath(projectRoot, fileName);
|
|
68
87
|
try {
|
|
69
|
-
|
|
70
|
-
writeFileSync(path, String(text));
|
|
88
|
+
atomicWrite(cachePath(projectRoot, fileName), String(text));
|
|
71
89
|
return true;
|
|
72
90
|
} catch {
|
|
73
91
|
return false;
|
|
@@ -80,5 +98,6 @@ module.exports = {
|
|
|
80
98
|
writeJsonState,
|
|
81
99
|
readTextState,
|
|
82
100
|
writeTextState,
|
|
101
|
+
atomicWrite,
|
|
83
102
|
CACHE_DIR_REL,
|
|
84
103
|
};
|
|
@@ -43,6 +43,10 @@ Parse user's prompt for time-window (`今日` / `last week`), topic keyword (`rc
|
|
|
43
43
|
|
|
44
44
|
Read `.fabric/fabric-config.json`; resolve `archive_max_candidates_per_batch` (default 8), `archive_max_recent_paths` (default 20), `archive_digest_max_sessions` (default 10). Missing file → defaults silently.
|
|
45
45
|
|
|
46
|
+
### Phase 0.6 — Store routing (v2.1 multi-store)
|
|
47
|
+
|
|
48
|
+
Archives land in the **active write store** for the entry's scope — NEVER pick a store yourself. Run `fabric scope-explain team` (or the relevant scope) to get the resolved `writeTarget` (alias + UUID); that is where `fab_extract_knowledge` persists. Single-store / no global config → the lone store (back-compat). After persisting, **echo the target store alias** to the user (`归档到 store '<alias>'`). Personal-scope entries route to the personal store (never the shared team store, R5#3). Do NOT read `~/.fabric` store trees directly — go through `scope-explain` / the MCP write path.
|
|
49
|
+
|
|
46
50
|
### UX i18n Policy
|
|
47
51
|
|
|
48
52
|
Read `fabric_language` (`zh-CN` / `en` / `zh-CN-hybrid` / `match-existing`); emit user-facing prose in resolved variant. Protected tokens (MCP tool names, schema fields, the verbatim `强 team` / `强 personal` / `默认 team` heuristic) NEVER translated. `AskUserQuestion` policy: `header` + `question` translate; `options[]` stay English (routing keys).
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fabric-audit
|
|
3
|
+
description: 知识库语义淘汰门面 — 审计 KB 健康并以 deprecate-over-delete + rescue-before-delete 收口陈旧/孤儿/被取代条目。引擎是 `fabric doctor`;本 skill 按用户意图挑动作并守「不硬删、先抢救」红线。Triggers 审计知识库/清理陈旧知识/知识库体检/deprecate 条目/prune stale knowledge/知识库瘦身/淘汰旧决策.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# fabric-audit — 知识库语义淘汰
|
|
7
|
+
|
|
8
|
+
知识库 *维护期* 的对话入口:体检 KB,把陈旧 / 孤儿 / 被取代的条目按 **语义淘汰** 收口 —— 而不是一删了之。CLI (`fabric doctor`) 是引擎(跑 lint、算 health、给 orphan/stale 信号);本 skill 按用户意图挑动作,并守两条红线:**deprecate-over-delete** 与 **rescue-before-delete**。
|
|
9
|
+
|
|
10
|
+
写新条目用 `fabric-archive`;批量审 pending 用 `fabric-review`;本 skill 专管 *已归档条目的退役*。
|
|
11
|
+
|
|
12
|
+
## When to use
|
|
13
|
+
|
|
14
|
+
- 「审计 / 体检知识库」「知识库健康度怎样?」
|
|
15
|
+
- 「清理陈旧知识」「这些旧决策还要吗?」「知识库瘦身」
|
|
16
|
+
- 发布 / 大重构前想把过时知识收口。
|
|
17
|
+
- doctor 报了 orphan / stale / 低 health,想逐条处置。
|
|
18
|
+
|
|
19
|
+
## When NOT to use
|
|
20
|
+
|
|
21
|
+
- 写 / 提议新知识条目 → `fabric-archive`。
|
|
22
|
+
- 批量审 `.fabric/knowledge/pending/` 的 draft → `fabric-review`。
|
|
23
|
+
- store 运维 / 同步 → `fabric-store` / `fabric-sync`。
|
|
24
|
+
|
|
25
|
+
## 两条红线
|
|
26
|
+
|
|
27
|
+
1. **deprecate-over-delete**:陈旧 ≠ 该删。一条「当时为什么这么决策」的 decision/pitfall 即使方案已换,其 **rationale 仍是知识**。退役 = 降 maturity / 标记 deprecated(保留正文 + 记录被什么取代),而非 `rm`。删除只用于「从未成立 / 纯噪声 / 重复」的条目。
|
|
28
|
+
2. **rescue-before-delete**:任何 *打算删* 的条目,删前必做抢救检查 —— 它是否携带别处没有的独特 rationale / 反例 / 边界?有则先 **merge 进取代它的条目**(或在新条目加 `related` 边指回),再删空壳。抢救检查没做过,不许删。
|
|
29
|
+
|
|
30
|
+
## 意图 → 动作映射
|
|
31
|
+
|
|
32
|
+
| 意图 | 动作 |
|
|
33
|
+
|---|---|
|
|
34
|
+
| 体检 / 健康度 | `fabric doctor`(读 lint + health rollup);零阻断,只报告 |
|
|
35
|
+
| 找孤儿 / 陈旧条目 | `fabric doctor`(消费 orphan / stale / orphan-demote 信号) |
|
|
36
|
+
| 退役一条陈旧条目 | **不删** → 降 maturity(proven→verified→draft)或在 frontmatter 标 deprecated + 记 superseded-by;经 `fabric-review` 落盘 |
|
|
37
|
+
| 删一条「从未成立 / 重复」条目 | 先跑 rescue 检查(独特 rationale?有则 merge/加 related);确认空壳后才删 |
|
|
38
|
+
| 被取代但有价值 | rescue:把独特 rationale merge 进取代条目,新条目加 `related` 边指回,再退役旧条目 |
|
|
39
|
+
|
|
40
|
+
## 流程(逐条处置)
|
|
41
|
+
|
|
42
|
+
1. `fabric doctor` 取 KB health + orphan/stale 候选清单(引擎给信号,本 skill 不自算)。
|
|
43
|
+
2. 对每个候选判 **三态**:still-valid(留) / superseded(退役,走 deprecate) / never-valid(删,走 rescue 检查)。
|
|
44
|
+
3. superseded → deprecate:降 maturity 或标 deprecated + `superseded-by`,保留正文 rationale。
|
|
45
|
+
4. never-valid → rescue-before-delete:独特知识?有则 merge + `related`,无则删空壳。
|
|
46
|
+
5. 处置经 `fabric-review` skill 落盘(本 skill 给决策,review 做写入),保持单一写路径。
|
|
47
|
+
|
|
48
|
+
## Constraints
|
|
49
|
+
|
|
50
|
+
- 本 skill **只读 + 给处置建议**;实际写入(降级 / 标记 / 删)经 `fabric-review` 的写路径,不自行改 `.fabric/knowledge/`。
|
|
51
|
+
- NEVER 绕过 rescue 检查直接删;删前 MUST 先跑抢救检查。删是最后手段,默认是 deprecate。
|
|
52
|
+
- `agents.meta.json` 派生态严禁手改;退役动作改的是 `.fabric/knowledge/<type>/` 下 markdown 的 frontmatter(maturity / deprecated / superseded-by),再 `fabric doctor --fix` reconcile。
|
|
53
|
+
- health / orphan / stale 一律取自 `fabric doctor` 的 JSON 输出,不在 skill 内重算(单一真源)。
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fabric-connect
|
|
3
|
+
description: 知识图谱关联门面 — 发现 KB 条目间隐藏关联并回写 H2 `related` 图边。读 fab_recall/fab_plan_context 看候选, 按语义/共路径/共引提议 related 边, 经 fabric-review 写路径落盘。Triggers 连接知识/找关联条目/建知识图谱/link related entries/补 related 边/知识库连通性.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# fabric-connect — 知识图谱关联
|
|
7
|
+
|
|
8
|
+
把孤立的 KB 条目连成图:发现彼此**隐藏关联**(同一决策的正反面、pitfall↔规避它的 guideline、被取代↔取代),回写到 frontmatter 的 `related: [<stable_id>...]` 图边(v2.2 H2)。下游 `fab_recall include_related:true` 据此一跳拉回连通知识。
|
|
9
|
+
|
|
10
|
+
`related` 是**有向引用**(A.related=[B] 表示「读 A 时也该看 B」),按需补反向边(B.related=[A])。
|
|
11
|
+
|
|
12
|
+
## When to use
|
|
13
|
+
|
|
14
|
+
- 「把这些知识连起来」「找出相关条目」「补 related 边」
|
|
15
|
+
- 「知识库连通性怎样?」「有哪些孤岛条目?」
|
|
16
|
+
- 新增一批条目后想建立彼此引用。
|
|
17
|
+
|
|
18
|
+
## When NOT to use
|
|
19
|
+
|
|
20
|
+
- 写新条目 → `fabric-archive`。
|
|
21
|
+
- 审 pending / 退役陈旧 → `fabric-review` / `fabric-audit`。
|
|
22
|
+
- 检索时临时拉关联 → 直接 `fab_recall include_related:true`(无需建边)。
|
|
23
|
+
|
|
24
|
+
## 关联类型(提议 related 边的判据)
|
|
25
|
+
|
|
26
|
+
| 类型 | 例 |
|
|
27
|
+
|---|---|
|
|
28
|
+
| 互补 | decision「用 JWT」 ↔ pitfall「JWT 过期未刷新踩坑」 |
|
|
29
|
+
| 规避 | pitfall「sprite 黑边」 ↔ guideline「premultiplyAlpha 正确设置」 |
|
|
30
|
+
| 取代 | 旧 decision ↔ 取代它的新 decision(配合 deprecated/superseded-by) |
|
|
31
|
+
| 同域 | 同一子系统 / 共 relevance_paths 的条目 |
|
|
32
|
+
| 引用链 | A 的 rationale 依赖 B 的结论 |
|
|
33
|
+
|
|
34
|
+
## 流程
|
|
35
|
+
|
|
36
|
+
1. `fab_recall(paths=[...])` 或 `fab_plan_context` 拿候选 + 现有 `related`(读 description.related 看已连状态)。
|
|
37
|
+
2. 对候选两两/成簇判隐藏关联(用上表判据);只提议**高置信**边,不为「话题相邻」乱连(噪声边稀释图价值)。
|
|
38
|
+
3. 每条提议 = `(源 id, 目标 id, 类型, 一句理由)`;按需提议反向边。
|
|
39
|
+
4. 落盘经 `fabric-review` 写路径:在源条目 frontmatter 的 `related` inline 数组追加目标 stable_id;`fabric doctor --fix` reconcile 进 agents.meta。
|
|
40
|
+
5. 回报新增/反向边数 + 连通性变化(孤岛减少)。
|
|
41
|
+
|
|
42
|
+
## Constraints
|
|
43
|
+
|
|
44
|
+
- 本 skill **只提议 + 经 review 写路径落盘**;不自行改 `.fabric/knowledge/`,不手改 `agents.meta.json`(派生态)。
|
|
45
|
+
- `related` MUST 只填**真实存在的 stable_id**(先 `fab_recall` 验证目标在库);NEVER 编造 / 指向 pending。
|
|
46
|
+
- **稀疏优于稠密**:宁缺毋滥。只连高置信关联;低置信「相邻」不连(图的信噪比比覆盖率重要)。
|
|
47
|
+
- 反向边按需补,不强制双向(有向语义:A 该带出 B ≠ B 该带出 A)。
|
|
48
|
+
- 写 `related` 复用 H2 字段(`fabric-review` 的 modify 路径);schema 已支持,无需迁移。
|
|
@@ -40,6 +40,10 @@ Read `.fabric/fabric-config.json` for tunables (defaults if absent):
|
|
|
40
40
|
|
|
41
41
|
First-run vs re-run by state file (ENOENT or `phase != complete && proposed == 0` → first-run window).
|
|
42
42
|
|
|
43
|
+
### Store routing (v2.1 multi-store)
|
|
44
|
+
|
|
45
|
+
Import requires an **explicit target store** (E7) — mined entries are NOT auto-routed. Resolve candidates via `fabric scope-explain team` (writable stores in the read-set); if more than one writable store exists, `AskUserQuestion` for the target store alias before persisting (header/question translate, the alias options stay English routing keys). Single writable store → use it. Persist through `fab_extract_knowledge` with the chosen store; echo the target alias. Never write to a store the project did not declare (read-set bound).
|
|
46
|
+
|
|
43
47
|
## UX i18n Policy
|
|
44
48
|
|
|
45
49
|
Read `fabric_language` (`zh-CN` / `en` / `zh-CN-hybrid` / `match-existing`). Emit prose per variant. Protected tokens NEVER translate (`fab_extract_knowledge`, `fab_review`, `.fabric/.import-state.json`, all enum strings, `MUST`/`NEVER`). Full 5-class taxonomy → `Read .../ref/i18n-policy.md`.
|
|
@@ -40,6 +40,12 @@ Read `.fabric/fabric-config.json`; resolve:
|
|
|
40
40
|
|
|
41
41
|
Missing or unreadable → defaults silently.
|
|
42
42
|
|
|
43
|
+
### Store routing (v2.1 multi-store)
|
|
44
|
+
|
|
45
|
+
Review iterates **per-store** — the read-set may span multiple stores (`fabric scope-explain team` → resolved `readSet.stores`). Pending/backlog is reported per-store (NOT aggregated into one undifferentiated pile); each candidate's provenance store is surfaced in cites as `KB: <store-alias>:<id>`. Promotion (draft → verified/proven) is a normal edit + git commit **inside that store's own repo** — no cross-store move. A `dismissed`/`modify` that flips layer between team and personal still goes through `AskUserQuestion`. Never read `~/.fabric` store trees directly; go through the MCP recall path / `scope-explain`.
|
|
46
|
+
|
|
47
|
+
`Read ref/cite-contract.md` (v2.2 SK5) for the authoritative cite-contract reference — operator syntax, skip/dismissed reason dictionaries, type routing, audit semantics, backward-compat, and the adjudication ladder (AI-self → multi-LLM cold-eval → non-blocking queue) — sunk out of the bootstrap so cite/governance depth lives here, not in `.fabric/AGENTS.md`.
|
|
48
|
+
|
|
43
49
|
### UX i18n Policy
|
|
44
50
|
|
|
45
51
|
Read `fabric_language` (`zh-CN` / `en` / `zh-CN-hybrid` / `match-existing`); emit user-facing prose in resolved variant. Protected tokens (`fab_review`, `fab_extract_knowledge`, `relevance_scope`, layer/scope enums, `stable_id`, the verbatim `强 team` / `强 personal` / `默认 team` block) NEVER translated. `AskUserQuestion` policy: `header` + `question` translate; `options[]` stay English (routing keys).
|