fraim 2.0.270 → 2.0.272

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.
Files changed (33) hide show
  1. package/dist/src/cli/commands/add-ide.js +37 -132
  2. package/dist/src/cli/commands/add-provider.js +32 -268
  3. package/dist/src/cli/commands/learning-usage.js +412 -0
  4. package/dist/src/cli/commands/login.js +5 -5
  5. package/dist/src/cli/commands/setup.js +15 -52
  6. package/dist/src/cli/commands/sync.js +111 -80
  7. package/dist/src/cli/fraim.js +1 -42
  8. package/dist/src/cli/mcp/ide-formats.js +1 -1
  9. package/dist/src/cli/mcp/mcp-server-registry.js +3 -3
  10. package/dist/src/cli/providers/local-provider-registry.js +4 -4
  11. package/dist/src/cli/setup/auto-mcp-setup.js +4 -13
  12. package/dist/src/cli/utils/remote-sync.js +41 -25
  13. package/dist/src/core/ai-mentor.js +27 -14
  14. package/dist/src/core/config-loader.js +48 -3
  15. package/dist/src/core/fraim-config-schema.generated.js +18 -0
  16. package/dist/src/core/handoff-contracts.js +37 -1
  17. package/dist/src/core/job-phases.js +2 -14
  18. package/dist/src/core/resolve-phase-edge.js +75 -0
  19. package/dist/src/core/types.js +7 -1
  20. package/dist/src/core/utils/git-utils.js +24 -14
  21. package/dist/src/core/utils/project-fraim-paths.js +16 -1
  22. package/dist/src/first-run/types.js +1 -1
  23. package/dist/src/local-mcp-server/artifact-retention-cleanup.js +8 -0
  24. package/dist/src/local-mcp-server/learning-context-builder.js +448 -95
  25. package/dist/src/local-mcp-server/learning-firing-parser.js +247 -0
  26. package/dist/src/local-mcp-server/learning-usage-analysis.js +347 -0
  27. package/dist/src/local-mcp-server/learning-usage-attestation.js +191 -0
  28. package/dist/src/local-mcp-server/learning-usage-command.js +408 -0
  29. package/dist/src/local-mcp-server/learning-usage-projection.js +79 -0
  30. package/dist/src/local-mcp-server/learning-usage-store.js +417 -0
  31. package/dist/src/local-mcp-server/stdio-server.js +43 -0
  32. package/dist/src/services/provider-service.js +4 -4
  33. package/package.json +1 -1
@@ -0,0 +1,417 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CLOCK_ADVANCING_OUTCOMES = exports.FIRING_OUTCOMES = void 0;
4
+ exports.resolveUsageStorePath = resolveUsageStorePath;
5
+ exports.resolveUsageLimits = resolveUsageLimits;
6
+ exports.normalizeEntryTitle = normalizeEntryTitle;
7
+ exports.titleUsageKey = titleUsageKey;
8
+ exports.ruleUsageKey = ruleUsageKey;
9
+ exports.readUsageStore = readUsageStore;
10
+ exports.sanitizeNote = sanitizeNote;
11
+ exports.recordOffers = recordOffers;
12
+ exports.recordFirings = recordFirings;
13
+ exports.noteTitleChange = noteTitleChange;
14
+ exports.pruneUsageStore = pruneUsageStore;
15
+ /**
16
+ * Issue #1103 — the usage record for learnings and rules.
17
+ *
18
+ * Two sides of one boundary, because only one of them is observable:
19
+ *
20
+ * OFFERED — written by the runtime with no agent involvement. The proxy composes
21
+ * the injected context block, so it knows which files it listed and,
22
+ * for the score-gated families, which entries in them were above
23
+ * threshold. That is what an offer means here.
24
+ * FIRED — attested by the agent at retrospective time. FRAIM cannot observe a
25
+ * file read, and a read is not a use, so influence has to be claimed
26
+ * rather than inferred.
27
+ *
28
+ * The record is metadata only (R16): entry identity, outcome, date, job, agent,
29
+ * model. Never prompts, diffs, or work content. Notes are reduced to one bounded
30
+ * line so a multi-line diff cannot be smuggled in through the attestation.
31
+ *
32
+ * Aggregate counters are kept in full; the detailed logs are bounded (R16a), on
33
+ * Mem0's precedent of retaining the last 20 accesses rather than an unbounded
34
+ * behavioural history.
35
+ *
36
+ * The store lives inside the manager layer's content root, which is the layer the
37
+ * existing GDPR guardrail blocks from a git backend without an explicit override
38
+ * (`src/ai-hub/server.ts`). That placement is what satisfies R18; there is no
39
+ * second guardrail here.
40
+ *
41
+ * It is derived data (R17): deleting it loses no lesson, because every firing is
42
+ * still written in the retrospective that attested it and every offer is
43
+ * re-recorded the next time the entry is delivered.
44
+ */
45
+ const fs_1 = require("fs");
46
+ const path_1 = require("path");
47
+ const pack_home_1 = require("../cli/utils/pack-home");
48
+ const artifact_retention_cleanup_1 = require("./artifact-retention-cleanup");
49
+ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
50
+ // ── Vocabulary ───────────────────────────────────────────────────────────────
51
+ /**
52
+ * The five outcomes (spec: Outcome vocabulary). `ignored` and `not-offered` are
53
+ * the two that change what the manager should do: the first says fix the entry,
54
+ * the second says fix the delivery.
55
+ */
56
+ exports.FIRING_OUTCOMES = ['prevented', 'applied', 'not-applicable', 'ignored', 'not-offered'];
57
+ /**
58
+ * R8: only these advance the decay clock. An entry that was offered, read, and
59
+ * correctly not relevant has not proved anything about its usefulness.
60
+ */
61
+ exports.CLOCK_ADVANCING_OUTCOMES = ['prevented', 'applied'];
62
+ const STORE_VERSION = 1;
63
+ const STORE_FILE_NAME = 'learning-usage.json';
64
+ /** Default bounds. The firing bound follows Mem0's published 20-access retention. */
65
+ const DEFAULT_FIRING_LOG_LIMIT = 20;
66
+ /**
67
+ * The offer log is larger than the firing log because R7 has to answer "was this
68
+ * entry in context for that job" across a plausible recurrence window, which
69
+ * needs per-job offer detail rather than a counter.
70
+ */
71
+ const DEFAULT_OFFER_LOG_LIMIT = 50;
72
+ /** Open Question 2: configurable and greater than a handful. */
73
+ const DEFAULT_RETIREMENT_OFFER_THRESHOLD = 10;
74
+ /** R16: one line, bounded, no fences. Long enough for a sentence, short enough to be useless as a transcript. */
75
+ const MAX_NOTE_LENGTH = 240;
76
+ // ── Paths and config ─────────────────────────────────────────────────────────
77
+ /**
78
+ * The store path, inside the manager layer so the existing git-backend guardrail
79
+ * covers it. `resolvePackHome` never touches the network and never throws.
80
+ */
81
+ function resolveUsageStorePath() {
82
+ return (0, path_1.join)((0, pack_home_1.resolvePackHome)('manager').contentRoot, 'usage', STORE_FILE_NAME);
83
+ }
84
+ function readWorkspaceConfig(workspaceRoot) {
85
+ try {
86
+ const configPath = (0, project_fraim_paths_1.getWorkspaceConfigPath)(workspaceRoot);
87
+ if (!(0, fs_1.existsSync)(configPath))
88
+ return {};
89
+ return JSON.parse((0, fs_1.readFileSync)(configPath, 'utf8'));
90
+ }
91
+ catch {
92
+ return {};
93
+ }
94
+ }
95
+ function positiveInt(value, fallback) {
96
+ return Number.isInteger(value) && value > 0 ? value : fallback;
97
+ }
98
+ function resolveUsageLimits(workspaceRoot) {
99
+ const usage = readWorkspaceConfig(workspaceRoot)?.learning?.usage;
100
+ const block = usage && typeof usage === 'object' && !Array.isArray(usage) ? usage : {};
101
+ let retentionDays = -1;
102
+ try {
103
+ retentionDays = (0, artifact_retention_cleanup_1.resolveArtifactRetentionConfig)(workspaceRoot).values.learning_usage;
104
+ }
105
+ catch {
106
+ // Retention config is advisory here; a malformed config must not stop a write.
107
+ }
108
+ return {
109
+ firingLogLimit: positiveInt(block.firingLogLimit, DEFAULT_FIRING_LOG_LIMIT),
110
+ offerLogLimit: positiveInt(block.offerLogLimit, DEFAULT_OFFER_LOG_LIMIT),
111
+ retirementOfferThreshold: positiveInt(block.retirementOfferThreshold, DEFAULT_RETIREMENT_OFFER_THRESHOLD),
112
+ retentionDays: typeof retentionDays === 'number' ? retentionDays : -1,
113
+ };
114
+ }
115
+ // ── Keys ─────────────────────────────────────────────────────────────────────
116
+ /** Normalize a title for keying. Case and whitespace only: never a similarity measure (D4). */
117
+ function normalizeEntryTitle(title) {
118
+ return title.trim().replace(/\s+/g, ' ').toLowerCase();
119
+ }
120
+ /**
121
+ * The fallback key for an entry that carries no `**Id**` line yet, so the feature
122
+ * works before the id migration has run. Namespaced by family so the same title
123
+ * in two families is two entries.
124
+ */
125
+ function titleUsageKey(fileType, title) {
126
+ return `T:${fileType}:${normalizeEntryTitle(title)}`;
127
+ }
128
+ /** The key for a rule file, namespaced so it can never collide with a learning id. */
129
+ function ruleUsageKey(displayPath) {
130
+ return `R:${displayPath.replace(/\\/g, '/')}`;
131
+ }
132
+ // ── Read and write ───────────────────────────────────────────────────────────
133
+ function emptyStore() {
134
+ return { version: STORE_VERSION, updatedAt: new Date(0).toISOString(), entries: {} };
135
+ }
136
+ /**
137
+ * Is this parsed value a record every consumer can read without crashing?
138
+ *
139
+ * The shallow `record.offered && record.fired` this replaced was not enough. A
140
+ * record whose sub-objects exist but are empty passed it and then threw in
141
+ * `buildUsageReport` (`record.offered.log is not iterable`),
142
+ * `findRetirementCandidates` and `deriveStanding`, so usage reports and
143
+ * candidate analysis failed outright instead of degrading. That state is
144
+ * reachable: the store is a JSON file inside the manager home, which is commonly
145
+ * a synced folder, so an interrupted write or a partial sync produces valid JSON
146
+ * with a hollow record.
147
+ *
148
+ * Checks only what a consumer actually dereferences, so a record carrying an
149
+ * unknown extra field is still accepted and a future field addition does not
150
+ * silently start discarding history.
151
+ */
152
+ function isUsableRecord(value) {
153
+ if (!value || typeof value !== 'object')
154
+ return false;
155
+ const record = value;
156
+ const offered = record.offered;
157
+ const fired = record.fired;
158
+ if (!offered || typeof offered !== 'object' || !fired || typeof fired !== 'object')
159
+ return false;
160
+ return Array.isArray(offered.log)
161
+ && typeof offered.total === 'number'
162
+ && typeof offered.aboveThreshold === 'number'
163
+ && !!offered.byModel && typeof offered.byModel === 'object'
164
+ && Array.isArray(fired.log)
165
+ && typeof fired.total === 'number'
166
+ && !!fired.byOutcome && typeof fired.byOutcome === 'object'
167
+ && Array.isArray(record.titles);
168
+ }
169
+ /**
170
+ * A missing or corrupt store reads as empty. It is derived data, so losing it is
171
+ * recoverable and must never break the loader that reads it.
172
+ */
173
+ function readUsageStore(storePath = resolveUsageStorePath()) {
174
+ try {
175
+ if (!(0, fs_1.existsSync)(storePath))
176
+ return emptyStore();
177
+ const parsed = JSON.parse((0, fs_1.readFileSync)(storePath, 'utf8'));
178
+ // `typeof [] === 'object'`, so an array has to be rejected explicitly: otherwise
179
+ // Object.values yields whatever the array held and every consumer that reads
180
+ // `entry.offered` throws on it.
181
+ if (!parsed || typeof parsed !== 'object' || !parsed.entries
182
+ || typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) {
183
+ return emptyStore();
184
+ }
185
+ // Drop any value that is not a usable record rather than handing a caller
186
+ // something it will crash on. A partially corrupt store degrades to the records
187
+ // that are still readable, which is the right behaviour for derived data.
188
+ const entries = {};
189
+ for (const [key, value] of Object.entries(parsed.entries)) {
190
+ if (isUsableRecord(value))
191
+ entries[key] = value;
192
+ }
193
+ return { version: parsed.version ?? STORE_VERSION, updatedAt: parsed.updatedAt ?? emptyStore().updatedAt, entries };
194
+ }
195
+ catch {
196
+ return emptyStore();
197
+ }
198
+ }
199
+ function writeUsageStore(store, storePath = resolveUsageStorePath()) {
200
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(storePath), { recursive: true });
201
+ (0, fs_1.writeFileSync)(storePath, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
202
+ }
203
+ // ── Mutation helpers ─────────────────────────────────────────────────────────
204
+ function emptyOutcomeCounts() {
205
+ return { prevented: 0, applied: 0, 'not-applicable': 0, ignored: 0, 'not-offered': 0 };
206
+ }
207
+ function ensureRecord(store, key, family, fileType, level) {
208
+ const existing = store.entries[key];
209
+ if (existing) {
210
+ if (fileType && !existing.fileType)
211
+ existing.fileType = fileType;
212
+ if (level && !existing.level)
213
+ existing.level = level;
214
+ return existing;
215
+ }
216
+ const created = {
217
+ key,
218
+ family,
219
+ titles: [],
220
+ fileType: fileType ?? null,
221
+ level: level ?? null,
222
+ offered: { total: 0, aboveThreshold: 0, byModel: {}, lastOfferedAt: null, log: [] },
223
+ fired: {
224
+ total: 0, byOutcome: emptyOutcomeCounts(), byModel: {},
225
+ firstFiredAt: null, lastFiredAt: null, lastAttestedAt: null, log: [], seenSources: [],
226
+ },
227
+ };
228
+ store.entries[key] = created;
229
+ return created;
230
+ }
231
+ function rememberTitle(record, title) {
232
+ if (!title)
233
+ return;
234
+ const trimmed = title.trim();
235
+ if (!trimmed)
236
+ return;
237
+ const normalized = normalizeEntryTitle(trimmed);
238
+ const already = record.titles.findIndex((t) => normalizeEntryTitle(t) === normalized);
239
+ // Already the most recent title: nothing to do. Guard the empty case explicitly,
240
+ // because findIndex returns -1 and length - 1 is also -1 on an empty list.
241
+ if (record.titles.length > 0 && already === record.titles.length - 1)
242
+ return;
243
+ if (already >= 0)
244
+ record.titles.splice(already, 1);
245
+ record.titles.push(trimmed);
246
+ }
247
+ /**
248
+ * R16: reduce an attested note to a single bounded line. This is the control that
249
+ * keeps a diff, a prompt, or a transcript out of a behavioural record.
250
+ */
251
+ function sanitizeNote(note) {
252
+ const oneLine = String(note ?? '')
253
+ .replace(/```+/g, ' ')
254
+ .replace(/[\r\n\t]+/g, ' ')
255
+ .replace(/\s+/g, ' ')
256
+ .trim();
257
+ return oneLine.length > MAX_NOTE_LENGTH ? `${oneLine.slice(0, MAX_NOTE_LENGTH - 1).trimEnd()}…` : oneLine;
258
+ }
259
+ function maxDate(a, b) {
260
+ if (!a)
261
+ return b;
262
+ return b > a ? b : a;
263
+ }
264
+ function minDate(a, b) {
265
+ if (!a)
266
+ return b;
267
+ return b < a ? b : a;
268
+ }
269
+ function isValidDate(value) {
270
+ return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(new Date(value).getTime());
271
+ }
272
+ function withinRetention(date, retentionDays, now) {
273
+ if (retentionDays < 0)
274
+ return true;
275
+ const parsed = new Date(date).getTime();
276
+ if (Number.isNaN(parsed))
277
+ return true;
278
+ return (now.getTime() - parsed) / 86_400_000 <= retentionDays;
279
+ }
280
+ /**
281
+ * Trim one entry's logs: drop records outside the retention window, then keep the
282
+ * newest N. Aggregates are never touched, so R16a's second criterion holds.
283
+ */
284
+ function trimLogs(record, limits, now) {
285
+ const byDate = (a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0);
286
+ record.offered.log = record.offered.log
287
+ .filter((r) => withinRetention(r.date, limits.retentionDays, now))
288
+ .sort(byDate)
289
+ .slice(-limits.offerLogLimit);
290
+ record.fired.log = record.fired.log
291
+ .filter((r) => withinRetention(r.date, limits.retentionDays, now))
292
+ .sort(byDate)
293
+ .slice(-limits.firingLogLimit);
294
+ }
295
+ /**
296
+ * R1 and R2: write one offer record per listed entry, without any agent
297
+ * involvement and without depending on the agent reading the file.
298
+ */
299
+ function recordOffers(offers, options) {
300
+ const storePath = options.storePath ?? resolveUsageStorePath();
301
+ const now = options.now ?? new Date();
302
+ const store = readUsageStore(storePath);
303
+ let recorded = 0;
304
+ for (const offer of offers) {
305
+ if (!offer.key || !isValidDate(offer.date))
306
+ continue;
307
+ const record = ensureRecord(store, offer.key, offer.family, offer.fileType, offer.level);
308
+ rememberTitle(record, offer.title);
309
+ record.offered.total += 1;
310
+ if (offer.aboveThreshold)
311
+ record.offered.aboveThreshold += 1;
312
+ if (offer.model)
313
+ record.offered.byModel[offer.model] = (record.offered.byModel[offer.model] ?? 0) + 1;
314
+ record.offered.lastOfferedAt = maxDate(record.offered.lastOfferedAt, offer.date);
315
+ record.offered.log.push({
316
+ date: offer.date,
317
+ job: offer.job,
318
+ agent: offer.agent ?? null,
319
+ model: offer.model ?? null,
320
+ aboveThreshold: Boolean(offer.aboveThreshold),
321
+ });
322
+ trimLogs(record, options.limits, now);
323
+ recorded += 1;
324
+ }
325
+ if (recorded > 0) {
326
+ store.updatedAt = now.toISOString();
327
+ writeUsageStore(store, storePath);
328
+ }
329
+ return { recorded, entries: Object.keys(store.entries).length };
330
+ }
331
+ /**
332
+ * R4 and R6: write the attested firings. Idempotent per (entry, attestation
333
+ * source) so a re-run, or a backfill that overlaps a live attestation, cannot
334
+ * inflate a count.
335
+ */
336
+ function recordFirings(firings, options) {
337
+ const storePath = options.storePath ?? resolveUsageStorePath();
338
+ const now = options.now ?? new Date();
339
+ const store = readUsageStore(storePath);
340
+ let recorded = 0;
341
+ let alreadyRecorded = 0;
342
+ for (const firing of firings) {
343
+ if (!exports.FIRING_OUTCOMES.includes(firing.outcome)) {
344
+ throw new Error(`Unknown firing outcome "${firing.outcome}". Use one of: ${exports.FIRING_OUTCOMES.join(', ')}.`);
345
+ }
346
+ if (!firing.key || !isValidDate(firing.date))
347
+ continue;
348
+ const record = ensureRecord(store, firing.key, firing.family, firing.fileType, firing.level);
349
+ if (firing.sourceRef && record.fired.seenSources.includes(firing.sourceRef)) {
350
+ alreadyRecorded += 1;
351
+ continue;
352
+ }
353
+ rememberTitle(record, firing.title);
354
+ record.fired.total += 1;
355
+ record.fired.byOutcome[firing.outcome] += 1;
356
+ record.fired.lastAttestedAt = maxDate(record.fired.lastAttestedAt, firing.date);
357
+ if (exports.CLOCK_ADVANCING_OUTCOMES.includes(firing.outcome)) {
358
+ record.fired.firstFiredAt = minDate(record.fired.firstFiredAt, firing.date);
359
+ record.fired.lastFiredAt = maxDate(record.fired.lastFiredAt, firing.date);
360
+ if (firing.model)
361
+ record.fired.byModel[firing.model] = (record.fired.byModel[firing.model] ?? 0) + 1;
362
+ }
363
+ record.fired.log.push({
364
+ date: firing.date,
365
+ outcome: firing.outcome,
366
+ job: firing.job,
367
+ agent: firing.agent ?? null,
368
+ model: firing.model ?? null,
369
+ note: sanitizeNote(firing.note),
370
+ source: firing.source ?? 'attested',
371
+ });
372
+ if (firing.sourceRef)
373
+ record.fired.seenSources.push(firing.sourceRef);
374
+ trimLogs(record, options.limits, now);
375
+ recorded += 1;
376
+ }
377
+ if (recorded > 0 || alreadyRecorded > 0) {
378
+ store.updatedAt = now.toISOString();
379
+ writeUsageStore(store, storePath);
380
+ }
381
+ return { recorded, alreadyRecorded };
382
+ }
383
+ /**
384
+ * R5: record that an entry's title changed, so a firing written before the edit
385
+ * still resolves to it. Identity lives on the entry; this keeps the store's title
386
+ * index able to follow a rename.
387
+ */
388
+ function noteTitleChange(key, newTitle, options) {
389
+ const storePath = options.storePath ?? resolveUsageStorePath();
390
+ const store = readUsageStore(storePath);
391
+ const record = store.entries[key];
392
+ if (!record)
393
+ return;
394
+ rememberTitle(record, newTitle);
395
+ store.updatedAt = (options.now ?? new Date()).toISOString();
396
+ writeUsageStore(store, storePath);
397
+ }
398
+ /** Apply retention and log bounds across the whole store. Reports what it dropped. */
399
+ function pruneUsageStore(limits, options = {}) {
400
+ const storePath = options.storePath ?? resolveUsageStorePath();
401
+ const now = options.now ?? new Date();
402
+ const store = readUsageStore(storePath);
403
+ let prunedOfferRecords = 0;
404
+ let prunedFiringRecords = 0;
405
+ for (const record of Object.values(store.entries)) {
406
+ const offersBefore = record.offered.log.length;
407
+ const firingsBefore = record.fired.log.length;
408
+ trimLogs(record, limits, now);
409
+ prunedOfferRecords += offersBefore - record.offered.log.length;
410
+ prunedFiringRecords += firingsBefore - record.fired.log.length;
411
+ }
412
+ if (prunedOfferRecords > 0 || prunedFiringRecords > 0) {
413
+ store.updatedAt = now.toISOString();
414
+ writeUsageStore(store, storePath);
415
+ }
416
+ return { prunedOfferRecords, prunedFiringRecords, entries: Object.keys(store.entries).length };
417
+ }
@@ -70,6 +70,7 @@ const usage_collector_js_1 = require("./usage-collector.js");
70
70
  const otlp_metrics_receiver_js_1 = require("./otlp-metrics-receiver.js");
71
71
  const token_adapter_registry_js_1 = require("./token-adapter-registry.js");
72
72
  const learning_context_builder_js_1 = require("./learning-context-builder.js");
73
+ const learning_usage_store_js_1 = require("./learning-usage-store.js");
73
74
  const learning_domains_js_1 = require("../config/learning-domains.js");
74
75
  const skill_include_dedup_js_1 = require("./skill-include-dedup.js");
75
76
  /**
@@ -1371,6 +1372,7 @@ class FraimLocalMCPServer {
1371
1372
  responseText += teamContextSection;
1372
1373
  this.log(`[req:${requestId}] Injected team context from ${workspaceRoot}`);
1373
1374
  }
1375
+ this.recordUsageOffers(workspaceRoot, userEmail, false, null, 'session', requestId);
1374
1376
  }
1375
1377
  if (this.latestConnectSyncWarning) {
1376
1378
  responseText += `\n\n## Local Catalog\n${this.latestConnectSyncWarning}`;
@@ -1391,10 +1393,51 @@ class FraimLocalMCPServer {
1391
1393
  finalizedResponse.result.content[0].text = text + `\n\n---` + learningSection + teamContextSection;
1392
1394
  this.log(`[req:${requestId}] Injected job-focus learning/team context for ${userEmail} (domain: ${jobDomain ?? 'global'})`);
1393
1395
  }
1396
+ this.recordUsageOffers(workspaceRoot, userEmail, true, jobDomain, String(args.job ?? 'unknown'), requestId);
1394
1397
  }
1395
1398
  }
1396
1399
  return this.processResponseWithHydration(finalizedResponse, requestSessionId);
1397
1400
  }
1401
+ /**
1402
+ * Issue #1103 R1, R2 and R3: record what this context injection delivered.
1403
+ *
1404
+ * Written here because this is the only place that knows all of it at once: the
1405
+ * files that were listed, the job, the agent, and the model. Nothing depends on
1406
+ * the agent reading anything, which is R2, and the three auto-loaded rule files
1407
+ * get a record for the first time, which is R3.
1408
+ *
1409
+ * Never throws and never blocks the response. Usage data is derived and optional:
1410
+ * a failure here must not change what the agent is given.
1411
+ */
1412
+ recordUsageOffers(workspaceRoot, userEmail, forJob, domain, job, requestId) {
1413
+ try {
1414
+ const offered = [
1415
+ ...(0, learning_context_builder_js_1.collectOfferedLearningEntries)(workspaceRoot, userEmail, forJob, domain),
1416
+ ...(0, learning_context_builder_js_1.collectOfferedRuleFiles)(workspaceRoot, forJob),
1417
+ ];
1418
+ if (offered.length === 0)
1419
+ return;
1420
+ const collector = this.usageCollector;
1421
+ const date = new Date().toISOString().slice(0, 10);
1422
+ const limits = (0, learning_usage_store_js_1.resolveUsageLimits)(workspaceRoot);
1423
+ const result = (0, learning_usage_store_js_1.recordOffers)(offered.map((entry) => ({
1424
+ key: entry.key,
1425
+ family: entry.family,
1426
+ title: entry.title,
1427
+ fileType: entry.fileType,
1428
+ level: entry.level,
1429
+ aboveThreshold: entry.aboveThreshold,
1430
+ job,
1431
+ date,
1432
+ agent: collector?.getAgentName() ?? null,
1433
+ model: collector?.getAgentModel() ?? null,
1434
+ })), { limits });
1435
+ this.log(`[req:${requestId}] Recorded ${result.recorded} usage offers for job ${job}`);
1436
+ }
1437
+ catch (error) {
1438
+ this.log(`[req:${requestId}] Usage offer recording skipped: ${error instanceof Error ? error.message : String(error)}`);
1439
+ }
1440
+ }
1398
1441
  /**
1399
1442
  * Resolve the learning domain for a `get_fraim_job` request (issue #806) from
1400
1443
  * the job's registry path, so the loader injects global + that domain's
@@ -12,7 +12,7 @@ const PROVIDERS = {
12
12
  description: 'GitHub repository and issue management',
13
13
  capabilities: ['code', 'issues', 'integrated'],
14
14
  docsUrl: 'https://github.com/settings/tokens',
15
- setupInstructions: 'Run "fraim add-provider github" your IDE handles OAuth automatically on first use',
15
+ setupInstructions: 'Ask your agent to use the FRAIM connect-mcp skill and follow current GitHub and host guidance',
16
16
  mcpServer: {
17
17
  type: 'http',
18
18
  url: 'https://api.githubcopilot.com/mcp/'
@@ -25,7 +25,7 @@ const PROVIDERS = {
25
25
  description: 'GitLab repository and issue management',
26
26
  capabilities: ['code', 'issues', 'integrated'],
27
27
  docsUrl: 'https://gitlab.com/-/profile/personal_access_tokens',
28
- setupInstructions: 'Create a Personal Access Token at https://gitlab.com/-/profile/personal_access_tokens with api scope',
28
+ setupInstructions: 'Ask your agent to use the FRAIM connect-mcp skill and follow current GitLab and host guidance',
29
29
  mcpServer: {
30
30
  type: 'http',
31
31
  url: 'https://gitlab.com/api/v4/mcp',
@@ -49,7 +49,7 @@ const PROVIDERS = {
49
49
  }
50
50
  ],
51
51
  docsUrl: 'https://dev.azure.com',
52
- setupInstructions: 'Create a Personal Access Token in Azure DevOps with Code (Read & Write) and Work Items (Read & Write) scopes',
52
+ setupInstructions: 'Ask your agent to use the FRAIM connect-mcp skill and follow current Azure DevOps and host guidance',
53
53
  mcpServer: {
54
54
  type: 'stdio',
55
55
  command: 'npx',
@@ -91,7 +91,7 @@ const PROVIDERS = {
91
91
  }
92
92
  ],
93
93
  docsUrl: 'https://id.atlassian.com/manage-profile/security/api-tokens',
94
- setupInstructions: 'Create an API Token at https://id.atlassian.com/manage-profile/security/api-tokens',
94
+ setupInstructions: 'Ask your agent to use the FRAIM connect-mcp skill and follow current Jira and host guidance',
95
95
  mcpServer: {
96
96
  type: 'stdio',
97
97
  command: 'uvx',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.270",
3
+ "version": "2.0.272",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {