fraim 2.0.339 → 2.0.340

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.
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.COMPLETION_END = exports.COMPLETION_START = void 0;
7
+ exports.validateProcessedSources = validateProcessedSources;
8
+ exports.decodeCompletion = decodeCompletion;
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ function validateProcessedSources(sources) {
11
+ if (!Array.isArray(sources) || new Set(sources.map(item => item.path)).size !== sources.length || sources.some(item => !item || !node_path_1.default.isAbsolute(item.path) || !node_path_1.default.isAbsolute(item.destination) || !['raw', 'retrospective'].includes(item.kind)
12
+ || !/^[a-f0-9]{64}$/.test(item.sha256) || !/^[a-f0-9]{64}$/.test(item.resultHash) || !/^\d{4}-\d{2}-\d{2}$/.test(item.synthesizedAt)))
13
+ throw new Error('invalid-processed-sources');
14
+ }
15
+ exports.COMPLETION_START = '<!-- fraim-sleep-decisions:v1 -->';
16
+ exports.COMPLETION_END = '<!-- /fraim-sleep-decisions -->';
17
+ const text = (value) => typeof value === 'string' && !!value.trim();
18
+ const hash = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
19
+ const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
20
+ const keys = (value, allowed) => object(value) && Object.keys(value).every(key => allowed.includes(key));
21
+ const ownerValid = (value) => object(value) && ['Project', 'Manager', 'Org'].includes(value.level)
22
+ && keys(value, ['level', 'path']) && text(value.path) && node_path_1.default.isAbsolute(value.path)
23
+ && !/[\x00-\x1f]/.test(value.path) && value.path === (process.platform === 'win32' ? node_path_1.default.resolve(value.path).replace(/\\/g, '/').toLowerCase() : node_path_1.default.resolve(value.path));
24
+ const sameOwner = (a, b) => a.level === b.level && a.path === b.path;
25
+ function decodeCompletion(markdown, expected = {}) {
26
+ const fail = () => { throw new Error('invalid-completion-decisions'); };
27
+ if ((markdown.match(/<!--\s*fraim-sleep-decisions:/g) || []).length !== 1 || markdown.split(exports.COMPLETION_START).length !== 2 || markdown.split(exports.COMPLETION_END).length !== 2)
28
+ fail();
29
+ const start = markdown.indexOf(exports.COMPLETION_START) + exports.COMPLETION_START.length;
30
+ const end = markdown.indexOf(exports.COMPLETION_END);
31
+ if (end < start)
32
+ fail();
33
+ const block = markdown.slice(start, end).match(/^\s*```json\r?\n([\s\S]*?)\r?\n```\s*$/);
34
+ if (!block)
35
+ fail();
36
+ let state;
37
+ try {
38
+ state = JSON.parse(block[1]);
39
+ }
40
+ catch {
41
+ fail();
42
+ }
43
+ if (!keys(state, ['schemaVersion', 'runId', 'owner', 'finalizationState', 'decisions', 'sourceDispositions', 'cleanup', 'reconciliations', 'retrospective', 'processedSources']) || state.schemaVersion !== 1 || !text(state.runId) || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(state.runId) || !ownerValid(state.owner)
44
+ || !['pending', 'complete'].includes(state.finalizationState) || !Array.isArray(state.decisions) || !Array.isArray(state.sourceDispositions) || !Array.isArray(state.cleanup))
45
+ fail();
46
+ if ((expected.runId && expected.runId !== state.runId) || (expected.owner && !sameOwner(expected.owner, state.owner)))
47
+ fail();
48
+ if (state.retrospective !== undefined && (!keys(state.retrospective, ['relativePath', 'sha256'])
49
+ || state.retrospective.relativePath !== `retrospectives/${state.runId}-sleep-postmortem.md` || !hash(state.retrospective.sha256)))
50
+ fail();
51
+ if (state.processedSources !== undefined)
52
+ validateProcessedSources(state.processedSources);
53
+ const ids = new Set();
54
+ for (const item of state.decisions) {
55
+ if (!keys(item, ['actionId', 'revision', 'digest', 'owner', 'claimKey', 'evidenceFingerprint', 'sourceRefs', 'decision', 'publicationRef', 'target', 'dependencies']) || !text(item.actionId) || ids.has(item.actionId) || !Number.isInteger(item.revision) || item.revision < 1 || !hash(item.digest) || !ownerValid(item.owner)
56
+ || !text(item.claimKey) || !text(item.evidenceFingerprint) || !Array.isArray(item.sourceRefs) || !item.sourceRefs.every(text) || (item.publicationRef !== undefined && !text(item.publicationRef)))
57
+ fail();
58
+ ids.add(item.actionId);
59
+ const decision = item.decision;
60
+ if (!keys(decision, ['action', 'revision', 'digest', 'reviewer', 'reason', 'revisitCondition', 'at']) || !['approve', 'reject'].includes(decision.action) || decision.revision !== item.revision || decision.digest !== item.digest
61
+ || !text(decision.reviewer) || !text(decision.reason) || !text(decision.at) || !Number.isFinite(Date.parse(decision.at))
62
+ || (decision.revisitCondition !== undefined && (decision.action !== 'reject' || !text(decision.revisitCondition))))
63
+ fail();
64
+ if ('content' in item || 'specification' in item || 'application' in item || 'preimage' in item)
65
+ fail();
66
+ if (item.target && (!keys(item.target, ['owner', 'relativePath', 'entryId', 'learningSubject']) || !ownerValid(item.target.owner) || !text(item.target.relativePath) || /^[\\/]|^[a-zA-Z]:/.test(item.target.relativePath) || item.target.relativePath.split('/').includes('..')))
67
+ fail();
68
+ if (item.dependencies && (!Array.isArray(item.dependencies) || item.dependencies.some((dependency) => !keys(dependency, ['actionId', 'outcome']) || !text(dependency.actionId) || !['applied', 'rejected'].includes(dependency.outcome))))
69
+ fail();
70
+ }
71
+ for (const source of state.sourceDispositions) {
72
+ if (!keys(source, ['sourceRef', 'sourceHash', 'outcome', 'actionId']) || !text(source.sourceRef) || !hash(source.sourceHash) || !['applied', 'rejected'].includes(source.outcome) || !ids.has(source.actionId))
73
+ fail();
74
+ }
75
+ const decisions = new Map(state.decisions.map((item) => [item.actionId, item]));
76
+ const outcome = (item) => item.decision.action === 'approve' ? 'applied' : 'rejected';
77
+ const dispositions = new Set();
78
+ for (const source of state.sourceDispositions) {
79
+ const action = decisions.get(source.actionId);
80
+ const key = JSON.stringify([source.actionId, source.sourceRef]);
81
+ if (dispositions.has(key) || !action.sourceRefs.includes(source.sourceRef) || source.outcome !== outcome(action))
82
+ fail();
83
+ dispositions.add(key);
84
+ }
85
+ for (const action of decisions.values()) {
86
+ if (action.target && !sameOwner(action.owner, action.target.owner))
87
+ fail();
88
+ if (new Set(action.sourceRefs).size !== action.sourceRefs.length || action.sourceRefs.some(ref => !dispositions.has(JSON.stringify([action.actionId, ref]))))
89
+ fail();
90
+ const dependencies = action.dependencies || [];
91
+ if (new Set(dependencies.map(dependency => dependency.actionId)).size !== dependencies.length)
92
+ fail();
93
+ for (const dependency of dependencies) {
94
+ const target = decisions.get(dependency.actionId);
95
+ if (!target || target.actionId === action.actionId || dependency.outcome !== outcome(target)
96
+ || (action.decision.action === 'approve' && dependency.outcome !== 'applied'))
97
+ fail();
98
+ }
99
+ }
100
+ const checked = new Set();
101
+ const visiting = new Set();
102
+ const visit = (id) => {
103
+ if (visiting.has(id))
104
+ fail();
105
+ if (checked.has(id))
106
+ return;
107
+ visiting.add(id);
108
+ for (const dependency of decisions.get(id).dependencies || [])
109
+ visit(dependency.actionId);
110
+ visiting.delete(id);
111
+ checked.add(id);
112
+ };
113
+ for (const id of decisions.keys())
114
+ visit(id);
115
+ for (const item of state.cleanup) {
116
+ if (!keys(item, ['path', 'outcome', 'reason', 'sha256', 'location']) || !text(item.path) || !['pending', 'deleted', 'absent', 'retained'].includes(item.outcome) || (item.reason !== undefined && typeof item.reason !== 'string') || (item.sha256 !== undefined && !hash(item.sha256)))
117
+ fail();
118
+ if (item.location !== undefined) {
119
+ const location = item.location;
120
+ if (!keys(location, ['owner', 'relativePath']) || !ownerValid(location.owner)
121
+ || !text(location.relativePath) || !/^learnings\/(?:review-queue|review-archive|review-evidence|archive)\//.test(location.relativePath)
122
+ || /[\\:]/.test(location.relativePath) || location.relativePath.split('/').some((part) => !part || part === '.' || part === '..'))
123
+ fail();
124
+ }
125
+ if (state.finalizationState === 'complete' && item.outcome === 'pending')
126
+ fail();
127
+ }
128
+ if (state.reconciliations !== undefined) {
129
+ if (!Array.isArray(state.reconciliations))
130
+ fail();
131
+ const sources = new Set();
132
+ for (const item of state.reconciliations) {
133
+ if (!keys(item, ['sourceRef', 'sourceHash', 'owner', 'relativePath', 'actionIds', 'actionBindings', 'disposition', 'rationale', 'preservedDecisions', 'reviewer'])
134
+ || !text(item.sourceRef) || sources.has(item.sourceRef) || !hash(item.sourceHash) || !ownerValid(item.owner)
135
+ || !text(item.relativePath) || !/^learnings\/(?:review-queue|review-archive|review-evidence|archive)\//.test(item.relativePath)
136
+ || /[\\:]/.test(item.relativePath) || item.relativePath.split('/').some((part) => !part || part === '.' || part === '..')
137
+ || !['accounted', 'unrelated', 'raw-source'].includes(item.disposition) || !text(item.rationale) || !text(item.reviewer)
138
+ || !Array.isArray(item.preservedDecisions) || !item.preservedDecisions.every(text)
139
+ || !Array.isArray(item.actionIds) || new Set(item.actionIds).size !== item.actionIds.length
140
+ || !Array.isArray(item.actionBindings) || item.actionBindings.length !== item.actionIds.length)
141
+ fail();
142
+ sources.add(item.sourceRef);
143
+ for (const [index, id] of item.actionIds.entries()) {
144
+ const decision = decisions.get(id);
145
+ const binding = item.actionBindings[index];
146
+ if (!decision || !keys(binding, ['actionId', 'revision', 'digest']) || binding.actionId !== id || binding.revision !== decision.revision || binding.digest !== decision.digest
147
+ || !state.sourceDispositions.some((source) => source.actionId === id && source.sourceRef.replace(/\\/g, '/') === item.sourceRef.replace(/\\/g, '/') && source.sourceHash === item.sourceHash))
148
+ fail();
149
+ }
150
+ }
151
+ }
152
+ return state;
153
+ }
@@ -22,10 +22,10 @@ function runCleanupArtifactsCommand(options) {
22
22
  }
23
23
  exports.cleanupArtifactsCommand = new commander_1.Command('cleanup-artifacts')
24
24
  .alias('cleanup-artifact')
25
- .description('Evaluate and optionally apply artifact retention cleanup for the current FRAIM workspace')
25
+ .description('Evaluate and optionally apply artifact retention cleanup for a FRAIM workspace or pack root')
26
26
  .option('--user <email>', 'Active user email used only for user-scoped cleanup categories')
27
27
  .option('--workspace-root <path>', 'Workspace root containing fraim/config.json')
28
- .option('--root <path>', 'Alias for --workspace-root')
28
+ .option('--root <path>', 'Workspace root or explicit Manager/Org pack root; uses that root\'s retention policy')
29
29
  .option('--apply', 'Delete eligible files. Without this flag the command performs a dry run.')
30
30
  .action((options) => {
31
31
  try {
@@ -348,4 +348,4 @@ class AIMentor {
348
348
  }
349
349
  }
350
350
  exports.AIMentor = AIMentor;
351
- AIMentor.COMPACT_PHASE_AUTHORITY = '**Use only the phases defined in this workflow.**';
351
+ AIMentor.COMPACT_PHASE_AUTHORITY = '**Use only the phases defined in this workflow.** Before phase work, read `get_fraim_file({ path: "orchestration/phase-authority.md" })` for phase closure, approval gates, and resumption.';
@@ -27,6 +27,7 @@ exports.validateReviewActionCompleteness = validateReviewActionCompleteness;
27
27
  exports.hasPhaseStarted = hasPhaseStarted;
28
28
  exports.upsertPhaseStarted = upsertPhaseStarted;
29
29
  exports.buildPhaseStartWarning = buildPhaseStartWarning;
30
+ exports.reviewHandoffNormalizationError = reviewHandoffNormalizationError;
30
31
  exports.validateReviewHandoff = validateReviewHandoff;
31
32
  exports.validateNextJobRecommendations = validateNextJobRecommendations;
32
33
  exports.validateDelegationLedger = validateDelegationLedger;
@@ -255,23 +256,49 @@ function validateReviewArtifactEntry(artifact, index) {
255
256
  return errors;
256
257
  }
257
258
  /**
258
- * Validates evidence.reviewHandoff. Returns null if valid, or an array of
259
- * human-readable error strings describing what is wrong.
260
- *
261
- * Required minimum shape:
262
- * {
263
- * reviewRequired: boolean,
264
- * artifacts: array,
265
- * // when reviewRequired is true:
266
- * reviewTarget: { type: "pull_request", label: string, url: string } | { type: "artifact_set", label: string },
267
- * reviewActions: [{ kind: "approve", ... }, { kind: "request_changes", ... }],
268
- * }
269
- *
270
- * Hub normalizers (server.ts + script.js) both read reviewTarget.type, not .kind.
271
- * Using reviewTarget.kind produces a payload that passes validation but is silently
272
- * un-renderable. This validator enforces the renderable contract so malformed payloads
273
- * are rejected before phase advancement.
259
+ * Reports payloads the Hub cannot normalize, including artifact_set entries it
260
+ * would silently drop. This deliberately preserves the Hub's legacy defaults;
261
+ * it does not impose the stronger, phase-dependent submission contract.
274
262
  */
263
+ function reviewHandoffNormalizationError(value) {
264
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
265
+ return 'reviewHandoff must be an object';
266
+ }
267
+ const handoff = value;
268
+ if (typeof handoff.reviewRequired !== 'boolean') {
269
+ return 'reviewHandoff.reviewRequired must be a boolean';
270
+ }
271
+ // This is renderability, not the stronger submission-phase contract below.
272
+ // In particular legacy handoffs need not supply actions or an artifacts array.
273
+ if (!handoff.reviewRequired)
274
+ return null;
275
+ const target = handoff.reviewTarget;
276
+ const targetType = typeof target?.type === 'string' ? target.type.trim() : '';
277
+ if (targetType !== 'pull_request' && targetType !== 'artifact_set') {
278
+ return 'reviewHandoff.reviewTarget.type must be pull_request or artifact_set';
279
+ }
280
+ if (targetType === 'pull_request' && !isAbsoluteHttpUrl(target?.url)) {
281
+ return 'reviewHandoff.reviewTarget.url must be an absolute http or https URL';
282
+ }
283
+ // Match the host parser's object-artifact selection and Hub's path/URL
284
+ // normalization. A missing label/type is filled in by the Hub, not rejected.
285
+ const artifacts = Array.isArray(handoff.artifacts)
286
+ ? handoff.artifacts.filter((artifact) => artifact && typeof artifact === 'object')
287
+ : [];
288
+ for (const [index, artifact] of artifacts.entries()) {
289
+ const hasPath = typeof artifact.path === 'string' && artifact.path.trim().length > 0;
290
+ const hasUrl = isAbsoluteHttpUrl(artifact.url);
291
+ if (targetType === 'pull_request') {
292
+ if (hasPath || hasUrl)
293
+ return 'reviewHandoff.artifacts must be empty for a pull_request target';
294
+ }
295
+ else if (!hasPath || hasUrl) {
296
+ return `reviewHandoff.artifacts[${index}] cannot be applied: an artifact_set requires a file path without an http or https URL`;
297
+ }
298
+ }
299
+ return null;
300
+ }
301
+ /** Validate the complete submission contract, including required actions. */
275
302
  function validateReviewHandoff(value) {
276
303
  if (value === undefined || value === null) {
277
304
  return ['evidence.reviewHandoff is missing'];
@@ -28,9 +28,14 @@ exports.ARTIFACT_RETENTION_CATEGORIES = [
28
28
  'learning_usage',
29
29
  'hub_event_logs',
30
30
  ];
31
+ function contentRoot(workspaceRoot) {
32
+ const packContent = node_path_1.default.join(workspaceRoot, 'personalized-employee');
33
+ return node_fs_1.default.existsSync(packContent) ? packContent : node_path_1.default.join(workspaceRoot, 'fraim', 'personalized-employee');
34
+ }
31
35
  function readWorkspaceConfig(workspaceRoot) {
32
36
  try {
33
- const configPath = (0, project_fraim_paths_1.getWorkspaceConfigPath)(workspaceRoot);
37
+ const configPath = contentRoot(workspaceRoot) === node_path_1.default.join(workspaceRoot, 'personalized-employee')
38
+ ? node_path_1.default.join(workspaceRoot, 'config.json') : (0, project_fraim_paths_1.getWorkspaceConfigPath)(workspaceRoot);
34
39
  if (!node_fs_1.default.existsSync(configPath))
35
40
  return {};
36
41
  return JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf8'));
@@ -143,7 +148,7 @@ function loadGitLastChangeDates(workspaceRoot, relativeDir) {
143
148
  return dates;
144
149
  }
145
150
  function readFrontmatter(content) {
146
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
151
+ const match = content.match(/^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---/);
147
152
  if (!match)
148
153
  return {};
149
154
  const values = {};
@@ -167,8 +172,20 @@ function synthesizedDate(absolutePath) {
167
172
  return undefined;
168
173
  }
169
174
  }
170
- function userScoped(fileName, activeUserEmail) {
171
- return fileName.startsWith(`${activeUserEmail}-`);
175
+ function userScoped(filePath, activeUserEmail) {
176
+ try {
177
+ const body = node_fs_1.default.readFileSync(filePath, 'utf8');
178
+ const author = readFrontmatter(body).author;
179
+ if (author !== undefined) {
180
+ const value = author.replace(/^(?:"([^"]*)"|'([^']*)')$/, (_match, double, single) => double ?? single);
181
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) && value === activeUserEmail;
182
+ }
183
+ // Older artifacts used the author in their filename.
184
+ return node_path_1.default.basename(filePath).startsWith(`${activeUserEmail}-`);
185
+ }
186
+ catch {
187
+ return false;
188
+ }
172
189
  }
173
190
  function evidenceEligibilityDate(workspaceRoot, absolutePath, gitDates) {
174
191
  const relativePath = normalizeRelative(workspaceRoot, absolutePath);
@@ -187,17 +204,22 @@ function collectCandidates(workspaceRoot, options) {
187
204
  eligibilityDate,
188
205
  });
189
206
  };
190
- const archiveDir = node_path_1.default.join(workspaceRoot, 'fraim', 'personalized-employee', 'learnings', 'archive');
207
+ const content = contentRoot(workspaceRoot);
208
+ const archiveDir = node_path_1.default.join(content, 'learnings', 'archive');
191
209
  for (const filePath of listFiles(archiveDir)) {
192
210
  const fileName = node_path_1.default.basename(filePath);
193
- if (!fileName.endsWith('.md') || !userScoped(fileName, activeUserEmail))
211
+ if (!fileName.endsWith('.md') || !userScoped(filePath, activeUserEmail))
194
212
  continue;
195
213
  add('learning_archive', filePath, true, 'Archived learning source for active user.', fileMtime(filePath));
196
214
  }
197
- const retrospectivesDir = node_path_1.default.join(workspaceRoot, 'docs', 'retrospectives');
198
- for (const filePath of listFiles(retrospectivesDir)) {
215
+ const retrospectives = new Set([
216
+ node_path_1.default.join(workspaceRoot, 'docs', 'retrospectives'),
217
+ node_path_1.default.join(workspaceRoot, 'retrospectives'),
218
+ node_path_1.default.join(content, 'retrospectives'),
219
+ ].flatMap(listFiles));
220
+ for (const filePath of retrospectives) {
199
221
  const fileName = node_path_1.default.basename(filePath);
200
- if (!fileName.endsWith('.md') || !userScoped(fileName, activeUserEmail))
222
+ if (!fileName.endsWith('.md') || !userScoped(filePath, activeUserEmail))
201
223
  continue;
202
224
  const date = synthesizedDate(filePath);
203
225
  if (date)
@@ -209,6 +231,11 @@ function collectCandidates(workspaceRoot, options) {
209
231
  const evidenceGitDates = loadGitLastChangeDates(workspaceRoot, 'docs/evidence');
210
232
  for (const filePath of listFiles(evidenceDir)) {
211
233
  const fileName = node_path_1.default.basename(filePath);
234
+ // A surviving run journal is unfinished recovery; completion removes it.
235
+ if (fileName.endsWith('.queue.jsonl')) {
236
+ add('evidence', filePath, false, 'Unfinished Sleep run journal; removed by run completion.');
237
+ continue;
238
+ }
212
239
  const isMarkdown = fileName.endsWith('.md');
213
240
  const isJson = fileName.endsWith('.json');
214
241
  const eligibilityDate = evidenceEligibilityDate(workspaceRoot, filePath, evidenceGitDates);
@@ -25,6 +25,7 @@ exports.resolveOrgBrandWriteDir = resolveOrgBrandWriteDir;
25
25
  exports.countPreservedLearnings = countPreservedLearnings;
26
26
  exports.collectBrainLearningDots = collectBrainLearningDots;
27
27
  exports.readPreservedLearnings = readPreservedLearnings;
28
+ exports.normalizeLearningDomain = normalizeLearningDomain;
28
29
  exports.applyLearningEntryChange = applyLearningEntryChange;
29
30
  exports.isTruthyFlag = isTruthyFlag;
30
31
  const fs_1 = require("fs");
@@ -496,6 +497,15 @@ function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-pattern
496
497
  * behaviour instead of merely equivalent.
497
498
  */
498
499
  function resolveUsageClock(workspaceRoot) {
500
+ return resolveUsageViews(workspaceRoot).clock;
501
+ }
502
+ /**
503
+ * Issue #1755: one store read, two views of it: the clock the scorer needs and
504
+ * the counters a surface renders. They are resolved together because reading the
505
+ * store twice for the same pass would let the two disagree, and because the
506
+ * identity resolution that finds a record is the projection's, not each caller's.
507
+ */
508
+ function resolveUsageViews(workspaceRoot) {
499
509
  try {
500
510
  // Required lazily: the store imports the retention config, which imports the
501
511
  // workspace path helpers, and a cycle through this module would be fragile.
@@ -503,14 +513,31 @@ function resolveUsageClock(workspaceRoot) {
503
513
  const projection = require('./learning-usage-projection');
504
514
  const loaded = store.readUsageStore();
505
515
  if (Object.keys(loaded.entries).length === 0)
506
- return undefined;
516
+ return {};
507
517
  const lookup = projection.buildUsageLookup(loaded);
508
- return (id, fileType, title) => projection.lookupUsage(lookup, id, fileType, title)?.lastFired ?? null;
518
+ return {
519
+ clock: (id, fileType, title) => projection.lookupUsage(lookup, id, fileType, title)?.lastFired ?? null,
520
+ stats: (id, fileType, title) => {
521
+ const found = projection.lookupUsage(lookup, id, fileType, title);
522
+ if (!found)
523
+ return null;
524
+ return {
525
+ offered: found.offered,
526
+ offeredAboveThreshold: found.offeredAboveThreshold,
527
+ applied: found.byOutcome.applied || 0,
528
+ prevented: found.byOutcome.prevented || 0,
529
+ ignored: found.byOutcome.ignored || 0,
530
+ notApplicable: found.byOutcome['not-applicable'] || 0,
531
+ lastOfferedAt: found.lastOffered,
532
+ lastFiredAt: found.lastFired,
533
+ };
534
+ },
535
+ };
509
536
  }
510
537
  catch {
511
538
  // Usage data is derived and optional. A failure here must never change what
512
539
  // the agent is given.
513
- return undefined;
540
+ return {};
514
541
  }
515
542
  }
516
543
  function readFrontmatter(content) {
@@ -1216,7 +1243,7 @@ function isTeamContextKey(value) {
1216
1243
  * user-level `~/.fraim/…` path (where org onboarding writes).
1217
1244
  * - Project scope (projectContext/projectBrief/projectRules/projectQa): repo-local only.
1218
1245
  */
1219
- function resolveTeamContextFile(workspaceRoot, key) {
1246
+ function resolveTeamContextFile(workspaceRoot, key, options = {}) {
1220
1247
  const { relativePath, scope } = TEAM_CONTEXT_FILE_MAP[key];
1221
1248
  const repoPath = (0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee', relativePath);
1222
1249
  const repoDisplay = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`personalized-employee/${relativePath}`);
@@ -1245,7 +1272,7 @@ function resolveTeamContextFile(workspaceRoot, key) {
1245
1272
  // destination (R5.1), so writePath stays empty and the caller must route
1246
1273
  // edits through the org backend's propose-and-approve flow.
1247
1274
  if (key === 'org' || key === 'orgRules') {
1248
- const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, relativePath);
1275
+ const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org', options).contentRoot, relativePath);
1249
1276
  if ((0, fs_1.existsSync)(cachePath)) {
1250
1277
  const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
1251
1278
  const orgDisplayPath = cachePath.startsWith(fraimDir)
@@ -1262,7 +1289,7 @@ function resolveTeamContextFile(workspaceRoot, key) {
1262
1289
  }
1263
1290
  }
1264
1291
  if (key === 'manager' || key === 'managerRules') {
1265
- const managerHome = (0, pack_home_1.resolvePackHome)('manager');
1292
+ const managerHome = (0, pack_home_1.resolvePackHome)('manager', options);
1266
1293
  const managerCachePath = (0, path_1.join)(managerHome.contentRoot, relativePath);
1267
1294
  if ((0, fs_1.existsSync)(managerCachePath)) {
1268
1295
  const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
@@ -1697,7 +1724,7 @@ function listDomainLearningFiles(dir, prefix, fileType) {
1697
1724
  // threshold: entries with score >= threshold (or in a non-gated category) are
1698
1725
  // marked injected:true. Callers that don't need scoring pass Infinity so
1699
1726
  // all entries are injected.
1700
- function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, threshold = exports.DEFAULT_THRESHOLD, usageClock) {
1727
+ function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, threshold = exports.DEFAULT_THRESHOLD, usageClock, usageStats) {
1701
1728
  if (!(0, fs_1.existsSync)(filePath))
1702
1729
  return [];
1703
1730
  let content;
@@ -1719,6 +1746,10 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1719
1746
  const flush = () => {
1720
1747
  if (current) {
1721
1748
  current.body = bodyLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
1749
+ // Issue #1755: resolve usage once per entry, before scoring, so the
1750
+ // counters a surface renders and the clock the score uses come from
1751
+ // the same record rather than two independent lookups.
1752
+ current.usage = usageStats ? usageStats(current.id, fileType, current.title) : null;
1722
1753
  if (isScoreGated) {
1723
1754
  if (curLastSeen !== null) {
1724
1755
  const lastFired = usageClock ? usageClock(current.id, fileType, current.title) : null;
@@ -1753,7 +1784,7 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1753
1784
  title: header[2].trim(),
1754
1785
  id: null,
1755
1786
  body: '', source: displayPath, category, level, domain,
1756
- score: null, injected: true,
1787
+ score: null, injected: true, usage: null,
1757
1788
  };
1758
1789
  if (tier !== undefined)
1759
1790
  entry.tier = tier;
@@ -1799,13 +1830,14 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
1799
1830
  const threshold = getScoreThreshold(workspaceRoot);
1800
1831
  // Issue #1103 R8: the Hub's injected flag must agree with the loader's, so both
1801
1832
  // read the same clock. One store read serves the whole call.
1802
- const usageClock = resolveUsageClock(workspaceRoot);
1833
+ // Issue #1755: the same read also yields the counters the Hub renders.
1834
+ const { clock: usageClock, stats: usageStats } = resolveUsageViews(workspaceRoot);
1803
1835
  const out = [];
1804
1836
  // Helper: parse + merge a list of tier paths for one file family.
1805
1837
  // ResolvedLearningTier uses 'manager' to mean machine-level personal; map it to 'machine'.
1806
1838
  const tierLevel = (t) => t.level === 'manager' ? 'machine' : t.level;
1807
1839
  const parseTiers = (tiers, cat, tierVal, domainVal) => mergeLearningEntriesAcrossTiers(tiers.map((t) => ({
1808
- entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, threshold, usageClock),
1840
+ entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, threshold, usageClock, usageStats),
1809
1841
  })));
1810
1842
  if (scope === 'org') {
1811
1843
  for (const cat of ['avoid', 'preference', 'repeat', 'coaching']) {
@@ -1826,7 +1858,7 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
1826
1858
  seenDomainFile.add(fileName);
1827
1859
  const domainTiers = resolveOrgLearningFileTiers(roots.repoLearningsBase, fileName);
1828
1860
  if (domainTiers.length === 0) {
1829
- out.push(...parseLearningEntries((0, path_1.join)(base, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain, undefined, threshold));
1861
+ out.push(...parseLearningEntries((0, path_1.join)(base, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain, undefined, threshold, usageClock, usageStats));
1830
1862
  }
1831
1863
  else {
1832
1864
  out.push(...parseTiers(domainTiers, cat, undefined, domain));
@@ -1843,13 +1875,13 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
1843
1875
  for (const cat of cats) {
1844
1876
  const fileType = exports.CATEGORY_TO_FILETYPE[cat];
1845
1877
  const f = `${resolvedUserId}-${fileType}.md`;
1846
- out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'project', 'global', 'hot', threshold));
1878
+ out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'project', 'global', 'hot', threshold, usageClock, usageStats));
1847
1879
  const coldF = `${resolvedUserId}-${fileType}${COLD_FILE_SUFFIX}.md`;
1848
1880
  if ((0, fs_1.existsSync)((0, path_1.join)(roots.repoLearningsBase, coldF))) {
1849
- out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, coldF), `${REPO_LEARNINGS_REL}/${coldF}`, cat, 'project', 'global', 'cold', threshold));
1881
+ out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, coldF), `${REPO_LEARNINGS_REL}/${coldF}`, cat, 'project', 'global', 'cold', threshold, usageClock, usageStats));
1850
1882
  }
1851
1883
  for (const { fileName, domain } of listDomainLearningFiles(roots.repoLearningsBase, resolvedUserId, fileType)) {
1852
- out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'project', domain, 'hot', threshold));
1884
+ out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'project', domain, 'hot', threshold, usageClock, usageStats));
1853
1885
  }
1854
1886
  }
1855
1887
  return out;
@@ -1880,6 +1912,25 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
1880
1912
  }
1881
1913
  return out;
1882
1914
  }
1915
+ /** True for a domain value that means "the undecorated global file". */
1916
+ function isGlobalDomain(domain) {
1917
+ return !domain || domain === 'global';
1918
+ }
1919
+ /** Normalize a caller-supplied domain to one of the known domains, or 'global'. */
1920
+ function normalizeLearningDomain(domain) {
1921
+ if (isGlobalDomain(domain))
1922
+ return 'global';
1923
+ const found = learning_domains_1.LEARNING_DOMAINS.find((d) => d === domain);
1924
+ return found ?? 'global';
1925
+ }
1926
+ function learningFileName(userId, fileType, domain) {
1927
+ // Issue #1755 / #754: normalize here rather than trusting the caller. The HTTP
1928
+ // layer already allowlists the domain, but this function builds a path and a
1929
+ // direct caller passing `../../escape` would otherwise write outside the
1930
+ // learnings root. Anything not in the known set resolves to the global file.
1931
+ const safe = normalizeLearningDomain(domain);
1932
+ return safe === 'global' ? `${userId}-${fileType}.md` : `${userId}-${safe}-${fileType}.md`;
1933
+ }
1883
1934
  function resolveLearningFilePath(workspaceRoot, userId, ref) {
1884
1935
  const roots = getLearningRoots(workspaceRoot);
1885
1936
  const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
@@ -1887,12 +1938,12 @@ function resolveLearningFilePath(workspaceRoot, userId, ref) {
1887
1938
  // Write org entries to the org pack home — same location share-with-others uses.
1888
1939
  // The Hub read path already merges pack home + repo-local, so edits land where
1889
1940
  // the agent reads them without needing a repo commit.
1890
- return (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings', `org-${fileType}.md`);
1941
+ return (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings', learningFileName('org', fileType, ref.domain));
1891
1942
  }
1892
1943
  const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
1893
1944
  // Issue #1070: for the 'machine' level use the configured manager home.
1894
1945
  const base = ref.level === 'project' ? roots.repoLearningsBase : roots.managerHomeBase;
1895
- return (0, path_1.join)(base, `${resolvedUserId}-${fileType}.md`);
1946
+ return (0, path_1.join)(base, learningFileName(resolvedUserId, fileType, ref.domain));
1896
1947
  }
1897
1948
  function titleCaseFromFileType(fileType) {
1898
1949
  return fileType.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
@@ -1916,9 +1967,10 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
1916
1967
  const body = String(payload.body || '').trim();
1917
1968
  const block = [`#### [${sev}] ${title}`, '', body, '', '---'].join(nl);
1918
1969
  const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
1970
+ const addDomain = normalizeLearningDomain(ref.domain);
1919
1971
  const preamble = existing.trim()
1920
1972
  ? existing.replace(/\s*$/, '')
1921
- : `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileType)}`;
1973
+ : `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileType)}${addDomain === 'global' ? '' : ` (${addDomain})`}`;
1922
1974
  write(preamble + nl + nl + block);
1923
1975
  return { ok: true, path: filePath };
1924
1976
  }
@@ -1935,8 +1987,12 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
1935
1987
  write(next.join(nl).replace(/\n{3,}/g, '\n\n'));
1936
1988
  return { ok: true, path: filePath };
1937
1989
  }
1938
- // edit: keep the entry's metadata lines (Score/Last seen/Recurrences/...) intact,
1990
+ // edit: keep the entry's metadata lines (Id/Score/Last seen/Recurrences/...) intact,
1939
1991
  // replace only the heading (priority + title) and the prose body.
1992
+ //
1993
+ // Issue #1755: `Id` is in this list. It used not to be, so every Hub edit dropped
1994
+ // the entry's stable identifier and the usage record keyed to it (#1752) was
1995
+ // orphaned: silently, and permanently once the title also changed.
1940
1996
  const headerMatch = lines[start].match(headingRe);
1941
1997
  const sev = (payload.severity || headerMatch[1]);
1942
1998
  const title = (payload.title !== undefined ? payload.title : headerMatch[2]).trim();
@@ -1945,11 +2001,32 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
1945
2001
  const t = l.trim();
1946
2002
  if (t === '---')
1947
2003
  continue;
1948
- if (/^\*\*(Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t) || /^(First|Last) synthesized:/i.test(t))
2004
+ if (/^\*\*(Id|Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t) || /^(First|Last) synthesized:/i.test(t))
1949
2005
  meta.push(l);
1950
2006
  }
1951
2007
  const body = String(payload.body !== undefined ? payload.body : '').trim();
1952
2008
  const rebuilt = [`#### [${sev}] ${title}`, '', ...meta, ...(meta.length ? [''] : []), body, '', '---'];
2009
+ // Issue #1755 R2/R4: a domain change is a move between files, because the domain
2010
+ // IS the filename. Cut the rebuilt block from the source file and append it to the
2011
+ // destination, so the entry keeps its id, score and history rather than being
2012
+ // re-created as a new entry with no past.
2013
+ const targetDomain = payload.targetDomain === undefined ? undefined : normalizeLearningDomain(payload.targetDomain);
2014
+ if (targetDomain !== undefined && targetDomain !== normalizeLearningDomain(ref.domain)) {
2015
+ const destPath = resolveLearningFilePath(workspaceRoot, userId, { ...ref, domain: targetDomain });
2016
+ const destExisting = (0, fs_1.existsSync)(destPath) ? (0, fs_1.readFileSync)(destPath, 'utf8') : '';
2017
+ const destNl = destExisting.includes('\r\n') ? '\r\n' : nl;
2018
+ const fileTypeForHeader = exports.CATEGORY_TO_FILETYPE[ref.category];
2019
+ const destPreamble = destExisting.trim()
2020
+ ? destExisting.replace(/\s*$/, '')
2021
+ : `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileTypeForHeader)}${targetDomain === 'global' ? '' : ` (${targetDomain})`}`;
2022
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(destPath), { recursive: true });
2023
+ (0, fs_1.writeFileSync)(destPath, (destPreamble + destNl + destNl + rebuilt.join(destNl)).replace(/\s*$/, '') + destNl, 'utf8');
2024
+ // Remove from the source only after the destination write succeeded, so a
2025
+ // failure mid-move duplicates rather than loses the entry.
2026
+ const without = [...lines.slice(0, start), ...lines.slice(end)];
2027
+ write(without.join(nl).replace(/\n{3,}/g, '\n\n'));
2028
+ return { ok: true, path: destPath };
2029
+ }
1953
2030
  const next = [...lines.slice(0, start), ...rebuilt, ...lines.slice(end)];
1954
2031
  write(next.join(nl).replace(/\n{3,}/g, '\n\n'));
1955
2032
  return { ok: true, path: filePath };
@@ -21,15 +21,10 @@ const learning_usage_attestation_1 = require("./learning-usage-attestation");
21
21
  const learning_usage_verify_1 = require("./learning-usage-verify");
22
22
  const pack_home_1 = require("../cli/utils/pack-home");
23
23
  /**
24
- * The repair for a usage record that has gone wrong.
25
- *
26
- * The record is derived (#1103 R17), so the fix is to rebuild it rather than to
27
- * reconcile it in place. Measured on a 733-file retrospective corpus: a rebuild
28
- * lands with zero split identities and one model bucket, because the identity
29
- * resolution and the model normalization fix the causes and a rebuilt store
30
- * carries no legacy shape.
24
+ * Preserve checkpointed history and distinguish backup recovery from attestation backfill.
25
+ * Backfill supplements older firings; it cannot reconstruct missing offers.
31
26
  */
32
- const REBUILD_NOTE = 'Delete the `usage/` directory and run `learning-usage backfill` to rebuild the record from the retrospectives. The record is derived: every firing is also written in the retrospective that attested it, so a rebuild restores one identity per lesson and one bucket per model. It costs the offer history, which rebuilds itself from live traffic.';
27
+ const REBUILD_NOTE = "Keep `learning-usage.json`, its `.checkpoint` backup, `events/` and any conflict copies. After consolidation, the snapshot and checkpoint backup preserve lifetime counters and already-counted source markers; the remaining journal cannot rebuild that history. A missing or torn snapshot is read from its checkpoint backup automatically. Restore matching checkpoint and journal files from backup if both copies are damaged. Inspect split identities and conflict copies before repairing them; do not delete the snapshot to clear a warning. Separately, `learning-usage backfill` can recover older firings from retrospectives; raw learnings and retrospectives cannot restore runtime offers.";
33
28
  function resolveWorkspaceRoot(options) {
34
29
  return node_path_1.default.resolve(options.workspaceRoot || options.root || process.cwd());
35
30
  }
@@ -488,14 +483,14 @@ const verifyCommand = addModelOption(addCommonOptions(new commander_1.Command('v
488
483
  for (const copy of report.storeCopies) {
489
484
  lines.push(` ${copy.path}: ${copy.readable ? `${copy.entries} entries, ${copy.offers} offers, ${copy.firings} firings` : `unreadable (${copy.error ?? 'unknown error'})`}`);
490
485
  }
491
- lines.push(' Nothing reads these. Their history is recoverable only through a rebuild.');
486
+ lines.push(' Nothing reads these. Preserve them and the checkpoint backup for inspection; do not delete snapshots to clear this warning.');
492
487
  }
493
488
  lines.push(` event logs folded: ${report.eventLogs.length === 0 ? 'none' : report.eventLogs.map((l) => `${l.machineId} (${l.bytes} bytes)`).join(', ')}`);
494
489
  for (const [machineId, lag] of Object.entries(report.checkpointLag)) {
495
490
  lines.push(` checkpoint lag for ${machineId}: ${lag} bytes${lag > 0 ? ' behind the log head' : ''}`);
496
491
  }
497
492
  lines.push(report.compactionAdvised
498
- ? ` log size ${mb(report.totalLogBytes)} is past the ${mb(report.compactionAdvisoryBytes)} point where a cold fold stops fitting the context-injection budget. Run \`learning-usage prune\` to compact it to the configured retention window.`
493
+ ? ` log size ${mb(report.totalLogBytes)} is past the ${mb(report.compactionAdvisoryBytes)} point where a cold fold stops fitting the context-injection budget. Run \`learning-usage prune\` to consolidate processed events into the snapshot and checkpoint backup, then clear them independently of retention.`
499
494
  : ` log size ${mb(report.totalLogBytes)} of ${mb(report.compactionAdvisoryBytes)} before compaction is worth running.`);
500
495
  if (report.rebuildAdvised)
501
496
  lines.push('', REBUILD_NOTE);
@@ -7,43 +7,17 @@ exports.listEventLogs = listEventLogs;
7
7
  exports.readEventsSince = readEventsSince;
8
8
  exports.compactEventLogs = compactEventLogs;
9
9
  /**
10
- * Issue #1752 — the durable half of the usage record.
10
+ * Per-machine usage journals: job runs append complete JSON lines; sleep folds
11
+ * them into a recoverable snapshot and removes processed records.
11
12
  *
12
- * The store used to be one mutable JSON document rewritten by every writer with a
13
- * plain `writeFileSync`. Measured (spike S1b): eight concurrent processes doing
14
- * that on a *plain local disk*, no sync folder involved, lost 1589 of 1600
15
- * writes. The four OneDrive conflict copies in issue #1752 are a symptom of the
16
- * same defect, not its cause.
13
+ * A cleared journal contains only {_usageJournalThroughBytes:N}. This framing
14
+ * marker preserves its logical position across atomic replacement, so a crash
15
+ * between checkpoint publication and journal cleanup cannot reset offsets or
16
+ * count an event twice. It contains no activity details. New appends continue
17
+ * from N; an older/missing snapshot cannot silently cold-fold compacted history.
17
18
  *
18
- * So the durable artifact is an append-only log, one file per machine:
19
- *
20
- * OFFER/FIRING event → appendFileSync of one whole JSON line
21
- * usage/learning-usage.json → a derived snapshot with a byte checkpoint
22
- *
23
- * Three measurements decided the shape, and each closed off an alternative:
24
- *
25
- * S2 16 processes x 1000 lines on the real OneDrive-synced path produced
26
- * 16000/16000 complete parseable lines, 0 interleaved, 0 conflict copies.
27
- * That is what removes the need for a same-machine lock. The per-machine
28
- * filename is what removes the cross-machine question, because two
29
- * machines then never write one file.
30
- * S3 `renameSync` over a target any process holds open fails EPERM 100 times
31
- * in 100 on Windows, on a synced path *and* a local one, because Node's
32
- * `openSync(path,'r')` does not request FILE_SHARE_DELETE. The usage store
33
- * is read by concurrent MCP processes by design, so rename-publish is not
34
- * available here. Recorded so it is not re-proposed. See also #820.
35
- * S4b A full fold is linear: 84 ms at today's volume, 794 ms at ten times it.
36
- * A snapshot plus a tail fold is 2.6 ms at both 9.9 MB and 79 MB. Flat.
37
- *
38
- * This module owns the log files and nothing else: framing, appending, reading a
39
- * tail from a byte offset, and compaction. It knows a line is a JSON object and
40
- * knows nothing about what a usage record looks like. That is deliberate — the
41
- * record shape lives in `learning-usage-store.ts`, which imports this, so there
42
- * is no cycle between them.
43
- *
44
- * The same primitive is already used by `src/ai-hub/raw-event-log-store.ts`
45
- * (append one JSON line, track byte offsets); this is the learning-usage
46
- * equivalent rather than a second general mechanism.
19
+ * Maintenance requires no concurrent writers or sync activity. Ordinary writers
20
+ * append one payload; different machines use different filenames.
47
21
  */
48
22
  const fs_1 = require("fs");
49
23
  const os_1 = require("os");
@@ -51,6 +25,7 @@ const path_1 = require("path");
51
25
  /** Subdirectory of the usage folder holding the per-machine logs. */
52
26
  const EVENT_DIR_NAME = 'events';
53
27
  const LOG_SUFFIX = '.jsonl';
28
+ const CHECKPOINT_MARKER = '_usageJournalThroughBytes';
54
29
  /** The log directory that belongs to a snapshot: `<usage>/events/`. */
55
30
  function resolveEventDir(snapshotPath) {
56
31
  return (0, path_1.join)((0, path_1.dirname)(snapshotPath), EVENT_DIR_NAME);
@@ -136,10 +111,25 @@ function listEventLogs(eventDir) {
136
111
  continue;
137
112
  const path = (0, path_1.join)(eventDir, name);
138
113
  let size = 0;
114
+ let baseOffset = 0;
115
+ let headerBytes = 0;
139
116
  try {
140
117
  const fd = (0, fs_1.openSync)(path, 'r');
141
118
  try {
142
119
  size = (0, fs_1.fstatSync)(fd).size;
120
+ const prefix = Buffer.alloc(Math.min(size, 128));
121
+ (0, fs_1.readSync)(fd, prefix, 0, prefix.length, 0);
122
+ const newline = prefix.indexOf(0x0a);
123
+ if (newline >= 0) {
124
+ try {
125
+ const header = JSON.parse(prefix.subarray(0, newline).toString('utf8'));
126
+ if (Object.keys(header).length === 1 && Number.isSafeInteger(header[CHECKPOINT_MARKER]) && header[CHECKPOINT_MARKER] >= 0) {
127
+ baseOffset = header[CHECKPOINT_MARKER];
128
+ headerBytes = newline + 1;
129
+ }
130
+ }
131
+ catch { /* An ordinary first event, not a checkpoint marker. */ }
132
+ }
143
133
  }
144
134
  finally {
145
135
  (0, fs_1.closeSync)(fd);
@@ -148,14 +138,20 @@ function listEventLogs(eventDir) {
148
138
  catch {
149
139
  continue;
150
140
  }
151
- logs.push({ machineId: name.slice(0, -LOG_SUFFIX.length), path, size });
141
+ logs.push({ machineId: name.slice(0, -LOG_SUFFIX.length), path, size, baseOffset, headerBytes });
152
142
  }
153
143
  return logs.sort((a, b) => a.machineId.localeCompare(b.machineId));
154
144
  }
155
145
  function readTail(log, from) {
156
- const restarted = from > log.size;
146
+ const end = log.baseOffset + log.size - log.headerBytes;
147
+ if (from < log.baseOffset)
148
+ throw new Error('Usage checkpoint is missing or older than the consolidated journal. Restore learning-usage.json or its .checkpoint backup; raw retrospectives cannot restore runtime offers.');
149
+ const restarted = from > end;
150
+ if (restarted && log.baseOffset > 0)
151
+ throw new Error('Usage journal is behind its checkpoint; restore matching files before continuing.');
157
152
  const start = restarted ? 0 : Math.max(0, from);
158
- const length = log.size - start;
153
+ const physicalStart = start - log.baseOffset + log.headerBytes;
154
+ const length = log.size - physicalStart;
159
155
  if (length <= 0) {
160
156
  return { ...log, records: [], skippedLines: 0, through: start, restarted };
161
157
  }
@@ -164,7 +160,7 @@ function readTail(log, from) {
164
160
  const fd = (0, fs_1.openSync)(log.path, 'r');
165
161
  try {
166
162
  const buffer = Buffer.alloc(length);
167
- const read = (0, fs_1.readSync)(fd, buffer, 0, length, start);
163
+ const read = (0, fs_1.readSync)(fd, buffer, 0, length, physicalStart);
168
164
  text = buffer.subarray(0, read).toString('utf8');
169
165
  }
170
166
  finally {
@@ -202,7 +198,7 @@ function readTail(log, from) {
202
198
  skippedLines += 1;
203
199
  }
204
200
  }
205
- return { ...log, records, skippedLines, through: log.size - partialBytes, restarted };
201
+ return { ...log, records, skippedLines, through: end - partialBytes, restarted };
206
202
  }
207
203
  /**
208
204
  * Read every log's tail past its checkpoint.
@@ -220,32 +216,42 @@ function readEventsSince(eventDir, checkpoints) {
220
216
  };
221
217
  }
222
218
  /**
223
- * Rewrite every log keeping only the records `keep` accepts.
224
- *
225
- * Required rather than an optimization: S4 measured a cold full fold growing
226
- * linearly to 794 ms at ten times today's volume, and the checkpoint only makes a
227
- * cold fold rare, not bounded.
228
- *
229
- * Published by writing a temp file and renaming it. That is safe here and not on
230
- * the snapshot (S3), because nothing holds a compacted log open for reading in
231
- * the way concurrent MCP processes hold the snapshot: this runs from the CLI, and
232
- * a failed rename leaves the original log untouched.
219
+ * Clear fully checkpointed logs whose records are all understood by this reader.
220
+ * Preserve unknown, malformed, partial or newly appended logs byte-for-byte.
221
+ * Flush then atomically replace each log with its logical-position header; the
222
+ * checkpoint saved before this call remains correct on either side of a crash.
233
223
  */
234
- function compactEventLogs(eventDir, keep) {
235
- const result = { scanned: 0, kept: 0, dropped: 0, skippedLines: 0, logs: [] };
224
+ function compactEventLogs(eventDir, keep, checkpoints) {
225
+ const result = { scanned: 0, kept: 0, dropped: 0, skippedLines: 0, logs: [], checkpoints: { ...checkpoints } };
236
226
  for (const log of listEventLogs(eventDir)) {
237
- const read = readTail(log, 0);
227
+ const read = readTail(log, log.baseOffset);
238
228
  result.scanned += read.records.length;
239
229
  result.skippedLines += read.skippedLines;
230
+ result.kept += read.records.length;
231
+ // The snapshot knows nothing about a later append or torn
232
+ // tail. Leave that log and its old checkpoint alone for the next fold.
233
+ if (read.through !== log.baseOffset + log.size - log.headerBytes
234
+ || read.through !== (checkpoints[log.machineId] ?? 0) || read.skippedLines > 0)
235
+ continue;
240
236
  const retained = read.records.filter(keep);
241
- result.kept += retained.length;
242
- result.dropped += read.records.length - retained.length;
243
- if (retained.length === read.records.length && read.skippedLines === 0)
237
+ // Unknown records may belong to a newer reader. Leave their whole log
238
+ // intact so their original positions remain meaningful.
239
+ if (retained.length > 0 || read.records.length === 0)
244
240
  continue;
245
241
  const temp = `${log.path}.compacting`;
246
242
  try {
247
- (0, fs_1.writeFileSync)(temp, retained.length ? `${retained.map((r) => JSON.stringify(r)).join('\n')}\n` : '', 'utf8');
243
+ const payload = `${JSON.stringify({ [CHECKPOINT_MARKER]: read.through })}\n`;
244
+ (0, fs_1.writeFileSync)(temp, payload, { encoding: 'utf8', flush: true });
245
+ if ((0, fs_1.statSync)(log.path).size !== log.size) {
246
+ (0, fs_1.rmSync)(temp, { force: true });
247
+ continue;
248
+ }
248
249
  (0, fs_1.renameSync)(temp, log.path);
250
+ const dropped = read.records.length - retained.length;
251
+ result.kept -= dropped;
252
+ result.dropped += dropped;
253
+ // The logical checkpoint remains unchanged across the atomic replacement.
254
+ result.checkpoints[log.machineId] = read.through;
249
255
  }
250
256
  catch {
251
257
  // The log is authoritative and the original is still intact, so a failed
@@ -10,6 +10,7 @@ exports.ruleUsageKey = ruleUsageKey;
10
10
  exports.resolveEntryIdentity = resolveEntryIdentity;
11
11
  exports.normalizeModelName = normalizeModelName;
12
12
  exports.readUsageSnapshot = readUsageSnapshot;
13
+ exports.resolveUsageCheckpointPath = resolveUsageCheckpointPath;
13
14
  exports.writeUsageSnapshot = writeUsageSnapshot;
14
15
  exports.foldUsageEvents = foldUsageEvents;
15
16
  exports.readUsageStore = readUsageStore;
@@ -46,18 +47,10 @@ exports.compactUsageEvents = compactUsageEvents;
46
47
  * (`src/ai-hub/server.ts`). That placement is what satisfies R18; there is no
47
48
  * second guardrail here.
48
49
  *
49
- * It is derived data (R17): deleting it loses no lesson, because every firing is
50
- * still written in the retrospective that attested it and every offer is
51
- * re-recorded the next time the entry is delivered.
52
- *
53
- * ## Issue #1752 — what changed and why
54
- *
55
- * The durable artifact is now the append-only event log in
56
- * `learning-usage-events.ts`; `usage/learning-usage.json` is a derived snapshot
57
- * carrying a `foldedThroughBytes` checkpoint into it. The old read-modify-write
58
- * lost 1589 of 1600 writes from eight concurrent processes on a plain local disk
59
- * (spike S1b), which is the real cause of both the impossible `fired > offered`
60
- * counts and the four OneDrive conflict copies.
50
+ * Sleep consolidates the append-only journal into learning-usage.json and its
51
+ * recoverable .checkpoint copy before clearing processed events. Those snapshots
52
+ * preserve lifetime aggregates and source bookmarks after journal cleanup.
53
+ * Raw retrospectives preserve lessons and attestations, not every runtime offer.
61
54
  *
62
55
  * Four smaller corrections ride with it, each removing a place where the record
63
56
  * gave a confident answer it could not prove:
@@ -306,32 +299,45 @@ function readCheckpoints(value) {
306
299
  }
307
300
  return out;
308
301
  }
309
- /**
310
- * Parse the snapshot alone, with no event folding. A missing or corrupt snapshot
311
- * reads as empty: it is derived data, so losing it is recoverable and must never
312
- * break the loader that reads it.
313
- */
302
+ /** Read the normal snapshot, recovering a missing or torn copy from its checkpoint. */
314
303
  function readUsageSnapshot(storePath = resolveUsageStorePath()) {
304
+ for (const candidate of [storePath, resolveUsageCheckpointPath(storePath)]) {
305
+ const snapshot = parseUsageSnapshot(candidate);
306
+ if (snapshot)
307
+ return snapshot;
308
+ }
309
+ if ((0, fs_1.existsSync)(resolveUsageCheckpointPath(storePath))) {
310
+ throw new Error('Usage snapshot and checkpoint backup are unreadable. Restore a matching backup; do not delete the usage directory.');
311
+ }
312
+ return emptyStore();
313
+ }
314
+ function resolveUsageCheckpointPath(storePath) {
315
+ return storePath + '.checkpoint';
316
+ }
317
+ function parseUsageSnapshot(storePath) {
315
318
  try {
316
319
  if (!(0, fs_1.existsSync)(storePath))
317
- return emptyStore();
320
+ return null;
318
321
  const parsed = JSON.parse((0, fs_1.readFileSync)(storePath, 'utf8'));
319
322
  // `typeof [] === 'object'`, so an array has to be rejected explicitly: otherwise
320
323
  // Object.values yields whatever the array held and every consumer that reads
321
324
  // `entry.offered` throws on it.
322
325
  if (!parsed || typeof parsed !== 'object' || !parsed.entries
323
326
  || typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) {
324
- return emptyStore();
327
+ return null;
325
328
  }
326
329
  // Drop any value that is not a usable record rather than handing a caller
327
330
  // something it will crash on. A partially corrupt store degrades to the records
328
- // that are still readable, which is the right behaviour for derived data.
331
+ // that are still readable before consolidation. Consolidated snapshots must be complete.
329
332
  const entries = {};
330
333
  for (const [key, value] of Object.entries(parsed.entries)) {
331
334
  if (isUsableRecord(value))
332
335
  entries[key] = value;
336
+ else if (parsed.consolidated)
337
+ return null;
333
338
  }
334
339
  return {
340
+ consolidated: parsed.consolidated === true,
335
341
  version: parsed.version ?? STORE_VERSION,
336
342
  updatedAt: parsed.updatedAt ?? emptyStore().updatedAt,
337
343
  entries,
@@ -342,28 +348,18 @@ function readUsageSnapshot(storePath = resolveUsageStorePath()) {
342
348
  };
343
349
  }
344
350
  catch {
345
- return emptyStore();
351
+ return null;
346
352
  }
347
353
  }
348
354
  /**
349
- * Publish the derived snapshot.
350
- *
351
- * In place, with a plain `writeFileSync`, and never by rename. S3 measured a
352
- * rename over a target any process holds open failing EPERM 100 times in 100, on
353
- * both a synced and a local path, because that is Windows file-sharing semantics
354
- * rather than a OneDrive artefact. The usage store is read by concurrent MCP
355
- * processes by design, so a rename-published snapshot would fail essentially
356
- * always in production. This is safe precisely because the snapshot is derived: a
357
- * torn one is rejected by `readUsageSnapshot` and rebuilt from the log.
358
- *
359
- * Returns whether it landed. A failure is never thrown: the log is authoritative
360
- * and the next reader either uses the older snapshot with a longer tail, or folds
361
- * cold.
355
+ * Refresh the normal snapshot in place (Windows readers can prevent rename).
356
+ * Sleep alone publishes the separate flushed checkpoint backup before clearing
357
+ * journals, so a torn ordinary refresh can recover without replaying old history.
362
358
  */
363
359
  function writeUsageSnapshot(store, storePath = resolveUsageStorePath()) {
364
360
  try {
365
361
  (0, fs_1.mkdirSync)((0, path_1.dirname)(storePath), { recursive: true });
366
- (0, fs_1.writeFileSync)(storePath, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
362
+ (0, fs_1.writeFileSync)(storePath, `${JSON.stringify(store, null, 2)}\n`, { encoding: 'utf8', flush: true });
367
363
  return true;
368
364
  }
369
365
  catch {
@@ -385,6 +381,8 @@ function foldUsageEvents(options) {
385
381
  // A checkpoint past the end of its log means the log was compacted or replaced
386
382
  // under this snapshot, so the snapshot may already count events the log no
387
383
  // longer holds. Re-folding onto it would double count. Discard and fold cold.
384
+ if (tail.restarted && base.consolidated)
385
+ throw new Error('Usage journal no longer matches its consolidated snapshot. Restore matching checkpoint files before continuing.');
388
386
  if (tail.restarted) {
389
387
  const cold = (0, learning_usage_events_1.readEventsSince)(options.eventDir, {});
390
388
  return applyTail(emptyStore(), cold, limits, now, true);
@@ -393,6 +391,7 @@ function foldUsageEvents(options) {
393
391
  }
394
392
  function applyTail(base, tail, limits, now, restarted) {
395
393
  const store = {
394
+ consolidated: base.consolidated,
396
395
  version: base.version,
397
396
  updatedAt: base.updatedAt,
398
397
  entries: base.entries,
@@ -430,13 +429,23 @@ function readUsageStore(storePath = resolveUsageStorePath(), options = {}) {
430
429
  const eventDir = options.eventDir ?? (0, learning_usage_events_1.resolveEventDir)(storePath);
431
430
  if ((0, learning_usage_events_1.listEventLogs)(eventDir).length === 0)
432
431
  return snapshot;
433
- const folded = foldUsageEvents({
432
+ const fold = (base) => foldUsageEvents({
434
433
  eventDir,
435
434
  limits: options.limits,
436
435
  now: options.now,
437
- base: snapshot,
438
- checkpoints: snapshot.foldedThroughBytes ?? {},
436
+ base,
437
+ checkpoints: base.foldedThroughBytes ?? {},
439
438
  });
439
+ let folded;
440
+ try {
441
+ folded = fold(snapshot);
442
+ }
443
+ catch (error) {
444
+ const backup = parseUsageSnapshot(resolveUsageCheckpointPath(storePath));
445
+ if (!backup)
446
+ throw error;
447
+ folded = fold(backup);
448
+ }
440
449
  const mode = options.refreshSnapshot ?? 'auto';
441
450
  const shouldRefresh = mode === 'always'
442
451
  || (mode === 'auto' && (folded.tailBytes >= SNAPSHOT_REFRESH_BYTES || !(0, fs_1.existsSync)(storePath) || folded.restarted));
@@ -747,7 +756,7 @@ function flagIntegrity(store) {
747
756
  /**
748
757
  * Materialize the snapshot when there is none.
749
758
  *
750
- * The snapshot is derived and normally published by readers, but a workspace that
759
+ * Before the first consolidation, readers publish snapshots, but a workspace that
751
760
  * has only ever been written to would have no snapshot at all, so every reader
752
761
  * would pay a cold fold and anything inspecting the store as a file would find
753
762
  * nothing there. Bounded: it only runs when the file is absent, and at that point
@@ -761,7 +770,7 @@ function ensureSnapshotExists(storePath, eventDir, limits, now) {
761
770
  readUsageStore(storePath, { limits, now, eventDir, refreshSnapshot: 'always' });
762
771
  }
763
772
  catch {
764
- // The log is authoritative. A missing snapshot costs a cold fold, nothing else.
773
+ // Initial cache publication can retry; consolidated history has a separate backup.
765
774
  }
766
775
  }
767
776
  /**
@@ -899,26 +908,48 @@ function pruneUsageStore(limits, options = {}) {
899
908
  const compaction = compactUsageEvents({ eventDir, snapshotPath: storePath, limits, now, store });
900
909
  return { prunedOfferRecords, prunedFiringRecords, entries: Object.keys(store.entries).length, compaction };
901
910
  }
902
- /**
903
- * Rewrite the event logs to the configured retention window and re-base the
904
- * snapshot's checkpoint onto the rewritten logs.
905
- *
906
- * Re-basing is not optional: leaving a checkpoint pointing into the old log would
907
- * make the next reader either re-count events the snapshot already holds, or skip
908
- * ones it does not.
911
+ /** Save totals and source bookmarks in both copies, then clear processed journals.
912
+ * Requires an interval without concurrent writers or sync activity.
909
913
  */
910
914
  function compactUsageEvents(options) {
911
915
  const now = options.now ?? new Date();
912
916
  const store = options.store
913
917
  ?? readUsageStore(options.snapshotPath, { limits: options.limits, now, eventDir: options.eventDir });
914
- const result = (0, learning_usage_events_1.compactEventLogs)(options.eventDir, (event) => {
918
+ for (const log of (0, learning_usage_events_1.listEventLogs)(options.eventDir)) {
919
+ if ((store.foldedThroughBytes?.[log.machineId] ?? 0) < log.baseOffset) {
920
+ throw new Error('Cannot consolidate from a snapshot older than the journal checkpoint.');
921
+ }
922
+ }
923
+ for (const entry of Object.values(store.entries))
924
+ trimLogs(entry, options.limits, now);
925
+ store.consolidated = true;
926
+ store.updatedAt = now.toISOString();
927
+ // Publish the recoverable checkpoint first, then the normal snapshot. No
928
+ // journal is cleared unless both writes succeed. The backup is never written
929
+ // by ordinary readers, so a torn cache refresh cannot destroy both copies.
930
+ const checkpoint = resolveUsageCheckpointPath(options.snapshotPath);
931
+ const temp = checkpoint + '.tmp';
932
+ try {
933
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(checkpoint), { recursive: true });
934
+ (0, fs_1.writeFileSync)(temp, JSON.stringify(store, null, 2) + '\n', { encoding: 'utf8', flush: true });
935
+ (0, fs_1.renameSync)(temp, checkpoint);
936
+ }
937
+ catch {
938
+ try {
939
+ (0, fs_1.rmSync)(temp, { force: true });
940
+ }
941
+ catch { /* Best effort. */ }
942
+ throw new Error('Cannot consolidate usage events: checkpoint backup could not be saved.');
943
+ }
944
+ if (!writeUsageSnapshot(store, options.snapshotPath)) {
945
+ throw new Error('Cannot consolidate usage events: aggregate snapshot could not be saved.');
946
+ }
947
+ return (0, learning_usage_events_1.compactEventLogs)(options.eventDir, (event) => {
948
+ if (event.kind !== 'offer' && event.kind !== 'firing')
949
+ return true;
915
950
  const date = typeof event.date === 'string' ? event.date : '';
916
- if (!isValidDate(date))
951
+ if (typeof event.key !== 'string' || !event.key || !isValidDate(date))
917
952
  return true;
918
- return withinRetention(date, options.limits.retentionDays, now);
919
- });
920
- store.foldedThroughBytes = Object.fromEntries(result.logs.map((log) => [log.machineId, log.size]));
921
- store.updatedAt = now.toISOString();
922
- writeUsageSnapshot(store, options.snapshotPath);
923
- return result;
953
+ return event.kind === 'firing' && !exports.FIRING_OUTCOMES.includes(event.outcome);
954
+ }, store.foldedThroughBytes ?? {});
924
955
  }
@@ -16,31 +16,16 @@ exports.buildVerifyReport = buildVerifyReport;
16
16
  * It reports and never blocks. Nothing here treats a finding as a gate: it states
17
17
  * what it found, and the manager decides.
18
18
  *
19
- * ## Why there is no repair command here
19
+ * ## Recovery boundary
20
20
  *
21
- * The usage record is derived data (#1103 R17): every firing is also written in
22
- * the retrospective that attested it, and every offer is re-recorded the next
23
- * time the entry is delivered. So the repair for a store that has gone wrong is
24
- * to delete it and rebuild, not to reconcile it in place.
25
- *
26
- * That is not a preference, it is what the numbers say. Measured on a 733-file
27
- * retrospective corpus with four unmerged store copies beside the canonical one:
28
- *
29
- * delete `usage/` then `learning-usage backfill` -> 149 entries, 2874 firings,
30
- * 0 split identities,
31
- * 1 clean model bucket
32
- *
33
- * A rebuild lands with zero split identities and one model bucket for free,
34
- * because the identity resolution and the model normalization fix the causes and
35
- * a rebuilt store carries no legacy shape. The only thing an in-place
36
- * reconciliation recovers beyond that is old firings whose retrospectives
37
- * retention has already deleted, and a decay clock reads the most recent firing
38
- * rather than the count. What a rebuild does cost is the offer history, which
39
- * rebuilds itself from live traffic and degrades toward `undetermined` and
40
- * hold-back in the meantime, which is this module's posture anyway.
41
- *
42
- * So `verify` names the rebuild when it finds a stale shape, and the destructive
43
- * step stays the manager's.
21
+ * Rebuild only the snapshot, keeping the event directory intact. The retained
22
+ * events recover the offers and firings they cover; retrospective backfill can
23
+ * supplement older firings but cannot recreate offers. Normal compaction keeps
24
+ * lifetime aggregate totals in the snapshot, so a backup is required before
25
+ * discarding it if history already pruned from events must remain inspectable.
26
+ * Rebuilding cannot invent missing history or fix identities still present in
27
+ * the authoritative events. Verify reports those limitations rather than
28
+ * claiming that deleting the usage directory is a lossless repair.
44
29
  */
45
30
  const fs_1 = require("fs");
46
31
  const path_1 = require("path");
@@ -212,7 +197,7 @@ function buildVerifyReport(options) {
212
197
  // The one number that makes a stale read visible: a snapshot that stops
213
198
  // advancing while the log grows is a failing snapshot write, which S3 showed
214
199
  // will happen intermittently on a synced folder and is otherwise silent.
215
- checkpointLag: Object.fromEntries(logs.map((log) => [log.machineId, Math.max(0, log.size - (checkpoints[log.machineId] ?? 0))])),
200
+ checkpointLag: Object.fromEntries(logs.map((log) => [log.machineId, Math.max(0, log.baseOffset + log.size - log.headerBytes - (checkpoints[log.machineId] ?? 0))])),
216
201
  skippedLines: 0,
217
202
  totalLogBytes,
218
203
  compactionAdvisoryBytes: exports.COMPACTION_ADVISORY_BYTES,
@@ -2428,6 +2428,35 @@ class FraimLocalMCPServer {
2428
2428
  if (request.method === 'tools/call' && typeof toolName === 'string') {
2429
2429
  // 🔍 SMART DISPATCHER: Intercept mentoring and job/workflow tools for local overrides
2430
2430
  if (toolName === 'seekMentoring') {
2431
+ // #1766: report an unrenderable supplied handoff before any mentoring
2432
+ // success response, including when phase-policy enforcement is disabled.
2433
+ // Match the host's direct/evidence/findings precedence and JSON support.
2434
+ const suppliedHandoffs = [args.reviewHandoff, args.evidence?.reviewHandoff, args.findings?.reviewHandoff]
2435
+ .filter((value) => value !== undefined && value !== null)
2436
+ .map((value) => {
2437
+ if (typeof value !== 'string')
2438
+ return value;
2439
+ try {
2440
+ return JSON.parse(value);
2441
+ }
2442
+ catch {
2443
+ return value;
2444
+ }
2445
+ });
2446
+ const suppliedHandoff = suppliedHandoffs.find((value) => value && typeof value === 'object' && !Array.isArray(value) && typeof value.reviewRequired === 'boolean') ?? suppliedHandoffs[0];
2447
+ if (suppliedHandoffs.length > 0) {
2448
+ const normalizationError = (0, handoff_contracts_1.reviewHandoffNormalizationError)(suppliedHandoff);
2449
+ if (normalizationError) {
2450
+ return {
2451
+ jsonrpc: '2.0',
2452
+ id: request.id,
2453
+ result: {
2454
+ isError: true,
2455
+ content: [{ type: 'text', text: `Review handoff was not applied: ${normalizationError}. This response does not confirm delivery.` }],
2456
+ },
2457
+ };
2458
+ }
2459
+ }
2431
2460
  try {
2432
2461
  const mentor = this.getMentor(requestSessionId);
2433
2462
  const tutoringResponse = await mentor.handleMentoringRequest(args);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.339",
3
+ "version": "2.0.340",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "dist/registry/scripts/learning/learning-domains.json",
11
11
  "dist/registry/scripts/learning/entry-format.js",
12
+ "dist/registry/scripts/learning/completion-state.js",
12
13
  "dist/src/local-mcp-server/",
13
14
  "dist/src/cli/",
14
15
  "!dist/src/cli/fraim-hub.js",