fraim 2.0.360-next.0 → 2.0.360-next.1

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');
@@ -282,11 +291,41 @@ async function verifyCapabilityDelivery(action, input, options) {
282
291
  const resolver = new LocalRegistryResolver({ workspaceRoot: options.projectPath,
283
292
  managerLocalRoot: manager.contentRoot, managerCacheRoot: manager.contentRoot, orgCacheRoot: org.contentRoot,
284
293
  remoteContentResolver: async () => { throw new Error('capability-not-available-locally'); },
294
+ onLocalDependency: (file, kind, content) => kind === 'directory' ? snapshotDirectory(file) : snapshot(file, content),
285
295
  });
286
296
  const originalResolve = resolver.resolveFile.bind(resolver);
287
297
  const snapshots = new Map();
298
+ const snapshotDirectory = (file) => {
299
+ const absolute = node_path_1.default.resolve(file);
300
+ let realPath = null;
301
+ try {
302
+ if (!node_fs_1.default.statSync(absolute).isDirectory())
303
+ throw new Error('capability-local-content-changed');
304
+ realPath = node_fs_1.default.realpathSync(absolute);
305
+ }
306
+ catch (error) {
307
+ if (error.code !== 'ENOENT')
308
+ throw error;
309
+ }
310
+ const prior = snapshots.get(absolute);
311
+ if (prior && (prior.kind !== 'directory' || prior.realPath !== realPath))
312
+ throw new Error('capability-local-content-changed');
313
+ snapshots.set(absolute, { file: absolute, realPath, sha256: null, kind: 'directory' });
314
+ };
288
315
  const snapshot = (file, expectedContent) => {
289
316
  const absolute = node_path_1.default.resolve(file);
317
+ try {
318
+ node_fs_1.default.lstatSync(absolute);
319
+ }
320
+ catch (error) {
321
+ if (error.code !== 'ENOENT' || expectedContent !== undefined)
322
+ throw error;
323
+ const prior = snapshots.get(absolute);
324
+ if (prior?.sha256)
325
+ throw new Error('capability-local-content-changed');
326
+ snapshots.set(absolute, { file: absolute, realPath: null, sha256: null });
327
+ return;
328
+ }
290
329
  const body = node_fs_1.default.readFileSync(absolute, 'utf8');
291
330
  if (expectedContent !== undefined && !sameDeliveryText(body, expectedContent))
292
331
  throw new Error('capability-local-content-changed');
@@ -296,12 +335,23 @@ async function verifyCapabilityDelivery(action, input, options) {
296
335
  throw new Error('capability-local-content-changed');
297
336
  snapshots.set(absolute, current);
298
337
  };
338
+ const snapshotRuleOverlays = (relative) => {
339
+ const normalized = relative.replace(/\\/g, '/');
340
+ if (node_path_1.default.isAbsolute(normalized) || normalized.split('/').some(part => !part || part === '..' || part === '.'))
341
+ throw new Error('invalid-capability-include');
342
+ // A new wrapper/fragment override can change delivery without touching
343
+ // the selected source. Bind absent overlay paths as well as its bytes.
344
+ if (normalized.startsWith('rules/')) {
345
+ for (const root of [node_path_1.default.join(options.projectPath, 'fraim/personalized-employee'), manager.contentRoot, org.contentRoot]) {
346
+ snapshot(node_path_1.default.join(root, normalized));
347
+ }
348
+ }
349
+ };
299
350
  const snapshotResolvedInclude = (relative, resolved) => {
300
351
  if (resolved.source !== 'local')
301
352
  return;
353
+ snapshotRuleOverlays(relative);
302
354
  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
355
  let candidates;
306
356
  if (resolved.scope === 'manager')
307
357
  candidates = [node_path_1.default.join(manager.contentRoot, normalized)];
@@ -309,17 +359,40 @@ async function verifyCapabilityDelivery(action, input, options) {
309
359
  candidates = [node_path_1.default.join(org.contentRoot, normalized)];
310
360
  else if (resolved.scope === 'project')
311
361
  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));
362
+ else
363
+ candidates = resolved.localPath ? [resolved.localPath] : [];
364
+ 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));
365
+ // Inherited overrides have expanded bodies. Their scoped raw source was
366
+ // snapshotted before resolution; imported children are traced separately.
367
+ const inheritedSource = normalized.startsWith('rules/') && resolved.scope && resolved.inherited ? candidates[0] : undefined;
368
+ const source = matchingSource ?? inheritedSource;
321
369
  if (source)
322
- snapshot(source, resolved.content);
370
+ snapshot(source, matchingSource ? resolved.content : undefined);
371
+ if (normalized.startsWith('rules/') && resolved.scope && source && node_path_1.default.resolve(source) !== node_path_1.default.resolve(target)) {
372
+ if (resolved.scope === 'project' && shared) {
373
+ sharedCallers.push({ file: source, expectedHash: snapshots.get(node_path_1.default.resolve(source)).sha256 });
374
+ }
375
+ else if (resolved.scope === 'manager' || resolved.scope === 'org') {
376
+ const level = resolved.scope === 'manager' ? 'Manager' : 'Org';
377
+ const sourceHome = (0, owner_identity_1.resolveOwnerHome)(options.projectPath, level, { runtimeModule: options.runtimeModule });
378
+ if (sourceHome.requiresPublish)
379
+ supportingPublications.set(source, sourceHome);
380
+ }
381
+ }
382
+ if (shared && normalized.startsWith('rules/') && resolved.scope === undefined) {
383
+ // Shared delivery depends on the composition that actually shipped,
384
+ // not a local wrapper edit (or a stale installed copy). Verification
385
+ // checks these files at both merged and current HEAD; it never
386
+ // publishes supporting edits under the target rule's approval.
387
+ const registryFile = node_path_1.default.join(options.projectPath, 'registry', normalized);
388
+ if (!node_fs_1.default.existsSync(registryFile) || !sameDeliveryText(node_fs_1.default.readFileSync(registryFile, 'utf8'), resolved.content)) {
389
+ sharedCatalogAvailable = false;
390
+ }
391
+ else {
392
+ snapshot(registryFile, resolved.content);
393
+ sharedCallers.push({ file: registryFile, expectedHash: (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(registryFile, 'utf8')) });
394
+ }
395
+ }
323
396
  };
324
397
  snapshot(target, targetContent);
325
398
  if (snapshots.get(node_path_1.default.resolve(target))?.sha256 !== localHash)
@@ -332,6 +405,45 @@ async function verifyCapabilityDelivery(action, input, options) {
332
405
  return { published: false, reference: target, contentHash, reachable: false };
333
406
  let reachable = true;
334
407
  const sharedCallers = [];
408
+ const supportingPublications = new Map();
409
+ let sharedCatalogAvailable = true;
410
+ const captureRuleSelection = (callerPath) => {
411
+ const paths = load('core/utils/project-fraim-paths');
412
+ const workspace = paths.getWorkspaceFraimPath(options.projectPath);
413
+ const user = paths.getUserFraimDirPath();
414
+ snapshot(node_path_1.default.join(workspace, 'config.json'));
415
+ snapshot(node_path_1.default.join(user, 'config.json'));
416
+ const machineCatalog = node_path_1.default.join(user, 'domains.json');
417
+ const workspaceCatalog = node_path_1.default.join(workspace, 'domains.json');
418
+ const registryCatalog = node_path_1.default.join(options.projectPath, 'registry/domains.json');
419
+ const syncedCatalog = node_fs_1.default.existsSync(machineCatalog) ? machineCatalog : workspaceCatalog;
420
+ const baselineCatalog = node_fs_1.default.existsSync(syncedCatalog) ? syncedCatalog : registryCatalog;
421
+ for (const file of [machineCatalog, workspaceCatalog, registryCatalog])
422
+ snapshot(file);
423
+ for (const [level, root] of [['Project', node_path_1.default.join(workspace, 'personalized-employee')], ['Manager', manager.contentRoot], ['Org', org.contentRoot]]) {
424
+ const file = node_path_1.default.join(root, 'domains.json');
425
+ snapshot(file);
426
+ if (node_fs_1.default.existsSync(file)) {
427
+ const sourceHome = (0, owner_identity_1.resolveOwnerHome)(options.projectPath, level, { runtimeModule: options.runtimeModule });
428
+ if (sourceHome.requiresPublish)
429
+ supportingPublications.set(file, sourceHome);
430
+ if (shared && level === 'Project')
431
+ sharedCallers.push({ file, expectedHash: snapshots.get(node_path_1.default.resolve(file)).sha256 });
432
+ }
433
+ // An override created after verification must invalidate the proof.
434
+ snapshot(node_path_1.default.join(root, targetPath));
435
+ snapshot(node_path_1.default.join(root, callerPath));
436
+ }
437
+ if (shared) {
438
+ // An installed catalog alone cannot prove that the shared selection
439
+ // shipped in the reviewed repository revision and current remote HEAD.
440
+ if (!node_fs_1.default.existsSync(registryCatalog) || !node_fs_1.default.existsSync(baselineCatalog)
441
+ || !sameDeliveryText(node_fs_1.default.readFileSync(registryCatalog, 'utf8'), node_fs_1.default.readFileSync(baselineCatalog, 'utf8')))
442
+ sharedCatalogAvailable = false;
443
+ else
444
+ sharedCallers.push({ file: registryCatalog, expectedHash: snapshots.get(node_path_1.default.resolve(registryCatalog)).sha256 });
445
+ }
446
+ };
335
447
  for (const caller of input.callers) {
336
448
  const callerHome = options.resolveHome(caller.owner);
337
449
  (0, owner_identity_1.assertSameOwner)(caller.owner, callerHome.owner);
@@ -349,6 +461,7 @@ async function verifyCapabilityDelivery(action, input, options) {
349
461
  sharedCallers.push({ file: callerFile, expectedHash: (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(callerFile, 'utf8')) });
350
462
  let traversed = false;
351
463
  resolver.resolveFile = async (relative, resolveOptions) => {
464
+ snapshotRuleOverlays(relative);
352
465
  const resolved = await originalResolve(relative, resolveOptions);
353
466
  snapshotResolvedInclude(relative, resolved);
354
467
  if (relative === targetPath && resolved.scope === targetScope && sameDeliveryText(resolved.content, targetContent))
@@ -356,6 +469,17 @@ async function verifyCapabilityDelivery(action, input, options) {
356
469
  return resolved;
357
470
  };
358
471
  const hydrated = await resolver.resolveIncludes(loadedCaller.content);
472
+ if (!traversed && callerPath.startsWith('jobs/') && targetPath.startsWith('rules/')) {
473
+ captureRuleSelection(callerPath);
474
+ const { assembleDomainRules } = load('core/utils/domain-rule-assembler');
475
+ const assembly = await assembleDomainRules({ jobPath: callerPath,
476
+ resolveFile: (relative) => resolver.resolveFile(relative, { includeMetadata: false, stripMcpHeader: true }) });
477
+ // Source traversal binds the approved raw bytes; assembly metadata
478
+ // proves this exact owner/path survived effective composition.
479
+ traversed = traversed && assembly.metadata.complete && assembly.metadata.selection === 'mapped'
480
+ && [...assembly.metadata.artifacts, ...(assembly.metadata.includedArtifacts || [])]
481
+ .some((artifact) => artifact.path === targetPath && artifact.scope === targetScope);
482
+ }
359
483
  if (!traversed || /\{\{include:[^}]+\}\}/.test(hydrated)) {
360
484
  reachable = false;
361
485
  break;
@@ -366,10 +490,39 @@ async function verifyCapabilityDelivery(action, input, options) {
366
490
  return { published: false, reference: target, contentHash, reachable: false };
367
491
  const localFiles = [...snapshots.values()].sort((a, b) => a.file.localeCompare(b.file));
368
492
  const verifySnapshots = () => {
369
- for (const item of localFiles)
493
+ for (const item of localFiles) {
494
+ if (item.kind === 'directory' && item.realPath !== null) {
495
+ 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)
496
+ throw new Error('capability-local-content-changed');
497
+ continue;
498
+ }
499
+ if (item.sha256 === null) {
500
+ try {
501
+ node_fs_1.default.lstatSync(item.file);
502
+ }
503
+ catch (error) {
504
+ if (error.code === 'ENOENT')
505
+ continue;
506
+ throw error;
507
+ }
508
+ throw new Error('capability-local-content-changed');
509
+ }
370
510
  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
511
  throw new Error('capability-local-content-changed');
512
+ }
372
513
  };
514
+ verifySnapshots();
515
+ if (!sharedCatalogAvailable)
516
+ return { published: false, reference: target, contentHash, reachable, localFiles, ...reviewEvidence };
517
+ for (const [file, sourceHome] of supportingPublications) {
518
+ if (!options.runtimeModulePath)
519
+ throw new Error('capability-publication-required');
520
+ // Rule approval never authorizes publishing supporting catalog or rule edits.
521
+ 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);
522
+ verifySnapshots();
523
+ if (!supporting.published)
524
+ return { ...supporting, contentHash, reachable, localFiles, ...reviewEvidence };
525
+ }
373
526
  if (shared) {
374
527
  const publication = verifySharedProjectPublication(options.projectPath, target, localHash, input.publicationRef, sharedCallers);
375
528
  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
  }