@unson/brainbase-mcp 0.20.0 → 0.21.1

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,337 @@
1
+ /*
2
+ * The right rail for the work inside a city (story-world-work-sites-and-gaps-v1).
3
+ *
4
+ * Builds the blocks from the host's `world-work.v1` answer, in the order a reader needs them: the
5
+ * facts as recorded (purpose, policies, the work's recorded state), then what we cannot see (gaps,
6
+ * each with its fact, what is unknown, its source and time, and what to check next), then people and
7
+ * relations, then where it all came from. Recorded, inferred and unreadable relations carry
8
+ * different marks. Nothing here writes; corrections go to the host's own task screen.
9
+ */
10
+
11
+ import {
12
+ makeWorkspaceElement as el,
13
+ workspaceRailBlock,
14
+ workspaceDefinition,
15
+ workspaceButton,
16
+ } from '../../workspace-kit.js';
17
+
18
+ export const WORLD_WORK_RAIL_CONTRACT_VERSION = 'brainbase.world-work-rail.v0';
19
+
20
+ /** Recorded work states, in the order the rail lists them; colours are the sites' walls. */
21
+ export const WORK_STATES = Object.freeze([
22
+ { key: 'waiting', label: '待ち', color: 0x7a6a9e },
23
+ { key: 'in_progress', label: '進行中', color: 0x2f7d86 },
24
+ { key: 'pending', label: '未着手', color: 0x8d979e },
25
+ { key: 'completed', label: '完了', color: 0x6f9a68 },
26
+ { key: 'cancelled', label: '取消', color: 0xb9b2a5 },
27
+ ]);
28
+
29
+ export const LINK_MARKS = Object.freeze({
30
+ recorded: { mark: '━', text: '記録された関係' },
31
+ inferred: { mark: '┅', text: '本文・名前からの推定' },
32
+ unreadable: { mark: '┄', text: '読めなかった' },
33
+ });
34
+
35
+ const READ_STATE_TEXT = Object.freeze({
36
+ complete: 'すべて読めた',
37
+ partial: '一部だけ読めた',
38
+ failed: '読めなかった',
39
+ forbidden: '権限が無く読めない',
40
+ not_connected: '未接続',
41
+ });
42
+
43
+ /** Why a read was partial or failed, in the words the rail shows; anything else is shown as given. */
44
+ const READ_REASON_TEXT = Object.freeze({
45
+ codes_not_permitted: '案件の一部は、あなたのタスクの権限の範囲外のため読んでいません',
46
+ page_limit_reached: '件数が多く、最初の200件だけを読みました',
47
+ limit_reached: '上限の500件まで読みました。ほかにもある可能性があります',
48
+ upstream_timeout: '時間内に応答がありませんでした',
49
+ upstream_request_failed: '接続できませんでした',
50
+ });
51
+
52
+ export function readReasonText(reason) {
53
+ if (!reason) return null;
54
+ return String(reason).split(',').map((part) => READ_REASON_TEXT[part] ?? part).join('。');
55
+ }
56
+
57
+ const WORK_UNAVAILABLE_TEXT = Object.freeze({
58
+ task_store_not_connected: 'この画面はタスクの正本に接続していないため、都市の中の仕事は描いていません',
59
+ });
60
+
61
+ function jst(value) {
62
+ const at = Date.parse(value ?? '');
63
+ if (!Number.isFinite(at)) return null;
64
+ return new Date(at + 9 * 60 * 60 * 1000);
65
+ }
66
+
67
+ /** 9/25 (Japan time); null stays null so the definition list says 未記録. */
68
+ export function shortDate(value) {
69
+ if (/^\d{4}-\d{2}-\d{2}$/u.test(value ?? '')) {
70
+ const [, month, day] = value.split('-');
71
+ return `${Number(month)}/${Number(day)}`;
72
+ }
73
+ const date = jst(value);
74
+ return date ? `${date.getUTCMonth() + 1}/${date.getUTCDate()}` : null;
75
+ }
76
+
77
+ export function shortTime(value) {
78
+ const date = jst(value);
79
+ if (!date) return null;
80
+ const pad = (number) => String(number).padStart(2, '0');
81
+ return `${date.getUTCMonth() + 1}/${date.getUTCDate()} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`;
82
+ }
83
+
84
+ /**
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).
87
+ */
88
+ export function workSign(summary) {
89
+ if (!summary || summary.state === 'not_connected') return null;
90
+ if (summary.state === 'complete' || summary.state === 'partial') {
91
+ 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;
95
+ }
96
+ return { kind: 'unreadable', text: '仕事を読めない' };
97
+ }
98
+
99
+ export function workSignText(summary) {
100
+ return workSign(summary)?.text ?? null;
101
+ }
102
+
103
+ function linkLine(doc, link, label, extra) {
104
+ const li = el(doc, 'li', { className: `bb-world-link is-${link}` });
105
+ li.append(el(doc, 'span', { className: 'bb-world-link-mark', text: LINK_MARKS[link]?.mark ?? '・', attrs: { 'aria-label': LINK_MARKS[link]?.text ?? link } }));
106
+ li.append(el(doc, 'span', { text: label }));
107
+ if (extra) li.append(el(doc, 'small', { className: 'bb-world-rail-note', text: extra }));
108
+ return li;
109
+ }
110
+
111
+ function sourceText(source, readAt) {
112
+ if (!source) return null;
113
+ const system = source.system === 'task_api' ? 'タスクの記録' : '組織のGraph';
114
+ const fields = source.fields?.length && !source.fields.includes('*') ? `の ${source.fields.join('・')}` : '';
115
+ const at = readAt ? `(${shortTime(readAt)}に読み込み)` : '';
116
+ return `${system}${fields}${at}`;
117
+ }
118
+
119
+ function readLine(what, read, at) {
120
+ const reason = read.state === 'complete' ? null : readReasonText(read.reason);
121
+ return `${what}・${READ_STATE_TEXT[read.state] ?? read.state}${at ? `(${at})` : ''}${reason ? `。${reason}` : ''}`;
122
+ }
123
+
124
+ /** The work as recorded, and what cannot be seen of it (only when the tasks were read). */
125
+ function workStateBlocks(doc, { work, summary, onSelectGap, onSelectStatus }) {
126
+ const blocks = [];
127
+ const states = el(doc, 'ul', { className: 'bb-world-rail-list is-plain' });
128
+ for (const state of WORK_STATES) {
129
+ const count = summary.by_status?.[state.key] ?? 0;
130
+ if (!count) continue;
131
+ const li = el(doc, 'li');
132
+ const open = ['waiting', 'in_progress', 'pending'].includes(state.key);
133
+ const button = el(doc, 'button', { className: 'bb-world-rail-pick', text: `${state.label} ${count}件`, attrs: { type: 'button', disabled: !open || !onSelectStatus } });
134
+ button.addEventListener('click', () => onSelectStatus?.(state.key));
135
+ li.append(el(doc, 'span', { className: `bb-world-swatch is-${state.key}`, attrs: { 'aria-hidden': 'true' } }), button);
136
+ states.append(li);
137
+ }
138
+ const reasons = new Map();
139
+ for (const site of work.sites) {
140
+ if (site.work.status !== 'waiting' || !site.work.waiting_on) continue;
141
+ reasons.set(site.work.waiting_on, (reasons.get(site.work.waiting_on) ?? 0) + 1);
142
+ }
143
+ const reasonList = el(doc, 'ul', { className: 'bb-world-rail-list' });
144
+ for (const [reason, count] of [...reasons].sort((a, b) => b[1] - a[1]).slice(0, 4)) {
145
+ reasonList.append(el(doc, 'li', { text: `${count}件:「${reason}」` }));
146
+ }
147
+ blocks.push(workspaceRailBlock(doc, {
148
+ title: `仕事(記録上の状態)未完了 ${summary.open}件`,
149
+ content: [
150
+ summary.total === 0
151
+ ? { text: summary.state === 'complete'
152
+ ? `この事業の仕事は0件です(${shortTime(summary.read_at) ?? '時点不明'}に読んだ実際の0件)。`
153
+ : `読めた範囲では0件です。${readReasonText(summary.reason) ?? '一部だけ読めました'}。読めていない分は0件とは限りません。` }
154
+ : states,
155
+ reasons.size ? el(doc, 'h4', { className: 'bb-world-rail-sub', text: '待ちの理由(記録の原文・多い順)' }) : null,
156
+ reasons.size ? reasonList : null,
157
+ { text: '状態はタスクの記録のままです。待ちの理由を読み替えて「止まっている」とは判断していません。', className: 'bb-world-rail-caveat is-quiet' },
158
+ ],
159
+ }));
160
+ // What we cannot see.
161
+ const gapList = el(doc, 'ul', { className: 'bb-world-rail-list is-plain' });
162
+ for (const [kind, taskIds] of Object.entries(summary.gaps ?? {})) {
163
+ const sample = work.sites.find((site) => site.gaps.some((gap) => gap.kind === kind));
164
+ const label = sample?.gaps.find((gap) => gap.kind === kind)?.label ?? kind;
165
+ const li = el(doc, 'li');
166
+ const button = el(doc, 'button', { className: 'bb-world-rail-pick is-gap', text: `${label} ${taskIds.length}件`, attrs: { type: 'button', disabled: !onSelectGap } });
167
+ button.addEventListener('click', () => onSelectGap?.(kind));
168
+ li.append(button);
169
+ gapList.append(li);
170
+ }
171
+ blocks.push(workspaceRailBlock(doc, {
172
+ title: `把握できていないこと(未完了${summary.open}件のうち${summary.needs_check.length}件)`,
173
+ className: 'bb-world-gaps',
174
+ content: summary.needs_check.length
175
+ ? [gapList, { text: '記録から分からないことの一覧です。仕事が止まっていることは意味しません。種類を選ぶと、該当する仕事と街の中の位置が出ます。', className: 'bb-world-rail-caveat is-quiet' }]
176
+ : { text: '未完了の仕事に、構造化された欄から分かる断絶はありません。本文の内容までは確かめていません。' },
177
+ }));
178
+ return blocks;
179
+ }
180
+
181
+ /** The blocks for a city whose work was read (or could not be). */
182
+ export function workCityBlocks(doc, { business, work, onSelectGap, onSelectStatus, onReload }) {
183
+ if (!work) return [workspaceRailBlock(doc, { title: '仕事', content: { text: '仕事を読み込んでいます…' } })];
184
+ if (work.status !== 'ok') {
185
+ const text = WORK_UNAVAILABLE_TEXT[work.reason] ?? `仕事の記録を読めません(${work.reason ?? work.status})。仕事が0件という意味ではありません。`;
186
+ const content = [{ text }];
187
+ if (onReload && work.status !== 'not_connected') content.push(workspaceButton(doc, { text: 'もう一度読む', onClick: onReload }));
188
+ return [workspaceRailBlock(doc, { title: '仕事', content })];
189
+ }
190
+ const blocks = [];
191
+ // 1. Purpose and policies (facts as recorded).
192
+ const policies = el(doc, 'ul', { className: 'bb-world-rail-list is-links' });
193
+ for (const policy of work.purpose.policies) {
194
+ policies.append(linkLine(doc, policy.link, policy.title, `${shortDate(policy.decided_at) ?? '日付なし'}の決定・${policy.basis}(${policy.decision_id})`));
195
+ }
196
+ blocks.push(workspaceRailBlock(doc, {
197
+ title: '目的と方針',
198
+ content: [
199
+ workspaceDefinition(doc, [
200
+ ['目的', work.purpose.text ?? { text: '目的の欄は未登録です', className: 'is-unrecorded' }],
201
+ ['概要', work.purpose.summary],
202
+ ]),
203
+ work.purpose.policies.length ? policies : { text: work.reads.decisions.state === 'complete' ? 'この事業に結び付く決定は見つかりません。' : `決定を${READ_STATE_TEXT[work.reads.decisions.state] ?? work.reads.decisions.state}ため、方針は分かりません。` },
204
+ ],
205
+ }));
206
+ // 2. The work as recorded.
207
+ const summary = work.summary;
208
+ if (summary.open === null) {
209
+ blocks.push(workspaceRailBlock(doc, {
210
+ title: '仕事',
211
+ content: [
212
+ { text: `仕事の記録を${READ_STATE_TEXT[summary.state] ?? summary.state}(${readReasonText(summary.reason) ?? '理由不明'})。仕事が0件という意味ではありません。` },
213
+ onReload ? workspaceButton(doc, { text: 'もう一度読む', onClick: onReload }) : null,
214
+ ],
215
+ }));
216
+ } else {
217
+ blocks.push(...workStateBlocks(doc, { work, summary, onSelectGap, onSelectStatus }));
218
+ }
219
+ // 4. People and relations.
220
+ const people = el(doc, 'ul', { className: 'bb-world-rail-list is-links' });
221
+ for (const person of work.people) {
222
+ people.append(linkLine(doc, person.link, person.name, `${person.roles.join('・')}${person.task_ids.length ? `(仕事${person.task_ids.length}件)` : ''}`));
223
+ }
224
+ // The project record's member field; participation recorded as Graph relations is listed below.
225
+ if (work.members_state === 'unregistered' && !work.relations.some((relation) => relation.kind === 'member_of' && relation.link === 'recorded')) {
226
+ people.append(linkLine(doc, 'recorded', 'メンバーは未登録です', 'プロジェクトの記録にもGraphの関係にも、メンバーの登録がありません'));
227
+ }
228
+ if (work.members_state === 'unreadable') people.append(linkLine(doc, 'unreadable', 'メンバーを読めませんでした', null));
229
+ const relations = el(doc, 'ul', { className: 'bb-world-rail-list is-links' });
230
+ for (const relation of work.relations.filter((entry) => entry.kind !== 'mentioned_person')) {
231
+ relations.append(linkLine(doc, relation.link, relation.label, relation.basis));
232
+ }
233
+ const legend = el(doc, 'p', { className: 'bb-world-rail-note' });
234
+ legend.textContent = Object.values(LINK_MARKS).map((entry) => `${entry.mark} ${entry.text}`).join(' ');
235
+ blocks.push(workspaceRailBlock(doc, { title: '人と関係', content: [people, relations, legend] }));
236
+ // 5. Sources and times.
237
+ blocks.push(workspaceRailBlock(doc, {
238
+ title: '出典と時点',
239
+ content: [
240
+ workspaceDefinition(doc, [
241
+ ['仕事', readLine(`タスクの記録 ${work.reads.tasks.count ?? '—'}件`, work.reads.tasks, shortTime(work.reads.tasks.read_at) ?? '時点不明')],
242
+ ['人物', readLine('組織のGraph', work.reads.persons)],
243
+ ['決定', readLine('組織のGraph', work.reads.decisions)],
244
+ ['関係', readLine('組織のGraph', work.reads.relations)],
245
+ ]),
246
+ onReload ? workspaceButton(doc, { text: 'もう一度読む', onClick: onReload }) : null,
247
+ ],
248
+ }));
249
+ return blocks;
250
+ }
251
+
252
+ /** The open tasks behind one count, to pick from. */
253
+ export function workPickBlock(doc, { title, sites, onSelectSite, note }) {
254
+ const ul = el(doc, 'ul', { className: 'bb-world-rail-list is-plain' });
255
+ for (const site of sites) {
256
+ const li = el(doc, 'li');
257
+ const button = el(doc, 'button', { className: 'bb-world-rail-pick', text: site.title, attrs: { type: 'button' } });
258
+ button.addEventListener('click', () => onSelectSite(site.task_id));
259
+ li.append(button, el(doc, 'small', { className: 'bb-world-rail-note', text: `記録上:${site.work.label}${site.work.due_at ? `・期限 ${shortDate(site.work.due_at)}` : ''}・断絶${site.gaps.length}種` }));
260
+ ul.append(li);
261
+ }
262
+ return workspaceRailBlock(doc, { title, className: 'bb-world-pick', content: [note ? { text: note, className: 'bb-world-rail-note' } : null, sites.length ? ul : { text: '該当する仕事はありません' }] });
263
+ }
264
+
265
+ /** The blocks for one work site. */
266
+ export function workSiteBlocks(doc, { business, site, readAt, taskHref }) {
267
+ const blocks = [];
268
+ const due = site.work.due_at ? `${shortDate(site.work.due_at)}${site.work.past_due ? '(記録上の期限を過ぎて未完了)' : ''}` : null;
269
+ blocks.push(workspaceRailBlock(doc, {
270
+ title: '業務の記録(記録上の状態)',
271
+ content: workspaceDefinition(doc, [
272
+ ['状態', site.work.label],
273
+ ['待ちの理由(原文)', site.work.status === 'waiting' ? site.work.waiting_on : null],
274
+ ['期限', due],
275
+ ['見直し予定', shortDate(site.work.review_at)],
276
+ ['最終更新', shortTime(site.work.updated_at)],
277
+ ['作成', shortTime(site.work.created_at)],
278
+ ...(site.other_business_codes.length ? [['ほかの事業', `${site.other_business_codes.join('・')} にもまたがる`]] : []),
279
+ ]),
280
+ }));
281
+ if (site.gaps.length) {
282
+ const content = [];
283
+ for (const gap of site.gaps) {
284
+ const section = el(doc, 'div', { className: `bb-world-gap is-${gap.kind}` });
285
+ section.append(el(doc, 'h4', { text: gap.label }));
286
+ section.append(workspaceDefinition(doc, [
287
+ ['分かっている事実', gap.fact],
288
+ ['未接続・未確認', gap.unknown],
289
+ ['出典と確認時点', `${sourceText(gap.source, readAt)}。確認時点:${gap.checked_at ? `${shortDate(gap.checked_at)}(${gap.checked_at_basis === 'text_note' ? '本文の確認メモ' : '記録の最終更新'})` : '不明'}`],
290
+ ['次に確認すること', gap.next_check],
291
+ ['確認先', gap.check_with ? gap.check_with.name : { text: '記録からは分かりません', className: 'is-unrecorded' }],
292
+ ]));
293
+ content.push(section);
294
+ }
295
+ blocks.push(workspaceRailBlock(doc, { title: `把握できていないこと(${site.gaps.length})`, className: 'bb-world-gaps', content }));
296
+ } else {
297
+ blocks.push(workspaceRailBlock(doc, { title: '把握できていないこと', content: { text: '構造化された欄から分かる断絶はありません。' } }));
298
+ }
299
+ const people = el(doc, 'ul', { className: 'bb-world-rail-list is-links' });
300
+ for (const person of site.people) {
301
+ const how = person.link === 'recorded' ? '担当欄' : `${person.stated_as_assignee ? '本文が担当として記載' : '本文に記載'}(${person.basis === 'text_person_id' ? '人物IDつき' : '氏名の一致'})・担当欄には未接続`;
302
+ people.append(linkLine(doc, person.link, person.name, how));
303
+ }
304
+ for (const mention of site.ambiguous_mentions ?? []) {
305
+ people.append(linkLine(doc, 'inferred', `「${mention.text}」`, `組織のGraphの${mention.candidates.map((candidate) => candidate.name).join('・')}のどれか一意に決まりません`));
306
+ }
307
+ blocks.push(workspaceRailBlock(doc, { title: '担当・関係者', content: site.people.length || site.ambiguous_mentions?.length ? people : { text: '担当欄も本文も、登録された人物を挙げていません。' } }));
308
+ const refs = el(doc, 'ul', { className: 'bb-world-rail-list' });
309
+ for (const ref of site.source_refs) {
310
+ const li = el(doc, 'li');
311
+ if (ref.url && /^https:\/\//u.test(ref.url)) li.append(el(doc, 'a', { className: 'bb-world-rail-link', text: ref.label, attrs: { href: ref.url, target: '_blank', rel: 'noopener noreferrer' } }));
312
+ else li.append(el(doc, 'span', { text: ref.label }));
313
+ refs.append(li);
314
+ }
315
+ blocks.push(workspaceRailBlock(doc, {
316
+ title: '成果物・出典',
317
+ content: [
318
+ site.expected_outcome ? workspaceDefinition(doc, [['完了条件(本文)', site.expected_outcome]]) : null,
319
+ site.source_refs.length ? refs : { text: '出典・関連記録へのリンクは0件です。' },
320
+ { text: 'タスクの記録には成果物や先方合意を記録する欄がありません。作成・提示・合意は、リンクか本文でしか分かりません。', className: 'bb-world-rail-note' },
321
+ ],
322
+ }));
323
+ if (site.description) {
324
+ const details = el(doc, 'details', { className: 'bb-world-original' });
325
+ details.append(el(doc, 'summary', { text: '記録の本文(原文)を開く' }), el(doc, 'p', { text: site.description }));
326
+ blocks.push(workspaceRailBlock(doc, { title: '本文', content: details }));
327
+ }
328
+ const href = typeof taskHref === 'function' ? taskHref(site) : null;
329
+ blocks.push(workspaceRailBlock(doc, {
330
+ title: '確認・訂正',
331
+ content: href
332
+ ? [el(doc, 'a', { className: 'bb-world-rail-link', text: '「タスク」でこの仕事を開く', attrs: { href } }), { text: '担当・期限・状態はタスク画面で直せます。保存先はタスクの正本で、世界にはもう一度読むと反映されます。本文の人物を担当欄へ接続するのも、そこで行います。', className: 'bb-world-rail-note' }]
333
+ : { text: 'この画面からは直せません。タスクを管理している画面で直してください。', className: 'bb-world-rail-note' },
334
+ }));
335
+ blocks.push(workspaceRailBlock(doc, { title: '出典', content: workspaceDefinition(doc, [['記録', `${site.task_id}(版 ${site.source.version ?? '不明'})`], ['事業', business.name]]) }));
336
+ return blocks;
337
+ }