@unson/brainbase-mcp 0.41.10 → 0.41.13

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.
package/README.md CHANGED
@@ -105,7 +105,7 @@ brainbase onboard:candidates
105
105
 
106
106
  ### Google Workspace local-first adopter
107
107
 
108
- 常時稼働のMac mini、Google Workspace、補助Gmail、許可したDrive/local folder、Calendar/notesに散在するタスクを対象にする例です。全Driveやhome directory全体は走査しません。
108
+ 常時稼働のローカルホスト(例: Mac mini)、Google Workspace、必要なら補助Gmail、許可したDrive/local folder、Calendar/notesに散在するタスクを想定した例です。使わないタスクツール(例: Notion)は`--inactive-task-tool`で非アクティブ扱いにできます。全Driveやhome directory全体は走査しません。
109
109
 
110
110
  ```bash
111
111
  brainbase onboard:plan --profile google-workspace-local --host mac-mini --email google-workspace --secondary-email gmail --calendar google-calendar --drive google-drive --drive-folder "<folder-id>" --local-folder "/Users/owner/Notes" --tasks scattered-calendar-notes --inactive-task-tool notion
@@ -25,8 +25,8 @@
25
25
  },
26
26
  {
27
27
  "path": "README.md",
28
- "sha256": "55fc2b9c7945b81cfe3b7971bc1d067f8cd45d057fbf23dcf3dfae175838d1d3"
28
+ "sha256": "26eacd2a057380b4fd386b2176bd6e790a41c0bb8d684857c53bb5cd947e2d28"
29
29
  }
30
30
  ],
31
- "digest": "89b20a5aa122e73e9d2f7c99e1b029cb12b101b95574ee4560ce023ec3197304"
31
+ "digest": "38bf5aff53993b1ee737071ca7952d44e36ac2e5ed3a7adcac65fdd86e8954af"
32
32
  }
@@ -793,6 +793,79 @@ function groupFor(groups, sessionRef, key) {
793
793
  groups.set(groupKey, group);
794
794
  return group;
795
795
  }
796
+ const FRAME_USE_ROLES = new Set(['constraint', 'criterion', 'prediction']);
797
+ const FRAME_USE_VERDICTS = new Set(['satisfied', 'violated', 'tension']);
798
+ const FRAME_PREDICTION_CHECKS = new Set(['held', 'failed', 'unchecked']);
799
+ /**
800
+ * The usage value shared by every writer of judgment history references
801
+ * (docs/specs/judgment-history-frame-basis.md): the role in the chosen option,
802
+ * then the constraint verdict or the prediction check.
803
+ */
804
+ function frameUseUsage(use) {
805
+ const role = text(use.role);
806
+ if (!role || !FRAME_USE_ROLES.has(role))
807
+ return null;
808
+ if (role === 'prediction') {
809
+ const check = isObject(use.check) ? text(use.check.status) : null;
810
+ return `prediction:${check && FRAME_PREDICTION_CHECKS.has(check) ? check : 'unchecked'}`;
811
+ }
812
+ const verdict = text(use.verdict);
813
+ if (role === 'constraint')
814
+ return verdict && FRAME_USE_VERDICTS.has(verdict) ? `constraint:${verdict}` : null;
815
+ return verdict && FRAME_USE_VERDICTS.has(verdict) ? `criterion:${verdict}` : 'criterion';
816
+ }
817
+ /** Carry how the chosen option used each selected reference; rejected options never leak in. */
818
+ function frameReferencesWithUsage(frame) {
819
+ const references = frame.selected_references;
820
+ if (!Array.isArray(references))
821
+ return references;
822
+ const chosenLabel = text(frame.chosen_option);
823
+ const chosen = Array.isArray(frame.alternatives)
824
+ ? frame.alternatives.find((option) => isObject(option) && chosenLabel !== null && option.label === chosenLabel)
825
+ : null;
826
+ const uses = isObject(chosen) && Array.isArray(chosen.uses) ? chosen.uses.filter(isObject) : [];
827
+ return references.map((reference) => {
828
+ if (!isObject(reference))
829
+ return reference;
830
+ const ref = text(reference.ref);
831
+ const use = ref ? uses.find((candidate) => text(candidate.ref) === ref) : undefined;
832
+ const usage = use ? frameUseUsage(use) : null;
833
+ return usage && reference.usage === undefined ? { ...reference, usage } : reference;
834
+ });
835
+ }
836
+ /**
837
+ * Personal KG records the owner's answer applied in this turn. Only resolved
838
+ * answers count; their content never enters the history record.
839
+ */
840
+ function personalKnowledgeReferences(events) {
841
+ const references = [];
842
+ const seen = new Set();
843
+ for (const event of events) {
844
+ if (event.success !== true || text(event.event_kind) !== 'personal_answer')
845
+ continue;
846
+ const metadata = isObject(event.safe_metadata) ? event.safe_metadata : null;
847
+ const answer = metadata && isObject(metadata.personal_answer) ? metadata.personal_answer : null;
848
+ if (!answer || text(answer.status) !== 'resolved' || !Array.isArray(answer.reference_ids))
849
+ continue;
850
+ for (const value of answer.reference_ids) {
851
+ const ref = safeId(value);
852
+ if (!ref || seen.has(ref))
853
+ continue;
854
+ seen.add(ref);
855
+ references.push({ ref, kind: 'personal_knowledge', version: null, digest: null, why: null, usage: 'applied', availability: 'unknown' });
856
+ }
857
+ }
858
+ return references;
859
+ }
860
+ function withPersonalKnowledge(judgment, events) {
861
+ const personal = personalKnowledgeReferences(events);
862
+ if (personal.length === 0)
863
+ return judgment;
864
+ const existing = judgment.selected_references ?? [];
865
+ const known = new Set(existing.map((reference) => `${reference.kind ?? ''}\u0000${reference.ref}`));
866
+ const added = personal.filter((reference) => !known.has(`${reference.kind}\u0000${reference.ref}`));
867
+ return added.length === 0 ? judgment : { ...judgment, selected_references: [...existing, ...added] };
868
+ }
796
869
  function sourceBlocks(value) {
797
870
  if (!value)
798
871
  return [];
@@ -808,7 +881,7 @@ function sourceBlocks(value) {
808
881
  const frame = isObject(metadata.judgment_frame) ? metadata.judgment_frame : null;
809
882
  if (frame)
810
883
  blocks.push({
811
- selected_references: frame.selected_references,
884
+ selected_references: frameReferencesWithUsage(frame),
812
885
  alternatives: Array.isArray(frame.alternatives) ? frame.alternatives.map((option) => isObject(option) ? {
813
886
  label: option.label,
814
887
  evaluation: null,
@@ -1018,7 +1091,7 @@ function normalizeFinalRecord(final, sessionRef, key, configuredEntrypoint, turn
1018
1091
  recorded_at: recordedAt,
1019
1092
  project_code: projectCode,
1020
1093
  turn_ref: `${sessionRef}/${sha256(turnId)}`,
1021
- judgment: mergeJudgment(judgment, blocks, field(blocks[0] ?? null, 'missing_fields')),
1094
+ judgment: withPersonalKnowledge(mergeJudgment(judgment, blocks, field(blocks[0] ?? null, 'missing_fields')), events),
1022
1095
  execution: mergeExecution({ ...execution, status: finalExecutionStatus }, [...executionBlocks(final), ...events.flatMap((event) => executionBlocks(event)).reverse()], completed)
1023
1096
  };
1024
1097
  return { ...baseRecord, missing_fields: missingFields(baseRecord, firstPresent(blocks, 'missing_fields')) };
@@ -1034,7 +1107,7 @@ function mergeRecordWithFinal(adoptionRecord, final, sessionRef, key, turnInput,
1034
1107
  recorded_at: projected.recorded_at,
1035
1108
  project_code: adoptionRecord.project_code ?? projected.project_code,
1036
1109
  entrypoint: adoptionRecord.entrypoint === 'unknown' ? projected.entrypoint : adoptionRecord.entrypoint,
1037
- judgment: mergeJudgment(adoptionRecord.judgment, blocks, undefined),
1110
+ judgment: withPersonalKnowledge(mergeJudgment(adoptionRecord.judgment, blocks, undefined), events),
1038
1111
  execution
1039
1112
  };
1040
1113
  return { ...baseRecord, missing_fields: missingFields(baseRecord, firstPresent(blocks, 'missing_fields')) };
@@ -52,7 +52,7 @@ export const LOCAL_WEB_JUDGMENT_HISTORY_PREFIX = JUDGMENT_HISTORY_HTTP_PREFIX;
52
52
  export const LOCAL_WEB_FOUNDATION_BODY_LIMIT_BYTES = 64 * 1024;
53
53
  export const LOCAL_WEB_PROVENANCE_SOURCE_ID = 'brainbase-local-web';
54
54
  const CANONICAL_FILES = ['graph.json', 'relationships.json', 'personal-kg.jsonl', 'decisions.jsonl'];
55
- const BASE_UI_FILES = ['brainbase-tokens.css', 'local-web-shell.js', 'local-web-shell.css', 'workspace-kit.js', 'workspace-kit.css', 'native-scrollbars.js', 'native-scrollbars.css'];
55
+ const BASE_UI_FILES = ['brainbase-tokens.css', 'local-web-shell.js', 'local-web-shell.css', 'workspace-kit.js', 'workspace-kit.css', 'native-scrollbars.js', 'native-scrollbars.css', 'area-nav.js', 'area-nav.css'];
56
56
  const CONTENT_TYPES = Object.freeze({
57
57
  '.js': 'text/javascript; charset=utf-8',
58
58
  '.css': 'text/css; charset=utf-8'
@@ -883,6 +883,8 @@ export function createGraphWebModule(context) {
883
883
  'project-graph-vendor.js',
884
884
  'graph-registry-view.js',
885
885
  'graph-registry-view.css',
886
+ 'graph-ontology-view.js',
887
+ 'graph-ontology-view.css',
886
888
  'people-and-companies-view.js',
887
889
  'people-and-companies-view.css'
888
890
  ],
@@ -938,10 +940,25 @@ const meta = (name) => document.querySelector(\`meta[name="\${name}"]\`)?.getAtt
938
940
  // Keep the query portion intact; the shell parses the Graph entity query and
939
941
  // only treats the entity id as a target of the Graph screen.
940
942
  const screenFromHash = () => location.hash.replace(/^#/, '') || undefined;
941
- const shell = createLocalWebShell({
943
+ // A screen that opened its own address (a record of 情報と関係) goes back with the
944
+ // browser's history, so 「一覧に戻る」 and the back button land on the same list.
945
+ // Each entry remembers how many records deep it is, which stays right when the
946
+ // owner also uses the browser's back and forward.
947
+ const depth = () => (Number.isInteger(history.state?.bbDepth) ? history.state.bbDepth : 0);
948
+ let shell = null;
949
+ const navigate = (target, { back = false } = {}) => {
950
+ if (back && depth() > 0) {
951
+ history.go(-depth());
952
+ return;
953
+ }
954
+ history.pushState({ bbDepth: back ? 0 : depth() + 1 }, '', \`#\${target}\`);
955
+ shell?.show(target);
956
+ };
957
+ shell = createLocalWebShell({
942
958
  root: document.getElementById('brainbase-local-web'),
943
959
  token: meta('brainbase-web-token'),
944
960
  initialScreen: screenFromHash(),
961
+ onNavigate: navigate,
945
962
  meetingMinutesStorage: ${JSON.stringify(meetingMinutesStorage ?? null)},
946
963
  meetingMinutesLineage: ${JSON.stringify(meetingMinutesLineage ?? null)},
947
964
  screens: ${leading ? `[${leading}, ...LOCAL_WEB_SCREENS${trailing ? `, ${trailing}` : ''}]` : '[...LOCAL_WEB_SCREENS, ...extensionScreens]'}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.41.10",
3
+ "version": "0.41.13",
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",
@@ -328,6 +328,8 @@
328
328
  "./ui/world-model-view.css": "./ui/world-model-view.css",
329
329
  "./ui/people-and-companies-view": "./ui/people-and-companies-view.js",
330
330
  "./ui/people-and-companies-view.css": "./ui/people-and-companies-view.css",
331
+ "./ui/area-nav": "./ui/area-nav.js",
332
+ "./ui/area-nav.css": "./ui/area-nav.css",
331
333
  "./ui/local-web-shell": "./ui/local-web-shell.js",
332
334
  "./ui/local-web-shell.css": "./ui/local-web-shell.css",
333
335
  "./ui/workspace-kit": "./ui/workspace-kit.js",
@@ -342,6 +344,8 @@
342
344
  "./ui/graph-projects-view.css": "./ui/graph-projects-view.css",
343
345
  "./ui/graph-registry-view": "./ui/graph-registry-view.js",
344
346
  "./ui/graph-registry-view.css": "./ui/graph-registry-view.css",
347
+ "./ui/graph-ontology-view": "./ui/graph-ontology-view.js",
348
+ "./ui/graph-ontology-view.css": "./ui/graph-ontology-view.css",
345
349
  "./ui/meeting-minutes-storage": "./ui/meeting-minutes-storage.js",
346
350
  "./ui/meeting-minutes-storage.css": "./ui/meeting-minutes-storage.css",
347
351
  "./ui/brainbase-tokens.css": "./ui/brainbase-tokens.css",
@@ -483,5 +487,5 @@
483
487
  "engines": {
484
488
  "node": ">=20"
485
489
  },
486
- "gitHead": "9ea5345d8da4b0cf825daf73610474dd8903a8f5"
490
+ "gitHead": "fcfea3958913012579029adf0bf785c45643c77b"
487
491
  }
@@ -0,0 +1,79 @@
1
+ /*
2
+ * Area navigation (ledger D10, stage 2): the area links on the left and the
3
+ * tabs of the current area above the page. Uses only the shared tokens in
4
+ * brainbase-tokens.css; a host gives the area links its own look through
5
+ * `linkClassName`.
6
+ */
7
+ .bb-area-nav {
8
+ display: grid;
9
+ gap: 2px;
10
+ }
11
+
12
+ .bb-area-link[hidden],
13
+ .bb-area-tabs[hidden] {
14
+ display: none;
15
+ }
16
+
17
+ .bb-area-link-label {
18
+ min-width: 0;
19
+ flex: 1 1 auto;
20
+ }
21
+
22
+ .bb-area-count {
23
+ min-width: 20px;
24
+ flex: 0 0 auto;
25
+ border-radius: 999px;
26
+ padding: 0 6px;
27
+ background: var(--bb-color-accent-soft);
28
+ color: var(--bb-color-accent);
29
+ font-size: 11px;
30
+ font-weight: 700;
31
+ line-height: 18px;
32
+ text-align: center;
33
+ }
34
+
35
+ .bb-area-tabs {
36
+ display: flex;
37
+ gap: var(--bb-space-1);
38
+ overflow-x: auto;
39
+ border-bottom: 1px solid var(--bb-color-border);
40
+ padding: 0 var(--bb-space-5);
41
+ background: var(--bb-color-paper);
42
+ scrollbar-width: none;
43
+ }
44
+
45
+ .bb-area-tab {
46
+ min-height: 44px;
47
+ display: inline-flex;
48
+ flex: 0 0 auto;
49
+ align-items: center;
50
+ gap: var(--bb-space-2);
51
+ border-bottom: 2px solid transparent;
52
+ padding: 0 var(--bb-space-3);
53
+ color: var(--bb-color-muted);
54
+ font-size: 13px;
55
+ font-weight: 620;
56
+ text-decoration: none;
57
+ white-space: nowrap;
58
+ }
59
+
60
+ .bb-area-tab:hover {
61
+ color: var(--bb-color-ink);
62
+ }
63
+
64
+ .bb-area-tab[aria-current="page"] {
65
+ border-bottom-color: var(--bb-color-accent);
66
+ color: var(--bb-color-accent);
67
+ }
68
+
69
+ .bb-area-tab:focus-visible,
70
+ .bb-area-link:focus-visible {
71
+ outline: 2px solid var(--bb-color-accent);
72
+ outline-offset: -2px;
73
+ }
74
+
75
+ @media (max-width: 760px) {
76
+ .bb-area-tabs {
77
+ padding: 0 var(--bb-space-4);
78
+ }
79
+ }
package/ui/area-nav.js ADDED
@@ -0,0 +1,249 @@
1
+ /*
2
+ * Area navigation shared by OSS Personal and the organization edition
3
+ * (brainbase-project ledger D10, stage 2).
4
+ *
5
+ * The left side lists only the areas; the screens of the current area are tabs
6
+ * above the page. An area link opens the screen last used in that area, which
7
+ * is remembered in the viewer's browser only (a display preference, not a fact
8
+ * of the organization). Every area link and tab is a link to a screen, so
9
+ * screen addresses never change. The part draws links only: showing and
10
+ * loading screens stays with the caller (`show` in, `onSelect` out).
11
+ */
12
+
13
+ export const AREA_NAV_CONTRACT_VERSION = 'brainbase.area-nav.v1';
14
+ export const AREA_NAV_MEMORY_KEY = 'brainbase.area-nav.last-screen.v1';
15
+
16
+ function makeElement(doc, tag, { className, text, attrs = {} } = {}) {
17
+ const element = doc.createElement(tag);
18
+ if (className) element.className = className;
19
+ if (text !== undefined && text !== null) element.textContent = String(text);
20
+ for (const [name, value] of Object.entries(attrs)) {
21
+ if (value === undefined || value === null || value === false) continue;
22
+ element.setAttribute(name, value === true ? '' : String(value));
23
+ }
24
+ return element;
25
+ }
26
+
27
+ /**
28
+ * The last screen per area, kept in the given storage (for example the
29
+ * browser's localStorage). A storage that is missing, full or blocked behaves
30
+ * as if nothing was remembered.
31
+ */
32
+ export function createAreaMemory(storage, key = AREA_NAV_MEMORY_KEY) {
33
+ const read = () => {
34
+ try {
35
+ const raw = storage?.getItem?.(key);
36
+ const value = raw ? JSON.parse(raw) : null;
37
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
38
+ } catch {
39
+ return {};
40
+ }
41
+ };
42
+ return Object.freeze({
43
+ get(areaId) {
44
+ const value = read()[areaId];
45
+ return typeof value === 'string' ? value : null;
46
+ },
47
+ set(areaId, screenId) {
48
+ try {
49
+ storage?.setItem?.(key, JSON.stringify({ ...read(), [areaId]: screenId }));
50
+ } catch {
51
+ // Not remembered; the area opens its default screen next time.
52
+ }
53
+ },
54
+ });
55
+ }
56
+
57
+ /** The browser's localStorage when it can be reached, otherwise null. */
58
+ export function browserAreaStorage() {
59
+ try {
60
+ return globalThis.localStorage ?? null;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * The screen an area link opens: the remembered screen if it is still shown
68
+ * there, else the area's default, else its first shown screen. External links
69
+ * (opened in another tab) are never a target.
70
+ */
71
+ export function resolveAreaTarget(area, { remembered = null, isShown = () => true } = {}) {
72
+ const candidates = area.screens.filter((screen) => !screen.external && isShown(screen.id)).map((screen) => screen.id);
73
+ if (remembered && candidates.includes(remembered)) return remembered;
74
+ if (area.defaultScreen && candidates.includes(area.defaultScreen)) return area.defaultScreen;
75
+ return candidates[0] ?? null;
76
+ }
77
+
78
+ /**
79
+ * @param {object} options
80
+ * @param {Document} options.document
81
+ * @param {Array<{id: string, label: string|null, defaultScreen?: string, screenIds: string[]}>} options.areas
82
+ * In menu order. A null label takes the label of the area's only screen.
83
+ * @param {Array<{id: string, label: string, href?: string, external?: boolean, hidden?: boolean}>} options.screens
84
+ * @param {{get(areaId: string): string|null, set(areaId: string, screenId: string): void}} [options.memory]
85
+ * @param {(screenId: string) => string} [options.hrefFor] Address of a screen (default `#<id>`).
86
+ * @param {(screenId: string, event: Event) => void} [options.onSelect]
87
+ * @param {(area: object) => Node|null} [options.iconFor] Icon in front of an area link.
88
+ * @param {string} [options.linkClassName] Extra class for the area links (the host's look).
89
+ */
90
+ export function createAreaNav({
91
+ document: doc,
92
+ areas: areaInput,
93
+ screens: screenInput,
94
+ memory = createAreaMemory(null),
95
+ hrefFor = (screenId) => `#${screenId}`,
96
+ onSelect,
97
+ iconFor,
98
+ linkClassName = '',
99
+ label = '画面',
100
+ } = {}) {
101
+ if (!doc || typeof doc.createElement !== 'function') throw new Error('document_unavailable');
102
+ const screens = new Map((screenInput ?? []).map((screen) => [screen.id, {
103
+ id: screen.id,
104
+ label: screen.label,
105
+ href: screen.href ?? null,
106
+ external: screen.external === true,
107
+ hidden: screen.hidden === true,
108
+ count: null,
109
+ waiting: false,
110
+ }]));
111
+ const areas = (areaInput ?? []).map((area) => ({
112
+ id: area.id,
113
+ label: area.label ?? null,
114
+ defaultScreen: area.defaultScreen ?? null,
115
+ screens: area.screenIds.map((id) => screens.get(id)).filter(Boolean),
116
+ })).filter((area) => area.screens.length > 0);
117
+ const areaByScreen = new Map();
118
+ for (const area of areas) for (const screen of area.screens) areaByScreen.set(screen.id, area);
119
+
120
+ let activeScreenId = null;
121
+ const isShown = (screenId) => screens.get(screenId)?.hidden === false;
122
+ const shownScreens = (area) => area.screens.filter((screen) => isShown(screen.id));
123
+ const targetFor = (areaId) => {
124
+ const area = areas.find((candidate) => candidate.id === areaId);
125
+ return area ? resolveAreaTarget(area, { remembered: memory.get(area.id), isShown }) : null;
126
+ };
127
+ const screenHref = (screen) => screen.href ?? hrefFor(screen.id);
128
+ const areaLabel = (area) => area.label ?? area.screens[0].label;
129
+
130
+ const countBadge = (count, ariaLabel) => makeElement(doc, 'span', {
131
+ className: 'bb-area-count',
132
+ text: count,
133
+ attrs: { 'aria-label': ariaLabel },
134
+ });
135
+
136
+ const nav = makeElement(doc, 'nav', { className: 'bb-area-nav', attrs: { 'aria-label': label, 'data-contract-version': AREA_NAV_CONTRACT_VERSION } });
137
+ const tabs = makeElement(doc, 'nav', { className: 'bb-area-tabs' });
138
+ tabs.hidden = true;
139
+ const areaLinks = new Map();
140
+
141
+ for (const area of areas) {
142
+ const link = makeElement(doc, 'a', {
143
+ className: ['bb-area-link', linkClassName].filter(Boolean).join(' '),
144
+ attrs: { 'data-area': area.id },
145
+ });
146
+ link.addEventListener('click', (event) => {
147
+ const target = targetFor(area.id);
148
+ if (target) onSelect?.(target, event);
149
+ });
150
+ areaLinks.set(area.id, link);
151
+ nav.append(link);
152
+ }
153
+
154
+ function renderAreaLink(area) {
155
+ const link = areaLinks.get(area.id);
156
+ const shown = shownScreens(area);
157
+ link.hidden = !shown.some((screen) => !screen.external);
158
+ const target = targetFor(area.id);
159
+ if (target) link.setAttribute('href', screenHref(screens.get(target)));
160
+ else link.removeAttribute('href');
161
+ if (areaByScreen.get(activeScreenId) === area) link.setAttribute('aria-current', 'page');
162
+ else link.removeAttribute('aria-current');
163
+ const waiting = shown.reduce((sum, screen) => sum + (screen.waiting && Number.isInteger(screen.count) ? screen.count : 0), 0);
164
+ const icon = iconFor?.(area) ?? null;
165
+ link.replaceChildren(...[
166
+ icon,
167
+ makeElement(doc, 'span', { className: 'bb-area-link-label', text: areaLabel(area) }),
168
+ waiting > 0 ? countBadge(waiting, `待っているもの${waiting}件`) : null,
169
+ ].filter(Boolean));
170
+ }
171
+
172
+ function renderTabs() {
173
+ const area = areaByScreen.get(activeScreenId) ?? null;
174
+ const shown = area ? shownScreens(area) : [];
175
+ if (!area || shown.length < 2) {
176
+ tabs.hidden = true;
177
+ tabs.replaceChildren();
178
+ tabs.removeAttribute('aria-label');
179
+ return;
180
+ }
181
+ tabs.hidden = false;
182
+ tabs.setAttribute('aria-label', `${areaLabel(area)}の画面`);
183
+ tabs.replaceChildren(...shown.map((screen) => {
184
+ const tab = makeElement(doc, 'a', {
185
+ className: 'bb-area-tab',
186
+ attrs: {
187
+ href: screenHref(screen),
188
+ 'data-screen-tab': screen.id,
189
+ 'aria-current': screen.id === activeScreenId ? 'page' : null,
190
+ target: screen.external ? '_blank' : null,
191
+ rel: screen.external ? 'noopener' : null,
192
+ },
193
+ });
194
+ tab.append(makeElement(doc, 'span', { text: screen.label }));
195
+ if (Number.isInteger(screen.count)) {
196
+ tab.append(countBadge(screen.count, screen.waiting ? `待っているもの${screen.count}件` : `${screen.count}件`));
197
+ }
198
+ if (!screen.external) tab.addEventListener('click', (event) => onSelect?.(screen.id, event));
199
+ return tab;
200
+ }));
201
+ }
202
+
203
+ function render() {
204
+ for (const area of areas) renderAreaLink(area);
205
+ renderTabs();
206
+ }
207
+
208
+ const controller = {
209
+ nav,
210
+ tabs,
211
+ get state() {
212
+ const area = areaByScreen.get(activeScreenId) ?? null;
213
+ return { activeScreenId, activeAreaId: area?.id ?? null };
214
+ },
215
+ areaOf(screenId) {
216
+ return areaByScreen.get(screenId)?.id ?? null;
217
+ },
218
+ targetFor,
219
+ /** Marks the shown screen; a screen in an area becomes that area's last screen. */
220
+ show(screenId) {
221
+ activeScreenId = screenId;
222
+ const area = areaByScreen.get(screenId);
223
+ const screen = screens.get(screenId);
224
+ if (area && screen && !screen.external && isShown(screenId)) memory.set(area.id, screenId);
225
+ render();
226
+ return controller;
227
+ },
228
+ setHidden(screenId, hidden) {
229
+ const screen = screens.get(screenId);
230
+ if (!screen) return controller;
231
+ screen.hidden = hidden === true;
232
+ render();
233
+ return controller;
234
+ },
235
+ /** `count` null removes it. `waiting` counts are added up on the area link too. */
236
+ setCount(screenId, count, { waiting = false } = {}) {
237
+ const screen = screens.get(screenId);
238
+ if (!screen) return controller;
239
+ screen.count = Number.isInteger(count) && count >= 0 ? count : null;
240
+ screen.waiting = waiting === true;
241
+ render();
242
+ return controller;
243
+ },
244
+ };
245
+ render();
246
+ return controller;
247
+ }
248
+
249
+ export default createAreaNav;
@@ -248,3 +248,12 @@
248
248
  .bb-fov-philosophy-card .bb-fov-original { border-top: 0; padding-top: 0; }
249
249
  .bb-fov-model-card .bb-fov-original { margin-top: 12px; padding-top: 10px; overflow-wrap: anywhere; }
250
250
  .bb-fov-objective-ledger .bb-ws-ledger-row:focus-visible { outline: 2px solid var(--bb-color-focus); outline-offset: 2px; }
251
+ /* Whose what an objective waits for, and what can be done with it (story-objective-row-actions-v1). */
252
+ .bb-fov-waiting { display: grid; gap: 2px; min-width: 0; }
253
+ .bb-fov-waiting strong { font-weight: 600; }
254
+ .bb-fov-waiting small { color: var(--bb-color-muted); font-size: 12px; line-height: 1.4; }
255
+ .bb-fov-ledger-hint { margin: 8px 0 0; color: var(--bb-color-muted); font-size: 12px; }
256
+ .bb-fov-actions { display: grid; gap: 8px; justify-items: start; padding: 12px; border: 1px solid var(--bb-color-border); border-radius: 8px; background: var(--bb-color-surface-subtle, rgb(0 0 0 / 2%)); }
257
+ .bb-fov-actions h3 { margin: 0; }
258
+ .bb-fov-action-note { margin: 0; color: var(--bb-color-muted); font-size: 13px; }
259
+ .bb-fov-action-note.is-unavailable { color: var(--bb-color-ink); }
@@ -507,13 +507,79 @@ function objectiveCard(doc, record, variableMap) {
507
507
  return card;
508
508
  }
509
509
 
510
- function objectiveReading(doc, record, variableMap, recordContexts) {
510
+ /**
511
+ * Whose what an objective waits for (story-objective-row-actions-v1), from its own record: the items its
512
+ * current state says were not confirmed when it was saved, or else the fields it has not recorded, and
513
+ * the accountable person who would settle them. Nothing is waited for when the record names nothing.
514
+ */
515
+ export function objectiveWaiting(record) {
516
+ const definition = recordDefinition(record);
517
+ const current = currentStateFor(record);
518
+ const accountable = nonEmpty(definition.accountableId);
519
+ const items = current.missing.length ? current.missing : missingObjectiveFields(record);
520
+ if (items.length === 0) return null;
521
+ const what = `${current.missing.length ? '未確認' : '未記録'}:${items.join('・')}`;
522
+ const who = accountable ? '責任者の確認待ち' : '責任者が未記録のため、確かめる人が決まっていません';
523
+ return { what, who, items, kind: current.missing.length ? 'unconfirmed' : 'unrecorded', accountableRecorded: Boolean(accountable) };
524
+ }
525
+
526
+ function waitingElement(doc, waiting) {
527
+ const cell = makeElement(doc, 'span', { className: 'bb-fov-waiting' });
528
+ if (!waiting) {
529
+ cell.append(makeElement(doc, 'span', { className: 'bb-fov-missing', text: '記録上はありません' }));
530
+ return cell;
531
+ }
532
+ cell.append(makeElement(doc, 'strong', { className: 'bb-fov-missing', text: waiting.what }), makeElement(doc, 'small', { text: waiting.who }));
533
+ return cell;
534
+ }
535
+
536
+ /**
537
+ * What the reader can do with this objective, from the host (`callbacks.objectiveAction`): a button that
538
+ * goes where it is corrected, or the reason it cannot be from here. Without a host action the screen says
539
+ * it only reads; it never shows a control that does nothing.
540
+ */
541
+ function objectiveActionElement(doc, record, recordContexts, callbacks, dialog) {
542
+ const definition = recordDefinition(record), details = detailsFor(record);
543
+ const part = makeElement(doc, 'section', { className: 'bb-fov-actions' });
544
+ part.append(makeElement(doc, 'h3', { text: 'この目的でできること' }));
545
+ const context = recordContexts?.get(record);
546
+ const action = typeof callbacks?.objectiveAction === 'function'
547
+ ? callbacks.objectiveAction({
548
+ objectiveId: definition.id ?? null,
549
+ revision: definition.revision ?? null,
550
+ title: formatAny(details.title) ?? formatAny(definition.meaning) ?? null,
551
+ scopeIds: context?.scopeIds ?? [],
552
+ record,
553
+ })
554
+ : null;
555
+ if (action && typeof action.onSelect === 'function') {
556
+ part.append(workspaceButton(doc, {
557
+ text: `${action.label ?? '目的を直す・現状を記録する'} ›`,
558
+ variant: 'primary',
559
+ onClick: () => { dialog()?.close(); action.onSelect(); },
560
+ }));
561
+ part.append(makeElement(doc, 'p', { className: 'bb-fov-action-note', text: action.description ?? '目指す状態・評価基準・現状を直し、新しい版として保存します。保存すると、この一覧の「待っていること」が変わります。' }));
562
+ } else {
563
+ part.append(makeElement(doc, 'p', {
564
+ className: 'bb-fov-action-note is-unavailable',
565
+ text: nonEmpty(action?.unavailable) ?? 'この画面は読むだけです。ここからは記録を変えられません。',
566
+ }));
567
+ }
568
+ return part;
569
+ }
570
+
571
+ function objectiveReading(doc, record, variableMap, recordContexts, callbacks = {}, dialog = () => null) {
511
572
  const definition = recordDefinition(record), details = detailsFor(record);
512
573
  const body = makeElement(doc, 'div', { className: 'bb-fov-reading' });
513
574
  const section = (title, content) => {
514
575
  const part = makeElement(doc, 'section');
515
576
  part.append(makeElement(doc, 'h3', { text: title }), content); body.append(part);
516
577
  };
578
+ const waiting = objectiveWaiting(record);
579
+ section('待っていること', waiting
580
+ ? workspaceDefinition(doc, [['何を', waiting.what], ['誰が', waiting.who]])
581
+ : makeElement(doc, 'p', { text: '記録上、待っていることはありません。達成・採用・検証済みを意味しません。' }));
582
+ body.append(objectiveActionElement(doc, record, recordContexts, callbacks, dialog));
517
583
  section('達成条件', workspaceDefinition(doc, [
518
584
  ['目指す状態', formatAny(definition.desiredState)],
519
585
  ['評価基準', criteriaElement(doc, definition, details, variableMap)],
@@ -560,18 +626,21 @@ function renderObjectives(doc, catalog, viewState, callbacks, recordContexts) {
560
626
  viewState.dialog?.close();
561
627
  viewState.dialog = workspaceDialog(doc, {
562
628
  title: formatAny(details.title) ?? formatAny(definition.meaning) ?? UNNAMED_RECORD, opener,
563
- content: objectiveReading(doc, record, recordContexts?.get(record)?.variableMap ?? variableMap, recordContexts),
629
+ content: objectiveReading(doc, record, recordContexts?.get(record)?.variableMap ?? variableMap, recordContexts, callbacks, () => viewState.dialog),
564
630
  });
565
631
  },
566
632
  cells: [
567
633
  { primary: formatAny(details.title) ?? formatAny(definition.meaning) ?? UNNAMED_RECORD },
568
634
  current.text ?? current.status ?? '未記録',
569
- { text: current.missing.length ? current.missing.join('・') : missingObjectiveFields(record).join('・') || '記録なし', className: 'bb-fov-missing' },
635
+ waitingElement(doc, objectiveWaiting(record)),
570
636
  formatDate(current.asOf),
571
637
  ],
572
638
  };
573
639
  });
574
- section.append(workspaceLedger(doc, { className: 'bb-fov-objective-ledger', ariaLabel: '登録された目的', columns: ['目的', '現状', '確認待ち', '確認日'], rows }));
640
+ section.append(workspaceLedger(doc, { className: 'bb-fov-objective-ledger', ariaLabel: '登録された目的', columns: ['目的', '現状', '待っていること', '確認日'], rows }));
641
+ section.append(makeElement(doc, 'p', { className: 'bb-fov-ledger-hint', text: typeof callbacks?.objectiveAction === 'function'
642
+ ? '行を押すと、目的の詳細と、この目的でできること(直す・現状を記録する)が開きます。'
643
+ : '行を押すと、目的の詳細が開きます。この画面は読むだけです。' }));
575
644
  return section;
576
645
  }
577
646
 
@@ -912,13 +981,14 @@ export function renderFoundationCollectionOverview(root, sources, {
912
981
  if (existing) {
913
982
  const context = contexts.get(existing);
914
983
  if (!context.sources.includes(source.label)) context.sources.push(source.label);
984
+ if (!context.scopeIds.includes(source.scopeId)) context.scopeIds.push(source.scopeId);
915
985
  context.label = `${shared ? '組織共通 / ' : ''}${context.sources.join('・')}`;
916
986
  continue;
917
987
  }
918
988
  const record = { ...original };
919
989
  seen.set(key, record);
920
990
  catalog[kind].push(record);
921
- contexts.set(record, { key, variableMap, sources: [source.label], label: `${shared ? '組織共通 / ' : ''}${source.label}` });
991
+ contexts.set(record, { key, variableMap, sources: [source.label], scopeIds: [source.scopeId], label: `${shared ? '組織共通 / ' : ''}${source.label}` });
922
992
  }
923
993
  }
924
994
  }