fraim-framework 2.0.196 → 2.0.197

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,232 @@
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.MAX_LOGO_BYTES = exports.ORG_BRAND_FILENAME = void 0;
7
+ exports.resolveBrandColor = resolveBrandColor;
8
+ exports.relativeLuminance = relativeLuminance;
9
+ exports.contrastRatio = contrastRatio;
10
+ exports.hasSufficientContrast = hasSufficientContrast;
11
+ exports.sanitizeSvg = sanitizeSvg;
12
+ exports.sanitizeLogo = sanitizeLogo;
13
+ exports.normalizeBrand = normalizeBrand;
14
+ exports.serializeBrand = serializeBrand;
15
+ exports.parseBrand = parseBrand;
16
+ exports.readBrandFromDir = readBrandFromDir;
17
+ exports.writeBrandToDir = writeBrandToDir;
18
+ /**
19
+ * Org brand store — Issue #744 (Hub cobranding).
20
+ *
21
+ * Deterministic, dependency-free, AI-free logic (architecture-standards §1/§3):
22
+ * the org brand descriptor (company name, color, logo) is stored ALONGSIDE
23
+ * company info, in the same org context storage as `org_context.md`
24
+ * (manager directive, spec R7.4), as a single `org_brand.json` file.
25
+ *
26
+ * Security: a manager-supplied logo is a stored-XSS vector. `sanitizeSvg` uses a
27
+ * strict element/attribute allowlist (not a denylist) and drops scripts, event
28
+ * handlers, external references, styles, and any non-graphical element, so the
29
+ * stored logo is safe to inline in the Hub (spec Compliance: SOC2/ISO27001).
30
+ */
31
+ const fs_1 = __importDefault(require("fs"));
32
+ const path_1 = __importDefault(require("path"));
33
+ /** File name of the brand descriptor, stored next to org_context.md. */
34
+ exports.ORG_BRAND_FILENAME = 'org_brand.json';
35
+ /** Max stored logo size (sanitized SVG markup or data: URI), in bytes. */
36
+ exports.MAX_LOGO_BYTES = 512 * 1024;
37
+ // ── Color ───────────────────────────────────────────────────────────────────
38
+ /**
39
+ * Normalize a hex color to lowercase `#rrggbb`, expanding `#rgb`. Returns null
40
+ * for anything that is not a valid 3- or 6-digit hex (named colors, functions,
41
+ * and injection attempts are rejected).
42
+ */
43
+ function resolveBrandColor(input) {
44
+ if (typeof input !== 'string')
45
+ return null;
46
+ const raw = input.trim().replace(/^#/, '').toLowerCase();
47
+ if (/^[0-9a-f]{3}$/.test(raw)) {
48
+ return '#' + raw.split('').map((c) => c + c).join('');
49
+ }
50
+ if (/^[0-9a-f]{6}$/.test(raw)) {
51
+ return '#' + raw;
52
+ }
53
+ return null;
54
+ }
55
+ function hexToRgb(hex) {
56
+ const norm = resolveBrandColor(hex);
57
+ if (!norm)
58
+ return null;
59
+ const n = norm.slice(1);
60
+ return [parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)];
61
+ }
62
+ /** WCAG relative luminance of an sRGB color. */
63
+ function relativeLuminance(hex) {
64
+ const rgb = hexToRgb(hex);
65
+ if (!rgb)
66
+ return 0;
67
+ const [r, g, b] = rgb.map((v) => {
68
+ const s = v / 255;
69
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
70
+ });
71
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
72
+ }
73
+ /** WCAG contrast ratio between two colors (1..21). */
74
+ function contrastRatio(a, b) {
75
+ const la = relativeLuminance(a);
76
+ const lb = relativeLuminance(b);
77
+ const lighter = Math.max(la, lb);
78
+ const darker = Math.min(la, lb);
79
+ return (lighter + 0.05) / (darker + 0.05);
80
+ }
81
+ /**
82
+ * True when `color` is readable as a UI accent against `background`. Uses the
83
+ * WCAG 3:1 threshold for graphical/large UI objects (the accent is used on
84
+ * filled buttons and tab underlines, not as body text). Guards R2.3 so an
85
+ * unreadable brand color never ships; callers fall back to the FRAIM accent.
86
+ */
87
+ function hasSufficientContrast(color, background, min = 3.0) {
88
+ if (!resolveBrandColor(color) || !resolveBrandColor(background))
89
+ return false;
90
+ return contrastRatio(color, background) >= min;
91
+ }
92
+ // ── SVG sanitization (allowlist) ──────────────────────────────────────────────
93
+ /** Only these elements survive sanitization. */
94
+ const ALLOWED_SVG_TAGS = new Set([
95
+ 'svg', 'g', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon',
96
+ 'text', 'tspan', 'defs', 'lineargradient', 'radialgradient', 'stop', 'title', 'desc',
97
+ ]);
98
+ /** Only these attributes survive; everything else (incl. on*, href, style) is dropped. */
99
+ const ALLOWED_SVG_ATTRS = new Set([
100
+ 'viewbox', 'xmlns', 'width', 'height', 'fill', 'fill-rule', 'fill-opacity', 'stroke',
101
+ 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-opacity',
102
+ 'opacity', 'd', 'points', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry',
103
+ 'transform', 'offset', 'stop-color', 'stop-opacity', 'gradientunits', 'gradienttransform',
104
+ 'font-family', 'font-size', 'font-weight', 'text-anchor', 'letter-spacing',
105
+ ]);
106
+ /**
107
+ * Sanitize an SVG string via a strict allowlist. Returns safe SVG markup, or ''
108
+ * if the input is not an <svg> document. Removes: <script>, <foreignObject>,
109
+ * <image>, <use>, <a>, comments/PIs/DOCTYPE, all event handlers, all href /
110
+ * xlink:href, inline styles, and any element or attribute not on the allowlist.
111
+ */
112
+ function sanitizeSvg(input) {
113
+ if (typeof input !== 'string')
114
+ return '';
115
+ // Bound the work up front so a pathological logo can't drive the tag-walk
116
+ // regex into catastrophic backtracking (DoS guard).
117
+ if (input.length > exports.MAX_LOGO_BYTES)
118
+ return '';
119
+ let s = input.trim();
120
+ if (!/^<svg[\s>]/i.test(s))
121
+ return '';
122
+ // Drop comments, CDATA, processing instructions, and DOCTYPE outright.
123
+ s = s.replace(/<!--[\s\S]*?-->/g, '');
124
+ s = s.replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, '');
125
+ s = s.replace(/<\?[\s\S]*?\?>/g, '');
126
+ s = s.replace(/<!DOCTYPE[\s\S]*?>/gi, '');
127
+ // Drop dangerous elements and their entire content.
128
+ s = s.replace(/<(script|style|foreignObject|iframe|use|image|a)\b[\s\S]*?<\/\1\s*>/gi, '');
129
+ // Drop self-closing / unclosed variants of the same dangerous elements.
130
+ s = s.replace(/<(script|style|foreignObject|iframe|use|image|a)\b[^>]*\/?>/gi, '');
131
+ // Walk every remaining tag; drop non-allowlisted elements and attributes.
132
+ s = s.replace(/<\/?([a-zA-Z][\w:-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (full, rawName, rawAttrs) => {
133
+ const name = rawName.toLowerCase().replace(/^svg:/, '');
134
+ if (!ALLOWED_SVG_TAGS.has(name))
135
+ return '';
136
+ const isClose = /^<\//.test(full);
137
+ if (isClose)
138
+ return `</${name}>`;
139
+ const selfClose = /\/>$/.test(full);
140
+ const attrs = [];
141
+ const attrRe = /([a-zA-Z_:][\w:.-]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
142
+ let m;
143
+ while ((m = attrRe.exec(rawAttrs)) !== null) {
144
+ const attr = m[1].toLowerCase().replace(/^xml:/, '');
145
+ const value = m[3] !== undefined ? m[3] : m[4] || '';
146
+ if (!ALLOWED_SVG_ATTRS.has(attr))
147
+ continue;
148
+ // Defense in depth: never allow url()/javascript:/data: sneaking through a value.
149
+ if (/javascript:|url\s*\(|data:|expression\s*\(|<|>/i.test(value))
150
+ continue;
151
+ attrs.push(`${attr}="${value.replace(/"/g, '&quot;')}"`);
152
+ }
153
+ const attrStr = attrs.length ? ' ' + attrs.join(' ') : '';
154
+ return `<${name}${attrStr}${selfClose ? '/' : ''}>`;
155
+ });
156
+ // If sanitization emptied the root, treat as invalid.
157
+ if (!/^<svg[\s>]/i.test(s.trim()))
158
+ return '';
159
+ return s.trim();
160
+ }
161
+ /** Accept only png/jpeg data URIs (sanitized SVG is handled separately). */
162
+ function isSafeRasterDataUri(s) {
163
+ return /^data:image\/(png|jpe?g);base64,[a-z0-9+/=\s]+$/i.test(s);
164
+ }
165
+ /** Sanitize a logo value: SVG markup -> sanitized SVG; png/jpeg data URI -> kept; else ''. */
166
+ function sanitizeLogo(input) {
167
+ if (typeof input !== 'string' || !input.trim())
168
+ return '';
169
+ const s = input.trim();
170
+ if (/^<svg[\s>]/i.test(s)) {
171
+ const clean = sanitizeSvg(s);
172
+ return clean.length <= exports.MAX_LOGO_BYTES ? clean : '';
173
+ }
174
+ if (isSafeRasterDataUri(s)) {
175
+ return s.length <= exports.MAX_LOGO_BYTES ? s : '';
176
+ }
177
+ return '';
178
+ }
179
+ // ── Serialize / parse ─────────────────────────────────────────────────────────
180
+ /** Normalize + sanitize a brand into its safe stored form. */
181
+ function normalizeBrand(brand) {
182
+ const out = {};
183
+ if (typeof brand.name === 'string' && brand.name.trim()) {
184
+ out.name = brand.name.trim().slice(0, 120);
185
+ }
186
+ const color = resolveBrandColor(brand.color);
187
+ if (color)
188
+ out.color = color;
189
+ const logo = sanitizeLogo(brand.logo);
190
+ if (logo)
191
+ out.logo = logo;
192
+ return out;
193
+ }
194
+ function serializeBrand(brand) {
195
+ return JSON.stringify(normalizeBrand(brand), null, 2);
196
+ }
197
+ /** Parse a brand JSON string into a normalized OrgBrand, or null on any error. */
198
+ function parseBrand(json) {
199
+ if (typeof json !== 'string' || !json.trim())
200
+ return null;
201
+ let obj;
202
+ try {
203
+ obj = JSON.parse(json);
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ if (!obj || typeof obj !== 'object')
209
+ return null;
210
+ const norm = normalizeBrand(obj);
211
+ return Object.keys(norm).length ? norm : null;
212
+ }
213
+ // ── Directory read / write ─────────────────────────────────────────────────────
214
+ /** Read + normalize the brand from `<dir>/org_brand.json`, or null when absent/invalid. */
215
+ function readBrandFromDir(dir) {
216
+ try {
217
+ const file = path_1.default.join(dir, exports.ORG_BRAND_FILENAME);
218
+ if (!fs_1.default.existsSync(file))
219
+ return null;
220
+ return parseBrand(fs_1.default.readFileSync(file, 'utf8'));
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ }
226
+ /** Sanitize + normalize a brand and write it to `<dir>/org_brand.json`. Returns the stored brand. */
227
+ function writeBrandToDir(dir, brand) {
228
+ const normalized = normalizeBrand(brand);
229
+ fs_1.default.mkdirSync(dir, { recursive: true });
230
+ fs_1.default.writeFileSync(path_1.default.join(dir, exports.ORG_BRAND_FILENAME), JSON.stringify(normalized, null, 2), 'utf8');
231
+ return normalized;
232
+ }
@@ -49,6 +49,7 @@ const child_process_1 = require("child_process");
49
49
  const https_1 = __importDefault(require("https"));
50
50
  const types_1 = require("../first-run/types");
51
51
  const learning_context_builder_1 = require("../local-mcp-server/learning-context-builder");
52
+ const brand_store_1 = require("./brand-store");
52
53
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
53
54
  const catalog_1 = require("./catalog");
54
55
  const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
@@ -1447,6 +1448,9 @@ class AiHubServer {
1447
1448
  // #533: the resolved account email, so the profile card shows the real
1448
1449
  // identity (not a hardcoded placeholder) and the client can display it.
1449
1450
  userEmail: getHubUserEmail(),
1451
+ // #744: the org cobrand identity (name/color/logo) from the org context
1452
+ // storage, or null when unset so the Hub falls back to FRAIM identity.
1453
+ orgBrand: (0, learning_context_builder_1.readOrgBrand)(normalizedProjectPath),
1450
1454
  assignments: { byProject: {}, source: 'client-localStorage' },
1451
1455
  // Issue #538 — source of truth for the "Hire a human manager" UI. Lazy-required
1452
1456
  // (not a top-level import) so the lightweight client CLI paths that import this
@@ -2302,7 +2306,15 @@ class AiHubServer {
2302
2306
  ? path_1.default.resolve(req.query.projectPath)
2303
2307
  : this.projectPath;
2304
2308
  const known = this.knownProjects(projectPath);
2305
- const entry = known.find((project) => project.id === req.params.id);
2309
+ // Match by id, falling back to the canonical folderPath. The client and server
2310
+ // hash a path to an id with different algorithms, and the client mints its own id
2311
+ // for the CURRENT project (tfEnsureCurrentProject), so a delete-by-id can carry an
2312
+ // id the server never assigned — the folderPath is the true project identity (#726).
2313
+ const requestedFolderPath = typeof req.query.folderPath === 'string' && req.query.folderPath.length > 0
2314
+ ? req.query.folderPath
2315
+ : null;
2316
+ const entry = known.find((project) => project.id === req.params.id)
2317
+ || (requestedFolderPath ? known.find((project) => sameDirectoryPath(project.folderPath, requestedFolderPath)) : undefined);
2306
2318
  if (!entry)
2307
2319
  return res.status(404).json({ error: 'Project not found.' });
2308
2320
  // The current projectPath is unconditionally re-injected on every load, so
@@ -2443,6 +2455,45 @@ class AiHubServer {
2443
2455
  // Re-read so the client gets the canonical post-write state (present flips).
2444
2456
  return res.json(readContextFile(projectPath, body.key));
2445
2457
  });
2458
+ // Issue #744 — Hub cobranding.
2459
+ // GET /api/ai-hub/brand → { brand: OrgBrand | null } resolved from the org
2460
+ // context storage (same layering as org_context.md).
2461
+ // POST /api/ai-hub/brand { name?, color?, logo?, projectPath? } → sanitizes
2462
+ // + normalizes (SVG allowlist, hex normalize) and persists org_brand.json to
2463
+ // the org context write location. Loopback-only; write is constrained under
2464
+ // a personalized-employee/ directory (same path-traversal guard as context).
2465
+ this.app.get('/api/ai-hub/brand', (req, res) => {
2466
+ const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
2467
+ ? path_1.default.resolve(req.query.projectPath)
2468
+ : this.projectPath;
2469
+ return res.json({ brand: (0, learning_context_builder_1.readOrgBrand)(projectPath) });
2470
+ });
2471
+ this.app.post('/api/ai-hub/brand', (req, res) => {
2472
+ const body = (req.body ?? {});
2473
+ const projectPath = typeof body.projectPath === 'string' && body.projectPath.length > 0
2474
+ ? path_1.default.resolve(body.projectPath)
2475
+ : this.projectPath;
2476
+ const input = {
2477
+ name: typeof body.name === 'string' ? body.name : undefined,
2478
+ color: typeof body.color === 'string' ? body.color : undefined,
2479
+ logo: typeof body.logo === 'string' ? body.logo : undefined,
2480
+ };
2481
+ const dest = path_1.default.resolve((0, learning_context_builder_1.resolveOrgBrandWriteDir)(projectPath));
2482
+ // Path-traversal guard: the write dir must live under a personalized-employee
2483
+ // directory (matches the context write guard; never the managed org cache).
2484
+ if (!dest.split(path_1.default.sep).includes('personalized-employee')) {
2485
+ return res.status(403).json({ error: 'Brand write destination outside personalized-employee.' });
2486
+ }
2487
+ try {
2488
+ // writeBrandToDir sanitizes the logo (SVG allowlist) and normalizes the
2489
+ // color before persisting, so unsafe input never reaches disk.
2490
+ const stored = (0, brand_store_1.writeBrandToDir)(dest, input);
2491
+ return res.json({ brand: stored });
2492
+ }
2493
+ catch (err) {
2494
+ return res.status(500).json({ error: err instanceof Error ? err.message : 'Brand write failed.' });
2495
+ }
2496
+ });
2446
2497
  this.app.post('/api/ai-hub/artifact/open', async (req, res) => {
2447
2498
  const rawPath = typeof req.body?.path === 'string' ? req.body.path : '';
2448
2499
  const projectPath = typeof req.body?.projectPath === 'string' && req.body.projectPath.length > 0
@@ -4,6 +4,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.ORG_CACHE_MANAGED_HEADER = exports.ORG_SYNC_METADATA_FILE = exports.ORG_CACHE_DIRNAME = void 0;
7
+ exports.isSafePackPath = isSafePackPath;
8
+ exports.shouldDecorateAsMarkdown = shouldDecorateAsMarkdown;
7
9
  exports.getOrgCacheDir = getOrgCacheDir;
8
10
  exports.readOrgCacheMetadata = readOrgCacheMetadata;
9
11
  exports.getOrgCacheAgeHours = getOrgCacheAgeHours;
@@ -37,6 +39,28 @@ const ORG_PACK_DIRS = ['context', 'rules', 'learnings'];
37
39
  * backend response can never write outside the cache directory.
38
40
  */
39
41
  const SAFE_PACK_RELATIVE_PATH = /^(context|rules|learnings)\/[\w.-]+\.md$/;
42
+ /**
43
+ * Issue #744: the org brand descriptor is stored ALONGSIDE company info, in the
44
+ * same org context scope as org_context.md, as a single JSON file. Allow exactly
45
+ * that path (not arbitrary JSON) so cobranding syncs like org context.
46
+ */
47
+ const ORG_BRAND_PACK_PATH = 'context/org_brand.json';
48
+ /**
49
+ * True when a pack-relative path is safe to materialize: a markdown file inside
50
+ * one of the three org pack dirs, or the org brand descriptor. A single guard
51
+ * shared by the git and cloud paths.
52
+ */
53
+ function isSafePackPath(relativePath) {
54
+ return SAFE_PACK_RELATIVE_PATH.test(relativePath) || relativePath === ORG_BRAND_PACK_PATH;
55
+ }
56
+ /**
57
+ * Managed-content decoration prepends a markdown "synced from your org" header.
58
+ * It must only touch markdown — prepending it to org_brand.json would corrupt
59
+ * the JSON (#744).
60
+ */
61
+ function shouldDecorateAsMarkdown(relativePath) {
62
+ return relativePath.toLowerCase().endsWith('.md');
63
+ }
40
64
  function getOrgCacheDir() {
41
65
  return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), exports.ORG_CACHE_DIRNAME);
42
66
  }
@@ -83,10 +107,12 @@ function collectGitPackFiles(snapshotDir) {
83
107
  if (!fs_1.default.existsSync(dirPath))
84
108
  continue;
85
109
  for (const entry of fs_1.default.readdirSync(dirPath, { withFileTypes: true })) {
86
- if (!entry.isFile() || !entry.name.endsWith('.md'))
110
+ const relativePath = `${dirName}/${entry.name}`;
111
+ // Markdown in any pack dir, plus the org brand descriptor (#744).
112
+ if (!entry.isFile() || !isSafePackPath(relativePath))
87
113
  continue;
88
114
  files.push({
89
- relativePath: `${dirName}/${entry.name}`,
115
+ relativePath,
90
116
  content: fs_1.default.readFileSync(path_1.default.join(dirPath, entry.name), 'utf8')
91
117
  });
92
118
  }
@@ -101,7 +127,7 @@ async function fetchCloudPack(remoteUrl, apiKey) {
101
127
  const files = Array.isArray(response.data?.files) ? response.data.files : [];
102
128
  return {
103
129
  files: files.filter((f) => typeof f?.relativePath === 'string' &&
104
- SAFE_PACK_RELATIVE_PATH.test(f.relativePath) &&
130
+ isSafePackPath(f.relativePath) &&
105
131
  typeof f?.content === 'string'),
106
132
  version: String(response.data?.version ?? '0')
107
133
  };
@@ -113,11 +139,15 @@ function materializeCache(files, metadata) {
113
139
  fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
114
140
  fs_1.default.mkdirSync(stagingDir, { recursive: true });
115
141
  for (const file of files) {
116
- if (!SAFE_PACK_RELATIVE_PATH.test(file.relativePath))
142
+ if (!isSafePackPath(file.relativePath))
117
143
  continue;
118
144
  const destination = path_1.default.join(stagingDir, file.relativePath);
119
145
  fs_1.default.mkdirSync(path_1.default.dirname(destination), { recursive: true });
120
- fs_1.default.writeFileSync(destination, decorateManagedOrgFile(file.content, metadata.backend));
146
+ // #744: only markdown gets the managed-content header; JSON is written verbatim.
147
+ const content = shouldDecorateAsMarkdown(file.relativePath)
148
+ ? decorateManagedOrgFile(file.content, metadata.backend)
149
+ : file.content;
150
+ fs_1.default.writeFileSync(destination, content);
121
151
  }
122
152
  fs_1.default.writeFileSync(path_1.default.join(stagingDir, exports.ORG_SYNC_METADATA_FILE), JSON.stringify(metadata, null, 2));
123
153
  fs_1.default.rmSync(cacheDir, { recursive: true, force: true });
@@ -52,7 +52,7 @@ exports.FIRST_RUN_AGENT_OPTIONS = [
52
52
  },
53
53
  {
54
54
  id: 'copilot-cli',
55
- label: 'GitHub Copilot',
55
+ label: 'GitHub Copilot CLI',
56
56
  detectAliases: ['copilot', 'copilot-cli', 'github copilot cli'],
57
57
  loginCommand: 'copilot login',
58
58
  launchCommand: 'copilot',
@@ -14,6 +14,8 @@ exports.buildTeamContextSection = buildTeamContextSection;
14
14
  exports.resolveTeamContextFiles = resolveTeamContextFiles;
15
15
  exports.isTeamContextKey = isTeamContextKey;
16
16
  exports.resolveTeamContextFile = resolveTeamContextFile;
17
+ exports.readOrgBrand = readOrgBrand;
18
+ exports.resolveOrgBrandWriteDir = resolveOrgBrandWriteDir;
17
19
  exports.countPreservedLearnings = countPreservedLearnings;
18
20
  exports.readPreservedLearnings = readPreservedLearnings;
19
21
  exports.applyLearningEntryChange = applyLearningEntryChange;
@@ -21,6 +23,7 @@ exports.isTruthyFlag = isTruthyFlag;
21
23
  const fs_1 = require("fs");
22
24
  const path_1 = require("path");
23
25
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
26
+ const brand_store_1 = require("../ai-hub/brand-store");
24
27
  const REPO_LEARNINGS_REL = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('personalized-employee/learnings').replace(/\/$/, '');
25
28
  const DEFAULT_THRESHOLD = 3.0;
26
29
  const AGING_HORIZON_DAYS = 7;
@@ -695,6 +698,44 @@ function resolveTeamContextFile(workspaceRoot, key) {
695
698
  scope
696
699
  };
697
700
  }
701
+ // ---------------------------------------------------------------------------
702
+ // Issue #744 — Org brand (Hub cobranding).
703
+ //
704
+ // The brand descriptor (company name/logo/color) is stored ALONGSIDE company
705
+ // info, in the SAME org context storage as org_context.md (manager directive,
706
+ // spec R7.4), as `context/org_brand.json`. Read resolves with the same org-scope
707
+ // layering as org context (repo-local override → synced org cache → user-level);
708
+ // writes target the same writable location org context edits use.
709
+ // ---------------------------------------------------------------------------
710
+ /** Ordered directories to look for org_brand.json, mirroring org-context layering. */
711
+ function orgBrandCandidateDirs(workspaceRoot) {
712
+ return [
713
+ (0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee', 'context'),
714
+ (0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'org', 'context'),
715
+ (0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee', 'context'),
716
+ ];
717
+ }
718
+ /** Read the resolved org brand, or null when none is set on this machine. */
719
+ function readOrgBrand(workspaceRoot) {
720
+ for (const dir of orgBrandCandidateDirs(workspaceRoot)) {
721
+ const brand = (0, brand_store_1.readBrandFromDir)(dir);
722
+ if (brand)
723
+ return brand;
724
+ }
725
+ return null;
726
+ }
727
+ /**
728
+ * Directory an org-brand edit should be written to. Mirrors org-context write
729
+ * resolution: the repo-local override dir when the repo carries its own org
730
+ * context, otherwise the portable user-level context dir (where org onboarding
731
+ * writes). Never the synced org cache (managed, overwritten on sync).
732
+ */
733
+ function resolveOrgBrandWriteDir(workspaceRoot) {
734
+ const loc = resolveTeamContextFile(workspaceRoot, 'org');
735
+ if (loc.writePath)
736
+ return (0, path_1.dirname)(loc.writePath);
737
+ return (0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee', 'context');
738
+ }
698
739
  /**
699
740
  * Count the number of `## [P-...]`-style entries inside a preserved learning
700
741
  * file. Returns 0 when the file is absent or unreadable. Counts ALL entries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-framework",
3
- "version": "2.0.196",
3
+ "version": "2.0.197",
4
4
  "description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -12,6 +12,9 @@
12
12
  // #700: set the theme pre-paint (no flash). Explicit choice in
13
13
  // localStorage wins; otherwise follow the OS preference.
14
14
  try{var t=localStorage.getItem('fraim-theme');if(t!=='light'&&t!=='dark'){t=(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';}h.setAttribute('data-theme',t);}catch(e){h.setAttribute('data-theme','light');}
15
+ // #744: apply the cached org brand pre-paint (no flash of FRAIM identity
16
+ // before company branding). script.js refines the per-theme accent on load.
17
+ try{var b=JSON.parse(localStorage.getItem('fraim-org-brand')||'null');if(b){if(typeof b.color==='string'&&/^#[0-9a-f]{6}$/i.test(b.color)){h.style.setProperty('--accent',b.color);h.style.setProperty('--accent-strong',b.color);}if(typeof b.name==='string'&&b.name){document.title=b.name+' Hub';}}}catch(e){}
15
18
  })();</script>
16
19
  <link rel="stylesheet" href="./styles.css?v=conv-panels-20260611b">
17
20
  <link rel="stylesheet" href="./review.css">
@@ -20,10 +23,15 @@
20
23
 
21
24
  <!-- Issue #512: three-area top nav + account menu (surface=hub only). -->
22
25
  <nav class="hub-tabs" id="hub-tabs" hidden>
26
+ <!-- #744: company brand lockup (logo + name), populated from bootstrap.orgBrand. -->
27
+ <span class="hub-brand" id="hub-brand" hidden></span>
28
+ <span class="hub-brand-divider" id="hub-brand-divider" hidden></span>
23
29
  <button class="hub-tab" type="button" data-area="company">Company</button>
24
30
  <button class="hub-tab" type="button" data-area="manager">Manager</button>
25
31
  <button class="hub-tab on" type="button" data-area="projects">Projects</button>
26
32
  <div class="nav-right">
33
+ <!-- #744: FRAIM co-mark (co-branding, not white-label) shown when a brand is set. -->
34
+ <span class="hub-cobrand" id="hub-cobrand" hidden></span>
27
35
  <button class="avatar-btn" id="avatar-btn" type="button" title="Account &amp; settings">SM</button>
28
36
  <div id="account-menu" class="account-menu">
29
37
  <div class="am-header">
@@ -68,6 +76,12 @@
68
76
  <div class="area-h1">Company</div>
69
77
  <p class="area-lede">Your organization's identity — what you do, how you operate, and the guardrails every employee follows on every job.</p>
70
78
  <div id="company-push-banner"></div>
79
+ <!-- #744: Brand editor — company identity across the Hub. -->
80
+ <details class="ctx-acc" id="company-brand-acc">
81
+ <summary><span class="ca-chev">▸</span> <span>🎨 Brand</span>
82
+ <span class="ca-note">— your company's identity across the Hub, applied for every teammate</span></summary>
83
+ <div class="ctx-acc-body"><div class="card area-profile" id="company-brand-editor"></div></div>
84
+ </details>
71
85
  <details class="ctx-acc" id="company-ctx-acc">
72
86
  <summary><span class="ca-chev">▸</span> <span>🏢 Context &amp; rules</span></summary>
73
87
  <div class="ctx-acc-body"><div class="card area-profile" id="company-profile"></div></div>
@@ -199,6 +213,7 @@
199
213
  <!-- Onboarding state banner lives ABOVE the accordions so it shows even
200
214
  when the Brief is collapsed (so the conversation keeps its height). -->
201
215
  <div id="proj-onboarding-banner"></div>
216
+ <div id="hub-agent-setup-panel" class="hub-agent-setup-panel" data-testid="hub-cli-setup-panel" hidden></div>
202
217
  <details class="ctx-acc" id="proj-brief-acc">
203
218
  <summary><span class="ca-chev">▸</span> <span>📋 Brief</span>
204
219
  <span class="ca-note">— this project's context and rules, captured by Project Onboarding</span></summary>
@@ -839,6 +854,7 @@
839
854
  <span class="cp-agent-label">Run with:</span>
840
855
  <span id="cp-agent-picker" class="cp-agent-pills"></span>
841
856
  </div>
857
+ <div id="cp-agent-install-panel" class="cp-agent-install-panel" data-testid="cp-cli-setup-panel" hidden></div>
842
858
  </div>
843
859
  </div>
844
860
 
@@ -868,6 +884,6 @@
868
884
  </div>
869
885
  </div>
870
886
 
871
- <script src="./script.js?v=conv-persist-20260705"></script>
887
+ <script src="./script.js?v=persona-mgr-del-20260707"></script>
872
888
  </body>
873
889
  </html>