@fenglimg/fabric-cli 2.2.0 → 2.3.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/README.md +2 -2
- package/dist/audit-PURSJJFH.js +734 -0
- package/dist/{chunk-QPAW6IYT.js → chunk-7V4XMLQ2.js} +3 -3
- package/dist/{chunk-7ZDXBOOU.js → chunk-ACSMNX3V.js} +44 -128
- package/dist/{chunk-PTGQAZEW.js → chunk-GGDVZCD6.js} +2 -4
- package/dist/{chunk-EOT63RDH.js → chunk-I5F5BHWI.js} +9 -0
- package/dist/chunk-PP7QVRXH.js +565 -0
- package/dist/chunk-SL77FXX7.js +54 -0
- package/dist/{chunk-3D7B2UAZ.js → chunk-VQKXTMWH.js} +44 -4
- package/dist/doctor-S6KPGS35.js +27 -0
- package/dist/index.js +90 -80
- package/dist/{info-7FKBTMVO.js → info-NJEY26H6.js} +91 -46
- package/dist/{context-UJCGYOT6.js → inspect-5YZMJPFM.js} +10 -10
- package/dist/{install-v2-3KJX3YRO.js → install-v2-KGIDII4H.js} +163 -364
- package/dist/{store-HOCORVL3.js → store-GF4SFBMJ.js} +155 -57
- package/dist/{sync-DT5UJMMR.js → sync-3XCIRDPK.js} +3 -4
- package/dist/{uninstall-IFN2KYBK.js → uninstall-BG4ML4FC.js} +39 -10
- package/package.json +3 -7
- package/templates/hooks/cite-policy-evict.cjs +1 -1
- package/templates/hooks/configs/claude-code.json +1 -5
- package/templates/hooks/configs/codex-hooks.json +1 -5
- package/templates/hooks/fabric-hint.cjs +67 -41
- package/templates/hooks/knowledge-hint-broad.cjs +82 -24
- package/templates/hooks/knowledge-hint-narrow.cjs +3 -3
- package/templates/hooks/knowledge-pretooluse.cjs +111 -0
- package/templates/hooks/lib/banner-i18n.cjs +12 -11
- package/templates/hooks/lib/bindings-snapshot-reader.cjs +1 -1
- package/templates/hooks/lib/event-writer.cjs +79 -0
- package/templates/hooks/lib/nudge-policy.cjs +11 -0
- package/templates/hooks/lib/theme.cjs +62 -0
- package/templates/hooks/post-tooluse-mutation.cjs +28 -39
- package/templates/skills/fabric-archive/SKILL.md +29 -12
- package/templates/skills/fabric-archive/ref/dry-run-scope.md +1 -1
- package/templates/skills/fabric-archive/ref/i18n-policy.md +1 -1
- package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +5 -5
- package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +2 -2
- package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +1 -1
- package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +1 -1
- package/templates/skills/fabric-archive/ref/phase-3-6-related-edges.md +1 -1
- package/templates/skills/fabric-archive/ref/phase-3-classify.md +1 -1
- package/templates/skills/fabric-archive/ref/phase-4-5-emit.md +1 -1
- package/templates/skills/fabric-archive/ref/phase-4-mcp-persist.md +6 -5
- package/templates/skills/{fabric-import/ref/checkpoint-state.md → fabric-archive/ref/source-checkpoint.md} +3 -3
- package/templates/skills/{fabric-import/ref/phase-3-dedup.md → fabric-archive/ref/source-dedup.md} +4 -4
- package/templates/skills/{fabric-import/ref/phase-2-mining.md → fabric-archive/ref/source-mining.md} +20 -20
- package/templates/skills/{fabric-import/ref/output-contract.md → fabric-archive/ref/source-output-contract.md} +3 -3
- package/templates/skills/{fabric-import/ref/state-recovery.md → fabric-archive/ref/source-state-recovery.md} +2 -2
- package/templates/skills/{fabric-import/ref/worked-examples.md → fabric-archive/ref/source-worked-examples.md} +10 -10
- package/templates/skills/fabric-archive/ref/worked-examples.md +3 -3
- package/templates/skills/fabric-review/SKILL.md +19 -15
- package/templates/skills/fabric-review/ref/cite-contract.md +2 -2
- package/templates/skills/fabric-review/ref/modify-flow.md +13 -1
- package/templates/skills/fabric-review/ref/per-mode-flows.md +5 -5
- package/templates/skills/fabric-review/ref/relate-mode.md +33 -0
- package/templates/skills/fabric-review/ref/retire-mode.md +47 -0
- package/templates/skills/fabric-review/ref/semantic-check.md +1 -1
- package/templates/skills/fabric-review/ref/worked-examples.md +5 -5
- package/templates/skills/fabric-store/SKILL.md +12 -27
- package/templates/skills/fabric-sync/SKILL.md +16 -35
- package/templates/skills/lib/shared-policy.md +6 -4
- package/dist/chunk-27HK6H5Y.js +0 -69
- package/dist/chunk-E7HJUU34.js +0 -1096
- package/dist/chunk-NLNH64A3.js +0 -43
- package/dist/chunk-QFIVFZRH.js +0 -13
- package/dist/doctor-MDTZWKBK.js +0 -24
- package/dist/metrics-HMFH4YHK.js +0 -135
- package/dist/scope-explain-HLJZ2M33.js +0 -48
- package/dist/status-4R3TM4FJ.js +0 -37
- package/dist/whoami-ITGEFWH4.js +0 -49
- package/templates/skills/fabric/SKILL.md +0 -100
- package/templates/skills/fabric-audit/SKILL.md +0 -63
- package/templates/skills/fabric-connect/SKILL.md +0 -48
- package/templates/skills/fabric-import/SKILL.md +0 -151
- package/templates/skills/fabric-import/ref/i18n-policy.md +0 -78
|
@@ -6,7 +6,11 @@ const { dirname, join } = require("node:path");
|
|
|
6
6
|
// ledgers (events.jsonl, metrics.jsonl). Under multi-window concurrency a bare
|
|
7
7
|
// appendFileSync can interleave a partial write; route through the advisory-lock
|
|
8
8
|
// primitive (drop-on-contention, best-effort — matches injection-log).
|
|
9
|
+
// ux-w2-9: events.jsonl writes go through the single guarded event-writer
|
|
10
|
+
// (envelope stamp + event_type guard); metrics.jsonl stays on the raw locked
|
|
11
|
+
// primitive (it is not a schema-governed event ledger).
|
|
9
12
|
const { appendLockedLine } = require("./lib/injection-log.cjs");
|
|
13
|
+
const { appendEvent } = require("./lib/event-writer.cjs");
|
|
10
14
|
|
|
11
15
|
// v2.0.0-rc.7 T5: session-digest writer. Best-effort (never blocks Stop hook
|
|
12
16
|
// on failure — see contract in lib/session-digest-writer.cjs).
|
|
@@ -976,11 +980,11 @@ function readArchiveEditThreshold(projectRoot) {
|
|
|
976
980
|
* Both default to a no-trigger shape when omitted (back-compat for callers
|
|
977
981
|
* pre-dating the two-lane split).
|
|
978
982
|
*
|
|
979
|
-
* Returns one of:
|
|
980
|
-
* - { decision: '
|
|
981
|
-
* - { decision: '
|
|
982
|
-
* - { decision: '
|
|
983
|
-
* - { decision: '
|
|
983
|
+
* Returns one of (ux-w0-3: `decision: 'soft'` — a reminder, never a gate):
|
|
984
|
+
* - { decision: 'soft', reason, signal: 'archive', recommended_skill: 'fabric-archive' }
|
|
985
|
+
* - { decision: 'soft', reason, signal: 'archive_backlog', recommended_skill: 'fabric-archive' }
|
|
986
|
+
* - { decision: 'soft', reason, signal: 'review', recommended_skill: 'fabric-review' }
|
|
987
|
+
* - { decision: 'soft', reason, signal: 'import', recommended_skill: 'fabric-import' }
|
|
984
988
|
* - null on no trigger
|
|
985
989
|
*/
|
|
986
990
|
// rc.7 T7: thresholds is the externalized-config view passed in by main().
|
|
@@ -1077,7 +1081,7 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
1077
1081
|
const line3 = renderBanner("archiveCta", variant, {});
|
|
1078
1082
|
const reason = [line1, line2, line3].filter((l) => l.length > 0).join("\n");
|
|
1079
1083
|
return {
|
|
1080
|
-
decision: "
|
|
1084
|
+
decision: "soft",
|
|
1081
1085
|
reason,
|
|
1082
1086
|
signal: "archive",
|
|
1083
1087
|
recommended_skill: "fabric-archive",
|
|
@@ -1100,7 +1104,7 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
1100
1104
|
const line2 = renderBanner("backlogCta", variant, {});
|
|
1101
1105
|
const reason = `${line1}\n${line2}`;
|
|
1102
1106
|
return {
|
|
1103
|
-
decision: "
|
|
1107
|
+
decision: "soft",
|
|
1104
1108
|
reason,
|
|
1105
1109
|
signal: "archive_backlog",
|
|
1106
1110
|
recommended_skill: "fabric-archive",
|
|
@@ -1138,7 +1142,7 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
1138
1142
|
const line2 = renderBanner("reviewCta", variant, {});
|
|
1139
1143
|
const reason = `${line1}\n${line2}`;
|
|
1140
1144
|
return {
|
|
1141
|
-
decision: "
|
|
1145
|
+
decision: "soft",
|
|
1142
1146
|
reason,
|
|
1143
1147
|
signal: "review",
|
|
1144
1148
|
recommended_skill: "fabric-review",
|
|
@@ -1196,10 +1200,11 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
1196
1200
|
const line2 = renderBanner("importCta", variant, {});
|
|
1197
1201
|
const reason = `${line1}\n${line2}`;
|
|
1198
1202
|
return {
|
|
1199
|
-
decision: "
|
|
1203
|
+
decision: "soft",
|
|
1200
1204
|
reason,
|
|
1201
1205
|
signal: "import",
|
|
1202
|
-
|
|
1206
|
+
// W3-C: fabric-import folded into fabric-archive `source` mode.
|
|
1207
|
+
recommended_skill: "fabric-archive",
|
|
1203
1208
|
// v2.1 NEW-N-3: underseed corpus trigger — node-count vs threshold. The
|
|
1204
1209
|
// "import" signal collapses to schema signal_type "other" in main().
|
|
1205
1210
|
threshold: underseed.threshold,
|
|
@@ -1525,8 +1530,8 @@ function writeMaintenanceLastEmit(projectRoot, nowMs, sessionId) {
|
|
|
1525
1530
|
* the previous Signal-D emit. Tracked via dedicated sidecar
|
|
1526
1531
|
* `.fabric/.cache/maintenance-hint-last-emit`.
|
|
1527
1532
|
*
|
|
1528
|
-
* Returns one of:
|
|
1529
|
-
* - { decision: '
|
|
1533
|
+
* Returns one of (ux-w0-3: `decision: 'soft'` — a reminder, never a gate):
|
|
1534
|
+
* - { decision: 'soft', reason, signal: 'maintenance', recommended_skill: null }
|
|
1530
1535
|
* - null on no trigger
|
|
1531
1536
|
*
|
|
1532
1537
|
* `recommended_skill` is intentionally null — the maintenance prompt
|
|
@@ -1591,7 +1596,7 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
|
|
|
1591
1596
|
const reason = `${line1}\n${line2}`;
|
|
1592
1597
|
|
|
1593
1598
|
return {
|
|
1594
|
-
decision: "
|
|
1599
|
+
decision: "soft",
|
|
1595
1600
|
reason,
|
|
1596
1601
|
signal: "maintenance",
|
|
1597
1602
|
// CLI recommendation rather than Skill — doctor is a CLI surface.
|
|
@@ -1681,7 +1686,7 @@ function emitGraphEdgeCandidateBestEffort(cwd, events, sessionId) {
|
|
|
1681
1686
|
};
|
|
1682
1687
|
if (store !== undefined) event.store = store;
|
|
1683
1688
|
if (typeof sessionId === "string" && sessionId.length > 0) event.session_id = sessionId;
|
|
1684
|
-
|
|
1689
|
+
appendEvent(fabricDir, event);
|
|
1685
1690
|
|
|
1686
1691
|
// Record the de-dup marker (best-effort; atomic when state-store lib loaded).
|
|
1687
1692
|
try {
|
|
@@ -1743,7 +1748,7 @@ function emitSignalFiredEvent(cwd, sessionId, result) {
|
|
|
1743
1748
|
fired: true,
|
|
1744
1749
|
};
|
|
1745
1750
|
if (typeof sessionId === "string" && sessionId.length > 0) event.session_id = sessionId;
|
|
1746
|
-
|
|
1751
|
+
appendEvent(fabricDir, event);
|
|
1747
1752
|
} catch {
|
|
1748
1753
|
// best-effort telemetry — never block the hook
|
|
1749
1754
|
}
|
|
@@ -1896,7 +1901,6 @@ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
|
|
|
1896
1901
|
// writer applies the same guard via its own internal check.
|
|
1897
1902
|
return;
|
|
1898
1903
|
}
|
|
1899
|
-
const ledgerPath = join(fabricDir, EVENT_LEDGER_FILE);
|
|
1900
1904
|
const client = detectClient();
|
|
1901
1905
|
let randomUUID;
|
|
1902
1906
|
try {
|
|
@@ -1952,7 +1956,7 @@ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
|
|
|
1952
1956
|
timestamp: new Date().toISOString(),
|
|
1953
1957
|
};
|
|
1954
1958
|
if (client !== undefined) event.client = client;
|
|
1955
|
-
|
|
1959
|
+
appendEvent(fabricDir, event);
|
|
1956
1960
|
} catch {
|
|
1957
1961
|
// Per-turn failure must not abort the remaining turns; the Stop hook
|
|
1958
1962
|
// contract is "never block on hook failure". Best-effort continues.
|
|
@@ -2220,6 +2224,45 @@ function writeSessionDigestBestEffort(projectRoot, stdinPayload) {
|
|
|
2220
2224
|
}
|
|
2221
2225
|
}
|
|
2222
2226
|
|
|
2227
|
+
// ux-w0-3 (KT-DEC-0007): the SINGLE soft-emit path for EVERY Stop-hook signal
|
|
2228
|
+
// (archive / archive_backlog / review / import / maintenance). A nudge is a
|
|
2229
|
+
// reminder layer, NEVER a gate — so no signal ever emits a blocking decision.
|
|
2230
|
+
// The AI channel always carries the reason (flow ⊥ observation, D3); the human
|
|
2231
|
+
// systemMessage is gated by nudge_mode (D4/D5), with high-value signals
|
|
2232
|
+
// (knowledge-loss: archive / archive_backlog) surfacing at lower volumes.
|
|
2233
|
+
// Mutates `result` (strips telemetry-only threshold/actual_value, like the prior
|
|
2234
|
+
// inline paths). When the client adapter is unavailable, falls back to a plain
|
|
2235
|
+
// non-blocking JSON payload (decision stays "soft", never blocking).
|
|
2236
|
+
function emitSoftSignal(out, result, cwd, highValue) {
|
|
2237
|
+
const reasonText = typeof result.reason === "string" ? result.reason : "";
|
|
2238
|
+
delete result.threshold;
|
|
2239
|
+
delete result.actual_value;
|
|
2240
|
+
const client =
|
|
2241
|
+
clientAdapter && typeof clientAdapter.detectClient === "function"
|
|
2242
|
+
? clientAdapter.detectClient(__dirname)
|
|
2243
|
+
: undefined;
|
|
2244
|
+
// Known client (cc / codex): emit the dual-sink envelope on stdout —
|
|
2245
|
+
// additionalContext(AI, always) + systemMessage(human, gated by nudge_mode).
|
|
2246
|
+
if (client && clientAdapter && typeof clientAdapter.emitDualSink === "function") {
|
|
2247
|
+
const humanGate =
|
|
2248
|
+
nudgePolicy !== null
|
|
2249
|
+
? nudgePolicy.resolveHumanSink(cwd, "stop", { highValue })
|
|
2250
|
+
: { emitHuman: true };
|
|
2251
|
+
clientAdapter.emitDualSink(
|
|
2252
|
+
{ human: humanGate.emitHuman ? reasonText : null, ai: reasonText },
|
|
2253
|
+
{ client, eventName: "Stop", streams: { stdout: out } },
|
|
2254
|
+
);
|
|
2255
|
+
return;
|
|
2256
|
+
}
|
|
2257
|
+
// Unknown client / no adapter → emit the reason as a plain, non-blocking
|
|
2258
|
+
// payload on stdout. `result.decision` is "soft" (non-blocking), so this is
|
|
2259
|
+
// a reminder, not a gate (KT-DEC-0007).
|
|
2260
|
+
out.write(JSON.stringify(result));
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
// High-value (knowledge-loss) signals surface at lower nudge_mode volumes.
|
|
2264
|
+
const HIGH_VALUE_SIGNALS = new Set(["archive", "archive_backlog"]);
|
|
2265
|
+
|
|
2223
2266
|
/**
|
|
2224
2267
|
* Main entry — invoked both as a CLI (require.main === module) and in-process by tests.
|
|
2225
2268
|
*
|
|
@@ -2492,9 +2535,7 @@ function main(env, stdio) {
|
|
|
2492
2535
|
// uses hours, so we branch here to avoid mixing semantics.
|
|
2493
2536
|
if (result.signal === "maintenance") {
|
|
2494
2537
|
emitSignalFiredEvent(cwd, sessionId, result);
|
|
2495
|
-
|
|
2496
|
-
delete result.actual_value;
|
|
2497
|
-
out.write(JSON.stringify(result));
|
|
2538
|
+
emitSoftSignal(out, result, cwd, HIGH_VALUE_SIGNALS.has(result.signal));
|
|
2498
2539
|
writeMaintenanceLastEmit(cwd, nowMs, resolveHookSessionId(stdinPayload));
|
|
2499
2540
|
return;
|
|
2500
2541
|
}
|
|
@@ -2516,27 +2557,12 @@ function main(env, stdio) {
|
|
|
2516
2557
|
}
|
|
2517
2558
|
|
|
2518
2559
|
emitSignalFiredEvent(cwd, sessionId, result);
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
//
|
|
2523
|
-
//
|
|
2524
|
-
|
|
2525
|
-
// it (flow ⊥ observation). Missing it is backstopped by the SessionEnd marker
|
|
2526
|
-
// + cross-session debt (D3). Review/import keep the decision:block contract
|
|
2527
|
-
// (out of Goal A scope; KT-DEC-0007 nudge semantics unchanged for them).
|
|
2528
|
-
if (result.signal === "archive" && clientAdapter && typeof clientAdapter.emitDualSink === "function") {
|
|
2529
|
-
const humanGate =
|
|
2530
|
-
nudgePolicy !== null
|
|
2531
|
-
? nudgePolicy.resolveHumanSink(cwd, "stop", { highValue: true })
|
|
2532
|
-
: { emitHuman: true };
|
|
2533
|
-
clientAdapter.emitDualSink(
|
|
2534
|
-
{ human: humanGate.emitHuman ? reasonText : null, ai: reasonText },
|
|
2535
|
-
{ client: clientAdapter.detectClient(__dirname), eventName: "Stop", streams: { stdout: out } },
|
|
2536
|
-
);
|
|
2537
|
-
} else {
|
|
2538
|
-
out.write(JSON.stringify(result));
|
|
2539
|
-
}
|
|
2560
|
+
// ux-w0-3 (KT-DEC-0007): EVERY A/B/C signal (archive / archive_backlog /
|
|
2561
|
+
// review / import) emits SOFT via the shared path — additionalContext(AI) +
|
|
2562
|
+
// nudge_mode-gated systemMessage(human), NEVER decision:block. Previously
|
|
2563
|
+
// only `archive` was soft and review/import blocked; the block contract is
|
|
2564
|
+
// retired (a nudge is a reminder layer, never a gate).
|
|
2565
|
+
emitSoftSignal(out, result, cwd, HIGH_VALUE_SIGNALS.has(result.signal));
|
|
2540
2566
|
cache[result.signal] = nowMs;
|
|
2541
2567
|
writeShownCache(cwd, cache, resolveHookSessionId(stdinPayload));
|
|
2542
2568
|
} catch {
|
|
@@ -42,11 +42,21 @@ const { spawnSync } = require("node:child_process");
|
|
|
42
42
|
const { existsSync, readdirSync, readFileSync } = require("node:fs");
|
|
43
43
|
const { join } = require("node:path");
|
|
44
44
|
|
|
45
|
+
// W3-B F-005 (C-003 HUD-shared layer): the SessionStart AI sink reskin consumes
|
|
46
|
+
// the parity-trivial shared structural primitives — sectionBar (title row) +
|
|
47
|
+
// scopeBadge ([team]/[project]/[personal]) — from the .cjs theme mirror. This is
|
|
48
|
+
// the ONLY shared-theme dependency the hook may take: lib/theme.cjs is byte-locked
|
|
49
|
+
// to packages/shared/src/theme.ts by theme-parity.test.ts (G-THEME). The hook MUST
|
|
50
|
+
// NEVER reach for the CLI-only ESM/TS structure layer (the tree / grid primitives
|
|
51
|
+
// under packages/cli/src/tui) — it is unrequireable from a .cjs runtime; the HUD
|
|
52
|
+
// deliberately uses plain two-space indent instead of the complex tree() primitive.
|
|
53
|
+
const { sectionBar, scopeBadge } = require("./lib/theme.cjs");
|
|
54
|
+
|
|
45
55
|
// W1-01 (ISS-012): the SessionStart broad hook appends a hook_surface_emitted
|
|
46
|
-
// event to the shared events.jsonl.
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
const {
|
|
56
|
+
// event to the shared events.jsonl. ux-w2-9: route through the single guarded
|
|
57
|
+
// event-writer (envelope stamp + event_type guard + advisory-lock append) so
|
|
58
|
+
// the row always satisfies the event-ledger schema the doctor reads.
|
|
59
|
+
const { appendEvent } = require("./lib/event-writer.cjs");
|
|
50
60
|
|
|
51
61
|
// rc.16 TASK-003: shared banner-i18n lib (resolves fabric_language config and
|
|
52
62
|
// renders localized banner text). Mirror of the wiring in fabric-hint.cjs
|
|
@@ -330,7 +340,7 @@ function isImportTouched(projectRoot) {
|
|
|
330
340
|
|
|
331
341
|
/**
|
|
332
342
|
* rc.8 underseed self-check: determine whether the SessionStart hook should
|
|
333
|
-
* surface the one-line `/fabric-
|
|
343
|
+
* surface the one-line `/fabric-archive` recommendation banner.
|
|
334
344
|
*
|
|
335
345
|
* Three-condition truth table (ALL must hold to return true):
|
|
336
346
|
* 1. the workspace is fabric-bound — readWorkspaceBindingId(cwd) !== null
|
|
@@ -441,7 +451,7 @@ const MATURITY_DRAFT = "draft";
|
|
|
441
451
|
// fabric-hint.cjs Signal C `📋 Fabric:`). rc.16 TASK-003 routed the literal
|
|
442
452
|
// through the banner-i18n lib (key: 'broadImportBanner') — see main() below
|
|
443
453
|
// for the renderBanner call site. Substring contracts preserved across all
|
|
444
|
-
// variants: leading two-space indent, `📋 Fabric:` prefix, `/fabric-
|
|
454
|
+
// variants: leading two-space indent, `📋 Fabric:` prefix, `/fabric-archive`
|
|
445
455
|
// verbatim token (asserted by knowledge-hint-broad.test.ts).
|
|
446
456
|
|
|
447
457
|
// -----------------------------------------------------------------------------
|
|
@@ -921,6 +931,24 @@ function renderScopeStoreLabel(snapshot, lang) {
|
|
|
921
931
|
// must_read_if}); `alwaysBodies` is always_bodies[] ({id,type,layer,summary,body}).
|
|
922
932
|
const REFERENCE_TYPES = new Set(["decision", "pitfall", "process"]);
|
|
923
933
|
|
|
934
|
+
// W3-B F-005: map a knowledge entry's layer to a scopeBadge() scope key
|
|
935
|
+
// (team→'team' · project→'project' · personal→'personal'). Bodies carry an
|
|
936
|
+
// explicit `.layer`; plan-context reference entries do not, so the layer is
|
|
937
|
+
// recovered from the id prefix (`team:KT-…` / `project:…` / `personal:…`), with a
|
|
938
|
+
// `KP-` id always personal. Anything unrecognized falls back to 'team' — the
|
|
939
|
+
// broadest, default tier — so the badge never renders an undefined scope.
|
|
940
|
+
const VALID_SCOPES = new Set(["team", "project", "personal"]);
|
|
941
|
+
function layerToScope(entry) {
|
|
942
|
+
const e = entry || {};
|
|
943
|
+
if (typeof e.layer === "string" && VALID_SCOPES.has(e.layer)) return e.layer;
|
|
944
|
+
const id = typeof e.id === "string" ? e.id : "";
|
|
945
|
+
const prefix = id.includes(":") ? id.slice(0, id.indexOf(":")) : "";
|
|
946
|
+
if (VALID_SCOPES.has(prefix)) return prefix;
|
|
947
|
+
// `KP-*` ids are the personal convention even without a `personal:` prefix.
|
|
948
|
+
if (/(^|:)KP-/.test(id)) return "personal";
|
|
949
|
+
return "team";
|
|
950
|
+
}
|
|
951
|
+
|
|
924
952
|
function renderAiSink(opts) {
|
|
925
953
|
const { entries, alwaysBodies, storeLabel, broadIndexBackstop, summaryMaxLen, lang } =
|
|
926
954
|
opts || {};
|
|
@@ -939,35 +967,56 @@ function renderAiSink(opts) {
|
|
|
939
967
|
let indexCount = 0; // total rendered index lines (always + reference), for the backstop.
|
|
940
968
|
|
|
941
969
|
const lines = [];
|
|
942
|
-
|
|
970
|
+
// W3-B F-005 (mockup #3): a single sectionBar title carrying the active /
|
|
971
|
+
// reference counts, replacing the legacy `[fabric:SessionStart] <store>` +
|
|
972
|
+
// flat `ALWAYS-ACTIVE RULES (...)` header. The store label is preserved on the
|
|
973
|
+
// human sink (renderScopeStoreLabel); the AI sink leads with the count title.
|
|
974
|
+
// sectionBar degrades to `# <title>` when color is off (NO_COLOR / non-TTY),
|
|
975
|
+
// byte-locked to the TS source by theme-parity.
|
|
976
|
+
lines.push(
|
|
977
|
+
sectionBar(
|
|
978
|
+
zh
|
|
979
|
+
? `Fabric Knowledge · ${bodies.length} 常驻 · ${referenceEntries.length} 参考`
|
|
980
|
+
: `Fabric Knowledge · ${bodies.length} active · ${referenceEntries.length} reference`,
|
|
981
|
+
),
|
|
982
|
+
);
|
|
943
983
|
|
|
944
|
-
// ALWAYS-ACTIVE
|
|
984
|
+
// ALWAYS-ACTIVE — plain sub-section label (the `RULES (...)` qualifier is kept
|
|
985
|
+
// so the contract substring + framing survive the reskin). Index-only (title +
|
|
986
|
+
// summary), never the eager body.
|
|
945
987
|
lines.push(
|
|
946
988
|
zh
|
|
947
|
-
? "ALWAYS-ACTIVE RULES (无条件适用 · 照此行遵循,正文按需取):"
|
|
948
|
-
: "ALWAYS-ACTIVE RULES (unconditional · act on the line; body on demand):",
|
|
989
|
+
? " ALWAYS-ACTIVE RULES (无条件适用 · 照此行遵循,正文按需取):"
|
|
990
|
+
: " ALWAYS-ACTIVE RULES (unconditional · act on the line; body on demand):",
|
|
949
991
|
);
|
|
950
992
|
if (bodies.length === 0) {
|
|
951
993
|
lines.push(zh ? " (无 always-active 条目)" : " (none)");
|
|
952
994
|
} else {
|
|
953
995
|
// KT-DEC-0036: render each always-active entry as a single index line
|
|
954
|
-
// (title + summary). The body is one cheap on-demand fetch away
|
|
955
|
-
// so injecting it on every SessionStart is a permanent context
|
|
956
|
-
// (KT-GLD-0005) we no longer pay.
|
|
996
|
+
// (scope badge + title + summary). The body is one cheap on-demand fetch away
|
|
997
|
+
// (see footer), so injecting it on every SessionStart is a permanent context
|
|
998
|
+
// tax (KT-GLD-0005) we no longer pay.
|
|
957
999
|
for (const b of bodies) {
|
|
1000
|
+
const badge = scopeBadge(layerToScope(b));
|
|
958
1001
|
const label = `[${TYPE_SINGULAR[b.type] || b.type}] ${b.id}`;
|
|
959
|
-
|
|
960
|
-
|
|
1002
|
+
// ux-w1-3: bound the always-active summary by hint_summary_max_len, mirroring
|
|
1003
|
+
// the REFERENCE must_read_if hook below — one long entry must not blow up the
|
|
1004
|
+
// SessionStart sink.
|
|
1005
|
+
const raw = typeof b.summary === "string" ? b.summary.trim() : "";
|
|
1006
|
+
const summary = truncateSummary(raw, summaryMaxLen);
|
|
1007
|
+
lines.push(
|
|
1008
|
+
summary.length > 0 ? ` ${badge} ${label} · ${summary}` : ` ${badge} ${label}`,
|
|
1009
|
+
);
|
|
961
1010
|
indexCount += 1;
|
|
962
1011
|
}
|
|
963
1012
|
}
|
|
964
1013
|
|
|
965
|
-
// REFERENCE — broad decision/pitfall/process: title + must_read_if hook.
|
|
1014
|
+
// REFERENCE — broad decision/pitfall/process: scope badge + title + must_read_if hook.
|
|
966
1015
|
if (referenceEntries.length > 0) {
|
|
967
1016
|
lines.push(
|
|
968
1017
|
zh
|
|
969
|
-
? "REFERENCE (情境触发 · 命中 must_read_if 时 Read / fab_recall):"
|
|
970
|
-
: "REFERENCE (situational · Read when must_read_if fires / fab_recall):",
|
|
1018
|
+
? " REFERENCE (情境触发 · 命中 must_read_if 时 Read / fab_recall):"
|
|
1019
|
+
: " REFERENCE (situational · Read when must_read_if fires / fab_recall):",
|
|
971
1020
|
);
|
|
972
1021
|
let folded = 0;
|
|
973
1022
|
for (const e of referenceEntries) {
|
|
@@ -975,6 +1024,7 @@ function renderAiSink(opts) {
|
|
|
975
1024
|
folded += 1;
|
|
976
1025
|
continue;
|
|
977
1026
|
}
|
|
1027
|
+
const badge = scopeBadge(layerToScope(e));
|
|
978
1028
|
const type = TYPE_SINGULAR[toPluralType(e.type)] || e.type;
|
|
979
1029
|
const rawHook =
|
|
980
1030
|
typeof e.must_read_if === "string" && e.must_read_if.length > 0
|
|
@@ -983,7 +1033,11 @@ function renderAiSink(opts) {
|
|
|
983
1033
|
? e.summary
|
|
984
1034
|
: "";
|
|
985
1035
|
const hookText = truncateSummary(rawHook, summaryMaxLen);
|
|
986
|
-
lines.push(
|
|
1036
|
+
lines.push(
|
|
1037
|
+
hookText.length > 0
|
|
1038
|
+
? ` ${badge} [${type}] ${e.id} — ${hookText}`
|
|
1039
|
+
: ` ${badge} [${type}] ${e.id}`,
|
|
1040
|
+
);
|
|
987
1041
|
indexCount += 1;
|
|
988
1042
|
}
|
|
989
1043
|
// D0028 backstop: fold the overflow tail into one marker + drift signal.
|
|
@@ -1022,7 +1076,7 @@ function renderAiSink(opts) {
|
|
|
1022
1076
|
// Block 5 (Option X): build the two SessionStart sinks (human systemMessage +
|
|
1023
1077
|
// AI additionalContext) from a plan-context-hint payload, WITHOUT emitting or
|
|
1024
1078
|
// recording telemetry. This is the single shared renderer: main() calls it then
|
|
1025
|
-
// emits + logs; `fabric
|
|
1079
|
+
// emits + logs; `fabric inspect` calls it then prints (byte-identical injection
|
|
1026
1080
|
// by construction — same code, same config/FS reads). Pure-ish: it reads config
|
|
1027
1081
|
// + snapshot + .md summaries for `cwd` but has no stdout/ledger side effects.
|
|
1028
1082
|
//
|
|
@@ -1124,8 +1178,8 @@ function buildSessionStartSinks(cwd, payload, env) {
|
|
|
1124
1178
|
if (humanLines.length > 0) {
|
|
1125
1179
|
humanLines.push(
|
|
1126
1180
|
fabricLanguageForEmit === "zh-CN"
|
|
1127
|
-
? " 看具体注入: fabric
|
|
1128
|
-
: " Inspect this injection: fabric
|
|
1181
|
+
? " 看具体注入: fabric inspect (--explain 看每条来源)"
|
|
1182
|
+
: " Inspect this injection: fabric inspect (--explain for per-entry provenance)",
|
|
1129
1183
|
);
|
|
1130
1184
|
}
|
|
1131
1185
|
|
|
@@ -1190,7 +1244,7 @@ function main(env, stdio) {
|
|
|
1190
1244
|
// per-line char cap + broad_index_backstop fold, not by dropping entries.
|
|
1191
1245
|
|
|
1192
1246
|
// Block 5 (Option X): build both sinks via the shared renderer (same code
|
|
1193
|
-
// `fabric
|
|
1247
|
+
// `fabric inspect` uses → byte-identical injection). Side-effect-free; the
|
|
1194
1248
|
// emit + telemetry below stay in main().
|
|
1195
1249
|
const { human, ai, resolvedPayload, hasRenderedContent, reminderToContext } =
|
|
1196
1250
|
buildSessionStartSinks(cwd, payload, env);
|
|
@@ -1273,7 +1327,7 @@ function main(env, stdio) {
|
|
|
1273
1327
|
rendered_ids: renderedIds,
|
|
1274
1328
|
delivery_status: "delivered",
|
|
1275
1329
|
};
|
|
1276
|
-
|
|
1330
|
+
appendEvent(fabricDir, surfaceEvent);
|
|
1277
1331
|
}
|
|
1278
1332
|
} catch {
|
|
1279
1333
|
// best-effort telemetry — never block session start
|
|
@@ -1299,6 +1353,10 @@ module.exports = {
|
|
|
1299
1353
|
renderFull,
|
|
1300
1354
|
renderTruncated,
|
|
1301
1355
|
renderSummary,
|
|
1356
|
+
// W3-B F-005: the reskinned SessionStart AI sink + its layer→scope mapper,
|
|
1357
|
+
// exported for the NO_COLOR snapshot test (hud-reskin.test.ts).
|
|
1358
|
+
renderAiSink,
|
|
1359
|
+
layerToScope,
|
|
1302
1360
|
truncateSummary,
|
|
1303
1361
|
// rc.8 underseed self-check helpers (exported for unit testing).
|
|
1304
1362
|
countCanonicalNodes,
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* [<id>] (<type>/<maturity>) <summary-line>
|
|
20
20
|
* [<id>] (<type>/<maturity>) <summary-line>
|
|
21
21
|
* ...
|
|
22
|
-
* (如需重读 broad
|
|
22
|
+
* (如需重读 broad 决策,跑 fabric plan-context-hint --all)
|
|
23
23
|
*
|
|
24
24
|
* When narrow.length === 0: complete silence (exit 0, no stderr).
|
|
25
25
|
*
|
|
@@ -1215,7 +1215,7 @@ function readSummaryMaxLen(projectRoot) {
|
|
|
1215
1215
|
* [fabric] N narrow-scoped knowledge entries match your edit targets:
|
|
1216
1216
|
* [<id>] (<type>/<maturity>) <summary>
|
|
1217
1217
|
* ...
|
|
1218
|
-
* (如需重读 broad
|
|
1218
|
+
* (如需重读 broad 决策,跑 fabric plan-context-hint --all)
|
|
1219
1219
|
*/
|
|
1220
1220
|
function renderSummary(payload, maxLen) {
|
|
1221
1221
|
if (!payload || payload.version !== 2) {
|
|
@@ -1242,7 +1242,7 @@ function renderSummary(payload, maxLen) {
|
|
|
1242
1242
|
for (const entry of entries) {
|
|
1243
1243
|
lines.push(formatEntryLine(entry, maxLen));
|
|
1244
1244
|
}
|
|
1245
|
-
lines.push(" (如需重读 broad
|
|
1245
|
+
lines.push(" (如需重读 broad 决策,跑 fabric plan-context-hint --all)");
|
|
1246
1246
|
return lines;
|
|
1247
1247
|
}
|
|
1248
1248
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ux-w2-6: the SINGLE PreToolUse hook. Previously the Edit|Write|MultiEdit
|
|
3
|
+
// matcher carried TWO commands — knowledge-hint-narrow.cjs (narrow KB hint) and
|
|
4
|
+
// cite-policy-evict.cjs (recall-before-edit nudge) — so a single edit produced
|
|
5
|
+
// TWO additionalContext envelopes (双弹). This orchestrator runs both in ONE
|
|
6
|
+
// process and merges their output into ONE envelope.
|
|
7
|
+
//
|
|
8
|
+
// narrow.cjs and cite-policy-evict.cjs stay as standalone modules (their full
|
|
9
|
+
// contract test-suites are unchanged); this entry imports them as libs, reads
|
|
10
|
+
// stdin ONCE (stdin is single-read), hands the parsed payload to each via the
|
|
11
|
+
// `env.payload` test seam, captures each one's stdout, and folds the two
|
|
12
|
+
// envelopes into a single `{ systemMessage?, hookSpecificOutput.additionalContext? }`.
|
|
13
|
+
// Each sub-hook stays best-effort/silent-on-failure, so a throw in one never
|
|
14
|
+
// blocks the edit or suppresses the other (KT-DEC-0007).
|
|
15
|
+
|
|
16
|
+
const narrow = require("./knowledge-hint-narrow.cjs");
|
|
17
|
+
const cite = require("./cite-policy-evict.cjs");
|
|
18
|
+
|
|
19
|
+
function readStdinPayload() {
|
|
20
|
+
try {
|
|
21
|
+
const raw = require("node:fs").readFileSync(0, "utf8");
|
|
22
|
+
if (!raw || raw.trim().length === 0) return null;
|
|
23
|
+
return JSON.parse(raw);
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Parse a captured stdout chunk as a Claude Code hook envelope. Returns null for
|
|
30
|
+
// empty / non-JSON output (e.g. a stderr-only codex breadcrumb leaves stdout
|
|
31
|
+
// empty). Tolerant: a malformed line is ignored, never thrown.
|
|
32
|
+
function parseEnvelope(text) {
|
|
33
|
+
const trimmed = typeof text === "string" ? text.trim() : "";
|
|
34
|
+
if (trimmed.length === 0) return null;
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(trimmed);
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Fold narrow + cite envelopes into one. additionalContext (AI sink) is
|
|
43
|
+
// concatenated (narrow hint first, then the cite nudge); systemMessage (human
|
|
44
|
+
// sink) likewise. Either side may be absent.
|
|
45
|
+
function mergeEnvelopes(narrowText, citeText) {
|
|
46
|
+
const a = parseEnvelope(narrowText);
|
|
47
|
+
const b = parseEnvelope(citeText);
|
|
48
|
+
if (a === null && b === null) return null;
|
|
49
|
+
|
|
50
|
+
const aiParts = [];
|
|
51
|
+
const humanParts = [];
|
|
52
|
+
let eventName = "PreToolUse";
|
|
53
|
+
for (const env of [a, b]) {
|
|
54
|
+
if (env === null) continue;
|
|
55
|
+
const hso = env.hookSpecificOutput;
|
|
56
|
+
if (hso && typeof hso.additionalContext === "string" && hso.additionalContext.length > 0) {
|
|
57
|
+
aiParts.push(hso.additionalContext);
|
|
58
|
+
if (typeof hso.hookEventName === "string") eventName = hso.hookEventName;
|
|
59
|
+
}
|
|
60
|
+
if (typeof env.systemMessage === "string" && env.systemMessage.length > 0) {
|
|
61
|
+
humanParts.push(env.systemMessage);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const merged = {};
|
|
66
|
+
if (humanParts.length > 0) merged.systemMessage = humanParts.join("\n");
|
|
67
|
+
if (aiParts.length > 0) {
|
|
68
|
+
merged.hookSpecificOutput = {
|
|
69
|
+
hookEventName: eventName,
|
|
70
|
+
additionalContext: aiParts.join("\n"),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return Object.keys(merged).length > 0 ? `${JSON.stringify(merged)}\n` : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function main(env, stdio) {
|
|
77
|
+
try {
|
|
78
|
+
const out = (stdio && stdio.stdout) || process.stdout;
|
|
79
|
+
const err = (stdio && stdio.stderr) || process.stderr;
|
|
80
|
+
// Read stdin ONCE and share the parsed payload with both sub-hooks (stdin is
|
|
81
|
+
// a single-read stream — the prior two-command wiring read it twice).
|
|
82
|
+
const payload = env && env.payload !== undefined ? env.payload : readStdinPayload();
|
|
83
|
+
const sub = { ...(env || {}), payload };
|
|
84
|
+
|
|
85
|
+
const narrowChunks = [];
|
|
86
|
+
const citeChunks = [];
|
|
87
|
+
const capture = (sink) => ({ write: (c) => sink.push(String(c)) });
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await narrow.main(sub, { stdout: capture(narrowChunks), stderr: err });
|
|
91
|
+
} catch {
|
|
92
|
+
// narrow best-effort — never block the edit
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
await cite.main(sub, { stdout: capture(citeChunks), stderr: err });
|
|
96
|
+
} catch {
|
|
97
|
+
// cite best-effort — never block the edit
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const merged = mergeEnvelopes(narrowChunks.join(""), citeChunks.join(""));
|
|
101
|
+
if (merged !== null) out.write(merged);
|
|
102
|
+
} catch {
|
|
103
|
+
// Silent — the PreToolUse hook MUST NEVER block the edit on its own failure.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { main, mergeEnvelopes, parseEnvelope };
|
|
108
|
+
|
|
109
|
+
if (require.main === module) {
|
|
110
|
+
main();
|
|
111
|
+
}
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* Broad hook: broadImportBanner
|
|
38
38
|
*
|
|
39
39
|
* Protected tokens — NEVER translated, kept verbatim across all 4 variants:
|
|
40
|
-
* - Slash commands: /fabric-archive, /fabric-review, /fabric-
|
|
40
|
+
* - Slash commands: /fabric-archive, /fabric-review, /fabric-archive
|
|
41
41
|
* - CLI commands: `fabric doctor --lint`
|
|
42
42
|
* - Numeric / template substrings the existing tests assert on:
|
|
43
43
|
* "${hoursElapsed.toFixed(1)}h" (e.g. "25.0h"), "阈值 ${N}h",
|
|
@@ -222,12 +222,13 @@ const STRINGS = {
|
|
|
222
222
|
`📋 Fabric: 知识库节点数 ${p.nodeCount}/${p.threshold},距 init_scan_completed ${p.hoursSinceInit}h。`,
|
|
223
223
|
},
|
|
224
224
|
|
|
225
|
-
//
|
|
226
|
-
//
|
|
225
|
+
// W3-C: fabric-import folded into fabric-archive `source` mode — the underseed
|
|
226
|
+
// cold-start nudge now points at /fabric-archive (its source mode). Protected
|
|
227
|
+
// token /fabric-archive verbatim across all variants.
|
|
227
228
|
importCta: {
|
|
228
|
-
"zh-CN": () => " 是否调 /fabric-
|
|
229
|
-
en: () => " Run /fabric-
|
|
230
|
-
"zh-CN-hybrid": () => " 是否调 /fabric-
|
|
229
|
+
"zh-CN": () => " 是否调 /fabric-archive 的 source mode 从 git 历史与现有文档回灌知识?",
|
|
230
|
+
en: () => " Run /fabric-archive source mode to backfill knowledge from git history and existing docs?",
|
|
231
|
+
"zh-CN-hybrid": () => " 是否调 /fabric-archive 的 source mode 从 git 历史与现有文档回灌知识?",
|
|
231
232
|
},
|
|
232
233
|
|
|
233
234
|
// ---- Signal D: maintenance -----------------------------------------------
|
|
@@ -289,15 +290,15 @@ const STRINGS = {
|
|
|
289
290
|
|
|
290
291
|
// ---- Broad hook: import recommendation ------------------------------------
|
|
291
292
|
// Source (zh-CN): knowledge-hint-broad.cjs:262
|
|
292
|
-
// " 📋 Fabric: 知识库稀疏,是否调 /fabric-
|
|
293
|
+
// " 📋 Fabric: 知识库稀疏,是否调 /fabric-archive 从 git 历史与现有文档回灌知识?"
|
|
293
294
|
// Note: leading two spaces are intentional (existing banner indent).
|
|
294
|
-
// params: {} — protected token /fabric-
|
|
295
|
+
// params: {} — protected token /fabric-archive verbatim.
|
|
295
296
|
broadImportBanner: {
|
|
296
|
-
"zh-CN": () => " 📋 Fabric: 知识库稀疏,是否调 /fabric-
|
|
297
|
+
"zh-CN": () => " 📋 Fabric: 知识库稀疏,是否调 /fabric-archive 从 git 历史与现有文档回灌知识?",
|
|
297
298
|
en: () =>
|
|
298
|
-
" 📋 Fabric: knowledge base is sparse — run /fabric-
|
|
299
|
+
" 📋 Fabric: knowledge base is sparse — run /fabric-archive to backfill from git history and existing docs?",
|
|
299
300
|
"zh-CN-hybrid": () =>
|
|
300
|
-
" 📋 Fabric: 知识库稀疏,是否调 /fabric-
|
|
301
|
+
" 📋 Fabric: 知识库稀疏,是否调 /fabric-archive 从 git 历史与现有文档回灌知识?",
|
|
301
302
|
},
|
|
302
303
|
|
|
303
304
|
// ---- Broad hook: meta auto-refresh breadcrumb (rc.22 Scope D T-D4) -------
|
|
@@ -153,7 +153,7 @@ function liveKnowledgeStats(snapshot) {
|
|
|
153
153
|
// out-of-band (store grew via git pull / cross-workspace sync), so trusting it
|
|
154
154
|
// re-introduced exactly the false-nudge this whole field cures — observed a
|
|
155
155
|
// store with 61 live canonical entries whose cached count was frozen at 1,
|
|
156
|
-
// mis-firing the "knowledge sparse → /fabric-
|
|
156
|
+
// mis-firing the "knowledge sparse → /fabric-archive" underseed nudge AND
|
|
157
157
|
// defeating the fabric-import `canonical > 50 → SKIP` guard. read_set carries
|
|
158
158
|
// no resolved store root either (alias/uuid only), so a live recount is
|
|
159
159
|
// impossible without re-resolution (which hooks must not do). Return null
|