@fenglimg/fabric-cli 2.2.0-rc.1 → 2.2.0-rc.10
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/README.md +8 -5
- package/dist/chunk-27HK6H5Y.js +69 -0
- package/dist/{chunk-AOE6AYI7.js → chunk-2KBCTMID.js} +31 -8
- package/dist/chunk-3D7B2UAZ.js +149 -0
- package/dist/{chunk-XC5RUHLK.js → chunk-3IOLS5EK.js} +23 -38
- package/dist/{plan-context-hint-FC6P3WFE.js → chunk-722JU5BP.js} +52 -12
- package/dist/{chunk-2R55HNVD.js → chunk-7ZDXBOOU.js} +234 -206
- package/dist/{doctor-YONYXDX6.js → chunk-E7HJUU34.js} +215 -52
- package/dist/chunk-EOT63RDH.js +36 -0
- package/dist/chunk-FNHDQTPC.js +16 -0
- package/dist/{chunk-2CY4BMTH.js → chunk-HORSMSZL.js} +9 -5
- package/dist/{chunk-BO4XIZWZ.js → chunk-NLNH64A3.js} +5 -18
- package/dist/{chunk-WU6GAPKH.js → chunk-PTGQAZEW.js} +12 -4
- package/dist/chunk-QFIVFZRH.js +13 -0
- package/dist/chunk-QPAW6IYT.js +387 -0
- package/dist/{chunk-COI5VDFU.js → chunk-WA3DYGSY.js} +1 -2
- package/dist/{config-XYRBZJDU.js → config-A3LTECAY.js} +4 -3
- package/dist/context-UJCGYOT6.js +117 -0
- package/dist/doctor-MDTZWKBK.js +24 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +133 -22
- package/dist/info-7FKBTMVO.js +139 -0
- package/dist/install-v2-RINEA24K.js +3279 -0
- package/dist/{metrics-RER6NLFC.js → metrics-HMFH4YHK.js} +1 -1
- package/dist/{onboard-coverage-JWQWDZW7.js → onboard-coverage-XSG77LL3.js} +48 -27
- package/dist/plan-context-hint-5TNGH3R4.js +12 -0
- package/dist/{scope-explain-CDIZESP5.js → scope-explain-HLJZ2M33.js} +17 -6
- package/dist/status-4R3TM4FJ.js +37 -0
- package/dist/store-HOCORVL3.js +563 -0
- package/dist/{sync-UJ4BBCZJ.js → sync-DT5UJMMR.js} +197 -30
- package/dist/{uninstall-C3QXKOO6.js → uninstall-IFN2KYBK.js} +97 -140
- package/dist/whoami-ITGEFWH4.js +49 -0
- package/package.json +7 -5
- package/templates/hooks/cite-policy-evict.cjs +412 -160
- package/templates/hooks/configs/README.md +14 -27
- package/templates/hooks/configs/claude-code.json +17 -2
- package/templates/hooks/configs/codex-hooks.json +15 -3
- package/templates/hooks/fabric-hint.cjs +477 -176
- package/templates/hooks/knowledge-hint-broad.cjs +577 -274
- package/templates/hooks/knowledge-hint-narrow.cjs +113 -73
- package/templates/hooks/lib/banner-i18n.cjs +31 -0
- package/templates/hooks/lib/bindings-snapshot-reader.cjs +118 -7
- package/templates/hooks/lib/cite-line-parser.cjs +12 -20
- package/templates/hooks/lib/client-adapter.cjs +66 -7
- package/templates/hooks/lib/nudge-policy.cjs +117 -0
- package/templates/hooks/lib/state-store.cjs +60 -0
- package/templates/hooks/post-tooluse-mutation.cjs +386 -0
- package/templates/hooks/session-end-marker.cjs +140 -0
- package/templates/skills/fabric/SKILL.md +100 -0
- package/templates/skills/fabric-archive/SKILL.md +35 -24
- package/templates/skills/fabric-archive/ref/dry-run-scope.md +1 -1
- package/templates/skills/fabric-archive/ref/i18n-policy.md +2 -3
- package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +2 -3
- package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +1 -1
- package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +1 -1
- package/templates/skills/fabric-archive/ref/phase-3-6-related-edges.md +18 -0
- package/templates/skills/fabric-archive/ref/phase-3-7-semantic-scope.md +47 -0
- package/templates/skills/fabric-audit/SKILL.md +13 -3
- package/templates/skills/fabric-connect/SKILL.md +3 -3
- package/templates/skills/fabric-import/SKILL.md +7 -7
- package/templates/skills/fabric-import/ref/i18n-policy.md +2 -3
- package/templates/skills/fabric-import/ref/state-recovery.md +1 -2
- package/templates/skills/fabric-review/SKILL.md +14 -5
- package/templates/skills/fabric-review/ref/cite-contract.md +1 -1
- package/templates/skills/fabric-review/ref/i18n-policy.md +2 -3
- package/templates/skills/fabric-review/ref/output-contract.md +1 -1
- package/templates/skills/fabric-review/ref/per-mode-flows.md +2 -2
- package/templates/skills/fabric-review/ref/worked-examples.md +1 -1
- package/templates/skills/fabric-store/SKILL.md +1 -1
- package/templates/skills/fabric-sync/SKILL.md +1 -1
- package/templates/skills/lib/shared-policy.md +2 -2
- package/dist/chunk-4R2CYEA4.js +0 -116
- package/dist/chunk-L4Q55UC4.js +0 -52
- package/dist/chunk-LFIKMVY7.js +0 -27
- package/dist/chunk-RYAFBNES.js +0 -33
- package/dist/chunk-T5RPGCCM.js +0 -40
- package/dist/install-74ANPCCP.js +0 -2737
- package/dist/status-GLQWLWH6.js +0 -23
- package/dist/store-XB3ADT65.js +0 -144
- package/dist/whoami-2MLO4Y37.js +0 -36
- package/templates/hooks/configs/cursor-hooks.json +0 -18
- package/templates/hooks/lib/cite-contract-reminder.cjs +0 -179
- package/templates/hooks/lib/summary-fallback.cjs +0 -210
|
@@ -44,23 +44,6 @@ try {
|
|
|
44
44
|
citeLineParser = null;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
// v2.0.0-rc.24 TASK-05: L1 enforcement layer — soft Stop hook reminder for
|
|
48
|
-
// [recalled] cites of decision/pitfall types that arrived without operator
|
|
49
|
-
// contract or skip:<reason>. Reads .fabric/agents.meta.json (via
|
|
50
|
-
// lib/cite-contract-reminder.cjs#readKnowledgeTypeMap) to type-route cite
|
|
51
|
-
// ids per B6 lock; emits one
|
|
52
|
-
// ⚠ KB: <id> cited as [recalled] but missing contract; add → edit:<glob>
|
|
53
|
-
// or → skip:<reason> next turn
|
|
54
|
-
// line to stderr per offending id. Non-blocking, never throws.
|
|
55
|
-
let citeContractReminder = null;
|
|
56
|
-
try {
|
|
57
|
-
citeContractReminder = require("./lib/cite-contract-reminder.cjs");
|
|
58
|
-
} catch {
|
|
59
|
-
// Helper module missing — soft reminder simply doesn't fire. Audit-side
|
|
60
|
-
// doctor (TASK-08) still catches contract violations at the next run.
|
|
61
|
-
citeContractReminder = null;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
47
|
// v2.0.0-rc.37 NEW-30: shared client-protocol adapter. Guarded require (this
|
|
65
48
|
// hook runs in arbitrary user repos); detectClient delegates the 3-tier
|
|
66
49
|
// detection to the lib, falling back to env-only when the lib is absent.
|
|
@@ -71,6 +54,17 @@ try {
|
|
|
71
54
|
clientAdapter = null;
|
|
72
55
|
}
|
|
73
56
|
|
|
57
|
+
// v2.2 dual-sink (Goal A / D4): human-output gate for the archive nudge. The Stop
|
|
58
|
+
// archive nudge is SOFT (additionalContext, never decision:block — D3) and the
|
|
59
|
+
// human systemMessage is gated by nudge_mode. Optional require — absent → human
|
|
60
|
+
// always emits (legacy posture).
|
|
61
|
+
let nudgePolicy = null;
|
|
62
|
+
try {
|
|
63
|
+
nudgePolicy = require("./lib/nudge-policy.cjs");
|
|
64
|
+
} catch {
|
|
65
|
+
nudgePolicy = null;
|
|
66
|
+
}
|
|
67
|
+
|
|
74
68
|
// v2.0.0-rc.37 NEW-16: shared config + sidecar I/O for the per-signal dismiss
|
|
75
69
|
// feature (config-level durable opt-out + session-scoped sidecar). Guarded
|
|
76
70
|
// require (house style); dismiss simply doesn't fire if the lib is absent.
|
|
@@ -98,16 +92,115 @@ try {
|
|
|
98
92
|
bindingsSnapshotReader = null;
|
|
99
93
|
}
|
|
100
94
|
|
|
101
|
-
// Read the
|
|
102
|
-
|
|
95
|
+
// Read the workspace binding id (snapshot key) from project config. Standard
|
|
96
|
+
// repos default to project_id; worktrees can set workspace_binding_id to isolate
|
|
97
|
+
// hook/runtime state without changing project identity.
|
|
98
|
+
function readWorkspaceBindingId(cwd) {
|
|
103
99
|
try {
|
|
104
100
|
const parsed = JSON.parse(readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8"));
|
|
101
|
+
if (typeof parsed.workspace_binding_id === "string") return parsed.workspace_binding_id;
|
|
105
102
|
return typeof parsed.project_id === "string" ? parsed.project_id : null;
|
|
106
103
|
} catch {
|
|
107
104
|
return null;
|
|
108
105
|
}
|
|
109
106
|
}
|
|
110
107
|
|
|
108
|
+
function readSnapshotKnowledgeStats(projectRoot, now) {
|
|
109
|
+
const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
|
|
110
|
+
const empty = { pendingCount: 0, oldestPendingAgeMs: null, canonicalCount: 0 };
|
|
111
|
+
if (bindingsSnapshotReader === null) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const bindingId = readWorkspaceBindingId(projectRoot);
|
|
115
|
+
if (bindingId === null) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const snapshot = bindingsSnapshotReader.readBindingsSnapshot(bindingId);
|
|
120
|
+
// No snapshot file → empty corpus (KT-DEC-0007), preserving prior behavior.
|
|
121
|
+
if (!snapshot) {
|
|
122
|
+
return empty;
|
|
123
|
+
}
|
|
124
|
+
// LIVE recount off the snapshot's resolved store dirs. The cached
|
|
125
|
+
// knowledge_stats projection is frozen at snapshot-write time, so once the
|
|
126
|
+
// pending queue is reviewed (or store content syncs out-of-band) it goes
|
|
127
|
+
// stale — that is the phantom review-backlog this hook used to report
|
|
128
|
+
// (KT-PIT-0017). The authoritative count is the live *.md walk under the
|
|
129
|
+
// resolved store dirs.
|
|
130
|
+
const live = bindingsSnapshotReader.liveKnowledgeStats(snapshot);
|
|
131
|
+
// #3: a snapshot predating knowledge_store_dirs makes liveKnowledgeStats
|
|
132
|
+
// return null — counts are undeterminable and the cached projection is
|
|
133
|
+
// unreliable. Return `undefined` (a marker distinct from the `null` that
|
|
134
|
+
// lib/binding-absent returns, which readPendingStats uses as its legacy-
|
|
135
|
+
// fallback signal) so countCanonicalNodes maps it to "unknown" and the
|
|
136
|
+
// underseed signal SKIPS rather than false-firing on a stale corpus (snapshot
|
|
137
|
+
// self-heals on the next install/sync). Distinguished from the missing-
|
|
138
|
+
// snapshot case above, which stays `empty` (genuine fresh-project zero).
|
|
139
|
+
if (live === null) {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
const pendingCount =
|
|
143
|
+
Number.isFinite(live.pendingCount) && live.pendingCount > 0 ? Math.floor(live.pendingCount) : 0;
|
|
144
|
+
const canonicalCount =
|
|
145
|
+
Number.isFinite(live.canonicalCount) && live.canonicalCount > 0
|
|
146
|
+
? Math.floor(live.canonicalCount)
|
|
147
|
+
: 0;
|
|
148
|
+
const oldestPendingAgeMs =
|
|
149
|
+
pendingCount > 0 &&
|
|
150
|
+
Number.isFinite(live.oldestPendingMtimeMs) &&
|
|
151
|
+
live.oldestPendingMtimeMs > 0
|
|
152
|
+
? Math.max(0, nowMs - live.oldestPendingMtimeMs)
|
|
153
|
+
: null;
|
|
154
|
+
return { pendingCount, oldestPendingAgeMs, canonicalCount };
|
|
155
|
+
} catch {
|
|
156
|
+
return empty;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function readLegacyPendingStats(projectRoot, now) {
|
|
161
|
+
const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
|
|
162
|
+
const baseDir = join(projectRoot, FABRIC_DIR, PENDING_DIR);
|
|
163
|
+
|
|
164
|
+
let count = 0;
|
|
165
|
+
let oldestMtime = null;
|
|
166
|
+
|
|
167
|
+
if (!existsSync(baseDir)) {
|
|
168
|
+
return { count: 0, oldestAgeMs: null };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const type of PENDING_TYPES) {
|
|
172
|
+
const typeDir = join(baseDir, type);
|
|
173
|
+
if (!existsSync(typeDir)) continue;
|
|
174
|
+
|
|
175
|
+
let entries;
|
|
176
|
+
try {
|
|
177
|
+
entries = readdirSync(typeDir);
|
|
178
|
+
} catch {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const entry of entries) {
|
|
183
|
+
if (!entry.endsWith(".md")) continue;
|
|
184
|
+
const filePath = join(typeDir, entry);
|
|
185
|
+
let mtime;
|
|
186
|
+
try {
|
|
187
|
+
mtime = statSync(filePath).mtimeMs;
|
|
188
|
+
} catch {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
count += 1;
|
|
192
|
+
if (oldestMtime === null || mtime < oldestMtime) {
|
|
193
|
+
oldestMtime = mtime;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
count,
|
|
200
|
+
oldestAgeMs: count > 0 && oldestMtime !== null ? nowMs - oldestMtime : null,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
111
204
|
// CONSTANTS — duplicated from packages/server/src/services/_shared.ts.
|
|
112
205
|
// DRY violation accepted: this hook script runs in user repos WITHOUT
|
|
113
206
|
// node_modules access, so it cannot import from @fenglimg/fabric-server.
|
|
@@ -125,6 +218,59 @@ const EVENT_LEDGER_FILE = "events.jsonl";
|
|
|
125
218
|
const METRICS_LEDGER_FILE = "metrics.jsonl";
|
|
126
219
|
const EVENT_TYPE_PROPOSED = "knowledge_proposed";
|
|
127
220
|
const EVENT_TYPE_INIT_SCAN_COMPLETED = "init_scan_completed";
|
|
221
|
+
|
|
222
|
+
// v2.2 dual-sink (Goal A / D6): deterministic high-value probe for the archive
|
|
223
|
+
// nudge value-gate. Mirrors packages/server/src/services/archive-scan.ts
|
|
224
|
+
// (hasHighValueSignal) — the hook replicates the SAME deterministic ledger probe
|
|
225
|
+
// rather than running the semantic archive-scan, staying within the Hook⊥MCP
|
|
226
|
+
// boundary (the hook reads events.jsonl mechanically; it never judges relevance).
|
|
227
|
+
// Keep these two literal sets in sync with archive-scan.ts.
|
|
228
|
+
const ARCHIVE_HIGH_VALUE_EVENT_TYPES = new Set([
|
|
229
|
+
"knowledge_context_planned",
|
|
230
|
+
"edit_paths_recorded",
|
|
231
|
+
"edit_intent_checked",
|
|
232
|
+
]);
|
|
233
|
+
const ARCHIVE_NORMATIVE_KEYWORDS = [
|
|
234
|
+
"以后",
|
|
235
|
+
"always",
|
|
236
|
+
"never",
|
|
237
|
+
"from now on",
|
|
238
|
+
"下次",
|
|
239
|
+
"记一下",
|
|
240
|
+
"永远不要",
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
// v2.2 dual-sink (Goal A / D6): does the ledger carry a high-value archive signal
|
|
244
|
+
// since the watermark (last knowledge_proposed)? True iff any HIGH_VALUE event
|
|
245
|
+
// fired past the watermark, OR the latest assistant_turn carries a normative
|
|
246
|
+
// keyword. Deterministic — no semantic judgement. Used to VALUE-GATE the archive
|
|
247
|
+
// nudge so the check cadence (edits/hours) is decoupled from the nudge cadence
|
|
248
|
+
// (D6): a workspace that crossed the edit threshold but produced no high-value
|
|
249
|
+
// signal stays quiet. watermarkTs null (never archived) → treat all events as
|
|
250
|
+
// past-watermark (a never-archived repo with any edit signal is worth nudging).
|
|
251
|
+
function hasHighValueArchiveSignal(events, watermarkTs) {
|
|
252
|
+
if (!Array.isArray(events)) return false;
|
|
253
|
+
const wm = typeof watermarkTs === "number" ? watermarkTs : 0;
|
|
254
|
+
let latestTurn = null;
|
|
255
|
+
for (const e of events) {
|
|
256
|
+
if (!e || typeof e.ts !== "number" || e.ts <= wm) continue;
|
|
257
|
+
if (typeof e.event_type === "string" && ARCHIVE_HIGH_VALUE_EVENT_TYPES.has(e.event_type)) {
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
if (e.event_type === "assistant_turn_observed") {
|
|
261
|
+
if (latestTurn === null || (typeof latestTurn.ts === "number" && e.ts > latestTurn.ts)) {
|
|
262
|
+
latestTurn = e;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (latestTurn !== null) {
|
|
267
|
+
const haystack = JSON.stringify(latestTurn).toLowerCase();
|
|
268
|
+
for (const kw of ARCHIVE_NORMATIVE_KEYWORDS) {
|
|
269
|
+
if (haystack.includes(kw.toLowerCase())) return true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
128
274
|
// v2.0.0-rc.7 T10: doctor_run event drives Signal D (maintenance hint).
|
|
129
275
|
const EVENT_TYPE_DOCTOR_RUN = "doctor_run";
|
|
130
276
|
// v2.0.0-rc.20 TASK-03: per-turn cite-policy observation event. Emitted by
|
|
@@ -267,92 +413,40 @@ function readLedger(projectRoot) {
|
|
|
267
413
|
}
|
|
268
414
|
|
|
269
415
|
/**
|
|
270
|
-
*
|
|
271
|
-
* PENDING_TYPES subdirs, collecting count and oldest mtime.
|
|
416
|
+
* Read pending counts from the CLI-generated resolved-bindings snapshot.
|
|
272
417
|
*
|
|
273
418
|
* Returns { count, oldestAgeMs } where:
|
|
274
419
|
* - count: total .md file count across all type subdirs
|
|
275
420
|
* - oldestAgeMs: (nowMs - oldestMtimeMs) when count>0, else null
|
|
276
421
|
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
422
|
+
* Store-only cutover: hooks never walk project-local knowledge or store
|
|
423
|
+
* trees. Missing snapshot stats degrade to zero (KT-DEC-0007).
|
|
279
424
|
*/
|
|
280
425
|
function readPendingStats(projectRoot, now) {
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (!existsSync(baseDir)) {
|
|
288
|
-
return { count: 0, oldestAgeMs: null };
|
|
426
|
+
const stats = readSnapshotKnowledgeStats(projectRoot, now);
|
|
427
|
+
// `!= null` (loose) also catches the `undefined` old-snapshot marker (#3) →
|
|
428
|
+
// fall through to the legacy reader (which degrades to 0 → no phantom review
|
|
429
|
+
// nudge), rather than dereferencing pendingCount on a non-object.
|
|
430
|
+
if (stats != null) {
|
|
431
|
+
return { count: stats.pendingCount, oldestAgeMs: stats.oldestPendingAgeMs };
|
|
289
432
|
}
|
|
290
|
-
|
|
291
|
-
for (const type of PENDING_TYPES) {
|
|
292
|
-
const typeDir = join(baseDir, type);
|
|
293
|
-
if (!existsSync(typeDir)) continue;
|
|
294
|
-
|
|
295
|
-
let entries;
|
|
296
|
-
try {
|
|
297
|
-
entries = readdirSync(typeDir);
|
|
298
|
-
} catch {
|
|
299
|
-
continue;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
for (const entry of entries) {
|
|
303
|
-
if (!entry.endsWith(".md")) continue;
|
|
304
|
-
const filePath = join(typeDir, entry);
|
|
305
|
-
let mtime;
|
|
306
|
-
try {
|
|
307
|
-
mtime = statSync(filePath).mtimeMs;
|
|
308
|
-
} catch {
|
|
309
|
-
continue;
|
|
310
|
-
}
|
|
311
|
-
count += 1;
|
|
312
|
-
if (oldestMtime === null || mtime < oldestMtime) {
|
|
313
|
-
oldestMtime = mtime;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
return {
|
|
319
|
-
count,
|
|
320
|
-
oldestAgeMs: count > 0 && oldestMtime !== null ? nowMs - oldestMtime : null,
|
|
321
|
-
};
|
|
433
|
+
return readLegacyPendingStats(projectRoot, now);
|
|
322
434
|
}
|
|
323
435
|
|
|
324
436
|
/**
|
|
325
|
-
* Count canonical knowledge entries
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
* Returns the integer count. ENOENT / unreadable subdir → silently treated as
|
|
330
|
-
* zero (preserves never-block-on-failure invariant). Filters on `.md` suffix
|
|
331
|
-
* only; the more-precise canonical filename pattern check is owned by
|
|
332
|
-
* doctor.ts (the hook is a coarse signal, not a lint).
|
|
437
|
+
* Count canonical knowledge entries from the CLI-generated resolved-bindings
|
|
438
|
+
* snapshot. Store-only: hooks never walk project-local knowledge or store
|
|
439
|
+
* trees — a missing snapshot degrades to zero (KT-DEC-0007).
|
|
333
440
|
*/
|
|
334
441
|
function countCanonicalNodes(projectRoot) {
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
for
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
let entries;
|
|
344
|
-
try {
|
|
345
|
-
entries = readdirSync(typeDir);
|
|
346
|
-
} catch {
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
for (const entry of entries) {
|
|
350
|
-
if (entry.endsWith(".md")) {
|
|
351
|
-
count += 1;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
return count;
|
|
442
|
+
const stats = readSnapshotKnowledgeStats(projectRoot);
|
|
443
|
+
// #3: `undefined` = snapshot EXISTS but predates knowledge_store_dirs →
|
|
444
|
+
// undeterminable → return null so decide()'s underseed signal SKIPS rather than
|
|
445
|
+
// false-firing on a stale corpus. `null` = no reader / not bound → degrade to 0
|
|
446
|
+
// (KT-DEC-0007, preserved). The `empty` object (missing snapshot) → canonical 0,
|
|
447
|
+
// still firing correctly for a genuinely fresh corpus.
|
|
448
|
+
if (stats === undefined) return null;
|
|
449
|
+
return stats === null ? 0 : stats.canonicalCount;
|
|
356
450
|
}
|
|
357
451
|
|
|
358
452
|
/**
|
|
@@ -411,6 +505,81 @@ function countEditsSince(projectRoot, anchorTs) {
|
|
|
411
505
|
return count;
|
|
412
506
|
}
|
|
413
507
|
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
// Observability grill (a + Q4): session-activity status breadcrumb.
|
|
510
|
+
//
|
|
511
|
+
// A no-signal Stop used to return SILENT — the human only ever heard from
|
|
512
|
+
// Fabric when there was a nudge to act on, never a "here is what I did" recap,
|
|
513
|
+
// which reads as "Fabric does nothing in the background". These two helpers add
|
|
514
|
+
// a HUMAN-ONLY trust anchor (the AI gets no activity recap — flow ⊥ observation,
|
|
515
|
+
// D5) plus the nudge_mode tier-guidance line (so the human-channel volume knob
|
|
516
|
+
// is discoverable). Cadence is gated by nudge_mode at emit time.
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
// Session-scoped tally. Counts ONLY events that carry session_id, filtered to
|
|
520
|
+
// the current session — knowledge_context_planned / knowledge_proposed lack
|
|
521
|
+
// session_id and are intentionally excluded (a cross-session count would
|
|
522
|
+
// mislead). Exported for unit tests.
|
|
523
|
+
function tallySessionActivity(events, sessionId) {
|
|
524
|
+
let edits = 0;
|
|
525
|
+
let consumed = 0;
|
|
526
|
+
if (!Array.isArray(events) || typeof sessionId !== "string" || sessionId.length === 0) {
|
|
527
|
+
return { edits, consumed };
|
|
528
|
+
}
|
|
529
|
+
for (const ev of events) {
|
|
530
|
+
if (!ev || ev.session_id !== sessionId) continue;
|
|
531
|
+
if (ev.event_type === "file_mutated") edits += 1;
|
|
532
|
+
else if (ev.event_type === "knowledge_consumed") consumed += 1;
|
|
533
|
+
}
|
|
534
|
+
return { edits, consumed };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Emit the human-facing session status breadcrumb when no actionable signal
|
|
538
|
+
// fired. Human sink ONLY. Cadence by nudge_mode: silent → never; minimal/normal
|
|
539
|
+
// → once per session; verbose → every turn. Folds in the tier-guidance line on
|
|
540
|
+
// the first status of the session so the volume knob is discoverable. Never
|
|
541
|
+
// throws — the caller wraps it, but every branch degrades silently anyway.
|
|
542
|
+
function emitSessionStatus(cwd, events, stdinPayload, nowMs, pendingStats, out) {
|
|
543
|
+
if (nudgePolicy === null || clientAdapter === null) return;
|
|
544
|
+
if (typeof clientAdapter.emitDualSink !== "function") return;
|
|
545
|
+
const sessionId = resolveHookSessionId(stdinPayload);
|
|
546
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) return;
|
|
547
|
+
|
|
548
|
+
const mode =
|
|
549
|
+
typeof nudgePolicy.readNudgeMode === "function" ? nudgePolicy.readNudgeMode(cwd) : "normal";
|
|
550
|
+
if (mode === "silent") return; // human channel globally muted
|
|
551
|
+
|
|
552
|
+
const tally = tallySessionActivity(events, sessionId);
|
|
553
|
+
const pending = pendingStats && typeof pendingStats.total === "number" ? pendingStats.total : 0;
|
|
554
|
+
// Nothing happened yet this session AND no backlog → no trust anchor to show.
|
|
555
|
+
if (tally.edits === 0 && tally.consumed === 0 && pending === 0) return;
|
|
556
|
+
|
|
557
|
+
// Cadence gate: normal/minimal show once per session; verbose every turn.
|
|
558
|
+
const cache = readShownCache(cwd, sessionId);
|
|
559
|
+
const firstThisSession = cache._status === undefined;
|
|
560
|
+
if (mode !== "verbose" && !firstThisSession) return;
|
|
561
|
+
|
|
562
|
+
const variant = readFabricLanguage(cwd);
|
|
563
|
+
const line1 = renderBanner("statusLine", variant, {
|
|
564
|
+
edits: tally.edits,
|
|
565
|
+
consumed: tally.consumed,
|
|
566
|
+
pending,
|
|
567
|
+
});
|
|
568
|
+
// Tier guidance only on the first status of the session (don't repeat it on
|
|
569
|
+
// every verbose turn).
|
|
570
|
+
const human = firstThisSession
|
|
571
|
+
? `${line1}\n${renderBanner("statusTier", variant, { mode })}`
|
|
572
|
+
: line1;
|
|
573
|
+
|
|
574
|
+
clientAdapter.emitDualSink(
|
|
575
|
+
{ human, ai: null },
|
|
576
|
+
{ client: clientAdapter.detectClient(__dirname), eventName: "Stop", streams: { stdout: out } },
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
cache._status = nowMs;
|
|
580
|
+
writeShownCache(cwd, cache, sessionId);
|
|
581
|
+
}
|
|
582
|
+
|
|
414
583
|
/**
|
|
415
584
|
* v2.0.0-rc.8 (TASK-002): detect whether a fabric-import skill run is
|
|
416
585
|
* currently in flight, used to gate Signal B (review hint) so the Stop
|
|
@@ -862,6 +1031,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
862
1031
|
lastInitScanTs === null ? null : (nowMs - lastInitScanTs) / MS_PER_HOUR;
|
|
863
1032
|
const hoursSinceProposed = hoursElapsed; // reuse archive-signal calc above
|
|
864
1033
|
const triggerUnderseed =
|
|
1034
|
+
// #3: null = undeterminable canonical count (old snapshot) → skip. Guard
|
|
1035
|
+
// first because `null < threshold` coerces to true in JS and would else
|
|
1036
|
+
// false-fire the underseed nudge on a stale corpus.
|
|
1037
|
+
underseed.nodeCount != null &&
|
|
865
1038
|
underseed.nodeCount < underseed.threshold &&
|
|
866
1039
|
hoursSinceInit !== null &&
|
|
867
1040
|
hoursSinceInit >= UNDERSEED_POST_INIT_QUIET_HOURS &&
|
|
@@ -984,8 +1157,34 @@ function readUnderseedThreshold(projectRoot) {
|
|
|
984
1157
|
return DEFAULT_UNDERSEED_NODE_THRESHOLD;
|
|
985
1158
|
}
|
|
986
1159
|
|
|
987
|
-
|
|
988
|
-
|
|
1160
|
+
// F13 (ISS-20260531-038): the reminder cooldown sidecars were process-global
|
|
1161
|
+
// (one file per project, no session key), so in concurrent multi-window sessions
|
|
1162
|
+
// one window firing a nudge wrote the cooldown and silenced that nudge in EVERY
|
|
1163
|
+
// other window. Scope the sidecar filename by sessionId — mirrors the already-
|
|
1164
|
+
// session-scoped dismiss sidecar (sessionDismissFileName). Backward-compatible:
|
|
1165
|
+
// a null/absent sessionId falls back to the legacy non-scoped path (upgrade +
|
|
1166
|
+
// pre-session-id callers), so existing on-disk state and tests are unaffected;
|
|
1167
|
+
// the Stop hook always passes the real session_id from its stdin payload.
|
|
1168
|
+
function resolveHookSessionId(payload) {
|
|
1169
|
+
return payload && typeof payload.session_id === "string" && payload.session_id.length > 0
|
|
1170
|
+
? payload.session_id
|
|
1171
|
+
: null;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function sessionScopedCacheFile(baseRelPath, sessionId) {
|
|
1175
|
+
if (sessionId === undefined || sessionId === null || String(sessionId).length === 0) {
|
|
1176
|
+
return baseRelPath;
|
|
1177
|
+
}
|
|
1178
|
+
const safe = String(sessionId).replace(/[^A-Za-z0-9_.-]/g, "-");
|
|
1179
|
+
const lastSlash = baseRelPath.lastIndexOf("/");
|
|
1180
|
+
const dot = baseRelPath.lastIndexOf(".");
|
|
1181
|
+
return dot > lastSlash
|
|
1182
|
+
? `${baseRelPath.slice(0, dot)}-${safe}${baseRelPath.slice(dot)}`
|
|
1183
|
+
: `${baseRelPath}-${safe}`;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function readShownCache(projectRoot, sessionId) {
|
|
1187
|
+
const cachePath = join(projectRoot, sessionScopedCacheFile(SHOWN_CACHE_FILE, sessionId));
|
|
989
1188
|
if (!existsSync(cachePath)) return {};
|
|
990
1189
|
try {
|
|
991
1190
|
const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
@@ -995,12 +1194,11 @@ function readShownCache(projectRoot) {
|
|
|
995
1194
|
}
|
|
996
1195
|
}
|
|
997
1196
|
|
|
998
|
-
function writeShownCache(projectRoot, cache) {
|
|
999
|
-
const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
|
|
1197
|
+
function writeShownCache(projectRoot, cache, sessionId) {
|
|
1198
|
+
const cachePath = join(projectRoot, sessionScopedCacheFile(SHOWN_CACHE_FILE, sessionId));
|
|
1000
1199
|
try {
|
|
1001
|
-
// ISS-016: atomic tmp+rename so
|
|
1002
|
-
//
|
|
1003
|
-
// plain write only if the shared lib failed to load.
|
|
1200
|
+
// ISS-016: atomic tmp+rename so a crash never leaves a truncated shown-cache.
|
|
1201
|
+
// Falls back to a plain write only if the shared lib failed to load.
|
|
1004
1202
|
if (stateStore && typeof stateStore.atomicWrite === "function") {
|
|
1005
1203
|
stateStore.atomicWrite(cachePath, JSON.stringify(cache));
|
|
1006
1204
|
} else {
|
|
@@ -1124,8 +1322,8 @@ function findLastDoctorRunTs(events) {
|
|
|
1124
1322
|
* v2.0.0-rc.7 T10: read the Signal-D cooldown sidecar timestamp (epoch ms).
|
|
1125
1323
|
* Missing file / parse failure → null (allow signal to fire).
|
|
1126
1324
|
*/
|
|
1127
|
-
function readMaintenanceLastEmit(projectRoot) {
|
|
1128
|
-
const p = join(projectRoot, MAINTENANCE_HINT_LAST_EMIT_FILE);
|
|
1325
|
+
function readMaintenanceLastEmit(projectRoot, sessionId) {
|
|
1326
|
+
const p = join(projectRoot, sessionScopedCacheFile(MAINTENANCE_HINT_LAST_EMIT_FILE, sessionId));
|
|
1129
1327
|
if (!existsSync(p)) return null;
|
|
1130
1328
|
try {
|
|
1131
1329
|
const raw = readFileSync(p, "utf8").trim();
|
|
@@ -1140,8 +1338,8 @@ function readMaintenanceLastEmit(projectRoot) {
|
|
|
1140
1338
|
return null;
|
|
1141
1339
|
}
|
|
1142
1340
|
|
|
1143
|
-
function writeMaintenanceLastEmit(projectRoot, nowMs) {
|
|
1144
|
-
const p = join(projectRoot, MAINTENANCE_HINT_LAST_EMIT_FILE);
|
|
1341
|
+
function writeMaintenanceLastEmit(projectRoot, nowMs, sessionId) {
|
|
1342
|
+
const p = join(projectRoot, sessionScopedCacheFile(MAINTENANCE_HINT_LAST_EMIT_FILE, sessionId));
|
|
1145
1343
|
try {
|
|
1146
1344
|
// ISS-016: atomic tmp+rename (see writeShownCache).
|
|
1147
1345
|
if (stateStore && typeof stateStore.atomicWrite === "function") {
|
|
@@ -1246,6 +1444,101 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
|
|
|
1246
1444
|
};
|
|
1247
1445
|
}
|
|
1248
1446
|
|
|
1447
|
+
// lifecycle-refactor W3-A2 (§7 graph generation signal): after a successful
|
|
1448
|
+
// archive the Stop hook REQUESTS edge extraction by emitting one
|
|
1449
|
+
// graph_edge_candidate_requested{stable_id, store?}. The hook never PRODUCES
|
|
1450
|
+
// edges (that is the archive/import skill's or doctor co-occurrence's job,
|
|
1451
|
+
// KT-DEC-0007) — it only flags "this entry just landed; someone should extract
|
|
1452
|
+
// its `related` edges". FROZEN-safe: O(1) tail scan, best-effort silent, single
|
|
1453
|
+
// advisory-locked appendLockedLine (same primitive the rest of this hook uses).
|
|
1454
|
+
//
|
|
1455
|
+
// HONEST stable_id sourcing — the deliberate limitation: pending entries (the
|
|
1456
|
+
// fabric-archive → extractKnowledge path) carry NO canonical stable_id (id is
|
|
1457
|
+
// late-bound at fab_review approve), so their knowledge_proposed event omits
|
|
1458
|
+
// stable_id (or sets the `pending:<key>` sentinel). A graph edge between
|
|
1459
|
+
// id-less pending drafts is meaningless, so we DO NOT fabricate one. We emit
|
|
1460
|
+
// ONLY when the most-recent knowledge_proposed event carries a real
|
|
1461
|
+
// K[TP]-XXX-NNNN stable_id (the approve/promote path) — i.e. an entry that
|
|
1462
|
+
// actually has a canonical node to attach edges to. When the latest proposed
|
|
1463
|
+
// is id-less we honestly skip; the request will fire on the approve event that
|
|
1464
|
+
// allocates the id. A session-scoped sidecar de-dupes so repeated Stop fires in
|
|
1465
|
+
// one session don't re-request the same id.
|
|
1466
|
+
const STABLE_ID_RE = /^K[TP]-[A-Z]{3}-\d{4}$/;
|
|
1467
|
+
const GRAPH_EDGE_REQUESTED_SIDECAR = ".fabric/.cache/graph-edge-requested";
|
|
1468
|
+
|
|
1469
|
+
function emitGraphEdgeCandidateBestEffort(cwd, events, sessionId) {
|
|
1470
|
+
try {
|
|
1471
|
+
if (!Array.isArray(events) || events.length === 0) return;
|
|
1472
|
+
const fabricDir = join(cwd, FABRIC_DIR);
|
|
1473
|
+
if (!existsSync(fabricDir)) return;
|
|
1474
|
+
|
|
1475
|
+
// O(1)-amortized tail scan for the newest knowledge_proposed carrying a
|
|
1476
|
+
// real (non-sentinel) stable_id. Stop at the first knowledge_proposed we
|
|
1477
|
+
// see — if the latest archive is id-less, we honestly skip rather than
|
|
1478
|
+
// reaching back to an older approved entry (that older entry's edges were
|
|
1479
|
+
// already requested when IT landed).
|
|
1480
|
+
let stableId = null;
|
|
1481
|
+
let store;
|
|
1482
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
1483
|
+
const ev = events[i];
|
|
1484
|
+
if (!ev || ev.event_type !== EVENT_TYPE_PROPOSED) continue;
|
|
1485
|
+
const candidate = typeof ev.stable_id === "string" ? ev.stable_id : null;
|
|
1486
|
+
if (candidate && STABLE_ID_RE.test(candidate)) {
|
|
1487
|
+
stableId = candidate;
|
|
1488
|
+
if (typeof ev.store === "string" && ev.store.length > 0) store = ev.store;
|
|
1489
|
+
}
|
|
1490
|
+
// First knowledge_proposed encountered (newest) decides; do not walk past
|
|
1491
|
+
// it to an older one.
|
|
1492
|
+
break;
|
|
1493
|
+
}
|
|
1494
|
+
if (stableId === null) return;
|
|
1495
|
+
|
|
1496
|
+
// Session-scoped de-dup: skip if we already requested edges for this exact
|
|
1497
|
+
// stable_id this session. Sidecar is a single line holding the last id.
|
|
1498
|
+
const sidecarPath = join(cwd, sessionScopedCacheFile(GRAPH_EDGE_REQUESTED_SIDECAR, sessionId));
|
|
1499
|
+
try {
|
|
1500
|
+
if (existsSync(sidecarPath)) {
|
|
1501
|
+
const prev = readFileSync(sidecarPath, "utf8").trim();
|
|
1502
|
+
if (prev === stableId) return;
|
|
1503
|
+
}
|
|
1504
|
+
} catch {
|
|
1505
|
+
// unreadable sidecar → fall through and (re)emit; de-dup is best-effort.
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
let idSuffix;
|
|
1509
|
+
try {
|
|
1510
|
+
idSuffix = require("node:crypto").randomUUID();
|
|
1511
|
+
} catch {
|
|
1512
|
+
idSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
1513
|
+
}
|
|
1514
|
+
const event = {
|
|
1515
|
+
kind: "fabric-event",
|
|
1516
|
+
id: `event:${idSuffix}`,
|
|
1517
|
+
ts: Date.now(),
|
|
1518
|
+
schema_version: 1,
|
|
1519
|
+
event_type: "graph_edge_candidate_requested",
|
|
1520
|
+
stable_id: stableId,
|
|
1521
|
+
};
|
|
1522
|
+
if (store !== undefined) event.store = store;
|
|
1523
|
+
if (typeof sessionId === "string" && sessionId.length > 0) event.session_id = sessionId;
|
|
1524
|
+
appendLockedLine(join(fabricDir, EVENT_LEDGER_FILE), JSON.stringify(event) + "\n");
|
|
1525
|
+
|
|
1526
|
+
// Record the de-dup marker (best-effort; atomic when state-store lib loaded).
|
|
1527
|
+
try {
|
|
1528
|
+
if (stateStore && typeof stateStore.atomicWrite === "function") {
|
|
1529
|
+
stateStore.atomicWrite(sidecarPath, stableId);
|
|
1530
|
+
} else {
|
|
1531
|
+
mkdirSync(dirname(sidecarPath), { recursive: true });
|
|
1532
|
+
writeFileSync(sidecarPath, stableId);
|
|
1533
|
+
}
|
|
1534
|
+
} catch {
|
|
1535
|
+
// de-dup marker write failed — at worst we re-request next Stop; harmless.
|
|
1536
|
+
}
|
|
1537
|
+
} catch {
|
|
1538
|
+
// best-effort §7 signal — never block the Stop hook (KT-DEC-0007).
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1249
1542
|
// v2.1 NEW-N-3 (ADJ-NEWN-3): hook_signal_emitted instrumentation. Writes ONE
|
|
1250
1543
|
// best-effort ledger row at the point a nudge is actually delivered (post-
|
|
1251
1544
|
// cooldown), so the join key measures nudge-trigger logic (which signal fired,
|
|
@@ -1390,14 +1683,13 @@ function parseKbLine(raw) {
|
|
|
1390
1683
|
* "codex". Covers the dominant deployment shape (hook script lives
|
|
1391
1684
|
* under the client's per-repo dir).
|
|
1392
1685
|
*
|
|
1393
|
-
* Returns `undefined` when neither signal fires (
|
|
1394
|
-
*
|
|
1395
|
-
* so omitting it leaves the event valid.
|
|
1686
|
+
* Returns `undefined` when neither signal fires (a custom deployment). The
|
|
1687
|
+
* Zod schema marks `client` optional, so omitting it leaves the event valid.
|
|
1396
1688
|
*/
|
|
1397
1689
|
function detectClient() {
|
|
1398
1690
|
// Delegate the full 3-tier detection (env → CLAUDE_PROJECT_DIR → path
|
|
1399
|
-
// heuristic
|
|
1400
|
-
//
|
|
1691
|
+
// heuristic) to the shared adapter. __dirname is passed so the path
|
|
1692
|
+
// heuristic reflects THIS hook's location.
|
|
1401
1693
|
if (clientAdapter && typeof clientAdapter.detectClient === "function") {
|
|
1402
1694
|
return clientAdapter.detectClient(__dirname);
|
|
1403
1695
|
}
|
|
@@ -1405,7 +1697,7 @@ function detectClient() {
|
|
|
1405
1697
|
const envClient = process.env.FABRIC_HINT_CLIENT;
|
|
1406
1698
|
if (typeof envClient === "string" && envClient.length > 0) {
|
|
1407
1699
|
const normalised = envClient.trim().toLowerCase();
|
|
1408
|
-
if (normalised === "cc" || normalised === "codex"
|
|
1700
|
+
if (normalised === "cc" || normalised === "codex") {
|
|
1409
1701
|
return normalised;
|
|
1410
1702
|
}
|
|
1411
1703
|
}
|
|
@@ -1744,50 +2036,6 @@ function summarizeTranscript(transcriptPath) {
|
|
|
1744
2036
|
return out;
|
|
1745
2037
|
}
|
|
1746
2038
|
|
|
1747
|
-
/**
|
|
1748
|
-
* v2.0.0-rc.24 TASK-05: emit soft L1 reminder to stderr when assistant turns
|
|
1749
|
-
* cited a decision/pitfall id with [recalled] but no operator contract and no
|
|
1750
|
-
* skip:<reason>. Reads agents.meta.json once per invocation; aggregated per
|
|
1751
|
-
* turn (one line per offending id). Non-blocking — never throws, always
|
|
1752
|
-
* returns the array of emitted reminder strings (for unit tests + callers
|
|
1753
|
-
* that want to observe what was written).
|
|
1754
|
-
*
|
|
1755
|
-
* The reminder writes go to stderr (the hook contract: stdout is structured
|
|
1756
|
-
* banner JSON consumed by the harness; stderr is free-text system message
|
|
1757
|
-
* that surfaces back to the model on the next turn in cc / codex / cursor).
|
|
1758
|
-
*/
|
|
1759
|
-
function emitCiteContractRemindersBestEffort(cwd, stdinPayload, stderr) {
|
|
1760
|
-
if (citeContractReminder === null) return [];
|
|
1761
|
-
if (stdinPayload === null || typeof stdinPayload !== "object") return [];
|
|
1762
|
-
try {
|
|
1763
|
-
const transcript = summarizeTranscript(stdinPayload.transcript_path);
|
|
1764
|
-
const turns = transcript.assistant_turns;
|
|
1765
|
-
if (!Array.isArray(turns) || turns.length === 0) return [];
|
|
1766
|
-
|
|
1767
|
-
const idTypeMap = citeContractReminder.readKnowledgeTypeMap(cwd);
|
|
1768
|
-
if (!(idTypeMap instanceof Map) || idTypeMap.size === 0) return [];
|
|
1769
|
-
|
|
1770
|
-
const reminders = citeContractReminder.formatContractMissingReminders({
|
|
1771
|
-
assistant_turns: turns,
|
|
1772
|
-
idTypeMap,
|
|
1773
|
-
});
|
|
1774
|
-
if (!Array.isArray(reminders) || reminders.length === 0) return [];
|
|
1775
|
-
|
|
1776
|
-
const sink = stderr || process.stderr;
|
|
1777
|
-
for (const line of reminders) {
|
|
1778
|
-
try {
|
|
1779
|
-
sink.write(line + "\n");
|
|
1780
|
-
} catch {
|
|
1781
|
-
// Sink write failure must not abort emission of remaining reminders.
|
|
1782
|
-
}
|
|
1783
|
-
}
|
|
1784
|
-
return reminders;
|
|
1785
|
-
} catch {
|
|
1786
|
-
// Outer guard — never throw. Hook continues silently.
|
|
1787
|
-
return [];
|
|
1788
|
-
}
|
|
1789
|
-
}
|
|
1790
|
-
|
|
1791
2039
|
/**
|
|
1792
2040
|
* v2.0.0-rc.7 T5: writeSessionDigestBestEffort — non-blocking digest fan-out.
|
|
1793
2041
|
* Called from main() before the existing decide() flow. Failure is silently
|
|
@@ -1841,17 +2089,18 @@ function main(env, stdio) {
|
|
|
1841
2089
|
// the hook's other I/O).
|
|
1842
2090
|
extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload);
|
|
1843
2091
|
|
|
1844
|
-
// v2.0.0-rc.24 TASK-05: L1 soft reminder layer. Surfaces ⚠ KB:<id> lines
|
|
1845
|
-
// to stderr when decision/pitfall cites arrived with [recalled] tag but
|
|
1846
|
-
// empty contract. Non-blocking, never throws; doctor (TASK-08) catches
|
|
1847
|
-
// any contract violation the model ignored.
|
|
1848
|
-
emitCiteContractRemindersBestEffort(
|
|
1849
|
-
cwd,
|
|
1850
|
-
stdinPayload,
|
|
1851
|
-
stdio && stdio.stderr,
|
|
1852
|
-
);
|
|
1853
|
-
|
|
1854
2092
|
const events = readLedger(cwd);
|
|
2093
|
+
|
|
2094
|
+
// lifecycle-refactor W3-A2 (§7): request graph-edge extraction for a freshly
|
|
2095
|
+
// archived canonical entry. Runs UNCONDITIONALLY here (before the nudge
|
|
2096
|
+
// cooldown/dismiss early-returns) so the §7 signal is independent of whether
|
|
2097
|
+
// a reminder banner is shown this Stop. Best-effort, never throws.
|
|
2098
|
+
try {
|
|
2099
|
+
emitGraphEdgeCandidateBestEffort(cwd, events, resolveHookSessionId(stdinPayload));
|
|
2100
|
+
} catch {
|
|
2101
|
+
// never block the Stop hook
|
|
2102
|
+
}
|
|
2103
|
+
|
|
1855
2104
|
let pendingStats;
|
|
1856
2105
|
try {
|
|
1857
2106
|
pendingStats = readPendingStats(cwd, now);
|
|
@@ -1984,7 +2233,7 @@ function main(env, stdio) {
|
|
|
1984
2233
|
// for the prompt to be actionable.
|
|
1985
2234
|
if (result === null) {
|
|
1986
2235
|
try {
|
|
1987
|
-
const lastEmit = readMaintenanceLastEmit(cwd);
|
|
2236
|
+
const lastEmit = readMaintenanceLastEmit(cwd, resolveHookSessionId(stdinPayload));
|
|
1988
2237
|
result = evaluateMaintenanceSignal(
|
|
1989
2238
|
events,
|
|
1990
2239
|
now,
|
|
@@ -1997,7 +2246,38 @@ function main(env, stdio) {
|
|
|
1997
2246
|
}
|
|
1998
2247
|
}
|
|
1999
2248
|
|
|
2000
|
-
if (result === null)
|
|
2249
|
+
if (result === null) {
|
|
2250
|
+
// Observability grill (a): no actionable signal — instead of returning
|
|
2251
|
+
// silently (which made Fabric feel inert in the background), surface a
|
|
2252
|
+
// session-activity status breadcrumb to the human sink (gated by
|
|
2253
|
+
// nudge_mode). Best-effort: never block the Stop hook on it.
|
|
2254
|
+
try {
|
|
2255
|
+
emitSessionStatus(cwd, events, stdinPayload, nowMs, pendingStats, out);
|
|
2256
|
+
} catch {
|
|
2257
|
+
// status breadcrumb is decorative — never throw
|
|
2258
|
+
}
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
// v2.2 dual-sink (Goal A / D6): VALUE-GATE the archive nudge. Signal A's
|
|
2263
|
+
// edit/hours trigger is the CHECK cadence; the nudge only fires when a
|
|
2264
|
+
// deterministic high-value signal accrued since the last archive (decouples
|
|
2265
|
+
// check frequency from disturb frequency). Boundary-correct: replicates
|
|
2266
|
+
// archive-scan's ledger probe (no semantic judgement). Other signals
|
|
2267
|
+
// (review/import/maintenance) are unaffected.
|
|
2268
|
+
if (result.signal === "archive") {
|
|
2269
|
+
let watermarkTs = null;
|
|
2270
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
2271
|
+
const ev = events[i];
|
|
2272
|
+
if (ev && ev.event_type === EVENT_TYPE_PROPOSED && typeof ev.ts === "number") {
|
|
2273
|
+
watermarkTs = ev.ts;
|
|
2274
|
+
break;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
if (!hasHighValueArchiveSignal(events, watermarkTs)) {
|
|
2278
|
+
return; // no high-value candidate → stay quiet (D6 value-gate)
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2001
2281
|
|
|
2002
2282
|
// v2.0.0-rc.37 NEW-16: per-signal dismiss. A chosen signal whose type the
|
|
2003
2283
|
// user dismissed (config-durable or session sidecar) exits silently —
|
|
@@ -2019,10 +2299,10 @@ function main(env, stdio) {
|
|
|
2019
2299
|
// pile. Best-effort; missing snapshot / single-store omits the line.
|
|
2020
2300
|
if (bindingsSnapshotReader !== null && typeof result.reason === "string") {
|
|
2021
2301
|
try {
|
|
2022
|
-
const
|
|
2023
|
-
if (
|
|
2302
|
+
const bindingId = readWorkspaceBindingId(cwd);
|
|
2303
|
+
if (bindingId) {
|
|
2024
2304
|
const label = bindingsSnapshotReader.formatStoreLabels(
|
|
2025
|
-
bindingsSnapshotReader.readBindingsSnapshot(
|
|
2305
|
+
bindingsSnapshotReader.readBindingsSnapshot(bindingId),
|
|
2026
2306
|
);
|
|
2027
2307
|
if (label) {
|
|
2028
2308
|
result.reason = `${result.reason}\n${label}`;
|
|
@@ -2041,7 +2321,7 @@ function main(env, stdio) {
|
|
|
2041
2321
|
delete result.threshold;
|
|
2042
2322
|
delete result.actual_value;
|
|
2043
2323
|
out.write(JSON.stringify(result));
|
|
2044
|
-
writeMaintenanceLastEmit(cwd, nowMs);
|
|
2324
|
+
writeMaintenanceLastEmit(cwd, nowMs, resolveHookSessionId(stdinPayload));
|
|
2045
2325
|
return;
|
|
2046
2326
|
}
|
|
2047
2327
|
|
|
@@ -2049,7 +2329,7 @@ function main(env, stdio) {
|
|
|
2049
2329
|
// archive_hint_cooldown_hours (default 12h) regardless of state drift.
|
|
2050
2330
|
// Pure reminder-noise reduction; the underlying trigger logic is unchanged.
|
|
2051
2331
|
const cooldownMs = readCooldownHours(cwd) * MS_PER_HOUR;
|
|
2052
|
-
const cache = readShownCache(cwd);
|
|
2332
|
+
const cache = readShownCache(cwd, resolveHookSessionId(stdinPayload));
|
|
2053
2333
|
const lastShown = cache[result.signal];
|
|
2054
2334
|
// rc.34 TASK-01 + review-fix (Gemini P1): future-stamped lastShown
|
|
2055
2335
|
// (backward clock skew) bypasses cooldown — sidecar treated as expired.
|
|
@@ -2062,11 +2342,29 @@ function main(env, stdio) {
|
|
|
2062
2342
|
}
|
|
2063
2343
|
|
|
2064
2344
|
emitSignalFiredEvent(cwd, sessionId, result);
|
|
2345
|
+
const reasonText = typeof result.reason === "string" ? result.reason : "";
|
|
2065
2346
|
delete result.threshold;
|
|
2066
2347
|
delete result.actual_value;
|
|
2067
|
-
|
|
2348
|
+
// v2.2 dual-sink (Goal A / D3): the archive nudge is SOFT — emitted as
|
|
2349
|
+
// additionalContext(AI) + systemMessage(human), NEVER decision:block. The
|
|
2350
|
+
// human channel is gated by nudge_mode (D4/D5); the AI channel always carries
|
|
2351
|
+
// it (flow ⊥ observation). Missing it is backstopped by the SessionEnd marker
|
|
2352
|
+
// + cross-session debt (D3). Review/import keep the decision:block contract
|
|
2353
|
+
// (out of Goal A scope; KT-DEC-0007 nudge semantics unchanged for them).
|
|
2354
|
+
if (result.signal === "archive" && clientAdapter && typeof clientAdapter.emitDualSink === "function") {
|
|
2355
|
+
const humanGate =
|
|
2356
|
+
nudgePolicy !== null
|
|
2357
|
+
? nudgePolicy.resolveHumanSink(cwd, "stop", { highValue: true })
|
|
2358
|
+
: { emitHuman: true };
|
|
2359
|
+
clientAdapter.emitDualSink(
|
|
2360
|
+
{ human: humanGate.emitHuman ? reasonText : null, ai: reasonText },
|
|
2361
|
+
{ client: clientAdapter.detectClient(__dirname), eventName: "Stop", streams: { stdout: out } },
|
|
2362
|
+
);
|
|
2363
|
+
} else {
|
|
2364
|
+
out.write(JSON.stringify(result));
|
|
2365
|
+
}
|
|
2068
2366
|
cache[result.signal] = nowMs;
|
|
2069
|
-
writeShownCache(cwd, cache);
|
|
2367
|
+
writeShownCache(cwd, cache, resolveHookSessionId(stdinPayload));
|
|
2070
2368
|
} catch {
|
|
2071
2369
|
// Silent — never block on hook failure.
|
|
2072
2370
|
}
|
|
@@ -2078,6 +2376,8 @@ module.exports = {
|
|
|
2078
2376
|
readPendingStats,
|
|
2079
2377
|
countCanonicalNodes,
|
|
2080
2378
|
countEditsSince,
|
|
2379
|
+
// observability grill (a): session-activity tally for the human status line.
|
|
2380
|
+
tallySessionActivity,
|
|
2081
2381
|
// rc.7 T4: top-edited-directories aggregator + banner overview formatter.
|
|
2082
2382
|
getTopEditedDirectories,
|
|
2083
2383
|
formatActivityOverview,
|
|
@@ -2117,10 +2417,9 @@ module.exports = {
|
|
|
2117
2417
|
parseKbLine,
|
|
2118
2418
|
detectClient,
|
|
2119
2419
|
extractAndWriteAssistantTurnsBestEffort,
|
|
2120
|
-
//
|
|
2121
|
-
// of the
|
|
2122
|
-
|
|
2123
|
-
emitCiteContractRemindersBestEffort,
|
|
2420
|
+
// lifecycle-refactor W3-A2 (§7): graph-edge-candidate request emitter
|
|
2421
|
+
// (exported for unit testing of the honest stable_id-gating + de-dup).
|
|
2422
|
+
emitGraphEdgeCandidateBestEffort,
|
|
2124
2423
|
CONSTANTS: {
|
|
2125
2424
|
FABRIC_DIR,
|
|
2126
2425
|
EVENT_LEDGER_FILE,
|
|
@@ -2158,6 +2457,8 @@ module.exports = {
|
|
|
2158
2457
|
// v2.0.0-rc.8 (TASK-002): in-flight import gate for Signal B.
|
|
2159
2458
|
IMPORT_STATE_FILE_REL,
|
|
2160
2459
|
IMPORT_IN_FLIGHT_MAX_AGE_HOURS,
|
|
2460
|
+
// lifecycle-refactor W3-A2 (§7): graph-edge-request de-dup sidecar.
|
|
2461
|
+
GRAPH_EDGE_REQUESTED_SIDECAR,
|
|
2161
2462
|
},
|
|
2162
2463
|
};
|
|
2163
2464
|
|