@unson/brainbase-mcp 0.15.1 → 0.15.3

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,361 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { buildJudgmentValueProofReviewHome, readJudgmentValueProofAnswers, readJudgmentValueProofFeedback, readJudgmentValueProofJournal, } from './judgment-value-proof-review.js';
4
+ import { publicHome } from './value-proof-review-http.js';
5
+ import { projectOrganizationGraph, projectVocabularyTerms } from './organization-graph-web.js';
6
+ import { readLocalGraphState } from './local-web-host.js';
7
+ import { loadPersonalOs } from './ssot.js';
8
+ import { requestUrl, writeJson } from './local-web-security.js';
9
+ /**
10
+ * World view (ledger P17, adopted 2026-10-04): the home's upper layer.
11
+ *
12
+ * The world is a read-only projection. Businesses come from the same Graph
13
+ * the other screens read (the organization Graph under C1, else the local
14
+ * Graph); judgments come from the existing value-proof home on the browser
15
+ * side. Nothing here writes, and a failure to read is reported as a state,
16
+ * never as zero businesses.
17
+ */
18
+ export const WORLD_EXTENSION_ID = 'world';
19
+ export const WORLD_EXTENSION_VERSION = 'world-extension.v1';
20
+ const LIFECYCLE_PHASES = ['active', 'maintenance', 'finished', 'concept'];
21
+ const BUILDING_FORMS = ['tower', 'hall', 'dome', 'office'];
22
+ /** Lifecycle words common to any Graph; a host vocabulary may relabel or add to them. */
23
+ const DEFAULT_STATUSES = Object.freeze({
24
+ active: { label: '進行中', phase: 'active' },
25
+ maintenance: { label: '保守', phase: 'maintenance' },
26
+ completed: { label: '完了', phase: 'finished' },
27
+ closed: { label: '終了', phase: 'finished' },
28
+ archived: { label: '保管', phase: 'finished' },
29
+ concept: { label: '構想', phase: 'concept' },
30
+ });
31
+ const KIND_PALETTE = ['#1261ad', '#b07a1f', '#35684c', '#6b4fa0', '#a8433a', '#2f7d86', '#7a6a2b', '#5b6470'];
32
+ const UNCLASSIFIED_COLOR = '#69746d';
33
+ const HEX_COLOR = /^#[0-9a-f]{6}$/iu;
34
+ function isRecord(value) {
35
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
36
+ }
37
+ function text(value) {
38
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
39
+ }
40
+ function engagementOf(entity) {
41
+ return {
42
+ id: entity.id,
43
+ code: text(entity.metadata?.code),
44
+ name: entity.name,
45
+ summary: text(entity.summary) ?? text(entity.metadata?.goal),
46
+ status: text(entity.metadata?.status),
47
+ };
48
+ }
49
+ /**
50
+ * The projects of a Graph v2 → cities (top-level projects) and their districts
51
+ * (projects whose `metadata.parent_project_id` names another project). A
52
+ * project whose declared parent (`metadata.parent_project_code`) is not in the
53
+ * Graph is counted as unplaced. Projects past `validTo` are counted, not drawn.
54
+ * Nothing here knows any organization's words: kinds and statuses are the
55
+ * Graph's own values.
56
+ */
57
+ export function projectWorldFromGraph(graph, now = new Date()) {
58
+ const projects = graph.entities.filter((entity) => entity.type === 'project');
59
+ const active = projects.filter((entity) => !entity.validTo || Date.parse(entity.validTo) > now.getTime());
60
+ const byId = new Map(active.map((entity) => [entity.id, entity]));
61
+ // A district hangs on its top-level ancestor, so nested projects stay in one city.
62
+ const topOf = (entity) => {
63
+ let current = entity;
64
+ const seen = new Set();
65
+ for (;;) {
66
+ const parentId = text(current.metadata?.parent_project_id);
67
+ if (!parentId || parentId === current.id)
68
+ return current;
69
+ const parent = byId.get(parentId);
70
+ if (!parent || seen.has(parentId))
71
+ return parent ? current : null;
72
+ seen.add(parentId);
73
+ current = parent;
74
+ }
75
+ };
76
+ const cities = new Map();
77
+ const unplaced = [];
78
+ const children = [];
79
+ for (const entity of active) {
80
+ const parentId = text(entity.metadata?.parent_project_id);
81
+ if (parentId && parentId !== entity.id)
82
+ children.push(entity);
83
+ else if (text(entity.metadata?.parent_project_code))
84
+ unplaced.push(engagementOf(entity));
85
+ else
86
+ cities.set(entity.id, { entity, engagements: [] });
87
+ }
88
+ for (const entity of children) {
89
+ const top = topOf(entity);
90
+ const city = top ? cities.get(top.id) : undefined;
91
+ if (city && top.id !== entity.id)
92
+ city.engagements.push(engagementOf(entity));
93
+ else
94
+ unplaced.push(engagementOf(entity));
95
+ }
96
+ const businesses = [...cities.values()].map(({ entity, engagements }) => ({
97
+ id: entity.id,
98
+ code: text(entity.metadata?.code) ?? entity.id,
99
+ name: entity.name,
100
+ kind: text(entity.metadata?.kind),
101
+ purpose: text(entity.metadata?.goal) ?? text(entity.summary),
102
+ status: text(entity.metadata?.status),
103
+ repositories: Array.isArray(entity.metadata?.repositories)
104
+ ? entity.metadata.repositories.map(text).filter((name) => name !== null)
105
+ : [],
106
+ engagements: engagements.sort((a, b) => a.name.localeCompare(b.name, 'ja')),
107
+ }));
108
+ return {
109
+ businesses: businesses.sort((a, b) => a.name.localeCompare(b.name, 'ja')),
110
+ unplaced,
111
+ excluded: { inactive: projects.length - active.length },
112
+ };
113
+ }
114
+ /**
115
+ * The vocabulary the world draws with: the host's kinds in their order, then
116
+ * kinds the Graph uses that the host did not name (under their own value),
117
+ * then unclassified projects; statuses are the common lifecycle words with the
118
+ * host's on top.
119
+ */
120
+ export function resolveWorldVocabulary(config, businesses, terms = undefined) {
121
+ // The organization names its kinds in the Graph; the host's file only adds a look (form, colour).
122
+ const graphTerms = new Map((terms ?? []).filter((term) => term.field === 'project.kind').map((term) => [term.value, term]));
123
+ const named = (key, configLabel) => {
124
+ const term = graphTerms.get(key);
125
+ if (term)
126
+ return { label: term.label, label_source: 'graph', definition: term.definition };
127
+ const label = text(configLabel);
128
+ return label ? { label, label_source: 'config', definition: null } : { label: key, label_source: 'value', definition: null };
129
+ };
130
+ const configured = Object.entries(config?.kinds ?? {});
131
+ const used = new Set(businesses.map((business) => business.kind));
132
+ const kinds = configured.map(([key, entry], index) => ({
133
+ key,
134
+ ...named(key, entry.label),
135
+ form: entry.form && BUILDING_FORMS.includes(entry.form) ? entry.form : 'office',
136
+ color: entry.color && HEX_COLOR.test(entry.color) ? entry.color : KIND_PALETTE[index % KIND_PALETTE.length],
137
+ configured: true,
138
+ }));
139
+ const configuredKeys = new Set(configured.map(([key]) => key));
140
+ [...used].filter((key) => key !== null && !configuredKeys.has(key)).sort().forEach((key) => {
141
+ kinds.push({ key, ...named(key, undefined), form: 'office', color: KIND_PALETTE[kinds.length % KIND_PALETTE.length], configured: false });
142
+ });
143
+ if (used.has(null))
144
+ kinds.push({ key: null, label: '分類なし', label_source: 'value', definition: null, form: 'office', color: UNCLASSIFIED_COLOR, configured: false });
145
+ const statuses = new Map(Object.entries(DEFAULT_STATUSES));
146
+ for (const [key, entry] of Object.entries(config?.statuses ?? {})) {
147
+ const base = statuses.get(key);
148
+ statuses.set(key, {
149
+ label: text(entry.label) ?? base?.label ?? key,
150
+ phase: entry.phase && LIFECYCLE_PHASES.includes(entry.phase) ? entry.phase : base?.phase ?? 'active',
151
+ });
152
+ }
153
+ return { kinds, statuses: [...statuses].map(([key, entry]) => ({ key, ...entry })), terms: terms === undefined ? 'none' : terms === null ? 'unavailable' : 'read' };
154
+ }
155
+ /** Reads and checks a vocabulary file; an invalid file is an error, never silently ignored. */
156
+ export async function readWorldVocabulary(path) {
157
+ const parsed = JSON.parse(await readFile(path, 'utf8'));
158
+ if (!isRecord(parsed))
159
+ throw new Error('world vocabulary must be a JSON object');
160
+ const kinds = {};
161
+ for (const [key, entry] of Object.entries(isRecord(parsed.kinds) ? parsed.kinds : {})) {
162
+ if (!isRecord(entry))
163
+ throw new Error(`world vocabulary kind ${key} must be an object`);
164
+ if (entry.form !== undefined && !BUILDING_FORMS.includes(entry.form))
165
+ throw new Error(`world vocabulary kind ${key} has an unknown form`);
166
+ if (entry.color !== undefined && !(typeof entry.color === 'string' && HEX_COLOR.test(entry.color)))
167
+ throw new Error(`world vocabulary kind ${key} has an invalid color`);
168
+ kinds[key] = { ...(text(entry.label) ? { label: text(entry.label) } : {}), ...(entry.form ? { form: entry.form } : {}), ...(entry.color ? { color: entry.color } : {}) };
169
+ }
170
+ const statuses = {};
171
+ for (const [key, entry] of Object.entries(isRecord(parsed.statuses) ? parsed.statuses : {})) {
172
+ if (!isRecord(entry))
173
+ throw new Error(`world vocabulary status ${key} must be an object`);
174
+ if (entry.phase !== undefined && !LIFECYCLE_PHASES.includes(entry.phase))
175
+ throw new Error(`world vocabulary status ${key} has an unknown phase`);
176
+ statuses[key] = { ...(text(entry.label) ? { label: text(entry.label) } : {}), ...(entry.phase ? { phase: entry.phase } : {}) };
177
+ }
178
+ return { kinds, statuses };
179
+ }
180
+ /**
181
+ * The businesses from the same Graph the other screens read: the organization
182
+ * Graph when the host reads it (C1), else the local Graph. A Graph that cannot
183
+ * be read is a state with its reason, never zero businesses.
184
+ */
185
+ export async function readWorldBusinesses(context, options = {}) {
186
+ const now = (options.now ?? (() => new Date()))();
187
+ let graph;
188
+ let source;
189
+ let terms;
190
+ try {
191
+ if (context.organizationGraph) {
192
+ if (!context.organizationGraph.readGraphFile) {
193
+ return { status: 'unavailable', version: WORLD_EXTENSION_VERSION, reason: 'organization_graph_records_unavailable' };
194
+ }
195
+ graph = await context.organizationGraph.readGraphFile();
196
+ source = { authority: 'organization_graph', server: context.organizationGraph.server };
197
+ terms = context.organizationGraph.readVocabularyTerms ? await context.organizationGraph.readVocabularyTerms().catch(() => null) : null;
198
+ }
199
+ else {
200
+ const state = await readLocalGraphState(context.dataDir);
201
+ if (state.status === 'not_initialized' || state.status === 'migration_required') {
202
+ return { status: state.status, version: WORLD_EXTENSION_VERSION, reason: state.status };
203
+ }
204
+ if (state.status !== 'ready')
205
+ return { status: 'unavailable', version: WORLD_EXTENSION_VERSION, reason: state.message ?? 'local_graph_unreadable' };
206
+ const os = await loadPersonalOs(context.dataDir);
207
+ if (os.graph.version !== 2)
208
+ return { status: 'migration_required', version: WORLD_EXTENSION_VERSION, reason: 'migration_required' };
209
+ graph = os.graph;
210
+ source = { authority: 'local', dataDir: context.dataDir };
211
+ }
212
+ }
213
+ catch (error) {
214
+ return { status: 'unavailable', version: WORLD_EXTENSION_VERSION, reason: error instanceof Error ? error.message : 'graph_unreadable' };
215
+ }
216
+ const projection = projectWorldFromGraph(graph, now);
217
+ return {
218
+ status: 'ok',
219
+ version: WORLD_EXTENSION_VERSION,
220
+ source,
221
+ as_of: now.toISOString(),
222
+ vocabulary: resolveWorldVocabulary(options.vocabulary, projection.businesses, terms),
223
+ ...projection,
224
+ };
225
+ }
226
+ /**
227
+ * The world of an organization from the project and glossary-term records its Graph API returns, for an
228
+ * organization web that hosts the same world screen (P16). Pure: the caller reads the records with the
229
+ * member's own grants, so the world shows only what that member may read.
230
+ */
231
+ export function projectOrganizationWorld(records, options) {
232
+ const now = options.now ?? new Date();
233
+ const { graph } = projectOrganizationGraph({
234
+ entities: { person: [], org: [], project: records.projects, decision: [], raci_assignment: [] },
235
+ memberOf: [],
236
+ assignedTo: [],
237
+ });
238
+ const projection = projectWorldFromGraph(graph, now);
239
+ return {
240
+ status: 'ok',
241
+ version: WORLD_EXTENSION_VERSION,
242
+ source: { authority: 'organization_graph', server: options.server },
243
+ as_of: now.toISOString(),
244
+ vocabulary: resolveWorldVocabulary(options.vocabulary, projection.businesses, projectVocabularyTerms(records.glossaryTerms ?? [])),
245
+ ...projection,
246
+ };
247
+ }
248
+ const VALUE_PROOF_SUFFIX = '.value-proof.json';
249
+ /**
250
+ * Reads, for every saved value proof in the judgment journal, the repository
251
+ * the Host recorded for that turn. The journal is listed through the host's
252
+ * shared listing cache (the same one as the 今日 list), so only folders that
253
+ * changed are listed again; turn inputs are never rewritten by the Host, so
254
+ * each is read once. A journal that cannot be read is `unavailable`, never
255
+ * an empty list.
256
+ */
257
+ export async function readJudgmentPlaces(journalRoot, options = {}) {
258
+ const journal = await readJudgmentValueProofJournal({ root: journalRoot, ...(options.cache ? { cache: options.cache } : {}) });
259
+ if (journal.status !== 'available')
260
+ return { status: 'unavailable', places: [], reason: 'journal_unreadable' };
261
+ const workspaces = options.workspaces ?? new Map();
262
+ const places = await Promise.all(journal.entries.map(async (entry) => {
263
+ const turnInputFile = `${entry.file.slice(0, -VALUE_PROOF_SUFFIX.length)}.turn-input.json`;
264
+ if (!workspaces.has(turnInputFile)) {
265
+ const turnInput = await readJsonFile(turnInputFile);
266
+ workspaces.set(turnInputFile, isRecord(turnInput) ? text(turnInput.project_code) : null);
267
+ }
268
+ return { decision_attempt_id: entry.proof.decision_attempt_id, workspace: workspaces.get(turnInputFile) ?? null };
269
+ }));
270
+ return { status: 'available', places };
271
+ }
272
+ /**
273
+ * The judgments of one organization, for a world drawn from that organization's Graph. A judgment
274
+ * belongs to the organization only when the repository it was worked in is one of the organization's
275
+ * businesses (a registered repository or the business code, the same rule as the world's placement).
276
+ * Every other judgment (another company's work, or one whose repository was not recorded) is withheld:
277
+ * only their count leaves the host, never their questions or repositories.
278
+ */
279
+ export async function readOrganizationJudgments(context, businesses, workspaces = new Map()) {
280
+ const journal = await readJudgmentValueProofJournal({ root: context.journalRoot, cache: context.journalCache });
281
+ if (journal.status !== 'available')
282
+ return { status: 'unavailable', scope: 'organization', withheld: 0, places: [], home: null, reason: 'journal_unreadable' };
283
+ const belongs = (workspace) => workspace !== null
284
+ && businesses.some((business) => business.repositories.includes(workspace) || business.code === workspace);
285
+ const kept = [];
286
+ const places = [];
287
+ for (const entry of journal.entries) {
288
+ const workspace = await entryWorkspace(entry.file, workspaces);
289
+ if (!belongs(workspace))
290
+ continue;
291
+ kept.push(entry);
292
+ places.push({ decision_attempt_id: entry.proof.decision_attempt_id, workspace });
293
+ }
294
+ const [feedback, answers] = await Promise.all([
295
+ readJudgmentValueProofFeedback({ dataDir: context.dataDir }),
296
+ readJudgmentValueProofAnswers({ dataDir: context.dataDir }),
297
+ ]);
298
+ // Rejected files are left out too: their names would say nothing about the organization they belong to.
299
+ const home = buildJudgmentValueProofReviewHome({ ...journal, entries: kept, rejected: [] }, feedback, { now: context.now(), answers });
300
+ return { status: 'available', scope: 'organization', withheld: journal.entries.length - kept.length, places, home: publicHome(home) };
301
+ }
302
+ async function entryWorkspace(valueProofFile, workspaces) {
303
+ const turnInputFile = `${valueProofFile.slice(0, -VALUE_PROOF_SUFFIX.length)}.turn-input.json`;
304
+ if (!workspaces.has(turnInputFile)) {
305
+ const turnInput = await readJsonFile(turnInputFile);
306
+ workspaces.set(turnInputFile, isRecord(turnInput) ? text(turnInput.project_code) : null);
307
+ }
308
+ return workspaces.get(turnInputFile) ?? null;
309
+ }
310
+ async function readJsonFile(path) {
311
+ try {
312
+ return JSON.parse(await readFile(path, 'utf8'));
313
+ }
314
+ catch {
315
+ return null;
316
+ }
317
+ }
318
+ function createWorldModule(context, read) {
319
+ const workspaces = new Map();
320
+ return {
321
+ id: WORLD_EXTENSION_ID,
322
+ uiFiles: [],
323
+ async handle(request, response) {
324
+ if (request.method !== 'GET')
325
+ return false;
326
+ const path = requestUrl(request).pathname;
327
+ if (path === `/api/extensions/${WORLD_EXTENSION_ID}/businesses`) {
328
+ response.setHeader('Cache-Control', 'no-store');
329
+ writeJson(response, 200, await read());
330
+ return true;
331
+ }
332
+ if (path === `/api/extensions/${WORLD_EXTENSION_ID}/organization-judgments`) {
333
+ response.setHeader('Cache-Control', 'no-store');
334
+ const world = await read();
335
+ if (world.status !== 'ok' || world.source.authority !== 'organization_graph') {
336
+ writeJson(response, 200, { version: WORLD_EXTENSION_VERSION, status: 'unavailable', scope: 'organization', withheld: 0, places: [], home: null, reason: 'not_reading_organization_graph' });
337
+ return true;
338
+ }
339
+ writeJson(response, 200, { version: WORLD_EXTENSION_VERSION, ...(await readOrganizationJudgments(context, world.businesses, workspaces)) });
340
+ return true;
341
+ }
342
+ if (path === `/api/extensions/${WORLD_EXTENSION_ID}/judgment-places`) {
343
+ response.setHeader('Cache-Control', 'no-store');
344
+ writeJson(response, 200, { version: WORLD_EXTENSION_VERSION, ...(await readJudgmentPlaces(context.journalRoot, { cache: context.journalCache, workspaces })) });
345
+ return true;
346
+ }
347
+ return false;
348
+ },
349
+ };
350
+ }
351
+ export function createWorldExtension(options = {}) {
352
+ return {
353
+ id: WORLD_EXTENSION_ID,
354
+ uiDir: fileURLToPath(new URL('../ui/world/', import.meta.url)),
355
+ uiFiles: ['world-view.js', 'world-view.css', 'world-vendor.js', 'world-placement.js'],
356
+ screenEntry: 'world-view.js',
357
+ // P17: the world sits above the home and opens first.
358
+ navPosition: 'first',
359
+ createModule: (context) => createWorldModule(context, () => readWorldBusinesses(context, { vocabulary: options.vocabulary, now: context.now })),
360
+ };
361
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
4
4
  "description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -147,6 +147,10 @@
147
147
  "types": "./dist/world-model.d.ts",
148
148
  "import": "./dist/world-model.js"
149
149
  },
150
+ "./world": {
151
+ "types": "./dist/world-extension.d.ts",
152
+ "import": "./dist/world-extension.js"
153
+ },
150
154
  "./problem-selection": {
151
155
  "types": "./dist/problem-selection.d.ts",
152
156
  "import": "./dist/problem-selection.js"
@@ -329,7 +333,8 @@
329
333
  "release:validate": "node scripts/npm-release.mjs validate",
330
334
  "release:publish": "node scripts/npm-release.mjs publish",
331
335
  "release:verify": "node scripts/npm-release.mjs verify",
332
- "build:graph": "node scripts/build-project-graph.mjs"
336
+ "build:graph": "node scripts/build-project-graph.mjs",
337
+ "build:world": "node scripts/build-world-vendor.mjs"
333
338
  },
334
339
  "dependencies": {
335
340
  "@modelcontextprotocol/sdk": "^1.30.0",
@@ -344,6 +349,7 @@
344
349
  "@electric-sql/pglite": "^0.5.8",
345
350
  "@types/node": "^22.15.29",
346
351
  "esbuild": "0.28.2",
352
+ "three": "0.186.1",
347
353
  "tsx": "^4.19.4",
348
354
  "typescript": "^5.8.3",
349
355
  "vitepress": "^1.6.4",
@@ -352,5 +358,5 @@
352
358
  "engines": {
353
359
  "node": ">=20"
354
360
  },
355
- "gitHead": "44f4640b17343eb3a44762b2374434568e73c890"
361
+ "gitHead": "bbcd694ef8e7231d88c1522d95c701421c9c5afa"
356
362
  }
@@ -34,6 +34,7 @@ import {
34
34
  graphPageContext,
35
35
  graphStateNotice,
36
36
  hostReadOnlyNote,
37
+ hostSourceNotice,
37
38
  isEdgeView,
38
39
  isEntityView,
39
40
  isRecord,
@@ -188,7 +189,8 @@ function normalizeProjectSummary(value) {
188
189
  * プロジェクトを直す, `fields` limits its form, `edges: false` withholds 役割を直す and 関わりを終える /
189
190
  * 終了日を直す, and `createEdges: false` withholds 関係者を加える. A withheld control is not drawn and the
190
191
  * client refuses such a correction before sending it. Without it every correction is allowed.
191
- * @param {string} [options.readOnlyNote] With `canCorrect: false`, or where `correctionScope` withholds a
192
+ * @param {string | ((record: { id: string, type: string | null, metadata: object } | null) => string | Element | null)} [options.readOnlyNote]
193
+ * With `canCorrect: false`, or where `correctionScope` withholds a
192
194
  * control of the selected project, a note shown in the rail where the correction buttons would be.
193
195
  * Without it the rail says nothing about corrections.
194
196
  * @param {{ label?: string, text: string | Element }} [options.emptyNotice] Replaces the 未登録 notice
@@ -506,7 +508,10 @@ export function createGraphProjectsView({
506
508
  const legacyChrome = [pageHeader];
507
509
  const children = [pageHeader];
508
510
  const dir = state.list.state === 'ok' ? textOrNull(state.list.payload.source?.dataDir) : null;
509
- const sourceBlock = workspaceHostNotice(doc, sourceNotice) ?? workspaceNotice(doc, {
511
+ // The record a host's 出典 notice may point at: the selected project once its detail is read.
512
+ const sourceDetail = state.detail?.id === state.selectedId && state.detail.state === 'ok' ? state.detail.payload.project : null;
513
+ const sourceSubject = sourceDetail ? { id: sourceDetail.id, type: 'project', metadata: sourceDetail.metadata ?? {} } : null;
514
+ const sourceBlock = workspaceHostNotice(doc, hostSourceNotice(sourceNotice, sourceSubject)) ?? workspaceNotice(doc, {
510
515
  label: '出典',
511
516
  text: `このMacのGraph${dir ? `(${dir})` : ''}から読み、ここで直した内容もそこへ保存します。関係者は、プロジェクトに参加している人や責任を持つ人の記録で、ログインや共有の設定ではありません。`,
512
517
  });
@@ -714,6 +719,8 @@ export function createGraphProjectsView({
714
719
  const detail = state.detail;
715
720
  const loaded = detail?.id === state.selectedId && detail.state === 'ok' ? detail.payload : null;
716
721
  const project = loaded?.project ?? listItem(state.selectedId) ?? { id: state.selectedId, name: state.selectedId };
722
+ // The record a host's read-only note may point at.
723
+ const noteSubject = { id: project.id, type: 'project', metadata: loaded?.project?.metadata ?? {} };
717
724
  const children = [workspaceRailHead(doc, { kicker: 'プロジェクト', title: project.name, sub: project.id })];
718
725
  if (!loaded) {
719
726
  const readState = detail?.id === state.selectedId
@@ -776,9 +783,9 @@ export function createGraphProjectsView({
776
783
  if (!loaded.participants.some((edge) => edge.id === state.panelAt)) children.push(correction.render(doc));
777
784
  // The host's reason for a control it withholds, where that control would be.
778
785
  const withheld = !canAdd || projectFields.length === 0 || (!scope.edges && loaded.participants.length > 0);
779
- if (withheld) children.push(hostReadOnlyNote(doc, readOnlyNote));
786
+ if (withheld) children.push(hostReadOnlyNote(doc, readOnlyNote, noteSubject));
780
787
  } else {
781
- children.push(hostReadOnlyNote(doc, readOnlyNote));
788
+ children.push(hostReadOnlyNote(doc, readOnlyNote, noteSubject));
782
789
  }
783
790
  children.push(...railExtensions(state.selectedId, loaded));
784
791
  children.push(renderOtherRelations(loaded));
@@ -35,6 +35,7 @@ import {
35
35
  graphPageContext,
36
36
  graphStateNotice,
37
37
  hostReadOnlyNote,
38
+ hostSourceNotice,
38
39
  isEdgeView,
39
40
  isEntityView,
40
41
  isRecord,
@@ -173,7 +174,8 @@ function textCell(value, fallback) {
173
174
  * offer この記録を直す, `fields` limits its form, `edges: false` withholds この関係を直す and
174
175
  * `createEdges: false` withholds 関係を加える. A withheld control is not drawn and the client refuses
175
176
  * such a correction before sending it. Without it every correction is allowed.
176
- * @param {string} [options.readOnlyNote] With `canCorrect: false`, or where `correctionScope` withholds a
177
+ * @param {string | ((record: { id: string, type: string | null, metadata: object } | null) => string | Element | null)} [options.readOnlyNote]
178
+ * With `canCorrect: false`, or where `correctionScope` withholds a
177
179
  * control of the selected record, a note shown in the rail where the correction buttons would be.
178
180
  * Without it the rail says nothing about corrections.
179
181
  * @param {{ label?: string, text: string | Element }} [options.emptyNotice] Replaces the 未登録 notice
@@ -443,6 +445,12 @@ export function createGraphRegistryView({
443
445
  return section;
444
446
  }
445
447
 
448
+ /** The record a host's 出典 notice may point at: the selected record once it is read. */
449
+ function sourceSubject() {
450
+ const entity = state.detail?.state === 'ok' ? state.detail.payload.entity : null;
451
+ return entity ? { id: entity.id, type: entity.type, metadata: entity.metadata ?? {} } : null;
452
+ }
453
+
446
454
  function renderWorkspace() {
447
455
  const dir = state.search.state === 'ok' ? textOrNull(state.search.payload.source?.dataDir) : null;
448
456
  return [
@@ -452,7 +460,7 @@ export function createGraphRegistryView({
452
460
  lead: writable ? 'Graphに何がどう登録されているかを確かめ、誤りを直します。' : 'Graphに何がどう登録されているかを確かめます。',
453
461
  source: context.source,
454
462
  }),
455
- workspaceHostNotice(doc, sourceNotice) ?? workspaceNotice(doc, {
463
+ workspaceHostNotice(doc, hostSourceNotice(sourceNotice, sourceSubject())) ?? workspaceNotice(doc, {
456
464
  label: '出典',
457
465
  text: `このMacのGraph${dir ? `(${dir})` : ''}だけを表示します。ここで直した内容は、同じGraphを読むMCPの search・get_context・resolve_entity で次から使われます。`,
458
466
  }),
@@ -601,6 +609,8 @@ export function createGraphRegistryView({
601
609
  children.push(graphStateNotice(doc, detail, { onRetry: () => controller.loadEntity(detail.id), loadingText: 'この記録を読み込んでいます。' }));
602
610
  return children;
603
611
  }
612
+ // The record a host's read-only note may point at.
613
+ const noteSubject = { id: entity.id, type: entity.type, metadata: loaded.entity?.metadata ?? {} };
604
614
  const summaryRows = [
605
615
  ['別名', entity.aliases.length > 0 ? entity.aliases.join('、') : { text: 'なし', className: 'is-unrecorded' }],
606
616
  ['要約', textOrNull(entity.summary)],
@@ -647,9 +657,9 @@ export function createGraphRegistryView({
647
657
  const inRelation = [...loaded.outgoing, ...loaded.incoming].some((edge) => edge.id === state.panelAt);
648
658
  if (!inRelation) children.push(correction.render(doc));
649
659
  // The host's reason for a control it withholds, where that control would be.
650
- if (!canFix || (relatable && !scope.createEdges) || (related && !scope.edges)) children.push(hostReadOnlyNote(doc, readOnlyNote));
660
+ if (!canFix || (relatable && !scope.createEdges) || (related && !scope.edges)) children.push(hostReadOnlyNote(doc, readOnlyNote, noteSubject));
651
661
  } else {
652
- children.push(hostReadOnlyNote(doc, readOnlyNote));
662
+ children.push(hostReadOnlyNote(doc, readOnlyNote, noteSubject));
653
663
  }
654
664
  children.push(renderRelations(loaded, 'outgoing'), renderRelations(loaded, 'incoming'));
655
665
  children.push(renderCorrectionHistory(doc, loaded.history));
@@ -47,6 +47,8 @@
47
47
  /* Notices inside the rail sit closer than the workspace's. */
48
48
  .bb-graph-rail .bb-ws-notice {
49
49
  margin: 16px 0;
50
+ /* A host's read-only note may list steps on separate lines. */
51
+ white-space: pre-line;
50
52
  }
51
53
 
52
54
  .bb-graph-notice-body {
@@ -459,9 +459,64 @@ function withinCorrectionScope(scope, body, recordType) {
459
459
  return scope.entity(recordType) && scope.fields(changed).length === changed.length;
460
460
  }
461
461
 
462
- /** A host's note that corrections are not available here, or null when it passed none. */
463
- export function hostReadOnlyNote(doc, readOnlyNote) {
464
- const text = textOrNull(readOnlyNote);
462
+ /**
463
+ * A host's 出典 notice with its text resolved for the selected record: the host may pass `text` as a
464
+ * function of that record (`{ id, type, metadata }`, or null before one is read), for a notice that points
465
+ * at it. Returns the notice for `workspaceHostNotice`, or the value it was given.
466
+ */
467
+ export function hostSourceNotice(sourceNotice, subject) {
468
+ if (!sourceNotice || typeof sourceNotice.text !== 'function') return sourceNotice;
469
+ return { ...sourceNotice, text: sourceNotice.text(subject ?? null) };
470
+ }
471
+
472
+ // ---------------------------------------------------------------------------
473
+ // The organization's web (Inspector UX-20261004-01)
474
+
475
+ const ORGANIZATION_RECORD_ID = /^[A-Za-z0-9_.:-]{1,200}$/u;
476
+
477
+ /** The organization web's origin the host was told about, or null. */
478
+ export function organizationWebOrigin(organizationGraph) {
479
+ if (organizationGraph?.status !== 'connected') return null;
480
+ const origin = organizationGraph.web_url;
481
+ return typeof origin === 'string' && /^https:\/\/[^/?#@\s]+$/u.test(origin) ? origin : null;
482
+ }
483
+
484
+ /**
485
+ * Where a record opens in the organization web, `{ href, label }`, or null when it has no usable id. The
486
+ * organization web opens 情報と関係 by record id and プロジェクトと関係者 by project code, and keeps that record
487
+ * across sign-in. A project opens by its own code only: a sub-project's scope code is its parent's and
488
+ * would open the parent, so without its own code the record opens by id.
489
+ */
490
+ export function organizationRecordLink(origin, screenId, record) {
491
+ const id = typeof record?.id === 'string' && ORGANIZATION_RECORD_ID.test(record.id) ? record.id : null;
492
+ const own = typeof record?.metadata?.code === 'string' ? record.metadata.code.trim() : '';
493
+ const code = own && ORGANIZATION_RECORD_ID.test(own) ? own : null;
494
+ if (screenId === 'projects' && record?.type === 'project' && code) {
495
+ return { href: `${origin}/?screen=projects&project=${encodeURIComponent(code)}`, label: '「プロジェクトと関係者」' };
496
+ }
497
+ return id ? { href: `${origin}/?screen=graph&entity_id=${encodeURIComponent(id)}`, label: '「情報と関係」' } : null;
498
+ }
499
+
500
+ export const ORGANIZATION_RECORD_LINK_TEXT = '組織版でこの記録を開く';
501
+
502
+ /** A link that opens `target` from organizationRecordLink in a new tab. */
503
+ export function organizationRecordAnchor(doc, target, className) {
504
+ return makeElement(doc, 'a', {
505
+ ...(className ? { className } : {}),
506
+ text: ORGANIZATION_RECORD_LINK_TEXT,
507
+ attrs: { href: target.href, target: '_blank', rel: 'noopener noreferrer' },
508
+ });
509
+ }
510
+
511
+ /**
512
+ * A host's note that corrections are not available here, or null when it passed none. The host may
513
+ * pass a function of the selected record (`{ id, type, metadata }`) returning a string or an element,
514
+ * for a note that points at that record (for example a link to where it is corrected).
515
+ */
516
+ export function hostReadOnlyNote(doc, readOnlyNote, subject) {
517
+ const value = typeof readOnlyNote === 'function' ? readOnlyNote(subject ?? null) : readOnlyNote;
518
+ if (value && typeof value === 'object' && typeof value.tagName === 'string') return workspaceNotice(doc, { text: value, tone: 'info' });
519
+ const text = textOrNull(value);
465
520
  return text ? workspaceNotice(doc, { text, tone: 'info' }) : null;
466
521
  }
467
522