@unson/brainbase-mcp 0.20.0 → 0.21.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,558 @@
1
+ /**
2
+ * The work inside a city of the world (story-world-work-sites-and-gaps-v1).
3
+ *
4
+ * A pure projection of what the host read with the member's grants: the
5
+ * business's Canonical Tasks (Task API) and, from the organization Graph, its
6
+ * people, decisions and relations. Two things are kept apart throughout:
7
+ *
8
+ * - the work as recorded (A): a task's `status` and `waiting_on`, as written;
9
+ * - what we can see of it (B): gaps found only from structured fields
10
+ * (an empty assignee field, no source link, a review date passed), never
11
+ * from reading the free text as a judgment of the work.
12
+ *
13
+ * A gap says what is known, what is not connected or not confirmed, where it
14
+ * comes from and when, and what to check next. Nothing here concludes that
15
+ * work has stopped, and nothing unreadable becomes zero.
16
+ */
17
+ export const WORLD_WORK_VERSION = 'world-work.v1';
18
+ const OPEN_STATUSES = new Set(['pending', 'in_progress', 'waiting']);
19
+ const STATUS_LABELS = Object.freeze({
20
+ pending: '未着手',
21
+ in_progress: '進行中',
22
+ waiting: '待ち',
23
+ completed: '完了',
24
+ cancelled: '取消',
25
+ });
26
+ export const WORLD_WORK_GAP_KINDS = Object.freeze([
27
+ 'assignee_unlinked_mentioned',
28
+ 'assignee_unrecorded',
29
+ 'outcome_unlinked',
30
+ 'source_unlinked',
31
+ 'review_overdue',
32
+ ]);
33
+ export const WORLD_WORK_GAP_LABELS = Object.freeze({
34
+ assignee_unlinked_mentioned: '担当欄に未接続(本文に人物あり)',
35
+ assignee_unrecorded: '担当の記録なし',
36
+ outcome_unlinked: '完了条件はあるが成果物の記録が未接続',
37
+ source_unlinked: '出典リンクなし',
38
+ review_overdue: '見直し予定を過ぎた(古い情報の可能性)',
39
+ });
40
+ function isRecord(value) {
41
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
42
+ }
43
+ function text(value) {
44
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
45
+ }
46
+ function payloadOf(record) {
47
+ return isRecord(record.payload) ? record.payload : isRecord(record.metadata) ? record.metadata : {};
48
+ }
49
+ function time(value) {
50
+ const parsed = value ? Date.parse(value) : Number.NaN;
51
+ return Number.isFinite(parsed) ? parsed : Number.NaN;
52
+ }
53
+ function squash(value) {
54
+ return value.normalize('NFKC').replace(/\s+/gu, '').toLowerCase();
55
+ }
56
+ function dateText(value) {
57
+ const at = time(value);
58
+ if (!Number.isFinite(at))
59
+ return '日付不明';
60
+ // Shown in Japan time, where the records are kept.
61
+ const jst = new Date(at + 9 * 60 * 60 * 1000);
62
+ return `${jst.getUTCMonth() + 1}/${jst.getUTCDate()}`;
63
+ }
64
+ function personDirectory(read) {
65
+ const readable = read?.state === 'complete' || read?.state === 'partial';
66
+ const entries = [];
67
+ for (const record of readable ? read?.items ?? [] : []) {
68
+ if (!isRecord(record))
69
+ continue;
70
+ const id = text(record.id);
71
+ const payload = payloadOf(record);
72
+ const name = text(payload.name) ?? text(record.name);
73
+ if (!id || !name)
74
+ continue;
75
+ const aliases = Array.isArray(payload.aliases) ? payload.aliases.map(text).filter((alias) => alias !== null) : [];
76
+ // Only names of three characters or more (spaces removed) are matched, so a bare surname or a
77
+ // two-character word in the text does not become a person.
78
+ const keys = [name, ...aliases].map((word) => ({ key: squash(word), word })).filter((entry) => entry.key.length >= 3);
79
+ entries.push({ id, name, keys });
80
+ }
81
+ return { readable, byId: new Map(entries.map((entry) => [entry.id, entry])), entries };
82
+ }
83
+ const PERSON_ID = /\bper_[A-Za-z0-9_]+/gu;
84
+ const STATED_ASSIGNEE = /担当/u;
85
+ /**
86
+ * People the text names. A Graph person id names one person. A name or alias names one person only
87
+ * when no other visible person shares it; a word shared by several (a duplicate record, a common
88
+ * alias) is kept as an ambiguous mention with its candidates.
89
+ */
90
+ function mentionedPeople(description, people) {
91
+ if (!description || !people.readable)
92
+ return { found: [], ambiguous: [] };
93
+ const found = new Map();
94
+ for (const match of description.matchAll(PERSON_ID)) {
95
+ const entry = people.byId.get(match[0]);
96
+ if (!entry || found.has(entry.id))
97
+ continue;
98
+ const before = description.slice(Math.max(0, (match.index ?? 0) - 30), match.index ?? 0);
99
+ found.set(entry.id, { person_id: entry.id, name: entry.name, link: 'inferred', basis: 'text_person_id', stated_as_assignee: STATED_ASSIGNEE.test(before) });
100
+ }
101
+ const body = squash(description);
102
+ const owners = new Map();
103
+ for (const entry of people.entries) {
104
+ for (const { key, word } of entry.keys) {
105
+ if (!body.includes(key))
106
+ continue;
107
+ const owner = owners.get(key) ?? { word, ids: new Set() };
108
+ owner.ids.add(entry.id);
109
+ owners.set(key, owner);
110
+ }
111
+ }
112
+ const ambiguous = new Map();
113
+ for (const [key, { word, ids }] of owners) {
114
+ // A key inside a longer matched key (高木 in 高木幹太) is the same mention.
115
+ if ([...owners.keys()].some((other) => other !== key && other.includes(key) && owners.get(other).ids.size === 1 && [...ids].some((id) => owners.get(other).ids.has(id))))
116
+ continue;
117
+ if (ids.size === 1) {
118
+ const id = [...ids][0];
119
+ if (found.has(id))
120
+ continue;
121
+ const at = body.indexOf(key);
122
+ found.set(id, { person_id: id, name: people.byId.get(id).name, link: 'inferred', basis: 'text_full_name', stated_as_assignee: STATED_ASSIGNEE.test(body.slice(Math.max(0, at - 8), at)) });
123
+ }
124
+ else if (![...ids].some((id) => found.has(id))) {
125
+ ambiguous.set(key, { text: word, candidates: [...ids].map((id) => ({ person_id: id, name: people.byId.get(id).name })) });
126
+ }
127
+ }
128
+ return { found: [...found.values()], ambiguous: [...ambiguous.values()] };
129
+ }
130
+ // A dated note of a check that was made (「[2026-09-18確認]」「2026-09-18監査」). 「2026-09-25再確認」 is a
131
+ // plan, not a check, so it is not read as one.
132
+ const AUDIT_NOTE = /(?<!\d)(\d{4}-\d{2}-\d{2})\s*(確認|監査)(?!する)/gu;
133
+ function auditNotes(description) {
134
+ if (!description)
135
+ return [];
136
+ const notes = new Map();
137
+ for (const match of description.matchAll(AUDIT_NOTE)) {
138
+ const date = match[1];
139
+ if (!notes.has(date))
140
+ notes.set(date, match[2]);
141
+ }
142
+ return [...notes].map(([date, kind]) => ({ date, text: kind })).sort((a, b) => b.date.localeCompare(a.date));
143
+ }
144
+ /** The text after 「完了条件」, up to the end of its sentence. */
145
+ function expectedOutcome(description) {
146
+ if (!description)
147
+ return null;
148
+ const match = /完了条件\s*[::]?\s*([^\n。]+。?)/u.exec(description);
149
+ return match ? match[1].trim().replace(/。$/u, '') : null;
150
+ }
151
+ function sourceRefs(value) {
152
+ if (!Array.isArray(value))
153
+ return [];
154
+ return value.filter(isRecord).map((ref) => {
155
+ const type = text(ref.type);
156
+ const url = text(ref.url) ?? text(ref.minutes_url);
157
+ const label = type === 'meeting_minutes' ? '議事録' : type === 'slack' ? 'Slack' : type ?? 'リンク';
158
+ return { type, url, label };
159
+ });
160
+ }
161
+ function workSite(record, context) {
162
+ const id = text(record.id);
163
+ if (!id)
164
+ return null;
165
+ const title = text(record.title) ?? '(題名なし)';
166
+ const statusValue = text(record.status);
167
+ const status = statusValue && statusValue in STATUS_LABELS ? statusValue : null;
168
+ const open = status !== null && OPEN_STATUSES.has(status);
169
+ const description = text(record.description);
170
+ const projectCodes = Array.isArray(record.project_codes) ? record.project_codes.map(text).filter((code) => code !== null) : [];
171
+ const otherBusinessCodes = [...new Set(projectCodes.map((code) => context.businessOfCode.get(code)).filter((code) => Boolean(code) && code !== context.businessCode))];
172
+ const dueAt = text(record.due_at);
173
+ const reviewAt = text(record.review_at);
174
+ const updatedAt = text(record.updated_at);
175
+ const assigneeId = text(record.assignee_person_id);
176
+ const assigneeName = text(record.assignee_display_name);
177
+ const refs = sourceRefs(record.source_refs);
178
+ const notes = auditNotes(description);
179
+ const outcome = expectedOutcome(description);
180
+ const people = [];
181
+ if (assigneeId || assigneeName) {
182
+ people.push({ person_id: assigneeId, name: assigneeName ?? context.people.byId.get(assigneeId ?? '')?.name ?? assigneeId, link: 'recorded', basis: 'assignee_field' });
183
+ }
184
+ const mentions = mentionedPeople(description, context.people);
185
+ const recordedAssignee = people[0]?.link === 'recorded' ? { person_id: people[0].person_id, name: people[0].name } : null;
186
+ const mentioned = mentions.found.filter((person) => person.person_id !== assigneeId);
187
+ people.push(...mentioned);
188
+ const checkedAt = notes[0] ? notes[0].date : updatedAt;
189
+ const checkedAtBasis = notes[0] ? 'text_note' : updatedAt ? 'updated_at' : null;
190
+ const source = (fields) => ({ system: 'task_api', record_id: id, fields });
191
+ const gap = (kind, fields, body) => ({
192
+ kind,
193
+ label: WORLD_WORK_GAP_LABELS[kind],
194
+ ...body,
195
+ source: source(fields),
196
+ checked_at: checkedAt,
197
+ checked_at_basis: checkedAtBasis,
198
+ });
199
+ const gaps = [];
200
+ if (status !== 'cancelled') {
201
+ if (!assigneeId && !assigneeName) {
202
+ const stated = mentioned.filter((person) => person.stated_as_assignee);
203
+ if (stated.length > 0) {
204
+ const names = stated.map((person) => person.name).join('・');
205
+ gaps.push(gap('assignee_unlinked_mentioned', ['assignee_person_id', 'description'], {
206
+ fact: `本文は担当として「${names}」を挙げています(${stated[0].basis === 'text_person_id' ? '人物IDつき' : '氏名の一致'})。担当欄は空です。`,
207
+ unknown: '本文と担当欄がつながっていないため、担当で絞る一覧や通知にはこの仕事が出ません。',
208
+ next_check: `担当欄に${names}を接続できるか(本文の記載と担当欄をそろえる)。`,
209
+ check_with: { person_id: stated[0].person_id, name: stated[0].name },
210
+ }));
211
+ }
212
+ else if (mentioned.length > 0 || mentions.ambiguous.length > 0) {
213
+ const named = mentioned.map((person) => `「${person.name}」`);
214
+ const unsure = mentions.ambiguous.map((mention) => `「${mention.text}」(組織のGraphの${mention.candidates.map((candidate) => candidate.name).join('・')}のどれか一意に決まらない)`);
215
+ gaps.push(gap('assignee_unlinked_mentioned', ['assignee_person_id', 'description'], {
216
+ fact: `本文に${[...named, ...unsure].join('、')}の記載があります。担当欄は空です。`,
217
+ unknown: '本文の人物がこの仕事の担当か、関係者として書かれているだけかは確認されていません。',
218
+ next_check: `本文の人物(${[...mentioned.map((person) => person.name), ...mentions.ambiguous.map((mention) => mention.text)].join('・')})がこの仕事でどんな役割か。担当なら担当欄に接続する。`,
219
+ check_with: mentioned.length === 1 && mentions.ambiguous.length === 0 ? { person_id: mentioned[0].person_id, name: mentioned[0].name } : null,
220
+ }));
221
+ }
222
+ else {
223
+ gaps.push(gap('assignee_unrecorded', ['assignee_person_id', 'description'], {
224
+ fact: context.people.readable
225
+ ? '担当欄が空です。本文にも、組織のGraphに登録された人物は見つかりません。'
226
+ : '担当欄が空です。組織のGraphの人物を読めなかったため、本文の人物とは照合できていません。',
227
+ unknown: '誰がこの仕事を担っているかは、記録からは分かりません。担当者がいないとは限りません。',
228
+ next_check: '誰が担当しているか。確認先の人物は記録からは分かりません。',
229
+ check_with: null,
230
+ }));
231
+ }
232
+ }
233
+ if (refs.length === 0) {
234
+ if (outcome) {
235
+ gaps.push(gap('outcome_unlinked', ['description', 'source_refs'], {
236
+ fact: `本文に完了条件があります:「${outcome}」。成果物や提示の記録へのリンクは0件です。`,
237
+ unknown: '完了条件にある成果物を作ったか、提示したかは確認されていません。仕事が止まっているとは限りません。',
238
+ next_check: '完了条件にある成果物(または提示した記録)がどこにあるか。',
239
+ check_with: recordedAssignee,
240
+ }));
241
+ }
242
+ else {
243
+ gaps.push(gap('source_unlinked', ['source_refs'], {
244
+ fact: 'この仕事の出典や関連記録(議事録・Slack・資料)へのリンクは0件です。',
245
+ unknown: 'どこから生まれた仕事か、何を作ったかは、この記録からはたどれません。',
246
+ next_check: 'この仕事の元になった記録や、作ったものの場所。',
247
+ check_with: recordedAssignee,
248
+ }));
249
+ }
250
+ }
251
+ }
252
+ if (open && Number.isFinite(time(reviewAt)) && time(reviewAt) < context.now) {
253
+ gaps.push(gap('review_overdue', ['review_at', 'updated_at'], {
254
+ fact: `見直し予定(${dateText(reviewAt)})を過ぎています。記録の最終更新は${dateText(updatedAt)}です。`,
255
+ unknown: `${dateText(updatedAt)}より後の進み具合は記録されていません。状態「${status ? STATUS_LABELS[status] : '不明'}」は古い可能性があります。`,
256
+ next_check: '今の状態(進んだか・終わったか・待ちのままか)を確かめ、記録を更新する。',
257
+ check_with: recordedAssignee,
258
+ }));
259
+ }
260
+ const version = typeof record.version === 'number' && Number.isInteger(record.version) ? record.version : null;
261
+ return {
262
+ task_id: id,
263
+ title,
264
+ purpose_label: text(record.purpose_label),
265
+ project_codes: projectCodes,
266
+ other_business_codes: otherBusinessCodes,
267
+ work: {
268
+ status,
269
+ label: status ? STATUS_LABELS[status] : '状態の記録なし',
270
+ open,
271
+ waiting_on: text(record.waiting_on),
272
+ due_at: dueAt,
273
+ past_due: open && Number.isFinite(time(dueAt)) && time(dueAt) < context.now,
274
+ review_at: reviewAt,
275
+ created_at: text(record.created_at),
276
+ updated_at: updatedAt,
277
+ completed_at: text(record.completed_at),
278
+ },
279
+ people,
280
+ ambiguous_mentions: mentions.ambiguous,
281
+ source_refs: refs,
282
+ expected_outcome: outcome,
283
+ audit_notes: notes,
284
+ gaps,
285
+ description,
286
+ source: { ...source(['*']), version, web_url: text(record.web_url) },
287
+ };
288
+ }
289
+ function readOf(read) {
290
+ const state = read?.state ?? 'not_connected';
291
+ const readable = state === 'complete' || state === 'partial';
292
+ return { state, read_at: read?.read_at ?? null, reason: read?.reason ?? null, count: readable ? (read?.items ?? []).length : null };
293
+ }
294
+ function businessIndex(business, neighbours) {
295
+ const index = new Map();
296
+ for (const other of neighbours) {
297
+ index.set(other.code, other.code);
298
+ for (const code of other.engagement_codes ?? [])
299
+ index.set(code, other.code);
300
+ }
301
+ index.set(business.code, business.code);
302
+ for (const code of business.engagement_codes ?? [])
303
+ index.set(code, business.code);
304
+ return index;
305
+ }
306
+ function sitesOf(business, tasks, persons, neighbours, now) {
307
+ if (!(tasks?.state === 'complete' || tasks?.state === 'partial'))
308
+ return [];
309
+ const ownCodes = new Set([business.code, ...(business.engagement_codes ?? [])]);
310
+ const context = {
311
+ businessCode: business.code,
312
+ ownCodes,
313
+ businessOfCode: businessIndex(business, neighbours),
314
+ people: personDirectory(persons),
315
+ now: now.getTime(),
316
+ readAt: tasks.read_at ?? null,
317
+ };
318
+ const sites = [];
319
+ const seen = new Set();
320
+ for (const record of tasks.items ?? []) {
321
+ if (!isRecord(record))
322
+ continue;
323
+ const codes = Array.isArray(record.project_codes) ? record.project_codes.map(text) : [];
324
+ if (!codes.some((code) => code !== null && ownCodes.has(code)))
325
+ continue;
326
+ const site = workSite(record, context);
327
+ if (!site || seen.has(site.task_id))
328
+ continue;
329
+ seen.add(site.task_id);
330
+ sites.push(site);
331
+ }
332
+ return sites.sort((a, b) => a.task_id.localeCompare(b.task_id));
333
+ }
334
+ function summaryOf(businessCode, tasks, sites) {
335
+ const read = readOf(tasks);
336
+ if (read.count === null) {
337
+ return { business_code: businessCode, state: read.state, read_at: read.read_at, reason: read.reason, open: null, total: null, by_status: null, gaps: null, needs_check: null };
338
+ }
339
+ const byStatus = {};
340
+ const gaps = {};
341
+ const needsCheck = [];
342
+ for (const site of sites) {
343
+ const key = site.work.status ?? 'unknown';
344
+ byStatus[key] = (byStatus[key] ?? 0) + 1;
345
+ if (!site.work.open)
346
+ continue;
347
+ if (site.gaps.length)
348
+ needsCheck.push(site.task_id);
349
+ for (const gap of site.gaps)
350
+ (gaps[gap.kind] ??= []).push(site.task_id);
351
+ }
352
+ return {
353
+ business_code: businessCode,
354
+ state: read.state,
355
+ read_at: read.read_at,
356
+ reason: read.reason,
357
+ open: sites.filter((site) => site.work.open).length,
358
+ total: sites.length,
359
+ by_status: byStatus,
360
+ gaps,
361
+ needs_check: needsCheck,
362
+ };
363
+ }
364
+ /** Per business, what the world shows above the city: counts with the tasks behind them. */
365
+ export function summarizeWorldWork(input, options = {}) {
366
+ const now = options.now ?? new Date();
367
+ const result = {};
368
+ for (const business of input.businesses) {
369
+ const tasks = input.tasks[business.code];
370
+ const others = input.businesses.filter((other) => other.code !== business.code);
371
+ const sites = sitesOf(business, tasks, input.persons, others, now);
372
+ result[business.code] = summaryOf(business.code, tasks, sites);
373
+ }
374
+ return result;
375
+ }
376
+ function decisionRows(read) {
377
+ if (!(read?.state === 'complete' || read?.state === 'partial'))
378
+ return [];
379
+ return (read.items ?? []).filter(isRecord).map((record) => {
380
+ const payload = payloadOf(record);
381
+ return {
382
+ id: text(record.id) ?? '',
383
+ title: text(payload.title) ?? text(payload.name) ?? text(payload.decision) ?? text(record.name) ?? '',
384
+ decided_at: text(payload.decided_at) ?? text(record.validFrom) ?? null,
385
+ scope_code: text(record.project_code) ?? text(payload.project_code),
386
+ };
387
+ }).filter((row) => row.id && row.title);
388
+ }
389
+ function policiesOf(business, project, decisions) {
390
+ const rows = decisionRows(decisions);
391
+ const cited = new Set(project.source_decision_ids ?? []);
392
+ const ownCodes = new Set([business.code, ...(business.engagement_codes ?? [])]);
393
+ const words = [
394
+ { word: business.name, basis: `事業名「${business.name}」` },
395
+ ...(business.aliases ?? []).map((alias) => ({ word: alias, basis: `事業の別名「${alias}」` })),
396
+ ...(project.term_aliases ?? []).map((entry) => ({ word: entry.alias, basis: `事業に属す用語「${entry.term}」の別名「${entry.alias}」` })),
397
+ ].filter((entry) => squash(entry.word).length >= 3);
398
+ const policies = [];
399
+ for (const row of rows) {
400
+ if (cited.has(row.id)) {
401
+ policies.push({ decision_id: row.id, title: row.title, decided_at: row.decided_at, link: 'recorded', basis: 'この事業の記録が出典として挙げる決定(source_decision_ids)', scope_code: row.scope_code });
402
+ continue;
403
+ }
404
+ if (row.scope_code && ownCodes.has(row.scope_code)) {
405
+ policies.push({ decision_id: row.id, title: row.title, decided_at: row.decided_at, link: 'recorded', basis: 'この事業の範囲コードで記録された決定', scope_code: row.scope_code });
406
+ continue;
407
+ }
408
+ const title = squash(row.title);
409
+ const hit = words.find((entry) => title.includes(squash(entry.word)));
410
+ if (hit) {
411
+ policies.push({ decision_id: row.id, title: row.title, decided_at: row.decided_at, link: 'inferred', basis: `題名に${hit.basis}を含む(範囲コードは ${row.scope_code ?? '未記録'})`, scope_code: row.scope_code });
412
+ }
413
+ }
414
+ return policies.sort((a, b) => (a.link === b.link ? 0 : a.link === 'recorded' ? -1 : 1) || String(b.decided_at ?? '').localeCompare(String(a.decided_at ?? '')));
415
+ }
416
+ const RELATION_WORDS = Object.freeze({
417
+ has_engagement: '案件として持つ',
418
+ belongs_to_project: 'この事業に属す',
419
+ member_of: 'メンバー',
420
+ assigned_to: '担当',
421
+ owned_by: '責任者',
422
+ governs: '決める',
423
+ });
424
+ function relationsOf(business, relations, neighbours, sites) {
425
+ const result = [];
426
+ const businessOfRecord = new Map();
427
+ for (const other of neighbours)
428
+ for (const id of [other.id, ...(other.record_ids ?? [])])
429
+ businessOfRecord.set(id, other);
430
+ const readable = relations?.state === 'complete' || relations?.state === 'partial';
431
+ for (const item of readable ? relations?.items ?? [] : []) {
432
+ if (!isRecord(item))
433
+ continue;
434
+ const kind = text(item.relation) ?? text(item.rel_type) ?? 'related';
435
+ const counterpartRecord = isRecord(item.counterpart) ? item.counterpart : null;
436
+ const counterpartId = text(counterpartRecord?.id);
437
+ const neighbour = counterpartId ? businessOfRecord.get(counterpartId) : undefined;
438
+ const direction = item.direction === 'incoming' || item.direction === 'outgoing' ? item.direction : null;
439
+ const counterpartName = text(counterpartRecord?.name) ?? counterpartId ?? '名前の無い記録';
440
+ const word = RELATION_WORDS[kind] ?? kind;
441
+ result.push({
442
+ link: 'recorded',
443
+ kind,
444
+ label: neighbour
445
+ ? direction === 'incoming' ? `${neighbour.name} が ${business.name} を${word}` : `${business.name} が ${neighbour.name} を${word}`
446
+ : `${counterpartName}(${word})`,
447
+ counterpart: counterpartRecord ? { id: counterpartId, type: text(counterpartRecord.type), name: counterpartName } : null,
448
+ business_code: neighbour?.code ?? null,
449
+ direction,
450
+ basis: '組織のGraphに記録された関係',
451
+ source: text(item.id) ? { system: 'organization_graph', record_id: text(item.id), fields: ['relation'] } : null,
452
+ task_ids: [],
453
+ });
454
+ }
455
+ if (!readable || relations?.state === 'partial') {
456
+ result.push({
457
+ link: 'unreadable',
458
+ kind: 'unreadable',
459
+ label: relations?.state === 'partial' ? 'Graphの関係の一部を読めませんでした' : 'Graphの関係を読めませんでした',
460
+ counterpart: null,
461
+ business_code: null,
462
+ direction: null,
463
+ basis: relations?.reason ?? relations?.state ?? 'not_connected',
464
+ source: null,
465
+ task_ids: [],
466
+ });
467
+ }
468
+ // Work that crosses into another business, by the tasks' own project codes (recorded).
469
+ const crossing = new Map();
470
+ for (const site of sites)
471
+ for (const code of site.other_business_codes)
472
+ crossing.set(code, [...(crossing.get(code) ?? []), site.task_id]);
473
+ for (const [code, taskIds] of crossing) {
474
+ const neighbour = neighbours.find((other) => other.code === code);
475
+ result.push({
476
+ link: 'recorded',
477
+ kind: 'shared_task',
478
+ label: `${neighbour?.name ?? code} にもまたがる仕事 ${taskIds.length}件`,
479
+ counterpart: neighbour ? { id: neighbour.id, type: 'project', name: neighbour.name } : null,
480
+ business_code: code,
481
+ direction: null,
482
+ basis: 'タスクのプロジェクト欄に両方の事業のコードがある',
483
+ source: null,
484
+ task_ids: taskIds,
485
+ });
486
+ }
487
+ return result;
488
+ }
489
+ /** The work of one business for its city: sites, purpose, people, relations and the summary. */
490
+ export function projectWorldWork(input, options = {}) {
491
+ const now = options.now ?? new Date();
492
+ const { business } = input;
493
+ const project = input.project ?? {};
494
+ const neighbours = (input.neighbours ?? []).filter((other) => other.code !== business.code);
495
+ const sites = sitesOf(business, input.tasks, input.persons, neighbours, now);
496
+ const peopleIndex = new Map();
497
+ const addPerson = (person, role, link, taskId) => {
498
+ const key = person.person_id ?? `name:${person.name}`;
499
+ const entry = peopleIndex.get(key) ?? { person_id: person.person_id, name: person.name, roles: new Set(), link, task_ids: [] };
500
+ entry.roles.add(role);
501
+ if (link === 'recorded')
502
+ entry.link = 'recorded';
503
+ if (taskId && !entry.task_ids.includes(taskId))
504
+ entry.task_ids.push(taskId);
505
+ peopleIndex.set(key, entry);
506
+ };
507
+ if (project.owner)
508
+ addPerson(project.owner, '責任者(プロジェクトの記録)', 'recorded');
509
+ for (const person of project.people ?? [])
510
+ for (const role of person.roles)
511
+ addPerson(person, role, 'recorded');
512
+ for (const site of sites) {
513
+ for (const person of site.people) {
514
+ addPerson(person, person.link === 'recorded' ? '仕事の担当(担当欄)' : '仕事の本文に記載(推定)', person.link, site.task_id);
515
+ }
516
+ }
517
+ const relations = relationsOf(business, input.relations, neighbours, sites);
518
+ for (const entry of peopleIndex.values()) {
519
+ if (entry.link !== 'inferred')
520
+ continue;
521
+ relations.push({
522
+ link: 'inferred',
523
+ kind: 'mentioned_person',
524
+ label: `${entry.name}(仕事${entry.task_ids.length}件の本文に記載。担当欄には未接続)`,
525
+ counterpart: { id: entry.person_id, type: 'person', name: entry.name },
526
+ business_code: null,
527
+ direction: null,
528
+ basis: 'タスクの本文に人物IDか氏名がある',
529
+ source: null,
530
+ task_ids: entry.task_ids,
531
+ });
532
+ }
533
+ const purposeText = text(business.purpose);
534
+ const reads = {
535
+ tasks: readOf(input.tasks),
536
+ persons: { state: input.persons?.state ?? 'not_connected', reason: input.persons?.reason ?? null },
537
+ decisions: { state: input.decisions?.state ?? 'not_connected', reason: input.decisions?.reason ?? null },
538
+ relations: { state: input.relations?.state ?? 'not_connected', reason: input.relations?.reason ?? null },
539
+ };
540
+ return {
541
+ status: 'ok',
542
+ version: WORLD_WORK_VERSION,
543
+ as_of: now.toISOString(),
544
+ business: { id: business.id, code: business.code, name: business.name },
545
+ reads,
546
+ purpose: {
547
+ text: purposeText,
548
+ state: purposeText ? 'registered' : 'unregistered',
549
+ summary: text(business.summary),
550
+ policies: policiesOf(business, project, input.decisions),
551
+ },
552
+ people: [...peopleIndex.values()].map((entry) => ({ person_id: entry.person_id, name: entry.name, roles: [...entry.roles], link: entry.link, task_ids: entry.task_ids })),
553
+ members_state: project.members_state ?? 'unreadable',
554
+ relations,
555
+ sites,
556
+ summary: summaryOf(business.code, input.tasks, sites),
557
+ };
558
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.20.0",
3
+ "version": "0.21.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",
@@ -364,5 +364,5 @@
364
364
  "engines": {
365
365
  "node": ">=20"
366
366
  },
367
- "gitHead": "eae9755b000357391dc4f1244537e8b6bfd7925c"
367
+ "gitHead": "8de743339210d3dd6e920bbaa180cd38988c8318"
368
368
  }