@unson/brainbase-mcp 0.39.0 → 0.41.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.
@@ -88,6 +88,82 @@ export const GRAPH_PROJECT_LEDGER_COLUMNS = Object.freeze(['プロジェクト',
88
88
 
89
89
  const INVALID = Object.freeze({ state: 'invalid', reason: '応答の形式が不正です' });
90
90
 
91
+ const PARTICIPANT_STATUS_VALUES = new Set(['available', 'empty', 'partial', 'unavailable', 'unknown', 'stale', 'failed']);
92
+ const PARTICIPANT_FRESHNESS_VALUES = new Set(['fresh', 'stale', 'unknown']);
93
+
94
+ /**
95
+ * Detail adapters may expose either wire-style snake_case or JS-style camelCase.
96
+ * `ready` is the existing host spelling for an available read. Conflicting
97
+ * aliases and unknown values are invalid instead of silently becoming empty.
98
+ */
99
+ function normalizeParticipantStatus(payload) {
100
+ const raw = [payload?.participants_status, payload?.participantsStatus].filter((value) => value !== undefined);
101
+ if (raw.length === 0) return { value: null, explicit: false };
102
+ const values = raw.map((value) => value === 'ready' ? 'available' : value);
103
+ if (values.some((value) => typeof value !== 'string' || !PARTICIPANT_STATUS_VALUES.has(value)) || new Set(values).size !== 1) {
104
+ return { invalid: true };
105
+ }
106
+ return { value: values[0], explicit: true };
107
+ }
108
+
109
+ function normalizeParticipantFreshness(payload) {
110
+ const raw = [payload?.participants_freshness, payload?.participantsFreshness].filter((value) => value !== undefined);
111
+ if (raw.length === 0) return { value: null, explicit: false };
112
+ if (raw.some((value) => typeof value !== 'string' || !PARTICIPANT_FRESHNESS_VALUES.has(value)) || new Set(raw).size !== 1) {
113
+ return { invalid: true };
114
+ }
115
+ return { value: raw[0], explicit: true };
116
+ }
117
+
118
+ function normalizeParticipantAbsence(payload) {
119
+ const raw = [payload?.participants_absence_confirmed, payload?.participantsAbsenceConfirmed]
120
+ .filter((value) => value !== undefined);
121
+ if (raw.length === 0) return { confirmed: false, explicit: false };
122
+ if (raw.some((value) => typeof value !== 'boolean') || new Set(raw).size !== 1) return { invalid: true };
123
+ return { confirmed: raw[0] === true, explicit: true };
124
+ }
125
+
126
+ function confirmsParticipantAbsence(payload) {
127
+ // `absenceConfirmed` describes a project-list miss, not participant absence;
128
+ // `participants_status=empty` is only a label. The native server owns the
129
+ // same-Graph strict-validation gate before setting this dedicated flag.
130
+ return payload.participantAbsenceConfirmed === true;
131
+ }
132
+
133
+ function participantReason(payload) {
134
+ const explicit = textOrNull(payload.participants_unavailable_reason)
135
+ ?? textOrNull(payload.participantsUnavailableReason);
136
+ if (explicit) return explicit;
137
+ if (Array.isArray(payload.warnings)) {
138
+ const warning = payload.warnings
139
+ .map((item) => textOrNull(item) ?? textOrNull(item?.reason) ?? textOrNull(item?.message))
140
+ .find(Boolean);
141
+ if (warning) return warning;
142
+ }
143
+ if (payload.participantStatus === 'partial') return '一部の関係者だけを読み取っています。';
144
+ return '関係者の有無を確認できません。登録なしとは判断できません。';
145
+ }
146
+
147
+ function participantCountConflict(payload) {
148
+ const project = payload.project;
149
+ const declared = [project.participantCount, project.accountableCount]
150
+ .some((value) => Number.isInteger(value) && value > 0);
151
+ const named = Array.isArray(project.people) && project.people.length > 0;
152
+ return payload.participants.length === 0 && (declared || named);
153
+ }
154
+
155
+ function participantCountSummary(payload) {
156
+ const { participantCount, accountableCount } = payload.project;
157
+ const known = [];
158
+ if (Number.isInteger(participantCount) && participantCount > 0) known.push(`関係者${participantCount}人`);
159
+ if (Number.isInteger(accountableCount) && accountableCount > 0) known.push(`責任者${accountableCount}人`);
160
+ return known.length > 0 ? `確認済みの件数: ${known.join('、')}。` : '';
161
+ }
162
+
163
+ function projectCorrectionSubject(name, fields) {
164
+ return `プロジェクト「${name}」の${fields.map((field) => GRAPH_CORRECTION_FIELDS[field].label).join('・')}`;
165
+ }
166
+
91
167
  function isProjectPerson(value) {
92
168
  return isRecord(value) && typeof value.id === 'string' && typeof value.name === 'string' && typeof value.accountable === 'boolean';
93
169
  }
@@ -118,7 +194,24 @@ export function normalizeProjectDetail(result) {
118
194
  if (!isEntityView(project) || project.type !== 'project') return INVALID;
119
195
  if (!Array.isArray(payload.participants) || !payload.participants.every(isEdgeView)) return INVALID;
120
196
  if (payload.relations !== undefined && (!Array.isArray(payload.relations) || !payload.relations.every(isEdgeView))) return INVALID;
121
- return { state: 'ok', payload };
197
+ const participantStatus = normalizeParticipantStatus(payload);
198
+ if (participantStatus.invalid) return INVALID;
199
+ const participantFreshness = normalizeParticipantFreshness(payload);
200
+ if (participantFreshness.invalid) return INVALID;
201
+ const participantAbsence = normalizeParticipantAbsence({ ...payload, project });
202
+ if (participantAbsence.invalid) return INVALID;
203
+ return {
204
+ state: 'ok',
205
+ payload: {
206
+ ...payload,
207
+ participantStatus: participantStatus.value,
208
+ participantStatusExplicit: participantStatus.explicit,
209
+ participantFreshness: participantFreshness.value,
210
+ participantFreshnessExplicit: participantFreshness.explicit,
211
+ participantAbsenceConfirmed: participantAbsence.confirmed,
212
+ participantAbsenceExplicit: participantAbsence.explicit,
213
+ },
214
+ };
122
215
  }
123
216
 
124
217
  /**
@@ -308,7 +401,19 @@ export function createGraphProjectsView({
308
401
  now,
309
402
  scope: correctionScope,
310
403
  rerender: () => controller.render(),
311
- onSaved: async () => {
404
+ onSaved: async (payload, form) => {
405
+ // The save response is the authoritative read-back. A project name
406
+ // may have changed, so refresh the sentence above the saved form before
407
+ // the list/detail reloads redraw the rail. Otherwise the old name
408
+ // remains visible while the read-back values already show the new one.
409
+ if (form?.kind === 'update_entity'
410
+ && form.recordType === 'project'
411
+ && payload?.record?.id === form.targetId
412
+ && typeof payload.record.name === 'string'
413
+ && Array.isArray(form.fields)) {
414
+ form.subject = projectCorrectionSubject(payload.record.name, form.fields);
415
+ controller.render();
416
+ }
312
417
  await Promise.all([
313
418
  state.detail ? controller.loadDetail(state.detail.id, { keep: true }) : null,
314
419
  controller.load({ keep: true }),
@@ -591,11 +696,16 @@ export function createGraphProjectsView({
591
696
  mountGraph: projectGraphMount,
592
697
  readEntity: (id) => graphClient.read(`/entities/${encodeURIComponent(id)}`),
593
698
  onTabChange: setKnowledgeTabChrome,
699
+ onRetryProject: (projectId) => controller.loadDetail(projectId),
594
700
  });
595
701
  children.push(knowledgeWorkspace.element);
596
702
  } else if (state.selectedId) {
597
- children.push(renderProjectKnowledgeLoading(state.selectedId, state.detail ?? { state: 'loading' }, {
598
- onRetry: () => controller.loadDetail(state.selectedId),
703
+ const projectId = state.selectedId;
704
+ children.push(renderProjectKnowledgeLoading(projectId, state.detail ?? { state: 'loading' }, {
705
+ onRetry: () => {
706
+ if (state.selectedId !== projectId) return state.detail;
707
+ return controller.loadDetail(projectId);
708
+ },
599
709
  }));
600
710
  }
601
711
 
@@ -679,16 +789,52 @@ export function createGraphProjectsView({
679
789
  return item;
680
790
  }
681
791
 
682
- function renderParticipants(payload) {
792
+ function renderParticipants(payload, { onRetry } = {}) {
683
793
  const { project } = payload;
684
794
  const content = [makeElement(doc, 'p', { className: 'bb-graph-block-lead', text: '参加している人と、責任を持つ人です。終わった関わりも、終了日つきで残ります。' })];
685
- if (payload.participants.length === 0) {
795
+ const hasParticipants = payload.participants.length > 0;
796
+ const state = payload.participantStatus;
797
+ const unavailable = ['partial', 'unavailable', 'unknown'].includes(state);
798
+ const contradictory = participantCountConflict(payload)
799
+ || (state === 'empty' && hasParticipants)
800
+ || (confirmsParticipantAbsence(payload) && hasParticipants);
801
+ // Retrieval uncertainty has precedence over the empty/contradictory
802
+ // branch so the host's reason is never discarded. A contradiction may
803
+ // still be shown below and known rows are always retained.
804
+ if (unavailable) {
805
+ content.push(workspaceNotice(doc, {
806
+ label: state === 'partial' ? '一部取得' : '未確認',
807
+ tone: 'warning',
808
+ role: 'status',
809
+ text: participantReason(payload),
810
+ }));
811
+ }
812
+ if (contradictory) {
813
+ content.push(workspaceNotice(doc, {
814
+ label: '関係者を確認できません',
815
+ tone: 'warning',
816
+ role: 'status',
817
+ text: `${participantCountSummary(payload)}取得した関係者と件数が一致しません。登録なしとは判断せず、読み直してください。`,
818
+ }));
819
+ }
820
+ if (!hasParticipants && !unavailable && !contradictory && confirmsParticipantAbsence(payload)) {
686
821
  content.push(makeElement(doc, 'p', { className: 'bb-graph-empty', text: 'このプロジェクトには、まだ関係者の登録がありません。' }));
687
- } else {
822
+ } else if (!hasParticipants && !unavailable && !contradictory) {
823
+ content.push(workspaceNotice(doc, {
824
+ label: '関係者を確認できません',
825
+ tone: 'warning',
826
+ role: 'status',
827
+ text: participantReason(payload),
828
+ }));
829
+ }
830
+ if (hasParticipants) {
688
831
  const list = makeElement(doc, 'ul', { className: 'bb-graph-compact-list' });
689
832
  for (const edge of payload.participants) list.append(participantItem(edge, project, payload.asOf));
690
833
  content.push(list);
691
834
  }
835
+ if (onRetry && (contradictory || unavailable || (!hasParticipants && !confirmsParticipantAbsence(payload)))) {
836
+ content.push(workspaceActions(doc, [workspaceButton(doc, { text: '再試行', variant: 'quiet', onClick: onRetry })]));
837
+ }
692
838
  return workspaceRailBlock(doc, { title: '関係者', content });
693
839
  }
694
840
 
@@ -736,14 +882,18 @@ export function createGraphProjectsView({
736
882
  const noteSubject = { id: project.id, type: 'project', metadata: loaded?.project?.metadata ?? {} };
737
883
  const children = [workspaceRailHead(doc, { kicker: 'プロジェクト', title: project.name, sub: project.id, leading: renderProjectIcon(doc, project.icon, project.name) })];
738
884
  if (!loaded) {
885
+ const projectId = state.selectedId;
739
886
  const readState = detail?.id === state.selectedId
740
887
  ? detail
741
888
  : state.list.state === 'ok' ? { state: 'loading' } : state.list;
742
889
  children.push(graphStateNotice(doc, readState, {
743
- onRetry: () => controller.loadDetail(state.selectedId),
890
+ onRetry: () => {
891
+ if (state.selectedId !== projectId) return state.detail;
892
+ return controller.loadDetail(projectId);
893
+ },
744
894
  loadingText: 'このプロジェクトの関係者を読み込んでいます。',
745
895
  }));
746
- children.push(...railExtensions(state.selectedId, null));
896
+ children.push(...railExtensions(projectId, null));
747
897
  return children;
748
898
  }
749
899
  const principles = Array.isArray(project.decisionPrinciples) ? project.decisionPrinciples.filter((item) => typeof item === 'string' && item.trim()) : [];
@@ -766,7 +916,13 @@ export function createGraphProjectsView({
766
916
  recordDetails(doc, [['プロジェクトID', project.id], ['digest', project.digest], ['読み取った時点', loaded.asOf]]),
767
917
  ],
768
918
  }));
769
- children.push(renderParticipants(loaded));
919
+ const projectId = state.selectedId;
920
+ children.push(renderParticipants(loaded, {
921
+ onRetry: () => {
922
+ if (state.selectedId !== projectId) return state.detail;
923
+ return controller.loadDetail(projectId);
924
+ },
925
+ }));
770
926
  if (writable) {
771
927
  const canAdd = scope.createEdges;
772
928
  const projectFields = scope.entity('project') ? scope.fields(['name', 'goal', 'status', 'icon']) : [];
@@ -787,7 +943,7 @@ export function createGraphProjectsView({
787
943
  onClick: () => openCorrection('project', () => correction.openEntity(project, {
788
944
  fields: projectFields,
789
945
  title: 'プロジェクトを直す',
790
- subject: `プロジェクト「${project.name}」の${projectFields.map((field) => GRAPH_CORRECTION_FIELDS[field].label).join('・')}`,
946
+ subject: projectCorrectionSubject(project.name, projectFields),
791
947
  })),
792
948
  }) : null,
793
949
  ].filter(Boolean);
@@ -119,6 +119,114 @@ export function normalizeEntityDetail(result) {
119
119
  return { state: 'ok', payload };
120
120
  }
121
121
 
122
+ const RELATION_UNAVAILABLE_STATES = new Set(['failed', 'incomplete', 'partial', 'stale', 'unknown', 'unavailable']);
123
+ const RELATION_EMPTY_STATES = new Set(['confirmed_empty', 'empty', 'none']);
124
+
125
+ function normalizedRelationState(value) {
126
+ if (typeof value !== 'string') return null;
127
+ const state = value.trim().toLowerCase().replace(/[- ]/gu, '_');
128
+ if (RELATION_UNAVAILABLE_STATES.has(state)) return 'unknown';
129
+ if (RELATION_EMPTY_STATES.has(state)) return 'confirmed_empty';
130
+ if (['available', 'complete', 'ready'].includes(state)) return 'available';
131
+ return null;
132
+ }
133
+
134
+ function firstText(...values) {
135
+ for (const value of values) {
136
+ const text = textOrNull(value);
137
+ if (text) return text;
138
+ }
139
+ return null;
140
+ }
141
+
142
+ function relationStatusFrom(payload, direction) {
143
+ const direct = [
144
+ payload[`${direction}_status`],
145
+ payload[`${direction}Status`],
146
+ payload[`${direction}_availability`],
147
+ payload[`${direction}Availability`],
148
+ ].map(normalizedRelationState).find(Boolean);
149
+ if (direct) return direct;
150
+ return [
151
+ payload.relations_status,
152
+ payload.relationsStatus,
153
+ payload.relation_status,
154
+ payload.relationStatus,
155
+ payload.edges_status,
156
+ payload.edgesStatus,
157
+ ].map(normalizedRelationState).find(Boolean) ?? null;
158
+ }
159
+
160
+ function participantStatusFrom(payload) {
161
+ return [
162
+ payload.participants_status,
163
+ payload.participantsStatus,
164
+ payload.participant_status,
165
+ payload.participantStatus,
166
+ ].map(normalizedRelationState).find(Boolean) ?? null;
167
+ }
168
+
169
+ function knownParticipantCount(payload) {
170
+ const entity = payload.entity ?? {};
171
+ const count = [
172
+ entity.participantCount,
173
+ entity.participant_count,
174
+ entity.accountableCount,
175
+ entity.accountable_count,
176
+ payload.actual_known_count,
177
+ payload.actualKnownCount,
178
+ ].find((value) => Number.isInteger(value) && value > 0);
179
+ if (count) return count;
180
+ if (Array.isArray(entity.people) && entity.people.length > 0) return entity.people.length;
181
+ if (Array.isArray(payload.people) && payload.people.length > 0) return payload.people.length;
182
+ return null;
183
+ }
184
+
185
+ function relationUnavailableReason(payload) {
186
+ const explicit = firstText(
187
+ payload.relations_reason,
188
+ payload.relationsReason,
189
+ payload.relation_reason,
190
+ payload.relationReason,
191
+ payload.edges_reason,
192
+ payload.edgesReason,
193
+ payload.participants_reason,
194
+ payload.participantsReason,
195
+ payload.participants_unavailable_reason,
196
+ payload.participantsUnavailableReason,
197
+ payload.reason,
198
+ payload.source?.reason,
199
+ payload.source?.message,
200
+ );
201
+ if (explicit) return `${explicit} 関係の有無は確認できません。登録なしとは判断できません。`;
202
+ const count = knownParticipantCount(payload);
203
+ const known = count ? `関係者の件数(${count}人)は確認できていますが、` : '';
204
+ return `${known}関係の有無を確認できません。関係の詳細が未取得のため、登録なしとは判断できません。`;
205
+ }
206
+
207
+ /**
208
+ * Empty edge arrays are a confirmed empty result only when the host says so.
209
+ * A project can carry a known participant count while its relation edges are
210
+ * unavailable; rendering the empty copy in that case turns unknown data into
211
+ * a product assertion.
212
+ */
213
+ function relationAvailability(payload, direction) {
214
+ if (payload[direction].length > 0) return { state: 'available' };
215
+ const direct = relationStatusFrom(payload, direction);
216
+ if (direct === 'unknown') return { state: 'unknown', reason: relationUnavailableReason(payload) };
217
+ // A host may accidentally include a stale confirmed-empty status alongside
218
+ // an incomplete relation payload. Completeness signals take precedence so
219
+ // the UI cannot turn a known participant count into a false "none" claim.
220
+ const edgesNotProvided = payload.source?.edges_not_provided === true || payload.source?.edgesNotProvided === true;
221
+ const participantsUnknown = participantStatusFrom(payload) === 'unknown';
222
+ const knownCount = knownParticipantCount(payload);
223
+ if (edgesNotProvided || participantsUnknown || knownCount) {
224
+ return { state: 'unknown', reason: relationUnavailableReason(payload) };
225
+ }
226
+ if (direct === 'confirmed_empty') return { state: 'confirmed_empty' };
227
+ return { state: 'confirmed_empty' };
228
+ }
229
+
122
230
  // A host that could not finish counting sends null (with countStatus
123
231
  // 'unknown', or 'at_least' plus atLeast) rather than a number it cannot vouch for.
124
232
  function isCount(value) {
@@ -396,11 +504,14 @@ export function createGraphRegistryView({
396
504
  }
397
505
  const { payload } = state.ontology;
398
506
  const ontology = payload.ontology;
507
+ const version = textOrNull(ontology.version);
508
+ const currentVersion = textOrNull(ontology.currentVersion);
509
+ const freshness = ontology.upToDate === false && currentVersion
510
+ ? `最新ではありません。最新は ${currentVersion}`
511
+ : ontology.upToDate === true && version ? '最新' : '最新性は未確認';
399
512
  section.append(makeElement(doc, 'p', {
400
513
  className: 'bb-gr-caption',
401
- text: `定義の版 ${ontology.upToDate === false
402
- ? `${String(ontology.version)}(最新ではありません。最新は ${String(ontology.currentVersion)})`
403
- : `${String(ontology.version)}(最新)`}`,
514
+ text: `定義の版 ${version ?? '未確認'}(${freshness})`,
404
515
  }));
405
516
  section.append(makeElement(doc, 'h3', { className: 'bb-gr-subheading', text: 'つながり方' }));
406
517
  section.append(workspaceLedger(doc, {
@@ -441,7 +552,11 @@ export function createGraphRegistryView({
441
552
  );
442
553
  section.append(list);
443
554
  }
444
- section.append(recordDetails(doc, [['定義ID', ontology.id], ['定義の版', ontology.version], ['release digest', ontology.releaseDigest]]));
555
+ section.append(recordDetails(doc, [
556
+ ['定義ID', textOrNull(ontology.id) ?? '未確認'],
557
+ ['定義の版', version ?? '未確認'],
558
+ ['release digest', textOrNull(ontology.releaseDigest) ?? '未確認'],
559
+ ]));
445
560
  return section;
446
561
  }
447
562
 
@@ -573,7 +688,22 @@ export function createGraphRegistryView({
573
688
  })];
574
689
  const edges = payload[direction];
575
690
  if (edges.length === 0) {
576
- content.push(makeElement(doc, 'p', { className: 'bb-graph-empty', text: outgoing ? 'この記録から出る関係はありません。' : 'この記録へ入る関係はありません。' }));
691
+ const availability = relationAvailability(payload, direction);
692
+ if (availability.state === 'unknown') {
693
+ content.push(workspaceNotice(doc, {
694
+ label: '未確認',
695
+ tone: 'warning',
696
+ role: 'status',
697
+ text: availability.reason,
698
+ }));
699
+ content.push(workspaceActions(doc, [workspaceButton(doc, {
700
+ text: '再試行',
701
+ variant: 'quiet',
702
+ onClick: () => controller.loadEntity(entity.id, { keep: true }),
703
+ })]));
704
+ } else {
705
+ content.push(makeElement(doc, 'p', { className: 'bb-graph-empty', text: outgoing ? 'この記録から出る関係はありません。' : 'この記録へ入る関係はありません。' }));
706
+ }
577
707
  } else {
578
708
  const list = makeElement(doc, 'ul', { className: 'bb-graph-compact-list' });
579
709
  for (const edge of edges) list.append(relationItem(edge, entity, payload.asOf));
@@ -584,7 +584,12 @@ export function graphStateNotice(doc, readState, { onRetry, loadingText = '読
584
584
  return workspaceNotice(doc, {
585
585
  label: '読み取り失敗',
586
586
  tone: 'danger',
587
- text: noticeBody(doc, makeElement(doc, 'p', { text: `読み取れませんでした(${readState.reason ?? '理由不明'})。0件ではありません。` }), retryButton(doc, onRetry)),
587
+ text: noticeBody(
588
+ doc,
589
+ makeElement(doc, 'p', { className: 'bb-graph-error-primary', text: '記録を取得できませんでした。' }),
590
+ makeElement(doc, 'small', { className: 'bb-graph-error-detail', text: `読み取れませんでした。詳細: ${readState.reason ?? '理由不明'}。0件ではありません。` }),
591
+ retryButton(doc, onRetry),
592
+ ),
588
593
  });
589
594
  }
590
595
 
@@ -834,6 +839,11 @@ export function buildCorrectionRequest(form) {
834
839
  const name = String(form.draft.name ?? '').trim();
835
840
  if (!name) return { ok: false, message: GRAPH_CORRECTION_ERROR_MESSAGES.correction_name_required };
836
841
  const entity = { type, name };
842
+ if (form.projects.length > 0) {
843
+ const projectCode = String(form.draft.projectCode ?? '');
844
+ if (!form.projects.some((project) => project.code === projectCode)) return { ok: false, message: '人を加えるプロジェクトを選んでください。' };
845
+ entity.projectCode = projectCode;
846
+ }
837
847
  const aliases = splitAliases(form.draft.aliases ?? '').filter((alias) => alias !== name);
838
848
  const summary = String(form.draft.summary ?? '').trim();
839
849
  if (aliases.length > 0) entity.aliases = aliases;
@@ -894,7 +904,11 @@ function readBackFields(form, payload) {
894
904
 
895
905
  /** What a new record is, as rows shown before its values. */
896
906
  function createdEntityRows(form, record) {
897
- return [['種類', form.typeNames[record?.type] ?? typeLabel(record?.type)]];
907
+ const project = form.projects.find((candidate) => candidate.code === form.draft.projectCode);
908
+ return [
909
+ ['種類', form.typeNames[record?.type] ?? typeLabel(record?.type)],
910
+ ...(project ? [['関わるプロジェクト', project.name ?? project.code]] : []),
911
+ ];
898
912
  }
899
913
 
900
914
  /** What a new relation connects, as rows shown before its values. */
@@ -1059,10 +1073,36 @@ function renderCreateEntityFields(doc, form, callbacks) {
1059
1073
  onChange: (value) => callbacks.onInput('type', value),
1060
1074
  }));
1061
1075
  }
1076
+ if (form.projects.length > 0) {
1077
+ group.append(selectControl(doc, {
1078
+ id: `${form.formId}-project`,
1079
+ name: 'projectCode',
1080
+ label: '関わるプロジェクト',
1081
+ value: String(form.draft.projectCode ?? ''),
1082
+ options: [
1083
+ { value: '', label: 'プロジェクトを選んでください' },
1084
+ ...form.projects.map((project) => ({ value: project.code, label: project.name ?? project.code })),
1085
+ ],
1086
+ onChange: (value) => callbacks.onInput('projectCode', value),
1087
+ }));
1088
+ group.append(notice(doc, 'muted', '人はこのプロジェクトの関係者として記録されます。会社への所属は、作った後に加えてください。'));
1089
+ }
1062
1090
  for (const field of form.fields) group.append(fieldControl(doc, form, field, callbacks));
1063
1091
  return group;
1064
1092
  }
1065
1093
 
1094
+ /** The projects a host offers for a new person, as `{ code, name }`, without duplicates. */
1095
+ function creationProjects(projects) {
1096
+ const seen = new Set();
1097
+ return (Array.isArray(projects) ? projects : []).flatMap((project) => {
1098
+ const code = typeof project?.code === 'string' ? project.code.trim() : '';
1099
+ if (!code || seen.has(code)) return [];
1100
+ seen.add(code);
1101
+ const name = typeof project.name === 'string' && project.name.trim() ? project.name.trim() : null;
1102
+ return [{ code, name }];
1103
+ });
1104
+ }
1105
+
1066
1106
  /**
1067
1107
  * The correction panel. `form` is the state held by `createGraphCorrection`;
1068
1108
  * callbacks are the controller's handlers.
@@ -1360,7 +1400,7 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
1360
1400
  * `person` and `org` are created; others are dropped), `typeNames` their
1361
1401
  * labels. Null outside the scope.
1362
1402
  */
1363
- openCreateEntity({ types = ['person', 'org'], type, typeNames = {}, title = '記録を加える', subject } = {}) {
1403
+ openCreateEntity({ types = ['person', 'org'], type, typeNames = {}, title = '記録を加える', subject, projects = [] } = {}) {
1364
1404
  const entityTypes = allowed.createEntities
1365
1405
  ? types.filter((candidate) => (candidate === 'person' || candidate === 'org') && allowed.entity(candidate))
1366
1406
  : [];
@@ -1372,8 +1412,9 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
1372
1412
  entityTypes,
1373
1413
  typeNames,
1374
1414
  fields,
1415
+ projects: entityTypes.length === 1 && entityTypes[0] === 'person' ? creationProjects(projects) : [],
1375
1416
  original: Object.fromEntries(fields.map((field) => [field, ''])),
1376
- draft: { type: entityTypes.includes(type) ? type : entityTypes[0], name: '', aliases: '', summary: '', validFrom: '' },
1417
+ draft: { type: entityTypes.includes(type) ? type : entityTypes[0], name: '', aliases: '', summary: '', validFrom: '', projectCode: '' },
1377
1418
  });
1378
1419
  redraw();
1379
1420
  return form;
@@ -1194,7 +1194,11 @@ function statusNotice(doc, phase, error, home, onRetry) {
1194
1194
  if (phase === 'loading') return workspaceNotice(doc, { label: '読み込み中', text: '判断履歴を確認しています。', tone: 'info' });
1195
1195
  if (phase === 'error') return workspaceNotice(doc, {
1196
1196
  label: '取得できません',
1197
- text: [`判断の記録を確認できません。${error ? `理由: ${error}` : ''} `, workspaceButton(doc, { text: '再読み込み', variant: 'quiet', onClick: onRetry })],
1197
+ text: [
1198
+ { className: 'bb-jh-error-primary', text: '判断履歴を取得できませんでした。' },
1199
+ error ? { className: 'bb-jh-error-detail', text: `詳細: ${error}` } : null,
1200
+ workspaceButton(doc, { text: '再読み込み', variant: 'quiet', onClick: onRetry }),
1201
+ ],
1198
1202
  tone: 'danger',
1199
1203
  role: 'alert',
1200
1204
  });
@@ -233,7 +233,10 @@ export function createMeetingMinutesUI({
233
233
  );
234
234
  const status = makeElement(doc, 'p', { className: 'bb-minutes-status', attrs: { role: 'status', 'aria-live': 'polite' } });
235
235
  const columns = makeElement(doc, 'div', { className: 'bb-minutes-columns' });
236
- const listPane = makeElement(doc, 'section', { className: 'bb-minutes-list-pane', attrs: { 'aria-label': '会議一覧' } });
236
+ const listPane = makeElement(doc, 'section', {
237
+ className: 'bb-minutes-list-pane',
238
+ attrs: { 'aria-label': '会議一覧', tabindex: '-1' },
239
+ });
237
240
  const detailPane = makeElement(doc, 'section', { className: 'bb-minutes-detail-pane', attrs: { 'aria-label': '会議詳細' } });
238
241
  columns.append(listPane, detailPane);
239
242
  screen.append(heading, status, columns);
@@ -288,7 +291,7 @@ export function createMeetingMinutesUI({
288
291
  head.append(button(doc, '会議を作成', () => renderCreateForm(), { className: 'bb-minutes-button is-primary' }));
289
292
  listPane.append(head);
290
293
  if (state.listError) {
291
- appendLoadError(listPane, `会議一覧を読み込めませんでした。理由: ${state.listError}`, loadMeetings);
294
+ appendLoadError(listPane, `会議一覧を読み込めませんでした。理由: ${state.listError}`, () => loadMeetings({ restoreFocus: true }));
292
295
  }
293
296
  if (state.meetings.length === 0) {
294
297
  if (state.listError) return;
@@ -538,7 +541,7 @@ export function createMeetingMinutesUI({
538
541
  detailPane.append(form);
539
542
  }
540
543
 
541
- async function loadMeetings() {
544
+ async function loadMeetings({ restoreFocus = false } = {}) {
542
545
  state.loading = true;
543
546
  setStatus('会議一覧を読み込んでいます。');
544
547
  try {
@@ -562,6 +565,7 @@ export function createMeetingMinutesUI({
562
565
  renderList();
563
566
  if (state.detailError && state.detail === null) renderDetail(null);
564
567
  setStatus(state.meetings.length ? `${state.meetings.length}件の会議を読み込みました。` : '保存された会議はありません。');
568
+ if (restoreFocus && typeof listPane.focus === 'function') listPane.focus({ preventScroll: true });
565
569
  } catch (error) {
566
570
  state.listError = loadErrorMessage(error, '会議一覧を読み込めませんでした。');
567
571
  const preserve = preserveLoadedStateOnFailure(error);
@@ -10,6 +10,8 @@
10
10
  * Constraint links are only read here; there is no replace method.
11
11
  */
12
12
 
13
+ import { normalizeObjectiveReadback } from './objective-readback-contract.js';
14
+
13
15
  export const OBJECTIVE_EDITOR_HTTP_PORT_VERSION = 'brainbase.objective-editor-http-port.v1';
14
16
 
15
17
  /** Top-level fields the Foundation route rejects with `authority_field_in_body`. */
@@ -93,7 +95,7 @@ export function createObjectiveEditorHttpPort({
93
95
  : typeof globalThis.fetch === 'function' ? (path, init) => globalThis.fetch(path, init) : null;
94
96
  const base = String(basePath).replace(/\/+$/u, '');
95
97
 
96
- async function call(path, init) {
98
+ async function call(path, init, { allowObjectiveReadback = false, expected } = {}) {
97
99
  if (!request) throw new ObjectiveEditorHttpError({ code: 'network_error', status: 0, message: ERROR_MESSAGES.network_error });
98
100
  let response;
99
101
  try {
@@ -107,7 +109,14 @@ export function createObjectiveEditorHttpPort({
107
109
  } catch {
108
110
  payload = null;
109
111
  }
110
- if (!response.ok) throw errorFromResponse(response.status, payload);
112
+ // Detail GETs use a typed envelope for both a canonical record and a
113
+ // confirmed miss/read failure. Accept that envelope even when its HTTP
114
+ // status is 404/5xx; every other non-2xx response remains an error.
115
+ if (!response.ok) {
116
+ const readback = allowObjectiveReadback ? normalizeObjectiveReadback(payload, expected) : null;
117
+ if (readback) return readback;
118
+ throw errorFromResponse(response.status, payload);
119
+ }
111
120
  if (payload === null) throw new ObjectiveEditorHttpError({ code: 'invalid_response', status: response.status, message: ERROR_MESSAGES.invalid_response });
112
121
  return payload;
113
122
  }
@@ -128,13 +137,17 @@ export function createObjectiveEditorHttpPort({
128
137
  return payload;
129
138
  },
130
139
  async readObjective(id, _context, revision) {
131
- try {
132
- return await call(withRevision(objectivePath(id), revision));
133
- } catch (error) {
134
- // A missing Objective is a readback miss, not a failure of the request.
135
- if (error instanceof ObjectiveEditorHttpError && error.status === 404 && error.code === 'not_found') return null;
136
- throw error;
140
+ const expected = { id, revision };
141
+ const payload = await call(withRevision(objectivePath(id), revision), undefined, { allowObjectiveReadback: true, expected });
142
+ const readback = normalizeObjectiveReadback(payload, expected);
143
+ if (!readback) {
144
+ throw new ObjectiveEditorHttpError({
145
+ code: 'invalid_response',
146
+ status: 200,
147
+ message: ERROR_MESSAGES.invalid_response,
148
+ });
137
149
  }
150
+ return readback;
138
151
  },
139
152
  checkObjectiveReadiness(id, _context, revision) {
140
153
  return call(withRevision(objectivePath(id, '/readiness'), revision));