@unson/brainbase-mcp 0.41.11 → 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.
@@ -32,7 +32,7 @@ import { createDistrictView, DISTRICT_LEGEND } from './world-district.js';
32
32
  import { createWorldCanvasUI } from './world-canvas-ui.js';
33
33
  import { workCanvasNotes } from './world-canvas-notes.js';
34
34
  import { canvasTexture, drawFacade, FACADE_SIZES, FACADE_UNITS, gableRoof, hashUnit, muted, paintVertices, roundedPlate, valueNoise } from './world-scenery.js';
35
- import { WORK_STATES, workCityBlocks, workGrowthBlock, workPickBlock, workSign, workSiteBlocks } from './world-work-rail.js';
35
+ import { WORK_STATES, readReasonShort, workCityBlocks, workGrowthBlock, workPickBlock, workSign, workSignBlocks, workSigns, workSiteBlocks } from './world-work-rail.js';
36
36
  import { exitHrefKind, exitLegend, exitsRailBlock, normalizeBusinessExits } from './world-exits.js';
37
37
  import {
38
38
  makeWorkspaceElement as el,
@@ -197,7 +197,7 @@ const GAP_SHORT_TEXT = Object.freeze({
197
197
  review_overdue: '見直し超過',
198
198
  });
199
199
 
200
- function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear, onEscape }) {
200
+ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear, onEscape, onSign }) {
201
201
  const renderer = new THREE.WebGLRenderer({ antialias: true });
202
202
  renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio ?? 1, 2));
203
203
  renderer.shadowMap.enabled = true;
@@ -1113,18 +1113,30 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear,
1113
1113
  for (const post of citySigns.splice(0)) scene.remove(post);
1114
1114
  for (const [code, entry] of cityIndex) {
1115
1115
  const summary = summaries?.[code];
1116
- entry.label?.node.querySelector('.bb-world-badge')?.remove();
1117
1116
  if (entry.label) entry.label.size = null;
1117
+ entry.label?.node.querySelectorAll('.bb-world-badge').forEach((badge) => badge.remove());
1118
1118
  const signal = workSign(summary);
1119
1119
  if (!signal) continue;
1120
- const { text } = signal;
1121
1120
  const unreadable = signal.kind === 'unreadable';
1122
1121
  const { city } = entry;
1123
1122
  const post = sign(unreadable ? 'unreadable' : 'check', 2.2, 0.9);
1124
1123
  post.position.set(city.x - city.size / 2 + 0.6, 0.45, city.z + city.size / 2 - 0.6);
1125
1124
  scene.add(post);
1126
1125
  citySigns.push(post);
1127
- if (entry.label) entry.label.node.append(el(doc, 'span', { className: `bb-world-badge ${unreadable ? 'is-unreadable' : 'is-check'}`, text }));
1126
+ if (!entry.label) continue;
1127
+ // Each sign says what it means in a few words; pressing one opens why and what to do next
1128
+ // (story-world-sign-meaning-v1). The rail's entrances are the keyboard way to the same blocks.
1129
+ for (const item of workSigns(summary)) {
1130
+ const badge = el(doc, 'span', { className: `bb-world-badge ${item.kind === 'unreadable' ? 'is-unreadable' : 'is-check'}${onSign ? ' is-pressable' : ''}`, text: item.text });
1131
+ if (onSign) {
1132
+ badge.title = 'この札の意味と、次にすることを見る';
1133
+ badge.addEventListener('click', (event) => {
1134
+ event.stopPropagation();
1135
+ onSign(entry.label.business ?? null, code);
1136
+ });
1137
+ }
1138
+ entry.label.node.append(badge);
1139
+ }
1128
1140
  }
1129
1141
  }
1130
1142
 
@@ -1748,9 +1760,9 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
1748
1760
  if (!summary) return { text: workConnected === false ? '' : '仕事は区画で読みます', count: -1 };
1749
1761
  if (summary.state !== 'complete' && summary.state !== 'partial') return { text: '仕事を読めない(0件とは確認できません)', count: -1, muted: true };
1750
1762
  const needs = summary.needs_check?.length ?? 0;
1751
- const parts = [needs ? `要確認 ${needs}` : Number.isInteger(summary.open) ? `未完了 ${summary.open}` : '未完了の件数は未確認'];
1752
- if (summary.state === 'partial') parts.push('一部だけ読めた');
1753
- return { text: parts.join('・'), count: needs };
1763
+ const signs = workSigns(summary);
1764
+ const parts = [...(needs ? [] : [Number.isInteger(summary.open) ? `未完了 ${summary.open}` : '未完了の件数は未確認']), ...signs.map((sign) => sign.text)];
1765
+ return { text: parts.join('・'), count: needs, signs: signs.length > 0 };
1754
1766
  };
1755
1767
  const rows = knownBusinesses
1756
1768
  .map((business) => ({ business, note: noteOf(business) }))
@@ -1761,6 +1773,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
1761
1773
  const button = workspaceButton(doc, { text: `${business.name}の区画に入る ›`, onClick: () => enterFromRail(business) });
1762
1774
  item.append(button);
1763
1775
  if (note.text) item.append(el(doc, 'span', { className: `bb-world-entrance-note${note.count > 0 ? ' is-check' : ''}${note.muted ? ' is-muted' : ''}`, text: note.text }));
1776
+ if (note.signs) item.append(workspaceButton(doc, { text: '札の意味 ›', variant: 'quiet', onClick: () => showSignMeaning(business) }));
1764
1777
  list.append(item);
1765
1778
  }
1766
1779
  showRail([
@@ -1771,6 +1784,32 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
1771
1784
  // The businesses are not known yet here (the list is drawn again once the world is built).
1772
1785
  showRail([workspaceDetailEmpty(doc, { mark: '◇', title: '都市や建物を選ぶと、ここに出ます', text: 'ドラッグで移動、ホイールで寄る・離れる、右ドラッグで回転します。Escで全体に戻ります。' })]);
1773
1786
 
1787
+ /**
1788
+ * What a city's signs mean (story-world-sign-meaning-v1): pressing 要確認 or 一部だけ読めた over a city
1789
+ * opens, without entering it, which gaps the open work shows and how to correct them, and why the
1790
+ * work was read only in part. From there the district opens on one kind of gap.
1791
+ */
1792
+ function showSignMeaning(business) {
1793
+ if (!business) return;
1794
+ const summary = workSummaries[business.code];
1795
+ const blocks = workSignBlocks(doc, {
1796
+ summary,
1797
+ onEnter: () => enterFromRail(business),
1798
+ onSelectGap: (kind) => {
1799
+ enterFromRail(business);
1800
+ showFocus(business, { kind });
1801
+ },
1802
+ });
1803
+ if (!blocks.length) return;
1804
+ clearAll();
1805
+ showRail([
1806
+ workspaceRailHead(doc, { kicker: `${business.name}の札`, title: '札の意味と、次にすること', lead: '地図の札は、タスクの記録と、その読み込みの状態から出しています。' }),
1807
+ ...blocks,
1808
+ workspaceRailBlock(doc, { title: '', content: workspaceButton(doc, { text: '← 区画の入口に戻る', variant: 'quiet', onClick: () => showEntrances() }) }),
1809
+ ]);
1810
+ canvasUI?.openRail();
1811
+ }
1812
+
1774
1813
  /** The entrances of the districts, the ones with the most work to check first, in the opened rail. */
1775
1814
  function showEntrances() {
1776
1815
  if (current.level !== 'world') goToWorld();
@@ -2396,7 +2435,10 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
2396
2435
  if (!needsKnown) note('記録の確認件数', '仕事の要約に記録の要確認件数がありません。確認事項が0件とは確認できません。', 'warning', '記録の要確認件数:未確認');
2397
2436
  if (!openKnown) note('仕事の件数', '読めた仕事の要約に未完了件数の記録がありません。0件とは確認できません。', 'warning', '未完了の件数:未確認');
2398
2437
  if (unreadable) note('仕事の読込', `仕事を読めなかった事業${unreadable}件。0件とは確認できません。`, 'warning', `仕事が読めない事業 ${unreadable}件`);
2399
- if (partial) note('仕事の範囲', `一部だけ読めた事業${partial}件。件数は読めた範囲です。`, 'warning', `仕事が一部だけ読めた事業 ${partial}件`);
2438
+ if (partial) {
2439
+ const reasons = [...new Set(readable.filter((entry) => entry.state === 'partial').map((entry) => readReasonShort(entry.reason) ?? '理由の記録なし'))].join('・');
2440
+ note('仕事の範囲', `一部だけ読めた事業${partial}件(${reasons})。件数は読めた範囲です。区画の入口で、事業ごとの理由と次にすることを見られます。`, 'warning', `仕事が一部だけ読めた事業 ${partial}件(${reasons})`, null, { action: { label: '理由を見る', onClick: showEntrances } });
2441
+ }
2400
2442
  if (needs) note('仕事の記録', `読めた未完了の仕事${needs}件(${cities}事業)に、記録から把握できていないことがあります。仕事の失敗や停止を意味しません。`, 'attention', `記録の要確認 ${needs}件・${cities}事業`, null, { action: { label: '区画を選ぶ', onClick: showEntrances } });
2401
2443
  }
2402
2444
  }
@@ -2627,7 +2669,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
2627
2669
  for (const business of exitsEnabled ? businesses : []) loadExits(business);
2628
2670
  return;
2629
2671
  }
2630
- scene = createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear: clearAll, onEscape: goUp });
2672
+ scene = createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear: clearAll, onEscape: goUp, onSign: (business, code) => showSignMeaning(business ?? knownBusinesses.find((entry) => entry.code === code) ?? null) });
2631
2673
  scene.setSky(SKY_MODES[skyIndex][0]);
2632
2674
  scene.buildPlaza(rows, rowItems, (row) => (rowItems.get(row.key) ?? []).filter((ref) => isWaiting(proofs.get(ref.decision_attempt_id))).length, Date.now(), { state: judgmentsUnavailable ? judgmentsUnavailable.title.replace('判断の記録:', '') : null });
2633
2675
  scene.buildCities(layoutWorld(businesses), placement.byBusiness);
@@ -54,6 +54,47 @@ export function readReasonText(reason) {
54
54
  return String(reason).split(',').map((part) => READ_REASON_TEXT[part] ?? part).join('。');
55
55
  }
56
56
 
57
+ /** The same reasons in a few words, for a sign over a city. A reason not listed here gets no words (none is guessed). */
58
+ const READ_REASON_SHORT = Object.freeze({
59
+ codes_not_permitted: '権限外の案件あり',
60
+ page_limit_reached: '件数が上限',
61
+ limit_reached: '件数が上限',
62
+ upstream_timeout: '応答なし',
63
+ upstream_request_failed: '接続できない',
64
+ });
65
+
66
+ export function readReasonShort(reason) {
67
+ if (!reason) return null;
68
+ const words = [...new Set(String(reason).split(',').map((part) => READ_REASON_SHORT[part]).filter(Boolean))];
69
+ return words.length ? words.join('・') : null;
70
+ }
71
+
72
+ /** What makes a read whole again, for each reason: where to go, or that nothing needs doing. */
73
+ const READ_REASON_NEXT = Object.freeze({
74
+ codes_not_permitted: '読むには、その案件のタスクを見る権限が要ります。権限は組織の管理者が付けます。',
75
+ page_limit_reached: '直す操作はありません。件数と区画の中の仕事は、読めた範囲だけです。',
76
+ limit_reached: '直す操作はありません。件数と区画の中の仕事は、読めた範囲だけです。',
77
+ upstream_timeout: '時間をおいて画面を開き直すと、もう一度読みます。',
78
+ upstream_request_failed: '時間をおいて画面を開き直すと、もう一度読みます。',
79
+ });
80
+
81
+ /** What to correct in the task record for each kind of gap (the same words as the task screen). */
82
+ const GAP_FIX_TEXT = Object.freeze({
83
+ assignee_unlinked_mentioned: '本文に書かれた人を、タスクの担当欄に登録する',
84
+ assignee_unrecorded: 'タスクの担当欄に、担当する人を登録する',
85
+ outcome_unlinked: '完了条件にある成果物の場所を、タスクの出典リンクに足す',
86
+ source_unlinked: '元になった議事録・Slack・資料のリンクを、タスクに足す',
87
+ review_overdue: '今の状態を確かめて、タスクの状態か見直し予定を更新する',
88
+ });
89
+
90
+ const GAP_TEXT = Object.freeze({
91
+ assignee_unlinked_mentioned: '担当欄に未接続(本文に人物あり)',
92
+ assignee_unrecorded: '担当の記録なし',
93
+ outcome_unlinked: '成果物の記録が未接続',
94
+ source_unlinked: '出典リンクなし',
95
+ review_overdue: '見直し予定を過ぎた',
96
+ });
97
+
57
98
  const WORK_UNAVAILABLE_TEXT = Object.freeze({
58
99
  task_store_not_connected: 'この画面はタスクの正本に接続していないため、都市の中の仕事は描いていません。仕事の件数は未確認です。0件とは確認できません。',
59
100
  });
@@ -82,24 +123,88 @@ export function shortTime(value) {
82
123
  }
83
124
 
84
125
  /**
85
- * What a city's sign says, and its kind: amber when open work shows a gap, grey when the work could not
86
- * be read or was read only in part (an unknown city is never shown as one without problems).
126
+ * The signs over a city, each with its meaning in a few words: amber when open work shows a gap in its
127
+ * record, grey when the work could not be read or was read only in part, with the recorded reason (an
128
+ * unknown city is never shown as one without problems).
87
129
  */
88
- export function workSign(summary) {
89
- if (!summary || summary.state === 'not_connected') return null;
130
+ export function workSigns(summary) {
131
+ if (!summary || summary.state === 'not_connected') return [];
132
+ const reason = readReasonShort(summary.reason);
90
133
  if (summary.state === 'complete' || summary.state === 'partial') {
134
+ const signs = [];
91
135
  const count = summary.needs_check?.length ?? 0;
92
- const part = summary.state === 'partial' ? '(一部だけ読めた)' : '';
93
- if (count > 0) return { kind: 'check', text: `要確認 ${count}${part}` };
94
- return summary.state === 'partial' ? { kind: 'unreadable', text: '一部だけ読めた' } : null;
136
+ if (count > 0) signs.push({ kind: 'check', text: `要確認 ${count}:記録に抜け` });
137
+ if (summary.state === 'partial') signs.push({ kind: 'unreadable', text: `一部だけ読めた${reason ? `:${reason}` : ''}` });
138
+ return signs;
95
139
  }
96
- return { kind: 'unreadable', text: '仕事を読めない' };
140
+ return [{ kind: 'unreadable', text: `仕事を読めない${summary.state === 'forbidden' ? ':権限がない' : reason ? `:${reason}` : ''}` }];
141
+ }
142
+
143
+ /** The pole over a city: amber when any open work shows a gap, else grey; its text is all its signs. */
144
+ export function workSign(summary) {
145
+ const signs = workSigns(summary);
146
+ if (!signs.length) return null;
147
+ return { kind: signs.some((sign) => sign.kind === 'check') ? 'check' : 'unreadable', text: signs.map((sign) => sign.text).join('・') };
97
148
  }
98
149
 
99
150
  export function workSignText(summary) {
100
151
  return workSign(summary)?.text ?? null;
101
152
  }
102
153
 
154
+ /**
155
+ * What a city's signs mean, opened by pressing one (story-world-sign-meaning-v1): for 要確認, how many
156
+ * open tasks show which gap and what to correct in the task record; for 一部だけ読めた or 仕事を読めない,
157
+ * the reason the host recorded and what would make the read whole. Every sentence is drawn from the
158
+ * summary; a reason the host did not give is said to be unrecorded, not guessed.
159
+ */
160
+ export function workSignBlocks(doc, { summary, onSelectGap, onEnter }) {
161
+ const blocks = [];
162
+ if (!summary) return blocks;
163
+ const readable = summary.state === 'complete' || summary.state === 'partial';
164
+ const needs = readable ? summary.needs_check?.length ?? 0 : 0;
165
+ if (needs > 0) {
166
+ const list = el(doc, 'ul', { className: 'bb-world-rail-list is-plain bb-world-sign-gaps' });
167
+ const kinds = Object.entries(summary.gaps ?? {}).filter(([, ids]) => ids?.length).sort((a, b) => b[1].length - a[1].length);
168
+ for (const [kind, ids] of kinds) {
169
+ const li = el(doc, 'li');
170
+ const button = el(doc, 'button', { className: 'bb-world-rail-pick is-gap', text: `${GAP_TEXT[kind] ?? kind} ${ids.length}件 ›`, attrs: { type: 'button', disabled: !onSelectGap } });
171
+ button.addEventListener('click', () => onSelectGap?.(kind));
172
+ li.append(button);
173
+ if (GAP_FIX_TEXT[kind]) li.append(el(doc, 'small', { className: 'bb-world-rail-note', text: `直し方:${GAP_FIX_TEXT[kind]}` }));
174
+ list.append(li);
175
+ }
176
+ blocks.push(workspaceRailBlock(doc, {
177
+ title: `要確認 ${needs}件:記録に抜けがある仕事`,
178
+ className: 'bb-world-sign-meaning',
179
+ content: [
180
+ { text: `読めた未完了の仕事${Number.isInteger(summary.open) ? `${summary.open}件` : ''}のうち${needs}件に、タスクの記録から分からないことがあります。仕事が止まっている・遅れているという意味ではありません。` },
181
+ kinds.length ? list : null,
182
+ { text: '直すのはタスクの記録です。種類を選ぶと区画に入り、該当する仕事が並びます。1件ずつ開くと、確かめることと、タスク画面への行き先が出ます。直すと、次に読んだときに札が減ります。', className: 'bb-world-rail-caveat is-quiet' },
183
+ ],
184
+ }));
185
+ }
186
+ if (summary.state !== 'complete') {
187
+ const reason = readReasonText(summary.reason);
188
+ const parts = String(summary.reason ?? '').split(',').filter(Boolean);
189
+ const next = [...new Set(parts.map((part) => READ_REASON_NEXT[part]).filter(Boolean))];
190
+ const what = { partial: '一部だけ読めました', failed: '読めませんでした', forbidden: '権限が無く読めませんでした' }[summary.state] ?? `読めませんでした(${summary.state})`;
191
+ const at = shortTime(summary.read_at);
192
+ const short = readReasonShort(summary.reason) ?? (summary.state === 'forbidden' ? '権限がない' : null);
193
+ blocks.push(workspaceRailBlock(doc, {
194
+ title: `${summary.state === 'partial' ? '一部だけ読めた' : '仕事を読めない'}${short ? `:${short}` : ''}`,
195
+ className: 'bb-world-sign-meaning',
196
+ content: workspaceDefinition(doc, [
197
+ ['読み込み', `この事業の仕事を${what}${at ? `(${at})` : ''}`],
198
+ ['理由', reason ?? { text: '理由は記録されていません', className: 'is-unrecorded' }],
199
+ ['件数', summary.state === 'partial' ? '読めた範囲です。読めていない仕事は0件とは限りません。' : '未確認です。0件とは確認できません。'],
200
+ ['次にすること', next.length ? next.join('') : { text: '記録された理由からは分かりません', className: 'is-unrecorded' }],
201
+ ]),
202
+ }));
203
+ }
204
+ if (blocks.length && onEnter) blocks.push(workspaceRailBlock(doc, { title: '', content: workspaceButton(doc, { text: '区画に入って仕事を見る ›', onClick: onEnter }) }));
205
+ return blocks;
206
+ }
207
+
103
208
  function linkLine(doc, link, label, extra) {
104
209
  const li = el(doc, 'li', { className: `bb-world-link is-${link}` });
105
210
  li.append(el(doc, 'span', { className: 'bb-world-link-mark', text: LINK_MARKS[link]?.mark ?? '・', attrs: { 'aria-label': LINK_MARKS[link]?.text ?? link } }));
@@ -176,6 +281,7 @@ function workStateBlocks(doc, { work, summary, onSelectGap, onSelectStatus }) {
176
281
  const button = el(doc, 'button', { className: 'bb-world-rail-pick is-gap', text: `${label} ${taskIds.length}件`, attrs: { type: 'button', disabled: !onSelectGap } });
177
282
  button.addEventListener('click', () => onSelectGap?.(kind));
178
283
  li.append(button);
284
+ if (GAP_FIX_TEXT[kind]) li.append(el(doc, 'small', { className: 'bb-world-rail-note', text: `直し方:${GAP_FIX_TEXT[kind]}` }));
179
285
  gapList.append(li);
180
286
  }
181
287
  blocks.push(workspaceRailBlock(doc, {