@fenglimg/fabric-cli 2.0.0-rc.28 → 2.0.0-rc.29
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/{doctor-ZIQXN2T2.js → doctor-TTDTKOFJ.js} +25 -0
- package/dist/index.js +5 -5
- package/dist/{install-MPSTI654.js → install-ODEKSJDS.js} +1 -1
- package/dist/{onboard-coverage-JJ5NGU7I.js → onboard-coverage-6MN3CYHT.js} +10 -3
- package/dist/{serve-U3TPWDOB.js → serve-43JTEM3U.js} +16 -3
- package/package.json +3 -3
- package/templates/hooks/fabric-hint.cjs +14 -1
- package/templates/hooks/knowledge-hint-broad.cjs +10 -6
- package/templates/hooks/knowledge-hint-narrow.cjs +13 -1
- package/templates/hooks/archive-hint.cjs +0 -463
|
@@ -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-
|
|
15
|
-
doctor: () => import("./doctor-
|
|
16
|
-
serve: () => import("./serve-
|
|
14
|
+
install: () => import("./install-ODEKSJDS.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-
|
|
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.
|
|
29
|
+
version: "2.0.0-rc.29",
|
|
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.
|
|
1351
|
+
return true ? "2.0.0-rc.29" : "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
|
-
|
|
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: "
|
|
188
|
+
description: t("cli.onboard-coverage.args.json.description"),
|
|
182
189
|
default: false
|
|
183
190
|
},
|
|
184
191
|
target: {
|
|
185
192
|
type: "string",
|
|
186
|
-
description: "
|
|
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
|
|
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.
|
|
3
|
+
"version": "2.0.0-rc.29",
|
|
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.
|
|
24
|
-
"@fenglimg/fabric-shared": "2.0.0-rc.
|
|
23
|
+
"@fenglimg/fabric-server": "2.0.0-rc.29",
|
|
24
|
+
"@fenglimg/fabric-shared": "2.0.0-rc.29"
|
|
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
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
|
|
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
|
-
}
|