fraim 2.0.360-next.0 → 2.0.360-next.2

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.
@@ -76,7 +76,7 @@ function verifySharedProjectPublication(projectPath, file, expectedHash, publica
76
76
  return { published: true, reference: pr.url };
77
77
  }
78
78
  /** Existing publishers run in a bounded process. Cloud confirmation reads the actual pack without syncing it. */
79
- function publishRunFile(projectPath, file, home, operationKey, expectedHash, runtimeModulePath, entryProof) {
79
+ function publishRunFile(projectPath, file, home, operationKey, expectedHash, runtimeModulePath, entryProof, verifyOnly = false) {
80
80
  const relative = home.level === 'Project' && node_path_1.default.resolve(file) === node_path_1.default.join(home.root, 'config.json')
81
81
  ? 'config.json' : node_path_1.default.relative(home.contentRoot, file).replace(/\\/g, '/');
82
82
  if ((0, owner_identity_1.ownerArtifactPath)(home, relative) !== node_path_1.default.resolve(file))
@@ -95,6 +95,13 @@ function publishRunFile(projectPath, file, home, operationKey, expectedHash, run
95
95
  throw new Error('project-publisher-unavailable');
96
96
  const branchSuffix = `sleep-${operationKey}`;
97
97
  if (home.backend === 'git' && home.gitUrl) {
98
+ if (verifyOnly) {
99
+ const head = fetchRemoteHead(home.root);
100
+ const relative = node_path_1.default.relative(home.root, file).replace(/\\/g, '/');
101
+ const delivered = head && (0, node_child_process_1.spawnSync)('git', ['show', `${head}:${relative}`], { cwd: home.root, encoding: 'utf8', timeout: 30000, windowsHide: true });
102
+ return { published: Boolean(delivered && delivered.status === 0 && sameDeliveryText(delivered.stdout, node_fs_1.default.readFileSync(file, 'utf8'))
103
+ && (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(file, 'utf8')) === expectedHash), reference: home.gitUrl };
104
+ }
98
105
  const branch = `fraim-${layer}-update-${branchSuffix}`;
99
106
  const found = (0, node_child_process_1.spawnSync)('gh', ['pr', 'view', branch, '--repo', home.gitUrl, '--json', 'state,url,mergeCommit'], { encoding: 'utf8', timeout: 30000, windowsHide: true });
100
107
  if (found.status === 0) {
@@ -125,7 +132,7 @@ function publishRunFile(projectPath, file, home, operationKey, expectedHash, run
125
132
  const fs=require('node:fs');
126
133
  const crypto=require('node:crypto');
127
134
  const hash=value=>crypto.createHash('sha256').update(value).digest('hex');
128
- const [publisherPath,userConfigPath,file,relative,layer,branchSuffix,expectedHash,backend,entryProofJson,entryFormatPath]=process.argv.slice(1);
135
+ const [publisherPath,userConfigPath,file,relative,layer,branchSuffix,expectedHash,backend,entryProofJson,entryFormatPath,verifyOnly]=process.argv.slice(1);
129
136
  const entryProof=JSON.parse(entryProofJson);
130
137
  const {learningEntryBlocks}=require(entryFormatPath);
131
138
  const matchesLearningPublication=${matchesLearningPublication.toString()};
@@ -146,14 +153,16 @@ async function confirmedCloud(){
146
153
  if(backend==='fraim-cloud'){
147
154
  const existing=await confirmedCloud();
148
155
  if(existing.published){console.log('FRAIM_PUBLISH_RESULT='+JSON.stringify(existing));return;}
156
+ if(verifyOnly==='true'){console.log('FRAIM_PUBLISH_RESULT='+JSON.stringify(existing));return;}
149
157
  }
158
+ if(verifyOnly==='true'){console.log('FRAIM_PUBLISH_RESULT='+JSON.stringify({published:false,reference:file}));return;}
150
159
  const publish=layer==='manager'?publisher.publishManagerArtifacts:publisher.publishOrgArtifacts;
151
160
  const result=await publish([{relativePath:relative,content:fs.readFileSync(file,'utf8')}],{branchSuffix});
152
161
  const confirmed=backend==='fraim-cloud'?await confirmedCloud():{published:false,reference:result.prUrl||result.branch||file};
153
162
  if(hash(fs.readFileSync(file,'utf8'))!==expectedHash)throw Error('publication-content-conflict');
154
163
  console.log('FRAIM_PUBLISH_RESULT='+JSON.stringify(confirmed));
155
164
  })().catch(()=>{process.exitCode=1;});`;
156
- const result = (0, node_child_process_1.spawnSync)(process.execPath, [...process.execArgv, '-e', script, publisherPath, userConfigPath, file, relative, layer, branchSuffix, expectedHash, home.backend, JSON.stringify(entryProof || null), require.resolve('./entry-format')], { cwd: projectPath, encoding: 'utf8', timeout: 120000, windowsHide: true });
165
+ const result = (0, node_child_process_1.spawnSync)(process.execPath, [...process.execArgv, '-e', script, publisherPath, userConfigPath, file, relative, layer, branchSuffix, expectedHash, home.backend, JSON.stringify(entryProof || null), require.resolve('./entry-format'), String(verifyOnly)], { cwd: projectPath, encoding: 'utf8', timeout: 120000, windowsHide: true });
157
166
  const encoded = String(result.stdout || '').match(/^FRAIM_PUBLISH_RESULT=(.+)$/m)?.[1];
158
167
  if (result.status !== 0 || !encoded)
159
168
  throw new Error('publication-pending: delivery or read-back has not been confirmed');
@@ -279,14 +288,55 @@ async function verifyCapabilityDelivery(action, input, options) {
279
288
  const { resolvePackHome } = load('cli/utils/pack-home');
280
289
  const manager = resolvePackHome('manager', { migrateLegacy: false });
281
290
  const org = resolvePackHome('org', { migrateLegacy: false });
291
+ const consumedLocalFiles = new Set();
282
292
  const resolver = new LocalRegistryResolver({ workspaceRoot: options.projectPath,
283
293
  managerLocalRoot: manager.contentRoot, managerCacheRoot: manager.contentRoot, orgCacheRoot: org.contentRoot,
284
294
  remoteContentResolver: async () => { throw new Error('capability-not-available-locally'); },
295
+ onLocalDependency: (file, kind, content) => {
296
+ if (kind === 'directory')
297
+ snapshotDirectory(file);
298
+ else {
299
+ snapshot(file, content);
300
+ // A content-bearing callback records an actual read; existence
301
+ // probes and absent overlays must not become publication inputs.
302
+ if (content !== undefined)
303
+ consumedLocalFiles.add(node_path_1.default.resolve(file));
304
+ }
305
+ },
285
306
  });
286
307
  const originalResolve = resolver.resolveFile.bind(resolver);
287
308
  const snapshots = new Map();
309
+ const snapshotDirectory = (file) => {
310
+ const absolute = node_path_1.default.resolve(file);
311
+ let realPath = null;
312
+ try {
313
+ if (!node_fs_1.default.statSync(absolute).isDirectory())
314
+ throw new Error('capability-local-content-changed');
315
+ realPath = node_fs_1.default.realpathSync(absolute);
316
+ }
317
+ catch (error) {
318
+ if (error.code !== 'ENOENT')
319
+ throw error;
320
+ }
321
+ const prior = snapshots.get(absolute);
322
+ if (prior && (prior.kind !== 'directory' || prior.realPath !== realPath))
323
+ throw new Error('capability-local-content-changed');
324
+ snapshots.set(absolute, { file: absolute, realPath, sha256: null, kind: 'directory' });
325
+ };
288
326
  const snapshot = (file, expectedContent) => {
289
327
  const absolute = node_path_1.default.resolve(file);
328
+ try {
329
+ node_fs_1.default.lstatSync(absolute);
330
+ }
331
+ catch (error) {
332
+ if (error.code !== 'ENOENT' || expectedContent !== undefined)
333
+ throw error;
334
+ const prior = snapshots.get(absolute);
335
+ if (prior?.sha256)
336
+ throw new Error('capability-local-content-changed');
337
+ snapshots.set(absolute, { file: absolute, realPath: null, sha256: null });
338
+ return;
339
+ }
290
340
  const body = node_fs_1.default.readFileSync(absolute, 'utf8');
291
341
  if (expectedContent !== undefined && !sameDeliveryText(body, expectedContent))
292
342
  throw new Error('capability-local-content-changed');
@@ -296,12 +346,23 @@ async function verifyCapabilityDelivery(action, input, options) {
296
346
  throw new Error('capability-local-content-changed');
297
347
  snapshots.set(absolute, current);
298
348
  };
349
+ const snapshotRuleOverlays = (relative) => {
350
+ const normalized = relative.replace(/\\/g, '/');
351
+ if (node_path_1.default.isAbsolute(normalized) || normalized.split('/').some(part => !part || part === '..' || part === '.'))
352
+ throw new Error('invalid-capability-include');
353
+ // A new wrapper/fragment override can change delivery without touching
354
+ // the selected source. Bind absent overlay paths as well as its bytes.
355
+ if (normalized.startsWith('rules/')) {
356
+ for (const root of [node_path_1.default.join(options.projectPath, 'fraim/personalized-employee'), manager.contentRoot, org.contentRoot]) {
357
+ snapshot(node_path_1.default.join(root, normalized));
358
+ }
359
+ }
360
+ };
299
361
  const snapshotResolvedInclude = (relative, resolved) => {
300
362
  if (resolved.source !== 'local')
301
363
  return;
364
+ snapshotRuleOverlays(relative);
302
365
  const normalized = relative.replace(/\\/g, '/');
303
- if (node_path_1.default.isAbsolute(normalized) || normalized.split('/').some(part => !part || part === '..' || part === '.'))
304
- throw new Error('invalid-capability-include');
305
366
  let candidates;
306
367
  if (resolved.scope === 'manager')
307
368
  candidates = [node_path_1.default.join(manager.contentRoot, normalized)];
@@ -309,17 +370,40 @@ async function verifyCapabilityDelivery(action, input, options) {
309
370
  candidates = [node_path_1.default.join(org.contentRoot, normalized)];
310
371
  else if (resolved.scope === 'project')
311
372
  candidates = [node_path_1.default.join(options.projectPath, 'fraim/personalized-employee', normalized)];
312
- else {
313
- const parts = normalized.split('/');
314
- const rolePath = ['jobs', 'workflows'].includes(parts[0]) && ['ai-employee', 'ai-manager'].includes(parts[1])
315
- ? node_path_1.default.join(parts[1], parts[0], ...parts.slice(2)) : undefined;
316
- candidates = [...(rolePath ? [node_path_1.default.join(options.projectPath, 'fraim', rolePath)] : []),
317
- node_path_1.default.join(options.projectPath, 'fraim/ai-employee', normalized), node_path_1.default.join(options.projectPath, 'fraim/ai-manager', normalized),
318
- node_path_1.default.join(options.projectPath, 'fraim', normalized), node_path_1.default.join(options.projectPath, 'registry', normalized)];
319
- }
320
- const source = candidates.find(file => node_fs_1.default.existsSync(file) && node_fs_1.default.statSync(file).isFile() && sameDeliveryText(node_fs_1.default.readFileSync(file, 'utf8'), resolved.content));
373
+ else
374
+ candidates = resolved.localPath ? [resolved.localPath] : [];
375
+ const matchingSource = candidates.find(file => node_fs_1.default.existsSync(file) && node_fs_1.default.statSync(file).isFile() && sameDeliveryText(node_fs_1.default.readFileSync(file, 'utf8'), resolved.content));
376
+ // Inherited overrides have expanded bodies. Their scoped raw source was
377
+ // snapshotted before resolution; imported children are traced separately.
378
+ const inheritedSource = normalized.startsWith('rules/') && resolved.scope && resolved.inherited ? candidates[0] : undefined;
379
+ const source = matchingSource ?? inheritedSource;
321
380
  if (source)
322
- snapshot(source, resolved.content);
381
+ snapshot(source, matchingSource ? resolved.content : undefined);
382
+ if (normalized.startsWith('rules/') && resolved.scope && source && node_path_1.default.resolve(source) !== node_path_1.default.resolve(target)) {
383
+ if (resolved.scope === 'project' && shared) {
384
+ sharedCallers.push({ file: source, expectedHash: snapshots.get(node_path_1.default.resolve(source)).sha256 });
385
+ }
386
+ else if (resolved.scope === 'manager' || resolved.scope === 'org') {
387
+ const level = resolved.scope === 'manager' ? 'Manager' : 'Org';
388
+ const sourceHome = (0, owner_identity_1.resolveOwnerHome)(options.projectPath, level, { runtimeModule: options.runtimeModule });
389
+ if (sourceHome.requiresPublish)
390
+ supportingPublications.set(source, sourceHome);
391
+ }
392
+ }
393
+ if (shared && normalized.startsWith('rules/') && resolved.scope === undefined) {
394
+ // Shared delivery depends on the composition that actually shipped,
395
+ // not a local wrapper edit (or a stale installed copy). Verification
396
+ // checks these files at both merged and current HEAD; it never
397
+ // publishes supporting edits under the target rule's approval.
398
+ const registryFile = node_path_1.default.join(options.projectPath, 'registry', normalized);
399
+ if (!node_fs_1.default.existsSync(registryFile) || !sameDeliveryText(node_fs_1.default.readFileSync(registryFile, 'utf8'), resolved.content)) {
400
+ sharedCatalogAvailable = false;
401
+ }
402
+ else {
403
+ snapshot(registryFile, resolved.content);
404
+ sharedCallers.push({ file: registryFile, expectedHash: (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(registryFile, 'utf8')) });
405
+ }
406
+ }
323
407
  };
324
408
  snapshot(target, targetContent);
325
409
  if (snapshots.get(node_path_1.default.resolve(target))?.sha256 !== localHash)
@@ -332,6 +416,45 @@ async function verifyCapabilityDelivery(action, input, options) {
332
416
  return { published: false, reference: target, contentHash, reachable: false };
333
417
  let reachable = true;
334
418
  const sharedCallers = [];
419
+ const supportingPublications = new Map();
420
+ let sharedCatalogAvailable = true;
421
+ const captureRuleSelection = (callerPath) => {
422
+ const paths = load('core/utils/project-fraim-paths');
423
+ const workspace = paths.getWorkspaceFraimPath(options.projectPath);
424
+ const user = paths.getUserFraimDirPath();
425
+ snapshot(node_path_1.default.join(workspace, 'config.json'));
426
+ snapshot(node_path_1.default.join(user, 'config.json'));
427
+ const machineCatalog = node_path_1.default.join(user, 'domains.json');
428
+ const workspaceCatalog = node_path_1.default.join(workspace, 'domains.json');
429
+ const registryCatalog = node_path_1.default.join(options.projectPath, 'registry/domains.json');
430
+ const syncedCatalog = node_fs_1.default.existsSync(machineCatalog) ? machineCatalog : workspaceCatalog;
431
+ const baselineCatalog = node_fs_1.default.existsSync(syncedCatalog) ? syncedCatalog : registryCatalog;
432
+ for (const file of [machineCatalog, workspaceCatalog, registryCatalog])
433
+ snapshot(file);
434
+ for (const [level, root] of [['Project', node_path_1.default.join(workspace, 'personalized-employee')], ['Manager', manager.contentRoot], ['Org', org.contentRoot]]) {
435
+ const file = node_path_1.default.join(root, 'domains.json');
436
+ snapshot(file);
437
+ if (node_fs_1.default.existsSync(file)) {
438
+ const sourceHome = (0, owner_identity_1.resolveOwnerHome)(options.projectPath, level, { runtimeModule: options.runtimeModule });
439
+ if (sourceHome.requiresPublish)
440
+ supportingPublications.set(file, sourceHome);
441
+ if (shared && level === 'Project')
442
+ sharedCallers.push({ file, expectedHash: snapshots.get(node_path_1.default.resolve(file)).sha256 });
443
+ }
444
+ // An override created after verification must invalidate the proof.
445
+ snapshot(node_path_1.default.join(root, targetPath));
446
+ snapshot(node_path_1.default.join(root, callerPath));
447
+ }
448
+ if (shared) {
449
+ // An installed catalog alone cannot prove that the shared selection
450
+ // shipped in the reviewed repository revision and current remote HEAD.
451
+ if (!node_fs_1.default.existsSync(registryCatalog) || !node_fs_1.default.existsSync(baselineCatalog)
452
+ || !sameDeliveryText(node_fs_1.default.readFileSync(registryCatalog, 'utf8'), node_fs_1.default.readFileSync(baselineCatalog, 'utf8')))
453
+ sharedCatalogAvailable = false;
454
+ else
455
+ sharedCallers.push({ file: registryCatalog, expectedHash: snapshots.get(node_path_1.default.resolve(registryCatalog)).sha256 });
456
+ }
457
+ };
335
458
  for (const caller of input.callers) {
336
459
  const callerHome = options.resolveHome(caller.owner);
337
460
  (0, owner_identity_1.assertSameOwner)(caller.owner, callerHome.owner);
@@ -349,6 +472,7 @@ async function verifyCapabilityDelivery(action, input, options) {
349
472
  sharedCallers.push({ file: callerFile, expectedHash: (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(callerFile, 'utf8')) });
350
473
  let traversed = false;
351
474
  resolver.resolveFile = async (relative, resolveOptions) => {
475
+ snapshotRuleOverlays(relative);
352
476
  const resolved = await originalResolve(relative, resolveOptions);
353
477
  snapshotResolvedInclude(relative, resolved);
354
478
  if (relative === targetPath && resolved.scope === targetScope && sameDeliveryText(resolved.content, targetContent))
@@ -356,20 +480,72 @@ async function verifyCapabilityDelivery(action, input, options) {
356
480
  return resolved;
357
481
  };
358
482
  const hydrated = await resolver.resolveIncludes(loadedCaller.content);
483
+ if (!traversed && callerPath.startsWith('jobs/') && targetPath.startsWith('rules/')) {
484
+ captureRuleSelection(callerPath);
485
+ const { assembleDomainRules } = load('core/utils/domain-rule-assembler');
486
+ const assembly = await assembleDomainRules({ jobPath: callerPath,
487
+ resolveFile: (relative) => resolver.resolveFile(relative, { includeMetadata: false, stripMcpHeader: true }) });
488
+ // Source traversal binds the approved raw bytes; assembly metadata
489
+ // proves this exact owner/path survived effective composition.
490
+ traversed = traversed && assembly.metadata.complete && assembly.metadata.selection === 'mapped'
491
+ && [...assembly.metadata.artifacts, ...(assembly.metadata.includedArtifacts || [])]
492
+ .some((artifact) => artifact.path === targetPath && artifact.scope === targetScope);
493
+ }
359
494
  if (!traversed || /\{\{include:[^}]+\}\}/.test(hydrated)) {
360
495
  reachable = false;
361
496
  break;
362
497
  }
363
498
  }
364
499
  resolver.resolveFile = originalResolve;
500
+ if (shared) {
501
+ // Inheritance resolves raw parents internally, bypassing the public
502
+ // resolveFile interceptor. Include the Project rule bytes it consumed
503
+ // in both merged-commit and current-remote publication verification.
504
+ const projectRules = node_path_1.default.join(options.projectPath, 'fraim/personalized-employee/rules');
505
+ for (const file of consumedLocalFiles) {
506
+ const relative = node_path_1.default.relative(projectRules, file);
507
+ if (!relative || relative === '..' || relative.startsWith('..' + node_path_1.default.sep) || node_path_1.default.isAbsolute(relative))
508
+ continue;
509
+ sharedCallers.push({ file, expectedHash: snapshots.get(file).sha256 });
510
+ }
511
+ }
365
512
  if (!reachable || (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(target, 'utf8')) !== localHash)
366
513
  return { published: false, reference: target, contentHash, reachable: false };
367
514
  const localFiles = [...snapshots.values()].sort((a, b) => a.file.localeCompare(b.file));
368
515
  const verifySnapshots = () => {
369
- for (const item of localFiles)
516
+ for (const item of localFiles) {
517
+ if (item.kind === 'directory' && item.realPath !== null) {
518
+ if (!node_fs_1.default.existsSync(item.file) || !node_fs_1.default.statSync(item.file).isDirectory() || node_fs_1.default.realpathSync(item.file) !== item.realPath)
519
+ throw new Error('capability-local-content-changed');
520
+ continue;
521
+ }
522
+ if (item.sha256 === null) {
523
+ try {
524
+ node_fs_1.default.lstatSync(item.file);
525
+ }
526
+ catch (error) {
527
+ if (error.code === 'ENOENT')
528
+ continue;
529
+ throw error;
530
+ }
531
+ throw new Error('capability-local-content-changed');
532
+ }
370
533
  if (node_fs_1.default.realpathSync(item.file) !== item.realPath || (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(item.file, 'utf8')) !== item.sha256)
371
534
  throw new Error('capability-local-content-changed');
535
+ }
372
536
  };
537
+ verifySnapshots();
538
+ if (!sharedCatalogAvailable)
539
+ return { published: false, reference: target, contentHash, reachable, localFiles, ...reviewEvidence };
540
+ for (const [file, sourceHome] of supportingPublications) {
541
+ if (!options.runtimeModulePath)
542
+ throw new Error('capability-publication-required');
543
+ // Rule approval never authorizes publishing supporting catalog or rule edits.
544
+ const supporting = publishRunFile(options.projectPath, file, sourceHome, input.operationKey || 'catalog-delivery-check', snapshots.get(node_path_1.default.resolve(file)).sha256, options.runtimeModulePath, undefined, true);
545
+ verifySnapshots();
546
+ if (!supporting.published)
547
+ return { ...supporting, contentHash, reachable, localFiles, ...reviewEvidence };
548
+ }
373
549
  if (shared) {
374
550
  const publication = verifySharedProjectPublication(options.projectPath, target, localHash, input.publicationRef, sharedCallers);
375
551
  verifySnapshots();
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.WORKSPACE_FRAIM_DIRNAME = exports.LEARNING_DOMAINS = void 0;
6
+ exports.WORKSPACE_FRAIM_DIRNAME = void 0;
7
7
  exports.getWorkspaceFraimDir = getWorkspaceFraimDir;
8
8
  exports.getWorkspaceConfigPath = getWorkspaceConfigPath;
9
9
  exports.getWorkspaceFraimPath = getWorkspaceFraimPath;
@@ -21,8 +21,6 @@ exports.isPathInsideOrEqual = isPathInsideOrEqual;
21
21
  const fs_1 = require("fs");
22
22
  const os_1 = __importDefault(require("os"));
23
23
  const path_1 = require("path");
24
- /** Share domain data with the MCP runtime and proposal authoring helpers. */
25
- exports.LEARNING_DOMAINS = Object.freeze(JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(__dirname, '../learning/learning-domains.json'), 'utf8')).domains);
26
24
  exports.WORKSPACE_FRAIM_DIRNAME = 'fraim';
27
25
  function getWorkspaceFraimDir(projectRoot = process.cwd()) {
28
26
  return (0, path_1.join)(projectRoot, exports.WORKSPACE_FRAIM_DIRNAME);
@@ -44,6 +44,7 @@ const projectFraimPaths = __importStar(require("../../core/utils/project-fraim-p
44
44
  const packHome = __importStar(require("../utils/pack-home"));
45
45
  const userConfig = __importStar(require("../utils/user-config"));
46
46
  const localRegistryResolver = __importStar(require("../../core/utils/local-registry-resolver"));
47
+ const domainRuleAssembler = __importStar(require("../../core/utils/domain-rule-assembler"));
47
48
  const learningContextBuilder = __importStar(require("../../local-mcp-server/learning-context-builder"));
48
49
  const learningUsageStore = __importStar(require("../../local-mcp-server/learning-usage-store"));
49
50
  const MODULE_MAP = {
@@ -51,6 +52,7 @@ const MODULE_MAP = {
51
52
  'cli/utils/pack-home': packHome,
52
53
  'cli/utils/user-config': userConfig,
53
54
  'core/utils/local-registry-resolver': localRegistryResolver,
55
+ 'core/utils/domain-rule-assembler': domainRuleAssembler,
54
56
  'local-mcp-server/learning-context-builder': learningContextBuilder,
55
57
  'local-mcp-server/learning-usage-store': learningUsageStore,
56
58
  };
@@ -228,6 +228,13 @@ async function syncTypedFilesToUserDir(files, type, subdir, label) {
228
228
  * Extracted from syncFromRemote so it can run without a project root.
229
229
  */
230
230
  async function syncScriptsToUserDir(files) {
231
+ const catalog = files.find(file => file.type === 'config' && file.path === 'domains.json');
232
+ if (catalog) {
233
+ // Only this known root artifact is accepted; never clean the user root.
234
+ JSON.parse(catalog.content);
235
+ (0, fs_1.mkdirSync)((0, script_sync_utils_1.getUserFraimDir)(), { recursive: true });
236
+ (0, fs_1.writeFileSync)((0, path_1.join)((0, script_sync_utils_1.getUserFraimDir)(), 'domains.json'), catalog.content, 'utf8');
237
+ }
231
238
  return syncTypedFilesToUserDir(files, 'script', 'scripts', 'script');
232
239
  }
233
240
  /**
@@ -105,6 +105,10 @@ function copyScriptToUserDirectory(sourcePath, registryScriptsPath) {
105
105
  */
106
106
  function syncScriptsToUserDirectory(registryPath) {
107
107
  ensureUserFraimDirectories();
108
+ const domainCatalog = path_1.default.join(registryPath, 'domains.json');
109
+ if (fs_1.default.existsSync(domainCatalog)) {
110
+ fs_1.default.copyFileSync(domainCatalog, path_1.default.join(getUserFraimDir(), 'domains.json'));
111
+ }
108
112
  const registryScriptsPath = path_1.default.join(registryPath, 'scripts');
109
113
  const allScripts = getRegistryScripts(registryPath);
110
114
  let syncedCount = 0;
@@ -1,119 +1,35 @@
1
1
  "use strict";
2
- /**
3
- * Learning domains (issue #806).
4
- *
5
- * A learning's DOMAIN is the fourth axis of the learning system, alongside:
6
- * - tier (L0 raw / L1 personal / L2 org),
7
- * - category (mistake-patterns / preferences / validated-patterns / manager-coaching),
8
- * - scope (machine-global `~/.fraim` vs repo-local `fraim/`).
9
- *
10
- * A learning is either `global` (applies to every job) or scoped to exactly one
11
- * coarse job-domain. At `get_fraim_job`, the loader injects global learnings plus
12
- * the current job's-domain learnings only, so a marketing job no longer loads
13
- * engineering learnings.
14
- *
15
- * The shared registry/scripts/learning/learning-domains.json owns the domain list, the
16
- * registry-category -> domain map, and the per-domain authoring voice (R3).
17
- * This module provides the pure resolver used at the injection sites, following the
18
- * config-module convention of `src/config/portfolio-slug-overrides.ts`.
19
- */
20
2
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.DOMAIN_VOICE = exports.GLOBAL_JOB_CATEGORIES = exports.JOB_DOMAIN_MAP = exports.LEARNING_DOMAINS = exports.LEARNING_DOMAIN_MAP_VERSION = void 0;
3
+ exports.jobCategoryFromRegistryPath = exports.STORED_LEARNING_DOMAINS = exports.HISTORICAL_LEARNING_DOMAINS = exports.LEARNING_DOMAINS = exports.LEARNING_DOMAIN_MAP_VERSION = void 0;
22
4
  exports.isLearningDomain = isLearningDomain;
23
- exports.jobCategoryFromRegistryPath = jobCategoryFromRegistryPath;
24
5
  exports.resolveJobDomain = resolveJobDomain;
25
- exports.domainFromJobFrontmatter = domainFromJobFrontmatter;
26
6
  exports.resolveDomainForJob = resolveDomainForJob;
27
- const learningDomainData = require('../../registry/scripts/learning/learning-domains.json');
28
- exports.LEARNING_DOMAIN_MAP_VERSION = learningDomainData.mapVersion;
29
- exports.LEARNING_DOMAINS = Object.freeze(learningDomainData.domains);
30
- exports.JOB_DOMAIN_MAP = Object.freeze(learningDomainData.jobDomainMap);
31
- exports.GLOBAL_JOB_CATEGORIES = new Set(learningDomainData.globalJobCategories);
32
- exports.DOMAIN_VOICE = Object.freeze(learningDomainData.domainVoice);
7
+ /** Learning domains use the same folder authority as rule delivery. */
8
+ const catalog_1 = require("../../registry/scripts/domains/catalog");
9
+ let catalog = { schemaVersion: 2, mapVersion: 0, domains: {} };
10
+ try {
11
+ const candidate = require('../../registry/domains.json');
12
+ if (candidate?.schemaVersion !== 2 || !candidate.domains || typeof candidate.domains !== 'object' || Array.isArray(candidate.domains))
13
+ throw new Error('Invalid domain catalog');
14
+ catalog = candidate;
15
+ }
16
+ catch {
17
+ console.warn('Learning domains unavailable: restore or sync registry/domains.json, then refresh instructions. Global learning files remain available.');
18
+ }
19
+ exports.LEARNING_DOMAIN_MAP_VERSION = catalog.mapVersion;
20
+ exports.LEARNING_DOMAINS = Object.freeze(Object.keys(catalog.domains).filter(domain => domain !== 'global'));
21
+ /** Retired storage names remain readable/editable; never use these as current job domains. */
22
+ exports.HISTORICAL_LEARNING_DOMAINS = Object.freeze(['compliance', 'company-building', 'operations']);
23
+ exports.STORED_LEARNING_DOMAINS = Object.freeze([...exports.LEARNING_DOMAINS, ...exports.HISTORICAL_LEARNING_DOMAINS]);
33
24
  function isLearningDomain(value) {
34
25
  return typeof value === 'string' && exports.LEARNING_DOMAINS.includes(value);
35
26
  }
36
- /**
37
- * Extract the registry job category from a job's registry path.
38
- *
39
- * Handles both `jobs/ai-employee/<category>/<name>.md` (and `ai-manager`,
40
- * `personalized-employee`) and the flatter `jobs/<category>/<name>.md`, with or
41
- * without a leading `registry/` prefix and with either slash style. Returns null
42
- * when the input is not a job path (for example a bare job name with no directory).
43
- *
44
- * Mirrors the jobs branch of `UsageCollector.parseComponentName` so both derive
45
- * the category the same way.
46
- */
47
- function jobCategoryFromRegistryPath(registryPath) {
48
- if (!registryPath || typeof registryPath !== 'string')
49
- return null;
50
- let clean = registryPath.replace(/\\/g, '/').replace(/^registry\//, '');
51
- if (clean.startsWith('/'))
52
- clean = clean.slice(1);
53
- const parts = clean.split('/');
54
- if (parts[0] !== 'jobs')
55
- return null;
56
- if (parts[1] === 'ai-employee' || parts[1] === 'ai-manager' || parts[1] === 'personalized-employee') {
57
- return parts.length > 3 ? parts[2] : null; // jobs/ai-employee/<category>/<name>.md
58
- }
59
- return parts.length > 2 ? parts[1] : null; // jobs/<category>/<name>.md
60
- }
61
- /**
62
- * Resolve the learning domain for a job from its registry path.
63
- *
64
- * Returns a `LearningDomain` for domain-scoped categories, or `null` for
65
- * cross-cutting categories, unmapped categories, and unparseable inputs. `null`
66
- * means "load global learnings only" at the injection site: global is never
67
- * hidden and an unknown category never guesses a wrong domain. The completeness
68
- * test is what distinguishes an intentional global from a missing mapping.
69
- */
27
+ exports.jobCategoryFromRegistryPath = catalog_1.jobDomainFromRegistryPath;
70
28
  function resolveJobDomain(registryPath) {
71
- const category = jobCategoryFromRegistryPath(registryPath);
72
- if (!category)
73
- return null;
74
- return exports.JOB_DOMAIN_MAP[category] ?? null;
75
- }
76
- /**
77
- * Read an explicit learning domain a job declares in its own frontmatter, if any.
78
- *
79
- * This is the extensibility hook (issue #806 review): a job can self-declare
80
- * `domain` so a newly-taught job with a new category is handled without editing
81
- * `JOB_DOMAIN_MAP`. Supports the JSON frontmatter block FRAIM jobs use
82
- * (`---\n{ ..., "domain": "people-ops" }\n---`) and a plain `domain: <value>`
83
- * line. Returns null when there is no frontmatter, no `domain`, or an unknown
84
- * domain - callers then fall back to the category map.
85
- */
86
- function domainFromJobFrontmatter(jobFileContent) {
87
- if (!jobFileContent || typeof jobFileContent !== 'string')
88
- return null;
89
- const fm = jobFileContent.match(/^?---\r?\n([\s\S]*?)\r?\n---/);
90
- if (!fm)
91
- return null;
92
- const block = fm[1];
93
- const trimmed = block.trim();
94
- if (trimmed.startsWith('{')) {
95
- try {
96
- const parsed = JSON.parse(trimmed);
97
- return isLearningDomain(parsed?.domain) ? parsed.domain : null;
98
- }
99
- catch {
100
- // fall through to line parsing
101
- }
102
- }
103
- const line = block.match(/^\s*domain:\s*["']?([a-z][a-z-]*)["']?\s*$/m);
104
- return line && isLearningDomain(line[1]) ? line[1] : null;
29
+ const selected = (0, catalog_1.selectCatalogDomain)(catalog, registryPath);
30
+ return selected.selection === 'mapped' && isLearningDomain(selected.domain) ? selected.domain : null;
105
31
  }
106
- /**
107
- * Resolve the learning domain for a job from its registry path and (optional)
108
- * file content. A domain the job self-declares in frontmatter overrides the
109
- * category map; otherwise the map applies; a null/unparseable path yields null
110
- * (load global only). Pure and side-effect-free: callers do the I/O (reading the
111
- * registry path and job file) and pass the results here, which keeps this
112
- * decision logic directly testable with mocked inputs.
113
- */
114
- function resolveDomainForJob(registryPath, jobFileContent) {
115
- if (!registryPath)
116
- return null;
117
- const declared = jobFileContent ? domainFromJobFrontmatter(jobFileContent) : null;
118
- return declared ?? resolveJobDomain(registryPath);
32
+ /** Job declarations are deliberately ignored, including personalized overrides. */
33
+ function resolveDomainForJob(registryPath, _jobFileContent) {
34
+ return registryPath ? resolveJobDomain(registryPath) : null;
119
35
  }
@@ -55,6 +55,13 @@ class AIMentor {
55
55
  }
56
56
  return null;
57
57
  }
58
+ /** Delivery identity and first entry come from the executable job, never request categories. */
59
+ async getRuleDeliveryContext(jobName) {
60
+ const job = await this.getOrLoadJob(jobName);
61
+ if (!job?.path)
62
+ return null;
63
+ return { jobPath: job.path.replace(/\\/g, '/').replace(/^.*\/registry\//, ''), initialPhase: job.metadata.initialPhase || Object.keys(job.metadata.phases || {})[0] || Array.from(job.phases.keys())[0] || 'starting' };
64
+ }
58
65
  async resolveIncludes(content, basePath) {
59
66
  return (0, include_resolver_1.resolveIncludes)(content, this.resolver, basePath, this.skillDedup);
60
67
  }