@unson/brainbase-mcp 0.38.0 → 0.40.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.
@@ -14,12 +14,16 @@ export declare const GRAPH_CORRECTION_ENTITY_FIELDS: readonly ["name", "aliases"
14
14
  export declare const GRAPH_CORRECTION_PROJECT_FIELDS: readonly ["goal", "status", "icon"];
15
15
  export declare const GRAPH_CORRECTION_EDGE_FIELDS: readonly ["role", "context", "validTo"];
16
16
  export declare const GRAPH_CORRECTION_NEW_EDGE_RELATIONS: readonly ["participates_in", "accountable_for", "member_of"];
17
+ /** Kinds a correction may create (people and companies only). */
18
+ export declare const GRAPH_CORRECTION_NEW_ENTITY_TYPES: readonly ["person", "org"];
19
+ export declare const GRAPH_CORRECTION_NEW_ENTITY_FIELDS: readonly ["type", "name", "aliases", "summary", "validFrom"];
17
20
  /** MCP tools that read the same graph.json on every call and therefore see a saved correction next time. */
18
21
  export declare const GRAPH_CORRECTION_APPLIES_TO: readonly ["search", "get_context", "resolve_entity"];
19
22
  /** Decoded bytes, shared with the browser-side project-icon helper. */
20
23
  export declare const PROJECT_ICON_MAX_BYTES: number;
21
- export type GraphCorrectionKind = 'update_entity' | 'update_edge' | 'create_edge';
24
+ export type GraphCorrectionKind = 'update_entity' | 'update_edge' | 'create_edge' | 'create_entity';
22
25
  export type GraphNewEdgeRelation = typeof GRAPH_CORRECTION_NEW_EDGE_RELATIONS[number];
26
+ export type GraphNewEntityType = typeof GRAPH_CORRECTION_NEW_ENTITY_TYPES[number];
23
27
  export interface GraphEntityCorrectionChanges {
24
28
  name?: string;
25
29
  aliases?: string[];
@@ -47,6 +51,13 @@ export interface GraphEdgeCreation {
47
51
  context?: string;
48
52
  validFrom?: string;
49
53
  }
54
+ export interface GraphEntityCreation {
55
+ type: GraphNewEntityType;
56
+ name: string;
57
+ aliases?: string[];
58
+ summary?: string;
59
+ validFrom?: string;
60
+ }
50
61
  export type GraphCorrectionInput = {
51
62
  kind: 'update_entity';
52
63
  entityId: string;
@@ -63,6 +74,10 @@ export type GraphCorrectionInput = {
63
74
  kind: 'create_edge';
64
75
  reason: string;
65
76
  edge: GraphEdgeCreation;
77
+ } | {
78
+ kind: 'create_entity';
79
+ reason: string;
80
+ entity: GraphEntityCreation;
66
81
  };
67
82
  export type JsonValue = string | number | boolean | null | JsonValue[] | {
68
83
  [key: string]: JsonValue;
@@ -21,6 +21,9 @@ export const GRAPH_CORRECTION_ENTITY_FIELDS = ['name', 'aliases', 'summary', 'va
21
21
  export const GRAPH_CORRECTION_PROJECT_FIELDS = ['goal', 'status', 'icon'];
22
22
  export const GRAPH_CORRECTION_EDGE_FIELDS = ['role', 'context', 'validTo'];
23
23
  export const GRAPH_CORRECTION_NEW_EDGE_RELATIONS = ['participates_in', 'accountable_for', 'member_of'];
24
+ /** Kinds a correction may create (people and companies only). */
25
+ export const GRAPH_CORRECTION_NEW_ENTITY_TYPES = ['person', 'org'];
26
+ export const GRAPH_CORRECTION_NEW_ENTITY_FIELDS = ['type', 'name', 'aliases', 'summary', 'validFrom'];
24
27
  /** MCP tools that read the same graph.json on every call and therefore see a saved correction next time. */
25
28
  export const GRAPH_CORRECTION_APPLIES_TO = ['search', 'get_context', 'resolve_entity'];
26
29
  const MAX_REASON_LENGTH = 500;
@@ -83,7 +86,11 @@ export function parseGraphCorrectionInput(value) {
83
86
  assertOnlyKeys(value, ['kind', 'reason', 'edge'], '');
84
87
  return { kind, reason: requireReason(value.reason), edge: parseEdgeCreation(value.edge) };
85
88
  }
86
- invalid('correction_kind_unsupported', 'kind must be update_entity, update_edge or create_edge');
89
+ if (kind === 'create_entity') {
90
+ assertOnlyKeys(value, ['kind', 'reason', 'entity'], '');
91
+ return { kind, reason: requireReason(value.reason), entity: parseEntityCreation(value.entity) };
92
+ }
93
+ invalid('correction_kind_unsupported', 'kind must be update_entity, update_edge, create_edge or create_entity');
87
94
  }
88
95
  /**
89
96
  * Applies one owner correction to the canonical Graph v2 in `dataDir`.
@@ -190,7 +197,7 @@ function isGraphCorrectionRecord(value) {
190
197
  const target = value.target;
191
198
  return typeof value.id === 'string'
192
199
  && typeof value.at === 'string'
193
- && (value.kind === 'update_entity' || value.kind === 'update_edge' || value.kind === 'create_edge')
200
+ && (value.kind === 'update_entity' || value.kind === 'update_edge' || value.kind === 'create_edge' || value.kind === 'create_entity')
194
201
  && isRecord(target) && (target.recordType === 'entity' || target.recordType === 'edge') && typeof target.id === 'string'
195
202
  && typeof value.reason === 'string'
196
203
  && Array.isArray(value.changedFields) && value.changedFields.every((field) => typeof field === 'string')
@@ -204,8 +211,51 @@ function planCorrection(graph, relationships, correction, at) {
204
211
  return planEntityUpdate(graph, relationships, correction, at);
205
212
  if (correction.kind === 'update_edge')
206
213
  return planEdgeUpdate(graph, relationships, correction, at);
214
+ if (correction.kind === 'create_entity')
215
+ return planEntityCreation(graph, relationships, correction, at);
207
216
  return planEdgeCreation(graph, relationships, correction, at);
208
217
  }
218
+ /**
219
+ * Creates a person or a company. A record of the same kind whose name or
220
+ * alias already reads the same (ignoring spacing, case and full/half width) is
221
+ * returned as a conflict instead of creating a duplicate. The new record
222
+ * keeps where it came from (`metadata.source`, `metadata.sourceId`).
223
+ */
224
+ function planEntityCreation(graph, relationships, correction, at) {
225
+ const input = correction.entity;
226
+ const names = new Set([input.name, ...(input.aliases ?? [])].map(comparableName));
227
+ const existing = graph.entities.find((entity) => entity.type === input.type
228
+ && [entity.name, ...(entity.aliases ?? [])].some((label) => names.has(comparableName(label))));
229
+ if (existing) {
230
+ throw new GraphCorrectionError('conflict', 'entity_already_exists', 'A record with the same name already exists; open it (and clear validTo if it has ended) instead of creating another', { recordType: 'entity', record: existing, digest: graphRecordDigest(existing) });
231
+ }
232
+ const base = `${input.type}-${stableHash(input.name)}`;
233
+ const correctionId = graphCorrectionId(at, 'create_entity', base, null, correction.reason);
234
+ const id = graph.entities.some((entity) => entity.id === base) ? `${base}-${correctionId.slice(-8)}` : base;
235
+ if (graph.entities.some((entity) => entity.id === id)) {
236
+ invalid('entity_id_ambiguous', `Entity id ${id} is already used; repair graph.json with the CLI first`);
237
+ }
238
+ const entity = compact({
239
+ id,
240
+ type: input.type,
241
+ name: input.name,
242
+ aliases: input.aliases && input.aliases.length > 0 ? input.aliases : undefined,
243
+ summary: input.summary,
244
+ validFrom: input.validFrom,
245
+ metadata: { source: 'user_approved', sourceId: correctionId }
246
+ });
247
+ const nextGraph = validatedGraph({ ...graph, entities: [...graph.entities, entity] });
248
+ const diff = {};
249
+ for (const [key, value] of Object.entries(entity)) {
250
+ if (key === 'id')
251
+ continue;
252
+ diff[key] = { before: null, after: value };
253
+ }
254
+ return finalize('create_entity', 'entity', null, entity, diff, correction.reason, at, nextGraph, { relationships, changes: [] }, correctionId);
255
+ }
256
+ function comparableName(value) {
257
+ return value.normalize('NFKC').replace(/\s+/gu, '').toLowerCase();
258
+ }
209
259
  function planEntityUpdate(graph, relationships, correction, at) {
210
260
  const matches = graph.entities.filter((entity) => entity.id === correction.entityId);
211
261
  if (matches.length === 0)
@@ -539,6 +589,35 @@ function parseEdgeCreation(value) {
539
589
  validFrom: validFrom ?? undefined
540
590
  });
541
591
  }
592
+ function parseEntityCreation(value) {
593
+ if (!isRecord(value))
594
+ invalid('correction_invalid', 'entity must be an object');
595
+ const type = value.type;
596
+ if (typeof type !== 'string' || !GRAPH_CORRECTION_NEW_ENTITY_TYPES.includes(type)) {
597
+ invalid('correction_entity_type_not_allowed', `entity.type must be one of ${GRAPH_CORRECTION_NEW_ENTITY_TYPES.join(', ')}`);
598
+ }
599
+ const allowed = GRAPH_CORRECTION_NEW_ENTITY_FIELDS.filter((key) => key !== 'type');
600
+ for (const key of Object.keys(value)) {
601
+ if (key === 'type' || allowed.includes(key))
602
+ continue;
603
+ if (key === 'id')
604
+ invalid('correction_id_change_not_allowed', 'entity.id is assigned by Brainbase');
605
+ invalid('correction_field_not_allowed', `entity.${key} cannot be set when creating a record`);
606
+ }
607
+ const name = value.name === undefined ? null : optionalText(value.name, 'entity.name', MAX_NAME_LENGTH);
608
+ if (name === null)
609
+ invalid('correction_name_required', 'entity.name must be a non-empty string');
610
+ const aliases = value.aliases === undefined ? [] : parseAliases(value.aliases).filter((alias) => alias !== name);
611
+ const summary = value.summary === undefined ? null : optionalText(value.summary, 'entity.summary', MAX_TEXT_LENGTH);
612
+ const validFrom = value.validFrom === undefined ? null : optionalDateTime(value.validFrom, 'entity.validFrom');
613
+ return compact({
614
+ type: type,
615
+ name,
616
+ aliases: aliases.length > 0 ? aliases : undefined,
617
+ summary: summary ?? undefined,
618
+ validFrom: validFrom ?? undefined
619
+ });
620
+ }
542
621
  function assertOnlyKeys(value, allowed, prefix) {
543
622
  for (const key of Object.keys(value)) {
544
623
  if (allowed.includes(key))
@@ -425,8 +425,20 @@ export function applyGraphOperations(snapshot, operations, { projectCode, humanG
425
425
  && !externalIds.has(toEntity.id)
426
426
  && toEntity.entity_type === 'app'
427
427
  && toEntity.lifecycle_status === 'active';
428
- if (!localFrom && !sameOrganizationToolAccountable)
428
+ // A person's membership of a company: both active, each either in this
429
+ // snapshot or a same-organization reference. Cross-organization
430
+ // references are never a membership endpoint.
431
+ const membershipFrom = localFrom || externalFrom;
432
+ const sameOrganizationPersonMembership = operation.rel_type === 'member_of'
433
+ && membershipFrom?.entity_type === 'person'
434
+ && membershipFrom.lifecycle_status === 'active'
435
+ && (Boolean(localFrom) || (externalIds.has(membershipFrom.id) && membershipFrom.reference_scope === 'same_organization'))
436
+ && toEntity.entity_type === 'org'
437
+ && toEntity.lifecycle_status === 'active'
438
+ && (!externalIds.has(toEntity.id) || toEntity.reference_scope === 'same_organization');
439
+ if (!localFrom && !sameOrganizationToolAccountable && !sameOrganizationPersonMembership) {
429
440
  throw new Error(`Unknown entity: ${operation.from_id}`);
441
+ }
430
442
  const fromEntity = (localFrom || externalFrom);
431
443
  // A project of this scope using a same-organization tool of another project.
432
444
  const sameOrganizationProjectUsesApp = operation.rel_type === 'uses_app'
@@ -455,7 +467,7 @@ export function applyGraphOperations(snapshot, operations, { projectCode, humanG
455
467
  throw new Error('decision_id must match the Decision owner source');
456
468
  }
457
469
  if (fromEntity.project_code !== toEntity.project_code && !sameOrganizationAppOwner && !sameOrganizationDecisionOwner
458
- && !sameOrganizationToolAccountable && !sameOrganizationProjectUsesApp) {
470
+ && !sameOrganizationToolAccountable && !sameOrganizationProjectUsesApp && !sameOrganizationPersonMembership) {
459
471
  throw new Error('edge endpoint project scope mismatch');
460
472
  }
461
473
  if (!String(operation.rel_type || '').trim())
@@ -491,7 +503,12 @@ export function applyGraphOperations(snapshot, operations, { projectCode, humanG
491
503
  id: operation.edge_id,
492
504
  from_id: operation.from_id, to_id: operation.to_id, rel_type: operation.rel_type,
493
505
  // The accountable person lives in another project; the edge belongs to the tool's project.
494
- project_code: sameOrganizationToolAccountable ? toEntity.project_code : fromEntity.project_code,
506
+ // A membership belongs to the company's project when the company is
507
+ // in this snapshot, else to the person's, else to the plan's.
508
+ project_code: sameOrganizationToolAccountable ? toEntity.project_code
509
+ : sameOrganizationPersonMembership
510
+ ? (!externalIds.has(toEntity.id) ? toEntity.project_code : localFrom ? fromEntity.project_code : state.project_code)
511
+ : fromEntity.project_code,
495
512
  payload: operation.payload || {}, role_min: roleMin,
496
513
  sensitivity, lifecycle_status: 'active', version: 1
497
514
  });
@@ -13,6 +13,7 @@ const CORE_RELATIONS = [
13
13
  'participates_in',
14
14
  'accountable_for',
15
15
  'owned_by',
16
+ 'related_to',
16
17
  'governs',
17
18
  'supersedes'
18
19
  ];
@@ -320,6 +320,8 @@ export interface GraphOntologySummary<S extends GraphWebAnySource = GraphWebSour
320
320
  projectFields: readonly string[];
321
321
  edgeFields: readonly string[];
322
322
  newEdgeRelations: readonly string[];
323
+ /** Kinds a correction may create (`create_entity`). */
324
+ newEntityTypes: readonly string[];
323
325
  };
324
326
  }
325
327
  export type GraphWebErrorKind = 'invalid' | 'not_found' | 'unavailable';
@@ -345,6 +347,20 @@ export interface GraphPersonSummaryView extends GraphEntityView {
345
347
  /** Distinct active projects with an active participates_in or accountable_for relation. */
346
348
  projectCount: number;
347
349
  accountableProjectCount: number;
350
+ /**
351
+ * The company text recorded on the person (`org`, or `organization` when
352
+ * `org` is absent), as written; null when none. A note only: it never
353
+ * decides the companies, the own company or internal / external.
354
+ */
355
+ orgNote: string | null;
356
+ }
357
+ export type GraphCompanyKind = 'customer' | 'partner' | 'prospect';
358
+ export type GraphCompanyKindSource = 'orgType' | 'lifecycle_stage' | 'related_to' | 'deals';
359
+ export interface GraphCompanyDealView {
360
+ /** The deal name as recorded (`name` or `title`), or null when the record has none. */
361
+ name: string | null;
362
+ /** The deal stage as recorded (`stage` or `status`), or null. */
363
+ stage: string | null;
348
364
  }
349
365
  export interface GraphCompanySummaryView extends GraphEntityView {
350
366
  type: 'org';
@@ -352,6 +368,25 @@ export interface GraphCompanySummaryView extends GraphEntityView {
352
368
  memberIds: string[];
353
369
  /** Active projects with an active owned_by to the company, by name. */
354
370
  projectIds: string[];
371
+ /** Active projects the company is involved in through an active related_to, by name, with the edge role. */
372
+ relatedProjects: Array<{
373
+ projectId: string;
374
+ role: string | null;
375
+ }>;
376
+ /** The recorded company attributes, as written; null when not recorded. */
377
+ orgType: string | null;
378
+ lifecycleStage: string | null;
379
+ /** Proposals in progress recorded on the company (`deals`). */
380
+ deals: GraphCompanyDealView[];
381
+ /**
382
+ * What kind of company this is for the owner, only from the records above
383
+ * (attributes, related_to roles and deals), with where each kind came from.
384
+ * Empty when nothing is recorded: the kind is never guessed.
385
+ */
386
+ kinds: Array<{
387
+ kind: GraphCompanyKind;
388
+ sources: GraphCompanyKindSource[];
389
+ }>;
355
390
  }
356
391
  export interface GraphPeopleAndCompanies<S extends GraphWebAnySource = GraphWebSource> {
357
392
  status: 'ok';
package/dist/graph-web.js CHANGED
@@ -2,7 +2,7 @@ import { access, readdir, readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { isActiveAt } from './canonical-graph.js';
4
4
  import { assertFoundationCatalog, findFoundationRecord } from './foundation-catalog.js';
5
- import { GRAPH_CORRECTION_EDGE_FIELDS, GRAPH_CORRECTION_ENTITY_FIELDS, GRAPH_CORRECTION_NEW_EDGE_RELATIONS, GRAPH_CORRECTION_PROJECT_FIELDS, GRAPH_CORRECTIONS_FILE, graphRecordDigest, isProjectIconDataUrl, readGraphCorrectionHistory } from './graph-corrections.js';
5
+ import { GRAPH_CORRECTION_EDGE_FIELDS, GRAPH_CORRECTION_ENTITY_FIELDS, GRAPH_CORRECTION_NEW_EDGE_RELATIONS, GRAPH_CORRECTION_NEW_ENTITY_TYPES, GRAPH_CORRECTION_PROJECT_FIELDS, GRAPH_CORRECTIONS_FILE, graphRecordDigest, isProjectIconDataUrl, readGraphCorrectionHistory } from './graph-corrections.js';
6
6
  import { ONTOLOGY_VERSION, portableOntology } from './ontology.js';
7
7
  import { canonicalRelationRegistry, getCanonicalRelation } from './relation-registry.js';
8
8
  import { loadPersonalOs } from './ssot.js';
@@ -123,21 +123,85 @@ function peopleAndCompaniesOf(graph, source, asOf) {
123
123
  isOwner: person.id === ownerId,
124
124
  companyIds: companiesOf(person.id),
125
125
  projectCount: new Set(work.map((edge) => edge.toId)).size,
126
- accountableProjectCount: new Set(work.filter((edge) => edge.relation === 'accountable_for').map((edge) => edge.toId)).size
126
+ accountableProjectCount: new Set(work.filter((edge) => edge.relation === 'accountable_for').map((edge) => edge.toId)).size,
127
+ orgNote: metadataString(person, 'org')?.trim() || metadataString(person, 'organization')?.trim() || null
127
128
  };
128
129
  }).sort((left, right) => Number(right.active) - Number(left.active) || Number(right.isOwner) - Number(left.isOwner) || byActiveThenName(left, right));
129
- const companies = graph.entities.filter((entity) => entity.type === 'org').map((company) => ({
130
- ...entityView(company, asOf),
131
- type: 'org',
132
- memberIds: [...new Set(membership.filter((edge) => edge.toId === company.id).map((edge) => edge.fromId))]
133
- .sort((left, right) => Number(left !== ownerId) - Number(right !== ownerId) || byName(left, right)),
134
- projectIds: [...new Set(live.filter((edge) => edge.relation === 'owned_by' && edge.toId === company.id
135
- && entities.get(edge.fromId)?.type === 'project').map((edge) => edge.fromId))].sort(byName)
136
- })).sort((left, right) => Number(right.active) - Number(left.active)
130
+ const companies = graph.entities.filter((entity) => entity.type === 'org').map((company) => {
131
+ const relatedProjects = live.filter((edge) => edge.relation === 'related_to' && edge.fromId === company.id
132
+ && entities.get(edge.toId)?.type === 'project')
133
+ .map((edge) => ({ projectId: edge.toId, role: edge.role?.trim() || null }))
134
+ .sort((left, right) => byName(left.projectId, right.projectId));
135
+ const orgType = metadataString(company, 'orgType')?.trim() || null;
136
+ const lifecycleStage = metadataString(company, 'lifecycle_stage')?.trim() || null;
137
+ const deals = companyDeals(company.metadata?.deals);
138
+ return {
139
+ ...entityView(company, asOf),
140
+ type: 'org',
141
+ memberIds: [...new Set(membership.filter((edge) => edge.toId === company.id).map((edge) => edge.fromId))]
142
+ .sort((left, right) => Number(left !== ownerId) - Number(right !== ownerId) || byName(left, right)),
143
+ projectIds: [...new Set(live.filter((edge) => edge.relation === 'owned_by' && edge.toId === company.id
144
+ && entities.get(edge.fromId)?.type === 'project').map((edge) => edge.fromId))].sort(byName),
145
+ relatedProjects,
146
+ orgType,
147
+ lifecycleStage,
148
+ deals,
149
+ kinds: companyKinds({ orgType, lifecycleStage, roles: relatedProjects.map((item) => item.role), dealCount: deals.length })
150
+ };
151
+ }).sort((left, right) => Number(right.active) - Number(left.active)
137
152
  || Number(right.id === ownCompany.id) - Number(left.id === ownCompany.id)
153
+ || Number(right.kinds.length > 0) - Number(left.kinds.length > 0)
138
154
  || byActiveThenName(left, right));
139
155
  return { status: 'ok', source, asOf, ownCompany, people, companies, absenceConfirmed: people.length === 0 && companies.length === 0 };
140
156
  }
157
+ const COMPANY_KINDS = ['customer', 'partner', 'prospect'];
158
+ const RELATION_KIND = Object.freeze({ customer: 'customer', partner: 'partner' });
159
+ const STAGE_KIND = Object.freeze({ customer: 'customer', partner: 'partner', opportunity: 'prospect' });
160
+ /**
161
+ * The kinds of a company, only from what is recorded: `orgType` and
162
+ * related_to roles of `customer` / `partner`, `lifecycle_stage` of
163
+ * `customer` / `partner` / `opportunity`, and any recorded deal (prospect).
164
+ * Other values give no kind; nothing is guessed from names.
165
+ */
166
+ function companyKinds(input) {
167
+ const sources = new Map();
168
+ const add = (kind, source) => {
169
+ if (!kind)
170
+ return;
171
+ sources.set(kind, (sources.get(kind) ?? new Set()).add(source));
172
+ };
173
+ const lookup = (table, value) => {
174
+ const key = value?.toLowerCase();
175
+ return key && Object.hasOwn(table, key) ? table[key] : undefined;
176
+ };
177
+ add(lookup(RELATION_KIND, input.orgType), 'orgType');
178
+ add(lookup(STAGE_KIND, input.lifecycleStage), 'lifecycle_stage');
179
+ for (const role of input.roles)
180
+ add(lookup(RELATION_KIND, role), 'related_to');
181
+ if (input.dealCount > 0)
182
+ add('prospect', 'deals');
183
+ return COMPANY_KINDS.filter((kind) => sources.has(kind)).map((kind) => ({ kind, sources: [...sources.get(kind)] }));
184
+ }
185
+ function companyDeals(value) {
186
+ if (!Array.isArray(value))
187
+ return [];
188
+ return value.flatMap((deal) => {
189
+ if (typeof deal === 'string')
190
+ return deal.trim() ? [{ name: deal.trim(), stage: null }] : [];
191
+ if (!deal || typeof deal !== 'object' || Array.isArray(deal))
192
+ return [];
193
+ const record = deal;
194
+ const text = (...keys) => {
195
+ for (const key of keys) {
196
+ const item = record[key];
197
+ if (typeof item === 'string' && item.trim())
198
+ return item.trim();
199
+ }
200
+ return null;
201
+ };
202
+ return [{ name: text('name', 'title'), stage: text('stage', 'status') }];
203
+ });
204
+ }
141
205
  export async function readGraphProject(dataDir, projectId, options = {}) {
142
206
  const loaded = await loadForRead(dataDir);
143
207
  if ('status' in loaded)
@@ -281,7 +345,8 @@ function ontologyOf(graph, source, asOf) {
281
345
  entityFields: GRAPH_CORRECTION_ENTITY_FIELDS,
282
346
  projectFields: GRAPH_CORRECTION_PROJECT_FIELDS,
283
347
  edgeFields: GRAPH_CORRECTION_EDGE_FIELDS,
284
- newEdgeRelations: GRAPH_CORRECTION_NEW_EDGE_RELATIONS
348
+ newEdgeRelations: GRAPH_CORRECTION_NEW_EDGE_RELATIONS,
349
+ newEntityTypes: GRAPH_CORRECTION_NEW_ENTITY_TYPES
285
350
  }
286
351
  };
287
352
  }
@@ -31,6 +31,14 @@ const definitions = {
31
31
  scopeTraversal: 'project_direct',
32
32
  traversalDirection: 'reverse'
33
33
  },
34
+ related_to: {
35
+ id: 'related_to',
36
+ from: 'org',
37
+ to: 'project',
38
+ meaning: 'An organization is involved in a project it does not own, such as a customer or a partner; the edge role names how.',
39
+ scopeTraversal: 'project_direct',
40
+ traversalDirection: 'forward'
41
+ },
34
42
  governs: {
35
43
  id: 'governs',
36
44
  from: 'decision',
package/dist/server.d.ts CHANGED
@@ -73,7 +73,7 @@ export declare const toolDefinitions: readonly [{
73
73
  readonly required: readonly ["relation", "direction"];
74
74
  readonly properties: {
75
75
  readonly relation: {
76
- readonly enum: readonly ["member_of", "participates_in", "accountable_for", "owned_by", "governs", "supersedes"];
76
+ readonly enum: readonly ["member_of", "participates_in", "accountable_for", "owned_by", "related_to", "governs", "supersedes"];
77
77
  };
78
78
  readonly direction: {
79
79
  readonly enum: readonly ["incoming", "outgoing"];
package/dist/server.js CHANGED
@@ -30,7 +30,7 @@ const argsSchema = z.object({
30
30
  as_of: z.string().datetime({ offset: true }).optional(),
31
31
  seedIds: z.array(z.string().min(1)).min(1).max(10).optional(),
32
32
  steps: z.array(z.object({
33
- relation: z.enum(['member_of', 'participates_in', 'accountable_for', 'owned_by', 'governs', 'supersedes']),
33
+ relation: z.enum(['member_of', 'participates_in', 'accountable_for', 'owned_by', 'related_to', 'governs', 'supersedes']),
34
34
  direction: z.enum(['incoming', 'outgoing']),
35
35
  targetType: z.enum(['person', 'org', 'project', 'decision']).optional()
36
36
  }).strict()).min(1).max(3).optional(),
@@ -188,7 +188,7 @@ export const toolDefinitions = [
188
188
  seedIds: { type: 'array', minItems: 1, maxItems: 10, items: { type: 'string' }, description: 'Canonical IDs chosen by the calling model; no embeddings needed.' },
189
189
  steps: { type: 'array', minItems: 1, maxItems: 3, items: {
190
190
  type: 'object', additionalProperties: false, required: ['relation', 'direction'], properties: {
191
- relation: { enum: ['member_of', 'participates_in', 'accountable_for', 'owned_by', 'governs', 'supersedes'] },
191
+ relation: { enum: ['member_of', 'participates_in', 'accountable_for', 'owned_by', 'related_to', 'governs', 'supersedes'] },
192
192
  direction: { enum: ['incoming', 'outgoing'] },
193
193
  targetType: { enum: ['person', 'org', 'project', 'decision'] }
194
194
  }
package/dist/templates.js CHANGED
@@ -85,7 +85,7 @@ export const schemaTemplates = {
85
85
  id: { type: 'string', minLength: 1 },
86
86
  fromId: { type: 'string', minLength: 1 },
87
87
  relation: {
88
- enum: ['member_of', 'participates_in', 'accountable_for', 'owned_by', 'governs', 'supersedes']
88
+ enum: ['member_of', 'participates_in', 'accountable_for', 'owned_by', 'related_to', 'governs', 'supersedes']
89
89
  },
90
90
  toId: { type: 'string', minLength: 1 },
91
91
  role: { type: 'string' },
package/dist/types.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { FoundationDefinition, FoundationRelationReference, FoundationRevision } from './ontology-foundation.js';
2
2
  export type EntityKind = 'person' | 'org' | 'project' | 'relationship' | 'decision';
3
3
  export type CanonicalEntityKind = 'person' | 'org' | 'project' | 'decision';
4
- export type CoreRelation = 'member_of' | 'participates_in' | 'accountable_for' | 'owned_by' | 'governs' | 'supersedes';
4
+ export type CoreRelation = 'member_of' | 'participates_in' | 'accountable_for' | 'owned_by' | 'related_to' | 'governs' | 'supersedes';
5
5
  export interface GraphEntity {
6
6
  id: string;
7
7
  type: Exclude<EntityKind, 'decision'>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.38.0",
3
+ "version": "0.40.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",
@@ -481,5 +481,5 @@
481
481
  "engines": {
482
482
  "node": ">=20"
483
483
  },
484
- "gitHead": "dfc88ee2c7c48aa99ae8f4157addbac69e92c7af"
484
+ "gitHead": "05c940b491a191339b9bc59ffb123579220714a3"
485
485
  }
@@ -56,6 +56,7 @@ export const GRAPH_RELATION_LABELS = Object.freeze({
56
56
  participates_in: Object.freeze({ label: '参加', meaning: '人物がプロジェクトに参加している', outgoing: 'に参加', incoming: 'が参加' }),
57
57
  accountable_for: Object.freeze({ label: '責任', meaning: '人物がプロジェクトの成果や判断に責任を持つ', outgoing: 'に責任を持つ', incoming: 'が責任を持つ' }),
58
58
  owned_by: Object.freeze({ label: '所有', meaning: 'プロジェクトを組織が所有している', outgoing: 'が所有', incoming: 'を所有' }),
59
+ related_to: Object.freeze({ label: '関わる', meaning: '会社が顧客・協業先などとしてプロジェクトに関わっている', outgoing: 'に関わる', incoming: 'が関わる' }),
59
60
  governs: Object.freeze({ label: '方針', meaning: '判断や原則がプロジェクトの進め方を決める', outgoing: 'の進め方を決める', incoming: 'が進め方を決める' }),
60
61
  supersedes: Object.freeze({ label: '置き換え', meaning: '判断が以前の判断を置き換える', outgoing: 'を置き換える', incoming: 'に置き換えられた' }),
61
62
  });
@@ -101,6 +102,7 @@ const CORRECTION_KIND_LABELS = Object.freeze({
101
102
  update_entity: '記録を直した',
102
103
  update_edge: '関係を直した',
103
104
  create_edge: '関係を加えた',
105
+ create_entity: '記録を加えた',
104
106
  });
105
107
 
106
108
  /** Inputs of the correction form. `date` inputs hold a local day (YYYY-MM-DD). */
@@ -127,6 +129,9 @@ export const GRAPH_CORRECTION_ERROR_MESSAGES = Object.freeze({
127
129
  correction_relation_change_not_allowed: '関係の種類は変えられません。今の関係を終えてから、新しい関係を加えてください。',
128
130
  correction_endpoint_change_not_allowed: '関係の相手は変えられません。今の関係を終えてから、新しい関係を加えてください。',
129
131
  correction_endpoint_type_invalid: 'この組み合わせの関係は加えられません。',
132
+ correction_entity_type_not_allowed: 'ここで作れるのは人と会社だけです。',
133
+ correction_name_required: '名前を書いてください。',
134
+ entity_already_exists: '同じ名前の記録がすでにあります。今の記録を開いて確かめてください。',
130
135
  correction_field_not_allowed: 'この項目は、ここでは直せません。',
131
136
  correction_changes_required: '変更がありません。直したい項目を書き換えてください。',
132
137
  correction_scope_not_allowed: 'この画面では、この記録や関係は直せません。',
@@ -442,6 +447,7 @@ export function readOnlyGraphClient(client) {
442
447
  * `GRAPH_CORRECTION_FIELDS` such as `name` or `goal` (default: every field).
443
448
  * - `edges: false`: relations cannot be corrected or ended.
444
449
  * - `createEdges: false`: relations cannot be added.
450
+ * - `createEntities: false`: people and companies cannot be created.
445
451
  */
446
452
  export function graphCorrectionScope(scope) {
447
453
  const given = isRecord(scope) ? scope : {};
@@ -451,6 +457,7 @@ export function graphCorrectionScope(scope) {
451
457
  return Object.freeze({
452
458
  edges: given.edges !== false,
453
459
  createEdges: given.createEdges !== false,
460
+ createEntities: given.createEntities !== false,
454
461
  /** Whether a record of this kind may be corrected. */
455
462
  entity: (type) => types === null || types.has(type),
456
463
  /** The given record fields that may be corrected, in their order. */
@@ -466,6 +473,7 @@ export function graphEntityCorrectionFields(type) {
466
473
  /** Whether a built correction request stays inside the scope (`recordType` is the corrected entity's kind). */
467
474
  function withinCorrectionScope(scope, body, recordType) {
468
475
  if (body.kind === 'create_edge') return scope.createEdges;
476
+ if (body.kind === 'create_entity') return scope.createEntities && scope.entity(body.entity?.type);
469
477
  if (body.kind === 'update_edge') return scope.edges;
470
478
  const changed = Object.keys(body.changes ?? {});
471
479
  return scope.entity(recordType) && scope.fields(changed).length === changed.length;
@@ -820,6 +828,29 @@ export function buildCorrectionRequest(form) {
820
828
  return { ok: true, body: { kind: 'create_edge', reason, edge } };
821
829
  }
822
830
 
831
+ if (form.kind === 'create_entity') {
832
+ const type = String(form.draft.type ?? '');
833
+ if (!form.entityTypes.includes(type)) return { ok: false, message: '作る記録の種類を選んでください。' };
834
+ const name = String(form.draft.name ?? '').trim();
835
+ if (!name) return { ok: false, message: GRAPH_CORRECTION_ERROR_MESSAGES.correction_name_required };
836
+ const entity = { type, name };
837
+ if (form.projects.length > 0) {
838
+ const projectCode = String(form.draft.projectCode ?? '');
839
+ if (!form.projects.some((project) => project.code === projectCode)) return { ok: false, message: '人を加えるプロジェクトを選んでください。' };
840
+ entity.projectCode = projectCode;
841
+ }
842
+ const aliases = splitAliases(form.draft.aliases ?? '').filter((alias) => alias !== name);
843
+ const summary = String(form.draft.summary ?? '').trim();
844
+ if (aliases.length > 0) entity.aliases = aliases;
845
+ if (summary) entity.summary = summary;
846
+ if (form.draft.validFrom) {
847
+ const validFrom = dayToRfc3339(form.draft.validFrom);
848
+ if (!validFrom) return { ok: false, message: '開始日の形式が正しくありません。' };
849
+ entity.validFrom = validFrom;
850
+ }
851
+ return { ok: true, body: { kind: 'create_entity', reason, entity } };
852
+ }
853
+
823
854
  const changes = {};
824
855
  for (const field of form.fields) {
825
856
  if (field === 'icon') {
@@ -860,11 +891,21 @@ export function buildCorrectionRequest(form) {
860
891
 
861
892
  function readBackFields(form, payload) {
862
893
  if (form.kind === 'create_edge') return ['role', 'context', 'validFrom', 'validTo'];
894
+ if (form.kind === 'create_entity') return ['name', 'aliases', 'summary', 'validFrom'];
863
895
  const changed = Array.isArray(payload?.correction?.changedFields) ? payload.correction.changedFields : [];
864
896
  const fields = changed.map((field) => field.replace(/^metadata\./u, '')).filter((field) => GRAPH_CORRECTION_FIELDS[field]);
865
897
  return fields.length > 0 ? fields : form.fields;
866
898
  }
867
899
 
900
+ /** What a new record is, as rows shown before its values. */
901
+ function createdEntityRows(form, record) {
902
+ const project = form.projects.find((candidate) => candidate.code === form.draft.projectCode);
903
+ return [
904
+ ['種類', form.typeNames[record?.type] ?? typeLabel(record?.type)],
905
+ ...(project ? [['関わるプロジェクト', project.name ?? project.code]] : []),
906
+ ];
907
+ }
908
+
868
909
  /** What a new relation connects, as rows shown before its values. */
869
910
  function createdRows(form, record) {
870
911
  const counterpartId = record?.fromId === form.anchor.id ? record?.toId : record?.fromId;
@@ -1015,6 +1056,48 @@ function renderCreateFields(doc, form, callbacks) {
1015
1056
  return group;
1016
1057
  }
1017
1058
 
1059
+ function renderCreateEntityFields(doc, form, callbacks) {
1060
+ const group = makeElement(doc, 'div', { className: 'bb-graph-fields' });
1061
+ if (form.entityTypes.length > 1) {
1062
+ group.append(selectControl(doc, {
1063
+ id: `${form.formId}-type`,
1064
+ name: 'type',
1065
+ label: '種類',
1066
+ value: form.draft.type,
1067
+ options: form.entityTypes.map((type) => ({ value: type, label: form.typeNames[type] ?? typeLabel(type) })),
1068
+ onChange: (value) => callbacks.onInput('type', value),
1069
+ }));
1070
+ }
1071
+ if (form.projects.length > 0) {
1072
+ group.append(selectControl(doc, {
1073
+ id: `${form.formId}-project`,
1074
+ name: 'projectCode',
1075
+ label: '関わるプロジェクト',
1076
+ value: String(form.draft.projectCode ?? ''),
1077
+ options: [
1078
+ { value: '', label: 'プロジェクトを選んでください' },
1079
+ ...form.projects.map((project) => ({ value: project.code, label: project.name ?? project.code })),
1080
+ ],
1081
+ onChange: (value) => callbacks.onInput('projectCode', value),
1082
+ }));
1083
+ group.append(notice(doc, 'muted', '人はこのプロジェクトの関係者として記録されます。会社への所属は、作った後に加えてください。'));
1084
+ }
1085
+ for (const field of form.fields) group.append(fieldControl(doc, form, field, callbacks));
1086
+ return group;
1087
+ }
1088
+
1089
+ /** The projects a host offers for a new person, as `{ code, name }`, without duplicates. */
1090
+ function creationProjects(projects) {
1091
+ const seen = new Set();
1092
+ return (Array.isArray(projects) ? projects : []).flatMap((project) => {
1093
+ const code = typeof project?.code === 'string' ? project.code.trim() : '';
1094
+ if (!code || seen.has(code)) return [];
1095
+ seen.add(code);
1096
+ const name = typeof project.name === 'string' && project.name.trim() ? project.name.trim() : null;
1097
+ return [{ code, name }];
1098
+ });
1099
+ }
1100
+
1018
1101
  /**
1019
1102
  * The correction panel. `form` is the state held by `createGraphCorrection`;
1020
1103
  * callbacks are the controller's handlers.
@@ -1035,7 +1118,9 @@ export function renderCorrectionPanel(doc, form, callbacks) {
1035
1118
  const readBack = makeElement(doc, 'div', { className: 'bb-graph-readback', attrs: { 'aria-label': '読み直した内容' } });
1036
1119
  readBack.append(
1037
1120
  makeElement(doc, 'strong', { text: '読み直した内容' }),
1038
- renderValues(doc, payload.record, readBackFields(form, payload), form.kind === 'create_edge' ? createdRows(form, payload.record) : []),
1121
+ renderValues(doc, payload.record, readBackFields(form, payload), form.kind === 'create_edge'
1122
+ ? createdRows(form, payload.record)
1123
+ : form.kind === 'create_entity' ? createdEntityRows(form, payload.record) : []),
1039
1124
  );
1040
1125
  panel.append(readBack, notice(doc, 'accent', mcpNote(payload)));
1041
1126
  panel.append(recordDetails(doc, [['記録ID', payload.record?.id], ['保存した内容のdigest', payload.digest], ['訂正ID', payload.correction?.id]]));
@@ -1045,12 +1130,17 @@ export function renderCorrectionPanel(doc, form, callbacks) {
1045
1130
 
1046
1131
  if (form.phase === 'conflict') {
1047
1132
  const exists = form.conflictCode === 'edge_already_exists';
1048
- panel.append(notice(doc, 'warning', exists
1049
- ? 'この関わりはすでに登録されているため、加えませんでした。今の内容を下に示します。終わった関わりをもう一度始めるときは、その関わりの終了日を消してください。入力は残しています。'
1050
- : 'ほかの保存で内容が変わっていたため、保存しませんでした。今の内容を下に示します。入力は残しています。今の内容を確かめてから、もう一度保存してください。', 'alert'));
1133
+ const entityExists = form.conflictCode === 'entity_already_exists';
1134
+ panel.append(notice(doc, 'warning', entityExists
1135
+ ? '同じ名前の記録がすでにあるため、作りませんでした。今の記録を下に示します。同じ人や会社なら、今の記録を使ってください(終わっている記録は、終了日を消すと戻せます)。入力は残しています。'
1136
+ : exists
1137
+ ? 'この関わりはすでに登録されているため、加えませんでした。今の内容を下に示します。終わった関わりをもう一度始めるときは、その関わりの終了日を消してください。入力は残しています。'
1138
+ : 'ほかの保存で内容が変わっていたため、保存しませんでした。今の内容を下に示します。入力は残しています。今の内容を確かめてから、もう一度保存してください。', 'alert'));
1051
1139
  const current = makeElement(doc, 'div', { className: 'bb-graph-current', attrs: { 'aria-label': '今の内容' } });
1052
- const fields = exists ? ['role', 'context', 'validFrom', 'validTo'] : form.fields;
1053
- current.append(makeElement(doc, 'strong', { text: '今の内容' }), renderValues(doc, form.current.record, fields));
1140
+ const fields = exists ? ['role', 'context', 'validFrom', 'validTo'] : entityExists ? ['name', 'aliases', 'summary', 'validFrom', 'validTo'] : form.fields;
1141
+ current.append(makeElement(doc, 'strong', { text: '今の内容' }), renderValues(doc, form.current.record, fields, entityExists
1142
+ ? [['種類', form.typeNames[form.current.record?.type] ?? typeLabel(form.current.record?.type)]]
1143
+ : []));
1054
1144
  current.append(recordDetails(doc, [['記録ID', form.current.record?.id], ['今の内容のdigest', form.current.digest]]));
1055
1145
  panel.append(current);
1056
1146
  } else if (form.message) {
@@ -1065,6 +1155,8 @@ export function renderCorrectionPanel(doc, form, callbacks) {
1065
1155
  });
1066
1156
  if (form.kind === 'create_edge') {
1067
1157
  formElement.append(renderCreateFields(doc, form, callbacks));
1158
+ } else if (form.kind === 'create_entity') {
1159
+ formElement.append(renderCreateEntityFields(doc, form, callbacks));
1068
1160
  } else {
1069
1161
  const group = makeElement(doc, 'div', { className: 'bb-graph-fields' });
1070
1162
  for (const field of form.fields) group.append(fieldControl(doc, form, field, callbacks));
@@ -1298,6 +1390,30 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
1298
1390
  void searchCandidates('');
1299
1391
  return form;
1300
1392
  },
1393
+ /**
1394
+ * Create a person or a company. `types` lists the kinds offered (only
1395
+ * `person` and `org` are created; others are dropped), `typeNames` their
1396
+ * labels. Null outside the scope.
1397
+ */
1398
+ openCreateEntity({ types = ['person', 'org'], type, typeNames = {}, title = '記録を加える', subject, projects = [] } = {}) {
1399
+ const entityTypes = allowed.createEntities
1400
+ ? types.filter((candidate) => (candidate === 'person' || candidate === 'org') && allowed.entity(candidate))
1401
+ : [];
1402
+ if (entityTypes.length === 0) return null;
1403
+ const fields = ['name', 'aliases', 'summary', 'validFrom'];
1404
+ form = baseForm('create_entity', {
1405
+ title,
1406
+ subject: subject ?? null,
1407
+ entityTypes,
1408
+ typeNames,
1409
+ fields,
1410
+ projects: entityTypes.length === 1 && entityTypes[0] === 'person' ? creationProjects(projects) : [],
1411
+ original: Object.fromEntries(fields.map((field) => [field, ''])),
1412
+ draft: { type: entityTypes.includes(type) ? type : entityTypes[0], name: '', aliases: '', summary: '', validFrom: '', projectCode: '' },
1413
+ });
1414
+ redraw();
1415
+ return form;
1416
+ },
1301
1417
  close() {
1302
1418
  iconReadGeneration += 1;
1303
1419
  form = null;
@@ -1353,7 +1469,7 @@ export function createGraphCorrection({ client, rerender, onSaved, onConflict, n
1353
1469
  // The owner now sees the current record; the next save is based on it
1354
1470
  // and still sends only the fields the owner changed. Untouched fields
1355
1471
  // show the current values so the form never suggests reverting them.
1356
- if (target.kind !== 'create_edge' && typeof result.current.digest === 'string') {
1472
+ if (target.kind !== 'create_edge' && target.kind !== 'create_entity' && typeof result.current.digest === 'string') {
1357
1473
  target.expectedDigest = result.current.digest;
1358
1474
  const currentValues = graphRecordValues(result.current.record);
1359
1475
  for (const field of target.fields) {
@@ -270,7 +270,7 @@ function mountObjectives(container, context) {
270
270
  return { editor, worldModel };
271
271
  }
272
272
 
273
- function mountGraphScreen(screenId, createView) {
273
+ function mountGraphScreen(screenId, createView, extra = {}) {
274
274
  return (container, context) => {
275
275
  const viewRoot = makeElement(context.document, 'div');
276
276
  makePage(container, context).append(viewRoot);
@@ -282,6 +282,7 @@ function mountGraphScreen(screenId, createView) {
282
282
  fetcher: context.fetcher,
283
283
  token: context.token,
284
284
  initialEntityId: context.initialEntityId,
285
+ ...extra,
285
286
  });
286
287
  };
287
288
  }
@@ -292,7 +293,7 @@ export const LOCAL_WEB_SCREENS = Object.freeze([
292
293
  Object.freeze({ id: 'objectives', label: '目的と現状', area: 'purpose', usesGraph: true, rail: true, source: '手元のGraph', mount: mountObjectives }),
293
294
  Object.freeze({ id: 'projects', label: 'プロジェクトと関係者', area: 'work', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('projects', createGraphProjectsView) }),
294
295
  Object.freeze({ id: 'graph', label: '情報と関係', area: 'information', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('graph', createGraphRegistryView) }),
295
- Object.freeze({ id: 'people', label: '人と会社', area: 'information', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('people', createPeopleAndCompaniesView) }),
296
+ Object.freeze({ id: 'people', label: '人と会社', area: 'information', usesGraph: true, rail: true, source: '手元のGraph', mount: mountGraphScreen('people', createPeopleAndCompaniesView, { canCorrect: true }) }),
296
297
  ]);
297
298
 
298
299
  function renderGraphGate(doc, slot, status, onRecheck, label) {
@@ -1,7 +1,8 @@
1
1
  /*
2
2
  * 「人と会社」. Load after workspace-kit.css and graph-view-shared.css.
3
- * Column templates of the people and company ledgers, the group titles and
4
- * the relations in the right rail, with a 760px variant.
3
+ * Column templates of the people and company ledgers, the group titles,
4
+ * the write buttons, the recorded company note and the relations in the
5
+ * right rail, with a 760px variant.
5
6
  * Uses only the shared tokens in brainbase-tokens.css.
6
7
  */
7
8
  .bb-pc-people,
@@ -35,7 +36,7 @@
35
36
  }
36
37
 
37
38
  .bb-pc-company-ledger .bb-ws-ledger-row {
38
- grid-template-columns: minmax(0, 1.4fr) 96px 96px minmax(0, 1fr);
39
+ grid-template-columns: minmax(0, 1.4fr) minmax(0, .8fr) 84px minmax(0, 1fr) minmax(0, 1fr);
39
40
  }
40
41
 
41
42
  .bb-pc-relation-list {
@@ -74,6 +75,32 @@
74
75
  font-size: 12px;
75
76
  }
76
77
 
78
+ .bb-pc-actions,
79
+ .bb-pc-relation-actions {
80
+ display: flex;
81
+ flex-wrap: wrap;
82
+ gap: 8px;
83
+ }
84
+
85
+ .bb-pc-actions {
86
+ margin: 0 0 12px;
87
+ }
88
+
89
+ .bb-pc-company-cell {
90
+ display: grid;
91
+ gap: 2px;
92
+ }
93
+
94
+ /* A note recorded as text on the person, never a membership: muted and dashed. */
95
+ .bb-pc-org-note {
96
+ justify-self: start;
97
+ padding: 1px 6px;
98
+ border: 1px dashed var(--bb-color-border);
99
+ border-radius: 4px;
100
+ color: var(--bb-color-muted);
101
+ font-size: 11px;
102
+ }
103
+
77
104
  @media (max-width: 760px) {
78
105
  .bb-pc-people-ledger .bb-ws-ledger-row {
79
106
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 84px;
@@ -84,10 +111,11 @@
84
111
  }
85
112
 
86
113
  .bb-pc-company-ledger .bb-ws-ledger-row {
87
- grid-template-columns: minmax(0, 1fr) 64px 64px;
114
+ grid-template-columns: minmax(0, 1fr) minmax(0, .7fr) 64px;
88
115
  }
89
116
 
90
- .bb-pc-company-ledger .bb-ws-ledger-row > :nth-child(4) {
117
+ .bb-pc-company-ledger .bb-ws-ledger-row > :nth-child(4),
118
+ .bb-pc-company-ledger .bb-ws-ledger-row > :nth-child(5) {
91
119
  display: none;
92
120
  }
93
121
  }
@@ -4,8 +4,23 @@
4
4
  *
5
5
  * Reads `GET {base}/people-and-companies` for the ledgers and
6
6
  * `GET {base}/entities/:id` for the selected record in the right rail (see
7
- * `graph-view-shared.js`). Read only: there is no correction here; the rail
8
- * points to 「情報と関係」, where the same record is corrected.
7
+ * `graph-view-shared.js`). Read only unless the host says it can write
8
+ * (`canCorrect: true`): then it creates people and companies
9
+ * (`create_entity`), adds a company membership (`create_edge` of
10
+ * `member_of`) and ends one with an end date (`update_edge`), all through
11
+ * `POST {base}/corrections`. Nothing is deleted. After a save the ledgers
12
+ * and the rail are read again from the host. Other corrections stay in
13
+ * 「情報と関係」, which the rail links to.
14
+ *
15
+ * The company text recorded on a person (`orgNote`) is shown only for
16
+ * people whose company is not yet known, and only as a note: it never
17
+ * decides a membership, the own company or internal / external.
18
+ *
19
+ * A company's kind (顧客・協業先・提案中) is what the host derives from the
20
+ * company attributes (`orgType`, `lifecycle_stage`), the roles of its
21
+ * `related_to` relations and its recorded deals; a company without such a
22
+ * record shows no kind. Its projects are split into its own (`owned_by`)
23
+ * and the ones it is involved in (`related_to`, with the role).
9
24
  *
10
25
  * Internal and external are not stored anywhere. They come from the
11
26
  * company memberships (`member_of`) and the own company the host answers.
@@ -19,7 +34,9 @@
19
34
 
20
35
  import {
21
36
  badge,
37
+ button,
22
38
  createGraphClient,
39
+ createGraphCorrection,
23
40
  createGraphLayout,
24
41
  getDocument,
25
42
  graphHostEmptyNotice,
@@ -30,6 +47,8 @@ import {
30
47
  isRecord,
31
48
  makeElement,
32
49
  provenanceSummary,
50
+ readOnlyGraphClient,
51
+ graphCorrectionScope,
33
52
  relationLabel,
34
53
  textOrNull,
35
54
  validityLabel,
@@ -48,7 +67,18 @@ import {
48
67
 
49
68
  export const PEOPLE_AND_COMPANIES_VIEW_CONTRACT_VERSION = 'brainbase.people-and-companies-view.v1';
50
69
  export const PEOPLE_LEDGER_COLUMNS = Object.freeze(['名前', '会社', '関わるプロジェクト', '有効期間']);
51
- export const COMPANY_LEDGER_COLUMNS = Object.freeze(['会社', '所属する人', 'プロジェクト', '有効期間']);
70
+ export const COMPANY_LEDGER_COLUMNS = Object.freeze(['会社', '種類', '所属する人', 'プロジェクト', '有効期間']);
71
+
72
+ /** Plain labels of the company kinds the host derives, and of where each came from. */
73
+ export const COMPANY_KIND_LABELS = Object.freeze({ customer: '顧客', partner: '協業先', prospect: '提案中' });
74
+ const COMPANY_KIND_SOURCE_LABELS = Object.freeze({
75
+ orgType: '会社の属性',
76
+ lifecycle_stage: '会社の段階',
77
+ related_to: 'プロジェクトとの関係の役割',
78
+ deals: '提案中の案件の記録',
79
+ });
80
+ /** Plain labels of the recorded related_to roles; another role shows as recorded. */
81
+ const RELATED_ROLE_LABELS = Object.freeze({ customer: '顧客', partner: '協業先' });
52
82
 
53
83
  const INVALID = Object.freeze({ state: 'invalid', reason: '応答の形式が不正です' });
54
84
 
@@ -62,6 +92,31 @@ function isIdList(value) {
62
92
  return Array.isArray(value) && value.every((item) => typeof item === 'string');
63
93
  }
64
94
 
95
+ const isTextOrNull = (value) => value === null || typeof value === 'string';
96
+
97
+ function isCompanySummary(company) {
98
+ return isEntityView(company) && isIdList(company.memberIds) && isIdList(company.projectIds)
99
+ && Array.isArray(company.relatedProjects) && company.relatedProjects.every((item) => isRecord(item) && typeof item.projectId === 'string' && isTextOrNull(item.role))
100
+ && isTextOrNull(company.orgType) && isTextOrNull(company.lifecycleStage)
101
+ && Array.isArray(company.deals) && company.deals.every((deal) => isRecord(deal) && isTextOrNull(deal.name) && isTextOrNull(deal.stage))
102
+ && Array.isArray(company.kinds) && company.kinds.every((item) => isRecord(item) && typeof item.kind === 'string' && isIdList(item.sources));
103
+ }
104
+
105
+ /** The company's kinds in plain Japanese (e.g. 顧客・協業先), or '' when none is recorded. */
106
+ export function companyKindLabel(company) {
107
+ return (company?.kinds ?? []).map((item) => ownKey(COMPANY_KIND_LABELS, item.kind) ?? item.kind).join('・');
108
+ }
109
+
110
+ /** A related_to role in plain Japanese; an unknown role shows as recorded. */
111
+ export function relatedRoleLabel(role) {
112
+ const text = textOrNull(role);
113
+ return text ? ownKey(RELATED_ROLE_LABELS, text.toLowerCase()) ?? text : null;
114
+ }
115
+
116
+ function ownKey(table, key) {
117
+ return typeof key === 'string' && Object.hasOwn(table, key) ? table[key] : undefined;
118
+ }
119
+
65
120
  /** The read is `ok` only when every person and company is well formed. */
66
121
  export function normalizePeopleAndCompanies(result) {
67
122
  if (result?.state !== 'ok') return result ?? INVALID;
@@ -69,9 +124,11 @@ export function normalizePeopleAndCompanies(result) {
69
124
  const own = payload.ownCompany;
70
125
  if (!isRecord(own) || !(own.id === null || typeof own.id === 'string') || typeof own.reason !== 'string' || !isIdList(own.candidateIds)) return INVALID;
71
126
  if (!Array.isArray(payload.people) || !payload.people.every((person) => isEntityView(person)
72
- && isIdList(person.companyIds) && Number.isInteger(person.projectCount) && Number.isInteger(person.accountableProjectCount))) return INVALID;
73
- if (!Array.isArray(payload.companies) || !payload.companies.every((company) => isEntityView(company)
74
- && isIdList(company.memberIds) && isIdList(company.projectIds))) return INVALID;
127
+ && isIdList(person.companyIds) && Number.isInteger(person.projectCount) && Number.isInteger(person.accountableProjectCount)
128
+ && (person.orgNote === undefined || isTextOrNull(person.orgNote)))) return INVALID;
129
+ if (!Array.isArray(payload.companies) || !payload.companies.every(isCompanySummary)) return INVALID;
130
+ if (payload.personProjects !== undefined && !(Array.isArray(payload.personProjects) && payload.personProjects.every((project) => isRecord(project)
131
+ && typeof project.code === 'string' && project.code.trim() && isTextOrNull(project.name)))) return INVALID;
75
132
  if (payload.people.length === 0 && payload.companies.length === 0 && payload.absenceConfirmed !== true) return { state: 'unknown' };
76
133
  return { state: 'ok', payload };
77
134
  }
@@ -116,6 +173,30 @@ export function personStanding(person, ownCompanyId) {
116
173
  return person.companyIds.length > 0 ? '社外' : '所属未確認';
117
174
  }
118
175
 
176
+ /** The company text recorded on a person whose company is not yet known, or null. */
177
+ export function personOrgNote(person, ownCompanyId) {
178
+ const note = textOrNull(person?.orgNote);
179
+ if (!note) return null;
180
+ const standing = personStanding(person, ownCompanyId);
181
+ return standing === '社内' || standing === '社外' ? null : note;
182
+ }
183
+
184
+ /**
185
+ * @param {object} options
186
+ * @param {boolean} [options.canCorrect=false] Whether the host lets this viewer write. Only then
187
+ * 人を加える, 会社を加える, 所属を付ける, 所属する人を加える and 所属を終える are drawn; otherwise the
188
+ * client refuses every correction before sending it. The host decides it (for example from the
189
+ * viewer's role); this part holds no role name.
190
+ * @param {{ entityTypes?: string[], edges?: boolean, createEdges?: boolean, createEntities?: boolean }} [options.correctionScope]
191
+ * With writes allowed, only these (see `graphCorrectionScope`): `createEntities: false` withholds
192
+ * 人を加える・会社を加える, `createEdges: false` withholds 所属を付ける・所属する人を加える, `edges: false`
193
+ * withholds 所属を終える, and `entityTypes` limits the kinds that may be created.
194
+ * A host that places people in projects lists the projects a new person may join as
195
+ * `personProjects` (`[{ code, name }]`) in its read; 人を加える then requires one and sends it as
196
+ * `entity.projectCode`. Without that list (the local host) no project is asked or sent.
197
+ * @param {string} [options.token] Launch token sent with a correction.
198
+ * @param {string} [options.tokenHeader] Header name for the token.
199
+ */
119
200
  export function createPeopleAndCompaniesView({
120
201
  root,
121
202
  rail,
@@ -123,16 +204,29 @@ export function createPeopleAndCompaniesView({
123
204
  document: explicitDocument,
124
205
  fetcher,
125
206
  basePath = '/api/graph',
207
+ token,
208
+ tokenHeader,
126
209
  autoLoad = true,
127
210
  initialEntityId,
128
211
  now = () => new Date(),
212
+ canCorrect = false,
213
+ correctionScope,
129
214
  sourceNotice,
130
215
  emptyNotice,
131
216
  correctionHref = (id) => `#graph?entity_id=${encodeURIComponent(id)}`,
132
217
  } = {}) {
133
218
  if (!root) throw new TypeError('root is required');
134
219
  const doc = getDocument(explicitDocument);
135
- const client = createGraphClient({ fetcher, basePath });
220
+ const graphClient = createGraphClient({ fetcher, basePath, token, ...(tokenHeader ? { tokenHeader } : {}) });
221
+ const writable = canCorrect === true;
222
+ const client = writable ? graphClient : readOnlyGraphClient(graphClient);
223
+ const scope = graphCorrectionScope(correctionScope);
224
+ const can = Object.freeze({
225
+ createPerson: writable && scope.createEntities && scope.entity('person'),
226
+ createCompany: writable && scope.createEntities && scope.entity('org'),
227
+ addMembership: writable && scope.createEdges,
228
+ endMembership: writable && scope.edges,
229
+ });
136
230
  const context = graphPageContext(page);
137
231
  const layout = createGraphLayout(doc, {
138
232
  root,
@@ -142,6 +236,21 @@ export function createPeopleAndCompaniesView({
142
236
  contractVersion: PEOPLE_AND_COMPANIES_VIEW_CONTRACT_VERSION,
143
237
  });
144
238
  const state = { list: { state: 'loading' }, detail: null };
239
+ const correction = createGraphCorrection({
240
+ client,
241
+ now,
242
+ scope: correctionScope,
243
+ rerender: () => controller.render(),
244
+ onSaved: async (payload, form) => {
245
+ // Read the saved state back from the host, never from the form.
246
+ const id = form.kind === 'create_entity' ? payload.record?.id : state.detail?.id;
247
+ await controller.load({ keepDetail: true });
248
+ if (id) await controller.openEntity(id, { keepCorrection: true });
249
+ },
250
+ onConflict: async () => {
251
+ if (state.detail) await controller.openEntity(state.detail.id, { keepCorrection: true });
252
+ },
253
+ });
145
254
 
146
255
  const nameOf = (id) => {
147
256
  if (state.list.state !== 'ok') return id;
@@ -161,7 +270,7 @@ export function createPeopleAndCompaniesView({
161
270
  onSelect: (id) => void controller.openEntity(id),
162
271
  cells: [
163
272
  { primary: person.isOwner ? `${person.name}(あなた)` : person.name, secondary: person.id },
164
- person.companyIds.length > 0 ? person.companyIds.map(nameOf).join('、') : '所属の記録なし',
273
+ person.companyIds.length > 0 ? person.companyIds.map(nameOf).join('、') : companyCell(person, ownId),
165
274
  person.accountableProjectCount > 0
166
275
  ? `${person.projectCount}件(うち責任 ${person.accountableProjectCount}件)`
167
276
  : `${person.projectCount}件`,
@@ -171,6 +280,39 @@ export function createPeopleAndCompaniesView({
171
280
  }));
172
281
  }
173
282
 
283
+ function orgNoteText(note) {
284
+ return `記録上のメモ: ${note}(所属ではありません)`;
285
+ }
286
+
287
+ function companyCell(person, ownId) {
288
+ const note = personOrgNote(person, ownId);
289
+ if (!note) return '所属の記録なし';
290
+ const cell = makeElement(doc, 'span', { className: 'bb-pc-company-cell' });
291
+ cell.append(
292
+ makeElement(doc, 'span', { text: '所属の記録なし' }),
293
+ makeElement(doc, 'small', { className: 'bb-pc-org-note', text: orgNoteText(note) }),
294
+ );
295
+ return cell;
296
+ }
297
+
298
+ function sectionActions(label, onClick) {
299
+ const actions = makeElement(doc, 'div', { className: 'bb-pc-actions' });
300
+ actions.append(button(doc, label, onClick));
301
+ return actions;
302
+ }
303
+
304
+ function openCreateEntity(type) {
305
+ // A host that places people in projects lists the ones a new person may join (`personProjects`).
306
+ const projects = type === 'person' && state.list.state === 'ok' ? state.list.payload.personProjects ?? [] : [];
307
+ correction.openCreateEntity({
308
+ types: [type],
309
+ typeNames: { person: '人', org: '会社' },
310
+ title: type === 'org' ? '会社を加える' : '人を加える',
311
+ subject: type === 'org' ? 'Graphに会社の記録を作ります。' : 'Graphに人の記録を作ります。',
312
+ projects,
313
+ });
314
+ }
315
+
174
316
  function peopleLedger(label, people, empty) {
175
317
  return workspaceLedger(doc, {
176
318
  className: 'bb-pc-people-ledger',
@@ -184,6 +326,7 @@ export function createPeopleAndCompaniesView({
184
326
  function peopleSection(payload) {
185
327
  const section = makeElement(doc, 'section', { className: 'bb-pc-people', attrs: { 'aria-label': '人' } });
186
328
  section.append(workspaceSectionTitle(doc, { title: '人', lead: '社内か社外かは、所属(会社への所属の関係)と自社から決めています。印は別に持っていません。' }));
329
+ if (can.createPerson) section.append(sectionActions('人を加える', () => openCreateEntity('person')));
187
330
  const groups = groupPeople(payload);
188
331
  if (!groups.ownCompanyId) {
189
332
  const reason = OWN_COMPANY_REASONS[payload.ownCompany.reason] ?? '自社を決められません。';
@@ -210,7 +353,11 @@ export function createPeopleAndCompaniesView({
210
353
 
211
354
  function companySection(payload) {
212
355
  const section = makeElement(doc, 'section', { className: 'bb-pc-companies', attrs: { 'aria-label': '会社' } });
213
- section.append(workspaceSectionTitle(doc, { title: '会社', lead: '自社を先頭に、Graphに登録された会社を並べています。' }));
356
+ section.append(workspaceSectionTitle(doc, {
357
+ title: '会社',
358
+ lead: '自社を先頭に、顧客・協業先など関わる会社を並べています。種類は、会社の属性、プロジェクトとの関係の役割、提案中の案件の記録から出しています。記録が無い会社には種類を出しません。',
359
+ }));
360
+ if (can.createCompany) section.append(sectionActions('会社を加える', () => openCreateEntity('org')));
214
361
  const active = payload.companies.filter((company) => company.active);
215
362
  section.append(workspaceLedger(doc, {
216
363
  className: 'bb-pc-company-ledger',
@@ -223,8 +370,11 @@ export function createPeopleAndCompaniesView({
223
370
  onSelect: (id) => void controller.openEntity(id),
224
371
  cells: [
225
372
  { primary: company.id === payload.ownCompany.id ? `${company.name}(自社)` : company.name, secondary: company.id },
373
+ companyKindLabel(company),
226
374
  `${company.memberIds.length}人`,
227
- `${company.projectIds.length}件`,
375
+ company.relatedProjects.length > 0
376
+ ? `${company.projectIds.length}件(関わる ${company.relatedProjects.length}件)`
377
+ : `${company.projectIds.length}件`,
228
378
  validityLabel(company, payload.asOf),
229
379
  ],
230
380
  })),
@@ -264,7 +414,7 @@ export function createPeopleAndCompaniesView({
264
414
  // -------------------------------------------------------------------------
265
415
  // Rail
266
416
 
267
- function edgeList(edges, empty) {
417
+ function edgeList(edges, empty, { roleLabel = textOrNull, actions } = {}) {
268
418
  if (edges.length === 0) return makeElement(doc, 'p', { className: 'bb-pc-empty', text: empty });
269
419
  const list = makeElement(doc, 'ul', { className: 'bb-pc-relation-list' });
270
420
  for (const edge of edges) {
@@ -276,16 +426,68 @@ export function createPeopleAndCompaniesView({
276
426
  head,
277
427
  makeElement(doc, 'span', { className: 'bb-pc-relation-meta', text: [
278
428
  validityLabel(edge, asOf()),
279
- textOrNull(edge.role) ? `役割: ${edge.role}` : null,
429
+ roleLabel(edge.role) ? `役割: ${roleLabel(edge.role)}` : null,
280
430
  `出典: ${source.kind}${source.reference ? `(${source.reference})` : ''}`,
281
431
  ].filter(Boolean).join(' ') }),
282
432
  );
433
+ const controls = actions?.(edge) ?? [];
434
+ if (controls.length > 0) {
435
+ const row = makeElement(doc, 'div', { className: 'bb-pc-relation-actions' });
436
+ row.append(...controls);
437
+ item.append(row);
438
+ }
439
+ list.append(item);
440
+ }
441
+ return list;
442
+ }
443
+
444
+ function dealList(deals) {
445
+ const list = makeElement(doc, 'ul', { className: 'bb-pc-relation-list' });
446
+ for (const deal of deals) {
447
+ const item = makeElement(doc, 'li', { className: 'bb-pc-relation' });
448
+ item.append(makeElement(doc, 'strong', { text: deal.name ?? '名前の記録が無い案件' }));
449
+ if (deal.stage) item.append(makeElement(doc, 'span', { className: 'bb-pc-relation-meta', text: `段階: ${deal.stage}` }));
283
450
  list.append(item);
284
451
  }
285
452
  return list;
286
453
  }
287
454
 
455
+ function membershipActions(edge, entity) {
456
+ if (!can.endMembership || !edge.active) return [];
457
+ const person = entity.type === 'org' ? edge.counterpart.name : entity.name;
458
+ const company = entity.type === 'org' ? entity.name : edge.counterpart.name;
459
+ return [button(doc, '所属を終える', () => correction.openEdge(edge, {
460
+ mode: 'end',
461
+ title: '所属を終える',
462
+ subject: `${person}の「${company}」への所属に終了日を入れます。記録は消さずに残ります。`,
463
+ }))];
464
+ }
465
+
466
+ function addMembershipButton(entity) {
467
+ if (!can.addMembership) return null;
468
+ const label = entity.type === 'org' ? '所属する人を加える' : '所属を付ける';
469
+ return sectionActions(label, () => correction.openCreate(
470
+ { id: entity.id, type: entity.type, name: entity.name },
471
+ {
472
+ relations: ['member_of'],
473
+ relationNames: { member_of: '所属' },
474
+ relationLabel: '関わり方',
475
+ title: label,
476
+ subject: entity.type === 'org' ? `「${entity.name}」に所属する人を選びます。` : `${entity.name}が所属する会社を選びます。`,
477
+ },
478
+ ));
479
+ }
480
+
288
481
  function railContent() {
482
+ const panel = correction.render(doc);
483
+ const blocks = railBlocks();
484
+ if (!panel) return blocks;
485
+ // A new record's form shows first; a membership form right after the record's head.
486
+ if (correction.form?.kind === 'create_entity' || !state.detail || state.detail.state !== 'ok') return [panel, ...blocks];
487
+ return [blocks[0], panel, ...blocks.slice(1)];
488
+ }
489
+
490
+ function railBlocks() {
289
491
  const detail = state.detail;
290
492
  if (!detail) {
291
493
  return [workspaceDetailEmpty(doc, { mark: '人', title: '人か会社を選んでください', text: '所属と関わるプロジェクトを、有効期間と出典つきで確かめられます。' })];
@@ -297,6 +499,9 @@ export function createPeopleAndCompaniesView({
297
499
  ? state.list.payload.people.find((person) => person.id === entity.id)
298
500
  : null;
299
501
  const ownId = state.list.state === 'ok' ? state.list.payload.ownCompany.id : null;
502
+ const company = state.list.state === 'ok' && entity.type === 'org'
503
+ ? state.list.payload.companies.find((item) => item.id === entity.id)
504
+ : null;
300
505
  const blocks = [workspaceRailHead(doc, {
301
506
  kicker: entity.type === 'org' ? '会社' : '人',
302
507
  title: entity.name,
@@ -305,14 +510,33 @@ export function createPeopleAndCompaniesView({
305
510
  })];
306
511
  blocks.push(workspaceDefinition(doc, [
307
512
  ...(entity.type === 'person' && listed ? [['区分', personStanding(listed, ownId)]] : []),
513
+ ...(entity.type === 'person' && listed && personOrgNote(listed, ownId)
514
+ ? [['記録上のメモ', `${personOrgNote(listed, ownId)}(人物の記録に文字で残っている会社名です。所属の記録ではありません)`]]
515
+ : []),
308
516
  ...(entity.type === 'org' && entity.id === ownId ? [['区分', '自社']] : []),
517
+ ...(company && company.kinds.length > 0 ? [['種類', company.kinds.map((item) => {
518
+ const sources = item.sources.map((source) => ownKey(COMPANY_KIND_SOURCE_LABELS, source) ?? source).join('・');
519
+ return `${ownKey(COMPANY_KIND_LABELS, item.kind) ?? item.kind}(${sources}から)`;
520
+ }).join('、')]] : []),
521
+ ...(company?.lifecycleStage ? [['記録上の段階', company.lifecycleStage]] : []),
309
522
  ['有効期間', validityLabel(entity, asOf())],
310
523
  ]));
311
524
  if (entity.type === 'org') {
312
- blocks.push(workspaceRailBlock(doc, { title: '所属する人', content: edgeList(incoming.filter((edge) => edge.relation === 'member_of'), '所属する人の記録はありません。') }));
313
- blocks.push(workspaceRailBlock(doc, { title: 'プロジェクト', content: edgeList(incoming.filter((edge) => edge.relation === 'owned_by'), 'この会社のプロジェクトの記録はありません。') }));
525
+ blocks.push(workspaceRailBlock(doc, { title: '所属する人', content: [
526
+ edgeList(incoming.filter((edge) => edge.relation === 'member_of'), '所属する人の記録はありません。', { actions: (edge) => membershipActions(edge, entity) }),
527
+ addMembershipButton(entity),
528
+ ] }));
529
+ blocks.push(workspaceRailBlock(doc, { title: 'この会社のプロジェクト', content: edgeList(incoming.filter((edge) => edge.relation === 'owned_by'), 'この会社のプロジェクトの記録はありません。') }));
530
+ blocks.push(workspaceRailBlock(doc, {
531
+ title: '顧客・協業先として関わるプロジェクト',
532
+ content: edgeList(outgoing.filter((edge) => edge.relation === 'related_to'), '顧客・協業先として関わるプロジェクトの記録はありません。', { roleLabel: relatedRoleLabel }),
533
+ }));
534
+ if (company && company.deals.length > 0) blocks.push(workspaceRailBlock(doc, { title: '提案中の案件', content: dealList(company.deals) }));
314
535
  } else {
315
- blocks.push(workspaceRailBlock(doc, { title: '所属', content: edgeList(outgoing.filter((edge) => edge.relation === 'member_of'), '会社への所属の記録はありません。') }));
536
+ blocks.push(workspaceRailBlock(doc, { title: '所属', content: [
537
+ edgeList(outgoing.filter((edge) => edge.relation === 'member_of'), '会社への所属の記録はありません。', { actions: (edge) => membershipActions(edge, entity) }),
538
+ addMembershipButton(entity),
539
+ ] }));
316
540
  blocks.push(workspaceRailBlock(doc, {
317
541
  title: '関わるプロジェクト',
318
542
  content: edgeList(outgoing.filter((edge) => edge.relation === 'participates_in' || edge.relation === 'accountable_for'), '関わるプロジェクトの記録はありません。'),
@@ -320,7 +544,9 @@ export function createPeopleAndCompaniesView({
320
544
  }
321
545
  const fix = makeElement(doc, 'p', { className: 'bb-pc-fix' });
322
546
  fix.append(
323
- makeElement(doc, 'span', { text: '間違いは「情報と関係」で、この記録を開いて直します。' }),
547
+ makeElement(doc, 'span', { text: writable
548
+ ? '名前・別名・要約などの間違いは「情報と関係」で、この記録を開いて直します。'
549
+ : '間違いは「情報と関係」で、この記録を開いて直します。' }),
324
550
  makeElement(doc, 'a', { text: '情報と関係で開く', attrs: { href: correctionHref(entity.id) } }),
325
551
  );
326
552
  blocks.push(fix);
@@ -334,17 +560,25 @@ export function createPeopleAndCompaniesView({
334
560
  get state() {
335
561
  return state;
336
562
  },
563
+ get correction() {
564
+ return correction;
565
+ },
337
566
  render() {
338
567
  layout.render(workspace(), railContent());
339
568
  },
340
- async load() {
341
- state.list = { state: 'loading' };
342
- controller.render();
569
+ /** Reads the ledgers. `keepDetail` keeps the open record (and the shown ledgers) while reading. */
570
+ async load({ keepDetail = false } = {}) {
571
+ if (!keepDetail || state.list.state !== 'ok') {
572
+ state.list = { state: 'loading' };
573
+ controller.render();
574
+ }
343
575
  state.list = normalizePeopleAndCompanies(await client.read('/people-and-companies'));
344
576
  controller.render();
345
577
  if (initialEntityId && !state.detail) await controller.openEntity(initialEntityId);
346
578
  },
347
- async openEntity(id) {
579
+ /** Opens a record in the rail. Choosing another record closes an open form unless `keepCorrection`. */
580
+ async openEntity(id, { keepCorrection = false } = {}) {
581
+ if (!keepCorrection && correction.form && state.detail?.id !== id) correction.close();
348
582
  state.detail = { id, state: 'loading' };
349
583
  controller.render();
350
584
  const result = await client.read(`/entities/${encodeURIComponent(id)}`);