@fenglimg/fabric-cli 2.0.0-rc.15 → 2.0.0-rc.21
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-4HC5ZK7H.js +598 -0
- package/dist/{chunk-AXIFEVAS.js → chunk-FNO7CQDG.js} +1 -1
- package/dist/chunk-KZ2YITOS.js +225 -0
- package/dist/{chunk-SKSYUHKK.js → chunk-MF3OTILQ.js} +0 -4
- package/dist/{chunk-OBQU6NHO.js → chunk-ZSESMG6L.js} +0 -6
- package/dist/{config-7YD365I3.js → config-AYP5F72E.js} +2 -2
- package/dist/{doctor-6XHLQJXB.js → doctor-L6TIXXIX.js} +129 -3
- package/dist/index.js +11 -8
- package/dist/{install-JLDCHAXV.js → install-DNZXGFHJ.js} +23 -25
- package/dist/{plan-context-hint-73U4FGKO.js → plan-context-hint-CFDGXHCA.js} +4 -4
- package/dist/{serve-L3X5UHG2.js → serve-6PPQX7AW.js} +1 -1
- package/dist/{uninstall-DD6FIFCI.js → uninstall-L2HEEOU3.js} +147 -55
- package/package.json +3 -3
- package/templates/hooks/fabric-hint.cjs +350 -21
- package/templates/hooks/knowledge-hint-broad.cjs +39 -14
- package/templates/hooks/knowledge-hint-narrow.cjs +31 -7
- package/templates/hooks/lib/banner-i18n.cjs +252 -0
- package/dist/chunk-AIB54QRT.js +0 -82
- package/dist/chunk-UTF4YBDN.js +0 -366
- 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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } = require("node:fs");
|
|
2
|
+
const { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } = require("node:fs");
|
|
3
3
|
const { dirname, join } = require("node:path");
|
|
4
4
|
|
|
5
5
|
// v2.0.0-rc.7 T5: session-digest writer. Best-effort (never blocks Stop hook
|
|
@@ -13,6 +13,15 @@ try {
|
|
|
13
13
|
sessionDigestWriter = null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// v2.0.0-rc.16 TASK-002 (F2-apply): banner-i18n lib for the 5 Signal
|
|
17
|
+
// banners (A/B/C/D-never/D-aged). Resolved ONCE per main() invocation and
|
|
18
|
+
// threaded into decide() / evaluateMaintenanceSignal() via the existing
|
|
19
|
+
// thresholds object. Lib is required at module load; failure to load is
|
|
20
|
+
// fatal-here-but-silent: the require itself can't throw without the .cjs
|
|
21
|
+
// being missing entirely (a packaging bug we'd want to surface during
|
|
22
|
+
// install integration tests, not silently swallow).
|
|
23
|
+
const { renderBanner, readFabricLanguage } = require("./lib/banner-i18n.cjs");
|
|
24
|
+
|
|
16
25
|
// CONSTANTS — duplicated from packages/server/src/services/_shared.ts.
|
|
17
26
|
// DRY violation accepted: this hook script runs in user repos WITHOUT
|
|
18
27
|
// node_modules access, so it cannot import from @fenglimg/fabric-server.
|
|
@@ -22,6 +31,11 @@ const EVENT_TYPE_PROPOSED = "knowledge_proposed";
|
|
|
22
31
|
const EVENT_TYPE_INIT_SCAN_COMPLETED = "init_scan_completed";
|
|
23
32
|
// v2.0.0-rc.7 T10: doctor_run event drives Signal D (maintenance hint).
|
|
24
33
|
const EVENT_TYPE_DOCTOR_RUN = "doctor_run";
|
|
34
|
+
// v2.0.0-rc.20 TASK-03: per-turn cite-policy observation event. Emitted by
|
|
35
|
+
// extractAndWriteAssistantTurnsBestEffort() after the Stop hook parses each
|
|
36
|
+
// assistant envelope's first non-empty line for a `KB:` prefix. Schema
|
|
37
|
+
// registered in packages/shared/src/schemas/event-ledger.ts (rc.20 TASK-02).
|
|
38
|
+
const EVENT_TYPE_ASSISTANT_TURN_OBSERVED = "assistant_turn_observed";
|
|
25
39
|
// rc.6 TASK-022 (E5): Signal A is now `24h OR N-edits since last
|
|
26
40
|
// knowledge_proposed`. The edit-count branch reads
|
|
27
41
|
// `.fabric/.cache/edit-counter` (one ISO-8601 line per PreToolUse fire,
|
|
@@ -556,6 +570,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
556
570
|
typeof cfg.reviewHintPendingAgeDays === "number" && cfg.reviewHintPendingAgeDays > 0
|
|
557
571
|
? cfg.reviewHintPendingAgeDays
|
|
558
572
|
: DEFAULT_REVIEW_HINT_PENDING_AGE_DAYS;
|
|
573
|
+
// rc.16 TASK-002: banner variant for the i18n lib. Defaults to 'zh-CN' so
|
|
574
|
+
// existing test callers (which never pass thresholds.variant) get the rc.15
|
|
575
|
+
// byte-identical Chinese output. main() always supplies the resolved variant.
|
|
576
|
+
const variant = typeof cfg.variant === "string" ? cfg.variant : "zh-CN";
|
|
559
577
|
|
|
560
578
|
// ---- Archive signal (rc.6 TASK-022 — Signal A, 24h-OR-N-edits) -----------
|
|
561
579
|
// Locate the most-recent knowledge_proposed event. If none exists, Signal A
|
|
@@ -611,14 +629,17 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
611
629
|
`累计 ${editStats.editsSinceLastProposed} 次编辑(阈值 ${editStats.threshold})`,
|
|
612
630
|
);
|
|
613
631
|
}
|
|
614
|
-
|
|
632
|
+
// rc.16 TASK-002: 5-banner i18n via lib/banner-i18n.cjs. Substring
|
|
633
|
+
// contracts ('25.0h', '阈值 N', 'fabric-archive') preserved by the lib's
|
|
634
|
+
// zh-CN templates — see lib header for the full contract.
|
|
635
|
+
const line1 = renderBanner("archiveLine1", variant, { parts: parts.join(" / ") });
|
|
615
636
|
const activity = banner && typeof banner.activityOverview === "string"
|
|
616
637
|
? banner.activityOverview
|
|
617
638
|
: "";
|
|
618
639
|
const line2 = activity.length > 0
|
|
619
|
-
?
|
|
640
|
+
? renderBanner("archiveActivity", variant, { activity })
|
|
620
641
|
: "";
|
|
621
|
-
const line3 = "
|
|
642
|
+
const line3 = renderBanner("archiveCta", variant, {});
|
|
622
643
|
const reason = [line1, line2, line3].filter((l) => l.length > 0).join("\n");
|
|
623
644
|
return {
|
|
624
645
|
decision: "block",
|
|
@@ -648,8 +669,13 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
648
669
|
stats.oldestAgeMs !== null
|
|
649
670
|
? ` / 最早一条 ${(stats.oldestAgeMs / MS_PER_DAY).toFixed(1)} 天前`
|
|
650
671
|
: "";
|
|
651
|
-
|
|
652
|
-
|
|
672
|
+
// rc.16 TASK-002: i18n via lib. Substrings ('${count} 条', 'fabric-review')
|
|
673
|
+
// preserved by the lib's zh-CN templates.
|
|
674
|
+
const line1 = renderBanner("reviewLine1", variant, {
|
|
675
|
+
count: stats.count,
|
|
676
|
+
ageSuffix,
|
|
677
|
+
});
|
|
678
|
+
const line2 = renderBanner("reviewCta", variant, {});
|
|
653
679
|
const reason = `${line1}\n${line2}`;
|
|
654
680
|
return {
|
|
655
681
|
decision: "block",
|
|
@@ -690,12 +716,16 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
690
716
|
(hoursSinceProposed === null || hoursSinceProposed >= UNDERSEED_NO_PROPOSED_HOURS);
|
|
691
717
|
|
|
692
718
|
if (triggerUnderseed) {
|
|
693
|
-
// rc.
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
719
|
+
// rc.16 TASK-002: i18n via lib. Substrings ('${nodeCount}/${threshold}',
|
|
720
|
+
// 'fabric-import', '${hoursSinceInit}h') preserved by the lib's zh-CN
|
|
721
|
+
// templates. Note: hoursSinceInit is passed as already-toFixed(1) string
|
|
722
|
+
// to keep the lib pure (no number formatting in render path).
|
|
723
|
+
const line1 = renderBanner("importLine1", variant, {
|
|
724
|
+
nodeCount: underseed.nodeCount,
|
|
725
|
+
threshold: underseed.threshold,
|
|
726
|
+
hoursSinceInit: hoursSinceInit.toFixed(1),
|
|
727
|
+
});
|
|
728
|
+
const line2 = renderBanner("importCta", variant, {});
|
|
699
729
|
const reason = `${line1}\n${line2}`;
|
|
700
730
|
return {
|
|
701
731
|
decision: "block",
|
|
@@ -898,6 +928,10 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
|
|
|
898
928
|
typeof cfg.maintenanceHintCooldownDays === "number" && cfg.maintenanceHintCooldownDays > 0
|
|
899
929
|
? cfg.maintenanceHintCooldownDays
|
|
900
930
|
: DEFAULT_MAINTENANCE_HINT_COOLDOWN_DAYS;
|
|
931
|
+
// rc.16 TASK-002: banner variant for the i18n lib. Defaults to 'zh-CN' so
|
|
932
|
+
// existing rc.7 T10 test fixtures (which never set thresholds.variant) get
|
|
933
|
+
// the byte-identical Chinese maintenance banner.
|
|
934
|
+
const variant = typeof cfg.variant === "string" ? cfg.variant : "zh-CN";
|
|
901
935
|
|
|
902
936
|
if (canonicalCount < MAINTENANCE_HINT_MIN_CANONICAL) {
|
|
903
937
|
return null;
|
|
@@ -922,14 +956,18 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
|
|
|
922
956
|
if (ageDays < days) return null; // doctor ran recently, no nag.
|
|
923
957
|
}
|
|
924
958
|
|
|
925
|
-
// rc.
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
929
|
-
const line2 = "
|
|
930
|
-
const
|
|
931
|
-
?
|
|
932
|
-
:
|
|
959
|
+
// rc.16 TASK-002: i18n via lib. Substrings ('从未运行 lint 检查',
|
|
960
|
+
// '已 N 天未跑 lint', 'fabric doctor --lint') preserved by the lib's
|
|
961
|
+
// zh-CN templates. ageDays passed as already-toFixed(1) string to keep
|
|
962
|
+
// the lib pure (no number formatting in render path).
|
|
963
|
+
const line2 = renderBanner("maintenanceLine2", variant, {});
|
|
964
|
+
const line1 = lastDoctorTs === null
|
|
965
|
+
? renderBanner("maintenanceLine1Never", variant, {})
|
|
966
|
+
: renderBanner("maintenanceLine1Aged", variant, {
|
|
967
|
+
days,
|
|
968
|
+
ageDays: ageDays.toFixed(1),
|
|
969
|
+
});
|
|
970
|
+
const reason = `${line1}\n${line2}`;
|
|
933
971
|
|
|
934
972
|
return {
|
|
935
973
|
decision: "block",
|
|
@@ -966,6 +1004,204 @@ function tryReadStdinJson() {
|
|
|
966
1004
|
}
|
|
967
1005
|
}
|
|
968
1006
|
|
|
1007
|
+
/**
|
|
1008
|
+
* v2.0.0-rc.20 TASK-03: parse the raw text that follows the `KB:` prefix on
|
|
1009
|
+
* the first non-empty line of an assistant turn. Returns parsed cite ids and
|
|
1010
|
+
* the per-id tag enum vocabulary (planned/recalled/chained-from/dismissed/
|
|
1011
|
+
* none). Never throws; best-effort tolerant parser.
|
|
1012
|
+
*
|
|
1013
|
+
* Vocabulary contract (mirrored in
|
|
1014
|
+
* packages/shared/src/schemas/event-ledger.ts → assistantTurnObservedEventSchema):
|
|
1015
|
+
* - "none" → ids=[], tags=["none"]
|
|
1016
|
+
* - "KP-001" → ids=["KP-001"], tags=[]
|
|
1017
|
+
* - "KP-001, KT-DEC-0009 (review)" → ids=["KP-001","KT-DEC-0009"], tags=["review"]
|
|
1018
|
+
* - "KP-001 [recalled][chained-from KP-002]" →
|
|
1019
|
+
* ids=["KP-001"], tags=["recalled","chained-from"]
|
|
1020
|
+
* - "dismissed:<reason>" → ids=[], tags=["dismissed"]
|
|
1021
|
+
* (kb_line_raw preserves the full "dismissed:<reason>" verbatim;
|
|
1022
|
+
* the parsed `cite_tags` only carries the enum value "dismissed".)
|
|
1023
|
+
*
|
|
1024
|
+
* Tags are filtered to the Zod enum set
|
|
1025
|
+
* { planned, recalled, chained-from, dismissed, none } before being returned —
|
|
1026
|
+
* arbitrary parenthetical/bracket text outside the enum is dropped (silently)
|
|
1027
|
+
* so the emitted event always round-trips through the schema.
|
|
1028
|
+
*/
|
|
1029
|
+
function parseKbLine(raw) {
|
|
1030
|
+
const result = { cite_ids: [], cite_tags: [] };
|
|
1031
|
+
if (typeof raw !== "string") return result;
|
|
1032
|
+
const trimmed = raw.trim();
|
|
1033
|
+
if (trimmed.length === 0) return result;
|
|
1034
|
+
|
|
1035
|
+
// dismissed:<reason> → tag="dismissed", no ids.
|
|
1036
|
+
if (/^dismissed:/i.test(trimmed)) {
|
|
1037
|
+
result.cite_tags.push("dismissed");
|
|
1038
|
+
return result;
|
|
1039
|
+
}
|
|
1040
|
+
// bare "none" → tag="none".
|
|
1041
|
+
if (/^none$/i.test(trimmed)) {
|
|
1042
|
+
result.cite_tags.push("none");
|
|
1043
|
+
return result;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Allowed tag enum (matches assistantTurnObservedEventSchema.cite_tags z.enum).
|
|
1047
|
+
const ALLOWED_TAGS = new Set(["planned", "recalled", "chained-from", "dismissed", "none"]);
|
|
1048
|
+
const tagSet = new Set();
|
|
1049
|
+
|
|
1050
|
+
// Extract bracketed tags: `[recalled]`, `[chained-from KP-002]`, etc.
|
|
1051
|
+
// We keep only the leading enum token (split on whitespace) so trailing
|
|
1052
|
+
// ref-ids inside the bracket are discarded — they're not part of the tag
|
|
1053
|
+
// vocabulary.
|
|
1054
|
+
const bracketRegex = /\[([^\]]+)\]/g;
|
|
1055
|
+
let bracketMatch;
|
|
1056
|
+
let stripped = trimmed;
|
|
1057
|
+
while ((bracketMatch = bracketRegex.exec(trimmed)) !== null) {
|
|
1058
|
+
const inner = bracketMatch[1].trim();
|
|
1059
|
+
if (inner.length === 0) continue;
|
|
1060
|
+
const head = inner.split(/\s+/)[0].toLowerCase();
|
|
1061
|
+
if (ALLOWED_TAGS.has(head)) tagSet.add(head);
|
|
1062
|
+
}
|
|
1063
|
+
stripped = stripped.replace(bracketRegex, " ");
|
|
1064
|
+
|
|
1065
|
+
// Extract parenthetical tags: `(review)`, `(planned)`, etc. Only enum
|
|
1066
|
+
// members are retained.
|
|
1067
|
+
const parenRegex = /\(([^)]+)\)/g;
|
|
1068
|
+
let parenMatch;
|
|
1069
|
+
while ((parenMatch = parenRegex.exec(trimmed)) !== null) {
|
|
1070
|
+
const inner = parenMatch[1].trim().toLowerCase();
|
|
1071
|
+
if (inner.length === 0) continue;
|
|
1072
|
+
if (ALLOWED_TAGS.has(inner)) tagSet.add(inner);
|
|
1073
|
+
}
|
|
1074
|
+
stripped = stripped.replace(parenRegex, " ");
|
|
1075
|
+
|
|
1076
|
+
// Remaining content: comma-separated ids, possibly with stray whitespace.
|
|
1077
|
+
// We split on comma, trim, and keep tokens that look like ref-ids
|
|
1078
|
+
// (uppercase letter prefix). This filters out leftover english words and
|
|
1079
|
+
// is permissive enough to allow custom id schemes (KP-, KT-, KD-, etc.).
|
|
1080
|
+
const parts = stripped.split(",");
|
|
1081
|
+
for (const partRaw of parts) {
|
|
1082
|
+
const part = partRaw.trim();
|
|
1083
|
+
if (part.length === 0) continue;
|
|
1084
|
+
// Take the leading token (whitespace-bounded) so "KT-DEC-0009 garbage"
|
|
1085
|
+
// still yields "KT-DEC-0009".
|
|
1086
|
+
const token = part.split(/\s+/)[0];
|
|
1087
|
+
if (/^[A-Z][A-Z0-9-]+$/.test(token)) {
|
|
1088
|
+
result.cite_ids.push(token);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Materialise tagSet → array (preserves insertion order via Set semantics).
|
|
1093
|
+
for (const t of tagSet) result.cite_tags.push(t);
|
|
1094
|
+
return result;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* v2.0.0-rc.20 TASK-03: detect which client surface invoked the hook so the
|
|
1099
|
+
* emitted assistant_turn_observed event can carry a `client` discriminator
|
|
1100
|
+
* without having to inspect the transcript shape.
|
|
1101
|
+
*
|
|
1102
|
+
* Resolution order (first match wins):
|
|
1103
|
+
* 1. `FABRIC_HINT_CLIENT` env var — explicit override, set by the per-
|
|
1104
|
+
* client install pipeline when the hook-config schema supports env
|
|
1105
|
+
* injection.
|
|
1106
|
+
* 2. Path heuristic against `__dirname` — `.claude/` → "cc", `.codex/` →
|
|
1107
|
+
* "codex". Covers the dominant deployment shape (hook script lives
|
|
1108
|
+
* under the client's per-repo dir).
|
|
1109
|
+
*
|
|
1110
|
+
* Returns `undefined` when neither signal fires (e.g. Cursor — deferred to
|
|
1111
|
+
* rc.21 — or a custom deployment). The Zod schema marks `client` optional,
|
|
1112
|
+
* so omitting it leaves the event valid.
|
|
1113
|
+
*/
|
|
1114
|
+
function detectClient() {
|
|
1115
|
+
const envClient = process.env.FABRIC_HINT_CLIENT;
|
|
1116
|
+
if (typeof envClient === "string" && envClient.length > 0) {
|
|
1117
|
+
const normalised = envClient.trim().toLowerCase();
|
|
1118
|
+
if (normalised === "cc" || normalised === "codex" || normalised === "cursor") {
|
|
1119
|
+
return normalised;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
// Path heuristic — __dirname is the directory containing this .cjs file
|
|
1123
|
+
// when invoked normally (require.main === module).
|
|
1124
|
+
try {
|
|
1125
|
+
if (__dirname.includes(".claude/") || __dirname.includes(".claude\\")) return "cc";
|
|
1126
|
+
if (__dirname.includes(".codex/") || __dirname.includes(".codex\\")) return "codex";
|
|
1127
|
+
} catch {
|
|
1128
|
+
// __dirname always defined for cjs modules; fall through defensively.
|
|
1129
|
+
}
|
|
1130
|
+
return undefined;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* v2.0.0-rc.20 TASK-03: emit one `assistant_turn_observed` event per
|
|
1135
|
+
* assistant envelope harvested from the transcript. Wrapped in try/catch
|
|
1136
|
+
* (best-effort, never throws — Stop hook MUST stay non-blocking on any
|
|
1137
|
+
* failure here). The event shape mirrors
|
|
1138
|
+
* assistantTurnObservedEventSchema in
|
|
1139
|
+
* packages/shared/src/schemas/event-ledger.ts (registered in rc.20 TASK-02).
|
|
1140
|
+
*
|
|
1141
|
+
* Call site sits immediately AFTER writeSessionDigestBestEffort so both
|
|
1142
|
+
* digest + per-turn events derive from the same transcript snapshot.
|
|
1143
|
+
*
|
|
1144
|
+
* `id` mirrors the server's convention (`event:<uuid>`) using
|
|
1145
|
+
* crypto.randomUUID when available — falls back to a timestamp+counter
|
|
1146
|
+
* tuple on older Node where randomUUID is missing (cjs hook tooling
|
|
1147
|
+
* defensively targets Node 18+, but the fallback keeps it event-shaped).
|
|
1148
|
+
*/
|
|
1149
|
+
function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
|
|
1150
|
+
if (stdinPayload === null || typeof stdinPayload !== "object") return;
|
|
1151
|
+
try {
|
|
1152
|
+
const sessionId = stdinPayload.session_id;
|
|
1153
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return;
|
|
1154
|
+
const transcript = summarizeTranscript(stdinPayload.transcript_path);
|
|
1155
|
+
const turns = transcript.assistant_turns;
|
|
1156
|
+
if (!Array.isArray(turns) || turns.length === 0) return;
|
|
1157
|
+
|
|
1158
|
+
// Resolve event-ledger path. Caller already validated cwd shape.
|
|
1159
|
+
const fabricDir = join(cwd, FABRIC_DIR);
|
|
1160
|
+
if (!existsSync(fabricDir)) {
|
|
1161
|
+
// No .fabric/ → workspace is uninitialised. Silently skip; the digest
|
|
1162
|
+
// writer applies the same guard via its own internal check.
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
const ledgerPath = join(fabricDir, EVENT_LEDGER_FILE);
|
|
1166
|
+
const client = detectClient();
|
|
1167
|
+
let randomUUID;
|
|
1168
|
+
try {
|
|
1169
|
+
({ randomUUID } = require("node:crypto"));
|
|
1170
|
+
} catch {
|
|
1171
|
+
randomUUID = null;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
for (const turn of turns) {
|
|
1175
|
+
try {
|
|
1176
|
+
const idSuffix = typeof randomUUID === "function"
|
|
1177
|
+
? randomUUID()
|
|
1178
|
+
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
1179
|
+
const event = {
|
|
1180
|
+
kind: "fabric-event",
|
|
1181
|
+
id: `event:${idSuffix}`,
|
|
1182
|
+
ts: Date.now(),
|
|
1183
|
+
schema_version: 1,
|
|
1184
|
+
session_id: sessionId,
|
|
1185
|
+
event_type: EVENT_TYPE_ASSISTANT_TURN_OBSERVED,
|
|
1186
|
+
kb_line_raw: turn.kb_line_raw,
|
|
1187
|
+
cite_ids: Array.isArray(turn.cite_ids) ? turn.cite_ids : [],
|
|
1188
|
+
cite_tags: Array.isArray(turn.cite_tags) ? turn.cite_tags : [],
|
|
1189
|
+
turn_id: `${sessionId}-${turn.envelope_index}`,
|
|
1190
|
+
envelope_index: turn.envelope_index,
|
|
1191
|
+
timestamp: new Date().toISOString(),
|
|
1192
|
+
};
|
|
1193
|
+
if (client !== undefined) event.client = client;
|
|
1194
|
+
appendFileSync(ledgerPath, JSON.stringify(event) + "\n", "utf8");
|
|
1195
|
+
} catch {
|
|
1196
|
+
// Per-turn failure must not abort the remaining turns; the Stop hook
|
|
1197
|
+
// contract is "never block on hook failure". Best-effort continues.
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
} catch {
|
|
1201
|
+
// Outer guard — never throw. Hook continues silently.
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
969
1205
|
/**
|
|
970
1206
|
* v2.0.0-rc.7 T5: extract user_messages + edit_paths + 1-line title from the
|
|
971
1207
|
* transcript JSONL referenced by the hook's stdin payload. Best-effort, never
|
|
@@ -974,9 +1210,18 @@ function tryReadStdinJson() {
|
|
|
974
1210
|
* Claude Code's transcript_path points at a JSONL where each line is a
|
|
975
1211
|
* message envelope. We sniff for `role: "user"` lines (text content) and
|
|
976
1212
|
* for tool-use entries naming Edit / Write / MultiEdit to harvest file_path.
|
|
1213
|
+
*
|
|
1214
|
+
* v2.0.0-rc.20 TASK-03: additionally collects `assistant_turns[]` — one
|
|
1215
|
+
* entry per assistant envelope with the parsed KB-line cite metadata. Field
|
|
1216
|
+
* is additive; existing callers (writeSessionDigestBestEffort) ignore it.
|
|
977
1217
|
*/
|
|
978
1218
|
function summarizeTranscript(transcriptPath) {
|
|
979
|
-
|
|
1219
|
+
// rc.20 TASK-03: additive `assistant_turns` array — one entry per assistant
|
|
1220
|
+
// envelope, regardless of whether the first line matched KB:. Downstream
|
|
1221
|
+
// consumers (extractAndWriteAssistantTurnsBestEffort) emit one
|
|
1222
|
+
// assistant_turn_observed event per element; `kb_line_raw=null` when no
|
|
1223
|
+
// KB: line was found.
|
|
1224
|
+
const out = { user_messages: [], edit_paths: [], title: "", assistant_turns: [] };
|
|
980
1225
|
if (typeof transcriptPath !== "string" || transcriptPath.length === 0) return out;
|
|
981
1226
|
if (!existsSync(transcriptPath)) return out;
|
|
982
1227
|
let raw;
|
|
@@ -986,6 +1231,7 @@ function summarizeTranscript(transcriptPath) {
|
|
|
986
1231
|
return out;
|
|
987
1232
|
}
|
|
988
1233
|
const lines = raw.split(/\r?\n/);
|
|
1234
|
+
let envelopeIndex = -1;
|
|
989
1235
|
for (const line of lines) {
|
|
990
1236
|
const trimmed = line.trim();
|
|
991
1237
|
if (trimmed.length === 0) continue;
|
|
@@ -996,6 +1242,7 @@ function summarizeTranscript(transcriptPath) {
|
|
|
996
1242
|
continue;
|
|
997
1243
|
}
|
|
998
1244
|
if (envelope === null || typeof envelope !== "object") continue;
|
|
1245
|
+
envelopeIndex += 1;
|
|
999
1246
|
|
|
1000
1247
|
// User text message — Claude Code shape: { role: "user", content: [...] }
|
|
1001
1248
|
// OR nested under `message.role`. Be generous.
|
|
@@ -1013,6 +1260,61 @@ function summarizeTranscript(transcriptPath) {
|
|
|
1013
1260
|
}
|
|
1014
1261
|
}
|
|
1015
1262
|
|
|
1263
|
+
// rc.20 TASK-03: assistant envelope — capture first non-empty line of the
|
|
1264
|
+
// first text block and parse for `KB:` prefix. We push ONE assistant_turns
|
|
1265
|
+
// entry per assistant envelope (even when no KB: line) so downstream can
|
|
1266
|
+
// distinguish "turn observed, no KB" (kb_line_raw=null) from "no turn".
|
|
1267
|
+
if (role === "assistant") {
|
|
1268
|
+
const content = envelope.content || (envelope.message && envelope.message.content);
|
|
1269
|
+
let firstText = null;
|
|
1270
|
+
if (typeof content === "string") {
|
|
1271
|
+
firstText = content;
|
|
1272
|
+
} else if (Array.isArray(content)) {
|
|
1273
|
+
for (const block of content) {
|
|
1274
|
+
if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
|
|
1275
|
+
firstText = block.text;
|
|
1276
|
+
break;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
let kbLineRaw = null;
|
|
1281
|
+
let citeIds = [];
|
|
1282
|
+
let citeTags = [];
|
|
1283
|
+
if (typeof firstText === "string" && firstText.length > 0) {
|
|
1284
|
+
// First non-empty line.
|
|
1285
|
+
const linesOfText = firstText.split(/\r?\n/);
|
|
1286
|
+
let firstNonEmpty = "";
|
|
1287
|
+
for (const l of linesOfText) {
|
|
1288
|
+
if (l.trim().length > 0) {
|
|
1289
|
+
firstNonEmpty = l.trim();
|
|
1290
|
+
break;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
if (firstNonEmpty.length > 0) {
|
|
1294
|
+
// KB: none (case-insensitive on the literal `none`).
|
|
1295
|
+
const noneMatch = firstNonEmpty.match(/^KB:\s*none\s*$/i);
|
|
1296
|
+
const kbMatch = firstNonEmpty.match(/^KB:\s+(.+)$/);
|
|
1297
|
+
if (noneMatch) {
|
|
1298
|
+
kbLineRaw = firstNonEmpty;
|
|
1299
|
+
const parsed = parseKbLine("none");
|
|
1300
|
+
citeIds = parsed.cite_ids;
|
|
1301
|
+
citeTags = parsed.cite_tags;
|
|
1302
|
+
} else if (kbMatch) {
|
|
1303
|
+
kbLineRaw = firstNonEmpty;
|
|
1304
|
+
const parsed = parseKbLine(kbMatch[1]);
|
|
1305
|
+
citeIds = parsed.cite_ids;
|
|
1306
|
+
citeTags = parsed.cite_tags;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
out.assistant_turns.push({
|
|
1311
|
+
envelope_index: envelopeIndex,
|
|
1312
|
+
kb_line_raw: kbLineRaw,
|
|
1313
|
+
cite_ids: citeIds,
|
|
1314
|
+
cite_tags: citeTags,
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1016
1318
|
// Tool use — look for Edit / Write / MultiEdit and harvest file_path.
|
|
1017
1319
|
const candidates = [];
|
|
1018
1320
|
if (envelope.type === "tool_use") candidates.push(envelope);
|
|
@@ -1099,6 +1401,13 @@ function main(env, stdio) {
|
|
|
1099
1401
|
? env.stdin_payload
|
|
1100
1402
|
: tryReadStdinJson();
|
|
1101
1403
|
writeSessionDigestBestEffort(cwd, stdinPayload);
|
|
1404
|
+
// v2.0.0-rc.20 TASK-03: per-turn cite-policy observation events. Same
|
|
1405
|
+
// best-effort contract as the digest writer — never throws, never blocks
|
|
1406
|
+
// the Stop hook on failure. Shares the transcript snapshot read by
|
|
1407
|
+
// writeSessionDigestBestEffort (each call re-reads independently; the
|
|
1408
|
+
// transcript file is small in practice and re-parse cost is dwarfed by
|
|
1409
|
+
// the hook's other I/O).
|
|
1410
|
+
extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload);
|
|
1102
1411
|
|
|
1103
1412
|
const events = readLedger(cwd);
|
|
1104
1413
|
let pendingStats;
|
|
@@ -1147,6 +1456,19 @@ function main(env, stdio) {
|
|
|
1147
1456
|
// rc.7 T7: read the externalized thresholds and pass them into decide.
|
|
1148
1457
|
// Reader failures degrade silently to documented defaults — fabric-hint
|
|
1149
1458
|
// must never block on config errors (see hook contract above).
|
|
1459
|
+
//
|
|
1460
|
+
// rc.16 TASK-002 (F2-apply): resolve `fabric_language` ONCE per main()
|
|
1461
|
+
// invocation via the banner-i18n lib. The result threads through
|
|
1462
|
+
// `thresholds.variant` into both decide() and evaluateMaintenanceSignal()
|
|
1463
|
+
// so we read the config file at most once, not five times. Lib reader
|
|
1464
|
+
// is never-throw; defensive try/catch is belt-and-suspenders.
|
|
1465
|
+
let variant = "zh-CN";
|
|
1466
|
+
try {
|
|
1467
|
+
variant = readFabricLanguage(cwd);
|
|
1468
|
+
} catch {
|
|
1469
|
+
variant = "zh-CN";
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1150
1472
|
let thresholds;
|
|
1151
1473
|
try {
|
|
1152
1474
|
thresholds = {
|
|
@@ -1155,6 +1477,7 @@ function main(env, stdio) {
|
|
|
1155
1477
|
reviewHintPendingAgeDays: readReviewHintPendingAgeDays(cwd),
|
|
1156
1478
|
maintenanceHintDays: readMaintenanceHintDays(cwd),
|
|
1157
1479
|
maintenanceHintCooldownDays: readMaintenanceHintCooldownDays(cwd),
|
|
1480
|
+
variant,
|
|
1158
1481
|
};
|
|
1159
1482
|
} catch {
|
|
1160
1483
|
thresholds = {
|
|
@@ -1163,6 +1486,7 @@ function main(env, stdio) {
|
|
|
1163
1486
|
reviewHintPendingAgeDays: DEFAULT_REVIEW_HINT_PENDING_AGE_DAYS,
|
|
1164
1487
|
maintenanceHintDays: DEFAULT_MAINTENANCE_HINT_DAYS,
|
|
1165
1488
|
maintenanceHintCooldownDays: DEFAULT_MAINTENANCE_HINT_COOLDOWN_DAYS,
|
|
1489
|
+
variant,
|
|
1166
1490
|
};
|
|
1167
1491
|
}
|
|
1168
1492
|
|
|
@@ -1293,6 +1617,11 @@ module.exports = {
|
|
|
1293
1617
|
readMaintenanceHintCooldownDays,
|
|
1294
1618
|
readShownCache,
|
|
1295
1619
|
writeShownCache,
|
|
1620
|
+
// v2.0.0-rc.20 TASK-03 / TASK-09: cite-policy parsing + per-turn emission
|
|
1621
|
+
// helpers (exported for unit testing of the parse + emit contract).
|
|
1622
|
+
parseKbLine,
|
|
1623
|
+
detectClient,
|
|
1624
|
+
extractAndWriteAssistantTurnsBestEffort,
|
|
1296
1625
|
CONSTANTS: {
|
|
1297
1626
|
FABRIC_DIR,
|
|
1298
1627
|
EVENT_LEDGER_FILE,
|
|
@@ -54,6 +54,12 @@ const {
|
|
|
54
54
|
} = require("node:fs");
|
|
55
55
|
const { join } = require("node:path");
|
|
56
56
|
|
|
57
|
+
// rc.16 TASK-003: shared banner-i18n lib (resolves fabric_language config and
|
|
58
|
+
// renders localized banner text). Mirror of the wiring in fabric-hint.cjs
|
|
59
|
+
// (TASK-002). Variant is resolved ONCE per main() invocation via
|
|
60
|
+
// readFabricLanguage(cwd) and threaded into renderBanner — no fs in render path.
|
|
61
|
+
const { renderBanner, readFabricLanguage } = require("./lib/banner-i18n.cjs");
|
|
62
|
+
|
|
57
63
|
// -----------------------------------------------------------------------------
|
|
58
64
|
// rc.12: SessionStart broad-menu is now unconditionally emitted on every
|
|
59
65
|
// SessionStart fire (matching Skill-style progressive disclosure). Prior
|
|
@@ -257,10 +263,12 @@ const MATURITY_PROVEN = "proven";
|
|
|
257
263
|
const MATURITY_VERIFIED = "verified";
|
|
258
264
|
const MATURITY_DRAFT = "draft";
|
|
259
265
|
|
|
260
|
-
// rc.8 underseed self-check banner
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
|
|
266
|
+
// rc.8 underseed self-check banner: single line, emoji-prefixed (cf.
|
|
267
|
+
// fabric-hint.cjs Signal C `📋 Fabric:`). rc.16 TASK-003 routed the literal
|
|
268
|
+
// through the banner-i18n lib (key: 'broadImportBanner') — see main() below
|
|
269
|
+
// for the renderBanner call site. Substring contracts preserved across all
|
|
270
|
+
// variants: leading two-space indent, `📋 Fabric:` prefix, `/fabric-import`
|
|
271
|
+
// verbatim token (asserted by knowledge-hint-broad.test.ts).
|
|
264
272
|
|
|
265
273
|
// -----------------------------------------------------------------------------
|
|
266
274
|
// CLI invocation
|
|
@@ -431,16 +439,29 @@ function renderTruncated(narrow) {
|
|
|
431
439
|
*
|
|
432
440
|
* Returns an array of lines (one stderr write per line keeps the formatter
|
|
433
441
|
* trivial and testable). Returns [] when there is nothing meaningful to say
|
|
434
|
-
* (empty
|
|
442
|
+
* (empty entries set) so callers know to stay silent.
|
|
443
|
+
*
|
|
444
|
+
* Protocol v2 gate (rc.18): payloads must carry `version: 2`. A null/missing
|
|
445
|
+
* payload returns [] silently; a payload with a mismatched `version` returns []
|
|
446
|
+
* after writing exactly one stderr breadcrumb so operators grepping a stuck-
|
|
447
|
+
* banner report can diagnose the version drift without source-diving.
|
|
435
448
|
*/
|
|
436
449
|
function renderSummary(payload) {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
450
|
+
if (!payload || payload.version !== 2) {
|
|
451
|
+
if (payload && payload.version !== undefined) {
|
|
452
|
+
try {
|
|
453
|
+
process.stderr.write(
|
|
454
|
+
`[fabric] hint payload version=${payload.version} unsupported (expected 2), skipping\n`,
|
|
455
|
+
);
|
|
456
|
+
} catch {}
|
|
457
|
+
}
|
|
458
|
+
return [];
|
|
459
|
+
}
|
|
460
|
+
// Protocol v2 (rc.18): the wire field is now `payload.entries`, matching what
|
|
461
|
+
// this renderer always called it locally. The historical `narrow` name (which
|
|
462
|
+
// degenerated in --all mode) has been retired without a compat shim per
|
|
463
|
+
// pre-user clean-slate policy.
|
|
464
|
+
const entries = Array.isArray(payload.entries) ? payload.entries : [];
|
|
444
465
|
if (entries.length === 0) return [];
|
|
445
466
|
|
|
446
467
|
const truncated = entries.length > TRUNCATION_THRESHOLD;
|
|
@@ -487,7 +508,12 @@ function main(env, stdio) {
|
|
|
487
508
|
const lines = renderSummary(payload);
|
|
488
509
|
|
|
489
510
|
if (recommendImport) {
|
|
490
|
-
|
|
511
|
+
// rc.16 TASK-003: resolve fabric_language ONCE per invocation (only when
|
|
512
|
+
// we actually need to emit the banner — keeps the no-banner path free of
|
|
513
|
+
// the extra config read). 'match-existing' / unknown variant folds to 'en'
|
|
514
|
+
// inside renderBanner per UX i18n Policy class 1.
|
|
515
|
+
const variant = readFabricLanguage(cwd);
|
|
516
|
+
lines.push(renderBanner("broadImportBanner", variant, {}));
|
|
491
517
|
}
|
|
492
518
|
|
|
493
519
|
if (lines.length === 0) return; // nothing to say — silent exit
|
|
@@ -523,7 +549,6 @@ module.exports = {
|
|
|
523
549
|
MATURITY_DRAFT,
|
|
524
550
|
DEFAULT_UNDERSEED_NODE_THRESHOLD,
|
|
525
551
|
KNOWLEDGE_CANONICAL_TYPES,
|
|
526
|
-
IMPORT_RECOMMENDATION_BANNER,
|
|
527
552
|
},
|
|
528
553
|
};
|
|
529
554
|
|
|
@@ -615,9 +615,17 @@ function formatEntryLine(entry) {
|
|
|
615
615
|
|
|
616
616
|
/**
|
|
617
617
|
* Render the narrow-match block to an array of stderr lines. Returns []
|
|
618
|
-
* when there is nothing to render (empty
|
|
618
|
+
* when there is nothing to render (empty entries set). Callers stay silent
|
|
619
619
|
* on empty output.
|
|
620
620
|
*
|
|
621
|
+
* Protocol gate (rc.18): only `payload.version === 2` payloads are
|
|
622
|
+
* rendered. Anything else returns []. When the payload exists but carries
|
|
623
|
+
* a mismatched (non-undefined) version, a one-line stderr breadcrumb is
|
|
624
|
+
* emitted as a debug aid — see `_protocol-v2-decisions.md` (Decision 2,
|
|
625
|
+
* "silent-skip + one-line stderr breadcrumb"). The wire field is
|
|
626
|
+
* `payload.entries` (renamed from `payload.narrow` in protocol v2,
|
|
627
|
+
* Decision 1).
|
|
628
|
+
*
|
|
621
629
|
* Output shape:
|
|
622
630
|
* [fabric] N narrow-scoped knowledge entries match your edit targets:
|
|
623
631
|
* [<id>] (<type>/<maturity>) <summary>
|
|
@@ -625,13 +633,28 @@ function formatEntryLine(entry) {
|
|
|
625
633
|
* (如需重读 broad 决策,调 fab_plan_context 或 fabric plan-context-hint --all)
|
|
626
634
|
*/
|
|
627
635
|
function renderSummary(payload) {
|
|
628
|
-
|
|
629
|
-
|
|
636
|
+
if (!payload || payload.version !== 2) {
|
|
637
|
+
if (payload && payload.version !== undefined) {
|
|
638
|
+
// breadcrumb only if payload exists but version mismatches (avoid
|
|
639
|
+
// spam on null). Best-effort write — silent-on-failure honors the
|
|
640
|
+
// hook's "never block edits" contract.
|
|
641
|
+
try {
|
|
642
|
+
process.stderr.write(
|
|
643
|
+
`[fabric] hint payload version=${payload.version} unsupported (expected 2), skipping\n`,
|
|
644
|
+
);
|
|
645
|
+
} catch {
|
|
646
|
+
// ignore — stderr unavailable, silent-skip still applies
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
return [];
|
|
650
|
+
}
|
|
651
|
+
const entries = Array.isArray(payload.entries) ? payload.entries : [];
|
|
652
|
+
if (entries.length === 0) return [];
|
|
630
653
|
|
|
631
654
|
const lines = [
|
|
632
|
-
`[fabric] ${
|
|
655
|
+
`[fabric] ${entries.length} narrow-scoped knowledge entries match your edit targets:`,
|
|
633
656
|
];
|
|
634
|
-
for (const entry of
|
|
657
|
+
for (const entry of entries) {
|
|
635
658
|
lines.push(formatEntryLine(entry));
|
|
636
659
|
}
|
|
637
660
|
lines.push(" (如需重读 broad 决策,调 fab_plan_context 或 fabric plan-context-hint --all)");
|
|
@@ -699,7 +722,8 @@ function main(env, stdio) {
|
|
|
699
722
|
: invokePlanContextHint(cwd, paths);
|
|
700
723
|
if (cliPayload === null || cliPayload === undefined) return;
|
|
701
724
|
|
|
702
|
-
|
|
725
|
+
// Protocol v2 (rc.18 TASK-005): wire field is `entries`, no v1 shim.
|
|
726
|
+
const narrow = Array.isArray(cliPayload.entries) ? cliPayload.entries : [];
|
|
703
727
|
if (narrow.length === 0) {
|
|
704
728
|
// rc.6 TASK-023 (E6): silence-counter — matched-narrow == 0. The CLI
|
|
705
729
|
// had a chance to match against the extracted paths but came back
|
|
@@ -764,7 +788,7 @@ function main(env, stdio) {
|
|
|
764
788
|
});
|
|
765
789
|
}
|
|
766
790
|
|
|
767
|
-
const lines = renderSummary({ ...cliPayload,
|
|
791
|
+
const lines = renderSummary({ ...cliPayload, entries: gateDecision.narrow });
|
|
768
792
|
if (lines.length === 0) return;
|
|
769
793
|
for (const line of lines) {
|
|
770
794
|
err.write(`${line}\n`);
|