@fenglimg/fabric-cli 2.0.0-rc.28 → 2.0.0-rc.30

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.
@@ -145,6 +145,15 @@ var doctorCommand = defineCommand({
145
145
  const citeCoverage = args["cite-coverage"] === true;
146
146
  const enrichDesc = args["enrich-descriptions"] === true;
147
147
  const archiveHistory = args["archive-history"] === true;
148
+ if (args.since !== void 0) {
149
+ try {
150
+ parseSinceDuration(args.since);
151
+ } catch {
152
+ writeStderr(dt("cli.doctor.errors.invalid-since", { input: args.since }));
153
+ process.exitCode = 1;
154
+ return;
155
+ }
156
+ }
148
157
  if (archiveHistory) {
149
158
  if (fix || fixKnowledge || citeCoverage || enrichDesc) {
150
159
  writeStderr(dt("cli.doctor.errors.archive-history-mutex"));
@@ -300,6 +309,22 @@ function renderHumanReport(report, dt) {
300
309
  writeIssueSection(dt("doctor.section.fixable"), report.fixable_errors);
301
310
  writeIssueSection(dt("doctor.section.manual"), report.manual_errors);
302
311
  writeIssueSection(dt("doctor.section.warnings"), report.warnings);
312
+ renderPayloadLimits(report, dt);
313
+ }
314
+ function renderPayloadLimits(report, dt) {
315
+ const limits = report.summary.payload_limits;
316
+ if (limits === void 0) {
317
+ return;
318
+ }
319
+ writeStdout("");
320
+ writeStdout(dt("doctor.section.payload-limits"));
321
+ writeStdout(
322
+ `- ${dt("doctor.payload-limits.line", {
323
+ warnKb: String(Math.round(limits.warn_bytes / 1024)),
324
+ hardKb: String(Math.round(limits.hard_bytes / 1024)),
325
+ source: limits.source
326
+ })}`
327
+ );
303
328
  }
304
329
  function renderFixKnowledgeMutations(fixKnowledgeReport, dt) {
305
330
  if (fixKnowledgeReport.mutations.length === 0) {
package/dist/index.js CHANGED
@@ -11,22 +11,22 @@ import { defineCommand, runMain } from "citty";
11
11
 
12
12
  // src/commands/index.ts
13
13
  var allCommands = {
14
- install: () => import("./install-MPSTI654.js").then((module) => module.default),
15
- doctor: () => import("./doctor-ZIQXN2T2.js").then((module) => module.default),
16
- serve: () => import("./serve-U3TPWDOB.js").then((module) => module.default),
14
+ install: () => import("./install-OEBNSCS5.js").then((module) => module.default),
15
+ doctor: () => import("./doctor-TTDTKOFJ.js").then((module) => module.default),
16
+ serve: () => import("./serve-43JTEM3U.js").then((module) => module.default),
17
17
  uninstall: () => import("./uninstall-VLLJG7JT.js").then((module) => module.default),
18
18
  config: () => import("./config-5CH4EJQ2.js").then((module) => module.default),
19
19
  "plan-context-hint": () => import("./plan-context-hint-CXTLNVSV.js").then((module) => module.default),
20
20
  // v2.0.0-rc.23 TASK-014 (F8c): S5 onboard-slot coverage. Used by the
21
21
  // fabric-archive Skill's first-run phase to detect unclaimed slots.
22
- "onboard-coverage": () => import("./onboard-coverage-JJ5NGU7I.js").then((module) => module.default)
22
+ "onboard-coverage": () => import("./onboard-coverage-6MN3CYHT.js").then((module) => module.default)
23
23
  };
24
24
 
25
25
  // src/index.ts
26
26
  var main = defineCommand({
27
27
  meta: {
28
28
  name: "fabric",
29
- version: "2.0.0-rc.28",
29
+ version: "2.0.0-rc.30",
30
30
  description: t("cli.main.description")
31
31
  },
32
32
  subCommands: allCommands
@@ -1348,7 +1348,7 @@ function readProjectName(target) {
1348
1348
  return basename(target);
1349
1349
  }
1350
1350
  function getCliVersion() {
1351
- return true ? "2.0.0-rc.28" : "unknown";
1351
+ return true ? "2.0.0-rc.30" : "unknown";
1352
1352
  }
1353
1353
  function sortRecord(record) {
1354
1354
  return Object.fromEntries(Object.entries(record).sort(([left], [right]) => left.localeCompare(right)));
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ t
4
+ } from "./chunk-PWLW3B57.js";
2
5
 
3
6
  // src/commands/onboard-coverage.ts
4
7
  import { existsSync, readdirSync, readFileSync } from "fs";
@@ -169,7 +172,11 @@ function renderHumanReadable(report) {
169
172
  var onboardCoverageCommand = defineCommand({
170
173
  meta: {
171
174
  name: "onboard-coverage",
172
- description: "Report S5 onboard-slot coverage for the workspace. Used by the fabric-archive Skill's first-run phase to detect unclaimed project-tone slots.",
175
+ // v2.0.0-rc.29 TASK-008 (BUG-L2): route description strings through t()
176
+ // (mirrors serve.ts pattern). Previously this command was English-only
177
+ // even when the rest of `fab --help` rendered zh-CN, so Chinese-locale
178
+ // users saw an isolated English block under --help.
179
+ description: t("cli.onboard-coverage.description"),
173
180
  // Mirrors `plan-context-hint`: hidden from `fab --help` so the top-level
174
181
  // banner stays focused on install/doctor/serve/config. The command stays
175
182
  // callable directly from Skills via `fab onboard-coverage --json`.
@@ -178,12 +185,12 @@ var onboardCoverageCommand = defineCommand({
178
185
  args: {
179
186
  json: {
180
187
  type: "boolean",
181
- description: "Emit machine-readable JSON to stdout instead of the human table.",
188
+ description: t("cli.onboard-coverage.args.json.description"),
182
189
  default: false
183
190
  },
184
191
  target: {
185
192
  type: "string",
186
- description: "Override the project root (defaults to cwd)."
193
+ description: t("cli.onboard-coverage.args.target.description")
187
194
  }
188
195
  },
189
196
  async run({ args }) {
@@ -41,6 +41,12 @@ var serveCommand = defineCommand({
41
41
  type: "boolean",
42
42
  description: t("cli.serve.args.debug.description"),
43
43
  default: false
44
+ },
45
+ // v2.0.0-rc.29 TASK-002 (BUG-K1): default-deny strict-auth.
46
+ "allow-loopback-no-auth": {
47
+ type: "boolean",
48
+ description: t("cli.serve.args.allow-loopback-no-auth.description"),
49
+ default: false
44
50
  }
45
51
  },
46
52
  async run({ args }) {
@@ -50,7 +56,8 @@ var serveCommand = defineCommand({
50
56
  const port = parsePort(args.port);
51
57
  const requestedHost = parseHost(args.host);
52
58
  const authToken = readAuthTokenFromEnv();
53
- const host = validateHost(requestedHost, authToken);
59
+ const allowLoopbackNoAuth = args["allow-loopback-no-auth"] === true;
60
+ const host = validateHost(requestedHost, authToken, allowLoopbackNoAuth);
54
61
  const projectRoot = resolution.target;
55
62
  try {
56
63
  acquireLock(projectRoot);
@@ -73,7 +80,8 @@ var serveCommand = defineCommand({
73
80
  port,
74
81
  projectRoot,
75
82
  host,
76
- authToken
83
+ authToken,
84
+ allowLoopbackNoAuth
77
85
  });
78
86
  } catch (error) {
79
87
  if (isNodeError(error) && error.code === "EADDRINUSE") {
@@ -105,7 +113,7 @@ function readAuthTokenFromEnv() {
105
113
  const token = process.env.FABRIC_AUTH_TOKEN;
106
114
  return token === void 0 || token.length === 0 ? void 0 : token;
107
115
  }
108
- function validateHost(host, authToken) {
116
+ function validateHost(host, authToken, allowLoopbackNoAuth) {
109
117
  if (authToken !== void 0) {
110
118
  return host;
111
119
  }
@@ -115,6 +123,11 @@ function validateHost(host, authToken) {
115
123
  );
116
124
  return "127.0.0.1";
117
125
  }
126
+ if (!allowLoopbackNoAuth) {
127
+ console.error(
128
+ `${symbol.warn} ${paint.warn(t("cli.serve.warning.loopback-deny-default"))}`
129
+ );
130
+ }
118
131
  return host;
119
132
  }
120
133
  function isLoopbackHost(host) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fenglimg/fabric-cli",
3
- "version": "2.0.0-rc.28",
3
+ "version": "2.0.0-rc.30",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "fab": "dist/index.js",
@@ -20,8 +20,8 @@
20
20
  "tree-sitter-javascript": "^0.25.0",
21
21
  "tree-sitter-typescript": "^0.23.2",
22
22
  "web-tree-sitter": "^0.26.8",
23
- "@fenglimg/fabric-server": "2.0.0-rc.28",
24
- "@fenglimg/fabric-shared": "2.0.0-rc.28"
23
+ "@fenglimg/fabric-server": "2.0.0-rc.30",
24
+ "@fenglimg/fabric-shared": "2.0.0-rc.30"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^22.15.0",
@@ -1079,7 +1079,20 @@ function tryReadStdinJson() {
1079
1079
  const parsed = JSON.parse(buf);
1080
1080
  if (parsed === null || typeof parsed !== "object") return null;
1081
1081
  return parsed;
1082
- } catch {
1082
+ } catch (e) {
1083
+ // v2.0.0-rc.29 TASK-008 (BUG-L1): hook used to silent-swallow JSON.parse
1084
+ // errors which masked real client-side payload bugs (e.g. CLI hosts that
1085
+ // stopped emitting Stop-hook JSON envelopes). Log a single best-effort
1086
+ // diagnostic line so operators see WHY the hook went quiet; keep returning
1087
+ // null so downstream behaviour (graceful exit 0, no rule render) is
1088
+ // unchanged.
1089
+ try {
1090
+ const message = (e && typeof e === "object" && "message" in e) ? String(e.message) : String(e);
1091
+ process.stderr.write(`[fabric-hint] malformed input: ${message}\n`);
1092
+ } catch {
1093
+ // stderr write failed (very unusual — sandbox / closed fd). The
1094
+ // hook contract still requires we never throw upward.
1095
+ }
1083
1096
  return null;
1084
1097
  }
1085
1098
  }
@@ -227,12 +227,16 @@ function shouldRecommendImport(projectRoot) {
227
227
  // CONSTANTS
228
228
  // -----------------------------------------------------------------------------
229
229
 
230
- // Per-type truncation triggers when total narrow entries > 30. The threshold
231
- // was originally aligned with the rc.5 plan-context degenerate-mode cutoff,
232
- // which is now retired (rc.7 T9 see docs/decisions/rc5-a3-superseded.md).
233
- // We keep 30 here as a stable rendering boundary independent of that protocol
234
- // change: it's a UI-density choice, not a wire-shape one.
235
- const TRUNCATION_THRESHOLD = 30;
230
+ // Per-type truncation triggers when total broad-scope entries > N.
231
+ // v2.0.0-rc.29 TASK-007 (BUG-F1): lowered from 30 → 12. SessionStart hint
232
+ // should bias toward "is there anything relevant?" rather than "exhaustive
233
+ // index" at 30, the banner consumed several terminal screens on
234
+ // well-seeded repos and operators reported scroll fatigue. 12 keeps a
235
+ // dense-enough scan (still fits "top hits per type" in 1-2 screenfuls)
236
+ // without prompting the user to mentally truncate themselves. The constant
237
+ // stays a stable rendering boundary; downstream consumers (banner-i18n.cjs,
238
+ // truncation summary lines) consume it as a single source of truth.
239
+ const TRUNCATION_THRESHOLD = 12;
236
240
 
237
241
  // `fabric plan-context-hint` is a thin wrapper over planContext(); on a
238
242
  // well-seeded repo it returns in ~100ms. Two-second cap is defensive — any
@@ -142,7 +142,19 @@ function readPayload(rawStdin) {
142
142
  return null;
143
143
  }
144
144
  return parsed;
145
- } catch {
145
+ } catch (e) {
146
+ // v2.0.0-rc.29 REVIEW (codex LOW-1): apply BUG-L1's malformed-input
147
+ // diagnostic uniformly across hook scripts. fabric-hint.cjs got the stderr
148
+ // trace in TASK-008; without this matching write here, a broken Codex /
149
+ // Cursor host payload silently kills the narrow hint with no operator
150
+ // signal at all. Best-effort: a failed stderr write must not throw upward
151
+ // (hook contract — never crash the host's edit pipeline).
152
+ try {
153
+ const message = (e && typeof e === "object" && "message" in e) ? String(e.message) : String(e);
154
+ process.stderr.write(`[fabric-knowledge-hint-narrow] malformed input: ${message}\n`);
155
+ } catch {
156
+ // stderr write itself failed (sandbox / closed fd) — accept silence.
157
+ }
146
158
  return null;
147
159
  }
148
160
  }
@@ -1,463 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * v2.0.0-rc.25 TASK-03: archive-hint hook — Signal A (archive reminder).
4
- *
5
- * Standalone Signal A archive hook re-established from the rc.2 design.
6
- * fabric-hint.cjs continues to ship the merged archive/review/import flow for
7
- * existing installs; archive-hint.cjs is the rc.25-redesigned variant whose
8
- * reason copy explicitly communicates that the plan_context backlog is
9
- * project-level cross-session debt rather than current-session activity.
10
- *
11
- * Behaviour (compared to the rc.2 baseline):
12
- * 1. Bilingual reason copy. zh-CN: "跨 N 个会话累计 M 次 plan_context · 距上次归档 …
13
- * — 这是项目级长期欠债, 不一定来自本会话。若本会话有产出, 可调用 fabric-archive;
14
- * 否则可忽略, 12h 后再提醒。" English mirror references "project-level long-term
15
- * debt" so callers can grep either side. Language driven by
16
- * `.fabric/fabric-config.json#fabric_language` via readFabricLanguage().
17
- * 2. Distinct-session count via `countDistinctSessions(events, lastProposedTs)`.
18
- * When ≥50% of plan_context events since the watermark carry a `session_id`
19
- * field, the wording reads "跨 N 个会话累计"; otherwise it degrades to
20
- * "跨多个会话累计" (transitional period before TASK-02 fully lands).
21
- * 3. Watermark fallback. When the workspace has never recorded a
22
- * knowledge_proposed event (or rotation cut off the historical watermark),
23
- * decide() uses events[0]?.ts as a virtual watermark and appends a
24
- * "(watermark 已被 rotation 清理)" suffix so operators understand why the
25
- * hours-elapsed display is approximate.
26
- *
27
- * Invariants preserved:
28
- * - stdout JSON shape: { decision: "block", reason, signal: "archive" }
29
- * - Cooldown throttle via `.fabric/.cache/archive-hint-shown.json`
30
- * - Fail-silent: any error → silent exit, NEVER blocks the Stop hook.
31
- */
32
- "use strict";
33
-
34
- const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs");
35
- const { dirname, join } = require("node:path");
36
-
37
- // CONSTANTS — duplicated from packages/server/src/services/_shared.ts.
38
- // DRY violation accepted: this hook script runs in user repos WITHOUT
39
- // node_modules access, so it cannot import from @fenglimg/fabric-server.
40
- const FABRIC_DIR = ".fabric";
41
- const EVENT_LEDGER_FILE = "events.jsonl";
42
- const EVENT_TYPE_PROPOSED = "knowledge_proposed";
43
- const EVENT_TYPE_PLAN_CONTEXT = "knowledge_context_planned";
44
- const EVENT_TYPE_ROTATED = "events_rotated";
45
- const THRESHOLD_PLAN_CONTEXTS = 5;
46
- const THRESHOLD_HOURS = 24;
47
- const MS_PER_HOUR = 60 * 60 * 1000;
48
-
49
- // rc.25 review remediation: differentiate "rotation cut watermark" from
50
- // "ledger truly fresh (never archived)" when `lastProposedTs === null`.
51
- // The previous wording appended `(watermark 已被 rotation 清理)` in both
52
- // cases, which is misleading for a brand-new project. Heuristic:
53
- // - events.length > ROTATION_HINT_EVENTS_THRESHOLD (>50 events
54
- // accumulated but no knowledge_proposed) → likely rotation cut the
55
- // watermark, OR
56
- // - an explicit `events_rotated` event appears in the ledger → definite
57
- // rotation evidence.
58
- // In either case, keep the legacy `(watermark 已被 rotation 清理)` suffix.
59
- // Otherwise the ledger is genuinely young (e.g. a brand-new project that
60
- // has accumulated a handful of plan_context events but never archived) —
61
- // emit no suffix, since claiming rotation cleared the watermark would
62
- // confuse the operator.
63
- const ROTATION_HINT_EVENTS_THRESHOLD = 50;
64
-
65
- // Cooldown throttle. After the hook surfaces a reminder, it stays silent for
66
- // this many hours — purely a reminder-noise throttle, not a state machine.
67
- // Override via .fabric/fabric-config.json#archive_hint_cooldown_hours.
68
- const CONFIG_FILE = "fabric-config.json";
69
- const DEFAULT_COOLDOWN_HOURS = 12;
70
- const SHOWN_CACHE_FILE = ".fabric/.cache/archive-hint-shown.json";
71
-
72
- // rc.25 TASK-03: session-id coverage threshold. When ≥50% of plan_context
73
- // events since the watermark carry a session_id, surface the distinct-session
74
- // count ("跨 N 个会话"); below that, degrade to "跨多个会话" to avoid lying
75
- // about a partial count during the transitional period before TASK-02 fully
76
- // lands AI session_id propagation.
77
- const SESSION_COVERAGE_THRESHOLD = 0.5;
78
- // rc.25 TASK-03: i18n field name + language enum. Mirrors banner-i18n.cjs's
79
- // readFabricLanguage contract; kept local so this hook stays self-contained.
80
- const FABRIC_LANGUAGE_FIELD = "fabric_language";
81
- const DEFAULT_LANGUAGE = "en";
82
- const VALID_LANGUAGES = ["zh-CN", "en", "zh-CN-hybrid", "match-existing"];
83
-
84
- /**
85
- * Read the events.jsonl ledger from <projectRoot>/.fabric/events.jsonl.
86
- * Mirrors the semantics of readEventLedger in packages/server/src/services/event-ledger.ts:
87
- * - ENOENT → return [] (fabric not initialized)
88
- * - split on /\r?\n/
89
- * - drop final fragment if file lacks trailing newline (partial-tail tolerance)
90
- * - JSON.parse per line, swallow per-line errors (corrupt-line tolerance)
91
- */
92
- function readLedger(projectRoot) {
93
- const eventPath = join(projectRoot, FABRIC_DIR, EVENT_LEDGER_FILE);
94
- if (!existsSync(eventPath)) {
95
- return [];
96
- }
97
-
98
- let raw;
99
- try {
100
- raw = readFileSync(eventPath, "utf8");
101
- } catch {
102
- return [];
103
- }
104
-
105
- const lines = raw.split(/\r?\n/);
106
- const hasTrailingNewline = raw.endsWith("\n");
107
- if (!hasTrailingNewline && lines.length > 0) {
108
- lines.pop();
109
- }
110
-
111
- const events = [];
112
- for (const line of lines) {
113
- const trimmed = line.trim();
114
- if (trimmed.length === 0) continue;
115
- try {
116
- const parsed = JSON.parse(trimmed);
117
- if (parsed && typeof parsed === "object") {
118
- events.push(parsed);
119
- }
120
- } catch {
121
- // corrupt JSON line — drop silently
122
- }
123
- }
124
- return events;
125
- }
126
-
127
- /**
128
- * Count distinct session_id values among knowledge_context_planned events that
129
- * happened AFTER the lastProposedTs watermark (or all such events when the
130
- * watermark is null).
131
- *
132
- * Returns { count, coverage_ratio, total } where:
133
- * - count = number of distinct non-empty session_id strings observed
134
- * - total = number of plan_context events considered
135
- * - coverage_ratio = (events with session_id field) / total, in [0, 1].
136
- * Used by decide() to choose between "跨 N 个会话" (high coverage) and
137
- * "跨多个会话" (degraded — most events lack session_id).
138
- *
139
- * When `total === 0` returns { count: 0, coverage_ratio: 0, total: 0 } — the
140
- * caller is responsible for not invoking the wording in that case.
141
- */
142
- function countDistinctSessions(events, lastProposedTs) {
143
- const sessions = new Set();
144
- let totalConsidered = 0;
145
- let withSessionId = 0;
146
- for (const ev of events) {
147
- if (!ev || ev.event_type !== EVENT_TYPE_PLAN_CONTEXT) continue;
148
- if (typeof ev.ts !== "number") continue;
149
- if (lastProposedTs !== null && ev.ts <= lastProposedTs) continue;
150
- totalConsidered += 1;
151
- if (typeof ev.session_id === "string" && ev.session_id.length > 0) {
152
- withSessionId += 1;
153
- sessions.add(ev.session_id);
154
- }
155
- }
156
- return {
157
- count: sessions.size,
158
- coverage_ratio: totalConsidered === 0 ? 0 : withSessionId / totalConsidered,
159
- total: totalConsidered,
160
- };
161
- }
162
-
163
- /**
164
- * Read `fabric_language` from <projectRoot>/.fabric/fabric-config.json.
165
- * Mirrors lib/banner-i18n.cjs#readFabricLanguage's never-throw contract.
166
- * Missing file / malformed JSON / missing field / unknown variant →
167
- * DEFAULT_LANGUAGE ('en' per rc.25 TASK-03 spec — en is the safe default for
168
- * non-Chinese users; explicit zh-CN config opts in to Chinese copy).
169
- */
170
- function readFabricLanguage(projectRoot) {
171
- if (typeof projectRoot !== "string" || projectRoot.length === 0) {
172
- return DEFAULT_LANGUAGE;
173
- }
174
- const configPath = join(projectRoot, FABRIC_DIR, CONFIG_FILE);
175
- if (!existsSync(configPath)) return DEFAULT_LANGUAGE;
176
- try {
177
- const parsed = JSON.parse(readFileSync(configPath, "utf8"));
178
- const v = parsed && parsed[FABRIC_LANGUAGE_FIELD];
179
- if (typeof v === "string" && VALID_LANGUAGES.indexOf(v) !== -1) {
180
- // Fold zh-CN-hybrid → zh-CN for this hook's two-variant copy (the rc.25
181
- // spec defines zh-CN and en; hybrid uses zh-CN narrative with protected
182
- // tokens, which matches our copy already). match-existing → en per
183
- // UX i18n Policy class 1.
184
- if (v === "zh-CN" || v === "zh-CN-hybrid") return "zh-CN";
185
- if (v === "en") return "en";
186
- return DEFAULT_LANGUAGE; // match-existing
187
- }
188
- } catch {
189
- // fall through to default
190
- }
191
- return DEFAULT_LANGUAGE;
192
- }
193
-
194
- /**
195
- * Render the bilingual two-line reason for an archive-signal trigger.
196
- *
197
- * Inputs:
198
- * - language: 'zh-CN' | 'en' (caller resolves via readFabricLanguage).
199
- * - sessionCount: integer ≥ 1 when distinct-session count is reliable; the
200
- * `useDistinctCount` flag controls whether to render the number or the
201
- * "跨多个会话累计" / "across multiple sessions" degraded phrase.
202
- * - planContextCount: total plan_context events since the watermark.
203
- * - hoursDisplay: pre-formatted hours-elapsed string (e.g. "24.2h" or
204
- * "尚未归档" — caller chooses).
205
- * - useDistinctCount: when true, embed `sessionCount`; when false, use the
206
- * degraded "多个" / "multiple" phrase.
207
- * - watermarkSuffix: optional suffix string ("(watermark 已被 rotation 清理)"
208
- * in zh-CN, "(watermark cleaned by rotation)" in en) appended when the
209
- * historical watermark was rotated away.
210
- */
211
- function buildReason({
212
- language,
213
- sessionCount,
214
- planContextCount,
215
- hoursDisplay,
216
- useDistinctCount,
217
- watermarkSuffix,
218
- }) {
219
- const suffix = watermarkSuffix ? ` ${watermarkSuffix}` : "";
220
- if (language === "zh-CN") {
221
- const sessionPhrase = useDistinctCount
222
- ? `跨 ${sessionCount} 个会话累计`
223
- : "跨多个会话累计";
224
- return (
225
- `${sessionPhrase} ${planContextCount} 次 plan_context · 距上次归档 ${hoursDisplay}${suffix} — 这是项目级长期欠债, 不一定来自本会话。\n` +
226
- `若本会话有产出, 可调用 fabric-archive; 否则可忽略, 12h 后再提醒。`
227
- );
228
- }
229
- // English variant. Preserves the "project-level long-term debt" substring
230
- // so convergence checks can grep either side of the bilingual split.
231
- const sessionPhrase = useDistinctCount
232
- ? `Across ${sessionCount} sessions`
233
- : "Across multiple sessions";
234
- return (
235
- `${sessionPhrase}, ${planContextCount} plan_context calls accumulated · ${hoursDisplay} since last archive${suffix} — this is project-level long-term debt, not necessarily from the current session.\n` +
236
- `If the current session produced something, run fabric-archive; otherwise feel free to ignore — next reminder in 12h.`
237
- );
238
- }
239
-
240
- /**
241
- * Decide whether to emit a hook reminder.
242
- *
243
- * Trigger logic (UNCHANGED from rc.2):
244
- * - Trigger when (plan_context count since last knowledge_proposed >= 5)
245
- * OR (hours since last knowledge_proposed >= 24).
246
- * - If no knowledge_proposed event has ever been recorded, count ALL
247
- * plan_context events and use events[0]?.ts as the virtual watermark
248
- * (rc.25 TASK-03 — fixes the Q3.8 gap where rotation-cut workspaces
249
- * reported `null` hours-elapsed forever).
250
- *
251
- * Returns one of:
252
- * - { decision: 'block', reason, signal: 'archive' } on archive trigger
253
- * - null on no trigger
254
- */
255
- function decide(events, now, language) {
256
- const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
257
- const lang = language === "zh-CN" || language === "en" ? language : DEFAULT_LANGUAGE;
258
-
259
- // Locate the most-recent knowledge_proposed watermark.
260
- let lastProposedTs = null;
261
- for (let i = events.length - 1; i >= 0; i -= 1) {
262
- const ev = events[i];
263
- if (ev && ev.event_type === EVENT_TYPE_PROPOSED && typeof ev.ts === "number") {
264
- lastProposedTs = ev.ts;
265
- break;
266
- }
267
- }
268
-
269
- // Count plan_context events since the watermark (or all when null).
270
- let planContextCount = 0;
271
- for (const ev of events) {
272
- if (!ev || ev.event_type !== EVENT_TYPE_PLAN_CONTEXT) continue;
273
- if (typeof ev.ts !== "number") continue;
274
- if (lastProposedTs === null || ev.ts > lastProposedTs) {
275
- planContextCount += 1;
276
- }
277
- }
278
-
279
- // rc.25 TASK-03: watermark fallback. When the workspace has never
280
- // recorded knowledge_proposed (or rotation cut it off), use events[0]?.ts
281
- // as the virtual watermark so hoursElapsed is meaningful instead of null.
282
- // We track whether the fallback fired so the reason copy can append a
283
- // breadcrumb explaining the approximation.
284
- //
285
- // rc.25 review remediation: only claim "rotation cut the watermark" when
286
- // there is evidence of rotation (events.length > 50 OR an `events_rotated`
287
- // event appears). For a truly fresh ledger (small, no rotation marker),
288
- // the fallback still fires (so hoursElapsed renders) but the suffix is
289
- // suppressed — claiming rotation in a brand-new project is misleading.
290
- let watermarkFallbackFired = false;
291
- let rotationLikely = false;
292
- let effectiveWatermarkTs = lastProposedTs;
293
- if (lastProposedTs === null) {
294
- const firstEventTs =
295
- events.length > 0 && typeof events[0]?.ts === "number" ? events[0].ts : null;
296
- if (firstEventTs !== null) {
297
- effectiveWatermarkTs = firstEventTs;
298
- watermarkFallbackFired = true;
299
- const hasRotatedEvent = events.some(
300
- (ev) => ev && ev.event_type === EVENT_TYPE_ROTATED,
301
- );
302
- rotationLikely =
303
- events.length > ROTATION_HINT_EVENTS_THRESHOLD || hasRotatedEvent;
304
- }
305
- }
306
-
307
- const hoursElapsed =
308
- effectiveWatermarkTs === null
309
- ? null
310
- : (nowMs - effectiveWatermarkTs) / MS_PER_HOUR;
311
-
312
- const triggerByCount = planContextCount >= THRESHOLD_PLAN_CONTEXTS;
313
- // Hours threshold only applies when a watermark exists AND at least one
314
- // plan_context has happened since (otherwise the user has been idle — no
315
- // knowledge to archive).
316
- const triggerByHours =
317
- hoursElapsed !== null && hoursElapsed >= THRESHOLD_HOURS && planContextCount > 0;
318
-
319
- if (!triggerByCount && !triggerByHours) return null;
320
-
321
- // rc.25 TASK-03: distinct-session count + coverage degrade.
322
- const sessionStats = countDistinctSessions(events, lastProposedTs);
323
- const useDistinctCount =
324
- sessionStats.total > 0 &&
325
- sessionStats.coverage_ratio >= SESSION_COVERAGE_THRESHOLD &&
326
- sessionStats.count > 0;
327
-
328
- const hoursDisplay =
329
- hoursElapsed === null
330
- ? lang === "zh-CN"
331
- ? "尚未归档"
332
- : "never archived"
333
- : `${hoursElapsed.toFixed(1)}h`;
334
-
335
- // Suffix decision (rc.25 review remediation):
336
- // - Fallback fired + rotation evidence → emit rotation-clarification suffix.
337
- // - Fallback fired + truly fresh ledger → no suffix (claiming rotation
338
- // would mislead first-time users).
339
- // - Fallback did not fire (proposed event exists) → no suffix.
340
- const watermarkSuffix =
341
- watermarkFallbackFired && rotationLikely
342
- ? lang === "zh-CN"
343
- ? "(watermark 已被 rotation 清理)"
344
- : "(watermark cleaned by rotation)"
345
- : "";
346
-
347
- const reason = buildReason({
348
- language: lang,
349
- sessionCount: sessionStats.count,
350
- planContextCount,
351
- hoursDisplay,
352
- useDistinctCount,
353
- watermarkSuffix,
354
- });
355
-
356
- return { decision: "block", reason, signal: "archive" };
357
- }
358
-
359
- /**
360
- * Resolve the cooldown setting from .fabric/fabric-config.json
361
- * (archive_hint_cooldown_hours), falling back to DEFAULT_COOLDOWN_HOURS.
362
- * Any read/parse failure → default (never block on config errors).
363
- */
364
- function readCooldownHours(projectRoot) {
365
- const configPath = join(projectRoot, FABRIC_DIR, CONFIG_FILE);
366
- if (!existsSync(configPath)) return DEFAULT_COOLDOWN_HOURS;
367
- try {
368
- const parsed = JSON.parse(readFileSync(configPath, "utf8"));
369
- const v = parsed && parsed.archive_hint_cooldown_hours;
370
- if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
371
- } catch {
372
- // fall through to default
373
- }
374
- return DEFAULT_COOLDOWN_HOURS;
375
- }
376
-
377
- function readShownCache(projectRoot) {
378
- const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
379
- if (!existsSync(cachePath)) return {};
380
- try {
381
- const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
382
- return parsed && typeof parsed === "object" ? parsed : {};
383
- } catch {
384
- return {};
385
- }
386
- }
387
-
388
- function writeShownCache(projectRoot, cache) {
389
- const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
390
- try {
391
- mkdirSync(dirname(cachePath), { recursive: true });
392
- writeFileSync(cachePath, JSON.stringify(cache));
393
- } catch {
394
- // Silent — cache failure must never block the hook.
395
- }
396
- }
397
-
398
- /**
399
- * Main entry — invoked both as a CLI (require.main === module) and in-process by tests.
400
- *
401
- * Wraps the entire flow in try/catch: ANY error → silent exit 0. The hook MUST NEVER
402
- * block tool execution on its own failure (per existing fabric-*-reminder.cjs precedent).
403
- */
404
- function main(env, stdio) {
405
- try {
406
- const cwd = (env && env.cwd) || process.cwd();
407
- const now = (env && env.now) || new Date();
408
- const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
409
- const out = (stdio && stdio.stdout) || process.stdout;
410
-
411
- const events = readLedger(cwd);
412
- const language = readFabricLanguage(cwd);
413
- const result = decide(events, now, language);
414
- if (result === null) return;
415
-
416
- // Cooldown throttle: once a signal fires, stay silent for
417
- // archive_hint_cooldown_hours (default 12h) regardless of state drift.
418
- const cooldownMs = readCooldownHours(cwd) * MS_PER_HOUR;
419
- const cache = readShownCache(cwd);
420
- const lastShown = cache[result.signal];
421
- if (typeof lastShown === "number" && nowMs - lastShown < cooldownMs) {
422
- return; // Still in cooldown — silent.
423
- }
424
-
425
- out.write(JSON.stringify(result));
426
- cache[result.signal] = nowMs;
427
- writeShownCache(cwd, cache);
428
- } catch {
429
- // Silent — never block on hook failure.
430
- }
431
- }
432
-
433
- module.exports = {
434
- main,
435
- readLedger,
436
- countDistinctSessions,
437
- readFabricLanguage,
438
- buildReason,
439
- decide,
440
- readCooldownHours,
441
- readShownCache,
442
- writeShownCache,
443
- CONSTANTS: {
444
- FABRIC_DIR,
445
- EVENT_LEDGER_FILE,
446
- EVENT_TYPE_PROPOSED,
447
- EVENT_TYPE_PLAN_CONTEXT,
448
- EVENT_TYPE_ROTATED,
449
- THRESHOLD_PLAN_CONTEXTS,
450
- THRESHOLD_HOURS,
451
- CONFIG_FILE,
452
- DEFAULT_COOLDOWN_HOURS,
453
- SHOWN_CACHE_FILE,
454
- SESSION_COVERAGE_THRESHOLD,
455
- ROTATION_HINT_EVENTS_THRESHOLD,
456
- DEFAULT_LANGUAGE,
457
- },
458
- };
459
-
460
- if (require.main === module) {
461
- main({ cwd: process.cwd(), now: new Date() }, { stdout: process.stdout });
462
- process.exit(0);
463
- }