@fenglimg/fabric-cli 2.0.0 → 2.1.0-rc.2
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/LICENSE +21 -0
- package/README.md +6 -5
- package/dist/chunk-BATF4PEJ.js +361 -0
- package/dist/{chunk-OBQU6NHO.js → chunk-COI5VDFU.js} +0 -18
- package/dist/chunk-F46ORPOA.js +903 -0
- package/dist/chunk-HFQVXY6P.js +86 -0
- package/dist/chunk-L4Q55UC4.js +52 -0
- package/dist/chunk-LFIKMVY7.js +27 -0
- package/dist/chunk-MF3OTILQ.js +544 -0
- package/dist/chunk-PWLW3B57.js +18 -0
- package/dist/chunk-RYAFBNES.js +33 -0
- package/dist/chunk-T5RPGCCM.js +40 -0
- package/dist/chunk-WU6GAPKH.js +36 -0
- package/dist/config-XJIPZNUP.js +13 -0
- package/dist/doctor-QVNPHLJK.js +920 -0
- package/dist/index.js +23 -8
- package/dist/{init-BIRSIOXO.js → install-2HDO5FTQ.js} +807 -705
- package/dist/metrics-ACEQFPDU.js +122 -0
- package/dist/onboard-coverage-MFCAEBDO.js +220 -0
- package/dist/{plan-context-hint-QMUPAXIB.js → plan-context-hint-FC6P3WFE.js} +34 -28
- package/dist/scope-explain-2F2R5URO.js +33 -0
- package/dist/status-GLQWLWH6.js +23 -0
- package/dist/store-XTSE5TY6.js +105 -0
- package/dist/sync-BJCWDPNC.js +245 -0
- package/dist/uninstall-TAXSUSKH.js +1073 -0
- package/dist/whoami-B6AEMSEV.js +31 -0
- package/package.json +30 -5
- package/templates/hooks/cite-policy-evict.cjs +231 -0
- package/templates/hooks/configs/README.md +29 -6
- package/templates/hooks/configs/claude-code.json +14 -3
- package/templates/hooks/configs/codex-hooks.json +6 -3
- package/templates/hooks/configs/cursor-hooks.json +8 -10
- package/templates/hooks/fabric-hint.cjs +873 -105
- package/templates/hooks/knowledge-hint-broad.cjs +549 -135
- package/templates/hooks/knowledge-hint-narrow.cjs +830 -26
- package/templates/hooks/lib/banner-i18n.cjs +309 -0
- package/templates/hooks/lib/bindings-snapshot-reader.cjs +81 -0
- package/templates/hooks/lib/cite-contract-reminder.cjs +179 -0
- package/templates/hooks/lib/cite-line-parser.cjs +180 -0
- package/templates/hooks/lib/client-adapter.cjs +106 -0
- package/templates/hooks/lib/config-cache.cjs +107 -0
- package/templates/hooks/lib/state-store.cjs +84 -0
- package/templates/hooks/lib/summary-fallback.cjs +210 -0
- package/templates/skills/fabric-archive/SKILL.md +97 -419
- package/templates/skills/fabric-archive/ref/dry-run-scope.md +16 -0
- package/templates/skills/fabric-archive/ref/e5-cron-recap.md +58 -0
- package/templates/skills/fabric-archive/ref/i18n-policy.md +86 -0
- package/templates/skills/fabric-archive/ref/phase-0-range-resolution.md +156 -0
- package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +218 -0
- package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +62 -0
- package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +68 -0
- package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +108 -0
- package/templates/skills/fabric-archive/ref/phase-3-classify.md +63 -0
- package/templates/skills/fabric-archive/ref/phase-4-5-emit.md +78 -0
- package/templates/skills/fabric-archive/ref/phase-4-mcp-persist.md +89 -0
- package/templates/skills/fabric-archive/ref/rc-history.md +38 -0
- package/templates/skills/fabric-archive/ref/worked-examples.md +78 -0
- package/templates/skills/fabric-import/SKILL.md +77 -514
- package/templates/skills/fabric-import/ref/checkpoint-state.md +85 -0
- package/templates/skills/fabric-import/ref/i18n-policy.md +79 -0
- package/templates/skills/fabric-import/ref/output-contract.md +61 -0
- package/templates/skills/fabric-import/ref/phase-2-mining.md +213 -0
- package/templates/skills/fabric-import/ref/phase-3-dedup.md +75 -0
- package/templates/skills/fabric-import/ref/state-recovery.md +57 -0
- package/templates/skills/fabric-import/ref/worked-examples.md +127 -0
- package/templates/skills/fabric-review/SKILL.md +90 -284
- package/templates/skills/fabric-review/ref/askuserquestion-policy.md +66 -0
- package/templates/skills/fabric-review/ref/i18n-policy.md +111 -0
- package/templates/skills/fabric-review/ref/modify-flow.md +103 -0
- package/templates/skills/fabric-review/ref/output-contract.md +58 -0
- package/templates/skills/fabric-review/ref/per-mode-flows.md +155 -0
- package/templates/skills/fabric-review/ref/semantic-check.md +26 -0
- package/templates/skills/fabric-review/ref/worked-examples.md +95 -0
- package/templates/skills/fabric-sync/SKILL.md +46 -0
- package/templates/skills/lib/shared-policy.md +69 -0
- package/dist/chunk-6ICJICVU.js +0 -10
- package/dist/chunk-74SZWYPH.js +0 -658
- package/dist/chunk-EYIDD2YS.js +0 -1000
- package/dist/doctor-T7JWODKG.js +0 -282
- package/dist/hooks-Y74Y5LQS.js +0 -12
- package/dist/scan-LMK3UCWL.js +0 -22
- package/dist/serve-H554BHLG.js +0 -124
- package/templates/agents-md/AGENTS.md.template +0 -59
- package/templates/bootstrap/CLAUDE.md +0 -8
- package/templates/bootstrap/codex-AGENTS-header.md +0 -6
- package/templates/bootstrap/cursor-fabric-bootstrap.mdc +0 -10
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// v2.0.0-rc.24 TASK-04: CJS twin of packages/shared/src/cite-line-parser.ts.
|
|
2
|
+
//
|
|
3
|
+
// Hook runtime has NO node_modules access, so the shared TS module cannot be
|
|
4
|
+
// imported. This file is a hand-authored CJS mirror; behavioral parity is
|
|
5
|
+
// asserted by packages/cli/__tests__/cite-line-parser-parity.test.ts which
|
|
6
|
+
// runs both implementations against the same corpus and asserts identical
|
|
7
|
+
// output. Any drift between this file and ../../shared/src/cite-line-parser.ts
|
|
8
|
+
// MUST be reflected in BOTH files plus the parity-test corpus, otherwise the
|
|
9
|
+
// parity test fails and blocks the commit.
|
|
10
|
+
//
|
|
11
|
+
// Why a hand-authored twin (not transpile-at-install or string-template inject)?
|
|
12
|
+
// - tsup/esbuild are CLI build-time deps, NOT install-time deps; bundling
|
|
13
|
+
// them into the install pipeline grows the user-facing footprint.
|
|
14
|
+
// - The parser is small (≤150 LOC), pure (zero deps), and rarely changes —
|
|
15
|
+
// hand-syncing is cheaper than introducing transpile machinery.
|
|
16
|
+
// - The existing `installHookLibs` pipeline auto-copies every `.cjs` under
|
|
17
|
+
// templates/hooks/lib/ to each client's hooks/lib/ dir, so this file
|
|
18
|
+
// auto-ships to cc/codex/cursor with no install pipeline change.
|
|
19
|
+
//
|
|
20
|
+
// Vocabulary contract (mirrored 1:1 with the TS source):
|
|
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.
|
|
25
|
+
// - operator kinds: edit | not_edit | require | forbid
|
|
26
|
+
// (source token `!edit:` → schema kind `not_edit`)
|
|
27
|
+
// - skip:<reason> captures everything after the first colon, so
|
|
28
|
+
// `skip:other:non-codifiable` yields skip_reason="other:non-codifiable".
|
|
29
|
+
// - Index contract: cite_commitments[i] ↔ cite_ids[i]. Sentinel `KB: none`
|
|
30
|
+
// contributes a "none" cite_tag only — no id, no commitment.
|
|
31
|
+
|
|
32
|
+
const ID_RE = /^K[TP]-[A-Z]+-\d+$/;
|
|
33
|
+
const SENTINEL_RE = /^KB:\s*none\b\s*(?:\[[^\]]*\])?\s*$/i;
|
|
34
|
+
// v2.0.0-rc.27 TASK-003 (audit §2.18): multi-id citations supported via
|
|
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
|
+
);
|
|
45
|
+
const CHAINED_FROM_ID_RE = /chained-from\s+(K[TP]-[A-Z]+-\d+)/i;
|
|
46
|
+
|
|
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
|
+
};
|
|
63
|
+
|
|
64
|
+
function parseTag(rawTag) {
|
|
65
|
+
if (!rawTag) return "none";
|
|
66
|
+
// Tags may carry tails like `chained-from KT-DEC-0001` or
|
|
67
|
+
// `dismissed:scope-mismatch`; head token (whitespace/colon-bounded) wins.
|
|
68
|
+
const head = rawTag.trim().split(/[\s:]+/)[0].toLowerCase();
|
|
69
|
+
if (head === "applied" || head === "dismissed" || head === "none") {
|
|
70
|
+
return head;
|
|
71
|
+
}
|
|
72
|
+
return LEGACY_CITE_TAG_REMAP[head] || "none";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseContractTail(tail) {
|
|
76
|
+
const result = { operators: [], skip_reason: null };
|
|
77
|
+
if (!tail) return result;
|
|
78
|
+
const tokens = tail.trim().split(/\s+/).filter((t) => t.length > 0);
|
|
79
|
+
for (const token of tokens) {
|
|
80
|
+
// skip:<reason> — reason may itself contain a colon (skip:other:<text>).
|
|
81
|
+
const skipMatch = token.match(/^skip:(.+)$/i);
|
|
82
|
+
if (skipMatch) {
|
|
83
|
+
if (result.skip_reason === null) result.skip_reason = skipMatch[1];
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
// !edit:<target> → schema kind "not_edit".
|
|
87
|
+
const notEditMatch = token.match(/^!edit:(.+)$/i);
|
|
88
|
+
if (notEditMatch) {
|
|
89
|
+
result.operators.push({ kind: "not_edit", target: notEditMatch[1] });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const opMatch = token.match(/^(edit|require|forbid):(.+)$/i);
|
|
93
|
+
if (opMatch) {
|
|
94
|
+
result.operators.push({
|
|
95
|
+
kind: opMatch[1].toLowerCase(),
|
|
96
|
+
target: opMatch[2],
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// Unknown token → forward-compat drop.
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseLine(line) {
|
|
105
|
+
const trimmed = line.trim();
|
|
106
|
+
if (trimmed.length === 0) return null;
|
|
107
|
+
if (SENTINEL_RE.test(trimmed)) {
|
|
108
|
+
return { ids: [], stores: [], tag: "none", commitment: null };
|
|
109
|
+
}
|
|
110
|
+
const fullMatch = trimmed.match(FULL_RE);
|
|
111
|
+
if (fullMatch) {
|
|
112
|
+
// v2.0.0-rc.27 TASK-003 (audit §2.18): split + revalidate each id;
|
|
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]
|
|
116
|
+
.split(",")
|
|
117
|
+
.map((part) => part.trim())
|
|
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);
|
|
123
|
+
|
|
124
|
+
const rawTag = fullMatch[3];
|
|
125
|
+
const tag = parseTag(rawTag);
|
|
126
|
+
|
|
127
|
+
const chainedIds = [];
|
|
128
|
+
if (rawTag) {
|
|
129
|
+
const chained = CHAINED_FROM_ID_RE.exec(rawTag);
|
|
130
|
+
if (chained && ID_RE.test(chained[1])) {
|
|
131
|
+
chainedIds.push(chained[1]);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
ids: primaryIds.concat(chainedIds),
|
|
137
|
+
stores: primaryStores.concat(chainedIds.map(() => null)),
|
|
138
|
+
tag,
|
|
139
|
+
commitment: parseContractTail(fullMatch[4]),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Parse one or more newline-separated `KB:` cite lines into structured arrays
|
|
147
|
+
* matching the assistant_turn_observed event-ledger fields. Tolerates
|
|
148
|
+
* whitespace, CR/LF, blank lines, interleaved prose. Never throws.
|
|
149
|
+
*
|
|
150
|
+
* v2.0.0-rc.27 TASK-003 (audit §2.18): supports multi-id citations
|
|
151
|
+
* (`KB: KT-DEC-0001, KT-PIT-0005 ...`) and surfaces `chained-from <id>`'s
|
|
152
|
+
* embedded id as an additional cite_id. cite_tags carries one tag per LINE.
|
|
153
|
+
*/
|
|
154
|
+
function parseCiteLine(raw) {
|
|
155
|
+
const result = { cite_ids: [], cite_tags: [], cite_commitments: [], cite_stores: [] };
|
|
156
|
+
if (typeof raw !== "string") return result;
|
|
157
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
158
|
+
const parsed = parseLine(line);
|
|
159
|
+
if (!parsed) continue;
|
|
160
|
+
result.cite_tags.push(parsed.tag);
|
|
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]);
|
|
164
|
+
}
|
|
165
|
+
if (parsed.commitment !== null) {
|
|
166
|
+
// v2.0.0-rc.27.1 (Codex review fix): cite_commitments MUST be index-
|
|
167
|
+
// aligned with cite_ids per the schema doc on event-ledger.ts:428.
|
|
168
|
+
// Multi-id citations share ONE parsed contract — propagate it across
|
|
169
|
+
// every id slot so downstream consumers (`doctor.ts` per-cite walk +
|
|
170
|
+
// `cite-contract-reminder.cjs`) can look up `commitments[i]` for any
|
|
171
|
+
// valid `i < cite_ids.length` without falling into an undefined slot.
|
|
172
|
+
for (let i = 0; i < parsed.ids.length; i += 1) {
|
|
173
|
+
result.cite_commitments.push(parsed.commitment);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = { parseCiteLine };
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2.0.0-rc.37 NEW-30: shared client-protocol adapter for hook scripts.
|
|
3
|
+
*
|
|
4
|
+
* The three host clients (Claude Code / Codex CLI / Cursor) differ in how a
|
|
5
|
+
* hook surfaces context back to the model:
|
|
6
|
+
* - Claude Code reads a stdout JSON envelope
|
|
7
|
+
* ({ hookSpecificOutput: { hookEventName, additionalContext } }).
|
|
8
|
+
* - Codex CLI and Cursor read plain stderr text.
|
|
9
|
+
* Each hook had its own copy of the detect-client + read-stdin + pick-channel
|
|
10
|
+
* logic (fabric-hint.detectClient, cite-policy-evict.isClaudeCode + readStdinJson,
|
|
11
|
+
* knowledge-hint-broad inline CLAUDE_PROJECT_DIR check). This module is the
|
|
12
|
+
* single canonical implementation so the protocol choice lives in one place.
|
|
13
|
+
*
|
|
14
|
+
* Provides:
|
|
15
|
+
* - detectClient(dirnameHint?) → 'cc' | 'codex' | 'cursor' | undefined
|
|
16
|
+
* 3-tier: FABRIC_HINT_CLIENT env override → CLAUDE_PROJECT_DIR (cc) →
|
|
17
|
+
* __dirname path heuristic (.claude / .codex / .cursor). dirnameHint
|
|
18
|
+
* defaults to this lib's own dir (which still lives under the client
|
|
19
|
+
* dir, e.g. .claude/hooks/lib), so the heuristic stays accurate.
|
|
20
|
+
* - isClaudeCode() → boolean (CLAUDE_PROJECT_DIR present)
|
|
21
|
+
* - readStdinJson({ timeoutMs }) → Promise<object | null>
|
|
22
|
+
* Async stdin JSON reader; null on parse error / closed stdin / timeout.
|
|
23
|
+
* - emitContext(text, { client, eventName, streams, forceStderr }) → void
|
|
24
|
+
* Standardised output: Claude Code → stdout JSON envelope; Codex/Cursor
|
|
25
|
+
* → plain stderr. forceStderr pins stderr even on Claude Code (used for
|
|
26
|
+
* SessionStart one-shot reminders). Best-effort — never throws.
|
|
27
|
+
*
|
|
28
|
+
* Never-throw contract (KT-DEC-0007): every path degrades silently rather than
|
|
29
|
+
* blocking the host's main flow.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
function isClaudeCode() {
|
|
33
|
+
return (
|
|
34
|
+
typeof process.env.CLAUDE_PROJECT_DIR === "string" &&
|
|
35
|
+
process.env.CLAUDE_PROJECT_DIR.length > 0
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function detectClient(dirnameHint) {
|
|
40
|
+
const envClient = process.env.FABRIC_HINT_CLIENT;
|
|
41
|
+
if (typeof envClient === "string" && envClient.length > 0) {
|
|
42
|
+
const normalised = envClient.trim().toLowerCase();
|
|
43
|
+
if (normalised === "cc" || normalised === "codex" || normalised === "cursor") {
|
|
44
|
+
return normalised;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (isClaudeCode()) return "cc";
|
|
48
|
+
// Path heuristic against the caller's directory (defaults to this lib's dir,
|
|
49
|
+
// which sits under the client root, e.g. .codex/hooks/lib).
|
|
50
|
+
const dir = typeof dirnameHint === "string" && dirnameHint.length > 0 ? dirnameHint : __dirname;
|
|
51
|
+
try {
|
|
52
|
+
if (dir.includes(".claude/") || dir.includes(".claude\\")) return "cc";
|
|
53
|
+
if (dir.includes(".codex/") || dir.includes(".codex\\")) return "codex";
|
|
54
|
+
if (dir.includes(".cursor/") || dir.includes(".cursor\\")) return "cursor";
|
|
55
|
+
} catch {
|
|
56
|
+
// fall through
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readStdinJson(opts) {
|
|
62
|
+
const { timeoutMs = 1000 } = opts || {};
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
let buffer = "";
|
|
65
|
+
process.stdin.on("data", (chunk) => {
|
|
66
|
+
buffer += chunk;
|
|
67
|
+
});
|
|
68
|
+
process.stdin.on("end", () => {
|
|
69
|
+
try {
|
|
70
|
+
resolve(JSON.parse(buffer));
|
|
71
|
+
} catch {
|
|
72
|
+
resolve(null);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
process.stdin.on("error", () => resolve(null));
|
|
76
|
+
// Defensive timeout: if stdin never closes (host bug), give up.
|
|
77
|
+
setTimeout(() => resolve(null), timeoutMs).unref();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function emitContext(text, opts) {
|
|
82
|
+
const { client, eventName = "UserPromptSubmit", streams = {}, forceStderr = false } = opts || {};
|
|
83
|
+
const stdout = streams.stdout || process.stdout;
|
|
84
|
+
const stderr = streams.stderr || process.stderr;
|
|
85
|
+
const useStdoutEnvelope =
|
|
86
|
+
!forceStderr && (client === "cc" || (client === undefined && isClaudeCode()));
|
|
87
|
+
try {
|
|
88
|
+
if (useStdoutEnvelope) {
|
|
89
|
+
const envelope = {
|
|
90
|
+
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
|
91
|
+
};
|
|
92
|
+
stdout.write(`${JSON.stringify(envelope)}\n`);
|
|
93
|
+
} else {
|
|
94
|
+
stderr.write(`${text}\n`);
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// best-effort — never throw
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
isClaudeCode,
|
|
103
|
+
detectClient,
|
|
104
|
+
readStdinJson,
|
|
105
|
+
emitContext,
|
|
106
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2.0.0-rc.37 NEW-19: shared fabric-config reader for hook scripts.
|
|
3
|
+
*
|
|
4
|
+
* Before this lib, every hook re-implemented the same defensive
|
|
5
|
+
* `readFileSync(.fabric/fabric-config.json) → JSON.parse → validate one key →
|
|
6
|
+
* fall back to default` boilerplate, once PER KEY (knowledge-hint-broad alone
|
|
7
|
+
* read the file 5× per SessionStart fire: cooldown / top_k / underseed /
|
|
8
|
+
* summary_max_len / reminder_to_context). This module centralises the read +
|
|
9
|
+
* mtime-keyed memoisation so a single hook fire parses the config once.
|
|
10
|
+
*
|
|
11
|
+
* Provides:
|
|
12
|
+
* - readConfig(projectRoot) → object
|
|
13
|
+
* Parsed fabric-config.json (memoised on path+mtime). Returns `{}` on
|
|
14
|
+
* any failure (missing file / parse error / non-object). Never throws.
|
|
15
|
+
* mtime-keyed so a config rewrite mid-process (test harness) invalidates
|
|
16
|
+
* the cached value automatically — production hooks are single-shot so
|
|
17
|
+
* the common case is one stat + one parse.
|
|
18
|
+
* - readConfigNumber(root, key, fallback, { min, max, integer }) → number
|
|
19
|
+
* - readConfigBoolean(root, key, fallback) → boolean
|
|
20
|
+
* - readConfigString(root, key, fallback) → string
|
|
21
|
+
* Typed getters with inline range/shape validation; any miss → fallback.
|
|
22
|
+
* - configPathFor(projectRoot) → absolute config path
|
|
23
|
+
* - clearConfigCache() → void (test helper)
|
|
24
|
+
*
|
|
25
|
+
* Never-throw contract: every export degrades to its fallback rather than
|
|
26
|
+
* throwing, preserving the reminder-layer hook invariant (KT-DEC-0007: hooks
|
|
27
|
+
* never block on their own malfunction).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const { existsSync, readFileSync, statSync } = require("node:fs");
|
|
31
|
+
const { join } = require("node:path");
|
|
32
|
+
|
|
33
|
+
const FABRIC_DIR_REL = ".fabric";
|
|
34
|
+
const FABRIC_CONFIG_FILE = "fabric-config.json";
|
|
35
|
+
|
|
36
|
+
// path → { mtime, value }. Per-process; mtime-keyed for test-mutation safety.
|
|
37
|
+
const _cache = new Map();
|
|
38
|
+
|
|
39
|
+
function configPathFor(projectRoot) {
|
|
40
|
+
return join(projectRoot, FABRIC_DIR_REL, FABRIC_CONFIG_FILE);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readConfig(projectRoot) {
|
|
44
|
+
const path = configPathFor(projectRoot);
|
|
45
|
+
let mtime;
|
|
46
|
+
try {
|
|
47
|
+
if (!existsSync(path)) {
|
|
48
|
+
_cache.delete(path);
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
mtime = statSync(path).mtimeMs;
|
|
52
|
+
} catch {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
const cached = _cache.get(path);
|
|
56
|
+
if (cached && cached.mtime === mtime) return cached.value;
|
|
57
|
+
let value = {};
|
|
58
|
+
try {
|
|
59
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
60
|
+
if (raw && typeof raw === "object") value = raw;
|
|
61
|
+
} catch {
|
|
62
|
+
value = {};
|
|
63
|
+
}
|
|
64
|
+
_cache.set(path, { mtime, value });
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clearConfigCache() {
|
|
69
|
+
_cache.clear();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// opts:
|
|
73
|
+
// min / max — inclusive range; out-of-range → fallback
|
|
74
|
+
// integer — require Number.isInteger; non-integer → fallback (strict)
|
|
75
|
+
// floor — accept any in-range number, return Math.floor(v) (lenient)
|
|
76
|
+
// `integer` and `floor` are independent: `integer` rejects fractional values,
|
|
77
|
+
// `floor` truncates them. Pick whichever matches the caller's legacy contract.
|
|
78
|
+
function readConfigNumber(projectRoot, key, fallback, opts) {
|
|
79
|
+
const { min, max, integer, floor } = opts || {};
|
|
80
|
+
const v = readConfig(projectRoot)[key];
|
|
81
|
+
if (typeof v === "number" && Number.isFinite(v)) {
|
|
82
|
+
if (integer && !Number.isInteger(v)) return fallback;
|
|
83
|
+
if (typeof min === "number" && v < min) return fallback;
|
|
84
|
+
if (typeof max === "number" && v > max) return fallback;
|
|
85
|
+
return floor ? Math.floor(v) : v;
|
|
86
|
+
}
|
|
87
|
+
return fallback;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readConfigBoolean(projectRoot, key, fallback) {
|
|
91
|
+
const v = readConfig(projectRoot)[key];
|
|
92
|
+
return typeof v === "boolean" ? v : fallback;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readConfigString(projectRoot, key, fallback) {
|
|
96
|
+
const v = readConfig(projectRoot)[key];
|
|
97
|
+
return typeof v === "string" && v.length > 0 ? v : fallback;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
readConfig,
|
|
102
|
+
clearConfigCache,
|
|
103
|
+
readConfigNumber,
|
|
104
|
+
readConfigBoolean,
|
|
105
|
+
readConfigString,
|
|
106
|
+
configPathFor,
|
|
107
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2.0.0-rc.37 NEW-19: shared `.fabric/.cache/` sidecar I/O for hook scripts.
|
|
3
|
+
*
|
|
4
|
+
* Hooks persist tiny per-session state (turn counters, last-emit timestamps,
|
|
5
|
+
* shown-hint sets) under `.fabric/.cache/`. Each hook had its own copy of the
|
|
6
|
+
* read-JSON-or-null / write-JSON-best-effort / read-text / write-text helpers
|
|
7
|
+
* (cite-policy-evict's readEvictState/writeEvictState, broad's
|
|
8
|
+
* readBroadLastEmit/writeBroadLastEmit, fabric-hint's shown-cache + edit-counter
|
|
9
|
+
* + maintenance-last-emit). This module is the single canonical implementation.
|
|
10
|
+
*
|
|
11
|
+
* Provides (all keyed on a bare `fileName` resolved under .fabric/.cache/):
|
|
12
|
+
* - cachePath(projectRoot, fileName) → absolute path
|
|
13
|
+
* - readJsonState(root, fileName, validate?) → parsed | null
|
|
14
|
+
* null on missing / parse error / validate() === false. Never throws.
|
|
15
|
+
* - writeJsonState(root, fileName, value) → boolean
|
|
16
|
+
* mkdir -p + write; false on failure. Never throws.
|
|
17
|
+
* - readTextState(root, fileName) → trimmed string | null
|
|
18
|
+
* - writeTextState(root, fileName, text) → boolean
|
|
19
|
+
*
|
|
20
|
+
* Never-throw contract: write failures return false (counter loss is
|
|
21
|
+
* acceptable — the hook never blocks user flow on sidecar I/O, KT-DEC-0007).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs");
|
|
25
|
+
const { dirname, join } = require("node:path");
|
|
26
|
+
|
|
27
|
+
const CACHE_DIR_REL = join(".fabric", ".cache");
|
|
28
|
+
|
|
29
|
+
function cachePath(projectRoot, fileName) {
|
|
30
|
+
return join(projectRoot, CACHE_DIR_REL, fileName);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readJsonState(projectRoot, fileName, validate) {
|
|
34
|
+
const path = cachePath(projectRoot, fileName);
|
|
35
|
+
if (!existsSync(path)) return null;
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
38
|
+
if (typeof validate === "function" && !validate(parsed)) return null;
|
|
39
|
+
return parsed;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeJsonState(projectRoot, fileName, value) {
|
|
46
|
+
const path = cachePath(projectRoot, fileName);
|
|
47
|
+
try {
|
|
48
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
49
|
+
writeFileSync(path, JSON.stringify(value));
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readTextState(projectRoot, fileName) {
|
|
57
|
+
const path = cachePath(projectRoot, fileName);
|
|
58
|
+
if (!existsSync(path)) return null;
|
|
59
|
+
try {
|
|
60
|
+
return readFileSync(path, "utf8").trim();
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function writeTextState(projectRoot, fileName, text) {
|
|
67
|
+
const path = cachePath(projectRoot, fileName);
|
|
68
|
+
try {
|
|
69
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
70
|
+
writeFileSync(path, String(text));
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
cachePath,
|
|
79
|
+
readJsonState,
|
|
80
|
+
writeJsonState,
|
|
81
|
+
readTextState,
|
|
82
|
+
writeTextState,
|
|
83
|
+
CACHE_DIR_REL,
|
|
84
|
+
};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rc.35 TASK-06 (P0-10.b) — summary-fallback library.
|
|
3
|
+
*
|
|
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
|
+
* file at `.fabric/knowledge/<type>/<id>--<slug>.md`, extracting the first
|
|
7
|
+
* paragraph under `## Summary`, and substituting that text into the entry
|
|
8
|
+
* before the hook renders it.
|
|
9
|
+
*
|
|
10
|
+
* Caching: results are stored in `.fabric/.cache/summary-fallback.json`
|
|
11
|
+
* keyed by the current `revision_hash` returned by plan-context-hint. The
|
|
12
|
+
* cache is wiped wholesale when the revision changes (cheap invariant —
|
|
13
|
+
* any meta rev bump implies entry text MAY have moved). Per-process call
|
|
14
|
+
* also benefits from in-memory dedup since the same opaque id may appear
|
|
15
|
+
* across narrow + broad paths.
|
|
16
|
+
*
|
|
17
|
+
* Design contract:
|
|
18
|
+
* - Never throw. ANY failure (cache read, fs scan, file read) degrades
|
|
19
|
+
* to a no-op — the original opaque summary is left untouched. Hooks
|
|
20
|
+
* must remain best-effort.
|
|
21
|
+
* - Idempotent over identical inputs. Two calls in succession with the
|
|
22
|
+
* same revision_hash + entries set produce zero disk reads on the
|
|
23
|
+
* second call.
|
|
24
|
+
*
|
|
25
|
+
* Public API (module.exports):
|
|
26
|
+
* resolveOpaqueSummaries(entries, projectRoot, revisionHash) — returns
|
|
27
|
+
* a NEW array of entries with `summary` substituted for opaque cases.
|
|
28
|
+
* Original `entry.id` is preserved verbatim.
|
|
29
|
+
*
|
|
30
|
+
* _extractFirstSummaryParagraph(md) — pure helper, exposed for testing.
|
|
31
|
+
*
|
|
32
|
+
* _readCache / _writeCache — exposed for testing.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } = require("node:fs");
|
|
36
|
+
const { join } = require("node:path");
|
|
37
|
+
|
|
38
|
+
const CACHE_DIR_REL = ".fabric/.cache";
|
|
39
|
+
const CACHE_FILE_REL = ".fabric/.cache/summary-fallback.json";
|
|
40
|
+
const KNOWLEDGE_DIR_REL = ".fabric/knowledge";
|
|
41
|
+
const SUMMARY_MAX_LEN = 80;
|
|
42
|
+
const KNOWLEDGE_TYPE_DIRS = ["decisions", "pitfalls", "guidelines", "models", "processes"];
|
|
43
|
+
|
|
44
|
+
function _isOpaque(entry) {
|
|
45
|
+
if (!entry || typeof entry.id !== "string" || typeof entry.summary !== "string") {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return entry.summary.trim() === entry.id.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Pure helper: extract the first paragraph under a `## Summary` heading.
|
|
53
|
+
*
|
|
54
|
+
* - `## Summary` is case-insensitive but level-sensitive (only H2).
|
|
55
|
+
* - First paragraph = lines until blank line or next heading.
|
|
56
|
+
* - Collapses whitespace + trims; returns `""` if no summary section or
|
|
57
|
+
* the section is empty.
|
|
58
|
+
*/
|
|
59
|
+
function _extractFirstSummaryParagraph(md) {
|
|
60
|
+
if (typeof md !== "string" || md.length === 0) return "";
|
|
61
|
+
const lines = md.split(/\r?\n/);
|
|
62
|
+
let i = 0;
|
|
63
|
+
while (i < lines.length) {
|
|
64
|
+
if (/^##\s+summary\s*$/i.test(lines[i].trim())) {
|
|
65
|
+
i += 1;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
i += 1;
|
|
69
|
+
}
|
|
70
|
+
if (i >= lines.length) return "";
|
|
71
|
+
// Skip blank lines after the heading
|
|
72
|
+
while (i < lines.length && lines[i].trim().length === 0) i += 1;
|
|
73
|
+
// Collect until the next blank line or next heading
|
|
74
|
+
const buf = [];
|
|
75
|
+
while (i < lines.length) {
|
|
76
|
+
const line = lines[i];
|
|
77
|
+
if (line.trim().length === 0) break;
|
|
78
|
+
if (/^#{1,6}\s/.test(line.trim())) break;
|
|
79
|
+
buf.push(line.trim());
|
|
80
|
+
i += 1;
|
|
81
|
+
}
|
|
82
|
+
const flat = buf.join(" ").replace(/\s+/g, " ").trim();
|
|
83
|
+
if (flat.length === 0) return "";
|
|
84
|
+
if (flat.length <= SUMMARY_MAX_LEN) return flat;
|
|
85
|
+
return `${flat.slice(0, SUMMARY_MAX_LEN - 1)}…`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function _readCache(projectRoot) {
|
|
89
|
+
const cachePath = join(projectRoot, CACHE_FILE_REL);
|
|
90
|
+
if (!existsSync(cachePath)) return null;
|
|
91
|
+
try {
|
|
92
|
+
const raw = readFileSync(cachePath, "utf8");
|
|
93
|
+
const parsed = JSON.parse(raw);
|
|
94
|
+
if (parsed && typeof parsed === "object" && typeof parsed.revision === "string" && parsed.summaries && typeof parsed.summaries === "object") {
|
|
95
|
+
return parsed;
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// ignore — caller treats null as no-cache
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function _writeCache(projectRoot, payload) {
|
|
104
|
+
try {
|
|
105
|
+
const cacheDir = join(projectRoot, CACHE_DIR_REL);
|
|
106
|
+
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
|
|
107
|
+
const cachePath = join(projectRoot, CACHE_FILE_REL);
|
|
108
|
+
writeFileSync(cachePath, JSON.stringify(payload), "utf8");
|
|
109
|
+
} catch {
|
|
110
|
+
// Best-effort — failing to persist cache is not an error
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Scan `.fabric/knowledge/<type>/` for the canonical `<id>--<slug>.md`
|
|
116
|
+
* matching `stableId`. Tries the most likely type-dir first based on the
|
|
117
|
+
* entry's `type` hint, then falls back to scanning all canonical type
|
|
118
|
+
* directories. Returns the absolute path or null.
|
|
119
|
+
*
|
|
120
|
+
* The id→file mapping is unique by construction (stable_id is allocated
|
|
121
|
+
* once per file), so the first match wins.
|
|
122
|
+
*/
|
|
123
|
+
function _findEntryFile(projectRoot, stableId, typeHint) {
|
|
124
|
+
const baseDir = join(projectRoot, KNOWLEDGE_DIR_REL);
|
|
125
|
+
if (!existsSync(baseDir)) return null;
|
|
126
|
+
const tryOrder = [];
|
|
127
|
+
if (typeof typeHint === "string" && typeHint.length > 0) {
|
|
128
|
+
// Accept both singular and plural hints — find the plural form.
|
|
129
|
+
const lower = typeHint.toLowerCase();
|
|
130
|
+
const plural = KNOWLEDGE_TYPE_DIRS.find((d) => d === lower || d.startsWith(lower));
|
|
131
|
+
if (plural) tryOrder.push(plural);
|
|
132
|
+
}
|
|
133
|
+
for (const t of KNOWLEDGE_TYPE_DIRS) {
|
|
134
|
+
if (!tryOrder.includes(t)) tryOrder.push(t);
|
|
135
|
+
}
|
|
136
|
+
const prefix = `${stableId}--`;
|
|
137
|
+
for (const t of tryOrder) {
|
|
138
|
+
const typeDir = join(baseDir, t);
|
|
139
|
+
if (!existsSync(typeDir)) continue;
|
|
140
|
+
let files;
|
|
141
|
+
try {
|
|
142
|
+
files = readdirSync(typeDir);
|
|
143
|
+
} catch {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
for (const f of files) {
|
|
147
|
+
if (f.startsWith(prefix) && f.endsWith(".md")) {
|
|
148
|
+
return join(typeDir, f);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _resolveOne(projectRoot, entry) {
|
|
156
|
+
const filePath = _findEntryFile(projectRoot, entry.id, entry.type);
|
|
157
|
+
if (filePath === null) return "";
|
|
158
|
+
let md;
|
|
159
|
+
try {
|
|
160
|
+
md = readFileSync(filePath, "utf8");
|
|
161
|
+
} catch {
|
|
162
|
+
return "";
|
|
163
|
+
}
|
|
164
|
+
return _extractFirstSummaryParagraph(md);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Main API. Returns a new array of entries with `summary` swapped for
|
|
169
|
+
* the extracted fallback wherever the original summary was opaque AND
|
|
170
|
+
* the fallback extraction yielded a non-empty string. Non-opaque entries
|
|
171
|
+
* pass through unchanged.
|
|
172
|
+
*/
|
|
173
|
+
function resolveOpaqueSummaries(entries, projectRoot, revisionHash) {
|
|
174
|
+
if (!Array.isArray(entries) || entries.length === 0) return entries;
|
|
175
|
+
const cache = _readCache(projectRoot);
|
|
176
|
+
const cachedSummaries = cache && cache.revision === revisionHash && cache.summaries ? cache.summaries : {};
|
|
177
|
+
const nextCacheSummaries = { ...cachedSummaries };
|
|
178
|
+
let cacheChanged = cache === null || cache.revision !== revisionHash;
|
|
179
|
+
const result = entries.map((entry) => {
|
|
180
|
+
if (!_isOpaque(entry)) return entry;
|
|
181
|
+
const id = entry.id;
|
|
182
|
+
let fallback;
|
|
183
|
+
if (Object.prototype.hasOwnProperty.call(cachedSummaries, id)) {
|
|
184
|
+
fallback = cachedSummaries[id];
|
|
185
|
+
} else {
|
|
186
|
+
fallback = _resolveOne(projectRoot, entry);
|
|
187
|
+
nextCacheSummaries[id] = fallback;
|
|
188
|
+
cacheChanged = true;
|
|
189
|
+
}
|
|
190
|
+
if (typeof fallback === "string" && fallback.length > 0) {
|
|
191
|
+
return { ...entry, summary: fallback };
|
|
192
|
+
}
|
|
193
|
+
return entry;
|
|
194
|
+
});
|
|
195
|
+
if (cacheChanged) {
|
|
196
|
+
_writeCache(projectRoot, { revision: revisionHash, summaries: nextCacheSummaries });
|
|
197
|
+
}
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
resolveOpaqueSummaries,
|
|
203
|
+
_extractFirstSummaryParagraph,
|
|
204
|
+
_readCache,
|
|
205
|
+
_writeCache,
|
|
206
|
+
_findEntryFile,
|
|
207
|
+
_isOpaque,
|
|
208
|
+
SUMMARY_MAX_LEN,
|
|
209
|
+
KNOWLEDGE_TYPE_DIRS,
|
|
210
|
+
};
|