fraim-framework 2.0.170 → 2.0.173

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/src/ai-hub/hosts.js +227 -6
  2. package/dist/src/ai-hub/server.js +1014 -35
  3. package/dist/src/cli/commands/add-ide.js +4 -2
  4. package/dist/src/cli/commands/cleanup-artifacts.js +38 -0
  5. package/dist/src/cli/commands/init-project.js +12 -5
  6. package/dist/src/cli/commands/setup.js +1 -1
  7. package/dist/src/cli/commands/sync.js +74 -7
  8. package/dist/src/cli/doctor/checks/ide-config-checks.js +2 -2
  9. package/dist/src/cli/fraim.js +2 -0
  10. package/dist/src/cli/mcp/ide-formats.js +10 -2
  11. package/dist/src/cli/setup/auto-mcp-setup.js +4 -2
  12. package/dist/src/cli/setup/ide-detector.js +26 -0
  13. package/dist/src/cli/setup/ide-global-integration.js +6 -2
  14. package/dist/src/cli/setup/ide-invocation-surfaces.js +12 -4
  15. package/dist/src/cli/setup/mcp-config-generator.js +12 -1
  16. package/dist/src/cli/utils/agent-adapters.js +42 -17
  17. package/dist/src/cli/utils/fraim-gitignore.js +13 -0
  18. package/dist/src/cli/utils/remote-sync.js +129 -53
  19. package/dist/src/cli/utils/user-config.js +12 -0
  20. package/dist/src/config/ai-manager-hiring.js +121 -0
  21. package/dist/src/config/compat.js +16 -0
  22. package/dist/src/config/feature-flags.js +25 -0
  23. package/dist/src/config/persona-capability-bundles.js +273 -0
  24. package/dist/src/config/persona-hiring.js +270 -0
  25. package/dist/src/config/portfolio-slug-overrides.js +17 -0
  26. package/dist/src/config/pricing.js +37 -0
  27. package/dist/src/config/stripe.js +43 -0
  28. package/dist/src/core/fraim-config-schema.generated.js +8 -2
  29. package/dist/src/core/utils/local-registry-resolver.js +26 -0
  30. package/dist/src/core/utils/project-fraim-paths.js +89 -2
  31. package/dist/src/first-run/session-service.js +12 -3
  32. package/dist/src/local-mcp-server/artifact-retention-cleanup.js +255 -0
  33. package/dist/src/local-mcp-server/learning-context-builder.js +41 -81
  34. package/dist/src/local-mcp-server/stdio-server.js +42 -7
  35. package/package.json +5 -1
  36. package/public/ai-hub/index.html +205 -89
  37. package/public/ai-hub/review.css +12 -0
  38. package/public/ai-hub/script.js +1734 -253
  39. package/public/ai-hub/styles.css +473 -6
@@ -269,7 +269,8 @@ exports.FRAIM_CONFIG_SCHEMA = {
269
269
  "kind": "enum",
270
270
  "values": [
271
271
  "salesforce",
272
- "customereq"
272
+ "customereq",
273
+ "attio"
273
274
  ]
274
275
  }
275
276
  }
@@ -457,7 +458,8 @@ exports.FRAIM_CONFIG_SCHEMA = {
457
458
  "kind": "enum",
458
459
  "values": [
459
460
  "servicenow",
460
- "fixture"
461
+ "fixture",
462
+ "facebook_messenger"
461
463
  ]
462
464
  },
463
465
  "table": {
@@ -489,6 +491,9 @@ exports.FRAIM_CONFIG_SCHEMA = {
489
491
  },
490
492
  "closeState": {
491
493
  "kind": "string"
494
+ },
495
+ "accessScript": {
496
+ "kind": "string"
492
497
  }
493
498
  }
494
499
  },
@@ -678,6 +683,7 @@ exports.SUPPORTED_FRAIM_CONFIG_PATHS = [
678
683
  "automation.support.queue.successState",
679
684
  "automation.support.queue.failureState",
680
685
  "automation.support.queue.closeState",
686
+ "automation.support.queue.accessScript",
681
687
  "automation.support.requestTypes",
682
688
  "automation.support.requestTypes.password_reset",
683
689
  "automation.support.requestTypes.password_reset.mode",
@@ -225,6 +225,23 @@ class LocalRegistryResolver {
225
225
  return null;
226
226
  }
227
227
  }
228
+ readWorkspaceRegistryFile(path) {
229
+ const normalizedPath = path.replace(/\\/g, '/').replace(/^\/+/, '');
230
+ const registryPath = (0, path_1.join)(this.workspaceRoot, 'registry', ...normalizedPath.split('/'));
231
+ if (!(0, fs_1.existsSync)(registryPath)) {
232
+ return null;
233
+ }
234
+ try {
235
+ const content = (0, fs_1.readFileSync)(registryPath, 'utf-8');
236
+ if (this.shouldFilter && this.shouldFilter(content)) {
237
+ return null;
238
+ }
239
+ return content;
240
+ }
241
+ catch {
242
+ return null;
243
+ }
244
+ }
228
245
  normalizeRegistryPath(path) {
229
246
  const normalized = path.trim().replace(/\\/g, '/').replace(/^\/+/, '');
230
247
  return normalized.endsWith('.md') ? normalized : `${normalized}.md`;
@@ -379,6 +396,15 @@ class LocalRegistryResolver {
379
396
  inherited: false
380
397
  };
381
398
  }
399
+ const workspaceRegistryContent = this.readWorkspaceRegistryFile(path);
400
+ if (workspaceRegistryContent !== null) {
401
+ return {
402
+ content: workspaceRegistryContent,
403
+ source: 'local',
404
+ personalized: false,
405
+ inherited: false
406
+ };
407
+ }
382
408
  // No useful override or synced content, fetch from remote
383
409
  try {
384
410
  const rawContent = await this.fetchRemoteWithFallback(path);
@@ -8,6 +8,10 @@ exports.getWorkspaceFraimDir = getWorkspaceFraimDir;
8
8
  exports.workspaceFraimExists = workspaceFraimExists;
9
9
  exports.getWorkspaceConfigPath = getWorkspaceConfigPath;
10
10
  exports.getWorkspaceFraimPath = getWorkspaceFraimPath;
11
+ exports.assertWorkspaceFraimRoot = assertWorkspaceFraimRoot;
12
+ exports.assertWorkspaceFraimPath = assertWorkspaceFraimPath;
13
+ exports.createWorkspaceFraimPathAsserter = createWorkspaceFraimPathAsserter;
14
+ exports.assertExactWorkspaceFraimPath = assertExactWorkspaceFraimPath;
11
15
  exports.getWorkspaceFraimDisplayPath = getWorkspaceFraimDisplayPath;
12
16
  exports.getUserFraimDisplayPath = getUserFraimDisplayPath;
13
17
  exports.getUserFraimDirPath = getUserFraimDirPath;
@@ -35,8 +39,38 @@ function normalizeDisplayPath(fullPath) {
35
39
  function getWorkspaceFraimDir(projectRoot = process.cwd()) {
36
40
  return (0, path_1.join)(projectRoot, exports.WORKSPACE_FRAIM_DIRNAME);
37
41
  }
42
+ function isPathInsideOrEqual(parentPath, childPath) {
43
+ const relativePath = (0, path_1.relative)(parentPath, childPath);
44
+ return relativePath === '' || (relativePath.length > 0 &&
45
+ !relativePath.startsWith('..') &&
46
+ !(0, path_1.isAbsolute)(relativePath));
47
+ }
48
+ function realpathIfExists(fullPath) {
49
+ try {
50
+ return fs_1.realpathSync.native(fullPath);
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ function deepestExistingAncestor(fullPath) {
57
+ let current = (0, path_1.resolve)(fullPath);
58
+ while (!(0, fs_1.existsSync)(current)) {
59
+ const parent = (0, path_1.dirname)(current);
60
+ if (parent === current) {
61
+ return null;
62
+ }
63
+ current = parent;
64
+ }
65
+ return current;
66
+ }
38
67
  function workspaceFraimExists(projectRoot = process.cwd()) {
39
- return (0, fs_1.existsSync)(getWorkspaceFraimDir(projectRoot));
68
+ try {
69
+ return (0, fs_1.readdirSync)(projectRoot, { withFileTypes: true }).some((entry) => entry.isDirectory() && entry.name === exports.WORKSPACE_FRAIM_DIRNAME);
70
+ }
71
+ catch {
72
+ return false;
73
+ }
40
74
  }
41
75
  function getWorkspaceConfigPath(projectRoot = process.cwd()) {
42
76
  return (0, path_1.join)(getWorkspaceFraimDir(projectRoot), 'config.json');
@@ -44,6 +78,59 @@ function getWorkspaceConfigPath(projectRoot = process.cwd()) {
44
78
  function getWorkspaceFraimPath(projectRoot = process.cwd(), ...parts) {
45
79
  return (0, path_1.join)(getWorkspaceFraimDir(projectRoot), ...parts);
46
80
  }
81
+ function assertWorkspaceFraimRoot(projectRoot = process.cwd()) {
82
+ const resolvedProjectRoot = (0, path_1.resolve)(projectRoot);
83
+ const fraimDir = getWorkspaceFraimDir(resolvedProjectRoot);
84
+ if (!workspaceFraimExists(resolvedProjectRoot)) {
85
+ throw new Error(`Refusing to use FRAIM workspace root "${resolvedProjectRoot}": expected exact lowercase fraim directory at "${fraimDir}".`);
86
+ }
87
+ const projectRootRealPath = realpathIfExists(resolvedProjectRoot);
88
+ const fraimRealPath = realpathIfExists(fraimDir);
89
+ if (projectRootRealPath &&
90
+ fraimRealPath &&
91
+ !isPathInsideOrEqual(projectRootRealPath, fraimRealPath)) {
92
+ throw new Error(`Refusing to use FRAIM workspace root "${resolvedProjectRoot}": "${fraimDir}" resolves outside the project root.`);
93
+ }
94
+ return fraimDir;
95
+ }
96
+ function assertWorkspaceFraimPath(projectRoot = process.cwd(), targetPath) {
97
+ createWorkspaceFraimPathAsserter(projectRoot)(targetPath);
98
+ }
99
+ function createWorkspaceFraimPathAsserter(projectRoot = process.cwd()) {
100
+ const fraimDir = assertWorkspaceFraimRoot(projectRoot);
101
+ const resolvedFraimDir = (0, path_1.resolve)(fraimDir);
102
+ const fraimRealPath = realpathIfExists(fraimDir);
103
+ const realpathCache = new Map();
104
+ const cachedRealpath = (fullPath) => {
105
+ const resolvedPath = (0, path_1.resolve)(fullPath);
106
+ if (!realpathCache.has(resolvedPath)) {
107
+ realpathCache.set(resolvedPath, realpathIfExists(resolvedPath));
108
+ }
109
+ return realpathCache.get(resolvedPath) ?? null;
110
+ };
111
+ return (targetPath) => {
112
+ const resolvedTargetPath = (0, path_1.resolve)(targetPath);
113
+ if (resolvedTargetPath === resolvedFraimDir ||
114
+ !isPathInsideOrEqual(resolvedFraimDir, resolvedTargetPath)) {
115
+ throw new Error(`Refusing to use FRAIM workspace path "${resolvedTargetPath}": expected a path under "${resolvedFraimDir}".`);
116
+ }
117
+ const existingAncestor = deepestExistingAncestor(resolvedTargetPath);
118
+ const ancestorRealPath = existingAncestor ? cachedRealpath(existingAncestor) : null;
119
+ if (fraimRealPath &&
120
+ ancestorRealPath &&
121
+ !isPathInsideOrEqual(fraimRealPath, ancestorRealPath)) {
122
+ throw new Error(`Refusing to use FRAIM workspace path "${resolvedTargetPath}": existing path "${existingAncestor}" resolves outside "${fraimDir}".`);
123
+ }
124
+ };
125
+ }
126
+ function assertExactWorkspaceFraimPath(projectRoot = process.cwd(), targetPath, ...parts) {
127
+ const expectedPath = (0, path_1.resolve)(getWorkspaceFraimDir((0, path_1.resolve)(projectRoot)), ...parts);
128
+ const resolvedTargetPath = (0, path_1.resolve)(targetPath);
129
+ if (resolvedTargetPath !== expectedPath) {
130
+ throw new Error(`Refusing to use FRAIM workspace path "${resolvedTargetPath}": expected exact managed path "${expectedPath}".`);
131
+ }
132
+ assertWorkspaceFraimPath(projectRoot, targetPath);
133
+ }
47
134
  function getWorkspaceFraimDisplayPath(relativePath = '') {
48
135
  const normalized = normalizeRelativePath(relativePath);
49
136
  return normalized.length > 0
@@ -120,7 +207,7 @@ function getConfiguredPortableLearningsDisplayPath(projectRoot = process.cwd())
120
207
  function getEffectiveFraimDir(projectRoot = process.cwd(), userFraimDir) {
121
208
  // 1. Check for project-level fraim/ directory
122
209
  const projectFraimDir = getWorkspaceFraimDir(projectRoot);
123
- if ((0, fs_1.existsSync)(projectFraimDir)) {
210
+ if (workspaceFraimExists(projectRoot)) {
124
211
  return projectFraimDir;
125
212
  }
126
213
  // 2. Fall back to user-level ~/.fraim/
@@ -174,8 +174,8 @@ function normalizeRows(rows) {
174
174
  ...(existingById.get(canonical.id) || {}),
175
175
  }));
176
176
  }
177
- function buildConfiguredSurfaces() {
178
- const ides = (0, ide_detector_1.detectInstalledIDEs)();
177
+ function buildRunnableAgentSurfaces() {
178
+ const ides = (0, ide_detector_1.detectInstalledIDEs)('cli-runnable');
179
179
  const hints = (0, ide_global_integration_1.describeOnboardingInvocationSurfaces)(ides);
180
180
  return ides.map((ide, index) => ({
181
181
  id: ide.configType,
@@ -333,7 +333,7 @@ class FirstRunSessionService {
333
333
  byId.set(surface.id, surface);
334
334
  }
335
335
  if (this.fakeMode !== 'no-agents') {
336
- for (const surface of buildConfiguredSurfaces()) {
336
+ for (const surface of buildRunnableAgentSurfaces()) {
337
337
  byId.set(surface.id, surface);
338
338
  }
339
339
  }
@@ -547,6 +547,15 @@ class FirstRunSessionService {
547
547
  this.persist();
548
548
  return this.respond('Fake-mode fraim ok.', true);
549
549
  }
550
+ // FRAIM_SKIP_SYNC signals an isolated/test environment. Skip all external
551
+ // writes (MCP config files, global config.json, slash commands) so tests
552
+ // that auto-trigger this path cannot corrupt the real user config.
553
+ if (process.env.FRAIM_SKIP_SYNC) {
554
+ row.status = 'ok';
555
+ row.verb = 'Ready.';
556
+ this.persist();
557
+ return this.respond('FRAIM configured.', true);
558
+ }
550
559
  try {
551
560
  if (!forceConfigure && !commandVersion('fraim')) {
552
561
  const prefix = path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
@@ -0,0 +1,255 @@
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.ARTIFACT_RETENTION_CATEGORIES = void 0;
7
+ exports.resolveArtifactRetentionConfig = resolveArtifactRetentionConfig;
8
+ exports.runArtifactCleanup = runArtifactCleanup;
9
+ exports.planArtifactCleanup = planArtifactCleanup;
10
+ const node_fs_1 = __importDefault(require("node:fs"));
11
+ const node_path_1 = __importDefault(require("node:path"));
12
+ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
13
+ exports.ARTIFACT_RETENTION_CATEGORIES = [
14
+ 'learning_archive',
15
+ 'retrospectives',
16
+ 'evidence',
17
+ 'feedback',
18
+ 'cleanup_manifests',
19
+ ];
20
+ function readWorkspaceConfig(workspaceRoot) {
21
+ try {
22
+ const configPath = (0, project_fraim_paths_1.getWorkspaceConfigPath)(workspaceRoot);
23
+ if (!node_fs_1.default.existsSync(configPath))
24
+ return {};
25
+ return JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf8'));
26
+ }
27
+ catch {
28
+ return {};
29
+ }
30
+ }
31
+ function validRetentionValue(value) {
32
+ return Number.isInteger(value) && value >= -1;
33
+ }
34
+ function normalizeRelative(workspaceRoot, absolutePath) {
35
+ return node_path_1.default.relative(workspaceRoot, absolutePath).replace(/\\/g, '/');
36
+ }
37
+ function parseRetentionValue(category, value, fallback, invalidValues) {
38
+ if (validRetentionValue(value))
39
+ return value;
40
+ invalidValues.push({
41
+ category,
42
+ value,
43
+ fallback,
44
+ reason: 'Retention values must be integers greater than or equal to -1.',
45
+ });
46
+ return fallback;
47
+ }
48
+ function resolveArtifactRetentionConfig(workspaceRoot) {
49
+ const config = readWorkspaceConfig(workspaceRoot);
50
+ const configuredRetention = config?.artifact_retention_period;
51
+ const rawRetention = configuredRetention && typeof configuredRetention === 'object' && !Array.isArray(configuredRetention)
52
+ ? configuredRetention
53
+ : {};
54
+ const invalidValues = [];
55
+ const rawDefault = rawRetention?.default;
56
+ const defaultValue = validRetentionValue(rawDefault)
57
+ ? rawDefault
58
+ : parseRetentionValue('default', rawDefault, -1, invalidValues);
59
+ const values = {};
60
+ for (const category of exports.ARTIFACT_RETENTION_CATEGORIES) {
61
+ values[category] = category in rawRetention
62
+ ? parseRetentionValue(category, rawRetention[category], defaultValue, invalidValues)
63
+ : defaultValue;
64
+ }
65
+ return { values, defaultValue, invalidValues };
66
+ }
67
+ function listFiles(dirPath) {
68
+ if (!node_fs_1.default.existsSync(dirPath))
69
+ return [];
70
+ const out = [];
71
+ const stack = [dirPath];
72
+ while (stack.length) {
73
+ const current = stack.pop();
74
+ let entries;
75
+ try {
76
+ entries = node_fs_1.default.readdirSync(current, { withFileTypes: true });
77
+ }
78
+ catch {
79
+ continue;
80
+ }
81
+ for (const entry of entries) {
82
+ const fullPath = node_path_1.default.join(current, entry.name);
83
+ if (entry.isDirectory())
84
+ stack.push(fullPath);
85
+ else if (entry.isFile())
86
+ out.push(fullPath);
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+ function fileMtime(absolutePath) {
92
+ try {
93
+ return node_fs_1.default.statSync(absolutePath).mtime;
94
+ }
95
+ catch {
96
+ return undefined;
97
+ }
98
+ }
99
+ function readFrontmatter(content) {
100
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
101
+ if (!match)
102
+ return {};
103
+ const values = {};
104
+ for (const line of match[1].split(/\r?\n/)) {
105
+ const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
106
+ if (kv)
107
+ values[kv[1]] = kv[2].trim();
108
+ }
109
+ return values;
110
+ }
111
+ function synthesizedDate(absolutePath) {
112
+ try {
113
+ const frontmatter = readFrontmatter(node_fs_1.default.readFileSync(absolutePath, 'utf8'));
114
+ const value = frontmatter.synthesized;
115
+ if (!value)
116
+ return undefined;
117
+ const parsed = new Date(value);
118
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed;
119
+ }
120
+ catch {
121
+ return undefined;
122
+ }
123
+ }
124
+ function userScoped(fileName, activeUserEmail) {
125
+ return fileName.startsWith(`${activeUserEmail}-`);
126
+ }
127
+ function collectCandidates(workspaceRoot, activeUserEmail) {
128
+ const candidates = [];
129
+ const add = (category, absolutePath, eligible, reason, eligibilityDate) => {
130
+ candidates.push({
131
+ category,
132
+ absolutePath,
133
+ relativePath: normalizeRelative(workspaceRoot, absolutePath),
134
+ eligible,
135
+ reason,
136
+ eligibilityDate,
137
+ });
138
+ };
139
+ const archiveDir = node_path_1.default.join(workspaceRoot, 'fraim', 'personalized-employee', 'learnings', 'archive');
140
+ for (const filePath of listFiles(archiveDir)) {
141
+ const fileName = node_path_1.default.basename(filePath);
142
+ if (!fileName.endsWith('.md') || !userScoped(fileName, activeUserEmail))
143
+ continue;
144
+ add('learning_archive', filePath, true, 'Archived learning source for active user.', fileMtime(filePath));
145
+ }
146
+ const retrospectivesDir = node_path_1.default.join(workspaceRoot, 'docs', 'retrospectives');
147
+ for (const filePath of listFiles(retrospectivesDir)) {
148
+ const fileName = node_path_1.default.basename(filePath);
149
+ if (!fileName.endsWith('.md') || !userScoped(fileName, activeUserEmail))
150
+ continue;
151
+ const date = synthesizedDate(filePath);
152
+ if (date)
153
+ add('retrospectives', filePath, true, 'Retrospective has synthesized frontmatter.', date);
154
+ else
155
+ add('retrospectives', filePath, false, 'Retrospective has no synthesized frontmatter.');
156
+ }
157
+ const evidenceDir = node_path_1.default.join(workspaceRoot, 'docs', 'evidence');
158
+ for (const filePath of listFiles(evidenceDir)) {
159
+ const fileName = node_path_1.default.basename(filePath);
160
+ const isMarkdown = fileName.endsWith('.md');
161
+ const isJson = fileName.endsWith('.json');
162
+ if (fileName.includes('cleanup-manifest') || fileName.includes('cleanup_manifest')) {
163
+ if (!isMarkdown && !isJson)
164
+ continue;
165
+ add('cleanup_manifests', filePath, true, 'Cleanup manifest file.', fileMtime(filePath));
166
+ continue;
167
+ }
168
+ if (!isMarkdown)
169
+ continue;
170
+ if (fileName.endsWith('-feedback.md')) {
171
+ add('feedback', filePath, true, 'Feedback evidence file.', fileMtime(filePath));
172
+ continue;
173
+ }
174
+ add('evidence', filePath, true, 'Evidence file.', fileMtime(filePath));
175
+ }
176
+ return candidates;
177
+ }
178
+ function daysBetween(from, to) {
179
+ return Math.floor((to.getTime() - from.getTime()) / 86_400_000);
180
+ }
181
+ function shouldDelete(candidate, retentionValue, now) {
182
+ if (!candidate.eligible)
183
+ return { delete: false, reason: candidate.reason };
184
+ if (retentionValue === -1)
185
+ return { delete: false, reason: 'Retention value -1 means never cleanup.' };
186
+ if (!candidate.eligibilityDate || Number.isNaN(candidate.eligibilityDate.getTime())) {
187
+ return { delete: false, reason: 'Eligibility date is unavailable or invalid.' };
188
+ }
189
+ if (retentionValue === 0)
190
+ return { delete: true, reason: 'Retention value 0 means cleanup immediately when eligible.' };
191
+ const ageDays = daysBetween(candidate.eligibilityDate, now);
192
+ if (ageDays >= retentionValue) {
193
+ return { delete: true, reason: `Eligible artifact age ${ageDays} days meets retention ${retentionValue} days.` };
194
+ }
195
+ return { delete: false, reason: `Eligible artifact age ${ageDays} days is below retention ${retentionValue} days.` };
196
+ }
197
+ function incrementCount(report, action) {
198
+ if (action === 'would_delete')
199
+ report.counts.wouldDelete++;
200
+ else
201
+ report.counts[action]++;
202
+ }
203
+ function runArtifactCleanup(workspaceRoot, options) {
204
+ const now = options.now ?? new Date();
205
+ const retention = resolveArtifactRetentionConfig(workspaceRoot);
206
+ const candidates = collectCandidates(workspaceRoot, options.activeUserEmail);
207
+ const report = {
208
+ generatedAt: now.toISOString(),
209
+ apply: options.apply,
210
+ retention: retention.values,
211
+ invalidConfig: retention.invalidValues,
212
+ counts: { retained: 0, wouldDelete: 0, deleted: 0, skipped: 0, failed: 0 },
213
+ items: [],
214
+ };
215
+ for (const candidate of candidates) {
216
+ const retentionValue = retention.values[candidate.category];
217
+ const decision = shouldDelete(candidate, retentionValue, now);
218
+ let action;
219
+ let error;
220
+ if (!candidate.eligible) {
221
+ action = 'skipped';
222
+ }
223
+ else if (!decision.delete) {
224
+ action = 'retained';
225
+ }
226
+ else if (!options.apply) {
227
+ action = 'would_delete';
228
+ }
229
+ else {
230
+ try {
231
+ node_fs_1.default.unlinkSync(candidate.absolutePath);
232
+ action = 'deleted';
233
+ }
234
+ catch (err) {
235
+ action = 'failed';
236
+ error = err instanceof Error ? err.message : String(err);
237
+ }
238
+ }
239
+ incrementCount(report, action);
240
+ report.items.push({
241
+ category: candidate.category,
242
+ path: candidate.relativePath,
243
+ action,
244
+ reason: error ? `Deletion failed: ${decision.reason}` : decision.reason,
245
+ retentionValue,
246
+ eligibilityDate: candidate.eligibilityDate?.toISOString().slice(0, 10),
247
+ cleanupDate: action === 'deleted' || action === 'would_delete' ? now.toISOString().slice(0, 10) : undefined,
248
+ error,
249
+ });
250
+ }
251
+ return report;
252
+ }
253
+ function planArtifactCleanup(workspaceRoot, options) {
254
+ return runArtifactCleanup(workspaceRoot, { ...options, apply: false });
255
+ }
@@ -25,8 +25,7 @@ const REPO_LEARNINGS_REL = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPat
25
25
  const DEFAULT_THRESHOLD = 3.0;
26
26
  const AGING_HORIZON_DAYS = 7;
27
27
  const MAX_ENTRIES_SCANNED = 200;
28
- const BACKLOG_MIN = 5;
29
- const OLDEST_AGE_DAYS_TRIGGER = 3;
28
+ const L0_SLEEP_ON_LEARNINGS_PROMPT_MIN = 5;
30
29
  // ── Single source of truth for the learning-entry format contract (#533) ──────
31
30
  // The `## [P-…] <title>` entry format is a tight contract shared by three sides:
32
31
  // 1. EMIT — the synthesis jobs (sleep-on-learnings, organizational-learning-
@@ -309,51 +308,49 @@ function isUnsynthesizedRetrospective(filePath) {
309
308
  return false;
310
309
  }
311
310
  }
312
- /** Oldest mtime-age in days across this user's L0 signals. 0 if none. */
313
- function computeOldestL0AgeDays(workspaceRoot, userId) {
314
- const learningsBase = (0, project_fraim_paths_1.getWorkspaceLearningsDir)(workspaceRoot);
315
- const now = Date.now();
316
- let oldest = 0;
317
- const consider = (filePath) => {
318
- try {
319
- const st = (0, fs_1.statSync)(filePath);
320
- const ageDays = Math.floor((now - st.mtimeMs) / (1000 * 60 * 60 * 24));
321
- if (ageDays > oldest)
322
- oldest = ageDays;
323
- }
324
- catch {
325
- // ignore
326
- }
327
- };
328
- const rawDir = (0, path_1.join)(learningsBase, 'raw');
329
- if ((0, fs_1.existsSync)(rawDir)) {
311
+ function pendingL0SortKey(fileName) {
312
+ const timestamp = fileName.match(/\d{4}-\d{2}-\d{2}(?:T\d{2}-\d{2}-\d{2})?/);
313
+ return timestamp?.[0] || fileName;
314
+ }
315
+ function collectPendingL0SourceFiles(workspaceRoot, resolvedUserId, roots) {
316
+ const sources = [];
317
+ const rawPath = (0, path_1.join)(roots.repoLearningsBase, 'raw');
318
+ if ((0, fs_1.existsSync)(rawPath)) {
330
319
  try {
331
- for (const f of (0, fs_1.readdirSync)(rawDir)) {
332
- if (!f.startsWith(`${userId}-`))
320
+ for (const fileName of (0, fs_1.readdirSync)(rawPath)) {
321
+ if (!fileName.startsWith(`${resolvedUserId}-`))
333
322
  continue;
334
- consider((0, path_1.join)(rawDir, f));
323
+ sources.push({
324
+ kind: 'coaching-moment',
325
+ displayPath: `${REPO_LEARNINGS_REL}/raw/${fileName}`,
326
+ sortKey: pendingL0SortKey(fileName)
327
+ });
335
328
  }
336
329
  }
337
330
  catch {
338
- // ignore
331
+ // Ignore read failures.
339
332
  }
340
333
  }
341
- const retroDir = (0, path_1.join)(workspaceRoot, 'docs', 'retrospectives');
342
- if ((0, fs_1.existsSync)(retroDir)) {
334
+ const retrospectivesPath = (0, path_1.join)(workspaceRoot, 'docs', 'retrospectives');
335
+ if ((0, fs_1.existsSync)(retrospectivesPath)) {
343
336
  try {
344
- for (const f of (0, fs_1.readdirSync)(retroDir)) {
345
- if (!f.startsWith(`${userId}-`) || !f.endsWith('.md'))
337
+ for (const fileName of (0, fs_1.readdirSync)(retrospectivesPath)) {
338
+ if (!fileName.startsWith(`${resolvedUserId}-`) || !fileName.endsWith('.md'))
346
339
  continue;
347
- if (!isUnsynthesizedRetrospective((0, path_1.join)(retroDir, f)))
340
+ if (!isUnsynthesizedRetrospective((0, path_1.join)(retrospectivesPath, fileName)))
348
341
  continue;
349
- consider((0, path_1.join)(retroDir, f));
342
+ sources.push({
343
+ kind: 'retrospective',
344
+ displayPath: `docs/retrospectives/${fileName}`,
345
+ sortKey: pendingL0SortKey(fileName)
346
+ });
350
347
  }
351
348
  }
352
349
  catch {
353
- // ignore
350
+ // Ignore read failures.
354
351
  }
355
352
  }
356
- return oldest;
353
+ return sources.sort((a, b) => b.sortKey.localeCompare(a.sortKey) || a.displayPath.localeCompare(b.displayPath));
357
354
  }
358
355
  /**
359
356
  * Resolve an L2 org-scope learning file (issue #563): a repo-local override
@@ -395,28 +392,9 @@ function buildLearningContextSection(workspaceRoot, userId, forJob) {
395
392
  const l1Validated = resolvePersonalLearningFile(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, `${resolvedUserId}-validated-patterns.md`);
396
393
  const l1MistakeStats = l1Mistake.present ? scanMistakePatternFile(l1Mistake.path, threshold, 'mistake-patterns') : null;
397
394
  const l1ValidatedStats = l1Validated.present ? scanMistakePatternFile(l1Validated.path, threshold, 'validated-patterns') : null;
398
- let l0CoachingCount = 0;
399
- const rawPath = (0, path_1.join)(roots.repoLearningsBase, 'raw');
400
- if ((0, fs_1.existsSync)(rawPath)) {
401
- try {
402
- l0CoachingCount = (0, fs_1.readdirSync)(rawPath).filter(f => f.startsWith(`${resolvedUserId}-`)).length;
403
- }
404
- catch {
405
- // Ignore read failures.
406
- }
407
- }
408
- let l0RetroCount = 0;
409
- const retrospectivesPath = (0, path_1.join)(workspaceRoot, 'docs', 'retrospectives');
410
- if ((0, fs_1.existsSync)(retrospectivesPath)) {
411
- try {
412
- l0RetroCount = (0, fs_1.readdirSync)(retrospectivesPath)
413
- .filter(f => f.startsWith(`${resolvedUserId}-`) && f.endsWith('.md'))
414
- .filter(f => isUnsynthesizedRetrospective((0, path_1.join)(retrospectivesPath, f))).length;
415
- }
416
- catch {
417
- // Ignore read failures.
418
- }
419
- }
395
+ const pendingL0Sources = collectPendingL0SourceFiles(workspaceRoot, resolvedUserId, roots);
396
+ const l0CoachingCount = pendingL0Sources.filter(source => source.kind === 'coaching-moment').length;
397
+ const l0RetroCount = pendingL0Sources.filter(source => source.kind === 'retrospective').length;
420
398
  const hasL2 = l2MistakePresent || l2PrefPresent || l2CoachPresent || l2ValidatedPresent;
421
399
  const hasL1 = l1Mistake.present || l1Pref.present || l1Coach.present || l1Validated.present;
422
400
  const hasContent = hasL2 || hasL1 || l0CoachingCount > 0 || l0RetroCount > 0;
@@ -458,6 +436,7 @@ function buildLearningContextSection(workspaceRoot, userId, forJob) {
458
436
  section += '\n';
459
437
  }
460
438
  if (l0CoachingCount > 0 || l0RetroCount > 0) {
439
+ const totalL0 = pendingL0Sources.length;
461
440
  section += '### L0 - Your unprocessed signals\n';
462
441
  if (l0CoachingCount > 0) {
463
442
  section += `${l0CoachingCount} coaching moment${l0CoachingCount !== 1 ? 's' : ''} in \`${REPO_LEARNINGS_REL}/raw/${resolvedUserId}-*\`\n`;
@@ -465,12 +444,16 @@ function buildLearningContextSection(workspaceRoot, userId, forJob) {
465
444
  if (l0RetroCount > 0) {
466
445
  section += `${l0RetroCount} retrospective${l0RetroCount !== 1 ? 's' : ''} in \`docs/retrospectives/${resolvedUserId}-*\` with unsynthesized or missing \`synthesized\` frontmatter\n`;
467
446
  }
447
+ section += 'Pending L0 Source Files:\n';
448
+ for (const source of pendingL0Sources) {
449
+ section += `- \`${source.displayPath}\`\n`;
450
+ }
451
+ section += 'Read these pending L0 source files before continuing; they are unsynthesized learnings for the current agent.\n';
452
+ if (totalL0 >= L0_SLEEP_ON_LEARNINGS_PROMPT_MIN) {
453
+ section += `This is ${totalL0} pending L0 file${totalL0 !== 1 ? 's' : ''}; to speed up future starts, run \`sleep-on-learnings\` to synthesize or archive them.\n`;
454
+ }
468
455
  section += '\n';
469
456
  }
470
- const totalL0 = l0CoachingCount + l0RetroCount;
471
- const oldestAgeDays = totalL0 > 0 ? computeOldestL0AgeDays(workspaceRoot, resolvedUserId) : 0;
472
- const agingRisk = l1MistakeStats?.agingRisk ?? 0;
473
- const backlogTriggered = totalL0 >= BACKLOG_MIN || (oldestAgeDays >= OLDEST_AGE_DAYS_TRIGGER && totalL0 > 0);
474
457
  if (forJob) {
475
458
  if (hasL2 || hasL1) {
476
459
  section += 'Use the relevant patterns and preferences in this job.\n';
@@ -478,23 +461,12 @@ function buildLearningContextSection(workspaceRoot, userId, forJob) {
478
461
  section += 'Treat manager-coaching as feedback for how the manager should continue or improve managing AI, not as agent instruction.\n';
479
462
  }
480
463
  }
481
- if (backlogTriggered) {
482
- section += '\n';
483
- section += `Warning: ${totalL0} unprocessed signals pending. Consider running \`sleep-on-learnings\` before starting today's work.\n`;
484
- section += renderBacklogDetail(oldestAgeDays, agingRisk);
485
- }
486
464
  }
487
465
  else {
488
466
  section += 'Use this synthesized learning context throughout the session.\n';
489
467
  if (l1Coach.present || l2CoachPresent) {
490
468
  section += 'Manager-coaching entries are manager-facing feedback, not instructions for the AI to follow.\n';
491
469
  }
492
- if (backlogTriggered) {
493
- section += '\n';
494
- section += `Warning: synthesis overdue with ${totalL0} unprocessed signals.\n`;
495
- section += 'Run `sleep-on-learnings` before starting today\'s work.\n';
496
- section += renderBacklogDetail(oldestAgeDays, agingRisk);
497
- }
498
470
  }
499
471
  return section;
500
472
  }
@@ -960,15 +932,3 @@ function isTruthyFlag(value) {
960
932
  return false;
961
933
  return normalized === 'true' || normalized === 'yes' || normalized === '1';
962
934
  }
963
- function renderBacklogDetail(oldestAgeDays, agingRisk) {
964
- if (oldestAgeDays <= 0 && agingRisk <= 0)
965
- return '';
966
- const parts = [];
967
- if (oldestAgeDays > 0)
968
- parts.push(`oldest ${oldestAgeDays}d`);
969
- parts.push('debrief takes ~3 minutes');
970
- if (agingRisk > 0) {
971
- parts.push(`${agingRisk} high-score pattern${agingRisk !== 1 ? 's' : ''} aging out within ${AGING_HORIZON_DAYS}d`);
972
- }
973
- return `Detail: ${parts.join('; ')}.\n`;
974
- }