@fenglimg/fabric-cli 2.2.0-rc.10 → 2.2.0-rc.11
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/index.js
CHANGED
|
@@ -23,7 +23,7 @@ import { defineCommand, renderUsage, runCommand, runMain } from "citty";
|
|
|
23
23
|
// src/commands/index.ts
|
|
24
24
|
var allCommands = {
|
|
25
25
|
// v2.2.0-rc.5: pipeline-based install with TUI renderer (EPIC-005/006/007/008)
|
|
26
|
-
install: () => import("./install-v2-
|
|
26
|
+
install: () => import("./install-v2-WLEJ5XHT.js").then((module) => module.installCommand),
|
|
27
27
|
// v2.1.0-rc.1 P3: multi-store lifecycle command group (list/add/remove/explain).
|
|
28
28
|
store: () => import("./store-HOCORVL3.js").then((module) => module.default),
|
|
29
29
|
// v2.1.0-rc.1 P3 (S9/S17/S37): multi-store pull --rebase + push, conflict resume.
|
|
@@ -153,7 +153,7 @@ async function customShowUsageGrouped(cmd, parent, version) {
|
|
|
153
153
|
var main = defineCommand({
|
|
154
154
|
meta: {
|
|
155
155
|
name: "fabric",
|
|
156
|
-
version: "2.2.0-rc.
|
|
156
|
+
version: "2.2.0-rc.11",
|
|
157
157
|
description: t("cli.main.description")
|
|
158
158
|
},
|
|
159
159
|
subCommands: allCommands
|
|
@@ -165,7 +165,7 @@ async function customShowUsage(cmd, parent) {
|
|
|
165
165
|
return;
|
|
166
166
|
}
|
|
167
167
|
if (cmdMeta?.name === "fabric" && parent === void 0) {
|
|
168
|
-
await customShowUsageGrouped(cmd, parent, "2.2.0-rc.
|
|
168
|
+
await customShowUsageGrouped(cmd, parent, "2.2.0-rc.11");
|
|
169
169
|
return;
|
|
170
170
|
}
|
|
171
171
|
console.log(await renderUsage(cmd, parent) + "\n");
|
|
@@ -1738,7 +1738,7 @@ function readProjectName(target) {
|
|
|
1738
1738
|
return basename(target);
|
|
1739
1739
|
}
|
|
1740
1740
|
function getCliVersion() {
|
|
1741
|
-
return true ? "2.2.0-rc.
|
|
1741
|
+
return true ? "2.2.0-rc.11" : "unknown";
|
|
1742
1742
|
}
|
|
1743
1743
|
function sortRecord(record) {
|
|
1744
1744
|
return Object.fromEntries(Object.entries(record).sort(([left], [right]) => left.localeCompare(right)));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fenglimg/fabric-cli",
|
|
3
|
-
"version": "2.2.0-rc.
|
|
3
|
+
"version": "2.2.0-rc.11",
|
|
4
4
|
"description": "Fabric CLI — installs the MCP server + skills + hooks for Claude Code and Codex CLI; runs doctor / knowledge maintenance.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "wangzhichao <fenglimg90@gmail.com>",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"tree-sitter-javascript": "^0.25.0",
|
|
47
47
|
"tree-sitter-typescript": "^0.23.2",
|
|
48
48
|
"web-tree-sitter": "^0.26.8",
|
|
49
|
-
"@fenglimg/fabric-server": "2.2.0-rc.
|
|
50
|
-
"@fenglimg/fabric-shared": "2.2.0-rc.
|
|
49
|
+
"@fenglimg/fabric-server": "2.2.0-rc.11",
|
|
50
|
+
"@fenglimg/fabric-shared": "2.2.0-rc.11"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/node": "^22.15.0",
|
|
@@ -248,12 +248,18 @@ const ARCHIVE_NORMATIVE_KEYWORDS = [
|
|
|
248
248
|
// (D6): a workspace that crossed the edit threshold but produced no high-value
|
|
249
249
|
// signal stays quiet. watermarkTs null (never archived) → treat all events as
|
|
250
250
|
// past-watermark (a never-archived repo with any edit signal is worth nudging).
|
|
251
|
-
|
|
251
|
+
// crack 1: optional `sessionId` scopes the probe to ONE session's events so a
|
|
252
|
+
// neighbour window's high-value work (past the same global watermark) cannot
|
|
253
|
+
// keep THIS session's archive nudge alive (or, in the backlog scan, attribute a
|
|
254
|
+
// neighbour's signal to a dead session). Omitted → workspace-wide (legacy).
|
|
255
|
+
function hasHighValueArchiveSignal(events, watermarkTs, sessionId) {
|
|
252
256
|
if (!Array.isArray(events)) return false;
|
|
253
257
|
const wm = typeof watermarkTs === "number" ? watermarkTs : 0;
|
|
258
|
+
const scoped = typeof sessionId === "string" && sessionId.length > 0;
|
|
254
259
|
let latestTurn = null;
|
|
255
260
|
for (const e of events) {
|
|
256
261
|
if (!e || typeof e.ts !== "number" || e.ts <= wm) continue;
|
|
262
|
+
if (scoped && e.session_id !== sessionId) continue;
|
|
257
263
|
if (typeof e.event_type === "string" && ARCHIVE_HIGH_VALUE_EVENT_TYPES.has(e.event_type)) {
|
|
258
264
|
return true;
|
|
259
265
|
}
|
|
@@ -505,6 +511,138 @@ function countEditsSince(projectRoot, anchorTs) {
|
|
|
505
511
|
return count;
|
|
506
512
|
}
|
|
507
513
|
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
// Two-lane archive strategy (crack 1 + 2).
|
|
516
|
+
//
|
|
517
|
+
// In-session lane (crack 1): the archive nudge's edit trigger counts ONLY the
|
|
518
|
+
// current session's `file_mutated` events since the current session's OWN
|
|
519
|
+
// archive watermark — a neighbour window archiving (which moves the GLOBAL
|
|
520
|
+
// `knowledge_proposed` anchor) must never zero THIS window's unarchived work.
|
|
521
|
+
// We read the event ledger (file_mutated carries session_id, written by
|
|
522
|
+
// post-tooluse-mutation.cjs; session_archive_attempted carries
|
|
523
|
+
// covered_through_ts), NOT the session-blind `.fabric/.cache/edit-counter`
|
|
524
|
+
// sidecar — that stays for the activity-overview DISPLAY line only.
|
|
525
|
+
//
|
|
526
|
+
// Cross-session lane (crack 2): `countBacklogSessions` is the safety net that
|
|
527
|
+
// replaces the old global-24h timer (which any neighbour's archive reset, so a
|
|
528
|
+
// low-signal "dead" session was orphaned forever). It reads events.jsonl
|
|
529
|
+
// directly — never the resolved-bindings snapshot (KT-PIT-0017/0019 stale
|
|
530
|
+
// projection class).
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
// rc cross-session backlog constants. ANTI_LOOP mirrors archive-scan.ts.
|
|
534
|
+
const ARCHIVE_BACKLOG_ANTI_LOOP_HOURS = 12;
|
|
535
|
+
const DEFAULT_ARCHIVE_BACKLOG_SESSION_COUNT = 2;
|
|
536
|
+
const DEFAULT_ARCHIVE_BACKLOG_IDLE_HOURS = 24;
|
|
537
|
+
|
|
538
|
+
// Latest session_archive_attempted.covered_through_ts for this session, else null.
|
|
539
|
+
function sessionArchiveWatermark(events, sessionId) {
|
|
540
|
+
if (!Array.isArray(events) || typeof sessionId !== "string" || sessionId.length === 0) {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
let wm = null;
|
|
544
|
+
for (const ev of events) {
|
|
545
|
+
if (!ev || ev.session_id !== sessionId) continue;
|
|
546
|
+
if (ev.event_type !== "session_archive_attempted") continue;
|
|
547
|
+
if (typeof ev.covered_through_ts !== "number") continue;
|
|
548
|
+
if (wm === null || ev.covered_through_ts > wm) wm = ev.covered_through_ts;
|
|
549
|
+
}
|
|
550
|
+
return wm;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Earliest event ts carrying this session_id, else null.
|
|
554
|
+
function sessionFirstActivityTs(events, sessionId) {
|
|
555
|
+
if (!Array.isArray(events) || typeof sessionId !== "string" || sessionId.length === 0) {
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
let first = null;
|
|
559
|
+
for (const ev of events) {
|
|
560
|
+
if (!ev || ev.session_id !== sessionId || typeof ev.ts !== "number") continue;
|
|
561
|
+
if (first === null || ev.ts < first) first = ev.ts;
|
|
562
|
+
}
|
|
563
|
+
return first;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Per-session archive anchor: this session's own last archive watermark, else
|
|
567
|
+
// its first activity ts. null only when the session has zero ledger presence.
|
|
568
|
+
function sessionAnchorTs(events, sessionId) {
|
|
569
|
+
const wm = sessionArchiveWatermark(events, sessionId);
|
|
570
|
+
if (wm !== null) return wm;
|
|
571
|
+
return sessionFirstActivityTs(events, sessionId);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Count this session's `file_mutated` events strictly after the anchor (anchor
|
|
575
|
+
// null → count all of the session's mutations). Replaces the session-blind
|
|
576
|
+
// countEditsSince(edit-counter) for the archive TRIGGER (crack 1).
|
|
577
|
+
function countSessionMutationsSince(events, sessionId, anchorTs) {
|
|
578
|
+
if (!Array.isArray(events) || typeof sessionId !== "string" || sessionId.length === 0) {
|
|
579
|
+
return 0;
|
|
580
|
+
}
|
|
581
|
+
let count = 0;
|
|
582
|
+
for (const ev of events) {
|
|
583
|
+
if (!ev || ev.session_id !== sessionId) continue;
|
|
584
|
+
if (ev.event_type !== "file_mutated" || typeof ev.ts !== "number") continue;
|
|
585
|
+
if (anchorTs === null || ev.ts > anchorTs) count += 1;
|
|
586
|
+
}
|
|
587
|
+
return count;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Cross-session safety net (crack 2). Counts DEAD sessions (carry a
|
|
591
|
+
// `session_ended` marker OR have been idle beyond idleHours) — OTHER than the
|
|
592
|
+
// current one — that hold unarchived high-value work and are NOT
|
|
593
|
+
// `user_dismissed` / inside the 12h anti-loop cooldown. This is the per-session
|
|
594
|
+
// replacement for the global-24h archive timer: it is NOT reset by any
|
|
595
|
+
// neighbour's archive, so a low-signal session that simply ended is no longer
|
|
596
|
+
// orphaned. Mirrors archive-scan.ts's outcome-filter semantics.
|
|
597
|
+
function countBacklogSessions(events, nowMs, currentSessionId, idleHours) {
|
|
598
|
+
if (!Array.isArray(events)) return 0;
|
|
599
|
+
const idleMs =
|
|
600
|
+
(typeof idleHours === "number" && idleHours > 0 ? idleHours : DEFAULT_ARCHIVE_BACKLOG_IDLE_HOURS) *
|
|
601
|
+
MS_PER_HOUR;
|
|
602
|
+
const lastActivity = new Map(); // sid -> max ts
|
|
603
|
+
const ended = new Set(); // sid with a session_ended marker
|
|
604
|
+
const lastAttempt = new Map(); // sid -> latest session_archive_attempted event
|
|
605
|
+
const sessions = new Set();
|
|
606
|
+
for (const ev of events) {
|
|
607
|
+
if (!ev || typeof ev.session_id !== "string" || ev.session_id.length === 0) continue;
|
|
608
|
+
const sid = ev.session_id;
|
|
609
|
+
sessions.add(sid);
|
|
610
|
+
if (typeof ev.ts === "number") {
|
|
611
|
+
const prev = lastActivity.get(sid);
|
|
612
|
+
if (prev === undefined || ev.ts > prev) lastActivity.set(sid, ev.ts);
|
|
613
|
+
}
|
|
614
|
+
if (ev.event_type === "session_ended") ended.add(sid);
|
|
615
|
+
if (ev.event_type === "session_archive_attempted" && typeof ev.ts === "number") {
|
|
616
|
+
const prior = lastAttempt.get(sid);
|
|
617
|
+
if (!prior || (typeof prior.ts === "number" && ev.ts > prior.ts)) lastAttempt.set(sid, ev);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
let count = 0;
|
|
621
|
+
for (const sid of sessions) {
|
|
622
|
+
if (sid === currentSessionId) continue; // live lane handles the current session
|
|
623
|
+
const last = lastActivity.get(sid);
|
|
624
|
+
const isDead = ended.has(sid) || (typeof last === "number" && nowMs - last >= idleMs);
|
|
625
|
+
if (!isDead) continue;
|
|
626
|
+
const attempt = lastAttempt.get(sid);
|
|
627
|
+
if (attempt && attempt.outcome === "user_dismissed") continue; // respect dismissal
|
|
628
|
+
if (
|
|
629
|
+
attempt &&
|
|
630
|
+
typeof attempt.ts === "number" &&
|
|
631
|
+
nowMs - attempt.ts < ARCHIVE_BACKLOG_ANTI_LOOP_HOURS * MS_PER_HOUR
|
|
632
|
+
) {
|
|
633
|
+
continue; // inside anti-loop cooldown
|
|
634
|
+
}
|
|
635
|
+
// Probe high-value work since the session's OWN archive watermark — null
|
|
636
|
+
// (never archived) means probe the whole session (wm→0), so a high-value
|
|
637
|
+
// signal that was the session's first event still counts. Using the
|
|
638
|
+
// first-activity anchor here would wrongly exclude it (strict `> anchor`).
|
|
639
|
+
const wm = sessionArchiveWatermark(events, sid);
|
|
640
|
+
if (!hasHighValueArchiveSignal(events, wm, sid)) continue; // no unarchived high-value work
|
|
641
|
+
count += 1;
|
|
642
|
+
}
|
|
643
|
+
return count;
|
|
644
|
+
}
|
|
645
|
+
|
|
508
646
|
// ---------------------------------------------------------------------------
|
|
509
647
|
// Observability grill (a + Q4): session-activity status breadcrumb.
|
|
510
648
|
//
|
|
@@ -830,14 +968,17 @@ function readArchiveEditThreshold(projectRoot) {
|
|
|
830
968
|
* Review wins over import because pending overflow is a sharper backlog signal
|
|
831
969
|
* than a sparse corpus.
|
|
832
970
|
*
|
|
833
|
-
* The `editCounterStats` parameter is the
|
|
834
|
-
*
|
|
835
|
-
* {
|
|
836
|
-
*
|
|
837
|
-
*
|
|
971
|
+
* The `editCounterStats` parameter is the per-session edit view (crack 1)
|
|
972
|
+
* computed in main() from file_mutated events:
|
|
973
|
+
* { editsSinceArchive: number, threshold: number, anchorPresent: boolean }
|
|
974
|
+
* The `backlogStats` parameter (crack 2) is the cross-session view:
|
|
975
|
+
* { deadSessionCount: number, threshold: number }
|
|
976
|
+
* Both default to a no-trigger shape when omitted (back-compat for callers
|
|
977
|
+
* pre-dating the two-lane split).
|
|
838
978
|
*
|
|
839
979
|
* Returns one of:
|
|
840
980
|
* - { decision: 'block', reason, signal: 'archive', recommended_skill: 'fabric-archive' }
|
|
981
|
+
* - { decision: 'block', reason, signal: 'archive_backlog', recommended_skill: 'fabric-archive' }
|
|
841
982
|
* - { decision: 'block', reason, signal: 'review', recommended_skill: 'fabric-review' }
|
|
842
983
|
* - { decision: 'block', reason, signal: 'import', recommended_skill: 'fabric-import' }
|
|
843
984
|
* - null on no trigger
|
|
@@ -847,21 +988,31 @@ function readArchiveEditThreshold(projectRoot) {
|
|
|
847
988
|
// without touching the filesystem. Omitting the arg falls back to documented
|
|
848
989
|
// defaults so existing in-process callers (tests that pre-date T7) still
|
|
849
990
|
// pass without modification — they implicitly exercise the default path.
|
|
850
|
-
function decide(events, now, pendingStats, underseedStats, editCounterStats, thresholds, banner, importInFlight) {
|
|
991
|
+
function decide(events, now, pendingStats, underseedStats, editCounterStats, thresholds, banner, importInFlight, backlogStats) {
|
|
851
992
|
const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
|
|
852
993
|
const stats = pendingStats || { count: 0, oldestAgeMs: null };
|
|
853
994
|
const underseed =
|
|
854
995
|
underseedStats || { nodeCount: 0, threshold: DEFAULT_UNDERSEED_NODE_THRESHOLD };
|
|
996
|
+
// crack 1: per-session edit view. `editsSinceArchive` = current session's
|
|
997
|
+
// file_mutated count since its own archive anchor; `anchorPresent` = the
|
|
998
|
+
// session has any ledger activity (the trigger gate, replacing the old
|
|
999
|
+
// "global knowledge_proposed exists" gate).
|
|
855
1000
|
const editStats =
|
|
856
1001
|
editCounterStats || {
|
|
857
|
-
|
|
1002
|
+
editsSinceArchive: 0,
|
|
858
1003
|
threshold: DEFAULT_ARCHIVE_EDIT_THRESHOLD,
|
|
1004
|
+
anchorPresent: false,
|
|
1005
|
+
};
|
|
1006
|
+
// crack 2: cross-session backlog view (dead sessions with unarchived work).
|
|
1007
|
+
const backlog =
|
|
1008
|
+
backlogStats || {
|
|
1009
|
+
deadSessionCount: 0,
|
|
1010
|
+
threshold: DEFAULT_ARCHIVE_BACKLOG_SESSION_COUNT,
|
|
859
1011
|
};
|
|
860
1012
|
const cfg = thresholds || {};
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
: DEFAULT_ARCHIVE_HINT_HOURS;
|
|
1013
|
+
// crack 2: the global archive_hint_hours timer is retired (the cross-session
|
|
1014
|
+
// case is now the archive_backlog signal). cfg.archiveHintHours is still
|
|
1015
|
+
// accepted on the thresholds bag for back-compat but no longer drives Signal A.
|
|
865
1016
|
const reviewHintPendingCount =
|
|
866
1017
|
typeof cfg.reviewHintPendingCount === "number" && cfg.reviewHintPendingCount > 0
|
|
867
1018
|
? cfg.reviewHintPendingCount
|
|
@@ -875,10 +1026,18 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
875
1026
|
// byte-identical Chinese output. main() always supplies the resolved variant.
|
|
876
1027
|
const variant = typeof cfg.variant === "string" ? cfg.variant : "zh-CN";
|
|
877
1028
|
|
|
878
|
-
// ---- Archive signal (
|
|
879
|
-
//
|
|
880
|
-
//
|
|
881
|
-
//
|
|
1029
|
+
// ---- Archive signal (crack 1 — per-session edit count) -------------------
|
|
1030
|
+
// In-session lane: nudge when THIS session has accumulated >= threshold file
|
|
1031
|
+
// mutations since its OWN archive anchor (computed per-session in main() from
|
|
1032
|
+
// file_mutated events — `editStats.editsSinceArchive`). The old global
|
|
1033
|
+
// 24h-OR-N-edits trigger is retired: the hours branch became the
|
|
1034
|
+
// archive_backlog signal below (crack 2), and the edit count is now
|
|
1035
|
+
// session-scoped so a neighbour window's archive can't zero this window's
|
|
1036
|
+
// work. `anchorPresent` gates the trigger (a session with zero ledger
|
|
1037
|
+
// activity has nothing to count).
|
|
1038
|
+
//
|
|
1039
|
+
// `lastProposedTs` / `hoursElapsed` are still derived here for the IMPORT
|
|
1040
|
+
// signal's "no knowledge_proposed in last 24h" guard further down.
|
|
882
1041
|
let lastProposedTs = null;
|
|
883
1042
|
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
884
1043
|
const ev = events[i];
|
|
@@ -887,63 +1046,27 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
887
1046
|
break;
|
|
888
1047
|
}
|
|
889
1048
|
}
|
|
890
|
-
|
|
891
1049
|
const hoursElapsed =
|
|
892
1050
|
lastProposedTs === null ? null : (nowMs - lastProposedTs) / MS_PER_HOUR;
|
|
893
1051
|
|
|
894
|
-
const triggerByHours =
|
|
895
|
-
hoursElapsed !== null && hoursElapsed >= archiveHintHours;
|
|
896
1052
|
const triggerByEdits =
|
|
897
|
-
|
|
898
|
-
editStats.
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
if (
|
|
904
|
-
//
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
//
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
// tests. When omitted (legacy callers / tests pre-T4) the overview
|
|
915
|
-
// line is skipped — the banner remains valid 3-or-2 lines depending
|
|
916
|
-
// on data availability.
|
|
917
|
-
//
|
|
918
|
-
// Substring contract preserved for existing tests:
|
|
919
|
-
// - "<hoursElapsed.toFixed(1)>h" (e.g. "25.0h")
|
|
920
|
-
// - "<editCount> 次编辑"
|
|
921
|
-
// - "阈值 <N>"
|
|
922
|
-
// - "fabric-archive"
|
|
923
|
-
// v2.0.0-rc.27 TASK-005 (audit §2.17): parts now assembled per-variant
|
|
924
|
-
// via banner-i18n's archivePartsHours / archivePartsEdits so en mode
|
|
925
|
-
// gets fully-English fragments instead of mixed-language output. zh-CN
|
|
926
|
-
// / zh-CN-hybrid still render the original substring contract verbatim.
|
|
927
|
-
const parts = [];
|
|
928
|
-
if (triggerByHours) {
|
|
929
|
-
parts.push(
|
|
930
|
-
renderBanner("archivePartsHours", variant, {
|
|
931
|
-
hoursFixed: hoursElapsed.toFixed(1),
|
|
932
|
-
threshold: archiveHintHours,
|
|
933
|
-
}),
|
|
934
|
-
);
|
|
935
|
-
}
|
|
936
|
-
if (triggerByEdits) {
|
|
937
|
-
parts.push(
|
|
938
|
-
renderBanner("archivePartsEdits", variant, {
|
|
939
|
-
count: editStats.editsSinceLastProposed,
|
|
940
|
-
threshold: editStats.threshold,
|
|
941
|
-
}),
|
|
942
|
-
);
|
|
943
|
-
}
|
|
944
|
-
// rc.16 TASK-002: 5-banner i18n via lib/banner-i18n.cjs. Substring
|
|
945
|
-
// contracts ('25.0h', '阈值 N', 'fabric-archive') preserved by the lib's
|
|
946
|
-
// zh-CN templates — see lib header for the full contract.
|
|
1053
|
+
editStats.anchorPresent === true &&
|
|
1054
|
+
typeof editStats.editsSinceArchive === "number" &&
|
|
1055
|
+
editStats.editsSinceArchive >= editStats.threshold;
|
|
1056
|
+
|
|
1057
|
+
// PRECEDENCE: in-session archive wins over backlog/review/import — recent
|
|
1058
|
+
// local work is the most actionable reminder.
|
|
1059
|
+
if (triggerByEdits) {
|
|
1060
|
+
// 人-first banner: edit-count fragment only (the hours fragment retired with
|
|
1061
|
+
// the global timer). Substring contracts ('次编辑', '阈值 N', 'fabric-archive')
|
|
1062
|
+
// preserved by banner-i18n's zh-CN templates. The activity overview line is
|
|
1063
|
+
// injected by main() via `banner` so decide() stays pure / filesystem-free.
|
|
1064
|
+
const parts = [
|
|
1065
|
+
renderBanner("archivePartsEdits", variant, {
|
|
1066
|
+
count: editStats.editsSinceArchive,
|
|
1067
|
+
threshold: editStats.threshold,
|
|
1068
|
+
}),
|
|
1069
|
+
];
|
|
947
1070
|
const line1 = renderBanner("archiveLine1", variant, { parts: parts.join(" / ") });
|
|
948
1071
|
const activity = banner && typeof banner.activityOverview === "string"
|
|
949
1072
|
? banner.activityOverview
|
|
@@ -959,10 +1082,30 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
959
1082
|
signal: "archive",
|
|
960
1083
|
recommended_skill: "fabric-archive",
|
|
961
1084
|
// v2.1 NEW-N-3: surface the firing sub-signal's numbers for the
|
|
962
|
-
// hook_signal_emitted ledger row main() writes.
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1085
|
+
// hook_signal_emitted ledger row main() writes.
|
|
1086
|
+
threshold: editStats.threshold,
|
|
1087
|
+
actual_value: editStats.editsSinceArchive,
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// ---- Archive backlog signal (crack 2 — cross-session safety net) ---------
|
|
1092
|
+
// Fires when N+ DEAD sessions (session_ended / idle) carry unarchived
|
|
1093
|
+
// high-value work — the per-session replacement for the old global-24h timer
|
|
1094
|
+
// (which any neighbour's archive reset, orphaning low-signal ended sessions).
|
|
1095
|
+
// KT-DEC-0007: a soft reminder, never a gate. Ranked AFTER in-session archive
|
|
1096
|
+
// but BEFORE review/import: losing knowledge from an ended session is a
|
|
1097
|
+
// sharper signal than a review/import backlog.
|
|
1098
|
+
if (backlog.threshold > 0 && backlog.deadSessionCount >= backlog.threshold) {
|
|
1099
|
+
const line1 = renderBanner("backlogLine1", variant, { count: backlog.deadSessionCount });
|
|
1100
|
+
const line2 = renderBanner("backlogCta", variant, {});
|
|
1101
|
+
const reason = `${line1}\n${line2}`;
|
|
1102
|
+
return {
|
|
1103
|
+
decision: "block",
|
|
1104
|
+
reason,
|
|
1105
|
+
signal: "archive_backlog",
|
|
1106
|
+
recommended_skill: "fabric-archive",
|
|
1107
|
+
threshold: backlog.threshold,
|
|
1108
|
+
actual_value: backlog.deadSessionCount,
|
|
966
1109
|
};
|
|
967
1110
|
}
|
|
968
1111
|
|
|
@@ -1121,6 +1264,23 @@ function readMaintenanceHintCooldownDays(projectRoot) {
|
|
|
1121
1264
|
);
|
|
1122
1265
|
}
|
|
1123
1266
|
|
|
1267
|
+
// crack 2: cross-session backlog signal thresholds.
|
|
1268
|
+
function readArchiveBacklogSessionCount(projectRoot) {
|
|
1269
|
+
return _readConfigNumber(
|
|
1270
|
+
projectRoot,
|
|
1271
|
+
"archive_backlog_session_count",
|
|
1272
|
+
DEFAULT_ARCHIVE_BACKLOG_SESSION_COUNT,
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function readArchiveBacklogIdleHours(projectRoot) {
|
|
1277
|
+
return _readConfigNumber(
|
|
1278
|
+
projectRoot,
|
|
1279
|
+
"archive_backlog_idle_hours",
|
|
1280
|
+
DEFAULT_ARCHIVE_BACKLOG_IDLE_HOURS,
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1124
1284
|
/**
|
|
1125
1285
|
* Resolve the cooldown setting from .fabric/fabric-config.json
|
|
1126
1286
|
* (archive_hint_cooldown_hours), falling back to DEFAULT_COOLDOWN_HOURS.
|
|
@@ -1231,7 +1391,7 @@ function writeShownCache(projectRoot, cache, sessionId) {
|
|
|
1231
1391
|
// precedence model — KT-DEC-0007 anti-nag spirit).
|
|
1232
1392
|
// -----------------------------------------------------------------------------
|
|
1233
1393
|
|
|
1234
|
-
const DISMISSABLE_SIGNALS = ["archive", "review", "import", "maintenance"];
|
|
1394
|
+
const DISMISSABLE_SIGNALS = ["archive", "archive_backlog", "review", "import", "maintenance"];
|
|
1235
1395
|
|
|
1236
1396
|
function sessionDismissFileName(sessionId) {
|
|
1237
1397
|
const safe = String(sessionId || "anonymous").replace(/[^A-Za-z0-9_.-]/g, "-");
|
|
@@ -2123,24 +2283,41 @@ function main(env, stdio) {
|
|
|
2123
2283
|
// ts to anchor the count; rather than rescanning events here, we mirror
|
|
2124
2284
|
// decide()'s scan locally to keep the helper pure. The threshold comes
|
|
2125
2285
|
// from fabric-config.json (archive_edit_threshold, default 20).
|
|
2286
|
+
// crack 1: per-session edit view. anchor = THIS session's own last archive
|
|
2287
|
+
// watermark (session_archive_attempted.covered_through_ts) else its first
|
|
2288
|
+
// ledger activity; count = THIS session's file_mutated events since anchor.
|
|
2289
|
+
// Reads the event ledger, NOT the session-blind edit-counter sidecar.
|
|
2126
2290
|
let editCounterStats;
|
|
2127
2291
|
try {
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
const ev = events[i];
|
|
2131
|
-
if (ev && ev.event_type === EVENT_TYPE_PROPOSED && typeof ev.ts === "number") {
|
|
2132
|
-
anchorTs = ev.ts;
|
|
2133
|
-
break;
|
|
2134
|
-
}
|
|
2135
|
-
}
|
|
2292
|
+
const sid = resolveHookSessionId(stdinPayload);
|
|
2293
|
+
const anchorTs = sessionAnchorTs(events, sid);
|
|
2136
2294
|
editCounterStats = {
|
|
2137
|
-
|
|
2295
|
+
editsSinceArchive: countSessionMutationsSince(events, sid, anchorTs),
|
|
2138
2296
|
threshold: readArchiveEditThreshold(cwd),
|
|
2297
|
+
anchorPresent: anchorTs !== null,
|
|
2139
2298
|
};
|
|
2140
2299
|
} catch {
|
|
2141
2300
|
editCounterStats = {
|
|
2142
|
-
|
|
2301
|
+
editsSinceArchive: 0,
|
|
2143
2302
|
threshold: DEFAULT_ARCHIVE_EDIT_THRESHOLD,
|
|
2303
|
+
anchorPresent: false,
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
// crack 2: cross-session backlog view — count DEAD sessions (other than the
|
|
2308
|
+
// current one) carrying unarchived high-value work. Drives the
|
|
2309
|
+
// archive_backlog signal that replaces the retired global-24h timer.
|
|
2310
|
+
let backlogStats;
|
|
2311
|
+
try {
|
|
2312
|
+
const sid = resolveHookSessionId(stdinPayload);
|
|
2313
|
+
backlogStats = {
|
|
2314
|
+
deadSessionCount: countBacklogSessions(events, nowMs, sid, readArchiveBacklogIdleHours(cwd)),
|
|
2315
|
+
threshold: readArchiveBacklogSessionCount(cwd),
|
|
2316
|
+
};
|
|
2317
|
+
} catch {
|
|
2318
|
+
backlogStats = {
|
|
2319
|
+
deadSessionCount: 0,
|
|
2320
|
+
threshold: DEFAULT_ARCHIVE_BACKLOG_SESSION_COUNT,
|
|
2144
2321
|
};
|
|
2145
2322
|
}
|
|
2146
2323
|
|
|
@@ -2223,6 +2400,7 @@ function main(env, stdio) {
|
|
|
2223
2400
|
thresholds,
|
|
2224
2401
|
{ activityOverview },
|
|
2225
2402
|
importInFlight,
|
|
2403
|
+
backlogStats,
|
|
2226
2404
|
);
|
|
2227
2405
|
|
|
2228
2406
|
// v2.0.0-rc.7 T10: Signal D — maintenance hint. Evaluated AFTER A/B/C
|
|
@@ -2259,22 +2437,18 @@ function main(env, stdio) {
|
|
|
2259
2437
|
return;
|
|
2260
2438
|
}
|
|
2261
2439
|
|
|
2262
|
-
// v2.2 dual-sink (Goal A / D6): VALUE-GATE the archive nudge.
|
|
2263
|
-
// edit
|
|
2264
|
-
//
|
|
2265
|
-
//
|
|
2266
|
-
//
|
|
2267
|
-
//
|
|
2440
|
+
// v2.2 dual-sink (Goal A / D6): VALUE-GATE the in-session archive nudge. The
|
|
2441
|
+
// edit trigger is the CHECK cadence; the nudge only fires when a high-value
|
|
2442
|
+
// signal accrued since the last archive (decouples check from disturb).
|
|
2443
|
+
// crack 1: re-anchored PER SESSION (watermark = this session's own anchor,
|
|
2444
|
+
// probe scoped to this session) so a neighbour window's high-value work past
|
|
2445
|
+
// the same global watermark can't keep — or suppress — THIS window's nudge.
|
|
2446
|
+
// archive_backlog already incorporates high-value in its count, so it is not
|
|
2447
|
+
// re-gated here. Other signals (review/import/maintenance) are unaffected.
|
|
2268
2448
|
if (result.signal === "archive") {
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
if (ev && ev.event_type === EVENT_TYPE_PROPOSED && typeof ev.ts === "number") {
|
|
2273
|
-
watermarkTs = ev.ts;
|
|
2274
|
-
break;
|
|
2275
|
-
}
|
|
2276
|
-
}
|
|
2277
|
-
if (!hasHighValueArchiveSignal(events, watermarkTs)) {
|
|
2449
|
+
const sid = resolveHookSessionId(stdinPayload);
|
|
2450
|
+
const watermarkTs = sessionAnchorTs(events, sid);
|
|
2451
|
+
if (!hasHighValueArchiveSignal(events, watermarkTs, sid)) {
|
|
2278
2452
|
return; // no high-value candidate → stay quiet (D6 value-gate)
|
|
2279
2453
|
}
|
|
2280
2454
|
}
|
|
@@ -2385,6 +2559,14 @@ module.exports = {
|
|
|
2385
2559
|
// for unit testing of the truth table).
|
|
2386
2560
|
isImportInFlight,
|
|
2387
2561
|
decide,
|
|
2562
|
+
// crack 1 + 2: two-lane archive strategy helpers (exported for unit testing).
|
|
2563
|
+
sessionArchiveWatermark,
|
|
2564
|
+
sessionFirstActivityTs,
|
|
2565
|
+
sessionAnchorTs,
|
|
2566
|
+
countSessionMutationsSince,
|
|
2567
|
+
countBacklogSessions,
|
|
2568
|
+
readArchiveBacklogSessionCount,
|
|
2569
|
+
readArchiveBacklogIdleHours,
|
|
2388
2570
|
readCooldownHours,
|
|
2389
2571
|
readUnderseedThreshold,
|
|
2390
2572
|
readArchiveEditThreshold,
|
|
@@ -28,8 +28,9 @@
|
|
|
28
28
|
*
|
|
29
29
|
* - STRINGS — exported for test introspection only (read-only by convention).
|
|
30
30
|
*
|
|
31
|
-
* Banner keys
|
|
31
|
+
* Banner keys:
|
|
32
32
|
* Signal A (archive): archiveLine1, archiveActivity, archiveCta
|
|
33
|
+
* Archive backlog: backlogLine1, backlogCta
|
|
33
34
|
* Signal B (review): reviewLine1, reviewCta
|
|
34
35
|
* Signal C (import): importLine1, importCta
|
|
35
36
|
* Signal D (maintenance): maintenanceLine1Never, maintenanceLine1Aged, maintenanceLine2
|
|
@@ -165,6 +166,23 @@ const STRINGS = {
|
|
|
165
166
|
"zh-CN-hybrid": () => " 是否调 /fabric-archive 检查值得归档的决策/踩坑/复用?",
|
|
166
167
|
},
|
|
167
168
|
|
|
169
|
+
// ---- Archive backlog (cross-session safety net, crack 2) ------------------
|
|
170
|
+
// Replaces the old global-24h archive timer: counts DEAD sessions (session
|
|
171
|
+
// ended / idle) carrying unarchived high-value work. Substring "${count}" is
|
|
172
|
+
// addressable for tests. params: { count: number }
|
|
173
|
+
backlogLine1: {
|
|
174
|
+
"zh-CN": (p) => `📋 Fabric: ${p.count} 个已结束的会话有未归档的高价值改动。`,
|
|
175
|
+
en: (p) => `📋 Fabric: ${p.count} ended session(s) carry unarchived high-value work.`,
|
|
176
|
+
"zh-CN-hybrid": (p) => `📋 Fabric: ${p.count} 个已结束的会话有未归档的高价值改动。`,
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
// params: {} — protected token /fabric-archive verbatim across all variants.
|
|
180
|
+
backlogCta: {
|
|
181
|
+
"zh-CN": () => " 是否调 /fabric-archive 跨会话补归档这些遗漏?",
|
|
182
|
+
en: () => " Run /fabric-archive to sweep these missed sessions across the backlog?",
|
|
183
|
+
"zh-CN-hybrid": () => " 是否调 /fabric-archive 跨会话补归档这些遗漏?",
|
|
184
|
+
},
|
|
185
|
+
|
|
168
186
|
// ---- Signal B: review -----------------------------------------------------
|
|
169
187
|
// Source (zh-CN): fabric-hint.cjs:651 `📋 Fabric: 已积累 ${stats.count} 条待审核知识${ageSuffix}。`
|
|
170
188
|
// params: { count, ageSuffix } — ageSuffix is " / 最早一条 N.N 天前" or "" (zh-CN only)
|
|
@@ -65,6 +65,18 @@ The deterministic ledger scan now runs **server-side** — call `fab_archive_sca
|
|
|
65
65
|
|
|
66
66
|
Then (LLM side, Boundary B): for each returned `session_id`, load `.fabric/.cache/session-digests/<session_id>.md`, concatenate into a `### Cross-session digest` block, and populate `source_sessions[]` + `session_context` for Phase 4. Cap at `archive_digest_max_sessions`. Missing digest files degrade silently.
|
|
67
67
|
|
|
68
|
+
**Coverage transparency (crack 3 — cheap recall backstop).** BEFORE collecting candidates, surface the scan's watermark + drops to the user so a human can act as the recall detector and manually override (`--range <session_id>` to force a dropped session back in). This is the affordable substitute for the (deferred) periodic cold-eval miss-rate audit — show, don't hide, what the deterministic filter skipped:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
📋 归档覆盖到 <covered_through_ts 转人类可读时间>。
|
|
72
|
+
纳入会话: <session_ids.length> 个。
|
|
73
|
+
跳过 <dropped.length> 个: <每个 {session_id 短码} (reason)>
|
|
74
|
+
reason 含义: user_dismissed=用户曾拒绝 / cooldown=12h 防抖内 / no_new_signal=自上次归档无新高价值活
|
|
75
|
+
若某个被跳过的会话其实有该归档的内容,显式 `fabric-archive --range <session_id>` 强制纳入。
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Render `dropped` only when non-empty; render the watermark line always. en variant mirrors the same fields. Keep it ONE compact block — this is a backstop affordance, not a report.
|
|
79
|
+
|
|
68
80
|
`Read ref/phase-1-cross-session.md` for the filter state machine + digest-stitch + graceful-degradation notes. The hand-rolled `tail -n 200` scan is retired — `fab_archive_scan` is the source of truth.
|
|
69
81
|
|
|
70
82
|
Graceful degradation: missing digest cache → single-session fallback. Missing `session_archive_attempted` events (pre-rc.25) → legacy "scan everything since anchor" behaviour.
|