@fenglimg/fabric-cli 2.0.0 → 2.1.0-rc.2
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/LICENSE +21 -0
- package/README.md +6 -5
- package/dist/chunk-BATF4PEJ.js +361 -0
- package/dist/{chunk-OBQU6NHO.js → chunk-COI5VDFU.js} +0 -18
- package/dist/chunk-F46ORPOA.js +903 -0
- package/dist/chunk-HFQVXY6P.js +86 -0
- package/dist/chunk-L4Q55UC4.js +52 -0
- package/dist/chunk-LFIKMVY7.js +27 -0
- package/dist/chunk-MF3OTILQ.js +544 -0
- package/dist/chunk-PWLW3B57.js +18 -0
- package/dist/chunk-RYAFBNES.js +33 -0
- package/dist/chunk-T5RPGCCM.js +40 -0
- package/dist/chunk-WU6GAPKH.js +36 -0
- package/dist/config-XJIPZNUP.js +13 -0
- package/dist/doctor-QVNPHLJK.js +920 -0
- package/dist/index.js +23 -8
- package/dist/{init-BIRSIOXO.js → install-2HDO5FTQ.js} +807 -705
- package/dist/metrics-ACEQFPDU.js +122 -0
- package/dist/onboard-coverage-MFCAEBDO.js +220 -0
- package/dist/{plan-context-hint-QMUPAXIB.js → plan-context-hint-FC6P3WFE.js} +34 -28
- package/dist/scope-explain-2F2R5URO.js +33 -0
- package/dist/status-GLQWLWH6.js +23 -0
- package/dist/store-XTSE5TY6.js +105 -0
- package/dist/sync-BJCWDPNC.js +245 -0
- package/dist/uninstall-TAXSUSKH.js +1073 -0
- package/dist/whoami-B6AEMSEV.js +31 -0
- package/package.json +30 -5
- package/templates/hooks/cite-policy-evict.cjs +231 -0
- package/templates/hooks/configs/README.md +29 -6
- package/templates/hooks/configs/claude-code.json +14 -3
- package/templates/hooks/configs/codex-hooks.json +6 -3
- package/templates/hooks/configs/cursor-hooks.json +8 -10
- package/templates/hooks/fabric-hint.cjs +873 -105
- package/templates/hooks/knowledge-hint-broad.cjs +549 -135
- package/templates/hooks/knowledge-hint-narrow.cjs +830 -26
- package/templates/hooks/lib/banner-i18n.cjs +309 -0
- package/templates/hooks/lib/bindings-snapshot-reader.cjs +81 -0
- package/templates/hooks/lib/cite-contract-reminder.cjs +179 -0
- package/templates/hooks/lib/cite-line-parser.cjs +180 -0
- package/templates/hooks/lib/client-adapter.cjs +106 -0
- package/templates/hooks/lib/config-cache.cjs +107 -0
- package/templates/hooks/lib/state-store.cjs +84 -0
- package/templates/hooks/lib/summary-fallback.cjs +210 -0
- package/templates/skills/fabric-archive/SKILL.md +97 -419
- package/templates/skills/fabric-archive/ref/dry-run-scope.md +16 -0
- package/templates/skills/fabric-archive/ref/e5-cron-recap.md +58 -0
- package/templates/skills/fabric-archive/ref/i18n-policy.md +86 -0
- package/templates/skills/fabric-archive/ref/phase-0-range-resolution.md +156 -0
- package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +218 -0
- package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +62 -0
- package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +68 -0
- package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +108 -0
- package/templates/skills/fabric-archive/ref/phase-3-classify.md +63 -0
- package/templates/skills/fabric-archive/ref/phase-4-5-emit.md +78 -0
- package/templates/skills/fabric-archive/ref/phase-4-mcp-persist.md +89 -0
- package/templates/skills/fabric-archive/ref/rc-history.md +38 -0
- package/templates/skills/fabric-archive/ref/worked-examples.md +78 -0
- package/templates/skills/fabric-import/SKILL.md +77 -514
- package/templates/skills/fabric-import/ref/checkpoint-state.md +85 -0
- package/templates/skills/fabric-import/ref/i18n-policy.md +79 -0
- package/templates/skills/fabric-import/ref/output-contract.md +61 -0
- package/templates/skills/fabric-import/ref/phase-2-mining.md +213 -0
- package/templates/skills/fabric-import/ref/phase-3-dedup.md +75 -0
- package/templates/skills/fabric-import/ref/state-recovery.md +57 -0
- package/templates/skills/fabric-import/ref/worked-examples.md +127 -0
- package/templates/skills/fabric-review/SKILL.md +90 -284
- package/templates/skills/fabric-review/ref/askuserquestion-policy.md +66 -0
- package/templates/skills/fabric-review/ref/i18n-policy.md +111 -0
- package/templates/skills/fabric-review/ref/modify-flow.md +103 -0
- package/templates/skills/fabric-review/ref/output-contract.md +58 -0
- package/templates/skills/fabric-review/ref/per-mode-flows.md +155 -0
- package/templates/skills/fabric-review/ref/semantic-check.md +26 -0
- package/templates/skills/fabric-review/ref/worked-examples.md +95 -0
- package/templates/skills/fabric-sync/SKILL.md +46 -0
- package/templates/skills/lib/shared-policy.md +69 -0
- package/dist/chunk-6ICJICVU.js +0 -10
- package/dist/chunk-74SZWYPH.js +0 -658
- package/dist/chunk-EYIDD2YS.js +0 -1000
- package/dist/doctor-T7JWODKG.js +0 -282
- package/dist/hooks-Y74Y5LQS.js +0 -12
- package/dist/scan-LMK3UCWL.js +0 -22
- package/dist/serve-H554BHLG.js +0 -124
- package/templates/agents-md/AGENTS.md.template +0 -59
- package/templates/bootstrap/CLAUDE.md +0 -8
- package/templates/bootstrap/codex-AGENTS-header.md +0 -6
- package/templates/bootstrap/cursor-fabric-bootstrap.mdc +0 -10
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } = require("node:fs");
|
|
2
|
+
const { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } = require("node:fs");
|
|
3
3
|
const { dirname, join } = require("node:path");
|
|
4
4
|
|
|
5
5
|
// v2.0.0-rc.7 T5: session-digest writer. Best-effort (never blocks Stop hook
|
|
@@ -13,15 +13,119 @@ try {
|
|
|
13
13
|
sessionDigestWriter = null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// v2.0.0-rc.16 TASK-002 (F2-apply): banner-i18n lib for the 5 Signal
|
|
17
|
+
// banners (A/B/C/D-never/D-aged). Resolved ONCE per main() invocation and
|
|
18
|
+
// threaded into decide() / evaluateMaintenanceSignal() via the existing
|
|
19
|
+
// thresholds object. Lib is required at module load; failure to load is
|
|
20
|
+
// fatal-here-but-silent: the require itself can't throw without the .cjs
|
|
21
|
+
// being missing entirely (a packaging bug we'd want to surface during
|
|
22
|
+
// install integration tests, not silently swallow).
|
|
23
|
+
const { renderBanner, readFabricLanguage } = require("./lib/banner-i18n.cjs");
|
|
24
|
+
|
|
25
|
+
// v2.0.0-rc.24 TASK-04: shared cite-line parser (CJS twin of
|
|
26
|
+
// packages/shared/src/cite-line-parser.ts, byte-shipped via installHookLibs).
|
|
27
|
+
// Provides `parseCiteLine(raw)` → { cite_ids, cite_tags, cite_commitments }.
|
|
28
|
+
// Hook runtime has no node_modules access; the twin is hand-synced and
|
|
29
|
+
// behavior-parity-tested against the TS source.
|
|
30
|
+
let citeLineParser = null;
|
|
31
|
+
try {
|
|
32
|
+
citeLineParser = require("./lib/cite-line-parser.cjs");
|
|
33
|
+
} catch {
|
|
34
|
+
// Helper module missing — degrade silently. parseKbLine falls back to a
|
|
35
|
+
// legacy in-file regex when the lib is unavailable (e.g. mid-upgrade where
|
|
36
|
+
// hook script lands before lib is copied). New cite_commitments output is
|
|
37
|
+
// empty in degraded mode.
|
|
38
|
+
citeLineParser = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// v2.0.0-rc.24 TASK-05: L1 enforcement layer — soft Stop hook reminder for
|
|
42
|
+
// [recalled] cites of decision/pitfall types that arrived without operator
|
|
43
|
+
// contract or skip:<reason>. Reads .fabric/agents.meta.json (via
|
|
44
|
+
// lib/cite-contract-reminder.cjs#readKnowledgeTypeMap) to type-route cite
|
|
45
|
+
// ids per B6 lock; emits one
|
|
46
|
+
// ⚠ KB: <id> cited as [recalled] but missing contract; add → edit:<glob>
|
|
47
|
+
// or → skip:<reason> next turn
|
|
48
|
+
// line to stderr per offending id. Non-blocking, never throws.
|
|
49
|
+
let citeContractReminder = null;
|
|
50
|
+
try {
|
|
51
|
+
citeContractReminder = require("./lib/cite-contract-reminder.cjs");
|
|
52
|
+
} catch {
|
|
53
|
+
// Helper module missing — soft reminder simply doesn't fire. Audit-side
|
|
54
|
+
// doctor (TASK-08) still catches contract violations at the next run.
|
|
55
|
+
citeContractReminder = null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// v2.0.0-rc.37 NEW-30: shared client-protocol adapter. Guarded require (this
|
|
59
|
+
// hook runs in arbitrary user repos); detectClient delegates the 3-tier
|
|
60
|
+
// detection to the lib, falling back to env-only when the lib is absent.
|
|
61
|
+
let clientAdapter = null;
|
|
62
|
+
try {
|
|
63
|
+
clientAdapter = require("./lib/client-adapter.cjs");
|
|
64
|
+
} catch {
|
|
65
|
+
clientAdapter = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// v2.0.0-rc.37 NEW-16: shared config + sidecar I/O for the per-signal dismiss
|
|
69
|
+
// feature (config-level durable opt-out + session-scoped sidecar). Guarded
|
|
70
|
+
// require (house style); dismiss simply doesn't fire if the lib is absent.
|
|
71
|
+
let configCache = null;
|
|
72
|
+
let stateStore = null;
|
|
73
|
+
try {
|
|
74
|
+
configCache = require("./lib/config-cache.cjs");
|
|
75
|
+
} catch {
|
|
76
|
+
configCache = null;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
stateStore = require("./lib/state-store.cjs");
|
|
80
|
+
} catch {
|
|
81
|
+
stateStore = null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
|
|
85
|
+
// resolved-bindings snapshot. The Stop hint surfaces the read-set stores
|
|
86
|
+
// (per-store, NOT aggregated into one pile) without re-resolving / walking
|
|
87
|
+
// store trees. Best-effort — a missing lib/snapshot omits the store line.
|
|
88
|
+
let bindingsSnapshotReader = null;
|
|
89
|
+
try {
|
|
90
|
+
bindingsSnapshotReader = require("./lib/bindings-snapshot-reader.cjs");
|
|
91
|
+
} catch {
|
|
92
|
+
bindingsSnapshotReader = null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Read the project's own `project_id` (the snapshot key) from its config.
|
|
96
|
+
function readProjectId(cwd) {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8"));
|
|
99
|
+
return typeof parsed.project_id === "string" ? parsed.project_id : null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
16
105
|
// CONSTANTS — duplicated from packages/server/src/services/_shared.ts.
|
|
17
106
|
// DRY violation accepted: this hook script runs in user repos WITHOUT
|
|
18
107
|
// node_modules access, so it cannot import from @fenglimg/fabric-server.
|
|
19
108
|
const FABRIC_DIR = ".fabric";
|
|
20
109
|
const EVENT_LEDGER_FILE = "events.jsonl";
|
|
110
|
+
// v2.0.0-rc.39 (P1 emit-fold): high-frequency empty-shell assistant_turn_observed
|
|
111
|
+
// turns (kb_line_raw=null AND no cite_ids AND no cite_commitments) carry zero
|
|
112
|
+
// cite-audit signal, so emitting one events.jsonl line each is pure bloat. They
|
|
113
|
+
// are folded at the emit source into a single per-Stop metrics.jsonl counter row
|
|
114
|
+
// `{ counters: { assistant_turn_observed[:<client>]: N } }`. The cite-coverage /
|
|
115
|
+
// emit-cadence readers add this counter back into total_turns so the metric is
|
|
116
|
+
// byte-for-byte invariant (the fold preserves count semantics, incl. the legacy
|
|
117
|
+
// per-Stop re-emission, exactly). Mirrors packages/server/src/services/metrics.ts
|
|
118
|
+
// row shape; written directly (the .cjs hook cannot import the TS service).
|
|
119
|
+
const METRICS_LEDGER_FILE = "metrics.jsonl";
|
|
21
120
|
const EVENT_TYPE_PROPOSED = "knowledge_proposed";
|
|
22
121
|
const EVENT_TYPE_INIT_SCAN_COMPLETED = "init_scan_completed";
|
|
23
122
|
// v2.0.0-rc.7 T10: doctor_run event drives Signal D (maintenance hint).
|
|
24
123
|
const EVENT_TYPE_DOCTOR_RUN = "doctor_run";
|
|
124
|
+
// v2.0.0-rc.20 TASK-03: per-turn cite-policy observation event. Emitted by
|
|
125
|
+
// extractAndWriteAssistantTurnsBestEffort() after the Stop hook parses each
|
|
126
|
+
// assistant envelope's first non-empty line for a `KB:` prefix. Schema
|
|
127
|
+
// registered in packages/shared/src/schemas/event-ledger.ts (rc.20 TASK-02).
|
|
128
|
+
const EVENT_TYPE_ASSISTANT_TURN_OBSERVED = "assistant_turn_observed";
|
|
25
129
|
// rc.6 TASK-022 (E5): Signal A is now `24h OR N-edits since last
|
|
26
130
|
// knowledge_proposed`. The edit-count branch reads
|
|
27
131
|
// `.fabric/.cache/edit-counter` (one ISO-8601 line per PreToolUse fire,
|
|
@@ -98,14 +202,20 @@ const MAINTENANCE_HINT_LAST_EMIT_FILE = ".fabric/.cache/maintenance-hint-last-em
|
|
|
98
202
|
// there's barely anything TO lint.
|
|
99
203
|
const MAINTENANCE_HINT_MIN_CANONICAL = 5;
|
|
100
204
|
|
|
101
|
-
// rc.
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
205
|
+
// v2.0.0-rc.8 (TASK-002): in-flight import gate for Signal B.
|
|
206
|
+
// fabric-import skill writes `.fabric/.import-state.json` checkpoints after
|
|
207
|
+
// every successful sub-step (P1/P2/P3 — see fabric-import/SKILL.md). The
|
|
208
|
+
// Stop hook reads this file as a soft signal to know that an import is
|
|
209
|
+
// mid-run, so we can silence Signal B (review hint at pending count >= 10)
|
|
210
|
+
// to avoid interrupting the import while it accumulates pending entries.
|
|
211
|
+
//
|
|
212
|
+
// Gate is intentionally narrow: ONLY Signal B is suppressed. Signals A
|
|
213
|
+
// (archive), C (import recommendation), D (maintenance) retain their
|
|
214
|
+
// pre-existing behaviour byte-for-byte. The 24h TTL on `last_checkpoint_at`
|
|
215
|
+
// guards against stale state files that would otherwise permanently
|
|
216
|
+
// silence Signal B if a user abandoned an import without completing.
|
|
217
|
+
const IMPORT_STATE_FILE_REL = join(".fabric", ".import-state.json");
|
|
218
|
+
const IMPORT_IN_FLIGHT_MAX_AGE_HOURS = 24;
|
|
109
219
|
|
|
110
220
|
/**
|
|
111
221
|
* Read the events.jsonl ledger from <projectRoot>/.fabric/events.jsonl.
|
|
@@ -296,35 +406,62 @@ function countEditsSince(projectRoot, anchorTs) {
|
|
|
296
406
|
}
|
|
297
407
|
|
|
298
408
|
/**
|
|
299
|
-
* rc.
|
|
300
|
-
*
|
|
301
|
-
*
|
|
409
|
+
* v2.0.0-rc.8 (TASK-002): detect whether a fabric-import skill run is
|
|
410
|
+
* currently in flight, used to gate Signal B (review hint) so the Stop
|
|
411
|
+
* hook does not interrupt an active import when its pending pile crosses
|
|
412
|
+
* the review threshold.
|
|
413
|
+
*
|
|
414
|
+
* Truth table — returns false (i.e. NOT in flight, do not gate) on:
|
|
415
|
+
* - `.fabric/.import-state.json` missing (no import has ever started or
|
|
416
|
+
* state file was deleted)
|
|
417
|
+
* - JSON.parse failure (malformed state file — never-block invariant
|
|
418
|
+
* forbids permanently silencing Signal B due to corruption)
|
|
419
|
+
* - `phase === "complete"` (import finished — see fabric-import SKILL.md
|
|
420
|
+
* Phase 3.4)
|
|
421
|
+
* - `last_checkpoint_at` missing OR older than IMPORT_IN_FLIGHT_MAX_AGE_HOURS
|
|
422
|
+
* (stale state — user likely abandoned the import; do not let a forever
|
|
423
|
+
* orphaned state file silence Signal B forever)
|
|
424
|
+
* - any unexpected throw (defensive — never-block invariant)
|
|
425
|
+
*
|
|
426
|
+
* Returns true ONLY when state file exists, parses, has a non-"complete"
|
|
427
|
+
* phase, and a fresh `last_checkpoint_at` (< 24h ago). Field names
|
|
428
|
+
* (`phase`, `last_checkpoint_at`) verified against fabric-import SKILL.md
|
|
429
|
+
* § Checkpoint Logic.
|
|
430
|
+
*
|
|
431
|
+
* `now` is optional — defaults to `new Date()`. Tests can inject a fixed
|
|
432
|
+
* Date for determinism; production callers may omit it.
|
|
302
433
|
*/
|
|
303
|
-
function
|
|
434
|
+
function isImportInFlight(projectRoot, now) {
|
|
304
435
|
try {
|
|
305
|
-
|
|
436
|
+
const p = join(projectRoot, IMPORT_STATE_FILE_REL);
|
|
437
|
+
if (!existsSync(p)) return false;
|
|
438
|
+
let raw;
|
|
439
|
+
try {
|
|
440
|
+
raw = readFileSync(p, "utf8");
|
|
441
|
+
} catch {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
let parsed;
|
|
445
|
+
try {
|
|
446
|
+
parsed = JSON.parse(raw);
|
|
447
|
+
} catch {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
if (parsed === null || typeof parsed !== "object") return false;
|
|
451
|
+
if (parsed.phase === "complete") return false;
|
|
452
|
+
const ts = parsed.last_checkpoint_at;
|
|
453
|
+
if (typeof ts !== "string" || ts.length === 0) return false;
|
|
454
|
+
const ms = Date.parse(ts);
|
|
455
|
+
if (!Number.isFinite(ms)) return false;
|
|
456
|
+
const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
|
|
457
|
+
const ageHours = (nowMs - ms) / MS_PER_HOUR;
|
|
458
|
+
if (ageHours > IMPORT_IN_FLIGHT_MAX_AGE_HOURS) return false;
|
|
459
|
+
return true;
|
|
306
460
|
} catch {
|
|
307
461
|
return false;
|
|
308
462
|
}
|
|
309
463
|
}
|
|
310
464
|
|
|
311
|
-
/**
|
|
312
|
-
* rc.7 T1: build the import-recommendation result that the Stop hook emits
|
|
313
|
-
* when the sentinel is present. Reuses the existing Signal C shape so
|
|
314
|
-
* downstream consumers (Cursor `followup_message`, etc.) need no schema
|
|
315
|
-
* change. The reason text reuses the rc.7 T4 人-first banner style.
|
|
316
|
-
*/
|
|
317
|
-
function makeImportSentinelResult() {
|
|
318
|
-
const line1 =
|
|
319
|
-
"📋 Fabric: 检测到 fabric init 提示要回灌知识 — 是否调 /fabric-import 从 git 历史和现有文档抽取?";
|
|
320
|
-
return {
|
|
321
|
-
decision: "block",
|
|
322
|
-
reason: line1,
|
|
323
|
-
signal: "import",
|
|
324
|
-
recommended_skill: "fabric-import",
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
|
|
328
465
|
/**
|
|
329
466
|
* rc.7 T4: read the edit-counter sidecar and return the top-N most-edited
|
|
330
467
|
* directories (grouped by the leading 2 path segments) since `anchorTs`.
|
|
@@ -397,14 +534,49 @@ function getTopEditedDirectories(projectRoot, topN, anchorTs) {
|
|
|
397
534
|
// any leading "./". POSIX-style only — the hook ships under POSIX
|
|
398
535
|
// path conventions even on Windows (the project doesn't currently
|
|
399
536
|
// ship a CRLF/backslash test matrix for the sidecar).
|
|
400
|
-
|
|
537
|
+
//
|
|
538
|
+
// v2.0.0-rc.27 TASK-005 (audit §2.8 leak surface): absolute paths
|
|
539
|
+
// already accumulated in legacy sidecars start with `/`. We strip
|
|
540
|
+
// the leading slash and also reject buckets that resolve to user-home
|
|
541
|
+
// segments (`Users/<name>/...`, `home/<name>/...`) so historical
|
|
542
|
+
// pollution from absolute-path writes doesn't surface the user's
|
|
543
|
+
// $HOME in the archive banner. The rc.27 appendEditCounter no longer
|
|
544
|
+
// writes such paths, but the sidecar is append-only so old lines
|
|
545
|
+
// persist until rotation.
|
|
546
|
+
let norm = p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
547
|
+
// Strip leading `/` so a stale absolute entry doesn't generate a leak.
|
|
548
|
+
while (norm.startsWith("/")) norm = norm.slice(1);
|
|
401
549
|
const segs = norm.split("/").filter((s) => s.length > 0);
|
|
550
|
+
// Reject any bucket whose top segments look like a host-system home
|
|
551
|
+
// prefix. The pattern is `<top>/<user>/...` where top ∈ Users|home|root.
|
|
552
|
+
// This silently drops legacy absolute-path entries from $HOME without
|
|
553
|
+
// mangling the buckets for legitimate project-relative `Users/...`
|
|
554
|
+
// (unlikely but possible) — the heuristic favours $HOME leak prevention
|
|
555
|
+
// over false-positive bucketing of project paths named after Unix
|
|
556
|
+
// conventions.
|
|
557
|
+
if (segs.length >= 2 && (segs[0] === "Users" || segs[0] === "home" || segs[0] === "root")) {
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
// v2.0.0-rc.27 TASK-005 (audit §2.8 file-as-dir): when segs[1] looks
|
|
561
|
+
// like a file (contains a dot-extension at the end), surface segs[0]
|
|
562
|
+
// alone instead of `segs[0]/segs[1]/` — a 2-seg path of the form
|
|
563
|
+
// `assets/foo.ts` would otherwise render as "assets/foo.ts/" which
|
|
564
|
+
// misleads the operator about whether they're seeing a file or a
|
|
565
|
+
// directory. The extension regex is permissive: any `.X` where X is
|
|
566
|
+
// 1-8 alphanumerics counts. README.md / package.json / foo.ts all
|
|
567
|
+
// match; "v1.2" or "dotted.module" do too — acceptable false-positive
|
|
568
|
+
// rate, since the worst outcome is over-aggregation to the parent.
|
|
569
|
+
const looksLikeFile = (segment) => /\.[A-Za-z0-9]{1,8}$/u.test(segment);
|
|
402
570
|
let bucket;
|
|
403
571
|
if (segs.length >= 2) {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
572
|
+
if (looksLikeFile(segs[1])) {
|
|
573
|
+
bucket = `${segs[0]}/`;
|
|
574
|
+
} else {
|
|
575
|
+
// Leading 2 segments: "packages/cli", "docs/decisions", etc. We
|
|
576
|
+
// trail with "/" so the banner reads "packages/cli/" — clearly a
|
|
577
|
+
// directory rather than a file basename.
|
|
578
|
+
bucket = `${segs[0]}/${segs[1]}/`;
|
|
579
|
+
}
|
|
408
580
|
} else if (segs.length === 1) {
|
|
409
581
|
// Single segment — treat the basename as its own bucket. Bare
|
|
410
582
|
// root-level files (README.md, package.json) get some signal too.
|
|
@@ -500,7 +672,7 @@ function readArchiveEditThreshold(projectRoot) {
|
|
|
500
672
|
// without touching the filesystem. Omitting the arg falls back to documented
|
|
501
673
|
// defaults so existing in-process callers (tests that pre-date T7) still
|
|
502
674
|
// pass without modification — they implicitly exercise the default path.
|
|
503
|
-
function decide(events, now, pendingStats, underseedStats, editCounterStats, thresholds, banner) {
|
|
675
|
+
function decide(events, now, pendingStats, underseedStats, editCounterStats, thresholds, banner, importInFlight) {
|
|
504
676
|
const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
|
|
505
677
|
const stats = pendingStats || { count: 0, oldestAgeMs: null };
|
|
506
678
|
const underseed =
|
|
@@ -523,6 +695,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
523
695
|
typeof cfg.reviewHintPendingAgeDays === "number" && cfg.reviewHintPendingAgeDays > 0
|
|
524
696
|
? cfg.reviewHintPendingAgeDays
|
|
525
697
|
: DEFAULT_REVIEW_HINT_PENDING_AGE_DAYS;
|
|
698
|
+
// rc.16 TASK-002: banner variant for the i18n lib. Defaults to 'zh-CN' so
|
|
699
|
+
// existing test callers (which never pass thresholds.variant) get the rc.15
|
|
700
|
+
// byte-identical Chinese output. main() always supplies the resolved variant.
|
|
701
|
+
const variant = typeof cfg.variant === "string" ? cfg.variant : "zh-CN";
|
|
526
702
|
|
|
527
703
|
// ---- Archive signal (rc.6 TASK-022 — Signal A, 24h-OR-N-edits) -----------
|
|
528
704
|
// Locate the most-recent knowledge_proposed event. If none exists, Signal A
|
|
@@ -569,23 +745,38 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
569
745
|
// - "<editCount> 次编辑"
|
|
570
746
|
// - "阈值 <N>"
|
|
571
747
|
// - "fabric-archive"
|
|
748
|
+
// v2.0.0-rc.27 TASK-005 (audit §2.17): parts now assembled per-variant
|
|
749
|
+
// via banner-i18n's archivePartsHours / archivePartsEdits so en mode
|
|
750
|
+
// gets fully-English fragments instead of mixed-language output. zh-CN
|
|
751
|
+
// / zh-CN-hybrid still render the original substring contract verbatim.
|
|
572
752
|
const parts = [];
|
|
573
753
|
if (triggerByHours) {
|
|
574
|
-
parts.push(
|
|
754
|
+
parts.push(
|
|
755
|
+
renderBanner("archivePartsHours", variant, {
|
|
756
|
+
hoursFixed: hoursElapsed.toFixed(1),
|
|
757
|
+
threshold: archiveHintHours,
|
|
758
|
+
}),
|
|
759
|
+
);
|
|
575
760
|
}
|
|
576
761
|
if (triggerByEdits) {
|
|
577
762
|
parts.push(
|
|
578
|
-
|
|
763
|
+
renderBanner("archivePartsEdits", variant, {
|
|
764
|
+
count: editStats.editsSinceLastProposed,
|
|
765
|
+
threshold: editStats.threshold,
|
|
766
|
+
}),
|
|
579
767
|
);
|
|
580
768
|
}
|
|
581
|
-
|
|
769
|
+
// rc.16 TASK-002: 5-banner i18n via lib/banner-i18n.cjs. Substring
|
|
770
|
+
// contracts ('25.0h', '阈值 N', 'fabric-archive') preserved by the lib's
|
|
771
|
+
// zh-CN templates — see lib header for the full contract.
|
|
772
|
+
const line1 = renderBanner("archiveLine1", variant, { parts: parts.join(" / ") });
|
|
582
773
|
const activity = banner && typeof banner.activityOverview === "string"
|
|
583
774
|
? banner.activityOverview
|
|
584
775
|
: "";
|
|
585
776
|
const line2 = activity.length > 0
|
|
586
|
-
?
|
|
777
|
+
? renderBanner("archiveActivity", variant, { activity })
|
|
587
778
|
: "";
|
|
588
|
-
const line3 = "
|
|
779
|
+
const line3 = renderBanner("archiveCta", variant, {});
|
|
589
780
|
const reason = [line1, line2, line3].filter((l) => l.length > 0).join("\n");
|
|
590
781
|
return {
|
|
591
782
|
decision: "block",
|
|
@@ -600,7 +791,13 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
600
791
|
const triggerByPendingAge =
|
|
601
792
|
stats.oldestAgeMs !== null && stats.oldestAgeMs / MS_PER_DAY >= reviewHintPendingAgeDays;
|
|
602
793
|
|
|
603
|
-
|
|
794
|
+
// v2.0.0-rc.8 (TASK-002): suppress ONLY Signal B while a fabric-import
|
|
795
|
+
// skill run is in flight (read from .fabric/.import-state.json by main()
|
|
796
|
+
// and threaded in as `importInFlight`). Signals A, C, D are unaffected.
|
|
797
|
+
// We fall through to Signal C evaluation rather than returning null —
|
|
798
|
+
// review backlog should not pre-empt import-recommendation evaluation
|
|
799
|
+
// when import is mid-run.
|
|
800
|
+
if ((triggerByPendingCount || triggerByPendingAge) && importInFlight !== true) {
|
|
604
801
|
// rc.7 T4: 人-first banner reformat for Signal B. Keeps the pending
|
|
605
802
|
// count and age substrings (`${count} 条`, `${days} 天`) so existing
|
|
606
803
|
// tests pass; drops the Agent-jussive "建议调用 ... skill ..." for a
|
|
@@ -609,8 +806,13 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
609
806
|
stats.oldestAgeMs !== null
|
|
610
807
|
? ` / 最早一条 ${(stats.oldestAgeMs / MS_PER_DAY).toFixed(1)} 天前`
|
|
611
808
|
: "";
|
|
612
|
-
|
|
613
|
-
|
|
809
|
+
// rc.16 TASK-002: i18n via lib. Substrings ('${count} 条', 'fabric-review')
|
|
810
|
+
// preserved by the lib's zh-CN templates.
|
|
811
|
+
const line1 = renderBanner("reviewLine1", variant, {
|
|
812
|
+
count: stats.count,
|
|
813
|
+
ageSuffix,
|
|
814
|
+
});
|
|
815
|
+
const line2 = renderBanner("reviewCta", variant, {});
|
|
614
816
|
const reason = `${line1}\n${line2}`;
|
|
615
817
|
return {
|
|
616
818
|
decision: "block",
|
|
@@ -651,12 +853,16 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
651
853
|
(hoursSinceProposed === null || hoursSinceProposed >= UNDERSEED_NO_PROPOSED_HOURS);
|
|
652
854
|
|
|
653
855
|
if (triggerUnderseed) {
|
|
654
|
-
// rc.
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
856
|
+
// rc.16 TASK-002: i18n via lib. Substrings ('${nodeCount}/${threshold}',
|
|
857
|
+
// 'fabric-import', '${hoursSinceInit}h') preserved by the lib's zh-CN
|
|
858
|
+
// templates. Note: hoursSinceInit is passed as already-toFixed(1) string
|
|
859
|
+
// to keep the lib pure (no number formatting in render path).
|
|
860
|
+
const line1 = renderBanner("importLine1", variant, {
|
|
861
|
+
nodeCount: underseed.nodeCount,
|
|
862
|
+
threshold: underseed.threshold,
|
|
863
|
+
hoursSinceInit: hoursSinceInit.toFixed(1),
|
|
864
|
+
});
|
|
865
|
+
const line2 = renderBanner("importCta", variant, {});
|
|
660
866
|
const reason = `${line1}\n${line2}`;
|
|
661
867
|
return {
|
|
662
868
|
decision: "block",
|
|
@@ -780,6 +986,97 @@ function writeShownCache(projectRoot, cache) {
|
|
|
780
986
|
}
|
|
781
987
|
}
|
|
782
988
|
|
|
989
|
+
// -----------------------------------------------------------------------------
|
|
990
|
+
// v2.0.0-rc.37 NEW-16 — per-signal dismiss.
|
|
991
|
+
//
|
|
992
|
+
// Two suppression levers, both honoured at emit time (a chosen signal whose
|
|
993
|
+
// type is dismissed exits silently, exactly like a cooldown hit):
|
|
994
|
+
// 1. Durable opt-out — fabric-config.json#hint_dismiss_signals: string[].
|
|
995
|
+
// Mirrors the cite_evict_interval=0 opt-out convention; survives across
|
|
996
|
+
// sessions. The concrete user-actionable lever surfaced in the nudge.
|
|
997
|
+
// 2. Session-scoped — .fabric/.cache/hint-dismiss-{sessionId}.json
|
|
998
|
+
// { dismissed: string[] }. Ephemeral; written by the agent when the user
|
|
999
|
+
// asks to silence a nudge type for the current session (Fabric's
|
|
1000
|
+
// AI-driven write convention — no new CLI surface).
|
|
1001
|
+
//
|
|
1002
|
+
// The four signal types ('archive' / 'review' / 'import' / 'maintenance')
|
|
1003
|
+
// each have an independent cooldown ALREADY (signal-keyed SHOWN_CACHE for
|
|
1004
|
+
// A/B/C + the maintenance day-cooldown sidecar), so dismiss layers cleanly on
|
|
1005
|
+
// top of per-signal cadence without a physical 4-hook split (which would 4×
|
|
1006
|
+
// the per-Stop process spawn and break the deliberate single-nudge-per-turn
|
|
1007
|
+
// precedence model — KT-DEC-0007 anti-nag spirit).
|
|
1008
|
+
// -----------------------------------------------------------------------------
|
|
1009
|
+
|
|
1010
|
+
const DISMISSABLE_SIGNALS = ["archive", "review", "import", "maintenance"];
|
|
1011
|
+
|
|
1012
|
+
function sessionDismissFileName(sessionId) {
|
|
1013
|
+
const safe = String(sessionId || "anonymous").replace(/[^A-Za-z0-9_.-]/g, "-");
|
|
1014
|
+
return `hint-dismiss-${safe}.json`;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// Returns a Set of dismissed signal types (config-durable ∪ session sidecar).
|
|
1018
|
+
// Never throws — degrades to an empty set when libs are absent.
|
|
1019
|
+
function readDismissedSignals(projectRoot, sessionId) {
|
|
1020
|
+
const dismissed = new Set();
|
|
1021
|
+
try {
|
|
1022
|
+
if (configCache && typeof configCache.readConfig === "function") {
|
|
1023
|
+
const cfg = configCache.readConfig(projectRoot);
|
|
1024
|
+
const list = cfg && cfg.hint_dismiss_signals;
|
|
1025
|
+
if (Array.isArray(list)) {
|
|
1026
|
+
for (const s of list) {
|
|
1027
|
+
if (DISMISSABLE_SIGNALS.includes(s)) dismissed.add(s);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
} catch {
|
|
1032
|
+
// defensive
|
|
1033
|
+
}
|
|
1034
|
+
try {
|
|
1035
|
+
if (stateStore && typeof stateStore.readJsonState === "function" && sessionId) {
|
|
1036
|
+
const sidecar = stateStore.readJsonState(
|
|
1037
|
+
projectRoot,
|
|
1038
|
+
sessionDismissFileName(sessionId),
|
|
1039
|
+
(p) => p && typeof p === "object" && Array.isArray(p.dismissed),
|
|
1040
|
+
);
|
|
1041
|
+
if (sidecar) {
|
|
1042
|
+
for (const s of sidecar.dismissed) {
|
|
1043
|
+
if (DISMISSABLE_SIGNALS.includes(s)) dismissed.add(s);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
} catch {
|
|
1048
|
+
// defensive
|
|
1049
|
+
}
|
|
1050
|
+
return dismissed;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Persist a session-scoped dismiss set (additive merge). Exposed for the
|
|
1054
|
+
// agent-driven write path + tests; not auto-invoked by the hook. Never throws.
|
|
1055
|
+
function writeSessionDismiss(projectRoot, sessionId, signals) {
|
|
1056
|
+
if (!stateStore || typeof stateStore.writeJsonState !== "function") return;
|
|
1057
|
+
const fileName = sessionDismissFileName(sessionId);
|
|
1058
|
+
const prior = stateStore.readJsonState(
|
|
1059
|
+
projectRoot,
|
|
1060
|
+
fileName,
|
|
1061
|
+
(p) => p && typeof p === "object" && Array.isArray(p.dismissed),
|
|
1062
|
+
);
|
|
1063
|
+
const merged = new Set(prior && Array.isArray(prior.dismissed) ? prior.dismissed : []);
|
|
1064
|
+
for (const s of Array.isArray(signals) ? signals : []) {
|
|
1065
|
+
if (DISMISSABLE_SIGNALS.includes(s)) merged.add(s);
|
|
1066
|
+
}
|
|
1067
|
+
stateStore.writeJsonState(projectRoot, fileName, { dismissed: [...merged] });
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// Bilingual one-line dismiss hint appended to every nudge so the user knows
|
|
1071
|
+
// the lever exists. Variant fold mirrors banner-i18n: zh-CN / zh-CN-hybrid →
|
|
1072
|
+
// Chinese; en / match-existing / unknown → English.
|
|
1073
|
+
function renderDismissOption(signal, variant) {
|
|
1074
|
+
const zh = variant === "zh-CN" || variant === "zh-CN-hybrid";
|
|
1075
|
+
return zh
|
|
1076
|
+
? ` (不想再看到此类提醒?在 .fabric/fabric-config.json 设 "hint_dismiss_signals": ["${signal}"],或让我本会话关闭 ${signal} 提醒)`
|
|
1077
|
+
: ` (Silence this nudge? Set "hint_dismiss_signals": ["${signal}"] in .fabric/fabric-config.json, or ask me to dismiss ${signal} for this session)`;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
783
1080
|
/**
|
|
784
1081
|
* v2.0.0-rc.7 T10: find the most recent doctor_run event ts in the ledger.
|
|
785
1082
|
* Returns the ts (epoch ms) of the newest doctor_run event, or null if none
|
|
@@ -859,15 +1156,23 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
|
|
|
859
1156
|
typeof cfg.maintenanceHintCooldownDays === "number" && cfg.maintenanceHintCooldownDays > 0
|
|
860
1157
|
? cfg.maintenanceHintCooldownDays
|
|
861
1158
|
: DEFAULT_MAINTENANCE_HINT_COOLDOWN_DAYS;
|
|
1159
|
+
// rc.16 TASK-002: banner variant for the i18n lib. Defaults to 'zh-CN' so
|
|
1160
|
+
// existing rc.7 T10 test fixtures (which never set thresholds.variant) get
|
|
1161
|
+
// the byte-identical Chinese maintenance banner.
|
|
1162
|
+
const variant = typeof cfg.variant === "string" ? cfg.variant : "zh-CN";
|
|
862
1163
|
|
|
863
1164
|
if (canonicalCount < MAINTENANCE_HINT_MIN_CANONICAL) {
|
|
864
1165
|
return null;
|
|
865
1166
|
}
|
|
866
1167
|
|
|
867
1168
|
// Cooldown gate — short-circuit when we just nagged.
|
|
1169
|
+
// rc.34 TASK-01 + review-fix (Gemini P1): future-stamped lastEmit (backward
|
|
1170
|
+
// clock skew) bypasses cooldown — treats sidecar as "expired" so the gate
|
|
1171
|
+
// heals on the next invocation instead of waiting (cooldown + |skew|).
|
|
868
1172
|
if (
|
|
869
1173
|
typeof lastEmitMs === "number" &&
|
|
870
1174
|
Number.isFinite(lastEmitMs) &&
|
|
1175
|
+
nowMs >= lastEmitMs &&
|
|
871
1176
|
nowMs - lastEmitMs < cooldownDays * MS_PER_DAY
|
|
872
1177
|
) {
|
|
873
1178
|
return null;
|
|
@@ -883,14 +1188,18 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
|
|
|
883
1188
|
if (ageDays < days) return null; // doctor ran recently, no nag.
|
|
884
1189
|
}
|
|
885
1190
|
|
|
886
|
-
// rc.
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
const line2 = "
|
|
891
|
-
const
|
|
892
|
-
?
|
|
893
|
-
:
|
|
1191
|
+
// rc.16 TASK-002: i18n via lib. Substrings ('从未运行 lint 检查',
|
|
1192
|
+
// '已 N 天未跑 lint', 'fabric doctor --lint') preserved by the lib's
|
|
1193
|
+
// zh-CN templates. ageDays passed as already-toFixed(1) string to keep
|
|
1194
|
+
// the lib pure (no number formatting in render path).
|
|
1195
|
+
const line2 = renderBanner("maintenanceLine2", variant, {});
|
|
1196
|
+
const line1 = lastDoctorTs === null
|
|
1197
|
+
? renderBanner("maintenanceLine1Never", variant, {})
|
|
1198
|
+
: renderBanner("maintenanceLine1Aged", variant, {
|
|
1199
|
+
days,
|
|
1200
|
+
ageDays: ageDays.toFixed(1),
|
|
1201
|
+
});
|
|
1202
|
+
const reason = `${line1}\n${line2}`;
|
|
894
1203
|
|
|
895
1204
|
return {
|
|
896
1205
|
decision: "block",
|
|
@@ -922,11 +1231,224 @@ function tryReadStdinJson() {
|
|
|
922
1231
|
const parsed = JSON.parse(buf);
|
|
923
1232
|
if (parsed === null || typeof parsed !== "object") return null;
|
|
924
1233
|
return parsed;
|
|
925
|
-
} catch {
|
|
1234
|
+
} catch (e) {
|
|
1235
|
+
// v2.0.0-rc.29 TASK-008 (BUG-L1): hook used to silent-swallow JSON.parse
|
|
1236
|
+
// errors which masked real client-side payload bugs (e.g. CLI hosts that
|
|
1237
|
+
// stopped emitting Stop-hook JSON envelopes). Log a single best-effort
|
|
1238
|
+
// diagnostic line so operators see WHY the hook went quiet; keep returning
|
|
1239
|
+
// null so downstream behaviour (graceful exit 0, no rule render) is
|
|
1240
|
+
// unchanged.
|
|
1241
|
+
try {
|
|
1242
|
+
const message = (e && typeof e === "object" && "message" in e) ? String(e.message) : String(e);
|
|
1243
|
+
process.stderr.write(`[fabric-hint] malformed input: ${message}\n`);
|
|
1244
|
+
} catch {
|
|
1245
|
+
// stderr write failed (very unusual — sandbox / closed fd). The
|
|
1246
|
+
// hook contract still requires we never throw upward.
|
|
1247
|
+
}
|
|
926
1248
|
return null;
|
|
927
1249
|
}
|
|
928
1250
|
}
|
|
929
1251
|
|
|
1252
|
+
/**
|
|
1253
|
+
* v2.0.0-rc.20 TASK-03 → v2.0.0-rc.24 TASK-04: legacy shim signature for
|
|
1254
|
+
* parsing the raw text that follows the `KB:` prefix on the first non-empty
|
|
1255
|
+
* line of an assistant turn. As of rc.24 the implementation delegates to the
|
|
1256
|
+
* shared `parseCiteLine` (inline-shipped via lib/cite-line-parser.cjs) to
|
|
1257
|
+
* eliminate per-client regex drift.
|
|
1258
|
+
*
|
|
1259
|
+
* Contract (rc.24 strict mode — superset of rc.20):
|
|
1260
|
+
* - Sentinel `none` (incl. `[no-relevant]` / `[not-applicable]` tail)
|
|
1261
|
+
* → cite_ids=[], cite_tags=["none"], cite_commitments=[]
|
|
1262
|
+
* - `KT-DEC-0001 [planned]` → cite_ids=["KT-DEC-0001"], cite_tags=["planned"],
|
|
1263
|
+
* cite_commitments=[{operators:[], skip_reason:null}]
|
|
1264
|
+
* - `KT-DEC-0001 [recalled] → edit:foo.ts` → cite_commitments=[{operators:
|
|
1265
|
+
* [{kind:"edit", target:"foo.ts"}], skip_reason:null}]
|
|
1266
|
+
* - `KT-DEC-0001 [recalled] → skip:sequencing` → cite_commitments=[{operators:
|
|
1267
|
+
* [], skip_reason:"sequencing"}]
|
|
1268
|
+
* - Id form is now strict `K[TP]-[A-Z]+-\d+` (rc.20 lax form `KP-001`
|
|
1269
|
+
* without letter-prefix is rejected — see TASK-03 schema).
|
|
1270
|
+
*
|
|
1271
|
+
* Argument is the post-`KB:` substring (matches the rc.20 call site). Returns
|
|
1272
|
+
* { cite_ids, cite_tags, cite_commitments }; cite_commitments was added in
|
|
1273
|
+
* rc.24 and is always present (empty array when no cite-line found).
|
|
1274
|
+
*
|
|
1275
|
+
* Never throws.
|
|
1276
|
+
*/
|
|
1277
|
+
function parseKbLine(raw) {
|
|
1278
|
+
// Compose the full `KB: <raw>` line because the shared parser anchors on
|
|
1279
|
+
// the `KB:` prefix. Handles the legacy `none` / `<sentinel>` inputs naturally
|
|
1280
|
+
// because parseCiteLine's SENTINEL_RE matches the composed line.
|
|
1281
|
+
if (typeof raw !== "string") {
|
|
1282
|
+
return { cite_ids: [], cite_tags: [], cite_commitments: [] };
|
|
1283
|
+
}
|
|
1284
|
+
const composed = `KB: ${raw}`;
|
|
1285
|
+
if (citeLineParser && typeof citeLineParser.parseCiteLine === "function") {
|
|
1286
|
+
return citeLineParser.parseCiteLine(composed);
|
|
1287
|
+
}
|
|
1288
|
+
// Degraded fallback: lib missing (e.g. partial install). Emit empty result
|
|
1289
|
+
// so downstream consumers see the cite-line as unobservable rather than
|
|
1290
|
+
// mis-parsed. The Stop-hook contract is best-effort, never blocking.
|
|
1291
|
+
return { cite_ids: [], cite_tags: [], cite_commitments: [] };
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* v2.0.0-rc.20 TASK-03: detect which client surface invoked the hook so the
|
|
1296
|
+
* emitted assistant_turn_observed event can carry a `client` discriminator
|
|
1297
|
+
* without having to inspect the transcript shape.
|
|
1298
|
+
*
|
|
1299
|
+
* Resolution order (first match wins):
|
|
1300
|
+
* 1. `FABRIC_HINT_CLIENT` env var — explicit override, set by the per-
|
|
1301
|
+
* client install pipeline when the hook-config schema supports env
|
|
1302
|
+
* injection.
|
|
1303
|
+
* 2. Path heuristic against `__dirname` — `.claude/` → "cc", `.codex/` →
|
|
1304
|
+
* "codex". Covers the dominant deployment shape (hook script lives
|
|
1305
|
+
* under the client's per-repo dir).
|
|
1306
|
+
*
|
|
1307
|
+
* Returns `undefined` when neither signal fires (e.g. Cursor — deferred to
|
|
1308
|
+
* rc.21 — or a custom deployment). The Zod schema marks `client` optional,
|
|
1309
|
+
* so omitting it leaves the event valid.
|
|
1310
|
+
*/
|
|
1311
|
+
function detectClient() {
|
|
1312
|
+
// Delegate the full 3-tier detection (env → CLAUDE_PROJECT_DIR → path
|
|
1313
|
+
// heuristic, incl. .cursor) to the shared adapter. __dirname is passed so
|
|
1314
|
+
// the path heuristic reflects THIS hook's location.
|
|
1315
|
+
if (clientAdapter && typeof clientAdapter.detectClient === "function") {
|
|
1316
|
+
return clientAdapter.detectClient(__dirname);
|
|
1317
|
+
}
|
|
1318
|
+
// Fallback (adapter lib absent): env override only.
|
|
1319
|
+
const envClient = process.env.FABRIC_HINT_CLIENT;
|
|
1320
|
+
if (typeof envClient === "string" && envClient.length > 0) {
|
|
1321
|
+
const normalised = envClient.trim().toLowerCase();
|
|
1322
|
+
if (normalised === "cc" || normalised === "codex" || normalised === "cursor") {
|
|
1323
|
+
return normalised;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
return undefined;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* v2.0.0-rc.20 TASK-03: emit one `assistant_turn_observed` event per
|
|
1331
|
+
* assistant envelope harvested from the transcript. Wrapped in try/catch
|
|
1332
|
+
* (best-effort, never throws — Stop hook MUST stay non-blocking on any
|
|
1333
|
+
* failure here). The event shape mirrors
|
|
1334
|
+
* assistantTurnObservedEventSchema in
|
|
1335
|
+
* packages/shared/src/schemas/event-ledger.ts (registered in rc.20 TASK-02).
|
|
1336
|
+
*
|
|
1337
|
+
* Call site sits immediately AFTER writeSessionDigestBestEffort so both
|
|
1338
|
+
* digest + per-turn events derive from the same transcript snapshot.
|
|
1339
|
+
*
|
|
1340
|
+
* `id` mirrors the server's convention (`event:<uuid>`) using
|
|
1341
|
+
* crypto.randomUUID when available — falls back to a timestamp+counter
|
|
1342
|
+
* tuple on older Node where randomUUID is missing (cjs hook tooling
|
|
1343
|
+
* defensively targets Node 18+, but the fallback keeps it event-shaped).
|
|
1344
|
+
*/
|
|
1345
|
+
function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
|
|
1346
|
+
if (stdinPayload === null || typeof stdinPayload !== "object") return;
|
|
1347
|
+
try {
|
|
1348
|
+
const sessionId = stdinPayload.session_id;
|
|
1349
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return;
|
|
1350
|
+
const transcript = summarizeTranscript(stdinPayload.transcript_path);
|
|
1351
|
+
const turns = transcript.assistant_turns;
|
|
1352
|
+
if (!Array.isArray(turns) || turns.length === 0) return;
|
|
1353
|
+
|
|
1354
|
+
// Resolve event-ledger path. Caller already validated cwd shape.
|
|
1355
|
+
const fabricDir = join(cwd, FABRIC_DIR);
|
|
1356
|
+
if (!existsSync(fabricDir)) {
|
|
1357
|
+
// No .fabric/ → workspace is uninitialised. Silently skip; the digest
|
|
1358
|
+
// writer applies the same guard via its own internal check.
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
const ledgerPath = join(fabricDir, EVENT_LEDGER_FILE);
|
|
1362
|
+
const client = detectClient();
|
|
1363
|
+
let randomUUID;
|
|
1364
|
+
try {
|
|
1365
|
+
({ randomUUID } = require("node:crypto"));
|
|
1366
|
+
} catch {
|
|
1367
|
+
randomUUID = null;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// v2.0.0-rc.39 (P1 emit-fold): empty-shell turns (no KB: line, no cites)
|
|
1371
|
+
// do not get an events.jsonl line — they are tallied and folded into one
|
|
1372
|
+
// metrics.jsonl counter row at the end of this batch. This zeroes the 99%
|
|
1373
|
+
// empty-shell bloat at the source while keeping cite-bearing turns as
|
|
1374
|
+
// discrete audit events. Count carries per-Stop re-emission exactly (we
|
|
1375
|
+
// tally every empty turn the transcript presents, not just new ones), so
|
|
1376
|
+
// the reader-side counter merge reconstructs total_turns byte-for-byte.
|
|
1377
|
+
let emptyShellCount = 0;
|
|
1378
|
+
for (const turn of turns) {
|
|
1379
|
+
try {
|
|
1380
|
+
const citeIds = Array.isArray(turn.cite_ids) ? turn.cite_ids : [];
|
|
1381
|
+
const citeCommitments = Array.isArray(turn.cite_commitments)
|
|
1382
|
+
? turn.cite_commitments
|
|
1383
|
+
: [];
|
|
1384
|
+
const isEmptyShell =
|
|
1385
|
+
(turn.kb_line_raw === null || turn.kb_line_raw === undefined) &&
|
|
1386
|
+
citeIds.length === 0 &&
|
|
1387
|
+
citeCommitments.length === 0;
|
|
1388
|
+
if (isEmptyShell) {
|
|
1389
|
+
emptyShellCount += 1;
|
|
1390
|
+
continue;
|
|
1391
|
+
}
|
|
1392
|
+
const idSuffix = typeof randomUUID === "function"
|
|
1393
|
+
? randomUUID()
|
|
1394
|
+
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
1395
|
+
const event = {
|
|
1396
|
+
kind: "fabric-event",
|
|
1397
|
+
id: `event:${idSuffix}`,
|
|
1398
|
+
ts: Date.now(),
|
|
1399
|
+
schema_version: 1,
|
|
1400
|
+
session_id: sessionId,
|
|
1401
|
+
event_type: EVENT_TYPE_ASSISTANT_TURN_OBSERVED,
|
|
1402
|
+
kb_line_raw: turn.kb_line_raw,
|
|
1403
|
+
cite_ids: citeIds,
|
|
1404
|
+
cite_tags: Array.isArray(turn.cite_tags) ? turn.cite_tags : [],
|
|
1405
|
+
// rc.24 TASK-04: cite_commitments parallel array (assistantTurn
|
|
1406
|
+
// ObservedEventSchema gained this slot in rc.24 TASK-01). Empty
|
|
1407
|
+
// array for legacy turns or when the parser lib is unavailable —
|
|
1408
|
+
// the schema defaults `.default([])` so omitting it would also be
|
|
1409
|
+
// valid, but emitting an explicit `[]` keeps the on-disk shape
|
|
1410
|
+
// uniform across rc.24+ events.
|
|
1411
|
+
cite_commitments: citeCommitments,
|
|
1412
|
+
turn_id: `${sessionId}-${turn.envelope_index}`,
|
|
1413
|
+
envelope_index: turn.envelope_index,
|
|
1414
|
+
timestamp: new Date().toISOString(),
|
|
1415
|
+
};
|
|
1416
|
+
if (client !== undefined) event.client = client;
|
|
1417
|
+
appendFileSync(ledgerPath, JSON.stringify(event) + "\n", "utf8");
|
|
1418
|
+
} catch {
|
|
1419
|
+
// Per-turn failure must not abort the remaining turns; the Stop hook
|
|
1420
|
+
// contract is "never block on hook failure". Best-effort continues.
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// rc.39 emit-fold: write one metrics.jsonl counter row for the folded
|
|
1425
|
+
// empty-shell turns. Best-effort — a failure here must never block the
|
|
1426
|
+
// Stop hook (KT-DEC-0007). The counter key is namespaced by client so the
|
|
1427
|
+
// reader's per_client total_turns breakdown stays invariant; an undefined
|
|
1428
|
+
// client (adapter lib absent) folds into the bare `assistant_turn_observed`
|
|
1429
|
+
// key, mirroring how such turns omit the event-side `client` discriminator.
|
|
1430
|
+
if (emptyShellCount > 0) {
|
|
1431
|
+
try {
|
|
1432
|
+
const counterKey =
|
|
1433
|
+
client !== undefined
|
|
1434
|
+
? `${EVENT_TYPE_ASSISTANT_TURN_OBSERVED}:${client}`
|
|
1435
|
+
: EVENT_TYPE_ASSISTANT_TURN_OBSERVED;
|
|
1436
|
+
const metricsRow = {
|
|
1437
|
+
timestamp: new Date().toISOString(),
|
|
1438
|
+
window: "stop",
|
|
1439
|
+
counters: { [counterKey]: emptyShellCount },
|
|
1440
|
+
};
|
|
1441
|
+
const metricsPath = join(fabricDir, METRICS_LEDGER_FILE);
|
|
1442
|
+
appendFileSync(metricsPath, JSON.stringify(metricsRow) + "\n", "utf8");
|
|
1443
|
+
} catch {
|
|
1444
|
+
// metrics fold is observability-only; never block the hook on failure.
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
} catch {
|
|
1448
|
+
// Outer guard — never throw. Hook continues silently.
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
930
1452
|
/**
|
|
931
1453
|
* v2.0.0-rc.7 T5: extract user_messages + edit_paths + 1-line title from the
|
|
932
1454
|
* transcript JSONL referenced by the hook's stdin payload. Best-effort, never
|
|
@@ -935,9 +1457,18 @@ function tryReadStdinJson() {
|
|
|
935
1457
|
* Claude Code's transcript_path points at a JSONL where each line is a
|
|
936
1458
|
* message envelope. We sniff for `role: "user"` lines (text content) and
|
|
937
1459
|
* for tool-use entries naming Edit / Write / MultiEdit to harvest file_path.
|
|
1460
|
+
*
|
|
1461
|
+
* v2.0.0-rc.20 TASK-03: additionally collects `assistant_turns[]` — one
|
|
1462
|
+
* entry per assistant envelope with the parsed KB-line cite metadata. Field
|
|
1463
|
+
* is additive; existing callers (writeSessionDigestBestEffort) ignore it.
|
|
938
1464
|
*/
|
|
939
1465
|
function summarizeTranscript(transcriptPath) {
|
|
940
|
-
|
|
1466
|
+
// rc.20 TASK-03: additive `assistant_turns` array — one entry per assistant
|
|
1467
|
+
// envelope, regardless of whether the first line matched KB:. Downstream
|
|
1468
|
+
// consumers (extractAndWriteAssistantTurnsBestEffort) emit one
|
|
1469
|
+
// assistant_turn_observed event per element; `kb_line_raw=null` when no
|
|
1470
|
+
// KB: line was found.
|
|
1471
|
+
const out = { user_messages: [], edit_paths: [], title: "", assistant_turns: [] };
|
|
941
1472
|
if (typeof transcriptPath !== "string" || transcriptPath.length === 0) return out;
|
|
942
1473
|
if (!existsSync(transcriptPath)) return out;
|
|
943
1474
|
let raw;
|
|
@@ -947,6 +1478,7 @@ function summarizeTranscript(transcriptPath) {
|
|
|
947
1478
|
return out;
|
|
948
1479
|
}
|
|
949
1480
|
const lines = raw.split(/\r?\n/);
|
|
1481
|
+
let envelopeIndex = -1;
|
|
950
1482
|
for (const line of lines) {
|
|
951
1483
|
const trimmed = line.trim();
|
|
952
1484
|
if (trimmed.length === 0) continue;
|
|
@@ -957,12 +1489,23 @@ function summarizeTranscript(transcriptPath) {
|
|
|
957
1489
|
continue;
|
|
958
1490
|
}
|
|
959
1491
|
if (envelope === null || typeof envelope !== "object") continue;
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
//
|
|
963
|
-
|
|
1492
|
+
envelopeIndex += 1;
|
|
1493
|
+
|
|
1494
|
+
// v2.0.0-rc.27 TASK-009 (audit §2.16): Codex CLI uses a different
|
|
1495
|
+
// envelope shape — { type:"response_item", payload:{ type:"message",
|
|
1496
|
+
// role, content:[{type:"input_text"|"output_text", text}] } } — vs Claude
|
|
1497
|
+
// Code's { type:"user", message:{ role, content } }. Resolve role +
|
|
1498
|
+
// content from whichever shape is present; without this, every Codex
|
|
1499
|
+
// session's digest came out empty (audit §2.16 — fixed here).
|
|
1500
|
+
const role =
|
|
1501
|
+
envelope.role ||
|
|
1502
|
+
(envelope.message && envelope.message.role) ||
|
|
1503
|
+
(envelope.payload && envelope.payload.role);
|
|
964
1504
|
if (role === "user") {
|
|
965
|
-
const content =
|
|
1505
|
+
const content =
|
|
1506
|
+
envelope.content ||
|
|
1507
|
+
(envelope.message && envelope.message.content) ||
|
|
1508
|
+
(envelope.payload && envelope.payload.content);
|
|
966
1509
|
if (typeof content === "string") {
|
|
967
1510
|
out.user_messages.push(content);
|
|
968
1511
|
} else if (Array.isArray(content)) {
|
|
@@ -974,6 +1517,85 @@ function summarizeTranscript(transcriptPath) {
|
|
|
974
1517
|
}
|
|
975
1518
|
}
|
|
976
1519
|
|
|
1520
|
+
// rc.20 TASK-03: assistant envelope — capture first non-empty line of the
|
|
1521
|
+
// first text block and parse for `KB:` prefix. We push ONE assistant_turns
|
|
1522
|
+
// entry per assistant envelope (even when no KB: line) so downstream can
|
|
1523
|
+
// distinguish "turn observed, no KB" (kb_line_raw=null) from "no turn".
|
|
1524
|
+
if (role === "assistant") {
|
|
1525
|
+
const content =
|
|
1526
|
+
envelope.content ||
|
|
1527
|
+
(envelope.message && envelope.message.content) ||
|
|
1528
|
+
(envelope.payload && envelope.payload.content);
|
|
1529
|
+
let firstText = null;
|
|
1530
|
+
if (typeof content === "string") {
|
|
1531
|
+
firstText = content;
|
|
1532
|
+
} else if (Array.isArray(content)) {
|
|
1533
|
+
for (const block of content) {
|
|
1534
|
+
if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
|
|
1535
|
+
firstText = block.text;
|
|
1536
|
+
break;
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
let kbLineRaw = null;
|
|
1541
|
+
let citeIds = [];
|
|
1542
|
+
let citeTags = [];
|
|
1543
|
+
// rc.24 TASK-04: parallel `cite_commitments` array, populated by the
|
|
1544
|
+
// shared cite-line parser. One entry per non-sentinel cite (index-aligned
|
|
1545
|
+
// with cite_ids). Sentinel `KB: none` contributes a `cite_tags=["none"]`
|
|
1546
|
+
// entry but no commitment — matches the parseCiteLine index contract.
|
|
1547
|
+
let citeCommitments = [];
|
|
1548
|
+
// v2.0.0-rc.27 TASK-009: Codex assistant blocks carry text under
|
|
1549
|
+
// `type:"output_text"` (not `type:"text"`). Fall back when no text-typed
|
|
1550
|
+
// block matched but a typed output_text block exists.
|
|
1551
|
+
if (firstText === null && Array.isArray(content)) {
|
|
1552
|
+
for (const block of content) {
|
|
1553
|
+
if (block && typeof block === "object" && block.type === "output_text" && typeof block.text === "string") {
|
|
1554
|
+
firstText = block.text;
|
|
1555
|
+
break;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
if (typeof firstText === "string" && firstText.length > 0) {
|
|
1560
|
+
// First non-empty line.
|
|
1561
|
+
const linesOfText = firstText.split(/\r?\n/);
|
|
1562
|
+
let firstNonEmpty = "";
|
|
1563
|
+
for (const l of linesOfText) {
|
|
1564
|
+
if (l.trim().length > 0) {
|
|
1565
|
+
firstNonEmpty = l.trim();
|
|
1566
|
+
break;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
if (firstNonEmpty.length > 0) {
|
|
1570
|
+
// rc.24 TASK-04: route the FULL `KB: ...` line to the shared parser.
|
|
1571
|
+
// parseCiteLine handles sentinels (`KB: none [<reason>]`) AND full
|
|
1572
|
+
// cite form including contract tail (`KB: KT-DEC-0001 [recalled] →
|
|
1573
|
+
// edit:foo.ts`) uniformly. The sentinel's `[<reason>]` tail stays in
|
|
1574
|
+
// `kb_line_raw` for doctor's downstream histogram parse; cite_tags
|
|
1575
|
+
// still emits the bare `none` token (schema enum-bound).
|
|
1576
|
+
if (/^KB:\s*/i.test(firstNonEmpty)) {
|
|
1577
|
+
kbLineRaw = firstNonEmpty;
|
|
1578
|
+
if (citeLineParser && typeof citeLineParser.parseCiteLine === "function") {
|
|
1579
|
+
const parsed = citeLineParser.parseCiteLine(firstNonEmpty);
|
|
1580
|
+
citeIds = parsed.cite_ids;
|
|
1581
|
+
citeTags = parsed.cite_tags;
|
|
1582
|
+
citeCommitments = parsed.cite_commitments;
|
|
1583
|
+
}
|
|
1584
|
+
// Degraded mode (lib missing) → keep kbLineRaw but emit empty
|
|
1585
|
+
// arrays; doctor downstream treats this as "turn observed, parse
|
|
1586
|
+
// unavailable" without crashing.
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
out.assistant_turns.push({
|
|
1591
|
+
envelope_index: envelopeIndex,
|
|
1592
|
+
kb_line_raw: kbLineRaw,
|
|
1593
|
+
cite_ids: citeIds,
|
|
1594
|
+
cite_tags: citeTags,
|
|
1595
|
+
cite_commitments: citeCommitments,
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
|
|
977
1599
|
// Tool use — look for Edit / Write / MultiEdit and harvest file_path.
|
|
978
1600
|
const candidates = [];
|
|
979
1601
|
if (envelope.type === "tool_use") candidates.push(envelope);
|
|
@@ -999,6 +1621,27 @@ function summarizeTranscript(transcriptPath) {
|
|
|
999
1621
|
}
|
|
1000
1622
|
}
|
|
1001
1623
|
}
|
|
1624
|
+
|
|
1625
|
+
// v2.0.0-rc.27 TASK-009 (audit §2.16): Codex apply_patch path. Codex
|
|
1626
|
+
// emits one response_item envelope per file-edit invocation with payload
|
|
1627
|
+
// shape { type:"custom_tool_call", name:"apply_patch", input:<patch
|
|
1628
|
+
// string> }. The patch body lists target files via `*** Update File:`,
|
|
1629
|
+
// `*** Add File:`, `*** Delete File:` directives — harvest those.
|
|
1630
|
+
if (
|
|
1631
|
+
envelope.type === "response_item" &&
|
|
1632
|
+
envelope.payload &&
|
|
1633
|
+
envelope.payload.type === "custom_tool_call" &&
|
|
1634
|
+
envelope.payload.name === "apply_patch" &&
|
|
1635
|
+
typeof envelope.payload.input === "string"
|
|
1636
|
+
) {
|
|
1637
|
+
const patchInput = envelope.payload.input;
|
|
1638
|
+
const fileDirectiveRe = /^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s+(.+?)\s*$/gm;
|
|
1639
|
+
let m;
|
|
1640
|
+
while ((m = fileDirectiveRe.exec(patchInput)) !== null) {
|
|
1641
|
+
const fp = m[1].trim();
|
|
1642
|
+
if (fp.length > 0) out.edit_paths.push(fp);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1002
1645
|
}
|
|
1003
1646
|
// 1-line title = first non-empty user message (trimmed). Falls back to "".
|
|
1004
1647
|
if (out.user_messages.length > 0) {
|
|
@@ -1015,6 +1658,50 @@ function summarizeTranscript(transcriptPath) {
|
|
|
1015
1658
|
return out;
|
|
1016
1659
|
}
|
|
1017
1660
|
|
|
1661
|
+
/**
|
|
1662
|
+
* v2.0.0-rc.24 TASK-05: emit soft L1 reminder to stderr when assistant turns
|
|
1663
|
+
* cited a decision/pitfall id with [recalled] but no operator contract and no
|
|
1664
|
+
* skip:<reason>. Reads agents.meta.json once per invocation; aggregated per
|
|
1665
|
+
* turn (one line per offending id). Non-blocking — never throws, always
|
|
1666
|
+
* returns the array of emitted reminder strings (for unit tests + callers
|
|
1667
|
+
* that want to observe what was written).
|
|
1668
|
+
*
|
|
1669
|
+
* The reminder writes go to stderr (the hook contract: stdout is structured
|
|
1670
|
+
* banner JSON consumed by the harness; stderr is free-text system message
|
|
1671
|
+
* that surfaces back to the model on the next turn in cc / codex / cursor).
|
|
1672
|
+
*/
|
|
1673
|
+
function emitCiteContractRemindersBestEffort(cwd, stdinPayload, stderr) {
|
|
1674
|
+
if (citeContractReminder === null) return [];
|
|
1675
|
+
if (stdinPayload === null || typeof stdinPayload !== "object") return [];
|
|
1676
|
+
try {
|
|
1677
|
+
const transcript = summarizeTranscript(stdinPayload.transcript_path);
|
|
1678
|
+
const turns = transcript.assistant_turns;
|
|
1679
|
+
if (!Array.isArray(turns) || turns.length === 0) return [];
|
|
1680
|
+
|
|
1681
|
+
const idTypeMap = citeContractReminder.readKnowledgeTypeMap(cwd);
|
|
1682
|
+
if (!(idTypeMap instanceof Map) || idTypeMap.size === 0) return [];
|
|
1683
|
+
|
|
1684
|
+
const reminders = citeContractReminder.formatContractMissingReminders({
|
|
1685
|
+
assistant_turns: turns,
|
|
1686
|
+
idTypeMap,
|
|
1687
|
+
});
|
|
1688
|
+
if (!Array.isArray(reminders) || reminders.length === 0) return [];
|
|
1689
|
+
|
|
1690
|
+
const sink = stderr || process.stderr;
|
|
1691
|
+
for (const line of reminders) {
|
|
1692
|
+
try {
|
|
1693
|
+
sink.write(line + "\n");
|
|
1694
|
+
} catch {
|
|
1695
|
+
// Sink write failure must not abort emission of remaining reminders.
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
return reminders;
|
|
1699
|
+
} catch {
|
|
1700
|
+
// Outer guard — never throw. Hook continues silently.
|
|
1701
|
+
return [];
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1018
1705
|
/**
|
|
1019
1706
|
* v2.0.0-rc.7 T5: writeSessionDigestBestEffort — non-blocking digest fan-out.
|
|
1020
1707
|
* Called from main() before the existing decide() flow. Failure is silently
|
|
@@ -1060,6 +1747,23 @@ function main(env, stdio) {
|
|
|
1060
1747
|
? env.stdin_payload
|
|
1061
1748
|
: tryReadStdinJson();
|
|
1062
1749
|
writeSessionDigestBestEffort(cwd, stdinPayload);
|
|
1750
|
+
// v2.0.0-rc.20 TASK-03: per-turn cite-policy observation events. Same
|
|
1751
|
+
// best-effort contract as the digest writer — never throws, never blocks
|
|
1752
|
+
// the Stop hook on failure. Shares the transcript snapshot read by
|
|
1753
|
+
// writeSessionDigestBestEffort (each call re-reads independently; the
|
|
1754
|
+
// transcript file is small in practice and re-parse cost is dwarfed by
|
|
1755
|
+
// the hook's other I/O).
|
|
1756
|
+
extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload);
|
|
1757
|
+
|
|
1758
|
+
// v2.0.0-rc.24 TASK-05: L1 soft reminder layer. Surfaces ⚠ KB:<id> lines
|
|
1759
|
+
// to stderr when decision/pitfall cites arrived with [recalled] tag but
|
|
1760
|
+
// empty contract. Non-blocking, never throws; doctor (TASK-08) catches
|
|
1761
|
+
// any contract violation the model ignored.
|
|
1762
|
+
emitCiteContractRemindersBestEffort(
|
|
1763
|
+
cwd,
|
|
1764
|
+
stdinPayload,
|
|
1765
|
+
stdio && stdio.stderr,
|
|
1766
|
+
);
|
|
1063
1767
|
|
|
1064
1768
|
const events = readLedger(cwd);
|
|
1065
1769
|
let pendingStats;
|
|
@@ -1108,6 +1812,19 @@ function main(env, stdio) {
|
|
|
1108
1812
|
// rc.7 T7: read the externalized thresholds and pass them into decide.
|
|
1109
1813
|
// Reader failures degrade silently to documented defaults — fabric-hint
|
|
1110
1814
|
// must never block on config errors (see hook contract above).
|
|
1815
|
+
//
|
|
1816
|
+
// rc.16 TASK-002 (F2-apply): resolve `fabric_language` ONCE per main()
|
|
1817
|
+
// invocation via the banner-i18n lib. The result threads through
|
|
1818
|
+
// `thresholds.variant` into both decide() and evaluateMaintenanceSignal()
|
|
1819
|
+
// so we read the config file at most once, not five times. Lib reader
|
|
1820
|
+
// is never-throw; defensive try/catch is belt-and-suspenders.
|
|
1821
|
+
let variant = "zh-CN";
|
|
1822
|
+
try {
|
|
1823
|
+
variant = readFabricLanguage(cwd);
|
|
1824
|
+
} catch {
|
|
1825
|
+
variant = "zh-CN";
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1111
1828
|
let thresholds;
|
|
1112
1829
|
try {
|
|
1113
1830
|
thresholds = {
|
|
@@ -1116,6 +1833,7 @@ function main(env, stdio) {
|
|
|
1116
1833
|
reviewHintPendingAgeDays: readReviewHintPendingAgeDays(cwd),
|
|
1117
1834
|
maintenanceHintDays: readMaintenanceHintDays(cwd),
|
|
1118
1835
|
maintenanceHintCooldownDays: readMaintenanceHintCooldownDays(cwd),
|
|
1836
|
+
variant,
|
|
1119
1837
|
};
|
|
1120
1838
|
} catch {
|
|
1121
1839
|
thresholds = {
|
|
@@ -1124,6 +1842,7 @@ function main(env, stdio) {
|
|
|
1124
1842
|
reviewHintPendingAgeDays: DEFAULT_REVIEW_HINT_PENDING_AGE_DAYS,
|
|
1125
1843
|
maintenanceHintDays: DEFAULT_MAINTENANCE_HINT_DAYS,
|
|
1126
1844
|
maintenanceHintCooldownDays: DEFAULT_MAINTENANCE_HINT_COOLDOWN_DAYS,
|
|
1845
|
+
variant,
|
|
1127
1846
|
};
|
|
1128
1847
|
}
|
|
1129
1848
|
|
|
@@ -1147,28 +1866,29 @@ function main(env, stdio) {
|
|
|
1147
1866
|
activityOverview = "";
|
|
1148
1867
|
}
|
|
1149
1868
|
|
|
1150
|
-
// rc.
|
|
1151
|
-
//
|
|
1152
|
-
//
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1869
|
+
// v2.0.0-rc.8 (TASK-002): probe `.fabric/.import-state.json` to
|
|
1870
|
+
// determine whether a fabric-import skill run is currently in flight.
|
|
1871
|
+
// Threaded into decide() so Signal B (review hint) is suppressed for
|
|
1872
|
+
// the duration of an active import — preventing the Stop hook from
|
|
1873
|
+
// interrupting the import when its pending pile crosses the review
|
|
1874
|
+
// threshold. See isImportInFlight() docstring for the full truth table.
|
|
1875
|
+
let importInFlight = false;
|
|
1876
|
+
try {
|
|
1877
|
+
importInFlight = isImportInFlight(cwd, now);
|
|
1878
|
+
} catch {
|
|
1879
|
+
importInFlight = false;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
let result = decide(
|
|
1883
|
+
events,
|
|
1884
|
+
now,
|
|
1885
|
+
pendingStats,
|
|
1886
|
+
underseedStats,
|
|
1887
|
+
editCounterStats,
|
|
1888
|
+
thresholds,
|
|
1889
|
+
{ activityOverview },
|
|
1890
|
+
importInFlight,
|
|
1891
|
+
);
|
|
1172
1892
|
|
|
1173
1893
|
// v2.0.0-rc.7 T10: Signal D — maintenance hint. Evaluated AFTER A/B/C
|
|
1174
1894
|
// because the existing three signals carry higher urgency (in-flight
|
|
@@ -1193,6 +1913,40 @@ function main(env, stdio) {
|
|
|
1193
1913
|
|
|
1194
1914
|
if (result === null) return;
|
|
1195
1915
|
|
|
1916
|
+
// v2.0.0-rc.37 NEW-16: per-signal dismiss. A chosen signal whose type the
|
|
1917
|
+
// user dismissed (config-durable or session sidecar) exits silently —
|
|
1918
|
+
// same shape as a cooldown hit. Covers BOTH maintenance and A/B/C paths.
|
|
1919
|
+
const sessionId =
|
|
1920
|
+
stdinPayload && typeof stdinPayload.session_id === "string"
|
|
1921
|
+
? stdinPayload.session_id
|
|
1922
|
+
: null;
|
|
1923
|
+
if (readDismissedSignals(cwd, sessionId).has(result.signal)) {
|
|
1924
|
+
return;
|
|
1925
|
+
}
|
|
1926
|
+
// Append the bilingual dismiss-option line so the lever is discoverable.
|
|
1927
|
+
if (typeof result.reason === "string") {
|
|
1928
|
+
result.reason = `${result.reason}\n${renderDismissOption(result.signal, variant)}`;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// v2.1.0-rc.1 P4 (F4/S63): surface the read-set stores on the Stop hint so
|
|
1932
|
+
// backlog/maintenance nudges are read per-store, not as one undifferentiated
|
|
1933
|
+
// pile. Best-effort; missing snapshot / single-store omits the line.
|
|
1934
|
+
if (bindingsSnapshotReader !== null && typeof result.reason === "string") {
|
|
1935
|
+
try {
|
|
1936
|
+
const projectId = readProjectId(cwd);
|
|
1937
|
+
if (projectId) {
|
|
1938
|
+
const label = bindingsSnapshotReader.formatStoreLabels(
|
|
1939
|
+
bindingsSnapshotReader.readBindingsSnapshot(projectId),
|
|
1940
|
+
);
|
|
1941
|
+
if (label) {
|
|
1942
|
+
result.reason = `${result.reason}\n${label}`;
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
} catch {
|
|
1946
|
+
// store label is decorative provenance — never crash the hook
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1196
1950
|
// v2.0.0-rc.7 T10: Signal D uses its own cooldown sidecar (day-based,
|
|
1197
1951
|
// see MAINTENANCE_HINT_LAST_EMIT_FILE). The A/B/C shared cooldown cache
|
|
1198
1952
|
// uses hours, so we branch here to avoid mixing semantics.
|
|
@@ -1202,24 +1956,19 @@ function main(env, stdio) {
|
|
|
1202
1956
|
return;
|
|
1203
1957
|
}
|
|
1204
1958
|
|
|
1205
|
-
// rc.7 T1: sentinel-driven results bypass the cooldown sidecar entirely.
|
|
1206
|
-
// The user explicitly asked at init time for the import recommendation
|
|
1207
|
-
// to surface; the cooldown is a noise-throttle for organic signals,
|
|
1208
|
-
// not for explicit user-driven hand-offs. We also do NOT bump the
|
|
1209
|
-
// cooldown cache when the sentinel fires — that would silence the
|
|
1210
|
-
// *next* organic Signal C unnecessarily.
|
|
1211
|
-
if (sentinelPresent) {
|
|
1212
|
-
out.write(JSON.stringify(result));
|
|
1213
|
-
return;
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
1959
|
// Cooldown throttle: once a signal fires, stay silent for
|
|
1217
1960
|
// archive_hint_cooldown_hours (default 12h) regardless of state drift.
|
|
1218
1961
|
// Pure reminder-noise reduction; the underlying trigger logic is unchanged.
|
|
1219
1962
|
const cooldownMs = readCooldownHours(cwd) * MS_PER_HOUR;
|
|
1220
1963
|
const cache = readShownCache(cwd);
|
|
1221
1964
|
const lastShown = cache[result.signal];
|
|
1222
|
-
|
|
1965
|
+
// rc.34 TASK-01 + review-fix (Gemini P1): future-stamped lastShown
|
|
1966
|
+
// (backward clock skew) bypasses cooldown — sidecar treated as expired.
|
|
1967
|
+
if (
|
|
1968
|
+
typeof lastShown === "number" &&
|
|
1969
|
+
nowMs >= lastShown &&
|
|
1970
|
+
nowMs - lastShown < cooldownMs
|
|
1971
|
+
) {
|
|
1223
1972
|
return; // Still in cooldown — silent.
|
|
1224
1973
|
}
|
|
1225
1974
|
|
|
@@ -1240,13 +1989,20 @@ module.exports = {
|
|
|
1240
1989
|
// rc.7 T4: top-edited-directories aggregator + banner overview formatter.
|
|
1241
1990
|
getTopEditedDirectories,
|
|
1242
1991
|
formatActivityOverview,
|
|
1243
|
-
// rc.
|
|
1244
|
-
|
|
1245
|
-
|
|
1992
|
+
// v2.0.0-rc.8 (TASK-002): in-flight import gate for Signal B (exported
|
|
1993
|
+
// for unit testing of the truth table).
|
|
1994
|
+
isImportInFlight,
|
|
1246
1995
|
decide,
|
|
1247
1996
|
readCooldownHours,
|
|
1248
1997
|
readUnderseedThreshold,
|
|
1249
1998
|
readArchiveEditThreshold,
|
|
1999
|
+
// v2.0.0-rc.37 NEW-16: per-signal dismiss helpers (exported for tests +
|
|
2000
|
+
// the agent-driven session-dismiss write path).
|
|
2001
|
+
readDismissedSignals,
|
|
2002
|
+
writeSessionDismiss,
|
|
2003
|
+
sessionDismissFileName,
|
|
2004
|
+
renderDismissOption,
|
|
2005
|
+
DISMISSABLE_SIGNALS,
|
|
1250
2006
|
// v2.0.0-rc.7 T5: session digest helpers (exported for unit testing).
|
|
1251
2007
|
tryReadStdinJson,
|
|
1252
2008
|
summarizeTranscript,
|
|
@@ -1264,9 +2020,20 @@ module.exports = {
|
|
|
1264
2020
|
readMaintenanceHintCooldownDays,
|
|
1265
2021
|
readShownCache,
|
|
1266
2022
|
writeShownCache,
|
|
2023
|
+
// v2.0.0-rc.20 TASK-03 / TASK-09: cite-policy parsing + per-turn emission
|
|
2024
|
+
// helpers (exported for unit testing of the parse + emit contract).
|
|
2025
|
+
parseKbLine,
|
|
2026
|
+
detectClient,
|
|
2027
|
+
extractAndWriteAssistantTurnsBestEffort,
|
|
2028
|
+
// v2.0.0-rc.24 TASK-05: L1 soft reminder helpers (exported for unit testing
|
|
2029
|
+
// of the contract-missing emission contract). The lib module itself is
|
|
2030
|
+
// also exported indirectly via the reminder helper.
|
|
2031
|
+
emitCiteContractRemindersBestEffort,
|
|
1267
2032
|
CONSTANTS: {
|
|
1268
2033
|
FABRIC_DIR,
|
|
1269
2034
|
EVENT_LEDGER_FILE,
|
|
2035
|
+
METRICS_LEDGER_FILE,
|
|
2036
|
+
EVENT_TYPE_ASSISTANT_TURN_OBSERVED,
|
|
1270
2037
|
EVENT_TYPE_PROPOSED,
|
|
1271
2038
|
EVENT_TYPE_INIT_SCAN_COMPLETED,
|
|
1272
2039
|
// rc.7 T7: legacy aliases kept for back-compat with the existing test
|
|
@@ -1296,8 +2063,9 @@ module.exports = {
|
|
|
1296
2063
|
EVENT_TYPE_DOCTOR_RUN,
|
|
1297
2064
|
MAINTENANCE_HINT_LAST_EMIT_FILE,
|
|
1298
2065
|
MAINTENANCE_HINT_MIN_CANONICAL,
|
|
1299
|
-
// rc.
|
|
1300
|
-
|
|
2066
|
+
// v2.0.0-rc.8 (TASK-002): in-flight import gate for Signal B.
|
|
2067
|
+
IMPORT_STATE_FILE_REL,
|
|
2068
|
+
IMPORT_IN_FLIGHT_MAX_AGE_HOURS,
|
|
1301
2069
|
},
|
|
1302
2070
|
};
|
|
1303
2071
|
|