fraim-hub 2.0.338 → 2.0.340

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.COMPLETION_END = exports.COMPLETION_START = void 0;
7
+ exports.validateProcessedSources = validateProcessedSources;
8
+ exports.decodeCompletion = decodeCompletion;
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ function validateProcessedSources(sources) {
11
+ if (!Array.isArray(sources) || new Set(sources.map(item => item.path)).size !== sources.length || sources.some(item => !item || !node_path_1.default.isAbsolute(item.path) || !node_path_1.default.isAbsolute(item.destination) || !['raw', 'retrospective'].includes(item.kind)
12
+ || !/^[a-f0-9]{64}$/.test(item.sha256) || !/^[a-f0-9]{64}$/.test(item.resultHash) || !/^\d{4}-\d{2}-\d{2}$/.test(item.synthesizedAt)))
13
+ throw new Error('invalid-processed-sources');
14
+ }
15
+ exports.COMPLETION_START = '<!-- fraim-sleep-decisions:v1 -->';
16
+ exports.COMPLETION_END = '<!-- /fraim-sleep-decisions -->';
17
+ const text = (value) => typeof value === 'string' && !!value.trim();
18
+ const hash = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
19
+ const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
20
+ const keys = (value, allowed) => object(value) && Object.keys(value).every(key => allowed.includes(key));
21
+ const ownerValid = (value) => object(value) && ['Project', 'Manager', 'Org'].includes(value.level)
22
+ && keys(value, ['level', 'path']) && text(value.path) && node_path_1.default.isAbsolute(value.path)
23
+ && !/[\x00-\x1f]/.test(value.path) && value.path === (process.platform === 'win32' ? node_path_1.default.resolve(value.path).replace(/\\/g, '/').toLowerCase() : node_path_1.default.resolve(value.path));
24
+ const sameOwner = (a, b) => a.level === b.level && a.path === b.path;
25
+ function decodeCompletion(markdown, expected = {}) {
26
+ const fail = () => { throw new Error('invalid-completion-decisions'); };
27
+ if ((markdown.match(/<!--\s*fraim-sleep-decisions:/g) || []).length !== 1 || markdown.split(exports.COMPLETION_START).length !== 2 || markdown.split(exports.COMPLETION_END).length !== 2)
28
+ fail();
29
+ const start = markdown.indexOf(exports.COMPLETION_START) + exports.COMPLETION_START.length;
30
+ const end = markdown.indexOf(exports.COMPLETION_END);
31
+ if (end < start)
32
+ fail();
33
+ const block = markdown.slice(start, end).match(/^\s*```json\r?\n([\s\S]*?)\r?\n```\s*$/);
34
+ if (!block)
35
+ fail();
36
+ let state;
37
+ try {
38
+ state = JSON.parse(block[1]);
39
+ }
40
+ catch {
41
+ fail();
42
+ }
43
+ if (!keys(state, ['schemaVersion', 'runId', 'owner', 'finalizationState', 'decisions', 'sourceDispositions', 'cleanup', 'reconciliations', 'retrospective', 'processedSources']) || state.schemaVersion !== 1 || !text(state.runId) || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(state.runId) || !ownerValid(state.owner)
44
+ || !['pending', 'complete'].includes(state.finalizationState) || !Array.isArray(state.decisions) || !Array.isArray(state.sourceDispositions) || !Array.isArray(state.cleanup))
45
+ fail();
46
+ if ((expected.runId && expected.runId !== state.runId) || (expected.owner && !sameOwner(expected.owner, state.owner)))
47
+ fail();
48
+ if (state.retrospective !== undefined && (!keys(state.retrospective, ['relativePath', 'sha256'])
49
+ || state.retrospective.relativePath !== `retrospectives/${state.runId}-sleep-postmortem.md` || !hash(state.retrospective.sha256)))
50
+ fail();
51
+ if (state.processedSources !== undefined)
52
+ validateProcessedSources(state.processedSources);
53
+ const ids = new Set();
54
+ for (const item of state.decisions) {
55
+ if (!keys(item, ['actionId', 'revision', 'digest', 'owner', 'claimKey', 'evidenceFingerprint', 'sourceRefs', 'decision', 'publicationRef', 'target', 'dependencies']) || !text(item.actionId) || ids.has(item.actionId) || !Number.isInteger(item.revision) || item.revision < 1 || !hash(item.digest) || !ownerValid(item.owner)
56
+ || !text(item.claimKey) || !text(item.evidenceFingerprint) || !Array.isArray(item.sourceRefs) || !item.sourceRefs.every(text) || (item.publicationRef !== undefined && !text(item.publicationRef)))
57
+ fail();
58
+ ids.add(item.actionId);
59
+ const decision = item.decision;
60
+ if (!keys(decision, ['action', 'revision', 'digest', 'reviewer', 'reason', 'revisitCondition', 'at']) || !['approve', 'reject'].includes(decision.action) || decision.revision !== item.revision || decision.digest !== item.digest
61
+ || !text(decision.reviewer) || !text(decision.reason) || !text(decision.at) || !Number.isFinite(Date.parse(decision.at))
62
+ || (decision.revisitCondition !== undefined && (decision.action !== 'reject' || !text(decision.revisitCondition))))
63
+ fail();
64
+ if ('content' in item || 'specification' in item || 'application' in item || 'preimage' in item)
65
+ fail();
66
+ if (item.target && (!keys(item.target, ['owner', 'relativePath', 'entryId', 'learningSubject']) || !ownerValid(item.target.owner) || !text(item.target.relativePath) || /^[\\/]|^[a-zA-Z]:/.test(item.target.relativePath) || item.target.relativePath.split('/').includes('..')))
67
+ fail();
68
+ if (item.dependencies && (!Array.isArray(item.dependencies) || item.dependencies.some((dependency) => !keys(dependency, ['actionId', 'outcome']) || !text(dependency.actionId) || !['applied', 'rejected'].includes(dependency.outcome))))
69
+ fail();
70
+ }
71
+ for (const source of state.sourceDispositions) {
72
+ if (!keys(source, ['sourceRef', 'sourceHash', 'outcome', 'actionId']) || !text(source.sourceRef) || !hash(source.sourceHash) || !['applied', 'rejected'].includes(source.outcome) || !ids.has(source.actionId))
73
+ fail();
74
+ }
75
+ const decisions = new Map(state.decisions.map((item) => [item.actionId, item]));
76
+ const outcome = (item) => item.decision.action === 'approve' ? 'applied' : 'rejected';
77
+ const dispositions = new Set();
78
+ for (const source of state.sourceDispositions) {
79
+ const action = decisions.get(source.actionId);
80
+ const key = JSON.stringify([source.actionId, source.sourceRef]);
81
+ if (dispositions.has(key) || !action.sourceRefs.includes(source.sourceRef) || source.outcome !== outcome(action))
82
+ fail();
83
+ dispositions.add(key);
84
+ }
85
+ for (const action of decisions.values()) {
86
+ if (action.target && !sameOwner(action.owner, action.target.owner))
87
+ fail();
88
+ if (new Set(action.sourceRefs).size !== action.sourceRefs.length || action.sourceRefs.some(ref => !dispositions.has(JSON.stringify([action.actionId, ref]))))
89
+ fail();
90
+ const dependencies = action.dependencies || [];
91
+ if (new Set(dependencies.map(dependency => dependency.actionId)).size !== dependencies.length)
92
+ fail();
93
+ for (const dependency of dependencies) {
94
+ const target = decisions.get(dependency.actionId);
95
+ if (!target || target.actionId === action.actionId || dependency.outcome !== outcome(target)
96
+ || (action.decision.action === 'approve' && dependency.outcome !== 'applied'))
97
+ fail();
98
+ }
99
+ }
100
+ const checked = new Set();
101
+ const visiting = new Set();
102
+ const visit = (id) => {
103
+ if (visiting.has(id))
104
+ fail();
105
+ if (checked.has(id))
106
+ return;
107
+ visiting.add(id);
108
+ for (const dependency of decisions.get(id).dependencies || [])
109
+ visit(dependency.actionId);
110
+ visiting.delete(id);
111
+ checked.add(id);
112
+ };
113
+ for (const id of decisions.keys())
114
+ visit(id);
115
+ for (const item of state.cleanup) {
116
+ if (!keys(item, ['path', 'outcome', 'reason', 'sha256', 'location']) || !text(item.path) || !['pending', 'deleted', 'absent', 'retained'].includes(item.outcome) || (item.reason !== undefined && typeof item.reason !== 'string') || (item.sha256 !== undefined && !hash(item.sha256)))
117
+ fail();
118
+ if (item.location !== undefined) {
119
+ const location = item.location;
120
+ if (!keys(location, ['owner', 'relativePath']) || !ownerValid(location.owner)
121
+ || !text(location.relativePath) || !/^learnings\/(?:review-queue|review-archive|review-evidence|archive)\//.test(location.relativePath)
122
+ || /[\\:]/.test(location.relativePath) || location.relativePath.split('/').some((part) => !part || part === '.' || part === '..'))
123
+ fail();
124
+ }
125
+ if (state.finalizationState === 'complete' && item.outcome === 'pending')
126
+ fail();
127
+ }
128
+ if (state.reconciliations !== undefined) {
129
+ if (!Array.isArray(state.reconciliations))
130
+ fail();
131
+ const sources = new Set();
132
+ for (const item of state.reconciliations) {
133
+ if (!keys(item, ['sourceRef', 'sourceHash', 'owner', 'relativePath', 'actionIds', 'actionBindings', 'disposition', 'rationale', 'preservedDecisions', 'reviewer'])
134
+ || !text(item.sourceRef) || sources.has(item.sourceRef) || !hash(item.sourceHash) || !ownerValid(item.owner)
135
+ || !text(item.relativePath) || !/^learnings\/(?:review-queue|review-archive|review-evidence|archive)\//.test(item.relativePath)
136
+ || /[\\:]/.test(item.relativePath) || item.relativePath.split('/').some((part) => !part || part === '.' || part === '..')
137
+ || !['accounted', 'unrelated', 'raw-source'].includes(item.disposition) || !text(item.rationale) || !text(item.reviewer)
138
+ || !Array.isArray(item.preservedDecisions) || !item.preservedDecisions.every(text)
139
+ || !Array.isArray(item.actionIds) || new Set(item.actionIds).size !== item.actionIds.length
140
+ || !Array.isArray(item.actionBindings) || item.actionBindings.length !== item.actionIds.length)
141
+ fail();
142
+ sources.add(item.sourceRef);
143
+ for (const [index, id] of item.actionIds.entries()) {
144
+ const decision = decisions.get(id);
145
+ const binding = item.actionBindings[index];
146
+ if (!decision || !keys(binding, ['actionId', 'revision', 'digest']) || binding.actionId !== id || binding.revision !== decision.revision || binding.digest !== decision.digest
147
+ || !state.sourceDispositions.some((source) => source.actionId === id && source.sourceRef.replace(/\\/g, '/') === item.sourceRef.replace(/\\/g, '/') && source.sourceHash === item.sourceHash))
148
+ fail();
149
+ }
150
+ }
151
+ }
152
+ return state;
153
+ }
@@ -468,6 +468,51 @@ function buildDesktopDebugSnapshot(runtimeId) {
468
468
  gpuFeatureStatus: electron_1.app.getGPUFeatureStatus(),
469
469
  };
470
470
  }
471
+ function installEditingContextMenu(window) {
472
+ const contents = window.webContents;
473
+ contents.on('context-menu', async (_event, params) => {
474
+ if (!params.isEditable && !params.selectionText)
475
+ return;
476
+ // The Hub can override the OS theme. Read only that existing preference;
477
+ // keep native menu colors consistent without introducing a renderer bridge.
478
+ try {
479
+ const theme = await contents.executeJavaScript("document.documentElement.getAttribute('data-theme')");
480
+ if (theme === 'light' || theme === 'dark')
481
+ electron_1.nativeTheme.themeSource = theme;
482
+ }
483
+ catch { /* Navigation may remove the document; retain the native theme. */ }
484
+ if (window.isDestroyed() || contents.isDestroyed())
485
+ return;
486
+ const flags = params.editFlags;
487
+ // Chromium marks read-only fields as editable, but disables paste/cut.
488
+ const canChange = params.isEditable && flags.canPaste;
489
+ const items = [];
490
+ if (canChange && params.misspelledWord) {
491
+ for (const suggestion of params.dictionarySuggestions) {
492
+ items.push({ label: suggestion, click: () => contents.replaceMisspelling(suggestion) });
493
+ }
494
+ items.push({
495
+ label: 'Add to dictionary',
496
+ click: () => {
497
+ let added = false;
498
+ try {
499
+ added = contents.session.addWordToSpellCheckerDictionary(params.misspelledWord);
500
+ }
501
+ catch { /* An unavailable platform dictionary must not disrupt editing. */ }
502
+ if (!added && !window.isDestroyed()) {
503
+ void electron_1.dialog.showMessageBox(window, {
504
+ type: 'warning', title: 'Dictionary unavailable',
505
+ message: 'Could not add the word to the dictionary. You can continue editing your draft.',
506
+ buttons: ['OK'],
507
+ }).catch(() => { });
508
+ }
509
+ },
510
+ }, { type: 'separator' });
511
+ }
512
+ items.push({ role: 'cut', enabled: canChange && flags.canCut }, { role: 'copy', enabled: flags.canCopy }, { role: 'paste', enabled: canChange }, { role: 'selectAll', enabled: flags.canSelectAll });
513
+ electron_1.Menu.buildFromTemplate(items).popup({ window });
514
+ });
515
+ }
471
516
  async function createWindow(url, runtimeId = process.env.FRAIM_HUB_RUNTIME_ID || 'hub') {
472
517
  const { width, height } = preferredWindowSize();
473
518
  const isMac = process.platform === 'darwin';
@@ -497,6 +542,7 @@ async function createWindow(url, runtimeId = process.env.FRAIM_HUB_RUNTIME_ID ||
497
542
  webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
498
543
  });
499
544
  electron_1.Menu.setApplicationMenu(null);
545
+ installEditingContextMenu(mainWindow);
500
546
  mainWindow.on('focus', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.focus'));
501
547
  mainWindow.on('blur', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.blur'));
502
548
  mainWindow.on('show', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.show', { elapsedSinceProcessStartMs: performance.now() }));
@@ -38,6 +38,7 @@ exports.spawnHostProcess = spawnHostProcess;
38
38
  exports.findGeminiSessionIdForPrompt = findGeminiSessionIdForPrompt;
39
39
  exports.checkFraimMcpAttachReadiness = checkFraimMcpAttachReadiness;
40
40
  const crypto_1 = require("crypto");
41
+ const handoff_contracts_1 = require("../core/handoff-contracts");
41
42
  const child_process_1 = require("child_process");
42
43
  const background_command_1 = require("./background-command");
43
44
  const process_liveness_1 = require("./process-liveness");
@@ -843,6 +844,7 @@ function extractSignalFromArgs(args) {
843
844
  : typeof args.issueNumber === 'number' ? String(args.issueNumber)
844
845
  : undefined;
845
846
  const reviewHandoff = extractReviewHandoffFromArgs(args);
847
+ const reviewHandoffError = reviewHandoff ? null : extractMalformedReviewHandoffError(args);
846
848
  const delegationLedger = extractDelegationLedgerFromArgs(args);
847
849
  const nextJobRecommendations = extractNextJobRecommendationsFromArgs(args);
848
850
  const deliveryActionId = readNullableString(evidenceArgs?.deliveryActionId);
@@ -858,6 +860,7 @@ function extractSignalFromArgs(args) {
858
860
  jobId,
859
861
  ...(issueNumber ? { issueNumber } : {}),
860
862
  ...(reviewHandoff ? { reviewHandoff } : {}),
863
+ ...(reviewHandoffError ? { reviewHandoffError } : {}),
861
864
  ...(delegationLedger ? { delegationLedger } : {}),
862
865
  ...(nextJobRecommendations ? { nextJobRecommendations } : {}),
863
866
  ...(deliveryActionId !== undefined ? { deliveryActionId } : {}),
@@ -1142,6 +1145,21 @@ function extractReviewHandoffFromArgs(args) {
1142
1145
  }
1143
1146
  return null;
1144
1147
  }
1148
+ function extractMalformedReviewHandoffError(args) {
1149
+ const evidence = args.evidence;
1150
+ const findings = args.findings;
1151
+ let candidate = [args.reviewHandoff, evidence?.reviewHandoff, findings?.reviewHandoff]
1152
+ .find((value) => value !== undefined && value !== null);
1153
+ if (candidate === undefined)
1154
+ return null;
1155
+ if (typeof candidate === 'string') {
1156
+ try {
1157
+ candidate = JSON.parse(candidate);
1158
+ }
1159
+ catch { /* The shared checker reports the invalid object. */ }
1160
+ }
1161
+ return (0, handoff_contracts_1.reviewHandoffNormalizationError)(candidate);
1162
+ }
1145
1163
  function readReviewHandoffCandidate(value) {
1146
1164
  let candidate = value;
1147
1165
  if (typeof candidate === 'string') {
@@ -45,6 +45,7 @@ exports.__setCatalogSyncSpawnForTests = __setCatalogSyncSpawnForTests;
45
45
  exports.__setAgentInstallTimeoutMsForTests = __setAgentInstallTimeoutMsForTests;
46
46
  exports.hubRunProcess = hubRunProcess;
47
47
  exports.buildOpenFileInvocation = buildOpenFileInvocation;
48
+ const handoff_contracts_1 = require("../core/handoff-contracts");
48
49
  const express_1 = __importDefault(require("express"));
49
50
  const path_1 = __importDefault(require("path"));
50
51
  const fs_1 = __importDefault(require("fs"));
@@ -55,6 +56,7 @@ const https_1 = __importDefault(require("https"));
55
56
  const native_voice_input_1 = require("./native-voice-input");
56
57
  const types_1 = require("../first-run/types");
57
58
  const local_provider_registry_1 = require("../cli/providers/local-provider-registry");
59
+ const learning_domains_1 = require("../config/learning-domains");
58
60
  const learning_context_builder_1 = require("../local-mcp-server/learning-context-builder");
59
61
  const brand_store_1 = require("../core/brand-store");
60
62
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
@@ -982,6 +984,7 @@ function normalizeReviewHandoff(raw) {
982
984
  const value = raw;
983
985
  if (typeof value.reviewRequired !== 'boolean')
984
986
  return null;
987
+ const producingJobName = typeof value.producingJobName === 'string' ? value.producingJobName.trim() : '';
985
988
  const artifacts = Array.isArray(value.artifacts)
986
989
  ? value.artifacts.map((artifact, index) => normalizeReviewArtifact(artifact, index)).filter((artifact) => Boolean(artifact))
987
990
  : [];
@@ -995,6 +998,7 @@ function normalizeReviewHandoff(raw) {
995
998
  : '';
996
999
  if (!value.reviewRequired) {
997
1000
  return {
1001
+ ...(producingJobName ? { producingJobName } : {}),
998
1002
  reviewRequired: false,
999
1003
  reviewTarget: rawTarget ? { type: targetType || 'none', label: targetLabel || 'Completed work' } : null,
1000
1004
  artifacts,
@@ -1010,6 +1014,7 @@ function normalizeReviewHandoff(raw) {
1010
1014
  if (artifacts.length > 0)
1011
1015
  return null;
1012
1016
  return {
1017
+ ...(producingJobName ? { producingJobName } : {}),
1013
1018
  reviewRequired: true,
1014
1019
  reviewTarget: { type: 'pull_request', label: targetLabel || 'Pull request', url },
1015
1020
  artifacts: [],
@@ -1027,6 +1032,7 @@ function normalizeReviewHandoff(raw) {
1027
1032
  const fileArtifacts = artifacts.filter((artifact) => artifact.path && !artifact.url);
1028
1033
  if (targetType === 'artifact_set' && fileArtifacts.length === artifacts.length) {
1029
1034
  return {
1035
+ ...(producingJobName ? { producingJobName } : {}),
1030
1036
  reviewRequired: true,
1031
1037
  reviewTarget: { type: 'artifact_set', label: targetLabel || `${artifacts.length} artifact${artifacts.length === 1 ? '' : 's'}` },
1032
1038
  artifacts: fileArtifacts,
@@ -1615,31 +1621,7 @@ function isCodexMissingReasoningResumeText(value) {
1615
1621
  return text.includes("provided without its required 'reasoning' item") ||
1616
1622
  text.includes('provided without its required "reasoning" item');
1617
1623
  }
1618
- // Issue #1447: a dropped or non-persisted review handoff must not look
1619
- // successful to the agent. Records the failure on the existing non-droppable
1620
- // hostError channel (the same `run.lastHostError`/`run.events` pattern the
1621
- // #1264/#1265 `detectUnrecognizedReviewHandoffCall` diagnostic uses) AND
1622
- // appends a system message directly to `run.messages` — the agent- and
1623
- // manager-visible thread — so the failure is visible immediately rather than
1624
- // only after a terminal non-zero exit (`pushTerminalFailureToThread` only
1625
- // fires then). The message names the reason and this run's identity so the
1626
- // agent can recover by resubmitting.
1627
- function failLoudlyOnDroppedReviewHandoff(run, reason) {
1628
- const message = `FRAIM review handoff was not applied to run ${run.id} (job "${run.jobId || 'unknown'}"): ${reason}. The Hub review surface does not reflect this submission — resubmit seekMentoring from this run's active job/phase.`;
1629
- run.lastHostError = message;
1630
- run.events.push((0, hosts_1.createHubEvent)('stderr', boundedEventText(message)));
1631
- // This runs outside recordHostEvent's own end-of-call cap, so cap here too —
1632
- // an agent stuck retrying a mismatched-job submission must not grow this
1633
- // array past the same bound every other event source respects.
1634
- capProjectedEvents(run);
1635
- run.messages.push((0, hosts_1.createHubMessage)('system', message));
1636
- }
1637
- // Issue #1447: applies a reviewHandoff carried on a same-job seekMentoring
1638
- // signal. Pure apply/normalize — it records no diagnostics itself. Fidelity
1639
- // to the submission is checked once, generically, by
1640
- // verifyReviewHandoffReflectsSubmission below, regardless of which of this
1641
- // function's branches (or the caller's cross-job guard) is why the stored
1642
- // result might not match.
1624
+ // Review ownership follows the submitted deliverable, independently of tracker identity.
1643
1625
  function applyReviewHandoffToRun(run, reviewHandoff) {
1644
1626
  const normalizedReviewHandoff = normalizeReviewHandoff(reviewHandoff);
1645
1627
  if (!normalizedReviewHandoff) {
@@ -1663,7 +1645,7 @@ function applyReviewHandoffToRun(run, reviewHandoff) {
1663
1645
  }
1664
1646
  // Issue #1447: a mismatched `pull_request` target, isolated for readability.
1665
1647
  function pullRequestTargetMismatch(stored, submittedTarget) {
1666
- const submittedUrl = typeof submittedTarget?.url === 'string' ? submittedTarget.url : undefined;
1648
+ const submittedUrl = (0, url_safety_1.safeHttpUrl)(submittedTarget?.url) || undefined;
1667
1649
  if (stored.reviewTarget?.type !== 'pull_request' || stored.reviewTarget.url !== submittedUrl) {
1668
1650
  return `stored reviewTarget (${JSON.stringify(stored.reviewTarget)}) does not match the submitted pull_request target (url: "${submittedUrl}")`;
1669
1651
  }
@@ -1680,7 +1662,7 @@ function artifactSetMismatch(stored, submitted) {
1680
1662
  const rawArtifacts = submitted.artifacts;
1681
1663
  const rawArtifactList = Array.isArray(rawArtifacts) ? rawArtifacts : [];
1682
1664
  const submittedPaths = rawArtifactList
1683
- .map((artifact) => (artifact && typeof artifact.path === 'string' ? artifact.path : null))
1665
+ .map((artifact) => (artifact && typeof artifact.path === 'string' ? artifact.path.trim() : null))
1684
1666
  .filter((path) => Boolean(path))
1685
1667
  .sort();
1686
1668
  const storedPaths = (stored.artifacts || [])
@@ -1693,8 +1675,7 @@ function artifactSetMismatch(stored, submitted) {
1693
1675
  return null;
1694
1676
  }
1695
1677
  // Issue #1447: one generic post-condition instead of enumerating each way a
1696
- // review handoff can fail to land. Whatever the reason — cross-job
1697
- // filtering, normalization rejecting the whole payload, or a per-artifact
1678
+ // review handoff can fail to land. Whether normalization rejects the whole payload or a per-artifact
1698
1679
  // normalization failure silently dropping one item while the handoff as a
1699
1680
  // whole still normalizes — this compares what was actually submitted against
1700
1681
  // what the run now holds and fails loudly on any structural mismatch. Only
@@ -1702,33 +1683,29 @@ function artifactSetMismatch(stored, submitted) {
1702
1683
  // issue's incident describes (a stale or absent handoff masquerading as a
1703
1684
  // successful submission); a `reviewRequired: false` payload carries no such
1704
1685
  // promise.
1705
- function verifyReviewHandoffReflectsSubmission(run, submitted, crossJobName) {
1686
+ function verifyReviewHandoffReflectsSubmission(run, submitted, onFailure) {
1706
1687
  if (!submitted || typeof submitted !== 'object' || submitted.reviewRequired !== true)
1707
1688
  return;
1708
- if (crossJobName) {
1709
- failLoudlyOnDroppedReviewHandoff(run, `a review handoff was submitted under job "${crossJobName}", but this run is tracking job "${run.jobId}" — the cross-job guard did not apply it`);
1710
- return;
1711
- }
1712
1689
  const stored = run.reviewHandoff;
1713
1690
  const submittedTarget = submitted.reviewTarget;
1714
1691
  if (!stored || stored.reviewRequired !== true) {
1715
- failLoudlyOnDroppedReviewHandoff(run, `evidence.reviewHandoff failed validation and was rejected (submitted reviewTarget: ${JSON.stringify(submittedTarget) || 'none'})`);
1692
+ onFailure(`evidence.reviewHandoff failed validation and was rejected (submitted reviewTarget: ${JSON.stringify(submittedTarget) || 'none'})`);
1716
1693
  return;
1717
1694
  }
1718
- const submittedType = submittedTarget && typeof submittedTarget.type === 'string' ? submittedTarget.type : undefined;
1695
+ const submittedType = submittedTarget && typeof submittedTarget.type === 'string' ? submittedTarget.type.trim() : undefined;
1719
1696
  const mismatch = submittedType === 'pull_request'
1720
1697
  ? pullRequestTargetMismatch(stored, submittedTarget)
1721
1698
  : artifactSetMismatch(stored, submitted);
1722
1699
  if (mismatch)
1723
- failLoudlyOnDroppedReviewHandoff(run, mismatch);
1700
+ onFailure(mismatch);
1724
1701
  }
1725
1702
  // Apply a parsed seekMentoring tool-use signal from the host stream to
1726
1703
  // the run state. Returns the updated currentPhase.
1727
- function applySeekMentoringSignal(run, signal) {
1704
+ function applySeekMentoringSignal(run, signal, onReviewFailure) {
1728
1705
  // Issue #347: filter cross-job pollution. The agent may call
1729
1706
  // seekMentoring for jobs OTHER than the one this run is tracking
1730
1707
  // (e.g., consulting `organizational-learning-synthesis` mid-run).
1731
- // Only apply signals whose job identity matches this run's.
1708
+ // Only tracker metadata requires matching job identity.
1732
1709
  // Issue #732: match on `jobName` — the stable job-name slug that equals
1733
1710
  // run.jobId — NOT `jobId`. get_fraim_job mints a fresh randomUUID as the
1734
1711
  // job id on every call and the agent echoes that UUID into
@@ -1739,33 +1716,24 @@ function applySeekMentoringSignal(run, signal) {
1739
1716
  const targetJobId = run.jobId;
1740
1717
  const callJobName = signal.jobName;
1741
1718
  const isCrossJob = Boolean(callJobName && targetJobId && callJobName !== targetJobId);
1742
- // Issue #1447: a genuine reviewHandoff must not vanish without a trace,
1743
- // whether the cross-job guard just below is why it was never applied, or
1744
- // applyReviewHandoffToRun rejected/truncated it. Apply first (skipped
1745
- // entirely for a foreign job, since that guard is still correct — a
1746
- // foreign job's data must never overwrite this run's tracked state), then
1747
- // verify unconditionally that the result actually reflects the submission.
1748
- if (signal.reviewHandoff) {
1749
- if (!isCrossJob)
1750
- applyReviewHandoffToRun(run, signal.reviewHandoff);
1751
- verifyReviewHandoffReflectsSubmission(run, signal.reviewHandoff, isCrossJob ? callJobName : null);
1752
- }
1753
- else if (!isCrossJob) {
1754
- // Issue #1501: No reviewHandoff in this same-job call. Clear any prior reviewHandoff so
1755
- // run.reviewHandoff always reflects the most recent seekMentoring intent,
1756
- // not a stale flag from an earlier submit phase.
1757
- run.reviewHandoff = null;
1758
- }
1759
- if (isCrossJob)
1719
+ if (signal.reviewHandoffError) {
1720
+ onReviewFailure(signal.reviewHandoffError);
1760
1721
  return;
1761
- // Issue #848: capture the issue number the call carries so a launched follow-on
1762
- // run can reference the originating issue, and persist any next-job recommendations.
1763
- if (signal.issueNumber)
1764
- run.issueNumber = signal.issueNumber;
1765
- if (signal.nextJobRecommendations) {
1766
- const normalized = normalizeNextJobRecommendations(signal.nextJobRecommendations);
1767
- if (normalized)
1768
- run.nextJobRecommendations = normalized;
1722
+ }
1723
+ else if (signal.reviewHandoff) {
1724
+ const error = (0, handoff_contracts_1.reviewHandoffNormalizationError)(signal.reviewHandoff);
1725
+ if (error) {
1726
+ onReviewFailure(error);
1727
+ return;
1728
+ }
1729
+ else {
1730
+ applyReviewHandoffToRun(run, { ...signal.reviewHandoff, producingJobName: callJobName });
1731
+ verifyReviewHandoffReflectsSubmission(run, signal.reviewHandoff, onReviewFailure);
1732
+ }
1733
+ }
1734
+ else {
1735
+ // The most recent mentoring intent owns review lifetime across all jobs.
1736
+ run.reviewHandoff = null;
1769
1737
  }
1770
1738
  if (signal.deliveryActionId !== undefined) {
1771
1739
  run.pendingDeliveryActionId = signal.deliveryActionId;
@@ -1785,6 +1753,17 @@ function applySeekMentoringSignal(run, signal) {
1785
1753
  run.pendingDeliveryActionId = null;
1786
1754
  }
1787
1755
  }
1756
+ // Delivery actions belong to the review, while tracker and issue metadata
1757
+ // remain scoped to the run's original job (including colliding phase names).
1758
+ if (isCrossJob)
1759
+ return;
1760
+ if (signal.issueNumber)
1761
+ run.issueNumber = signal.issueNumber;
1762
+ if (signal.nextJobRecommendations) {
1763
+ const normalized = normalizeNextJobRecommendations(signal.nextJobRecommendations);
1764
+ if (normalized)
1765
+ run.nextJobRecommendations = normalized;
1766
+ }
1788
1767
  if (signal.delegationLedger &&
1789
1768
  run.jobId === 'fully-delegate' &&
1790
1769
  signal.phaseStatus === 'complete' &&
@@ -3515,6 +3494,10 @@ class AiHubServer {
3515
3494
  project.droppedRecordedReason = this.droppedRecordedReason;
3516
3495
  }
3517
3496
  const rawJobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath, FULL_JOB_CATALOG_OPTIONS);
3497
+ // Issue #1891: build the custom-employee index once (O(1) per job, one disk walk total)
3498
+ // so custom ownership wins over catalog assignment. Follows the same pattern as
3499
+ // buildCustomJobOwnerIndex documented in the Issue #1090 comment above.
3500
+ const customJobOwners = buildCustomJobOwnerIndex(normalizedProjectPath ?? '');
3518
3501
  // Issue #566 (R7): jobs already carry `personalized` from catalog discovery
3519
3502
  // (true for the fraim/personalized-employee layer). The Hub renders a plain
3520
3503
  // "Personalized" marking from that flag — no author/attribution is tracked.
@@ -3522,7 +3505,7 @@ class AiHubServer {
3522
3505
  .filter((job) => !FRAIM_INTERNAL_JOB_IDS.has(job.id))
3523
3506
  .map((job) => ({
3524
3507
  ...job,
3525
- requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
3508
+ requiredPersonaKey: customJobOwners.get(job.id) ?? getProtectedPersonaForHubJob(job.id),
3526
3509
  }));
3527
3510
  // Issue #1247: expose internal jobs in a separate bootstrap field so next-job
3528
3511
  // recommendation chips can resolve and launch them. They are intentionally
@@ -5128,7 +5111,9 @@ class AiHubServer {
5128
5111
  // jobId (resolveHubJob would never match a UUID, leaving a freeform run
5129
5112
  // unpromoted and then dropping its phase signals).
5130
5113
  this.maybePromoteFreeformRunToJob(run, signal.jobName || signal.jobId);
5131
- applySeekMentoringSignal(run, signal);
5114
+ applySeekMentoringSignal(run, signal, (reason) => {
5115
+ this.recordHostEvent(run, run.hostId, { hostError: 'Review handoff was not applied: ' + reason }, 'stderr');
5116
+ });
5132
5117
  if (signal.phaseId === 'retrospective' && signal.phaseStatus === 'complete') {
5133
5118
  void this.releaseBrowserConversationPage(run.conversationId || run.id);
5134
5119
  }
@@ -5645,7 +5630,10 @@ class AiHubServer {
5645
5630
  const projectPath = resolveProjectPath(req);
5646
5631
  const entries = (0, learning_context_builder_1.readPreservedLearnings)(projectPath, userEmail, scope, level);
5647
5632
  const threshold = (0, learning_context_builder_1.getScoreThreshold)(projectPath);
5648
- return res.json({ scope, level, entries, threshold });
5633
+ // Issue #1755 R9: the domain catalogue travels with the corpus so the library's
5634
+ // domain filter and the re-scope selector offer every valid domain, not only the
5635
+ // ones that happen to have an entry already.
5636
+ return res.json({ scope, level, entries, threshold, domains: ['global', ...learning_domains_1.LEARNING_DOMAINS] });
5649
5637
  }
5650
5638
  catch (error) {
5651
5639
  return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not read learnings.' });
@@ -5664,7 +5652,16 @@ class AiHubServer {
5664
5652
  if (!b.category || !VALID_CATEGORIES.includes(b.category))
5665
5653
  return res.status(400).json({ error: `category must be one of: ${VALID_CATEGORIES.join(', ')}` });
5666
5654
  const level = (b.level && VALID_LEVELS.includes(b.level)) ? b.level : 'machine';
5667
- const ref = { scope: b.scope, level, category: b.category };
5655
+ // Issue #1755: `domain` names the file the entry lives in today, `targetDomain`
5656
+ // the file it should live in after the write. Without the first, every write
5657
+ // resolved to the undecorated global file and a domain-scoped entry could not be
5658
+ // edited or deleted at all.
5659
+ const ref = {
5660
+ scope: b.scope,
5661
+ level,
5662
+ category: b.category,
5663
+ domain: (0, learning_context_builder_1.normalizeLearningDomain)(b.domain),
5664
+ };
5668
5665
  // Issue #750: writes to a personal-scope file need a resolved identity —
5669
5666
  // fail explicitly rather than writing to a guessed or empty-string file.
5670
5667
  const userEmail = await this.resolveLearningIdentity(b.scope);
@@ -5677,6 +5674,7 @@ class AiHubServer {
5677
5674
  severity: b.severity,
5678
5675
  title: b.title,
5679
5676
  body: b.body,
5677
+ targetDomain: b.targetDomain === undefined ? undefined : (0, learning_context_builder_1.normalizeLearningDomain)(b.targetDomain),
5680
5678
  });
5681
5679
  return res.json({ ok: true, path: result.path });
5682
5680
  }