@unson/brainbase-mcp 0.22.5 → 0.23.0

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,34 @@
1
+ import type { Tool } from '@modelcontextprotocol/sdk/types.js';
2
+ import { type JudgmentFrameKind, type JudgmentFrameRecordsPage } from './judgment-frame.js';
3
+ type JudgmentFrameReadResult = {
4
+ readonly status: 'ok';
5
+ readonly data: {
6
+ readonly schema_version: 'brainbase-judgment-frame-read-v1';
7
+ readonly catalog_digest: `sha256:${string}`;
8
+ readonly records: readonly JudgmentFrameReadRecord[];
9
+ };
10
+ } | {
11
+ readonly status: 'unavailable' | 'error';
12
+ readonly error: {
13
+ readonly code: string;
14
+ readonly message: string;
15
+ readonly details?: unknown;
16
+ };
17
+ };
18
+ export type JudgmentFrameReadRecord = {
19
+ readonly kind: JudgmentFrameKind;
20
+ readonly ref: string;
21
+ readonly body: string;
22
+ readonly body_digest: `sha256:${string}`;
23
+ readonly version: string;
24
+ readonly version_source: 'graph' | 'body_digest';
25
+ readonly applicability_scope: string;
26
+ readonly applicability_scope_source: 'graph' | 'unspecified';
27
+ };
28
+ export type JudgmentFrameReadDependencies = {
29
+ /** Read every record of one kind visible to the caller, at most `limit`. */
30
+ readonly loadRecords: (kind: JudgmentFrameKind, limit: number) => Promise<JudgmentFrameRecordsPage>;
31
+ };
32
+ export declare const judgmentFrameReadTools: Tool[];
33
+ export declare function handleJudgmentFrameReadToolCall(name: string, args: Record<string, unknown>, dependencies: JudgmentFrameReadDependencies): Promise<JudgmentFrameReadResult | null>;
34
+ export {};
@@ -0,0 +1,223 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { buildJudgmentFrameCatalog, } from './judgment-frame.js';
3
+ const KINDS = ['philosophy', 'objective', 'model'];
4
+ const FRAME_PAGE_LIMIT = 500;
5
+ const MAX_SELECTIONS = 24;
6
+ const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
7
+ const MAX_REF_LENGTH = 1_000;
8
+ const UNSPECIFIED_APPLICABILITY_SCOPE = 'Graphに適用範囲の記載なし';
9
+ const isRecord = (value) => (Boolean(value) && typeof value === 'object' && !Array.isArray(value));
10
+ const text = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined);
11
+ const versionText = (value) => (text(value) ?? (typeof value === 'number' && Number.isFinite(value) ? String(value) : undefined));
12
+ function canonicalJson(value) {
13
+ if (Array.isArray(value))
14
+ return `[${value.map(canonicalJson).join(',')}]`;
15
+ if (isRecord(value)) {
16
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
17
+ }
18
+ const serialized = JSON.stringify(value);
19
+ return serialized === undefined ? 'null' : serialized;
20
+ }
21
+ function scopeText(value) {
22
+ const stringValue = text(value);
23
+ if (stringValue)
24
+ return stringValue;
25
+ if (!isRecord(value) && !Array.isArray(value))
26
+ return undefined;
27
+ const serialized = canonicalJson(value);
28
+ return serialized && serialized !== '{}' && serialized !== '[]' ? serialized : undefined;
29
+ }
30
+ function itemBody(kind, payload) {
31
+ const foundation = isRecord(payload.foundation) ? payload.foundation : {};
32
+ if (kind === 'philosophy') {
33
+ const statement = text(payload.statement) ?? text(foundation.meaning);
34
+ const name = text(payload.display_name) ?? text(payload.title);
35
+ if (!statement)
36
+ return undefined;
37
+ return name && name !== statement ? `${name}:${statement}` : statement;
38
+ }
39
+ if (kind === 'objective') {
40
+ return text(foundation.desiredState) ?? text(foundation.meaning) ?? text(payload.statement);
41
+ }
42
+ // A model title is a label, not a readable world-model body. Require a
43
+ // meaning/statement so a name-only catalog item cannot become evidence.
44
+ return text(foundation.meaning) ?? text(payload.statement);
45
+ }
46
+ function recordScope(kind, record) {
47
+ const payload = isRecord(record.payload) ? record.payload : {};
48
+ const foundation = isRecord(payload.foundation) ? payload.foundation : {};
49
+ const recordMetadata = record;
50
+ const candidates = kind === 'model'
51
+ ? [
52
+ payload.applicability_text,
53
+ payload.applicability_scope,
54
+ foundation.applicability_text,
55
+ foundation.applicability_scope,
56
+ payload.applicability,
57
+ foundation.applicability,
58
+ ]
59
+ : [
60
+ payload.applicability_scope,
61
+ payload.applicability_text,
62
+ foundation.applicability_scope,
63
+ foundation.applicability_text,
64
+ payload.applicability,
65
+ foundation.applicability,
66
+ ];
67
+ candidates.push(recordMetadata.applicability_scope, recordMetadata.applicability_text);
68
+ for (const candidate of candidates) {
69
+ const value = scopeText(candidate);
70
+ if (value)
71
+ return { value, source: 'graph' };
72
+ }
73
+ return { value: UNSPECIFIED_APPLICABILITY_SCOPE, source: 'unspecified' };
74
+ }
75
+ function invalidInput() {
76
+ return {
77
+ status: 'error',
78
+ error: {
79
+ code: 'judgment_frame_read_input_invalid',
80
+ message: 'Provide one current catalog digest and one or more unique philosophy, objective, or model references.',
81
+ },
82
+ };
83
+ }
84
+ function toolError(status, code, message, details) {
85
+ return { status, error: { code, message, ...(details === undefined ? {} : { details }) } };
86
+ }
87
+ function isSelection(value) {
88
+ if (!isRecord(value) || Object.keys(value).length !== 2
89
+ || !Object.hasOwn(value, 'kind') || !Object.hasOwn(value, 'ref'))
90
+ return false;
91
+ return KINDS.includes(value.kind)
92
+ && typeof value.ref === 'string'
93
+ && value.ref.trim() === value.ref
94
+ && value.ref.length > 0
95
+ && value.ref.length <= MAX_REF_LENGTH;
96
+ }
97
+ function parseInput(args) {
98
+ if (!isRecord(args)
99
+ || Object.keys(args).length !== 2
100
+ || !Object.hasOwn(args, 'catalog_digest')
101
+ || !Object.hasOwn(args, 'selections')
102
+ || typeof args.catalog_digest !== 'string'
103
+ || !DIGEST_PATTERN.test(args.catalog_digest)
104
+ || !Array.isArray(args.selections)
105
+ || args.selections.length === 0
106
+ || args.selections.length > MAX_SELECTIONS
107
+ || !args.selections.every(isSelection))
108
+ return null;
109
+ const selections = args.selections;
110
+ const keys = selections.map((selection) => `${selection.kind}:${selection.ref}`);
111
+ if (new Set(keys).size !== keys.length)
112
+ return null;
113
+ return { catalogDigest: args.catalog_digest, selections };
114
+ }
115
+ async function loadFrameCatalog(dependencies) {
116
+ const records = [];
117
+ for (const kind of KINDS) {
118
+ let page;
119
+ try {
120
+ page = await dependencies.loadRecords(kind, FRAME_PAGE_LIMIT);
121
+ }
122
+ catch (error) {
123
+ return toolError('unavailable', 'judgment_frame_catalog_unavailable', `Graph ${kind} records could not be read: ${error instanceof Error ? error.message : String(error)}`);
124
+ }
125
+ if (page.status !== 'ok')
126
+ return toolError(page.status, page.code, page.message);
127
+ if (page.records.length >= FRAME_PAGE_LIMIT) {
128
+ return toolError('error', 'judgment_frame_catalog_truncated', `Graph returned ${page.records.length} ${kind} records; the catalog may be incomplete`);
129
+ }
130
+ records.push(...page.records);
131
+ }
132
+ return { ...buildJudgmentFrameCatalog(records), records };
133
+ }
134
+ export const judgmentFrameReadTools = [{
135
+ name: 'brainbase_judgment_frame_read',
136
+ description: 'On every confirmed turn, after reading the judgment frame catalog, retrieve the full canonical Graph body, version, and applicability scope for every selected philosophy, objective, and world model reference. Pass the exact catalog_digest and selected kind/ref pairs. The read is authorized against the caller-visible Graph and fails on a stale catalog, unknown reference, or empty body. If Graph metadata has no applicability scope, the result says so explicitly; authorization scope is never presented as applicability scope.',
137
+ inputSchema: {
138
+ type: 'object',
139
+ additionalProperties: false,
140
+ properties: {
141
+ catalog_digest: { type: 'string', pattern: '^sha256:[a-f0-9]{64}$' },
142
+ selections: {
143
+ type: 'array',
144
+ minItems: 1,
145
+ maxItems: MAX_SELECTIONS,
146
+ items: {
147
+ type: 'object',
148
+ additionalProperties: false,
149
+ properties: {
150
+ kind: { type: 'string', enum: [...KINDS] },
151
+ ref: { type: 'string', minLength: 1, maxLength: MAX_REF_LENGTH },
152
+ },
153
+ required: ['kind', 'ref'],
154
+ },
155
+ },
156
+ },
157
+ required: ['catalog_digest', 'selections'],
158
+ },
159
+ annotations: { readOnlyHint: true, destructiveHint: false },
160
+ }];
161
+ export async function handleJudgmentFrameReadToolCall(name, args, dependencies) {
162
+ if (name !== 'brainbase_judgment_frame_read')
163
+ return null;
164
+ const input = parseInput(args);
165
+ if (!input)
166
+ return invalidInput();
167
+ const loaded = await loadFrameCatalog(dependencies);
168
+ if ('status' in loaded)
169
+ return loaded;
170
+ const { catalog, records: sourceRecords } = loaded;
171
+ if (input.catalogDigest !== catalog.digest) {
172
+ return toolError('error', 'judgment_frame_catalog_mismatch', 'The supplied catalog_digest is stale or was produced from a different visible Graph.', { current_catalog_digest: catalog.digest });
173
+ }
174
+ const records = [];
175
+ const sourceByKey = new Map();
176
+ for (const record of sourceRecords) {
177
+ const key = `${record.entity_type}:${record.id}`;
178
+ if (!sourceByKey.has(key))
179
+ sourceByKey.set(key, record);
180
+ }
181
+ for (const selection of input.selections) {
182
+ const item = catalog.items.find((candidate) => candidate.kind === selection.kind && candidate.id === selection.ref);
183
+ if (!item) {
184
+ return toolError('error', 'judgment_frame_ref_unknown', `${selection.kind}/${selection.ref} is not in the current judgment frame catalog`);
185
+ }
186
+ const source = sourceByKey.get(`${selection.kind}:${selection.ref}`);
187
+ if (!source) {
188
+ return toolError('error', 'judgment_frame_body_unavailable', `${selection.kind}/${selection.ref} is no longer available in the authorized Graph`);
189
+ }
190
+ const payload = isRecord(source.payload) ? source.payload : {};
191
+ const semanticBody = itemBody(selection.kind, payload);
192
+ if (!semanticBody)
193
+ return toolError('error', 'judgment_frame_body_unavailable', `${selection.kind}/${selection.ref} has no readable body`);
194
+ // The catalog intentionally clips a compact summary. Return the full
195
+ // canonical payload so model predictions, constraints, and measurements
196
+ // are not silently discarded by the read step.
197
+ const body = canonicalJson(payload);
198
+ const bodyDigest = `sha256:${createHash('sha256').update(body, 'utf8').digest('hex')}`;
199
+ const foundation = isRecord(payload.foundation) ? payload.foundation : {};
200
+ const graphVersion = versionText(foundation.revision) ?? versionText(source.version);
201
+ const version = graphVersion ?? bodyDigest;
202
+ const versionSource = graphVersion ? 'graph' : 'body_digest';
203
+ const applicabilityScope = recordScope(selection.kind, source);
204
+ records.push({
205
+ kind: selection.kind,
206
+ ref: selection.ref,
207
+ body,
208
+ body_digest: bodyDigest,
209
+ version,
210
+ version_source: versionSource,
211
+ applicability_scope: applicabilityScope.value,
212
+ applicability_scope_source: applicabilityScope.source,
213
+ });
214
+ }
215
+ return {
216
+ status: 'ok',
217
+ data: {
218
+ schema_version: 'brainbase-judgment-frame-read-v1',
219
+ catalog_digest: catalog.digest,
220
+ records,
221
+ },
222
+ };
223
+ }
@@ -125,3 +125,5 @@ export type JudgmentFrameToolResult = {
125
125
  };
126
126
  };
127
127
  export declare function handleJudgmentFrameToolCall(name: string, args: Record<string, unknown>, dependencies: JudgmentFrameToolDependencies): Promise<JudgmentFrameToolResult | null>;
128
+ export { judgmentFrameReadTools, handleJudgmentFrameReadToolCall, } from './judgment-frame-read.js';
129
+ export type { JudgmentFrameReadDependencies, JudgmentFrameReadRecord, } from './judgment-frame-read.js';
@@ -381,3 +381,4 @@ export async function handleJudgmentFrameToolCall(name, args, dependencies) {
381
381
  },
382
382
  };
383
383
  }
384
+ export { judgmentFrameReadTools, handleJudgmentFrameReadToolCall, } from './judgment-frame-read.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.22.5",
3
+ "version": "0.23.0",
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",
@@ -407,5 +407,5 @@
407
407
  "engines": {
408
408
  "node": ">=20"
409
409
  },
410
- "gitHead": "c361ea6dc167dd1ffe372a6f0d6c1f445d0d5ebf"
410
+ "gitHead": "25fb5aa910ba704d704768d3df5f271b085df5a2"
411
411
  }
@@ -234,3 +234,6 @@
234
234
  grid-template-columns: minmax(90px, .55fr) minmax(0, 1fr);
235
235
  }
236
236
  }
237
+
238
+ /* Catalog provenance is secondary to the topic, but remains visible on every card. */
239
+ .bb-fov-source { display: inline-block; margin: 0 0 12px; padding: 4px 10px; border-radius: 999px; background: var(--bb-surface-subtle, #f4f4f8); color: var(--bb-text-muted, #62626e); font-size: 12px; overflow-wrap: anywhere; }
@@ -415,7 +415,7 @@ function objectiveCard(doc, record, variableMap) {
415
415
  return card;
416
416
  }
417
417
 
418
- function renderObjectives(doc, catalog, viewState, callbacks) {
418
+ function renderObjectives(doc, catalog, viewState, callbacks, recordContexts) {
419
419
  const section = makeElement(doc, 'section', { className: 'bb-fov-section', attrs: { 'aria-label': '目的' } });
420
420
  section.append(workspaceSectionTitle(doc, {
421
421
  title: '目的',
@@ -429,7 +429,7 @@ function renderObjectives(doc, catalog, viewState, callbacks) {
429
429
  const rows = catalog.objectives.map((record) => {
430
430
  const definition = recordDefinition(record);
431
431
  const details = detailsFor(record);
432
- const key = `${definition.id}@${definition.revision}`;
432
+ const key = recordContexts?.get(record)?.key ?? `${definition.id}@${definition.revision}`;
433
433
  return {
434
434
  key,
435
435
  selected: key === viewState.objectiveKey,
@@ -438,7 +438,7 @@ function renderObjectives(doc, catalog, viewState, callbacks) {
438
438
  callbacks.render?.();
439
439
  },
440
440
  cells: [
441
- { primary: formatAny(firstValue(details, 'title')) ?? formatAny(definition.meaning) ?? definition.id, secondary: `${definition.id}@${definition.revision}` },
441
+ { primary: formatAny(firstValue(details, 'title')) ?? formatAny(definition.meaning) ?? definition.id, secondary: recordContexts?.get(record)?.label ?? `${definition.id}@${definition.revision}` },
442
442
  formatAny(definition.desiredState) ?? '目指す状態: 未記録',
443
443
  formatAny(definition.adoptionState) ? (ADOPTION_LABELS[definition.adoptionState] ?? definition.adoptionState) : '状態未確認',
444
444
  ],
@@ -452,18 +452,21 @@ function renderObjectives(doc, catalog, viewState, callbacks) {
452
452
  }));
453
453
  const selected = catalog.objectives.find((record) => {
454
454
  const definition = recordDefinition(record);
455
- return `${definition.id}@${definition.revision}` === viewState.objectiveKey;
456
- }) ?? catalog.objectives[0];
455
+ return (recordContexts?.get(record)?.key ?? `${definition.id}@${definition.revision}`) === viewState.objectiveKey;
456
+ }) ?? (recordContexts ? null : catalog.objectives[0]);
457
+ if (!selected && recordContexts) section.append(makeElement(doc, 'p', { className: 'bb-fov-recorded-notice', text: '目的の行を選ぶと、評価基準と記録上の現状を確認できます。' }));
457
458
  if (selected) {
458
459
  const selectedDefinition = recordDefinition(selected);
459
- viewState.objectiveKey = `${selectedDefinition.id}@${selectedDefinition.revision}`;
460
+ viewState.objectiveKey = recordContexts?.get(selected)?.key ?? `${selectedDefinition.id}@${selectedDefinition.revision}`;
460
461
  if (catalog.objectives.length > 1) {
461
462
  section.append(workspaceNotice(doc, {
462
463
  label: '詳細表示',
463
464
  text: `目的${catalog.objectives.length}件のうち1件を選択して詳細を表示しています。`,
464
465
  }));
465
466
  }
466
- section.append(objectiveCard(doc, selected, variableMap));
467
+ const card = objectiveCard(doc, selected, recordContexts?.get(selected)?.variableMap ?? variableMap);
468
+ appendRecordSource(doc, card, selected, recordContexts);
469
+ section.append(card);
467
470
  }
468
471
  return section;
469
472
  }
@@ -504,7 +507,7 @@ function philosophyCard(doc, record) {
504
507
  return card;
505
508
  }
506
509
 
507
- function renderPhilosophies(doc, catalog) {
510
+ function renderPhilosophies(doc, catalog, recordContexts) {
508
511
  const section = makeElement(doc, 'section', { className: 'bb-fov-section', attrs: { 'aria-label': '哲学' } });
509
512
  section.append(workspaceSectionTitle(doc, {
510
513
  title: '哲学',
@@ -515,7 +518,11 @@ function renderPhilosophies(doc, catalog) {
515
518
  return section;
516
519
  }
517
520
  const grid = makeElement(doc, 'div', { className: 'bb-fov-philosophy-grid' });
518
- for (const record of catalog.philosophies) grid.append(philosophyCard(doc, record));
521
+ for (const record of catalog.philosophies) {
522
+ const card = philosophyCard(doc, record);
523
+ appendRecordSource(doc, card, record, recordContexts);
524
+ grid.append(card);
525
+ }
519
526
  section.append(grid);
520
527
  return section;
521
528
  }
@@ -569,7 +576,7 @@ function modelCard(doc, record, variableMap) {
569
576
  return card;
570
577
  }
571
578
 
572
- function renderModels(doc, catalog, viewState, callbacks) {
579
+ function renderModels(doc, catalog, viewState, callbacks, recordContexts) {
573
580
  const section = makeElement(doc, 'section', { className: 'bb-fov-section', attrs: { 'aria-label': '世界モデル' } });
574
581
  section.append(workspaceSectionTitle(doc, {
575
582
  title: '世界モデル',
@@ -595,7 +602,7 @@ function renderModels(doc, catalog, viewState, callbacks) {
595
602
  const renderResults = () => {
596
603
  results.replaceChildren();
597
604
  const query = viewState.query.trim().toLocaleLowerCase();
598
- const filtered = query ? catalog.models.filter((record) => searchTextForModel(record).includes(query)) : catalog.models;
605
+ const filtered = query ? catalog.models.filter((record) => `${searchTextForModel(record)} ${recordContexts?.get(record)?.label ?? ""}`.toLocaleLowerCase().includes(query)) : catalog.models;
599
606
  if (filtered.length === 0) {
600
607
  results.append(workspaceNotice(doc, { label: '検索結果', text: query ? '条件に一致する世界モデルはありません。' : 'この対象範囲には登録された世界モデルがありません。' }));
601
608
  return;
@@ -610,7 +617,11 @@ function renderModels(doc, catalog, viewState, callbacks) {
610
617
  }));
611
618
  const variableMap = new Map(catalog.variables.map((record) => [foundationKey(recordDefinition(record)), record]));
612
619
  const cards = makeElement(doc, 'div', { className: 'bb-fov-model-grid' });
613
- for (const record of pageItems) cards.append(modelCard(doc, record, variableMap));
620
+ for (const record of pageItems) {
621
+ const card = modelCard(doc, record, recordContexts?.get(record)?.variableMap ?? variableMap);
622
+ appendRecordSource(doc, card, record, recordContexts);
623
+ cards.append(card);
624
+ }
614
625
  results.append(cards);
615
626
  if (pages > 1) {
616
627
  const pager = makeElement(doc, 'nav', { className: 'bb-fov-pager', attrs: { 'aria-label': '世界モデルのページ' } });
@@ -655,6 +666,8 @@ export function renderFoundationOverview(root, state, {
655
666
  viewState = { query: '', page: 0 },
656
667
  callbacks = {},
657
668
  showHeader = true,
669
+ recordContexts,
670
+ partial = false,
658
671
  } = {}) {
659
672
  if (!root) throw new TypeError('root is required');
660
673
  const doc = getDocument(explicitDocument);
@@ -671,7 +684,7 @@ export function renderFoundationOverview(root, state, {
671
684
  source: `正本: ${scopeId}`,
672
685
  }));
673
686
  }
674
- surface.append(workspaceNotice(doc, {
687
+ if (!recordContexts) surface.append(workspaceNotice(doc, {
675
688
  label: '対象範囲',
676
689
  text: scopeId,
677
690
  }));
@@ -686,22 +699,85 @@ export function renderFoundationOverview(root, state, {
686
699
  return surface;
687
700
  }
688
701
  const catalog = state.catalog;
689
- surface.append(workspaceNotice(doc, {
690
- label: '読み方',
691
- text: '登録は達成・採用・検証済みを意味しません。記録上の現状は保存時点の記述です。',
692
- }));
702
+ if (partial) surface.append(workspaceNotice(doc, { label: '取得済みの範囲', text: '以下の件数・登録なし・検索結果は、取得できた記録だけを対象にしています。全体は未確認です。' }));
703
+ surface.append(recordContexts
704
+ ? makeElement(doc, 'p', { className: 'bb-fov-recorded-notice', text: '記録上の現状は保存時点の記述です。登録状態と検証状態を分けて読みます。' })
705
+ : workspaceNotice(doc, { label: '読み方', text: '登録は達成・採用・検証済みを意味しません。記録上の現状は保存時点の記述です。' }));
693
706
  surface.append(workspaceMetrics(doc, [
694
- { label: '目的', value: catalog.objectives.length, note: '登録された定義' },
695
- { label: '哲学', value: catalog.philosophies.length, note: '登録された版' },
696
- { label: '世界モデル', value: catalog.models.length, note: '検索対象' },
707
+ { label: '目的', value: catalog.objectives.length, note: partial ? '取得済みの定義(全体は未確認)' : '登録された定義' },
708
+ { label: '哲学', value: catalog.philosophies.length, note: partial ? '取得済みの版(全体は未確認)' : '登録された版' },
709
+ { label: '世界モデル', value: catalog.models.length, note: partial ? '取得済みの検索対象(全体は未確認)' : '検索対象' },
697
710
  ], { ariaLabel: 'Foundationの登録数' }));
698
- surface.append(renderObjectives(doc, catalog, viewState, callbacks));
699
- surface.append(renderPhilosophies(doc, catalog));
700
- surface.append(renderModels(doc, catalog, viewState, callbacks));
711
+ surface.append(renderObjectives(doc, catalog, viewState, callbacks, recordContexts));
712
+ surface.append(renderPhilosophies(doc, catalog, recordContexts));
713
+ surface.append(renderModels(doc, catalog, viewState, callbacks, recordContexts));
701
714
  root.append(surface);
702
715
  return surface;
703
716
  }
704
717
 
718
+ function appendRecordSource(doc, card, record, contexts) {
719
+ const context = contexts?.get(record);
720
+ if (context) card.prepend(makeElement(doc, 'p', { className: 'bb-fov-source', text: context.label }));
721
+ }
722
+
723
+ /** Compose independently validated catalogs by topic, preserving each reference's source context. */
724
+ export function renderFoundationCollectionOverview(root, sources, {
725
+ document: explicitDocument, viewState = { query: '', page: 0 }, callbacks = {}, scopeLabel = '全体',
726
+ } = {}) {
727
+ const doc = getDocument(explicitDocument);
728
+ root.replaceChildren();
729
+ const catalog = { objectives: [], variables: [], models: [], philosophies: [] };
730
+ const contexts = new WeakMap();
731
+ const seen = new Map();
732
+ let ready = 0;
733
+ for (const source of sources) {
734
+ if (source.state.status !== 'ready') {
735
+ const notice = workspaceNotice(doc, {
736
+ label: source.label,
737
+ text: source.state.status === 'loading' ? '目的・哲学・世界モデルを読み込んでいます。'
738
+ : `取得失敗: ${source.state.error?.message ?? 'この対象は未確認です。0件ではありません。'}`,
739
+ tone: source.state.status === 'loading' ? 'info' : 'danger',
740
+ });
741
+ notice.append(workspaceButton(doc, { text: '再試行', variant: 'quiet', onClick: callbacks.load }));
742
+ root.append(notice);
743
+ continue;
744
+ }
745
+ const value = normalizeFoundationCatalog(source.state.catalog, source.scopeId);
746
+ ready++;
747
+ const variableMap = new Map(value.variables.map((record) => [foundationKey(recordDefinition(record)), record]));
748
+ for (const kind of ['objectives', 'models', 'philosophies']) {
749
+ for (const original of value[kind]) {
750
+ const definition = original.definition ?? original;
751
+ const scope = original.scope ?? original.currentScope ?? definition.scope;
752
+ // Scope-less/project records remain tied to their authenticated catalog.
753
+ const shared = ['organization', 'organisation', 'company'].includes(scope?.type) && nonEmpty(scope?.id);
754
+ const key = JSON.stringify([kind, shared ? scope.type : 'project', shared ? scope.id : source.scopeId,
755
+ definition.id, definition.revision, original.digest]);
756
+ const existing = seen.get(key);
757
+ if (existing) {
758
+ const context = contexts.get(existing);
759
+ if (!context.sources.includes(source.label)) context.sources.push(source.label);
760
+ context.label = `${shared ? '組織共通 / ' : ''}${context.sources.join('・')}`;
761
+ continue;
762
+ }
763
+ const record = { ...original };
764
+ seen.set(key, record);
765
+ catalog[kind].push(record);
766
+ contexts.set(record, { key, variableMap, sources: [source.label], label: `${shared ? '組織共通 / ' : ''}${source.label}` });
767
+ }
768
+ }
769
+ }
770
+ if (ready) {
771
+ const mount = makeElement(doc, 'div');
772
+ renderFoundationOverview(mount, { status: 'ready', catalog }, {
773
+ document: doc, scopeId: scopeLabel, showHeader: false, viewState, callbacks,
774
+ recordContexts: contexts, partial: ready !== sources.length,
775
+ });
776
+ root.append(mount);
777
+ }
778
+ return root;
779
+ }
780
+
705
781
  /** Mount the read-only overview and ignore responses from a destroyed generation. */
706
782
  export function createFoundationOverview({ root, port, scopeId, document: explicitDocument, autoLoad = true, showHeader = true } = {}) {
707
783
  if (!root) throw new TypeError('root is required');
@@ -243,6 +243,8 @@ textarea.objective-editor-input { min-height: 5.4rem; resize: vertical; }
243
243
  .bb-objective-rail { display: block; }
244
244
  .bb-objective-rail > .bb-ws-notice { margin: var(--bb-space-4) 0 0; }
245
245
  .bb-objective-rail > .bb-ws-actions { padding-top: 22px; }
246
+ .bb-objective-rail > .bb-objective-revision-note { margin: var(--bb-space-4) 0 0; }
247
+ .bb-objective-rail > .bb-objective-revision-note + .bb-ws-actions { padding-top: var(--bb-space-3); }
246
248
  .bb-objective-rail .bb-ws-rail-block > p + p,
247
249
  .bb-objective-rail .bb-objective-note { margin-top: var(--bb-space-2); }
248
250
  .bb-objective-rail .bb-objective-note { color: var(--bb-color-muted); font-size: 12px; }
@@ -1121,6 +1121,7 @@ function renderObjectiveDetail(doc, state, callbacks) {
1121
1121
  parts.push(workspaceRailBlock(doc, { title: '制約の参照', className: 'bb-objective-constraints-block', content: constraintContent(doc, state, callbacks) }));
1122
1122
  parts.push(workspaceRailBlock(doc, { title: '判断に使えるか', className: 'bb-objective-readiness-block', content: readinessContent(doc, state.readiness) }));
1123
1123
  if (callbacks.canEdit) {
1124
+ parts.push(makeElement('p', { className: 'objective-editor-muted bb-objective-revision-note', text: '直して保存すると同じ目的の新しい版を作ります。保存結果を読み戻して確認し、確認できない場合はその状態を表示します。' }));
1124
1125
  parts.push(workspaceActions(doc, [{ text: '目的を直す', variant: 'primary', onClick: callbacks.onEdit }]));
1125
1126
  }
1126
1127
  return parts;