@fenglimg/fabric-cli 2.2.0-rc.1 → 2.2.0-rc.3

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.
Files changed (35) hide show
  1. package/dist/chunk-5LQIHYFC.js +64 -0
  2. package/dist/chunk-5ZUMLCD5.js +248 -0
  3. package/dist/chunk-EOT63RDH.js +36 -0
  4. package/dist/{chunk-AOE6AYI7.js → chunk-F6ITRM7T.js} +2 -2
  5. package/dist/{chunk-WU6GAPKH.js → chunk-H3FE6VIK.js} +3 -5
  6. package/dist/chunk-XCBVSGCS.js +25 -0
  7. package/dist/{chunk-2R55HNVD.js → chunk-XHHCRDIR.js} +71 -6
  8. package/dist/{config-XYRBZJDU.js → config-VJMXCLXW.js} +1 -1
  9. package/dist/{doctor-YONYXDX6.js → doctor-J4O3X54I.js} +118 -7
  10. package/dist/index.js +13 -12
  11. package/dist/{install-74ANPCCP.js → install-BULNDUIM.js} +159 -80
  12. package/dist/{plan-context-hint-FC6P3WFE.js → plan-context-hint-CHVZGOZ5.js} +21 -8
  13. package/dist/{scope-explain-CDIZESP5.js → scope-explain-BWRWBCCP.js} +14 -4
  14. package/dist/{status-GLQWLWH6.js → status-PANEGKU2.js} +17 -6
  15. package/dist/store-66NK2FTQ.js +443 -0
  16. package/dist/{sync-UJ4BBCZJ.js → sync-EA5HZMXM.js} +165 -21
  17. package/dist/{uninstall-C3QXKOO6.js → uninstall-F75MPKQC.js} +27 -1
  18. package/dist/{whoami-2MLO4Y37.js → whoami-66YKY5DZ.js} +16 -5
  19. package/package.json +3 -3
  20. package/templates/hooks/cite-policy-evict.cjs +412 -160
  21. package/templates/hooks/configs/claude-code.json +17 -2
  22. package/templates/hooks/configs/codex-hooks.json +14 -2
  23. package/templates/hooks/configs/cursor-hooks.json +14 -2
  24. package/templates/hooks/fabric-hint.cjs +151 -15
  25. package/templates/hooks/knowledge-hint-broad.cjs +12 -1
  26. package/templates/hooks/knowledge-hint-narrow.cjs +54 -1
  27. package/templates/hooks/post-tooluse-mutation.cjs +285 -0
  28. package/templates/hooks/session-end-marker.cjs +140 -0
  29. package/templates/skills/fabric-archive/SKILL.md +7 -1
  30. package/dist/chunk-4R2CYEA4.js +0 -116
  31. package/dist/chunk-L4Q55UC4.js +0 -52
  32. package/dist/chunk-LFIKMVY7.js +0 -27
  33. package/dist/chunk-RYAFBNES.js +0 -33
  34. package/dist/chunk-T5RPGCCM.js +0 -40
  35. package/dist/store-XB3ADT65.js +0 -144
@@ -29,17 +29,32 @@
29
29
  {
30
30
  "type": "command",
31
31
  "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/knowledge-hint-narrow.cjs"
32
+ },
33
+ {
34
+ "type": "command",
35
+ "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/cite-policy-evict.cjs"
32
36
  }
33
37
  ]
34
38
  }
35
39
  ],
36
- "UserPromptSubmit": [
40
+ "PostToolUse": [
41
+ {
42
+ "matcher": "Edit|Write|MultiEdit",
43
+ "hooks": [
44
+ {
45
+ "type": "command",
46
+ "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/post-tooluse-mutation.cjs"
47
+ }
48
+ ]
49
+ }
50
+ ],
51
+ "SessionEnd": [
37
52
  {
38
53
  "matcher": "*",
39
54
  "hooks": [
40
55
  {
41
56
  "type": "command",
42
- "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/cite-policy-evict.cjs"
57
+ "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/session-end-marker.cjs"
43
58
  }
44
59
  ]
45
60
  }
@@ -8,15 +8,27 @@
8
8
  "SessionStart": [
9
9
  {
10
10
  "command": "\"$(git rev-parse --show-toplevel)/.codex/hooks/knowledge-hint-broad.cjs\""
11
+ }
12
+ ],
13
+ "PreToolUse": [
14
+ {
15
+ "matcher": "Edit|Write|MultiEdit",
16
+ "command": "\"$(git rev-parse --show-toplevel)/.codex/hooks/knowledge-hint-narrow.cjs\""
11
17
  },
12
18
  {
19
+ "matcher": "Edit|Write|MultiEdit",
13
20
  "command": "\"$(git rev-parse --show-toplevel)/.codex/hooks/cite-policy-evict.cjs\""
14
21
  }
15
22
  ],
16
- "PreToolUse": [
23
+ "PostToolUse": [
17
24
  {
18
25
  "matcher": "Edit|Write|MultiEdit",
19
- "command": "\"$(git rev-parse --show-toplevel)/.codex/hooks/knowledge-hint-narrow.cjs\""
26
+ "command": "\"$(git rev-parse --show-toplevel)/.codex/hooks/post-tooluse-mutation.cjs\""
27
+ }
28
+ ],
29
+ "SessionEnd": [
30
+ {
31
+ "command": "\"$(git rev-parse --show-toplevel)/.codex/hooks/session-end-marker.cjs\""
20
32
  }
21
33
  ]
22
34
  }
@@ -5,14 +5,26 @@
5
5
  { "command": ".cursor/hooks/fabric-hint.cjs" }
6
6
  ],
7
7
  "sessionStart": [
8
- { "command": ".cursor/hooks/knowledge-hint-broad.cjs" },
9
- { "command": ".cursor/hooks/cite-policy-evict.cjs" }
8
+ { "command": ".cursor/hooks/knowledge-hint-broad.cjs" }
10
9
  ],
11
10
  "preToolUse": [
12
11
  {
13
12
  "matcher": "Edit|Write|MultiEdit",
14
13
  "command": ".cursor/hooks/knowledge-hint-narrow.cjs"
14
+ },
15
+ {
16
+ "matcher": "Edit|Write|MultiEdit",
17
+ "command": ".cursor/hooks/cite-policy-evict.cjs"
15
18
  }
19
+ ],
20
+ "postToolUse": [
21
+ {
22
+ "matcher": "Edit|Write|MultiEdit",
23
+ "command": ".cursor/hooks/post-tooluse-mutation.cjs"
24
+ }
25
+ ],
26
+ "sessionEnd": [
27
+ { "command": ".cursor/hooks/session-end-marker.cjs" }
16
28
  ]
17
29
  }
18
30
  }
@@ -984,8 +984,34 @@ function readUnderseedThreshold(projectRoot) {
984
984
  return DEFAULT_UNDERSEED_NODE_THRESHOLD;
985
985
  }
986
986
 
987
- function readShownCache(projectRoot) {
988
- const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
987
+ // F13 (ISS-20260531-038): the reminder cooldown sidecars were process-global
988
+ // (one file per project, no session key), so in concurrent multi-window sessions
989
+ // one window firing a nudge wrote the cooldown and silenced that nudge in EVERY
990
+ // other window. Scope the sidecar filename by sessionId — mirrors the already-
991
+ // session-scoped dismiss sidecar (sessionDismissFileName). Backward-compatible:
992
+ // a null/absent sessionId falls back to the legacy non-scoped path (upgrade +
993
+ // pre-session-id callers), so existing on-disk state and tests are unaffected;
994
+ // the Stop hook always passes the real session_id from its stdin payload.
995
+ function resolveHookSessionId(payload) {
996
+ return payload && typeof payload.session_id === "string" && payload.session_id.length > 0
997
+ ? payload.session_id
998
+ : null;
999
+ }
1000
+
1001
+ function sessionScopedCacheFile(baseRelPath, sessionId) {
1002
+ if (sessionId === undefined || sessionId === null || String(sessionId).length === 0) {
1003
+ return baseRelPath;
1004
+ }
1005
+ const safe = String(sessionId).replace(/[^A-Za-z0-9_.-]/g, "-");
1006
+ const lastSlash = baseRelPath.lastIndexOf("/");
1007
+ const dot = baseRelPath.lastIndexOf(".");
1008
+ return dot > lastSlash
1009
+ ? `${baseRelPath.slice(0, dot)}-${safe}${baseRelPath.slice(dot)}`
1010
+ : `${baseRelPath}-${safe}`;
1011
+ }
1012
+
1013
+ function readShownCache(projectRoot, sessionId) {
1014
+ const cachePath = join(projectRoot, sessionScopedCacheFile(SHOWN_CACHE_FILE, sessionId));
989
1015
  if (!existsSync(cachePath)) return {};
990
1016
  try {
991
1017
  const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
@@ -995,12 +1021,11 @@ function readShownCache(projectRoot) {
995
1021
  }
996
1022
  }
997
1023
 
998
- function writeShownCache(projectRoot, cache) {
999
- const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
1024
+ function writeShownCache(projectRoot, cache, sessionId) {
1025
+ const cachePath = join(projectRoot, sessionScopedCacheFile(SHOWN_CACHE_FILE, sessionId));
1000
1026
  try {
1001
- // ISS-016: atomic tmp+rename so concurrent windows / a crash never leave a
1002
- // truncated shown-cache (this file is NOT session-scoped). Falls back to a
1003
- // plain write only if the shared lib failed to load.
1027
+ // ISS-016: atomic tmp+rename so a crash never leaves a truncated shown-cache.
1028
+ // Falls back to a plain write only if the shared lib failed to load.
1004
1029
  if (stateStore && typeof stateStore.atomicWrite === "function") {
1005
1030
  stateStore.atomicWrite(cachePath, JSON.stringify(cache));
1006
1031
  } else {
@@ -1124,8 +1149,8 @@ function findLastDoctorRunTs(events) {
1124
1149
  * v2.0.0-rc.7 T10: read the Signal-D cooldown sidecar timestamp (epoch ms).
1125
1150
  * Missing file / parse failure → null (allow signal to fire).
1126
1151
  */
1127
- function readMaintenanceLastEmit(projectRoot) {
1128
- const p = join(projectRoot, MAINTENANCE_HINT_LAST_EMIT_FILE);
1152
+ function readMaintenanceLastEmit(projectRoot, sessionId) {
1153
+ const p = join(projectRoot, sessionScopedCacheFile(MAINTENANCE_HINT_LAST_EMIT_FILE, sessionId));
1129
1154
  if (!existsSync(p)) return null;
1130
1155
  try {
1131
1156
  const raw = readFileSync(p, "utf8").trim();
@@ -1140,8 +1165,8 @@ function readMaintenanceLastEmit(projectRoot) {
1140
1165
  return null;
1141
1166
  }
1142
1167
 
1143
- function writeMaintenanceLastEmit(projectRoot, nowMs) {
1144
- const p = join(projectRoot, MAINTENANCE_HINT_LAST_EMIT_FILE);
1168
+ function writeMaintenanceLastEmit(projectRoot, nowMs, sessionId) {
1169
+ const p = join(projectRoot, sessionScopedCacheFile(MAINTENANCE_HINT_LAST_EMIT_FILE, sessionId));
1145
1170
  try {
1146
1171
  // ISS-016: atomic tmp+rename (see writeShownCache).
1147
1172
  if (stateStore && typeof stateStore.atomicWrite === "function") {
@@ -1246,6 +1271,101 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
1246
1271
  };
1247
1272
  }
1248
1273
 
1274
+ // lifecycle-refactor W3-A2 (§7 graph generation signal): after a successful
1275
+ // archive the Stop hook REQUESTS edge extraction by emitting one
1276
+ // graph_edge_candidate_requested{stable_id, store?}. The hook never PRODUCES
1277
+ // edges (that is the archive/import skill's or doctor co-occurrence's job,
1278
+ // KT-DEC-0007) — it only flags "this entry just landed; someone should extract
1279
+ // its `related` edges". FROZEN-safe: O(1) tail scan, best-effort silent, single
1280
+ // advisory-locked appendLockedLine (same primitive the rest of this hook uses).
1281
+ //
1282
+ // HONEST stable_id sourcing — the deliberate limitation: pending entries (the
1283
+ // fabric-archive → extractKnowledge path) carry NO canonical stable_id (id is
1284
+ // late-bound at fab_review approve), so their knowledge_proposed event omits
1285
+ // stable_id (or sets the `pending:<key>` sentinel). A graph edge between
1286
+ // id-less pending drafts is meaningless, so we DO NOT fabricate one. We emit
1287
+ // ONLY when the most-recent knowledge_proposed event carries a real
1288
+ // K[TP]-XXX-NNNN stable_id (the approve/promote path) — i.e. an entry that
1289
+ // actually has a canonical node to attach edges to. When the latest proposed
1290
+ // is id-less we honestly skip; the request will fire on the approve event that
1291
+ // allocates the id. A session-scoped sidecar de-dupes so repeated Stop fires in
1292
+ // one session don't re-request the same id.
1293
+ const STABLE_ID_RE = /^K[TP]-[A-Z]{3}-\d{4}$/;
1294
+ const GRAPH_EDGE_REQUESTED_SIDECAR = ".fabric/.cache/graph-edge-requested";
1295
+
1296
+ function emitGraphEdgeCandidateBestEffort(cwd, events, sessionId) {
1297
+ try {
1298
+ if (!Array.isArray(events) || events.length === 0) return;
1299
+ const fabricDir = join(cwd, FABRIC_DIR);
1300
+ if (!existsSync(fabricDir)) return;
1301
+
1302
+ // O(1)-amortized tail scan for the newest knowledge_proposed carrying a
1303
+ // real (non-sentinel) stable_id. Stop at the first knowledge_proposed we
1304
+ // see — if the latest archive is id-less, we honestly skip rather than
1305
+ // reaching back to an older approved entry (that older entry's edges were
1306
+ // already requested when IT landed).
1307
+ let stableId = null;
1308
+ let store;
1309
+ for (let i = events.length - 1; i >= 0; i -= 1) {
1310
+ const ev = events[i];
1311
+ if (!ev || ev.event_type !== EVENT_TYPE_PROPOSED) continue;
1312
+ const candidate = typeof ev.stable_id === "string" ? ev.stable_id : null;
1313
+ if (candidate && STABLE_ID_RE.test(candidate)) {
1314
+ stableId = candidate;
1315
+ if (typeof ev.store === "string" && ev.store.length > 0) store = ev.store;
1316
+ }
1317
+ // First knowledge_proposed encountered (newest) decides; do not walk past
1318
+ // it to an older one.
1319
+ break;
1320
+ }
1321
+ if (stableId === null) return;
1322
+
1323
+ // Session-scoped de-dup: skip if we already requested edges for this exact
1324
+ // stable_id this session. Sidecar is a single line holding the last id.
1325
+ const sidecarPath = join(cwd, sessionScopedCacheFile(GRAPH_EDGE_REQUESTED_SIDECAR, sessionId));
1326
+ try {
1327
+ if (existsSync(sidecarPath)) {
1328
+ const prev = readFileSync(sidecarPath, "utf8").trim();
1329
+ if (prev === stableId) return;
1330
+ }
1331
+ } catch {
1332
+ // unreadable sidecar → fall through and (re)emit; de-dup is best-effort.
1333
+ }
1334
+
1335
+ let idSuffix;
1336
+ try {
1337
+ idSuffix = require("node:crypto").randomUUID();
1338
+ } catch {
1339
+ idSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1340
+ }
1341
+ const event = {
1342
+ kind: "fabric-event",
1343
+ id: `event:${idSuffix}`,
1344
+ ts: Date.now(),
1345
+ schema_version: 1,
1346
+ event_type: "graph_edge_candidate_requested",
1347
+ stable_id: stableId,
1348
+ };
1349
+ if (store !== undefined) event.store = store;
1350
+ if (typeof sessionId === "string" && sessionId.length > 0) event.session_id = sessionId;
1351
+ appendLockedLine(join(fabricDir, EVENT_LEDGER_FILE), JSON.stringify(event) + "\n");
1352
+
1353
+ // Record the de-dup marker (best-effort; atomic when state-store lib loaded).
1354
+ try {
1355
+ if (stateStore && typeof stateStore.atomicWrite === "function") {
1356
+ stateStore.atomicWrite(sidecarPath, stableId);
1357
+ } else {
1358
+ mkdirSync(dirname(sidecarPath), { recursive: true });
1359
+ writeFileSync(sidecarPath, stableId);
1360
+ }
1361
+ } catch {
1362
+ // de-dup marker write failed — at worst we re-request next Stop; harmless.
1363
+ }
1364
+ } catch {
1365
+ // best-effort §7 signal — never block the Stop hook (KT-DEC-0007).
1366
+ }
1367
+ }
1368
+
1249
1369
  // v2.1 NEW-N-3 (ADJ-NEWN-3): hook_signal_emitted instrumentation. Writes ONE
1250
1370
  // best-effort ledger row at the point a nudge is actually delivered (post-
1251
1371
  // cooldown), so the join key measures nudge-trigger logic (which signal fired,
@@ -1852,6 +1972,17 @@ function main(env, stdio) {
1852
1972
  );
1853
1973
 
1854
1974
  const events = readLedger(cwd);
1975
+
1976
+ // lifecycle-refactor W3-A2 (§7): request graph-edge extraction for a freshly
1977
+ // archived canonical entry. Runs UNCONDITIONALLY here (before the nudge
1978
+ // cooldown/dismiss early-returns) so the §7 signal is independent of whether
1979
+ // a reminder banner is shown this Stop. Best-effort, never throws.
1980
+ try {
1981
+ emitGraphEdgeCandidateBestEffort(cwd, events, resolveHookSessionId(stdinPayload));
1982
+ } catch {
1983
+ // never block the Stop hook
1984
+ }
1985
+
1855
1986
  let pendingStats;
1856
1987
  try {
1857
1988
  pendingStats = readPendingStats(cwd, now);
@@ -1984,7 +2115,7 @@ function main(env, stdio) {
1984
2115
  // for the prompt to be actionable.
1985
2116
  if (result === null) {
1986
2117
  try {
1987
- const lastEmit = readMaintenanceLastEmit(cwd);
2118
+ const lastEmit = readMaintenanceLastEmit(cwd, resolveHookSessionId(stdinPayload));
1988
2119
  result = evaluateMaintenanceSignal(
1989
2120
  events,
1990
2121
  now,
@@ -2041,7 +2172,7 @@ function main(env, stdio) {
2041
2172
  delete result.threshold;
2042
2173
  delete result.actual_value;
2043
2174
  out.write(JSON.stringify(result));
2044
- writeMaintenanceLastEmit(cwd, nowMs);
2175
+ writeMaintenanceLastEmit(cwd, nowMs, resolveHookSessionId(stdinPayload));
2045
2176
  return;
2046
2177
  }
2047
2178
 
@@ -2049,7 +2180,7 @@ function main(env, stdio) {
2049
2180
  // archive_hint_cooldown_hours (default 12h) regardless of state drift.
2050
2181
  // Pure reminder-noise reduction; the underlying trigger logic is unchanged.
2051
2182
  const cooldownMs = readCooldownHours(cwd) * MS_PER_HOUR;
2052
- const cache = readShownCache(cwd);
2183
+ const cache = readShownCache(cwd, resolveHookSessionId(stdinPayload));
2053
2184
  const lastShown = cache[result.signal];
2054
2185
  // rc.34 TASK-01 + review-fix (Gemini P1): future-stamped lastShown
2055
2186
  // (backward clock skew) bypasses cooldown — sidecar treated as expired.
@@ -2066,7 +2197,7 @@ function main(env, stdio) {
2066
2197
  delete result.actual_value;
2067
2198
  out.write(JSON.stringify(result));
2068
2199
  cache[result.signal] = nowMs;
2069
- writeShownCache(cwd, cache);
2200
+ writeShownCache(cwd, cache, resolveHookSessionId(stdinPayload));
2070
2201
  } catch {
2071
2202
  // Silent — never block on hook failure.
2072
2203
  }
@@ -2121,6 +2252,9 @@ module.exports = {
2121
2252
  // of the contract-missing emission contract). The lib module itself is
2122
2253
  // also exported indirectly via the reminder helper.
2123
2254
  emitCiteContractRemindersBestEffort,
2255
+ // lifecycle-refactor W3-A2 (§7): graph-edge-candidate request emitter
2256
+ // (exported for unit testing of the honest stable_id-gating + de-dup).
2257
+ emitGraphEdgeCandidateBestEffort,
2124
2258
  CONSTANTS: {
2125
2259
  FABRIC_DIR,
2126
2260
  EVENT_LEDGER_FILE,
@@ -2158,6 +2292,8 @@ module.exports = {
2158
2292
  // v2.0.0-rc.8 (TASK-002): in-flight import gate for Signal B.
2159
2293
  IMPORT_STATE_FILE_REL,
2160
2294
  IMPORT_IN_FLIGHT_MAX_AGE_HOURS,
2295
+ // lifecycle-refactor W3-A2 (§7): graph-edge-request de-dup sidecar.
2296
+ GRAPH_EDGE_REQUESTED_SIDECAR,
2161
2297
  },
2162
2298
  };
2163
2299
 
@@ -574,7 +574,18 @@ function truncateSummary(raw, maxLen) {
574
574
  function formatEntryLine(entry, maxLen) {
575
575
  const id = entry.id || "(no-id)";
576
576
  const summary = truncateSummary(entry.summary, maxLen);
577
- return summary.length > 0 ? ` - ${id} · ${summary}` : ` - ${id}`;
577
+ // lifecycle-refactor W3-T2 (§7 图谱消费 / §5 hook 沿 related 二阶召回): when this
578
+ // entry was pulled in by following a surfaced entry's `related` graph edge,
579
+ // tag the line with its provenance so the agent knows it arrived via the graph,
580
+ // not its own ranking. Omitted entirely for ordinarily-ranked entries — no fake
581
+ // "related" annotation is ever synthesized (graph-empty honesty).
582
+ const provenance =
583
+ typeof entry.related_to === "string" && entry.related_to.length > 0
584
+ ? ` (related-to-${entry.related_to})`
585
+ : "";
586
+ return summary.length > 0
587
+ ? ` - ${id} · ${summary}${provenance}`
588
+ : ` - ${id}${provenance}`;
578
589
  }
579
590
 
580
591
  /**
@@ -91,6 +91,9 @@ const { readJsonState, writeJsonState } = require("./lib/state-store.cjs");
91
91
  // and corrupt a line. Route every shared-file append through the advisory-lock
92
92
  // primitive (drop-on-contention, best-effort — matches injection-log).
93
93
  const { appendLockedLine } = require("./lib/injection-log.cjs");
94
+ // lifecycle-refactor W1-T2: client discriminator for the hook_surface_emitted
95
+ // event (schema requires the `client` enum). Mirrors the broad hook's import.
96
+ const { detectClient } = require("./lib/client-adapter.cjs");
94
97
  // v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
95
98
  // resolved-bindings snapshot. Store-aware hint surfaces the write-target store
96
99
  // for the edited file WITHOUT re-resolving or walking store trees. Best-effort.
@@ -1169,7 +1172,15 @@ function formatEntryLine(entry, maxLen) {
1169
1172
  const maturity = entry.maturity || "unknown";
1170
1173
  const summary = truncateSummary(entry.summary, maxLen);
1171
1174
  const tail = summary.length > 0 ? ` ${summary}` : "";
1172
- return ` [${id}] (${type}/${maturity})${tail}`;
1175
+ // lifecycle-refactor W3-T2 (§7 图谱消费 / §5 hook 沿 related 二阶召回): mark entries
1176
+ // pulled in by a surfaced entry's one-hop `related` graph edge with their source
1177
+ // provenance. Omitted for ordinarily-ranked entries — no fake graph annotation
1178
+ // is ever synthesized (graph-empty honesty).
1179
+ const provenance =
1180
+ typeof entry.related_to === "string" && entry.related_to.length > 0
1181
+ ? ` (related-to-${entry.related_to})`
1182
+ : "";
1183
+ return ` [${id}] (${type}/${maturity})${tail}${provenance}`;
1173
1184
  }
1174
1185
 
1175
1186
  function readSummaryMaxLen(projectRoot) {
@@ -1516,6 +1527,48 @@ function main(env, stdio) {
1516
1527
  err.write(`${line}\n`);
1517
1528
  }
1518
1529
 
1530
+ // lifecycle-refactor W1-T2: hook_surface_emitted — record WHICH narrow-scoped
1531
+ // stable_ids this PreToolUse fire surfaced into the edit, so doctor can join
1532
+ // surfaced→edited (this is the join's LEFT half; the edit_intent_checked event
1533
+ // appended above supplies the edited path / RIGHT half, keyed on the same
1534
+ // real payload session_id). Fires only after all gates passed and lines were
1535
+ // rendered (so it tracks genuinely-surfaced hints, never bloat). Best-effort,
1536
+ // never blocks the edit (KT-DEC-0007); the schema's `client` is a required
1537
+ // enum, so skip when the client is undetectable rather than emit an invalid
1538
+ // row. Mirrors the broad SessionStart emit (knowledge-hint-broad.cjs).
1539
+ try {
1540
+ const surfaceClient = detectClient();
1541
+ const fabricDir = join(cwd, FABRIC_DIR_REL);
1542
+ if (surfaceClient !== undefined && existsSync(fabricDir)) {
1543
+ const renderedIds = resolvedEntries
1544
+ .map((e) => (e && typeof e.id === "string" ? e.id : null))
1545
+ .filter((x) => x !== null);
1546
+ const realSessionId =
1547
+ payload &&
1548
+ typeof payload === "object" &&
1549
+ typeof payload.session_id === "string" &&
1550
+ payload.session_id.length > 0
1551
+ ? payload.session_id
1552
+ : null;
1553
+ const surfaceEvent = {
1554
+ kind: "fabric-event",
1555
+ id: `event:${randomUUID()}`,
1556
+ ts: nowMs,
1557
+ schema_version: 1,
1558
+ ...(realSessionId ? { session_id: realSessionId } : {}),
1559
+ event_type: "hook_surface_emitted",
1560
+ hook_name: "knowledge-hint-narrow",
1561
+ client: surfaceClient,
1562
+ target_channel: "stderr",
1563
+ rendered_ids: renderedIds,
1564
+ delivery_status: "delivered",
1565
+ };
1566
+ appendLockedLine(join(fabricDir, EVENTS_LEDGER_FILE), JSON.stringify(surfaceEvent) + "\n");
1567
+ }
1568
+ } catch {
1569
+ // best-effort telemetry — never block the edit
1570
+ }
1571
+
1519
1572
  // v2.0.0-rc.33 W2-6 (P0-7): stdout JSON envelope. When
1520
1573
  // hint_reminder_to_context is true (default), serialize the same banner
1521
1574
  // body as Claude Code's PreToolUse hookSpecificOutput shape so the model