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.
- package/dist/registry/scripts/learning/completion-state.js +153 -0
- package/dist/src/cli/commands/cleanup-artifacts.js +2 -2
- package/dist/src/core/ai-mentor.js +1 -1
- package/dist/src/core/handoff-contracts.js +43 -16
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +36 -9
- package/dist/src/local-mcp-server/learning-context-builder.js +96 -19
- package/dist/src/local-mcp-server/learning-usage-command.js +5 -10
- package/dist/src/local-mcp-server/learning-usage-events.js +63 -57
- package/dist/src/local-mcp-server/learning-usage-store.js +86 -55
- package/dist/src/local-mcp-server/learning-usage-verify.js +10 -25
- package/dist/src/local-mcp-server/stdio-server.js +29 -0
- package/package.json +2 -1
|
@@ -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
|
|
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>', '
|
|
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
|
-
*
|
|
259
|
-
*
|
|
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 = (
|
|
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(
|
|
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(
|
|
171
|
-
|
|
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
|
|
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(
|
|
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
|
|
198
|
-
|
|
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(
|
|
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
|
|
516
|
+
return {};
|
|
507
517
|
const lookup = projection.buildUsageLookup(loaded);
|
|
508
|
-
return
|
|
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
|
|
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
|
-
|
|
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',
|
|
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,
|
|
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
|
-
*
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
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:
|
|
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
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
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,
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
if (retained.length
|
|
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
|
-
|
|
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
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
351
|
+
return null;
|
|
346
352
|
}
|
|
347
353
|
}
|
|
348
354
|
/**
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
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
|
|
432
|
+
const fold = (base) => foldUsageEvents({
|
|
434
433
|
eventDir,
|
|
435
434
|
limits: options.limits,
|
|
436
435
|
now: options.now,
|
|
437
|
-
base
|
|
438
|
-
checkpoints:
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
-
* ##
|
|
19
|
+
* ## Recovery boundary
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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.
|
|
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",
|