@unson/brainbase-mcp 0.10.3 → 0.10.5

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.
@@ -1,6 +1,8 @@
1
- import { FOUNDATION_HTTP_CONTRACT_VERSION, createFoundationHttpRouter } from './foundation-http.js';
1
+ import { FOUNDATION_HTTP_CONTRACT_VERSION, createFoundationHttpRouter, createObjectiveFoundationRoute } from './foundation-http.js';
2
+ import { checkObjectiveReadiness } from './company-os-objectives.js';
2
3
  import { createFoundationPublicProvider, createFoundationPublicRoute } from './foundation-public-provider.js';
3
4
  import { createGraphFoundationReaders } from './graph-foundation-reader.js';
5
+ import { FoundationStoreError } from './foundation-store.js';
4
6
  /**
5
7
  * Readiness probe for the Graph history contract used by the public adapter.
6
8
  *
@@ -217,16 +219,36 @@ export function createFoundationGraphHttpHandler(options) {
217
219
  }
218
220
  const readers = createGraphFoundationReaders({
219
221
  context,
222
+ selectedProjectCode: scopeId,
220
223
  query: async (text, values) => ({
221
224
  rows: (await client.query(text, values ?? [])).rows
222
225
  })
223
226
  });
224
227
  const provider = createFoundationPublicProvider(readers);
225
228
  const route = createFoundationPublicRoute(provider);
229
+ const objectiveRoute = createObjectiveFoundationRoute({
230
+ objectives: {
231
+ async createObjective() {
232
+ throw new FoundationStoreError('unsupported_graph', 'Objective writes are not configured for the Graph read adapter');
233
+ },
234
+ async readObjective(id, objectiveContext, revision) {
235
+ return revision === undefined
236
+ ? readers.store.readLatest('objective', id, objectiveContext)
237
+ : readers.store.read({ id, type: 'objective', revision }, objectiveContext);
238
+ },
239
+ async updateObjective() {
240
+ throw new FoundationStoreError('unsupported_graph', 'Objective writes are not configured for the Graph read adapter');
241
+ },
242
+ async checkObjectiveReadiness(id, objectiveContext, revision) {
243
+ return checkObjectiveReadiness(readers.store, id, objectiveContext, revision);
244
+ },
245
+ list: (type, objectiveContext) => readers.store.list(type, objectiveContext)
246
+ }
247
+ });
226
248
  const handler = createFoundationHttpRouter({
227
249
  resolveContext: () => context,
228
250
  csrf: options.csrf,
229
- routes: [route],
251
+ routes: [route, objectiveRoute],
230
252
  bodyLimitBytes
231
253
  });
232
254
  return handler.handle(request);
@@ -13,6 +13,18 @@ export declare const GRAPH_FOUNDATION_READ_CONTRACT_VERSION: "graph-foundation-h
13
13
  * future providers fail closed when they do not prove current-row visibility.
14
14
  */
15
15
  export declare const GRAPH_FOUNDATION_READ_SQL = "\nSELECT\n history.entity_id,\n history.entity_type,\n history.revision,\n history.payload,\n history.project_id,\n history.role_min,\n history.sensitivity,\n history.lifecycle_status,\n history.storage_digest,\n history.captured_at,\n (\n history.storage_digest = 'sha256:' ||\n encode(sha256(convert_to(history.payload::text, 'UTF8')), 'hex')\n ) AS storage_digest_valid,\n current_entity.id AS current_entity_id,\n current_entity.entity_type AS current_entity_type,\n current_entity.payload AS current_payload,\n current_entity.project_id AS current_project_id,\n current_entity.role_min AS current_role_min,\n current_entity.sensitivity AS current_sensitivity,\n current_entity.lifecycle_status AS current_lifecycle_status,\n projects.code AS current_project_code,\n true AS current_visible\nFROM public.graph_foundation_revisions AS history\nJOIN public.graph_entities AS current_entity\n ON current_entity.id = history.entity_id\n AND current_entity.entity_type = history.entity_type\nJOIN public.projects AS projects\n ON projects.id = current_entity.project_id\nWHERE history.entity_id = $1\n AND history.entity_type = $2\n AND history.revision = $3\n";
16
+ /**
17
+ * Read the newest immutable revision for one Foundation entity. The current
18
+ * Graph row remains part of the query so RLS and the adapter's current-row
19
+ * checks apply identically to latest and historical reads.
20
+ */
21
+ export declare const GRAPH_FOUNDATION_LATEST_SQL = "\nSELECT\n history.entity_id,\n history.entity_type,\n history.revision,\n history.payload,\n history.project_id,\n history.role_min,\n history.sensitivity,\n history.lifecycle_status,\n history.storage_digest,\n history.captured_at,\n (\n history.storage_digest = 'sha256:' ||\n encode(sha256(convert_to(history.payload::text, 'UTF8')), 'hex')\n ) AS storage_digest_valid,\n current_entity.id AS current_entity_id,\n current_entity.entity_type AS current_entity_type,\n current_entity.payload AS current_payload,\n current_entity.project_id AS current_project_id,\n current_entity.role_min AS current_role_min,\n current_entity.sensitivity AS current_sensitivity,\n current_entity.lifecycle_status AS current_lifecycle_status,\n projects.code AS current_project_code,\n true AS current_visible\nFROM public.graph_entities AS current_entity\nLEFT JOIN public.projects AS projects\n ON projects.id = current_entity.project_id\nLEFT JOIN LATERAL (\n SELECT history.*\n FROM public.graph_foundation_revisions AS history\n WHERE history.entity_id = current_entity.id\n AND history.entity_type = current_entity.entity_type\n ORDER BY history.revision::numeric DESC\n LIMIT 1\n) AS history ON true\nWHERE current_entity.id = $1\n AND current_entity.entity_type = $2\nORDER BY history.revision::numeric DESC NULLS LAST\nLIMIT 1\n";
22
+ /**
23
+ * List the newest revision of each Foundation entity visible in the selected
24
+ * project. The project filter is by canonical project code, which is the
25
+ * scope identifier selected by the organization BFF.
26
+ */
27
+ export declare const GRAPH_FOUNDATION_LIST_SQL = "\nSELECT\n history.entity_id,\n history.entity_type,\n history.revision,\n history.payload,\n history.project_id,\n history.role_min,\n history.sensitivity,\n history.lifecycle_status,\n history.storage_digest,\n history.captured_at,\n (\n history.storage_digest = 'sha256:' ||\n encode(sha256(convert_to(history.payload::text, 'UTF8')), 'hex')\n ) AS storage_digest_valid,\n current_entity.id AS current_entity_id,\n current_entity.entity_type AS current_entity_type,\n current_entity.payload AS current_payload,\n current_entity.project_id AS current_project_id,\n current_entity.role_min AS current_role_min,\n current_entity.sensitivity AS current_sensitivity,\n current_entity.lifecycle_status AS current_lifecycle_status,\n projects.code AS current_project_code,\n true AS current_visible\nFROM public.graph_entities AS current_entity\nLEFT JOIN public.projects AS projects\n ON projects.id = current_entity.project_id\nLEFT JOIN LATERAL (\n SELECT history.*\n FROM public.graph_foundation_revisions AS history\n WHERE history.entity_id = current_entity.id\n AND history.entity_type = current_entity.entity_type\n ORDER BY history.revision::numeric DESC\n LIMIT 1\n) AS history ON true\nWHERE ($1::text IS NULL OR current_entity.entity_type = $1)\n AND ($2::text IS NULL OR projects.code = $2 OR projects.id IS NULL)\n AND current_entity.entity_type IN ('objective', 'variable', 'model', 'constraint')\nORDER BY current_entity.id, current_entity.entity_type\n";
16
28
  /** Flat row shape returned by GRAPH_FOUNDATION_READ_SQL. */
17
29
  export interface GraphFoundationHistoryRow {
18
30
  readonly entity_id?: unknown;
@@ -46,9 +58,11 @@ export interface GraphFoundationReaderOptions {
46
58
  readonly context: FoundationStoreContext;
47
59
  /** Query callback bound to the host's already-authenticated transaction. */
48
60
  readonly query: GraphFoundationQuery;
61
+ /** Canonical project code selected by the organization boundary. */
62
+ readonly selectedProjectCode?: string;
49
63
  }
50
64
  export interface GraphFoundationReaders {
51
- readonly store: Pick<FoundationRevisionStore, 'read'>;
65
+ readonly store: Pick<FoundationRevisionStore, 'read' | 'readLatest' | 'list'>;
52
66
  readonly philosophyReader: PhilosophyRevisionReader;
53
67
  }
54
68
  /**
@@ -50,6 +50,98 @@ WHERE history.entity_id = $1
50
50
  AND history.entity_type = $2
51
51
  AND history.revision = $3
52
52
  `;
53
+ /**
54
+ * Read the newest immutable revision for one Foundation entity. The current
55
+ * Graph row remains part of the query so RLS and the adapter's current-row
56
+ * checks apply identically to latest and historical reads.
57
+ */
58
+ export const GRAPH_FOUNDATION_LATEST_SQL = `
59
+ SELECT
60
+ history.entity_id,
61
+ history.entity_type,
62
+ history.revision,
63
+ history.payload,
64
+ history.project_id,
65
+ history.role_min,
66
+ history.sensitivity,
67
+ history.lifecycle_status,
68
+ history.storage_digest,
69
+ history.captured_at,
70
+ (
71
+ history.storage_digest = 'sha256:' ||
72
+ encode(sha256(convert_to(history.payload::text, 'UTF8')), 'hex')
73
+ ) AS storage_digest_valid,
74
+ current_entity.id AS current_entity_id,
75
+ current_entity.entity_type AS current_entity_type,
76
+ current_entity.payload AS current_payload,
77
+ current_entity.project_id AS current_project_id,
78
+ current_entity.role_min AS current_role_min,
79
+ current_entity.sensitivity AS current_sensitivity,
80
+ current_entity.lifecycle_status AS current_lifecycle_status,
81
+ projects.code AS current_project_code,
82
+ true AS current_visible
83
+ FROM public.graph_entities AS current_entity
84
+ LEFT JOIN public.projects AS projects
85
+ ON projects.id = current_entity.project_id
86
+ LEFT JOIN LATERAL (
87
+ SELECT history.*
88
+ FROM public.graph_foundation_revisions AS history
89
+ WHERE history.entity_id = current_entity.id
90
+ AND history.entity_type = current_entity.entity_type
91
+ ORDER BY history.revision::numeric DESC
92
+ LIMIT 1
93
+ ) AS history ON true
94
+ WHERE current_entity.id = $1
95
+ AND current_entity.entity_type = $2
96
+ ORDER BY history.revision::numeric DESC NULLS LAST
97
+ LIMIT 1
98
+ `;
99
+ /**
100
+ * List the newest revision of each Foundation entity visible in the selected
101
+ * project. The project filter is by canonical project code, which is the
102
+ * scope identifier selected by the organization BFF.
103
+ */
104
+ export const GRAPH_FOUNDATION_LIST_SQL = `
105
+ SELECT
106
+ history.entity_id,
107
+ history.entity_type,
108
+ history.revision,
109
+ history.payload,
110
+ history.project_id,
111
+ history.role_min,
112
+ history.sensitivity,
113
+ history.lifecycle_status,
114
+ history.storage_digest,
115
+ history.captured_at,
116
+ (
117
+ history.storage_digest = 'sha256:' ||
118
+ encode(sha256(convert_to(history.payload::text, 'UTF8')), 'hex')
119
+ ) AS storage_digest_valid,
120
+ current_entity.id AS current_entity_id,
121
+ current_entity.entity_type AS current_entity_type,
122
+ current_entity.payload AS current_payload,
123
+ current_entity.project_id AS current_project_id,
124
+ current_entity.role_min AS current_role_min,
125
+ current_entity.sensitivity AS current_sensitivity,
126
+ current_entity.lifecycle_status AS current_lifecycle_status,
127
+ projects.code AS current_project_code,
128
+ true AS current_visible
129
+ FROM public.graph_entities AS current_entity
130
+ LEFT JOIN public.projects AS projects
131
+ ON projects.id = current_entity.project_id
132
+ LEFT JOIN LATERAL (
133
+ SELECT history.*
134
+ FROM public.graph_foundation_revisions AS history
135
+ WHERE history.entity_id = current_entity.id
136
+ AND history.entity_type = current_entity.entity_type
137
+ ORDER BY history.revision::numeric DESC
138
+ LIMIT 1
139
+ ) AS history ON true
140
+ WHERE ($1::text IS NULL OR current_entity.entity_type = $1)
141
+ AND ($2::text IS NULL OR projects.code = $2 OR projects.id IS NULL)
142
+ AND current_entity.entity_type IN ('objective', 'variable', 'model', 'constraint')
143
+ ORDER BY current_entity.id, current_entity.entity_type
144
+ `;
53
145
  const FOUNDATION_TYPES = ['objective', 'variable', 'model', 'constraint'];
54
146
  const REVISION_PATTERN = /^[1-9]\d*$/u;
55
147
  const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u;
@@ -65,6 +157,7 @@ export function createGraphFoundationReaders(options) {
65
157
  assertReaderOptions(options);
66
158
  const context = cloneContext(options.context);
67
159
  const query = options.query;
160
+ const selectedProjectCode = options.selectedProjectCode;
68
161
  const store = {
69
162
  async read(reference, suppliedContext) {
70
163
  assertTrustedPrincipal(context, suppliedContext);
@@ -75,20 +168,42 @@ export function createGraphFoundationReaders(options) {
75
168
  if (rows.length !== 1) {
76
169
  throw corrupt('Graph foundation history returned multiple rows for one revision');
77
170
  }
171
+ return validateFoundationRecord(rows[0], reference, context, selectedProjectCode);
172
+ },
173
+ async readLatest(type, id, suppliedContext) {
174
+ assertTrustedPrincipal(context, suppliedContext);
175
+ assertFoundationType(type);
176
+ assertEntityId(id);
177
+ const rows = await executeLatestRead(query, id, type);
178
+ if (rows.length === 0)
179
+ return null;
180
+ if (rows.length !== 1) {
181
+ throw corrupt('Graph foundation latest query returned multiple rows');
182
+ }
78
183
  const row = rows[0];
79
- const history = validateHistoryRow(row, reference.id, reference.type, reference.revision);
80
- const current = validateCurrentRow(row, reference.id, reference.type);
81
- authorizeCurrentFoundation(current, context);
82
- // A history row remains readable only through the current Graph row,
83
- // including its current project and current scope. The old payload is
84
- // returned unchanged after these current-row checks.
85
- if (history.projectId !== current.projectId) {
86
- throw scopeViolation('Historical foundation project differs from current Graph project');
184
+ const reference = rowReference(row);
185
+ if (reference.id !== id || reference.type !== type) {
186
+ throw corrupt('Graph foundation latest row identity does not match the request');
87
187
  }
88
- return {
89
- definition: cloneFoundationDefinition(history.definition),
90
- digest: digestFoundationDefinition(history.definition)
91
- };
188
+ return validateFoundationRecord(row, reference, context, selectedProjectCode, true);
189
+ },
190
+ async list(type, suppliedContext) {
191
+ assertTrustedPrincipal(context, suppliedContext);
192
+ if (type !== undefined)
193
+ assertFoundationType(type);
194
+ const rows = await executeList(query, type, selectedProjectCode);
195
+ const seen = new Set();
196
+ return rows.map((row) => {
197
+ const reference = rowReference(row);
198
+ if (type !== undefined && reference.type !== type) {
199
+ throw corrupt('Graph foundation list returned an unexpected entity type');
200
+ }
201
+ const key = `${reference.type}:${reference.id}`;
202
+ if (seen.has(key))
203
+ throw corrupt('Graph foundation list returned duplicate entity rows');
204
+ seen.add(key);
205
+ return validateFoundationRecord(row, reference, context, selectedProjectCode, true);
206
+ });
92
207
  }
93
208
  };
94
209
  const philosophyReader = {
@@ -254,6 +369,66 @@ async function executeRead(query, id, type, revision) {
254
369
  }
255
370
  throw corrupt('Graph foundation history query returned an invalid result');
256
371
  }
372
+ async function executeLatestRead(query, id, type) {
373
+ let result;
374
+ try {
375
+ result = await query(GRAPH_FOUNDATION_LATEST_SQL, [id, type]);
376
+ }
377
+ catch {
378
+ throw corrupt('Graph foundation latest query failed');
379
+ }
380
+ if (Array.isArray(result))
381
+ return result;
382
+ if (!Array.isArray(result) && 'rows' in result && Array.isArray(result.rows)) {
383
+ return result.rows;
384
+ }
385
+ throw corrupt('Graph foundation latest query returned an invalid result');
386
+ }
387
+ async function executeList(query, type, selectedProjectCode) {
388
+ let result;
389
+ try {
390
+ result = await query(GRAPH_FOUNDATION_LIST_SQL, [type ?? null, selectedProjectCode ?? null]);
391
+ }
392
+ catch {
393
+ throw corrupt('Graph foundation list query failed');
394
+ }
395
+ if (Array.isArray(result))
396
+ return result;
397
+ if (!Array.isArray(result) && 'rows' in result && Array.isArray(result.rows)) {
398
+ return result.rows;
399
+ }
400
+ throw corrupt('Graph foundation list query returned an invalid result');
401
+ }
402
+ function validateFoundationRecord(row, reference, context, selectedProjectCode, requireCurrentRevision = false) {
403
+ const history = validateHistoryRow(row, reference.id, reference.type, reference.revision);
404
+ const current = validateCurrentRow(row, reference.id, reference.type);
405
+ if (requireCurrentRevision && current.projectCode === undefined) {
406
+ throw corrupt('Current Graph project code is missing');
407
+ }
408
+ authorizeCurrentFoundation(current, context, selectedProjectCode);
409
+ // Latest reads must prove that the current Graph payload points at the
410
+ // revision returned by the history query. Otherwise a missing newer history
411
+ // row could silently fall back to an older immutable revision.
412
+ if (requireCurrentRevision && current.foundation?.revision !== reference.revision) {
413
+ throw corrupt('Current Graph foundation revision does not match the latest history revision');
414
+ }
415
+ // A history row remains readable only through the current Graph row,
416
+ // including its current project and current scope. The old payload is
417
+ // returned unchanged after these current-row checks.
418
+ if (history.projectId !== current.projectId) {
419
+ throw scopeViolation('Historical foundation project differs from current Graph project');
420
+ }
421
+ return {
422
+ definition: cloneFoundationDefinition(history.definition),
423
+ digest: digestFoundationDefinition(history.definition)
424
+ };
425
+ }
426
+ function rowReference(row) {
427
+ const id = requireString(row.entity_id, 'Foundation history entity_id');
428
+ const type = parseFoundationType(row.entity_type);
429
+ const revision = requireRevision(row.revision, 'Foundation history revision');
430
+ return { id, type, revision };
431
+ }
257
432
  function validateHistoryRow(row, id, type, revision) {
258
433
  assertRowIdentity(row, id, type, revision);
259
434
  assertStorageDigest(row);
@@ -301,21 +476,27 @@ function validateCurrentRow(row, id, type) {
301
476
  throw new FoundationStoreError('authorization_denied', 'The current Graph row is not active');
302
477
  }
303
478
  const projectId = requireString(row.current_project_id, 'Current Graph project_id');
479
+ const projectCode = typeof row.current_project_code === 'string' && row.current_project_code.length > 0
480
+ ? row.current_project_code
481
+ : undefined;
304
482
  const payload = requireObject(row.current_payload, 'Current Graph payload');
305
483
  if (type === 'philosophy') {
306
484
  const applicability = parseJudgmentApplicability(payload);
307
- return { payload, projectId, philosophy: { applicability } };
485
+ return { payload, projectId, projectCode, philosophy: { applicability } };
308
486
  }
309
487
  const definition = requireObject(payload.foundation, 'Current Graph payload.foundation');
310
488
  const result = validateFoundationDefinition(definition, { use: 'draft' });
311
489
  if (!result.valid || definition.id !== id || definition.type !== type) {
312
490
  throw corrupt('Current Graph foundation definition is invalid');
313
491
  }
314
- return { payload, projectId, foundation: definition };
492
+ return { payload, projectId, projectCode, foundation: definition };
315
493
  }
316
- function authorizeCurrentFoundation(current, context) {
494
+ function authorizeCurrentFoundation(current, context, selectedProjectCode) {
317
495
  if (!current.foundation)
318
496
  throw corrupt('Current Graph foundation definition is missing');
497
+ if (selectedProjectCode !== undefined && current.projectCode !== selectedProjectCode) {
498
+ throw scopeViolation('The current Graph project is outside the selected project scope');
499
+ }
319
500
  const definition = current.foundation;
320
501
  if (!canRead(definition.acl, context.principal)) {
321
502
  throw new FoundationStoreError('authorization_denied', 'The current foundation ACL does not permit this principal');
@@ -334,6 +515,28 @@ function assertRowIdentity(row, id, type, revision) {
334
515
  throw corrupt('Foundation history identity does not match the requested revision');
335
516
  }
336
517
  }
518
+ function parseFoundationType(value) {
519
+ if (typeof value === 'string' && FOUNDATION_TYPES.includes(value)) {
520
+ return value;
521
+ }
522
+ throw corrupt('Foundation history entity_type is invalid');
523
+ }
524
+ function requireRevision(value, label) {
525
+ if (typeof value !== 'string' || !REVISION_PATTERN.test(value)) {
526
+ throw corrupt(`${label} must be a positive revision`);
527
+ }
528
+ return value;
529
+ }
530
+ function assertEntityId(value) {
531
+ if (typeof value !== 'string' || value.trim().length === 0 || value.includes('\0')) {
532
+ throw new FoundationStoreError('invalid_input', 'A foundation entity id is required');
533
+ }
534
+ }
535
+ function assertFoundationType(value) {
536
+ if (!FOUNDATION_TYPES.includes(value)) {
537
+ throw new FoundationStoreError('invalid_input', 'A supported foundation type is required');
538
+ }
539
+ }
337
540
  function assertFoundationReference(reference) {
338
541
  if (!isPlainRecord(reference) || typeof reference.id !== 'string' || reference.id.length === 0 ||
339
542
  !FOUNDATION_TYPES.includes(reference.type) ||
@@ -365,6 +568,10 @@ function assertReaderOptions(options) {
365
568
  if (!isValidFoundationScope(scope))
366
569
  throw new TypeError('A trusted Graph foundation scope is invalid');
367
570
  }
571
+ if (options.selectedProjectCode !== undefined
572
+ && (typeof options.selectedProjectCode !== 'string' || options.selectedProjectCode.trim().length === 0)) {
573
+ throw new TypeError('A selected Graph project code is invalid');
574
+ }
368
575
  }
369
576
  function assertTrustedPrincipal(trusted, supplied) {
370
577
  if (!supplied || supplied.principal !== trusted.principal) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.10.3",
3
+ "version": "0.10.5",
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",
@@ -336,5 +336,5 @@
336
336
  "engines": {
337
337
  "node": ">=20"
338
338
  },
339
- "gitHead": "8abcea403a82666c8c4616d4023fb28a10af761a"
339
+ "gitHead": "8cfad04bef7bf934165d96b979e9e5d52dd837dc"
340
340
  }
@@ -325,16 +325,62 @@ function contextSectionSkeleton(doc, { title = null, message = null } = {}) {
325
325
  return group;
326
326
  }
327
327
 
328
+ function renderProjectContextGroup(doc, item) {
329
+ const group = makeElement(doc, 'section', {
330
+ className: `bb-pkw-context-group${item.state === 'loading' ? ' bb-pkw-context-group-loading' : ''}`,
331
+ attrs: { 'aria-label': item.title, ...(item.state === 'loading' ? { 'aria-busy': 'true' } : {}) },
332
+ });
333
+ const heading = makeElement(doc, 'div', { className: 'bb-pkw-context-heading' });
334
+ heading.append(makeElement(doc, 'h4', { text: item.title }), badge(doc, item.state === 'ok' ? '利用可能' : item.state === 'loading' ? '読み込み中' : item.state === 'failed' ? '読み取り失敗' : '未接続', item.state === 'ok' ? 'success' : item.state === 'failed' ? 'danger' : 'muted'));
335
+ group.append(heading);
336
+ if (item.message && !['failed', 'unavailable'].includes(item.state)) group.append(makeElement(doc, 'p', { className: 'bb-pkw-muted', text: item.message }));
337
+ if (item.asOf) group.append(makeElement(doc, 'small', { className: 'bb-pkw-muted', text: `読み取った時点: ${formatDateTime(item.asOf)}` }));
338
+ if (item.state === 'loading') {
339
+ group.append(skeletonBars(doc));
340
+ } else if (item.state === 'failed') {
341
+ group.append(workspaceNotice(doc, { label: '読み取り失敗', text: item.message ?? 'このセクションを読み取れませんでした。', tone: 'danger', role: 'alert' }));
342
+ } else if (item.state === 'unavailable') {
343
+ group.append(workspaceNotice(doc, { label: '未接続', text: item.message ?? 'このセクションは接続されていません。', tone: 'warning' }));
344
+ } else if (item.items.length > 0) {
345
+ const list = makeElement(doc, 'ul', { className: 'bb-pkw-context-list' });
346
+ for (const entry of item.items) {
347
+ const row = makeElement(doc, 'li');
348
+ row.append(makeElement(doc, 'strong', { text: entry.title }));
349
+ if (entry.status) row.append(badge(doc, entry.status, 'muted'));
350
+ const facts = [entry.summary, entry.owner ? `担当: ${entry.owner}` : null, entry.dueAt ? `期限: ${entry.dueAt}` : null, entry.updatedAt ? `更新: ${entry.updatedAt}` : null, entry.source ? `出典: ${entry.source}` : null].filter(Boolean);
351
+ if (facts.length > 0) row.append(makeElement(doc, 'p', { className: 'bb-pkw-muted', text: facts.join(' · ') }));
352
+ list.append(row);
353
+ }
354
+ group.append(list);
355
+ } else if (item.state === 'ok') {
356
+ group.append(makeElement(doc, 'p', { className: 'bb-pkw-muted', text: 'このセクションに表示できる記録はありません。' }));
357
+ }
358
+ return group;
359
+ }
360
+
361
+ function projectContextInsertionAnchor(doc) {
362
+ return makeElement(doc, 'span', {
363
+ className: 'bb-pkw-context-anchor',
364
+ attrs: { 'aria-hidden': 'true', hidden: 'true' },
365
+ });
366
+ }
367
+
328
368
  /** Renders supplementary context while preserving each section's own state. */
329
- export function renderProjectContext(doc, context) {
369
+ export function renderProjectContext(doc, context, {
370
+ sections: selectedSections = null,
371
+ includeStatus = true,
372
+ includeHeading = true,
373
+ } = {}) {
330
374
  if (!context) return null;
331
- const sections = Array.isArray(context.sections) ? context.sections : [];
375
+ const sections = Array.isArray(selectedSections)
376
+ ? selectedSections
377
+ : Array.isArray(context.sections) ? context.sections : [];
332
378
  const section = makeElement(doc, 'section', {
333
379
  className: 'bb-pkw-section bb-pkw-context',
334
380
  attrs: { 'aria-label': '補足情報' },
335
381
  });
336
- section.append(makeElement(doc, 'h3', { text: '補足情報' }));
337
- if (context.state !== 'ok') {
382
+ if (includeHeading) section.append(makeElement(doc, 'h3', { text: '補足情報' }));
383
+ if (includeStatus && context.state !== 'ok') {
338
384
  if (context.state === 'loading') {
339
385
  section.append(workspaceNotice(doc, { label: '読み込み中', text: context.message ?? '補足情報を読み込んでいます。', tone: 'info', role: 'status' }));
340
386
  if (sections.length === 0) section.append(contextSectionSkeleton(doc));
@@ -342,42 +388,11 @@ export function renderProjectContext(doc, context) {
342
388
  section.append(workspaceNotice(doc, { label: context.state === 'failed' ? '読み取り失敗' : '未接続', text: context.message ?? '補足情報を確認できません。', tone: context.state === 'failed' ? 'danger' : 'warning' }));
343
389
  }
344
390
  }
345
- if (context.state === 'ok' && sections.length === 0) {
391
+ if (includeStatus && context.state === 'ok' && sections.length === 0) {
346
392
  section.append(makeElement(doc, 'p', { className: 'bb-pkw-muted', text: '補足情報のセクションはありません。' }));
347
393
  return section;
348
394
  }
349
- for (const item of sections) {
350
- const group = makeElement(doc, 'section', {
351
- className: `bb-pkw-context-group${item.state === 'loading' ? ' bb-pkw-context-group-loading' : ''}`,
352
- attrs: { 'aria-label': item.title, ...(item.state === 'loading' ? { 'aria-busy': 'true' } : {}) },
353
- });
354
- const heading = makeElement(doc, 'div', { className: 'bb-pkw-context-heading' });
355
- heading.append(makeElement(doc, 'h4', { text: item.title }), badge(doc, item.state === 'ok' ? '利用可能' : item.state === 'loading' ? '読み込み中' : item.state === 'failed' ? '読み取り失敗' : '未接続', item.state === 'ok' ? 'success' : item.state === 'failed' ? 'danger' : 'muted'));
356
- group.append(heading);
357
- if (item.message && !['failed', 'unavailable'].includes(item.state)) group.append(makeElement(doc, 'p', { className: 'bb-pkw-muted', text: item.message }));
358
- if (item.asOf) group.append(makeElement(doc, 'small', { className: 'bb-pkw-muted', text: `読み取った時点: ${formatDateTime(item.asOf)}` }));
359
- if (item.state === 'loading') {
360
- group.append(skeletonBars(doc));
361
- } else if (item.state === 'failed') {
362
- group.append(workspaceNotice(doc, { label: '読み取り失敗', text: item.message ?? 'このセクションを読み取れませんでした。', tone: 'danger', role: 'alert' }));
363
- } else if (item.state === 'unavailable') {
364
- group.append(workspaceNotice(doc, { label: '未接続', text: item.message ?? 'このセクションは接続されていません。', tone: 'warning' }));
365
- } else if (item.items.length > 0) {
366
- const list = makeElement(doc, 'ul', { className: 'bb-pkw-context-list' });
367
- for (const entry of item.items) {
368
- const row = makeElement(doc, 'li');
369
- row.append(makeElement(doc, 'strong', { text: entry.title }));
370
- if (entry.status) row.append(badge(doc, entry.status, 'muted'));
371
- const facts = [entry.summary, entry.owner ? `担当: ${entry.owner}` : null, entry.dueAt ? `期限: ${entry.dueAt}` : null, entry.updatedAt ? `更新: ${entry.updatedAt}` : null, entry.source ? `出典: ${entry.source}` : null].filter(Boolean);
372
- if (facts.length > 0) row.append(makeElement(doc, 'p', { className: 'bb-pkw-muted', text: facts.join(' · ') }));
373
- list.append(row);
374
- }
375
- group.append(list);
376
- } else if (item.state === 'ok') {
377
- group.append(makeElement(doc, 'p', { className: 'bb-pkw-muted', text: 'このセクションに表示できる記録はありません。' }));
378
- }
379
- section.append(group);
380
- }
395
+ for (const item of sections) section.append(renderProjectContextGroup(doc, item));
381
396
  return section;
382
397
  }
383
398
 
@@ -387,6 +402,12 @@ export function renderProjectKnowledgeSkeleton(doc, { context = null } = {}) {
387
402
  className: 'bb-pkw-panel bb-pkw-overview bb-pkw-overview-loading',
388
403
  attrs: { 'aria-label': '概要' },
389
404
  });
405
+ const contextSections = Array.isArray(context?.sections) ? context.sections : [];
406
+ const readySections = contextSections.filter((item) => item?.state === 'ok' && Array.isArray(item.items) && item.items.length > 0);
407
+ const deferredSections = contextSections.filter((item) => !readySections.includes(item));
408
+ if (readySections.length > 0) {
409
+ panel.append(renderProjectContext(doc, context, { sections: readySections, includeStatus: false }));
410
+ }
390
411
  const columns = makeElement(doc, 'div', { className: 'bb-pkw-columns' });
391
412
  for (const title of ['目的と現在地', '関係者と役割']) {
392
413
  const item = makeElement(doc, 'section', { className: 'bb-pkw-section bb-pkw-skeleton-section', attrs: { 'aria-label': title, 'aria-busy': 'true' } });
@@ -399,7 +420,9 @@ export function renderProjectKnowledgeSkeleton(doc, { context = null } = {}) {
399
420
  item.append(makeElement(doc, 'h3', { text: title }), skeletonBars(doc, 2));
400
421
  panel.append(item);
401
422
  }
402
- const contextBlock = renderProjectContext(doc, context ?? { state: 'loading', sections: [] });
423
+ const contextBlock = readySections.length > 0
424
+ ? (deferredSections.length > 0 ? renderProjectContext(doc, context, { sections: deferredSections, includeHeading: false }) : null)
425
+ : renderProjectContext(doc, context ?? { state: 'loading', sections: [] });
403
426
  if (contextBlock) panel.append(contextBlock);
404
427
  return panel;
405
428
  }
@@ -439,6 +462,16 @@ function renderOverview(doc, detail, context, edges, entities, onSelect, onConte
439
462
  columns.append(people);
440
463
  panel.append(columns);
441
464
 
465
+ const contextBlock = renderProjectContext(doc, context);
466
+ if (contextBlock) {
467
+ panel.append(contextBlock);
468
+ if (typeof onContext === 'function') onContext(contextBlock, null);
469
+ } else {
470
+ const contextAnchor = projectContextInsertionAnchor(doc);
471
+ panel.append(contextAnchor);
472
+ if (typeof onContext === 'function') onContext(null, contextAnchor);
473
+ }
474
+
442
475
  const decisions = makeElement(doc, 'section', { className: 'bb-pkw-section', attrs: { 'aria-label': '記録された判断' } });
443
476
  decisions.append(makeElement(doc, 'h3', { text: '記録された判断' }));
444
477
  const decisionEdges = edges.filter((edge) => entities.get(edge.source)?.type === 'decision' || entities.get(edge.target)?.type === 'decision');
@@ -493,11 +526,6 @@ function renderOverview(doc, detail, context, edges, entities, onSelect, onConte
493
526
  missing.append(list);
494
527
  panel.append(missing);
495
528
  }
496
- const contextBlock = renderProjectContext(doc, context);
497
- if (contextBlock) {
498
- panel.append(contextBlock);
499
- if (typeof onContext === 'function') onContext(contextBlock);
500
- }
501
529
  return panel;
502
530
  }
503
531
 
@@ -543,8 +571,16 @@ export function createProjectKnowledgeWorkspace({
543
571
  let selectedPanel = null;
544
572
  let overviewPanel = null;
545
573
  let contextBlock = null;
546
-
547
- const root = makeElement(doc, 'section', { className: 'bb-pkw', attrs: { 'data-contract-version': PROJECT_WORKSPACE_CONTRACT_VERSION, 'aria-label': 'プロジェクトの知識' } });
574
+ let contextAnchor = null;
575
+
576
+ const root = makeElement(doc, 'section', {
577
+ className: 'bb-pkw',
578
+ attrs: {
579
+ 'data-contract-version': PROJECT_WORKSPACE_CONTRACT_VERSION,
580
+ 'data-project-id': cleanId(project.id),
581
+ 'aria-label': 'プロジェクトの知識',
582
+ },
583
+ });
548
584
  const body = makeElement(doc, 'div', { className: 'bb-pkw-body' });
549
585
  const tabs = makeElement(doc, 'div', { className: 'bb-pkw-tabs', attrs: { role: 'tablist', 'aria-label': 'プロジェクト情報の表示' } });
550
586
  const panels = makeElement(doc, 'div', { className: 'bb-pkw-panels' });
@@ -779,8 +815,15 @@ export function createProjectKnowledgeWorkspace({
779
815
  if (destroyed) return;
780
816
  const nextBlock = renderProjectContext(doc, currentContext);
781
817
  if (!nextBlock) {
782
- if (contextBlock?.parentNode?.removeChild) contextBlock.parentNode.removeChild(contextBlock);
783
- contextBlock = null;
818
+ if (contextBlock?.parentNode && typeof contextBlock.parentNode.replaceChild === 'function') {
819
+ const nextAnchor = projectContextInsertionAnchor(doc);
820
+ contextBlock.parentNode.replaceChild(nextAnchor, contextBlock);
821
+ contextBlock = null;
822
+ contextAnchor = nextAnchor;
823
+ } else if (contextBlock?.parentNode?.removeChild) {
824
+ contextBlock.parentNode.removeChild(contextBlock);
825
+ contextBlock = null;
826
+ }
784
827
  return;
785
828
  }
786
829
  if (contextBlock?.parentNode && typeof contextBlock.parentNode.replaceChild === 'function') {
@@ -788,14 +831,16 @@ export function createProjectKnowledgeWorkspace({
788
831
  contextBlock = nextBlock;
789
832
  return;
790
833
  }
791
- if (contextBlock?.replaceWith) {
792
- contextBlock.replaceWith(nextBlock);
834
+ if (contextAnchor?.parentNode && typeof contextAnchor.parentNode.replaceChild === 'function') {
835
+ contextAnchor.parentNode.replaceChild(nextBlock, contextAnchor);
793
836
  contextBlock = nextBlock;
837
+ contextAnchor = null;
794
838
  return;
795
839
  }
796
- if (overviewPanel && !contextBlock) {
797
- overviewPanel.append(nextBlock);
840
+ if (contextBlock?.replaceWith) {
841
+ contextBlock.replaceWith(nextBlock);
798
842
  contextBlock = nextBlock;
843
+ return;
799
844
  }
800
845
  }
801
846
 
@@ -806,7 +851,11 @@ export function createProjectKnowledgeWorkspace({
806
851
  tabs.replaceChildren(tabButton('概要', 'overview'), tabButton('情報を探す', 'graph'));
807
852
  panels.replaceChildren();
808
853
  contextBlock = null;
809
- const overview = renderOverview(doc, payload, currentContext, overviewEdges, overviewEntities, selectAndExplore, (node) => { contextBlock = node; });
854
+ contextAnchor = null;
855
+ const overview = renderOverview(doc, payload, currentContext, overviewEdges, overviewEntities, selectAndExplore, (node, anchor) => {
856
+ contextBlock = node;
857
+ contextAnchor = anchor;
858
+ });
810
859
  overview.id = 'bb-pkw-panel-overview';
811
860
  overview.hidden = activeTab !== 'overview';
812
861
  overviewPanel = overview;